Skip to content

fix(graph): refuse to refresh a graph from a foreign root, and scream when a rebuild empties it (#438) - #443

Open
insuffer wants to merge 1 commit into
trailhq:mainfrom
insuffer:fix/graph-root-guard
Open

insuffer wants to merge 1 commit into
trailhq:mainfrom
insuffer:fix/graph-root-guard

Conversation

@insuffer

Copy link
Copy Markdown

What

Fixes #438: graft --dir graft/demo ask "who calls utilFn" — without the repo-root positional — silently wipes the graph. queryRoot falls back to walking up from the cwd, the freshness check anchors on that root, its unrelated file set reads as "every file changed", and the rebuild writes nodeCount: 0, edgeCount: 0 over a valid graph in under a second. The per-file cards survive, so the damage is invisible until a query misses, and the exit code stays 0.

Changes

  • Record the home root: the build fingerprint now carries the absolute source root it ran on (sidecar version 1 → 2, so pre-ask without the repo-root positional silently wipes the graph: freshness check anchors on cwd and rebuilds 0 nodes (cards survive) #438 caches read as unknown and fall back to one honest rebuild instead of serving stale bags forever).
  • Root guard (ensureFreshGraph): before any drift probe or rebuild, the recorded root is compared with the root the query resolved. On a mismatch the graph is left untouched and the caller gets an actionable note — graph was built for <root>, not <cwd> — answering from the existing graph; to refresh it, pass its root: graft ask <query> <root> — instead of a wiped graph.
  • Worktree seeds re-home: a seeded worktree inherits the parent checkout's fingerprint (its prints are exactly the diff base the worktree refresh wants), so the seed path re-stamps only the recorded root — otherwise every worktree query would look foreign.
  • Emptied-graph safety net: behind the guard, a rebuild that takes a non-empty graph to 0 nodes now returns a loud note naming the previous node count and pointing at graft build from the right root — "silently emptied" is the worst failure mode here because the cards mask it.

Validation

Windows 11:

  • 8 new tests in test/graph-root-guard.test.ts, run locally: no recorded root is not treated as a move; the same root matches through trailing slashes and . segments; a mismatch note names both roots and the corrected invocation; write/read round-trip records the root; a v1 sidecar reads as null (forces one honest rebuild); restampFingerprintRoot re-homes a seeded sidecar and is a no-op on a missing cache dir. Each test fails to import without the source change.
  • tsc --noEmit clean.
  • Full-suite note: the repo's tests need the tree-sitter native grammars, which ship no win32 prebuilds and cannot compile here (no MSVC) — the same environment limit as fix(ask): tokenize Unicode text instead of dropping every non-ASCII word (#432) #442. The guard's decision layer is pure and covered locally; the ensureFreshGraph wiring (guard placement, seed re-stamp call, safety net) is what CI on Linux/macOS exercises.

Deliberately not included

  • Confirmation prompts or auto-recovery of an already-emptied graph: prevention plus a loud note is the reversible behavior; the issue's own recovery commands (graft ask <query> <root>, graft build … <entry>) work unchanged.

@insuffer

Copy link
Copy Markdown
Author

Hi — second PR from a first-time contributor, this one fixes #438 (the more severe one): graft --dir graft/demo ask "<query>" without the repo-root positional anchors the freshness check on the cwd, and when that root is unrelated to the graph the refresh rebuilds it to 0 nodes in a second — with the per-file cards surviving to mask the damage. The fingerprint now records the build's root, the refresh refuses to rebuild across roots (leaving the graph intact and naming the root to pass), seeded worktrees re-home their copied fingerprint, and a rebuild that empties a non-empty graph now says so loudly. The CodeQL, CI, and Blast radius runs are waiting for fork approval — could a maintainer approve them so the checks can run? (PR #442 is waiting on the same approval.) Thanks!

@trailhq-graft

trailhq-graft Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

1 area changed → 4 areas can be affected. 12 dependent symbols, depth 2.
Tests: 1 area updated its tests.
Tag: @anirudhkumar-nanonets — 4 of 5 areas · @shhdwi — 5 of 5 areas · @qoole — Graph Refresh CLI

flowchart TB
  A0(("Pull Request Review<br/>4 symbols"))
  A1(("Graph Refresh CLI<br/>3 symbols"))
  A2(("CLI Engine Integration<br/>3 symbols"))
  A3(("MCP Server Tools<br/>2 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3 reached;
Loading
Can be affected Symbols Nearest hop Reached from
Pull Request Review 4 src/app/review.ts:L45-L99 reviewPullRequest — calls, depth 1 Graph Fingerprinting
Graph Refresh CLI 3 src/graph/refresh-run.ts:L18-L21 main — calls, depth 1 Graph Fingerprinting
CLI Engine Integration 3 src/cli.ts:L278-L288 refreshBefore — calls, depth 1 Graph Fingerprinting
MCP Server Tools 2 src/mcp/tools.ts:L233-L262 callTool — calls, depth 1 Graph Fingerprinting
Who knows this code — 3 people across 5 areas
Area Who knows it
Graph Fingerprinting · changed @anirudhkumar-nanonets — 22 commits, last 10d ago · @shhdwi — 7 commits, last 15d ago
Pull Request Review · affected @anirudhkumar-nanonets — 11 commits, last 28d ago · @shhdwi — 2 commits, last 15d ago
Graph Refresh CLI · affected @shhdwi — 6 commits, last 15d ago · @qoole — 3 commits, last 3d ago
CLI Engine Integration · affected @anirudhkumar-nanonets — 73 commits, last 4d ago · @shhdwi — 52 commits, last 15d ago
MCP Server Tools · affected @shhdwi — 21 commits, last 16d ago · @anirudhkumar-nanonets — 14 commits, last 16d ago

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 12 dependent symbols, grouped by area

Pull Request Review — 4 symbols in 4 files

  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 1)
    55: await buildGraph(checkout.dir);
  • src/app/review-process.ts:L178-L182 — childReviewer (references, depth 2)
  • src/app/review-worker.ts:L67-L87 — run (calls, depth 2)
  • src/app/server.ts:L31-L37 — AppSeams (references, depth 2)

Graph Refresh CLI — 3 symbols in 2 files

  • src/graph/refresh-run.ts:L18-L21 — main (calls, depth 1)
    20: await buildGraph(dir, { contextDir, graphOnly: true, onlyDirs, extensions });
  • src/graph/refresh-run.ts:L1-L33 — refresh-run.ts (calls, depth 2)
    9: import { buildGraph } from "./build.js";
  • src/graph/workspace-cli.ts:L65-L108 — buildChild (calls, depth 2)

CLI Engine Integration — 3 symbols in 2 files

  • src/cli.ts:L278-L288 — refreshBefore (calls, depth 1)
    285: : await ensureFreshGraph(root, { contextDir: globalDir, disabled });
  • src/engine.ts:L104-L115 — graph (calls, depth 1)
    105: return buildGraph(dir, {
  • src/cli.ts:L1-L3449 — cli.ts (calls, depth 2)
    35: import { ensureFreshChildren, ensureFreshGraph, refreshNote } from "./graph/refresh.js";

MCP Server Tools — 2 symbols in 2 files

  • src/mcp/tools.ts:L233-L262 — callTool (calls, depth 1)
    252: : await ensureFreshGraph(root, { contextDir: dirOverride });
  • src/mcp/server.ts:L101-L256 — startMcpServer (calls, depth 2)
Test signal per changed area — 1 ✓

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Graph Fingerprinting — 6 of 7 reached · 1 test file changed here: test/graph-root-guard.test.ts
    • not reached: realPath
54 test suites also reference this code

66 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/claude-hooks.test.ts
  • test/claude-prompt-private.test.ts
  • test/container-extract.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/depth-parity.ts
  • test/generic-extract.test.ts
  • test/graph-bash.test.ts
  • test/graph-elixir.test.ts
  • test/graph-extensions.test.ts
  • test/graph-follow-submodules.test.ts
  • test/graph-go.test.ts
  • test/graph-imported-call-mcp.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-jsx.test.ts
  • …34 more

graft blast · origin/main...HEAD · depth 2 · 4 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 22, 2026
@shhdwi

shhdwi commented Nov 9, 2026

Copy link
Copy Markdown
Collaborator

Reproduced on main: running ask with --dir A/graft from inside repo B rebuilt the graph of A from the files of B (3 nodes down to 1), so this fix matters. Could you rebase over the fingerprint extensions and refresh rebuild() changes on main? Also compare roots with realpathSync: on macOS /tmp and /private/tmp are the same folder, and without it the graph would never refresh there.

… when a rebuild empties it (trailhq#438)

`graft ask` without its repo-root positional anchors the refresh on the cwd.
When that root is unrelated to the graph's home, the drift probe reads "every
file changed" and the rebuild writes an empty graph over a valid one — with
the per-file cards surviving to mask the damage.

The fingerprint now records the root its build ran on, and the refresh gate
compares it against the root a query resolved, as real paths: macOS symlinks
/tmp to /private/tmp, so a graph built through one name and queried through
the other is the same graph, and comparing lexically would refuse to refresh
it on every query from then on. A mismatch leaves the graph untouched and the
note names the one argument that makes the refresh honest. A seeded worktree
re-homes its copied sidecar, since the seed copies the parent checkout's
fingerprint verbatim and its prints are the diff base the worktree wants.

Behind that guard there is a safety net for the case it cannot catch: a
legitimate-looking refresh that still empties the graph, because every source
was deleted or an --only-dir whitelist no longer matches. The rebuild
succeeded, so that is not an error — but "silently emptied" is the worst
failure mode there is, because the cards survive and the graph looks intact
until a query misses. The note says what the root now contains and what to run.

Refs trailhq#438
@insuffer
insuffer force-pushed the fix/graph-root-guard branch from 28b45f3 to 01bb163 Compare November 12, 2026 03:43
@insuffer

Copy link
Copy Markdown
Author

All three done, rebased onto current main (b22055dce3).

extensions in the fingerprint. writeFingerprint had gained a fourth parameter since this branch was cut, so root is now the fifth rather than the fourth, and the sidecar records it alongside the extension selection. The Fingerprint interface carries it, version bumped to 2 so a v1 sidecar without a root forces one honest rebuild rather than comparing against a field that was never there.

rebuild(). The rebuild had also moved into its own function taking { dir, contextDir, onlyDirs, extensions }, so the safety net now wraps that call instead of the inline buildGraph. The node count is read either side of it, exactly as before.

realpathSync. You were right, and this was a real bug rather than a refinement. resolve is purely lexical, so on macOS a graph built through /tmp/repo and queried through /private/tmp/repo — the same directory — compared unequal, and the guard would have refused to refresh it on every query from then on. Roots are now compared as real paths, with resolve as the fallback for a path that does not exist, since realpathSync throws there and the guard still has to answer for a root the user may have mistyped. The note keeps showing the resolved form, so the invocation it prints is the one to paste.

There is a test for it: it builds a graph root, links a second name at it, and asserts the two compare equal and produce no guard note. It skips itself where the platform refuses to create the link, which is Windows without developer mode — and on this machine it does refuse, so that leg is covered by the lexical tests rather than exercised here.

On verification: test/graph-root-guard.test.ts is 9 passed. Reverting only the realpathSync call — keeping everything else — fails exactly that one test and no other, which is the check that it is testing the thing you pointed at rather than passing incidentally.

The wider suite is where I want to be straight with you rather than quote a number. A full run here reports 4504 tests / 57 failed, and the same run on an unmodified main checkout reports 57 failed too — but the two failure sets are not identical, each containing four the other does not, all in the skills-probes / git-probe families that create symlinks this Windows runner has no privilege for. Running the two affected files in isolation, three times each, gives identical results with and without this change. So I read the 57 as a floor of environment failures that move around between runs, not as a clean bill of health — I would rather say that than present the number as if it meant the suite was green.

github-actions Bot added a commit that referenced this pull request Nov 12, 2026

This branch has not been deployed

No deployments
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.

ask without the repo-root positional silently wipes the graph: freshness check anchors on cwd and rebuilds 0 nodes (cards survive)

2 participants