Skip to content

fix(graph): cold builds disagree on reference edges (#116) - #120

Merged
shhdwi merged 1 commit into
trailhq:mainfrom
VivienP:fix/graph-build-determinism
Aug 16, 2026
Merged

shhdwi merged 1 commit into
trailhq:mainfrom
VivienP:fix/graph-build-determinism

Conversation

@VivienP

@VivienP VivienP commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Two cold builds of byte-identical source can produce different graphs. On graft's own tracked source, ten graft build --no-reuse runs gave four distinct canonical edge digests. The node set was stable — as were calls, contains, imports and implements. Only references moved, between 201 and 238 edges.

Root cause

isDirectCallee, isDeclarationName and withoutShadowedImports ask whether an identifier is the callee (or the declared name) of its parent, and answered by comparing SyntaxNode objects with ===.

node-tree-sitter materializes those wrappers on demand and caches them weakly, so reaching the same node twice can return two different JS objects depending on whether a scavenge ran in between. A purely syntactic question therefore depended on collector timing, and a direct callee was intermittently booked as a value reference as well.

#87's determinism gate does not catch this: its fixture is three small files, far too little allocation to recycle the cache.

Fix

A sameSyntaxNode() helper compares the stable node id within the same tree. Ids are unique per tree but not across trees, so the tree is compared too; a Tree is one object per parse (unlike its nodes), so === is correct for it.

Applied at those three sites — an audit of src/ found they are the only SyntaxNode identity comparisons in the codebase.

Validation

pre-fix fixed
regression test ×20, Node 20.20.2 (local, Windows) 20/20 red 0/20
regression test ×20, Node 24.8.0 (local, Windows) 20/20 red 0/20
10 cold builds of graft's own source 4 distinct canonical edge digests 1 digest, identical across all 10
CI — ubuntu-latest, Node 20.20.2 — 660 tests, 660 pass, 0 fail
CI — windows-latest, Node 20.20.2 — 660 tests, 0 fail (4 pre-existing platform skips)

The #116 test executed on both CI platforms (not skipped).

The regression test runs the build in a child with a 1 MB young generation, so the cold cache the defect needs is reproducible instead of occasional, and its fixture is bulk direct call sites rather than padding — asking the question once is a coin flip (measured 12/20 on Node 20), asking it 60 times is not. It also pins the behaviour a fix must not break: a value use still yields a references edge, calls and references still coexist when one body does both, and a declaration that reuses an imported name is not a use.

Validation limit worth stating: the red/green contrast was measured locally on Windows (Node 20 and Node 24). CI confirms the fixed state is green on Ubuntu and Windows, but the pre-fix red state was not re-run on Ubuntu — so the test's detection power on Linux is inferred from the mechanism, not measured.

Behavioral impact

references on graft's own corpus goes from a fluctuating 201–238 to a stable 22. That is a precision correction rather than lost recall:

  • 216 references edges removed, 0 added, every other relation unchanged.
  • 215 of the 216 duplicated a calls edge with the same source→target — which is exactly what isDirectCallee exists to prevent.
  • Classified against the AST (a classifier over each occurrence, not a ground-truth oracle): 214 were direct-callee occurrences; 2 moved from a file node to the enclosing symbol (src/cli.ts → src/cli.ts#wireTarget, test/ask.test.ts → …#rankOf), i.e. more precise attribution.
  • One removal genuinely loses connectivity: test/claude-init-workspace.test.ts → test/helpers.ts#runCli. runCli is defined in four files, so the bare call drops as ambiguous and the (wrongly emitted) reference edge was the only thing linking them. That is a pre-existing call-resolution limit the bug was masking; not addressed here.

Note on #40

PR #40 (tree-sitter 0.25 bump) independently changes two of these three sites to compare .id, as an incidental part of a runtime upgrade. Happy to rebase on top of it, or to drop this if you would rather take it there — though that PR does not cover withoutShadowedImports and carries no determinism test.

Fixes #116

…#116)

Two cold builds of byte-identical source could produce different graphs. The
node set was stable, and so were `calls`, `contains`, `imports` and
`implements` — only `references` moved, between 201 and 238 edges across
repeated builds of graft's own source.

`isDirectCallee`, `isDeclarationName` and `withoutShadowedImports` ask whether
an identifier IS the callee (or the declared name) of its parent, and answered
by comparing `SyntaxNode` objects with `===`. node-tree-sitter materializes
those wrappers on demand and caches them weakly, so reaching one node twice can
hand back two different JS objects depending on whether a scavenge ran in
between. The guards returned different verdicts for identical source, and a
direct callee was intermittently booked as a value reference as well.

`sameSyntaxNode()` compares the stable node id within the same tree instead. An
id is unique per tree but not across trees, so the tree is compared too; a Tree
is one object per parse, unlike its nodes, so `===` is right for it. Applied at
those three sites — the only SyntaxNode identity comparisons in src/.

Measured over 10 independent cold builds of the tracked source:

  before  3658 / 3687 / 3695 / 3690 edges, 4 distinct canonical edge digests
  after   3479 edges, one digest, identical on all 10 runs

That delta is 216 `references` edges removed and none added, with every
calls/imports/contains/implements edge unchanged. Classified against the AST,
214 were direct-callee occurrences already carried by a `calls` edge with the
same source→target, and 2 moved from a file node to the enclosing symbol
(src/cli.ts → src/cli.ts#wireTarget, test/ask.test.ts → …#rankOf). The
remaining one, test/claude-init-workspace.test.ts → test/helpers.ts#runCli,
loses connectivity: `runCli` is defined in four files, so the bare call drops
as ambiguous. That is a pre-existing call-resolution limit the false edge was
masking, and is left alone here.

The regression test makes the bug reproducible rather than occasional: its
fixture is bulk direct call sites, and the build runs in a child with a 1 MB
young generation so the cold cache the defect needs actually occurs. Against
the previous extractor it fails 20/20 on Node 20.20.2 and 20/20 on Node 24.8.0;
against this one, 0/20 on both. It also pins what a fix must not break — a
value use still yields a reference, `calls` and `references` still coexist when
one body does both, and a declaration reusing an imported name is not a use.
660 tests (+1).
@VivienP
VivienP force-pushed the fix/graph-build-determinism branch from 7e18f1d to 9249e8a Compare August 16, 2026 07:00
@VivienP
VivienP marked this pull request as draft August 16, 2026 07:01
@VivienP
VivienP marked this pull request as ready for review August 16, 2026 07:13
@shhdwi
shhdwi merged commit 9fb9d4f into trailhq:main Aug 16, 2026
FrancescoMerenda pushed a commit to FrancescoMerenda/Graft that referenced this pull request Sep 7, 2026
fix(graph): cold builds disagree on reference edges (trailhq#116)
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.

Cold builds of identical source produce different reference edges

2 participants