fix(extract): anchor source_file on the scan root, not the --out dir (#1941)

`graphify extract <root> --out <dir>` reduced every node's `source_file` to a
bare filename, so a graph.json could no longer be resolved back to files on
disk by joining source_file onto the scan root.

--out passes the output dir as cache_root to relocate the cache, but that value
also anchored relativization. Every scanned file then failed relative_to(root),
fell through to the #1899 out-of-root fallback, tripped its `updepth > 3`
walk-up guard -- written for a stray ProjectReference, not a whole corpus -- and
collapsed to a basename. On Windows an --out on another drive hit the
cross-drive branch and basenamed unconditionally, which is what the reporter
saw: 0 of ~120k source_files kept a separator. The directory survived only in
the node id slug, lossily (`.`, `/`, `\`, `-`, spaces all map to `_`), and no
other field carried it -- origin_file is stripped (#1516) and the export has no
file table -- so 0% of nodes resolved.

extract() now takes an explicit `root` anchor for source_file/ids/symbol
resolution, which the CLI pins to the scan root independent of where the cache
lives. This completes the cache/anchor decoupling #1774 started and matches
build(root=target), which already anchored on the scan root -- extract was the
lone component keying off --out.

cache_root keeps its fallback-anchor role, so callers that pass the scan root as
cache_root (watch, the no---out CLI path, tests) are unchanged, as are cache
location and out-of-root portability (#1899).
This commit is contained in:
SinghAman21
2026-07-17 11:35:11 +01:00
committed by safishamsi
parent 709b208175
commit be80ee82d5
4 changed files with 65 additions and 5 deletions
+1
View File
@@ -14,6 +14,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- 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: the Stage 1 sensitive-directory check no longer silently drops legitimate source under `secrets/` or `credentials/` directories (#1943, thanks @HerenderKumar). A directory named `secrets/`, `.secrets/`, or `credentials/` is as often a real source package (Go `internal/secrets`, a `credentials/` service module) as a credential store, but `_is_sensitive` pruned everything beneath one wholesale, with no trace and no override. The dir list is now split: dedicated credential stores (`.ssh`, `.gnupg`, `.aws`, `.gcloud`) still drop everything unconditionally, while the ambiguous bare-name dirs spare genuine programming-language source — the same carve-out Stage 3 applies to keyword-named files (#1666), extracted into a shared `_is_graphable_source` predicate so the two stages can't drift. Rescued source still falls through the Stage 2/3 filename screens (`secrets/service_account.py` and `credentials/id_rsa` stay dropped), and data/config formats under those dirs (`secrets/db.json`, `.secrets/token.yaml`) remain flagged — those are exactly the formats credentials ship in.
- 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.
- Fix: `graphify extract <root> --out <dir>` 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, and collapsed to a basename; on Windows an `--out` on another drive hit the cross-drive branch and basenamed unconditionally. `extract()` now takes an explicit `root` anchor the CLI pins to the scan root, independent of where the cache lives (completing the #1774 cache/anchor decoupling and matching `build(root=target)`). Cache location, out-of-root portability (#1899), and the no-`--out`/watch paths are unchanged. A graph already written with basenamed paths does not self-heal on an unchanged corpus (the stale nodes are pruned as out-of-scope, leaving them empty); run `graphify extract --force` once, or re-extract after a file change, to rebuild it correctly.
## 0.9.17 (2026-07-16)
+8 -4
View File
@@ -134,9 +134,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.
@@ -2555,7 +2557,9 @@ def dispatch_command(cmd: str) -> None:
# Anchor the cache at the output root, not the scanned project:
# with --out, a <target>/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...")
+14 -1
View File
@@ -4294,6 +4294,7 @@ def extract(
paths: list[Path],
cache_root: Path | None = None,
*,
root: Path | None = None,
parallel: bool = True,
max_workers: int | None = None,
) -> dict:
@@ -4306,15 +4307,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.
@@ -4338,7 +4345,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()
+42
View File
@@ -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 <far-away-dir>` must not basename every in-root node.
The CLI passes cache_root=<out dir> 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