Repository navigation
fix(graph): cold builds disagree on reference edges (#116) - #120
Merged
Merged
Conversation
VivienP
marked this pull request as ready for review
August 16, 2026 06:40
…#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
force-pushed
the
fix/graph-build-determinism
branch
from
August 16, 2026 07:00
7e18f1d to
9249e8a
Compare
VivienP
marked this pull request as draft
August 16, 2026 07:01
VivienP
marked this pull request as ready for review
August 16, 2026 07:13
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Two cold builds of byte-identical source can produce different graphs. On graft's own tracked source, ten
graft build --no-reuseruns gave four distinct canonical edge digests. The node set was stable — as werecalls,contains,importsandimplements. Onlyreferencesmoved, between 201 and 238 edges.Root cause
isDirectCallee,isDeclarationNameandwithoutShadowedImportsask whether an identifier is the callee (or the declared name) of its parent, and answered by comparingSyntaxNodeobjects 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; aTreeis 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 onlySyntaxNodeidentity comparisons in the codebase.Validation
ubuntu-latest, Node 20.20.2windows-latest, Node 20.20.2The #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
referencesedge,callsandreferencesstill 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
referenceson graft's own corpus goes from a fluctuating 201–238 to a stable 22. That is a precision correction rather than lost recall:referencesedges removed, 0 added, every other relation unchanged.callsedge with the same source→target — which is exactly whatisDirectCalleeexists to prevent.src/cli.ts→src/cli.ts#wireTarget,test/ask.test.ts→…#rankOf), i.e. more precise attribution.test/claude-init-workspace.test.ts → test/helpers.ts#runCli.runCliis 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 coverwithoutShadowedImportsand carries no determinism test.Fixes #116