diff --git a/graphify/detect.py b/graphify/detect.py index 0b569e4b7d..6eb9472b94 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -15,7 +15,7 @@ convert_google_workspace_file, google_workspace_enabled, ) -from graphify.paths import GRAPHIFY_OUT, GRAPHIFY_OUT_NAME, out_path +from graphify.paths import GRAPHIFY_OUT, out_path class FileType(str, Enum): @@ -793,7 +793,7 @@ def count_words(path: Path) -> int: "site-packages", "lib64", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".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) + "graphify-out", # never treat the default output as source input (#524) # Coverage/test-artefact dirs — generated, never architecturally meaningful "coverage", "lcov-report", # Vitest/Istanbul/nyc HTML reports (#870) "visual-tests", "visual-test", # Playwright/visual-regression bundles (#869) @@ -1182,6 +1182,13 @@ def _resolves_under_root(path: Path, root: Path) -> bool: def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None, gitignore: bool = True) -> dict: root = root.resolve() + configured_out_dir = root / GRAPHIFY_OUT + configured_out_names = {configured_out_dir.name} + try: + configured_out_dir = configured_out_dir.resolve() + except (OSError, RuntimeError): + configured_out_dir = configured_out_dir.absolute() + configured_out_names.add(configured_out_dir.name) # .graphifyinclude support was removed (#2112): its loader and matchers had # no consumers, so the file has been a silent no-op since dot directories # became indexed by default (#873). Surface that once per scan so a @@ -1295,6 +1302,16 @@ def _on_walk_error(err: OSError) -> None: # repos for no correctness gain. kept_dirs: list[str] = [] for d in dirnames: + child = dp / d + is_configured_out = False + if d in configured_out_names: + try: + is_configured_out = child.resolve() == configured_out_dir + except (OSError, RuntimeError): + pass + if is_configured_out: + pruned_noise.append(str(child) + os.sep) + continue if _is_noise_dir(d, dp): # Record pruned-as-noise dirs so a wrongly-pruned real # source dir is at least traceable in the output rather diff --git a/graphify/paths.py b/graphify/paths.py index a1adaf9f2f..ef0cedb5b9 100644 --- a/graphify/paths.py +++ b/graphify/paths.py @@ -279,9 +279,8 @@ def disambiguate_ambiguous_candidates( {c: candidate_files.get(c, "") for c in survivors}, ) -# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by the -# path guards that walk parents looking for the output dir by name, and by the -# detect scan-exclude so a custom output dir is never re-ingested as source. +# Bare directory name even when GRAPHIFY_OUT is an absolute path. Used by path +# guards that walk parents looking for the output directory by name. GRAPHIFY_OUT_NAME = os.path.basename(os.path.normpath(GRAPHIFY_OUT)) diff --git a/tests/test_detect.py b/tests/test_detect.py index 63b13a0067..cf6e549b61 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -2192,6 +2192,78 @@ def test_detect_prunes_venv_names_without_markers(tmp_path): assert not any(f"{os.sep}{name}{os.sep}" in f for f in all_files), f"{name} must stay pruned" +@pytest.mark.parametrize( + ("configured_out", "absolute", "symlink_target"), + [ + pytest.param("graphify-out/nlp", False, None, id="default-parent"), + pytest.param("artifacts/nlp", False, None, id="custom-parent"), + pytest.param("artifacts/nlp", True, None, id="absolute"), + pytest.param( + "aliases/output-link", + False, + "artifacts/nlp", + id="in-root-symlink", + ), + ], +) +def test_nested_graphify_out_prunes_only_configured_path( + tmp_path, configured_out, absolute, symlink_target +): + """#2273: a nested output basename must not prune same-named source dirs.""" + import json + import subprocess + import sys + + if absolute: + configured_out = str(tmp_path / configured_out) + + source = tmp_path / "src" / "revil" / "nexus" / "nlp" / "core.py" + source.parent.mkdir(parents=True) + source.write_text("def tokenize(text):\n return text.split()\n") + + output_dir = ( + tmp_path / symlink_target + if symlink_target is not None + else tmp_path / configured_out + ) + if symlink_target is not None: + output_dir.mkdir(parents=True) + configured_out_link = tmp_path / configured_out + configured_out_link.parent.mkdir(parents=True) + try: + configured_out_link.symlink_to(output_dir, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("filesystem does not support symlinks") + + generated = output_dir / "generated.py" + generated.parent.mkdir(parents=True, exist_ok=True) + generated.write_text("SHOULD_NOT_BE_INDEXED = True\n") + + probe = subprocess.run( + [ + sys.executable, + "-c", + ( + "import json, sys\n" + "from pathlib import Path\n" + "from graphify.detect import detect\n" + "result = detect(Path(sys.argv[1]))\n" + "print(json.dumps(result['files']['code']))\n" + ), + str(tmp_path), + ], + cwd=Path(__file__).parents[1], + env={**os.environ, "GRAPHIFY_OUT": configured_out}, + check=True, + capture_output=True, + text=True, + ) + detected = {Path(p).resolve() for p in json.loads(probe.stdout)} + + assert source.resolve() in detected + assert generated.resolve() not in detected + + def test_detect_records_unclassified_extensionless_files(tmp_path): # #1692: extensionless, non-shebang project files (Dockerfile, Makefile, ...) # were considered but left no trace. detect() now lists them under