diff --git a/graphify/detect.py b/graphify/detect.py index 0b569e4b7..a22b58e75 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -795,7 +795,8 @@ def count_words(path: Path) -> int: ".tox", ".nox", ".eggs", "*.egg-info", # nox is tox's successor, same .nox/ venv shape (#1804) "graphify-out", GRAPHIFY_OUT_NAME, # never treat own output as source input (#524); honour GRAPHIFY_OUT (#1423) # Coverage/test-artefact dirs — generated, never architecturally meaningful - "coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870) + # "coverage"/"lcov-report" are gated on real report markers below (#2339) — a + # real source package named coverage/ was silently pruned with no trace. "visual-tests", "visual-test", # Playwright/visual-regression bundles (#869) "__snapshots__", # Jest/Vitest snapshot dir (unambiguous) "storybook-static", # Storybook production build output @@ -848,6 +849,34 @@ def _has_venv_markers(d: "Path") -> bool: return False +def _has_coverage_markers(d: "Path") -> bool: + """True only when *d* has actual coverage/test-report structure on disk. + + "coverage"/"lcov-report" is a real source-directory name too (e.g. a + package named coverage/), so pruning it by name alone silently drops + legitimate source with no trace (#2339). Prune it only on real evidence: + known report files (lcov.info, coverage-final.json, clover.xml, + coverage.xml, cobertura.xml), an index.html alongside Istanbul/nyc's + static assets (base.css/prettify.js/block-navigation.js/sorter.js), any + *.gcov file, or a nested lcov-report/ subdir. + """ + try: + for name in ("lcov.info", "coverage-final.json", "clover.xml", "coverage.xml", "cobertura.xml"): + if (d / name).is_file(): + return True + if (d / "index.html").is_file(): + for asset in ("base.css", "prettify.js", "block-navigation.js", "sorter.js"): + if (d / asset).is_file(): + return True + if next(d.glob("*.gcov"), None) is not None: + return True + if (d / "lcov-report").is_dir(): + return True + except OSError: + pass + return False + + def _is_noise_dir(part: str, parent: "Path | None" = None) -> bool: """Return True if this directory name looks like a venv, cache, or dep dir.""" if part in _SKIP_DIRS: @@ -858,6 +887,13 @@ def _is_noise_dir(part: str, parent: "Path | None" = None) -> bool: if parent is None: return False # cannot verify; keep a possibly-real code dir return _has_venv_markers(parent / part) + if part in ("coverage", "lcov-report"): + # Ambiguous: a real coverage report OR a real source dir (e.g. a + # package named coverage/). Prune only on actual report evidence, + # mirroring the "snapshots" gating (#1666/#2058/#2339). + if parent is None: + return False # cannot verify; keep a possibly-real code dir + return _has_coverage_markers(parent / part) if part == "snapshots": # Prune only when it looks like an actual JS/Vitest snapshot dir. if parent is None: diff --git a/tests/test_detect.py b/tests/test_detect.py index 63b13a006..254c54bfd 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -2,7 +2,7 @@ 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.detect import classify_file, count_words, detect, detect_incremental, save_manifest, FileType, _looks_like_paper, _is_ignored, _load_graphifyignore, _is_sensitive, _is_noise_dir from graphify import detect as detect_mod FIXTURES = Path(__file__).parent / "fixtures" @@ -699,6 +699,78 @@ def test_detect_skips_visual_tests_dir(tmp_path): assert any("app.py" in f for f in all_files) +def test_detect_keeps_coverage_code_package(tmp_path): + """#2339: a real Python package named coverage/ is not a report dir and + must not be pruned by name alone.""" + pkg = tmp_path / "coverage" + pkg.mkdir() + (pkg / "__init__.py").write_text("") + (pkg / "impact.py").write_text("def measure(): pass") + (tmp_path / "main.py").write_text("import coverage") + 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("main.py" in f for f in all_files) + + +def test_detect_skips_real_lcov_report(tmp_path): + """#2339: a genuine Istanbul/nyc report dir is still pruned on real evidence.""" + cov = tmp_path / "coverage" / "lcov-report" + cov.mkdir(parents=True) + (cov / "index.html").write_text("coverage report") + (cov / "base.css").write_text("body {}") + (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_real_lcov_info(tmp_path): + """#2339: a bare coverage/ dir holding lcov.info is still pruned.""" + cov = tmp_path / "coverage" + cov.mkdir() + (cov / "lcov.info").write_text("SF:src/foo.js\nend_of_record") + (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_keeps_nested_coverage_code_package(tmp_path): + """#2339: name match at any depth — a nested real coverage/ package is kept.""" + pkg = tmp_path / "src" / "pkg" / "coverage" + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("") + (pkg / "impact.py").write_text("def measure(): pass") + (tmp_path / "main.py").write_text("import src.pkg.coverage") + 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) + + +def test_detect_skips_nested_real_lcov_report(tmp_path): + """#2339: name match at any depth — a nested real report dir is still pruned.""" + cov = tmp_path / "src" / "pkg" / "coverage" / "lcov-report" + cov.mkdir(parents=True) + (cov / "index.html").write_text("coverage report") + (cov / "base.css").write_text("body {}") + (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 / "src" / "pkg" / "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_is_noise_dir_coverage_no_parent_returns_false(): + """#2339: with no parent to verify, coverage/ is kept, not pruned by name.""" + assert _is_noise_dir("coverage", None) is False + + def test_detect_skips_snapshots_dir(tmp_path): """__snapshots__/ and real jest/vitest snapshots/ dirs are artefacts — excluded.""" (tmp_path / "__snapshots__").mkdir()