diff --git a/graphify/detect.py b/graphify/detect.py index d16b5800c..81c743155 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -43,7 +43,7 @@ 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'} -DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} +DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml', '.typ'} 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 30dc49cfb..7af25a8c7 100644 --- a/graphify/file_slice.py +++ b/graphify/file_slice.py @@ -26,7 +26,10 @@ # `_file_to_text` is a straight ``read_text`` (so a char range matches the bytes # the model is shown). Deliberately excludes code (.py, .ts, ...) and binary # docs (.pdf) — those are never sliced. -_SPLITTABLE_TEXT_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".txt", ".rst"}) +# .typ (Typst) qualifies on both counts (#2826): it is prose source read straight +# through `read_text`, and although its headings are `=`/`==` rather than `#`, +# the blank-line and newline boundaries below still cut it at paragraph edges. +_SPLITTABLE_TEXT_SUFFIXES = frozenset({".md", ".mdx", ".markdown", ".txt", ".rst", ".typ"}) # Boundary preferences, strongest first. A Markdown heading (``\n#``) keeps a # section with its title; a blank line keeps a paragraph intact; a bare newline diff --git a/tests/test_detect.py b/tests/test_detect.py index 94642424b..5dc98ee67 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -49,6 +49,32 @@ 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_typst(): + # #2826: Typst is a prose authoring format, but .typ was in no extension set, + # so a repo whose report/spec chapters are written in Typst lost all of them. + assert classify_file(Path("main.typ")) == FileType.DOCUMENT + +def test_typst_is_watched(): + # watch derives _WATCHED_EXTENSIONS from DOC_EXTENSIONS, so a new document + # type must also trigger an incremental rebuild when it is edited (#2826). + from graphify.watch import _WATCHED_EXTENSIONS + assert ".typ" in _WATCHED_EXTENSIONS + +def test_detect_surfaces_typst_chapters_as_documents(tmp_path): + """The reported symptom (#2826): a Typst-authored report reaches the corpus. + + Classification alone is not the promise — the file has to come back out of + ``detect()`` in the document set, which is what was empty for the reporter. + """ + (tmp_path / "main.typ").write_text( + "= Test Report\n\nProse documentation authored in Typst.\n", encoding="utf-8") + (tmp_path / "chapter.typ").write_text( + "== Measurements\n\nMore prose.\n", encoding="utf-8") + + docs = detect(tmp_path)["files"]["document"] + + assert sorted(Path(d).name for d in docs) == ["chapter.typ", "main.typ"] + def test_classify_pdf(): assert classify_file(Path("paper.pdf")) == FileType.PAPER diff --git a/tests/test_file_slice.py b/tests/test_file_slice.py index 619ffc1f4..e69692dfa 100644 --- a/tests/test_file_slice.py +++ b/tests/test_file_slice.py @@ -91,6 +91,24 @@ def test_expand_does_not_slice_code_even_when_oversized(tmp_path): assert units == [f] # stays whole — code needs whole-symbol context +def test_expand_oversized_typst_is_sliced_with_full_coverage(tmp_path): + """#2826: Typst chapters are prose, so an oversized one must slice, not truncate. + + Typst marks headings with `=` rather than `#`, so the heading separator never + fires — the blank-line boundary carries it, which is the same fallback a .txt + document relies on. + """ + text = ("= Section\n\n" + "word " * 200 + "\n\n") * 30 + f = _write(tmp_path / "report.typ", text) + assert is_splittable_text(f) + units = expand_oversized_files([f], max_chars=2000) + slices = [u for u in units if isinstance(u, FileSlice)] + assert len(slices) >= 2 + assert "".join(read_slice_text(s) for s in slices) == text + assert all((s.end - s.start) <= 2000 for s in slices) + assert all(s.path == f for s in slices) + + def test_expand_unreadable_file_passes_through(tmp_path): missing = tmp_path / "nope.md" units = expand_oversized_files([missing], max_chars=10)