Skip to content

Resolve graph.json from an ancestor directory, plus two independent fixes - #3296

Open
bobthearsonist wants to merge 7 commits into
Graphify-Labs:v8from
bobthearsonist:fix/follow-symlinks-containment
Open

Resolve graph.json from an ancestor directory, plus two independent fixes#3296
bobthearsonist wants to merge 7 commits into
Graphify-Labs:v8from
bobthearsonist:fix/follow-symlinks-containment

Conversation

@bobthearsonist

Copy link
Copy Markdown

In a multi-repo workspace the graph lives at a parent root — here C:/Repositories/graphify-out/ covering 23 repos. Queries from a child repo or worktree failed with "graph file not found", because resolution only ever looked in cwd.

Five commits make ancestor resolution consistent: CLI, non-CLI callers, hook-guard, and the skill doc.

Two independent fixes:

  • extractfollow_symlinks=True was overridden by a root-containment check, so linked trees were silently skipped rather than followed.
  • security — the graph.json size cap rejected an 859 MB workspace graph. Raised to 2 GiB.

Verified on Windows against a 331k-node, 23-repo graph, resolving from the CLI, PowerShell, and the MCP server.

bobthearsonist and others added 7 commits August 25, 2026 07:55
Passing follow_symlinks=True declares intent to follow links outside the
scan root, but collect_files applied _resolves_under_root unconditionally,
rejecting every file reached through such a link. That made the flag inert
for its only documented use case: a directory of links to scattered source
dirs. Skip containment when the caller explicitly opted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read commands defaulted to <GRAPHIFY_OUT>/graph.json relative to cwd, so
query/path/explain/affected/god-nodes failed from any subdirectory of the
scan root -- a package subdir, a git worktree -- even when a usable graph
sat one or more levels up. The workaround was a shell wrapper function,
which only ever worked in the one shell that sourced it and did nothing for
tools that exec graphify directly.

Walk up to the nearest readable, non-empty <GRAPHIFY_OUT>/graph.json when
the configured path is not present here, and name the resolved graph on
stderr (stdout carries the answer and is parsed by callers). Candidates that
exist but are empty, unreadable, or a dangling link are skipped so the walk
continues rather than returning a path that fails to parse.

An explicit --graph still wins and is passed through untouched even when it
does not exist; a graph present in cwd is returned with no filesystem walk,
keeping the common case byte-identical; and an absolute GRAPHIFY_OUT
disables the walk, since it names one fixed location rather than a per-root
convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`explain` resolved an ambiguous label by refusing to answer: when the winning
match tier spanned several source files it listed them and exited 1. Indexing
git worktrees beside their canonical repo makes that tie the normal case --
the same symbol legitimately exists as matching/Foo.py and
matching-1234-ga-merge/Foo.py -- so the command failed even when standing in
the worktree whose copy was obviously meant.

Prefer the candidate whose top-level directory is the one cwd is in, derived
from the graph's own scan root the way `affected` derives it. When cwd
singles out nothing, warn on stderr naming every alternative and answer
anyway, matching the "warning: ... was ambiguous" style `path` already uses.
Ambiguity now travels on stderr so stdout stays parseable.

The fallback picks the lowest source_file rather than the first match, so the
answer stays stable under graph iteration order -- the ordering bug the
previous hard failure existed to prevent is still covered, without making the
command unusable. `deduplicate_by_label` is deliberately left off: it
conflates same-label symbols across files, which is the opposite of the
distinction being drawn here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
default_graph_json is documented as the package-wide fallback, but only the
CLI walked up to an ancestor graph; serve, build, prs and benchmark got a
cwd-relative path that does not exist outside the scan root. The MCP server
inherits the client's cwd, which is routinely a git worktree, so it failed
where the CLI succeeded from the identical directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A multi-root workspace graph covering many repositories plus the worktrees
under active development runs well past 512 MiB. Tripping the cap fails both
the CLI and the MCP server with "could not load graph.json", which reads as
corruption rather than as a configurable limit. The cap guards against absurd
input, not against a legitimate graph.

