import os import unicodedata import pytest from pathlib import Path from graphify.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore, _is_sensitive from graphify import detect as detect_mod FIXTURES = Path(__file__).parent / "fixtures" def as_posix_list(paths) -> list[str]: """Normalize detect() output to forward slashes before matching on it. detect() returns native absolute paths, so a literal like ``"vendor/sub/important.py"`` never matches on Windows. That breaks positive assertions outright, and — worse — makes NEGATIVE ones (``not any(... in ...)``) pass unconditionally, so the property the test exists to guard is never actually checked. """ return [Path(p).as_posix() for p in paths] def test_classify_python(): assert classify_file(Path("foo.py")) == FileType.CODE def test_classify_typescript(): assert classify_file(Path("bar.ts")) == FileType.CODE def test_classify_powershell_module(): # #1315: .psm1 modules were never indexed (CODE_EXTENSIONS gap). assert classify_file(Path("Utils.psm1")) == FileType.CODE def test_classify_powershell_manifest(): # #1331: .psd1 manifests must be classified as CODE so the manifest extractor runs. assert classify_file(Path("MyModule.psd1")) == FileType.CODE def test_classify_markdown(): assert classify_file(Path("README.md")) == FileType.DOCUMENT 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_pdf(): assert classify_file(Path("paper.pdf")) == FileType.PAPER def test_classify_pdf_in_xcassets_skipped(): # PDFs inside Xcode asset catalogs are vector icons, not papers asset_pdf = Path("MyApp/Images.xcassets/icon.imageset/icon.pdf") assert classify_file(asset_pdf) is None def test_classify_pdf_in_xcassets_root_skipped(): asset_pdf = Path("Pods/HXPHPicker/Assets.xcassets/photo.pdf") assert classify_file(asset_pdf) is None def test_classify_unknown_returns_none(): assert classify_file(Path("archive.zip")) is None def test_classify_image(): assert classify_file(Path("screenshot.png")) == FileType.IMAGE assert classify_file(Path("design.jpg")) == FileType.IMAGE assert classify_file(Path("diagram.webp")) == FileType.IMAGE def test_count_words_sample_md(): words = count_words(FIXTURES / "sample.md") assert words > 5 def test_detect_finds_fixtures(): result = detect(FIXTURES) assert result["total_files"] >= 2 assert "code" in result["files"] assert "document" in result["files"] def test_detect_warns_small_corpus(): result = detect(FIXTURES) assert result["needs_graph"] is False assert result["warning"] is not None def test_detect_skips_noise_dot_dirs(): """Noise dot dirs (.next, .nuxt, .graphify cache, …) are skipped (#873). Non-noise dot dirs (.github, .claude, …) are now allowed through.""" result = detect(FIXTURES) for files in result["files"].values(): for f in files: # graphify's own cache is always skipped assert "/.graphify/" not in f # well-known framework caches are always skipped for noise in ("/.next/", "/.nuxt/", "/.turbo/", "/.angular/"): assert noise not in f 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"): metadata_dir = tmp_path / directory metadata_dir.mkdir() (metadata_dir / "state.json").write_text("{}") trash_dir = tmp_path / ".trash" trash_dir.mkdir() (trash_dir / "state.json").write_text("{}") (tmp_path / "project.json").write_text("{}") result = detect(tmp_path) assert result["files"]["code"] == [ str(trash_dir / "state.json"), str(tmp_path / "project.json"), ] def test_classify_md_paper_by_signals(tmp_path): """A .md file with enough paper signals should classify as PAPER.""" paper = tmp_path / "paper.md" paper.write_text( "# Abstract\n\nWe propose a new method. See [1] and [23].\n" "This work was published in the Journal of AI. ArXiv preprint.\n" "See Equation 3 for details. \\cite{vaswani2017}.\n" ) assert classify_file(paper) == FileType.PAPER def test_classify_md_doc_without_signals(tmp_path): """A plain .md file without paper signals should stay DOCUMENT.""" doc = tmp_path / "notes.md" doc.write_text("# My Notes\n\nHere are some notes about the project.\n") assert classify_file(doc) == FileType.DOCUMENT def test_classify_attention_paper(): """The real attention paper file should be classified as PAPER.""" paper_path = Path("/home/safi/graphify_eval/papers/attention_is_all_you_need.md") if paper_path.exists(): result = classify_file(paper_path) assert result == FileType.PAPER def test_graphifyignore_excludes_file(tmp_path): """Files matching .graphifyignore patterns are excluded from detect().""" (tmp_path / ".graphifyignore").write_text("vendor/\n*.generated.py\n") vendor = tmp_path / "vendor" vendor.mkdir() (vendor / "lib.py").write_text("x = 1") (tmp_path / "main.py").write_text("print('hi')") (tmp_path / "schema.generated.py").write_text("x = 1") result = detect(tmp_path) file_list = result["files"]["code"] assert any("main.py" in f for f in file_list) assert not any("vendor" in f for f in file_list) assert not any("generated" in f for f in file_list) assert result["graphifyignore_patterns"] == 2 def test_graphifyignore_matches_nfd_path_with_nfc_pattern(tmp_path): """An accented pattern excludes its directory even when the FS stores NFD. macOS returns filenames in NFD ("c" + U+0327) while editors write ignore files in NFC (U+00E7). Without normalization the two compare unequal and the rule silently does nothing — the files get scanned, and docs/PDFs are sent to an LLM despite an explicit exclusion. """ nfc_name = unicodedata.normalize("NFC", "Or\u00e7amento") nfd_name = unicodedata.normalize("NFD", nfc_name) assert nfc_name != nfd_name # guard: the two forms really do differ (tmp_path / ".graphifyignore").write_text(f"{nfc_name}/\n") secret_dir = tmp_path / nfd_name secret_dir.mkdir() (secret_dir / "contrato.py").write_text("x = 1") (tmp_path / "main.py").write_text("print('hi')") result = detect(tmp_path) file_list = result["files"]["code"] assert any("main.py" in f for f in file_list) assert not any("contrato.py" in f for f in file_list) def test_graphifyignore_matches_nfc_path_with_nfd_pattern(tmp_path): """The reverse direction also holds: NFD pattern, NFC path on disk.""" nfc_name = unicodedata.normalize("NFC", "Or\u00e7amento") nfd_name = unicodedata.normalize("NFD", nfc_name) (tmp_path / ".graphifyignore").write_text(f"{nfd_name}/\n") d = tmp_path / nfc_name d.mkdir() (d / "contrato.py").write_text("x = 1") (tmp_path / "main.py").write_text("print('hi')") result = detect(tmp_path) file_list = result["files"]["code"] assert any("main.py" in f for f in file_list) assert not any("contrato.py" in f for f in file_list) def test_graphifyignore_ascii_patterns_unaffected(tmp_path): """Normalization is a no-op for ASCII patterns — no regression.""" (tmp_path / ".graphifyignore").write_text("vendor/\n") v = tmp_path / "vendor" v.mkdir() (v / "lib.py").write_text("x = 1") (tmp_path / "main.py").write_text("x = 1") result = detect(tmp_path) file_list = result["files"]["code"] assert any("main.py" in f for f in file_list) assert not any("vendor" in f for f in file_list) def test_graphifyignore_missing_is_fine(tmp_path): """No .graphifyignore is not an error.""" (tmp_path / "main.py").write_text("x = 1") result = detect(tmp_path) assert result["graphifyignore_patterns"] == 0 def test_graphifyignore_comments_ignored(tmp_path): """Comment lines in .graphifyignore are not treated as patterns.""" (tmp_path / ".graphifyignore").write_text("# this is a comment\n\nmain.py\n") (tmp_path / "main.py").write_text("x = 1") (tmp_path / "other.py").write_text("x = 2") result = detect(tmp_path) assert not any("main.py" in f for f in result["files"]["code"]) assert any("other.py" in f for f in result["files"]["code"]) def test_graphifyignore_utf8_bom_first_pattern_honored(tmp_path): """A UTF-8 BOM at the start of .graphifyignore must not corrupt the first pattern (#2163): git strips a single leading BOM, so `*.log` on line 1 must still exclude app.log.""" (tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf*.log\nbuild/\n") build = tmp_path / "build" build.mkdir() (build / "lib.py").write_text("x = 1") (tmp_path / "app.log").write_text("log line") (tmp_path / "main.py").write_text("print('hi')") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("app.log" in f for f in all_files), "BOM'd first pattern was dropped" assert not any("build" in f for f in all_files) assert any("main.py" in f for f in all_files) assert result["graphifyignore_patterns"] == 2 def test_gitignore_utf8_bom_matches_git(tmp_path): """A BOM'd .gitignore first pattern must match, exactly like git (#2163).""" (tmp_path / ".gitignore").write_bytes(b"\xef\xbb\xbf*.log\n") (tmp_path / "app.log").write_text("log line") (tmp_path / "main.py").write_text("print('hi')") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("app.log" in f for f in all_files) assert any("main.py" in f for f in all_files) def test_graphifyignore_bom_only_file(tmp_path): """A .graphifyignore containing only a BOM yields zero patterns, not one bogus U+FEFF pattern (#2163).""" (tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf") (tmp_path / "main.py").write_text("x = 1") result = detect(tmp_path) assert result["graphifyignore_patterns"] == 0 assert any("main.py" in f for f in result["files"]["code"]) def test_graphifyignore_bom_then_comment(tmp_path): """A BOM followed by a comment must still parse as a comment, not become a `\\ufeff# comment` pattern (#2163).""" (tmp_path / ".graphifyignore").write_bytes(b"\xef\xbb\xbf# comment\nmain.py\n") (tmp_path / "main.py").write_text("x = 1") (tmp_path / "other.py").write_text("x = 2") result = detect(tmp_path) assert not any("main.py" in f for f in result["files"]["code"]) assert any("other.py" in f for f in result["files"]["code"]) assert result["graphifyignore_patterns"] == 1, "BOM'd comment became a pattern" def test_nested_gitignore_utf8_bom(tmp_path): """A BOM'd .gitignore below the scan root (loaded live during the walk, #1206 path) must also have its first pattern honored (#2163).""" sub = tmp_path / "sub" sub.mkdir() (sub / ".gitignore").write_bytes(b"\xef\xbb\xbf*.log\n") (sub / "app.log").write_text("log line") (sub / "keep.py").write_text("x = 1") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("app.log" in f for f in all_files) assert any("keep.py" in f for f in all_files) def test_git_info_exclude_utf8_bom(tmp_path): """A BOM at the start of $GIT_DIR/info/exclude must not corrupt the first pattern either (#2163) — second read site in _load_graphifyignore.""" (tmp_path / ".git" / "info").mkdir(parents=True) (tmp_path / ".git" / "info" / "exclude").write_bytes(b"\xef\xbb\xbfsecrets/\n") secrets = tmp_path / "secrets" secrets.mkdir() (secrets / "x.py").write_text("token = 'x'") (tmp_path / "real.py").write_text("def real(): pass") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("secrets" in f for f in all_files), "BOM'd info/exclude pattern was dropped" assert any("real.py" in f for f in all_files) def test_detect_follows_symlinked_directory(requires_symlinks, tmp_path): real_dir = tmp_path / "real_lib" real_dir.mkdir() (real_dir / "util.py").write_text("x = 1") (tmp_path / "linked_lib").symlink_to(real_dir) result_no = detect(tmp_path, follow_symlinks=False) result_yes = detect(tmp_path, follow_symlinks=True) assert any("real_lib" in f for f in result_no["files"]["code"]) assert not any("linked_lib" in f for f in result_no["files"]["code"]) assert any("linked_lib" in f for f in result_yes["files"]["code"]) def test_detect_follows_symlinked_file(requires_symlinks, tmp_path): (tmp_path / "real.py").write_text("x = 1") (tmp_path / "link.py").symlink_to(tmp_path / "real.py") result = detect(tmp_path, follow_symlinks=True) code = result["files"]["code"] assert any("real.py" in f for f in code) assert any("link.py" in f for f in code) def test_graphifyignore_hermetic_without_vcs(tmp_path): """Without a VCS root, parent .graphifyignore does NOT apply (hermetic).""" (tmp_path / ".graphifyignore").write_text("vendor/\n") sub = tmp_path / "packages" / "mylib" sub.mkdir(parents=True) (sub / "main.py").write_text("x = 1") vendor = sub / "vendor" vendor.mkdir() (vendor / "dep.py").write_text("y = 2") result = detect(sub) code_files = result["files"]["code"] assert any("main.py" in f for f in code_files) # parent .graphifyignore must NOT leak into a non-VCS scan assert any("vendor" in f for f in code_files) assert result["graphifyignore_patterns"] == 0 def test_graphifyignore_discovered_from_parent_in_vcs(tmp_path): """Inside a VCS repo, parent .graphifyignore applies to subdirectory scans.""" (tmp_path / ".git").mkdir() (tmp_path / ".graphifyignore").write_text("vendor/\n") sub = tmp_path / "packages" / "mylib" sub.mkdir(parents=True) (sub / "main.py").write_text("x = 1") vendor = sub / "vendor" vendor.mkdir() (vendor / "dep.py").write_text("y = 2") result = detect(sub) code_files = result["files"]["code"] assert any("main.py" in f for f in code_files) assert not any("vendor" in f for f in code_files) assert result["graphifyignore_patterns"] >= 1 def test_graphifyignore_stops_at_git_boundary(tmp_path): """Upward search stops at the git repo root (.git directory).""" (tmp_path / ".graphifyignore").write_text("main.py\n") repo = tmp_path / "repo" repo.mkdir() (repo / ".git").mkdir() sub = repo / "sub" sub.mkdir() (sub / "main.py").write_text("x = 1") result = detect(sub) code_files = result["files"]["code"] assert any("main.py" in f for f in code_files) assert result["graphifyignore_patterns"] == 0 def test_graphifyignore_at_git_root_is_included(tmp_path): """A .graphifyignore at the git repo root is included when scanning a subdir.""" repo = tmp_path / "repo" repo.mkdir() (repo / ".git").mkdir() (repo / ".graphifyignore").write_text("vendor/\n") sub = repo / "packages" / "mylib" sub.mkdir(parents=True) (sub / "main.py").write_text("x = 1") vendor = sub / "vendor" vendor.mkdir() (vendor / "dep.py").write_text("y = 2") result = detect(sub) code_files = result["files"]["code"] assert any("main.py" in f for f in code_files) assert not any("vendor" in f for f in code_files) assert result["graphifyignore_patterns"] == 1 def test_gitignore_nested_below_root_excludes_file(tmp_path): """A .gitignore in a subdirectory below the scan root is honored too (#1206). Previously only the scan root and its ancestors were read, so a .gitignore sitting inside e.g. vendor/sub/ was silently skipped. """ (tmp_path / ".gitignore").write_text("*.log\n") sub = tmp_path / "vendor" / "sub" sub.mkdir(parents=True) (sub / ".gitignore").write_text("secret.txt\n") (tmp_path / "root.py").write_text("x = 1") (tmp_path / "root.log").write_text("noise") (sub / "keep.py").write_text("y = 2") (sub / "secret.txt").write_text("shh") result = detect(tmp_path) code_files = result["files"]["code"] assert any("root.py" in f for f in code_files) assert any("keep.py" in f for f in code_files) assert not any("root.log" in f for f in code_files) assert not any("secret.txt" in f for f in code_files) assert result["graphifyignore_patterns"] == 2 def test_gitignore_nested_below_root_prunes_whole_directory(tmp_path): """A nested .gitignore excluding a directory prevents descending into it.""" sub = tmp_path / "vendor" / "sub" sub.mkdir(parents=True) (sub / ".gitignore").write_text("build/\n") build = sub / "build" build.mkdir() (build / "generated.py").write_text("x = 1") (sub / "keep.py").write_text("y = 2") result = detect(tmp_path) code_files = result["files"]["code"] assert any("keep.py" in f for f in code_files) assert not any("generated.py" in f for f in code_files) def test_gitignore_nested_negation_overrides_broader_root_rule(tmp_path): """A closer (nested) .gitignore's `!` re-include wins over a root exclude, matching git's closer-file-wins precedence. Uses .py so classification lands in the deterministic `code` bucket.""" (tmp_path / ".gitignore").write_text("*.py\n") sub = tmp_path / "vendor" / "sub" sub.mkdir(parents=True) (sub / ".gitignore").write_text("!important.py\n") (tmp_path / "root.py").write_text("a = 1") (sub / "important.py").write_text("b = 1") (sub / "other.py").write_text("c = 1") result = detect(tmp_path) code = as_posix_list(result["files"]["code"]) # nested `!important.py` re-includes it despite the root `*.py` exclude... assert any(f.endswith("vendor/sub/important.py") for f in code) # ...while the root-excluded and non-re-included files stay out assert not any(f.endswith("root.py") for f in code) assert not any(f.endswith("other.py") for f in code) def test_nested_ignore_overrides_git_info_exclude_and_root(tmp_path): """Precedence across all three sources: a nested `.gitignore` `!` re-include outranks both a root `.gitignore` and `.git/info/exclude` (lowest, from #1810), while an info/exclude-only file with no re-include stays out.""" (tmp_path / ".git" / "info").mkdir(parents=True) (tmp_path / ".git" / "info" / "exclude").write_text("*.py\n") (tmp_path / ".gitignore").write_text("keep.py\n") # root also excludes it sub = tmp_path / "a" / "b" sub.mkdir(parents=True) (sub / ".gitignore").write_text("!keep.py\n") # nearest wins -> re-included (sub / "keep.py").write_text("x = 1") (tmp_path / "drop.py").write_text("y = 1") # only info/exclude -> excluded result = detect(tmp_path) code = as_posix_list(result["files"]["code"]) assert any(f.endswith("a/b/keep.py") for f in code), "nested ! must beat root + info/exclude" assert not any(f.endswith("drop.py") for f in code) def test_detect_handles_circular_symlinks(requires_symlinks, tmp_path): sub = tmp_path / "a" sub.mkdir() (sub / "main.py").write_text("x = 1") (sub / "loop").symlink_to(tmp_path) result = detect(tmp_path, follow_symlinks=True) assert any("main.py" in f for f in result["files"]["code"]) def test_detect_default_does_not_auto_follow_direct_symlink_child(requires_symlinks, tmp_path): """Symlink directory following is explicit opt-in.""" real_dir = tmp_path / "real_lib" real_dir.mkdir() (real_dir / "util.py").write_text("x = 1") (tmp_path / "linked_lib").symlink_to(real_dir) result = detect(tmp_path) assert any("real_lib" in f for f in result["files"]["code"]) assert not any("linked_lib" in f for f in result["files"]["code"]) def test_detect_default_does_not_follow_when_no_symlinks(tmp_path): """Ordinary scans still walk normal directories by default.""" (tmp_path / "main.py").write_text("x = 1") sub = tmp_path / "sub" sub.mkdir() (sub / "other.py").write_text("y = 2") result = detect(tmp_path) assert any("main.py" in f for f in result["files"]["code"]) assert any("other.py" in f for f in result["files"]["code"]) def test_detect_explicit_false_overrides_auto_detect(requires_symlinks, tmp_path): """An explicit follow_symlinks=False skips symlinked directories.""" real_dir = tmp_path / "real_lib" real_dir.mkdir() (real_dir / "util.py").write_text("x = 1") (tmp_path / "linked_lib").symlink_to(real_dir) # Explicit False overrides auto-detect; symlink contents must NOT appear. result = detect(tmp_path, follow_symlinks=False) assert not any("linked_lib" in f for f in result["files"]["code"]) def test_detect_skips_out_of_root_symlinked_directory_even_when_following(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.py").write_text("token = 'outside'") (root / "linked_secret").symlink_to(outside) result = detect(root, follow_symlinks=True) assert not any("linked_secret" in f for f in result["files"]["code"]) assert any("symlink target outside scan root" in item for item in result["skipped_sensitive"]) def test_detect_skips_out_of_root_symlinked_file_by_default(requires_symlinks, tmp_path): root = tmp_path / "root" root.mkdir() outside = tmp_path / "outside" outside.mkdir() (outside / "secret.py").write_text("token = 'outside'") (root / "secret_link.py").symlink_to(outside / "secret.py") result = detect(root) assert not any("secret_link.py" in f for f in result["files"]["code"]) assert any("symlink target outside scan root" in item for item in result["skipped_sensitive"]) def test_detect_incremental_propagates_follow_symlinks(requires_symlinks, tmp_path, monkeypatch): """detect_incremental must forward follow_symlinks so symlinked sub-trees appear in incremental scans the same way they appear in full scans.""" monkeypatch.chdir(tmp_path) real_dir = tmp_path / "real_corpus" real_dir.mkdir() (real_dir / "note.md").write_text("# real note\n\nsome content") (tmp_path / "linked_corpus").symlink_to(real_dir) # Store manifest inside graphify-out/ so it is pruned by _SKIP_DIRS # and doesn't get re-detected as a code file now that .json is indexed. manifest_dir = tmp_path / "graphify-out" manifest_dir.mkdir() manifest_path = str(manifest_dir / "manifest.json") # Without following symlinks, the symlinked dir contents are invisible. no_link = detect_incremental(tmp_path, manifest_path, follow_symlinks=False) assert not any("linked_corpus" in f for f in no_link["files"]["document"]) # With follow_symlinks=True, the symlinked dir contents appear and are new. yes_link = detect_incremental(tmp_path, manifest_path, follow_symlinks=True) assert any("linked_corpus" in f for f in yes_link["files"]["document"]) assert yes_link["new_total"] >= 2 # real + linked # After saving manifest, a second incremental scan should see no changes. save_manifest(yes_link["files"], manifest_path) second = detect_incremental(tmp_path, manifest_path, follow_symlinks=True) assert second["new_total"] == 0 def test_detect_incremental_survives_dict_valued_mtime(tmp_path, monkeypatch): """A schema-drifted manifest whose entry stores mtime as a nested dict (instead of a float) must not crash detect_incremental (#1163). The guard coerces the bad mtime to None so the file is re-verified by content hash and treated as new, rather than blowing up on the int/float comparison. """ import json monkeypatch.chdir(tmp_path) src = tmp_path / "mod.py" src.write_text("def f():\n return 1\n", encoding="utf-8") manifest_dir = tmp_path / "graphify-out" manifest_dir.mkdir() manifest_path = str(manifest_dir / "manifest.json") # Drifted entry: a non-empty ast_hash (so the dict branch reaches the mtime # comparison) with mtime stored as a dict rather than a float. Absolute key # so it matches detect's absolute file paths without re-anchoring. drifted = { str(src.resolve()): { "mtime": {"mtime": 123.0}, "ast_hash": "deadbeef" * 4, "semantic_hash": "cafebabe" * 4, } } Path(manifest_path).write_text(json.dumps(drifted), encoding="utf-8") # Must not raise (pre-fix: TypeError comparing float and dict). result = detect_incremental(tmp_path, manifest_path) # The drifted file is re-classified as new rather than silently skipped. assert any("mod.py" in f for f in result["new_files"]["code"]) assert not any("mod.py" in f for f in result["unchanged_files"]["code"]) def test_detect_incremental_legacy_float_reextracts_on_backwards_mtime(tmp_path, monkeypatch): """Legacy float manifests must re-extract when mtime moves BACKWARDS (#1859). Pre-fix the legacy branch used `current_mtime > stored`, which silently kept the cached entry after operations that restore older mtimes: `git checkout` of an older commit, `tar -xf` restore, or `rsync --times`. The graph then reflected the newer content while disk held the older content. The dict branch has always used `!=`; this test pins the legacy branch to the same contract. """ import json monkeypatch.chdir(tmp_path) src = tmp_path / "mod.py" src.write_text("def old_content():\n return 1\n", encoding="utf-8") current_mtime = os.stat(src).st_mtime manifest_dir = tmp_path / "graphify-out" manifest_dir.mkdir() manifest_path = str(manifest_dir / "manifest.json") # Legacy schema (pre-dict-migration): the value is a bare float mtime. # Store a mtime FROM THE FUTURE, simulating a checkout of an older # revision that restored the file to an earlier timestamp. future_mtime = current_mtime + 3600 legacy = {str(src.resolve()): future_mtime} Path(manifest_path).write_text(json.dumps(legacy), encoding="utf-8") result = detect_incremental(tmp_path, manifest_path) assert any("mod.py" in f for f in result["new_files"]["code"]), ( "backwards-moving mtime on a legacy manifest entry must trigger re-extract" ) assert not any("mod.py" in f for f in result["unchanged_files"]["code"]) def test_detect_incremental_legacy_float_skips_when_mtime_matches(tmp_path, monkeypatch): """Non-regression for the fix above: legacy float branch still skips when the stored mtime equals the current mtime.""" import json monkeypatch.chdir(tmp_path) src = tmp_path / "mod.py" src.write_text("def stable():\n return 1\n", encoding="utf-8") manifest_dir = tmp_path / "graphify-out" manifest_dir.mkdir() manifest_path = str(manifest_dir / "manifest.json") # Legacy schema with the exact current mtime → no change → skip. legacy = {str(src.resolve()): os.stat(src).st_mtime} Path(manifest_path).write_text(json.dumps(legacy), encoding="utf-8") result = detect_incremental(tmp_path, manifest_path) assert not any("mod.py" in f for f in result["new_files"]["code"]) assert any("mod.py" in f for f in result["unchanged_files"]["code"]) def test_classify_video_extensions(): """Video and audio file extensions should classify as VIDEO.""" from graphify.detect import FileType assert classify_file(Path("lecture.mp4")) == FileType.VIDEO assert classify_file(Path("podcast.mp3")) == FileType.VIDEO assert classify_file(Path("talk.mov")) == FileType.VIDEO assert classify_file(Path("recording.wav")) == FileType.VIDEO assert classify_file(Path("webinar.webm")) == FileType.VIDEO assert classify_file(Path("audio.m4a")) == FileType.VIDEO def test_classify_google_workspace_shortcuts(): assert classify_file(Path("notes.gdoc")) == FileType.DOCUMENT assert classify_file(Path("budget.gsheet")) == FileType.DOCUMENT assert classify_file(Path("deck.gslides")) == FileType.DOCUMENT def test_detect_skips_google_workspace_shortcuts_by_default(tmp_path): (tmp_path / "notes.gdoc").write_text('{"doc_id":"doc-1"}', encoding="utf-8") result = detect(tmp_path) assert not result["files"]["document"] assert any("Google Workspace shortcut skipped" in item for item in result["skipped_sensitive"]) def test_detect_converts_google_workspace_shortcuts_when_enabled(tmp_path, monkeypatch): shortcut = tmp_path / "notes.gdoc" shortcut.write_text('{"doc_id":"doc-1"}', encoding="utf-8") def fake_convert(path, out_dir, *, xlsx_to_markdown=None, root=None): out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / "notes_converted.md" out.write_text("# Notes\n\nA converted Google Doc.", encoding="utf-8") return out monkeypatch.setattr("graphify.detect.convert_google_workspace_file", fake_convert) result = detect(tmp_path, google_workspace=True) assert len(result["files"]["document"]) == 1 assert result["files"]["document"][0].endswith("notes_converted.md") assert result["total_words"] > 0 def test_detect_includes_video_key(tmp_path): """detect() result always includes a 'video' key even with no video files.""" (tmp_path / "main.py").write_text("x = 1") result = detect(tmp_path) assert "video" in result["files"] def test_detect_finds_video_files(tmp_path): """detect() correctly counts video files and does not add them to word count.""" (tmp_path / "lecture.mp4").write_bytes(b"fake video data") (tmp_path / "notes.md").write_text("# Notes\nSome content here.") result = detect(tmp_path) assert len(result["files"]["video"]) == 1 assert any("lecture.mp4" in f for f in result["files"]["video"]) # total_words should not include video files (they have no readable text) assert result["total_words"] >= 0 # won't crash def test_detect_video_not_in_words(tmp_path): """Video files do not contribute to total_words.""" (tmp_path / "clip.mp4").write_bytes(b"\x00" * 100) result = detect(tmp_path) # Only video file present — total_words should be 0 assert result["total_words"] == 0 def test_detect_skips_coverage_dir(tmp_path): """coverage/ and lcov-report/ are noise dirs — HTML reports inside must be excluded (#870).""" cov = tmp_path / "coverage" / "lcov-report" cov.mkdir(parents=True) (cov / "index.html").write_text("coverage report") (cov / "src.ts.html").write_text("file coverage") (tmp_path / "main.py").write_text("def hello(): pass") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] cov_prefix = str(tmp_path / "coverage") assert not any(f.startswith(cov_prefix) for f in all_files) assert any("main.py" in f for f in all_files) def test_detect_skips_coverage_dir_by_lcov_info(tmp_path): """A coverage/ dir is still pruned on any single artefact file — an lcov.info with no lcov-report/ subtree is enough evidence (#870, #2339).""" cov = tmp_path / "coverage" cov.mkdir() (cov / "lcov.info").write_text("TN:\nSF:src/app.ts\nend_of_record\n") (cov / "prettify.js").write_text("var PR_SHOULD_USE_CONTINUATION=true;") (tmp_path / "main.py").write_text("def hello(): pass") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any(f.startswith(str(cov)) for f in all_files) assert any("main.py" in f for f in all_files) def test_detect_keeps_coverage_code_namespace(tmp_path): """#2339: a coverage/ dir holding real modules and no coverage artefacts is a legitimate package name, not a generated report, and must NOT be pruned. Pruning it by name dropped an entire production package while leaving its dependents in the graph, so queries kept returning plausible neighbours and nothing in the report or skipped lists showed the loss.""" pkg = tmp_path / "auditor_toolkit" / "assurance" / "coverage" pkg.mkdir(parents=True) (pkg / "__init__.py").write_text("from .impact import Impact\n") (pkg / "impact.py").write_text("class Impact:\n def score(self): return 1\n") (pkg / "inventory.py").write_text("def inventory():\n return []\n") (tmp_path / "app.py").write_text("from auditor_toolkit.assurance import coverage\n") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert any("impact.py" in f for f in all_files) assert any("inventory.py" in f for f in all_files) assert any(f.endswith("coverage" + os.sep + "__init__.py") for f in all_files) def test_collect_files_keeps_coverage_code_namespace(tmp_path): """#2339 as reported: collect_files returned [] for a real coverage package, both when it is the walk target and when it is reached through the repo root. A genuine report dir alongside it must still be skipped.""" from graphify.extract import collect_files pkg = tmp_path / "auditor_toolkit" / "assurance" / "coverage" pkg.mkdir(parents=True) for name in ("__init__.py", "impact.py", "mapping.py"): (pkg / name).write_text("def f(): pass\n") report = tmp_path / "webapp" / "coverage" report.mkdir(parents=True) (report / "index.html").write_text("coverage") (report / "base.css").write_text("body{}") (report / "prettify.js").write_text("var PR=1;") assert sorted(p.name for p in collect_files(pkg)) == [ "__init__.py", "impact.py", "mapping.py", ] walked = {str(p.relative_to(tmp_path)) for p in collect_files(tmp_path)} assert any(p.endswith("impact.py") for p in walked) assert not any("webapp" in p for p in walked), ( "a generated Istanbul report dir must still be pruned (#870)" ) def test_is_noise_dir_coverage_is_evidence_gated(tmp_path): """The gate itself: name alone is not enough, and an unverifiable call (no parent) keeps a possibly-real code dir — same contract as env/snapshots.""" src = tmp_path / "coverage" src.mkdir() (src / "__init__.py").write_text("") assert detect_mod._is_noise_dir("coverage", tmp_path) is False generated = tmp_path / "report" / "coverage" generated.mkdir(parents=True) (generated / "coverage-final.json").write_text("{}") assert detect_mod._is_noise_dir("coverage", tmp_path / "report") is True assert detect_mod._is_noise_dir("coverage") is False # lcov-report stays unconditional — no package is ever named that. assert detect_mod._is_noise_dir("lcov-report") is True def test_detect_skips_visual_tests_dir(tmp_path): """visual-tests/ bundles and snapshots are noise — must be excluded (#869).""" vt = tmp_path / "visual-tests" vt.mkdir() (vt / "bundle.js").write_text("var u3=function(){};var d2=function(){}") (vt / "screens.tsx").write_text("export const Screen = () =>
") (tmp_path / "app.py").write_text("def main(): pass") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("visual-tests" in f for f in all_files) assert any("app.py" in f for f in all_files) def test_detect_skips_snapshots_dir(tmp_path): """__snapshots__/ and real jest/vitest snapshots/ dirs are artefacts — excluded.""" (tmp_path / "__snapshots__").mkdir() (tmp_path / "__snapshots__" / "app.test.ts.snap").write_text("// Jest Snapshot\nexports[`test 1`] = `
`") # a bare snapshots/ dir that actually holds .snap files is still a JS artefact snap = tmp_path / "snapshots" snap.mkdir() (snap / "component.test.tsx.snap").write_text("exports[`renders`] = ``") (tmp_path / "app.ts").write_text("export function greet() { return 'hi'; }") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert not any("__snapshots__" in f for f in all_files) assert not any(f"{os.sep}snapshots{os.sep}" in f for f in all_files) assert any("app.ts" in f for f in all_files) def test_detect_keeps_snapshots_code_namespace(tmp_path): """#1666: a bare snapshots/ dir with no .snap files is a legit code namespace (e.g. Rails app/services/snapshots/) and must NOT be pruned as a JS artefact.""" svc = tmp_path / "app" / "services" / "snapshots" svc.mkdir(parents=True) (svc / "round_reader.rb").write_text("class RoundReader\n def call; end\nend\n") (svc / "backfill_marker.rb").write_text("class BackfillMarker\n def run; end\nend\n") (tmp_path / "app.rb").write_text("class App; end\n") result = detect(tmp_path) all_files = [f for files in result["files"].values() for f in files] assert any("round_reader.rb" in f for f in all_files) assert any("backfill_marker.rb" in f for f in all_files) def test_detect_skips_storybook_static_dir(tmp_path): """storybook-static/ is a build artefact — must be excluded.""" sb = tmp_path / "storybook-static" sb.mkdir() (sb / "index.html").write_text("storybook") (sb / "main.js").write_text("(function(){var s=1;})()") (tmp_path / "Button.tsx").write_text("export const Button = () =>