diff --git a/CHANGELOG.md b/CHANGELOG.md index f797354ecb..580714a11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: when duplicate nodes merge, the richer (more complete) node is now kept as the survivor and the losers' non-empty fields are folded in, instead of a shorter-id passing mention winning and dropping content (#3372, thanks @abhay-codes07). - Fix: a C# generic call site with explicit type arguments — `Get(...)`, unqualified or through `this` — now resolves to the method definition instead of capturing `Get` as the callee and failing to match (#3406, thanks @abhay-codes07). - Fix: `this.X = function` / `this.X = () => …` members are now captured in every enclosing-function form (function expressions, arrows, IIFEs, callbacks), not just function declarations (#3408, thanks @abhay-codes07). +- Fix: Jinja templates (`.j2`, `.jinja`, `.jinja2`) are now classified as documents instead of being silently unclassified — a codegen repo's graph described only the generated files marked `DO NOT EDIT` and none of the templates they come from; oversized templates are sliced like every other text document (#3427, thanks @AmberProof). ## 0.9.56 (2026-09-07) diff --git a/graphify/detect.py b/graphify/detect.py index 1adad00bbc..435a360187 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -42,7 +42,16 @@ class FileType(str, Enum): _MTIME_SUBSECOND_S = 0.05 CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource'} -DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} +# Jinja templates are documents, not code: in a codegen repo the template is the +# only file a developer is allowed to edit (the generated module says DO NOT EDIT +# and is overwritten every build), so leaving `.j2` unclassified made the graph +# describe exactly the half that must not be touched (#3427). They are not routed +# to CODE because a template is not valid source in its target language — +# `{% for %}` in a .py.j2 is a parse error, not an AST — and the semantic pass +# reads the prose, generator header and structure that carry the real link. +# Note the outer suffix is what classifies: `module.py.j2` -> `.j2`. +DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml', + '.j2', '.jinja', '.jinja2'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} OFFICE_EXTENSIONS = {'.docx', '.xlsx'} diff --git a/graphify/file_slice.py b/graphify/file_slice.py index c43d4a1e63..28b57a8293 100644 --- a/graphify/file_slice.py +++ b/graphify/file_slice.py @@ -38,6 +38,7 @@ _SPLITTABLE_TEXT_SUFFIXES = frozenset({ ".md", ".mdx", ".markdown", ".txt", ".rst", ".qmd", ".skill", ".html", ".yaml", ".yml", + ".j2", ".jinja", ".jinja2", }) # Document types whose BYTES are not what the model is shown. `llm._file_to_text` diff --git a/tests/test_detect.py b/tests/test_detect.py index 1bf6b056bc..247c2f859c 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -49,6 +49,16 @@ def test_classify_skill(): # #1901: .skill agent files (Markdown with YAML frontmatter) were dropped as unclassified. assert classify_file(Path("10_Orchestrator.skill")) == FileType.DOCUMENT +def test_classify_jinja_template(): + # #3427: .j2/.jinja/.jinja2 were unclassified, so a codegen repo's graph + # described only the generated files that must NOT be edited. + assert classify_file(Path("templates/module.py.j2")) == FileType.DOCUMENT + assert classify_file(Path("templates/base.jinja")) == FileType.DOCUMENT + assert classify_file(Path("templates/base.jinja2")) == FileType.DOCUMENT + # The outer suffix classifies, whatever the target language underneath. + for inner in ("py", "ts", "yaml", "conf", "sql", "tf"): + assert classify_file(Path(f"t/app.{inner}.j2")) == FileType.DOCUMENT + def test_classify_pdf(): assert classify_file(Path("paper.pdf")) == FileType.PAPER @@ -97,6 +107,27 @@ def test_detect_skips_noise_dot_dirs(): assert noise not in f +def test_detect_finds_jinja_templates_beside_generated_code(tmp_path): + """The reported shape: a codegen repo where the generated module is graphed + and the template it comes from is invisible (#3427).""" + (tmp_path / "templates").mkdir() + (tmp_path / "templates" / "module.py.j2").write_text( + "# generated from module.py.j2 — DO NOT EDIT MANUALLY\n" + "class {{ name }}:\n pass\n" + ) + (tmp_path / "templates" / "config.yaml.jinja2").write_text("name: {{ name }}\n") + (tmp_path / "module.py").write_text("class Generated:\n pass\n") + + result = detect(tmp_path) + + assert str(tmp_path / "module.py") in result["files"]["code"] + assert set(result["files"]["document"]) == { + str(tmp_path / "templates" / "module.py.j2"), + str(tmp_path / "templates" / "config.yaml.jinja2"), + } + assert result["unclassified"] == [] + + def test_detect_skips_obsidian_vault_metadata_dirs(tmp_path): """Obsidian metadata and plugin caches are not part of the source corpus (#2493).""" for directory in (".obsidian", ".smart-env"): diff --git a/tests/test_oversized_document_slicing.py b/tests/test_oversized_document_slicing.py index e8ad54a6f9..e2baa0cbb8 100644 --- a/tests/test_oversized_document_slicing.py +++ b/tests/test_oversized_document_slicing.py @@ -104,7 +104,7 @@ def test_slices_stay_within_the_cap(tmp_path): # End to end through the prompt builder # --------------------------------------------------------------------------- -@pytest.mark.parametrize("ext", [".qmd", ".html", ".yaml", ".yml", ".skill"]) +@pytest.mark.parametrize("ext", [".qmd", ".html", ".yaml", ".yml", ".skill", ".j2"]) def test_the_tail_of_a_big_document_reaches_the_prompt(tmp_path, ext): """The symptom a user would notice: content past 20k was invisible to the semantic pass, so nothing in the tail could ever become a node."""