Skip to content

extract(): cache_root without root re-anchors every AST node id, and the node-count shrink guard cannot notice #3727

Description

@jnrod03-rgb

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions