Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## 0.9.18 (unreleased)

- 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 (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.

Expand Down
12 changes: 8 additions & 4 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <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...")
Expand Down
15 changes: 14 additions & 1 deletion graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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()

Expand Down
42 changes: 42 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading