cache_root appears as a parameter of both the AST extract() and the semantic
cache helpers (check_semantic_cache, etc.). In the semantic path it says where
the cache lives. In extract(), when root is not passed, it also becomes the
base that source_file and node ids are computed from. The two meanings share one
name, and the skill's incremental-rebuild guidance (Step 3 Part A vs. Step B0/B3)
does not say so.
In one rebuild without an LLM (AST + semantic cache), the value documented for the
semantic cache — the folder that contains graphify-out — was passed to
extract() as well, by name. Nothing errored. The result:
source_file paths lost the sub-folder prefix, so all 1328 code node ids
changed.
- 313 cached edges linking document concepts to code were left pointing at ids
that no longer existed.
- Communities went from 262 to 433, and manual labels keyed by file stopped
matching.
Reproduction (re-tested on 0.9.64)
One file, proj/pkg/mod.py, containing a single function. Same file list, three calls:
from graphify.extract import extract
f = [base/"proj"/"pkg"/"mod.py"]
extract(f, cache_root=base/"proj") # A
extract(f, cache_root=base) # B
extract(f, cache_root=base, root=base/"proj") # C
| call |
node ids |
source_file |
| A |
pkg_mod, pkg_mod_hello |
pkg/mod.py |
| B |
proj_pkg_mod, proj_pkg_mod_hello |
proj/pkg/mod.py |
| C |
pkg_mod, pkg_mod_hello |
pkg/mod.py |
B is the trap: moving only the cache location renamed every node. C shows the fix is one keyword.
What 0.9.64 already does: the extract() docstring now states the fallback ("without it the anchor falls back to cache_root", #1941), and the CLI passes both ({"cache_root": out_root, "root": target}). What is still open: the skill's Step 3 Part A still calls extract(code_files, cache_root=Path('INPUT_PATH')) with no root, so anyone adapting that snippet to a cache that lives elsewhere (the documented --out case) gets B; and nothing between build and export checks node identity.
Why no existing guard caught it
- The anti-shrink gate compares node counts. 1328 nodes disappeared and 1328
appeared: the count did not move. A full replacement with the same cardinality
passes any size guard.
- The health check did report 412 dangling edges, but the corpus normally carries
~100 from third-party imports, so the figure read as "the usual, a bit higher".
A count without a baseline next to it is easy to explain away.
- The first hypothesis was a schema change in the new version, which the upgrade
made plausible. What settled it was comparing node ids before and after by
type: lost 1328, gained 1328, all code.
Suggested changes
- Skill, Step 3 Part A: pass
root=INPUT_PATH explicitly next to
cache_root, and say in one sentence that cache_root anchors ids when root
is absent. The two call sites look alike and the parameter name invites copying
the value across.
- After build, before export — an identity check, not a size check: against
the previous graph.json, report how many code-node ids disappeared, how many
appeared, and how many cached edges point at ids that do not exist. Stop (or at
least warn loudly) when a large share of one node type is replaced. This is the
check that would have caught it; the node-count gate cannot, by construction.
- Health check: print the dangling-edge count next to the previous run's, or
split it into "endpoints that are third-party stubs" and "endpoints that look
like project ids". The second number going from 0 to 300 is unmistakable; the
sum going from 100 to 412 was not.
- Library (optional): the docstring now documents the fallback; a one-time
warning when extract() receives cache_root without root and the scanned
files do not sit directly under it would turn a silent rename into a visible one.
Workaround in use
Pass root and cache_root explicitly and separately, and gate the rebuild on two
assertions: no more than a small fraction of code-node ids may disappear between
consecutive builds, and no cached edge may point at a missing id. Both stop the
rebuild before anything is overwritten.
Environment: first hit on graphifyy 0.9.61; reproduction above re-run on 0.9.64. Windows 11, Python via uv tool. Corpus where it happened: ~2.9k nodes, mixed code and documents.
cache_rootappears as a parameter of both the ASTextract()and the semanticcache helpers (
check_semantic_cache, etc.). In the semantic path it says wherethe cache lives. In
extract(), whenrootis not passed, it also becomes thebase that
source_fileand node ids are computed from. The two meanings share onename, and the skill's incremental-rebuild guidance (Step 3 Part A vs. Step B0/B3)
does not say so.
In one rebuild without an LLM (AST + semantic cache), the value documented for the
semantic cache — the folder that contains
graphify-out— was passed toextract()as well, by name. Nothing errored. The result:source_filepaths lost the sub-folder prefix, so all 1328 code node idschanged.
that no longer existed.
matching.
Reproduction (re-tested on 0.9.64)
One file,
proj/pkg/mod.py, containing a single function. Same file list, three calls:source_filepkg_mod,pkg_mod_hellopkg/mod.pyproj_pkg_mod,proj_pkg_mod_helloproj/pkg/mod.pypkg_mod,pkg_mod_hellopkg/mod.pyB is the trap: moving only the cache location renamed every node. C shows the fix is one keyword.
What 0.9.64 already does: the
extract()docstring now states the fallback ("without it the anchor falls back to cache_root", #1941), and the CLI passes both ({"cache_root": out_root, "root": target}). What is still open: the skill's Step 3 Part A still callsextract(code_files, cache_root=Path('INPUT_PATH'))with noroot, so anyone adapting that snippet to a cache that lives elsewhere (the documented--outcase) gets B; and nothing between build and export checks node identity.Why no existing guard caught it
appeared: the count did not move. A full replacement with the same cardinality
passes any size guard.
~100 from third-party imports, so the figure read as "the usual, a bit higher".
A count without a baseline next to it is easy to explain away.
made plausible. What settled it was comparing node ids before and after by
type: lost 1328, gained 1328, all
code.Suggested changes
root=INPUT_PATHexplicitly next tocache_root, and say in one sentence thatcache_rootanchors ids whenrootis absent. The two call sites look alike and the parameter name invites copying
the value across.
the previous
graph.json, report how many code-node ids disappeared, how manyappeared, and how many cached edges point at ids that do not exist. Stop (or at
least warn loudly) when a large share of one node type is replaced. This is the
check that would have caught it; the node-count gate cannot, by construction.
split it into "endpoints that are third-party stubs" and "endpoints that look
like project ids". The second number going from 0 to 300 is unmistakable; the
sum going from 100 to 412 was not.
warning when
extract()receivescache_rootwithoutrootand the scannedfiles do not sit directly under it would turn a silent rename into a visible one.
Workaround in use
Pass
rootandcache_rootexplicitly and separately, and gate the rebuild on twoassertions: no more than a small fraction of code-node ids may disappear between
consecutive builds, and no cached edge may point at a missing id. Both stop the
rebuild before anything is overwritten.
Environment: first hit on graphifyy 0.9.61; reproduction above re-run on 0.9.64. Windows 11, Python via
uv tool. Corpus where it happened: ~2.9k nodes, mixed code and documents.