diff --git a/CHANGELOG.md b/CHANGELOG.md index 446ed9e65..5159e8cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.18 (unreleased) +- Fix: `graphify extract --out ` no longer reduces every node's `source_file` to a bare filename, so a `graph.json` stays resolvable against its scan root (#1941, thanks @JensD-git). `--out` passes the output dir as `cache_root` to relocate the cache, but that value also anchored relativization — so every scanned file failed `relative_to(root)`, fell through to the #1899 out-of-root fallback, tripped its `updepth > 3` walk-up guard (meant for a stray `ProjectReference`), and collapsed to a basename; on Windows an `--out` on another drive hit the cross-drive branch and basenamed unconditionally. With the directory gone from the only field that carried it (the `id` slug is lossy — `.`, `/`, `\`, `-` and spaces all map to `_`), no downstream tool could resolve a node back to its file. `extract()` now takes an explicit `root` anchor that the CLI pins to the scan root, independent of where the cache lives (completing the #1774 cache/anchor decoupling and matching `build(root=target)`, which already anchored on the scan root). Cache location, out-of-root portability (#1899), and the no---out/watch paths are unchanged; existing graphs re-resolve on the next extract. - Fix: the semantic cache no longer replays extractions from an older prompt after an upgrade (#1939, thanks @HunterMcGrew and @SinghAman21). Entries were keyed on `sha256(file content + path)` alone, with no component for the extraction prompt that produced them, so a release that changed the prompt left every unchanged file a cache hit: the run exited 0, `cost.json` looked cheap, and the graph silently carried two prompt generations side by side. Semantic entries are now namespaced by a fingerprint of the extraction prompt (`cache/semantic/p{fingerprint}/`, mirroring the AST cache's `v{version}/` layout), keeping both properties #1252 wanted — entries survive releases that don't touch the prompt, and invalidate only when it actually changed. The fingerprint normalizes line endings so a CRLF checkout doesn't look like a prompt change. Both extraction paths pass their prompt: the Python/CLI path (`llm.py`'s `_EXTRACTION_SYSTEM`, all backends) automatically, and the skill path via a new `prompt_file` argument in Step B0/B3 pointing at the `references/extraction-spec.md` the subagents were handed. Pre-existing entries predate fingerprinting and have unknowable vintage: they are still served rather than re-billing a whole corpus, but `check_semantic_cache` now warns with the count, so the "no signal at all" the report describes becomes a visible one; `--force` (or `GRAPHIFY_FORCE=1`) re-extracts them. Old-fingerprint entries are pruned by liveness only, never swept wholesale the way stale AST versions are — two hosts with different prompts can share one `graphify-out/`, and a wholesale sweep would have each run delete the other's entries. (The two monolith skills, aider and devin, inline their prompt instead of shipping a spec sidecar and stay on the unfingerprinted path for now.) - Fix: PostgreSQL foreign-key `references` edges are no longer dropped when a routine in the same schema is unparseable (#1854, thanks @sekmur). `pg_introspect` builds one synthetic DDL document and parsed it with the function stubs emitted before the FK `ALTER TABLE`s, so a C-language (or otherwise unparseable) routine's stub parsed as a tree-sitter ERROR node that swallowed the trailing FK statements into the error region, losing every FK edge after it. The FK DDL is now emitted before the function stubs, so table-to-table `references` edges are produced first and can't be eaten by a later unparseable routine. diff --git a/graphify/cli.py b/graphify/cli.py index 2700f68df..c9c1f23a8 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -125,9 +125,11 @@ def _stale_graph_sources( (--include sources, symlinked external corpora) are never walked by detect, so their absence from the corpus is not staleness evidence. Relative entries are re-anchored against both the scan root and the - graph's own output root (``--out`` extracts store source_files relative - to the OUT root, e.g. ``../project/x.py``, #555/#1899); only anchors - that land inside the scan root count. + graph's own output root; only anchors that land inside the scan root + count. Since #1941 extracts always store source_file relative to the SCAN + root, so the scan-root anchor is the live one; the out-root anchor stays + for graphs written by <=0.9.16, which stored them relative to the OUT root + (e.g. ``../project/x.py``, #555/#1899). ``seen_files`` must be the FULL detect output including unclassified files, so nodes from walked-but-unsupported sources (e.g. introspected Cargo.toml manifests) are not misread as stale. @@ -2527,7 +2529,9 @@ def _parse_float(name: str, raw: str) -> float: # Anchor the cache at the output root, not the scanned project: # with --out, a /graphify-out/cache/ would leak a # graphify-out/ dir into a project that asked for external output. - ast_kwargs: dict = {"cache_root": out_root} + # `root` stays the scanned project so source_file/ids relativize + # against it; conflating the two basenamed every node (#1941). + ast_kwargs: dict = {"cache_root": out_root, "root": target} if cli_max_workers is not None: ast_kwargs["max_workers"] = cli_max_workers print(f"[graphify extract] AST extraction on {len(code_files)} code files...") diff --git a/graphify/extract.py b/graphify/extract.py index 2ff5411cf..844bb6875 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4293,6 +4293,7 @@ def extract( paths: list[Path], cache_root: Path | None = None, *, + root: Path | None = None, parallel: bool = True, max_workers: int | None = None, ) -> dict: @@ -4305,15 +4306,21 @@ def extract( Args: paths: files to extract from + root: explicit anchor for source_file relativization, node ids, and + symbol resolution. Pass the SCAN root whenever the cache lives + somewhere else (`--out`); without it the anchor falls back to + cache_root and every scanned file reads as out-of-root (#1941). cache_root: explicit root for graphify-out/cache/ (overrides the inferred common path prefix). Pass Path('.') when running on a subdirectory so the cache stays at ./graphify-out/cache/. + Anchors ids/source_file only as a fallback when `root` is unset. parallel: if True and there are >= _PARALLEL_THRESHOLD uncached files, use ProcessPoolExecutor for multi-core extraction. max_workers: max subprocess count. Defaults to cpu_count (or the value of GRAPHIFY_MAX_WORKERS if set), bounded by len(uncached_work). """ paths = [Path(p) for p in paths] + anchor_root = Path(root) if root is not None else None _check_tree_sitter_version() _raise_recursion_limit() # Workspace package manifests/globs can change during watch or repeated extraction. @@ -4337,7 +4344,13 @@ def extract( root = Path(*paths[0].parts[:common_len]) if common_len else Path(".") except Exception: root = Path(".") - if cache_root is not None: + # An explicit anchor wins. cache_root is only a fallback anchor: it happens to + # equal the scan root for the no---out CLI path and for watch, but with --out it + # is the OUTPUT dir, and letting it anchor made every scanned file "out-of-root" + # -> _portable_out_of_root_sf() -> bare basename for the whole corpus (#1941). + if anchor_root is not None: + root = anchor_root + elif cache_root is not None: root = cache_root root = root.resolve() diff --git a/tests/test_extract.py b/tests/test_extract.py index 32c8e8e13..98b73edb9 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -987,6 +987,48 @@ def test_degenerate_symbol_name_does_not_leak_absolute_id(tmp_path): assert "$()" not in labels, "the degenerate `$` symbol must be dropped (#1899)" +def test_out_of_tree_cache_root_keeps_source_file_relative_to_scan_root(tmp_path): + """#1941: `--out ` must not basename every in-root node. + + The CLI passes cache_root= to relocate the cache, but that value also + anchored relativization, so every scanned file failed `relative_to(root)`, fell + into `_portable_out_of_root_sf`, tripped the `updepth > 3` walk-up guard meant + for stray out-of-root ProjectReferences, and collapsed to a bare basename. + An explicit `root=` anchors ids/source_file on the SCAN root regardless of + where the cache lives. + """ + scan_root = tmp_path / "corpus" + nested = scan_root / "src" / "Data" / "Database" / "RepositoryTests" + nested.mkdir(parents=True) + (nested / "order_repository_tests.py").write_text( + "class OrderRepositoryTests:\n def test_get(self):\n return 1\n", + encoding="utf-8", + ) + # >3 levels off the shared ancestor: the exact shape that triggered basenaming. + out_dir = tmp_path / "a" / "b" / "c" / "d" / "out" + out_dir.mkdir(parents=True) + + result = extract( + [nested / "order_repository_tests.py"], + cache_root=out_dir, + root=scan_root, + ) + source_files = { + n["source_file"] for n in result["nodes"] if n.get("source_file") + } + assert source_files, "expected nodes carrying a source_file" + assert source_files == { + "src/Data/Database/RepositoryTests/order_repository_tests.py" + }, f"source_file must stay relative to the scan root, got {source_files}" + # The point of the field: it resolves back to a real file against the root. + for sf in source_files: + assert (scan_root / sf).is_file(), f"{sf} does not resolve under {scan_root}" + # #1899 must not regress: no absolute path / username leak. + for n in result["nodes"]: + assert str(tmp_path) not in (n.get("source_file") or "") + assert str(tmp_path) not in n["id"] + + def test_python_module_qualified_call_resolves_extracted(tmp_path): """`module.func()` where `module` is imported resolves to the callable that module contains, with an EXTRACTED `calls` edge (#1883). A lowercase module