GRAPHIFY_MAX_GRAPH_BYTES still overrides. It is not a sufficient answer on its
own: a machine environment variable does not reach processes that are already
running, so the stack stays broken until every client is restarted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fast-path check told agents to look for graphify-out/graph.json
"relative to the current working directory". In a git worktree or any
nested subdirectory that file is absent while a perfectly good graph sits
one or more levels up, so an agent following the skill concludes no graph
exists and falls back to grep or to rebuilding one.

The read commands already resolve upward and report which graph they used,
so the reliable check is to run one rather than to stat a fixed path.

Applied to every client variant, since the mistake is identical in all of
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PreToolUse guard checked out_path("graph.json") relative to the current
directory. Agents run from wherever they happen to be — a git worktree, a
package subdirectory — while the graph sits at the scan root above them, so
the guard silently never fired in exactly the places it was needed and the
agent grepped a codebase that had a graph one level up.

Mirrors what the read commands already do. Still fails open: any error
resolves to None and the guard prints nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 4 change(s) tested, no difference found (not proven).


Graphify review — findings

Makes read commands (explain, affected, god-nodes, path) resolve a missing default graph by walking up to the nearest ancestor's graph.json, while an explicit --graph is always used verbatim even when absent. Extends the hook guards (search/read/gemini) to fire when a graph exists in an ancestor rather than only in cwd, so agents running from worktrees or subdirectories still get nudged. Disambiguates explain when several files define the same label by preferring the top-level directory the caller is standing in, falling back to a stderr warning plus the lowest-sorted source_file for a stable pick.

Worth a look

  • SSRF guard bypassed when follow_symlinks=True in collect_filesgraphify/extract.py:7080 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • collect_files no longer enforces root containment when following symlinksgraphify/extract.py:7081 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Default graph lookup trusts attacker-controlled ancestor directoriesgraphify/cli.py:108 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • follow_symlinks bypasses root containment and includes files outside the scan rootgraphify/extract.py:7078 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • follow_symlinks=True disables containment, enabling path traversal outside rootgraphify/extract.py:7080 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3056 functions depend on the 739 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 490 callers, 42 callees
  • new: _rebuild_code() — 98 callers, 50 callees
  • new: build_from_json() — 187 callers, 18 callees
  • new: build_merge() — 46 callers, 14 callees
  • new: to_obsidian() — 36 callers, 13 callees
  • new: save_manifest() — 40 callers, 11 callees
  • new: _extract_generic() — 18 callers, 24 callees
  • new: extract_bash() — 41 callers, 10 callees
  • …and 74 more — each is listed as a finding

Verification — 3056 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2836 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify \_check\_skill\_version.

The verifier did not have enough to check \_check\_skill\_version, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `skill_dst` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).

The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

No difference found (not proven): No behavior difference found in find\_import\_cycles (not a proof).

The verifier ran both versions of find\_import\_cycles on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_infer\_merge\_root.

The verifier did not have enough to check \_infer\_merge\_root, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify build\_merge.

The verifier did not have enough to check build\_merge, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify merge\_raw\_extraction.

The verifier did not have enough to check merge\_raw\_extraction, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `graph_path` is annotated `str | Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prefix\_graph\_for\_global.

The verifier did not have enough to check prefix\_graph\_for\_global, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 115 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous

Could not verify: Could not verify \_absolutize\_ids\_in.

The verifier did not have enough to check \_absolutize\_ids\_in, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `'str | Path'` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_flush\_stat\_index (not a proof).

The verifier ran both versions of \_flush\_stat\_index on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_relativize\_ids\_in.

The verifier did not have enough to check \_relativize\_ids\_in, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `path` is annotated `'str | Path'` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_rewrite\_strings (not a proof).

The verifier ran both versions of \_rewrite\_strings on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 1 grounded finding(s) anchored inline below; 81 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/extract.py
@@ -7075,9 +7075,15 @@ def _canon(nid: str) -> str:

def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | None = None) -> list[Path]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioncollect_files()

fans out to 7 callees (efferent coupling); 17 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant