Skip to content

Fix two graph-correctness bugs found auditing the whole codebase - #6

Merged
Anuragp22 merged 8 commits into
masterfrom
fix/graph-correctness-audit
Jul 28, 2026
Merged

Fix two graph-correctness bugs found auditing the whole codebase#6
Anuragp22 merged 8 commits into
masterfrom
fix/graph-correctness-audit

Conversation

@Anuragp22

Copy link
Copy Markdown
Owner

Started as a walkthrough page. Reading every file in internal/ to write it
truthfully turned up two defects that put edges in the graph which do not exist
in the source, plus several smaller ones.

Every fix has a test that fails without it. go build, go vet and all 11
packages pass on each commit.

The two that matter

One CALLS edge per call site, not per caller. Build appended an edge for
every call expression, and caller counts come from counting those edges, so a
function calling another eleven times was reported as having eleven callers.
On this repo, 928 call edges collapsed to 705 distinct pairs. Four functions
were rated HIGH purely from the inflation:

function counted actual
resolveUnique 12 2
findChildContent 21 7
httpError 11 5
Match 6 4

The SCIP backend already deduped (scipGraphBuilder.addEdge), so the two
backends disagreed about risk for identical code.

Chained JS/TS calls bound to unrelated local functions. findChildContent
scans direct children only, so for logger.output.write() the object is itself
a member_expression and no identifier child exists. The receiver came back
empty, making the call indistinguishable from a bare write() — which
bare-name resolution then bound to any local function of that name, with
EXACT confidence
. A fabricated edge reported as a certainty. Python already
handled this correctly via flattenAttribute.

Also stops claiming EXACT for any call that falls through to bare-name matching
with a receiver present: x.Foo() matching some Foo chosen without knowing
what x is has not been resolved with certainty.

Also fixed

  • Test functions crowded the top of the review list. Every Go TestXxx is an
    exported name, so the ladder's export fallback tagged them MEDIUM and the
    -6 test penalty was too small to sink them.
  • serve ignored graph_path from .tracescope.yaml.
  • The server advertised GET /api/why, which was never registered.
  • No HTTP server timeouts.
  • SaveCache wrote a fixed .tmp path; two indexes could race.
  • A Python class with an unextractable name dropped its whole body.
  • git log fields were |-separated, which author names may contain.

Effect on the graph, measured by re-indexing

before after
duplicate call edges 223 0
RiskExitError.Error inbound 75 (3 EXACT) 46, all heuristic
unresolved calls 3,059 3,204
functions rated HIGH 7 6

Unresolved rising is the intended trade: fewer invented edges, more admitted gaps.

Behaviour changes to review before merging

  • Test-only changes now exit 0 instead of 2. That MEDIUM came entirely from
    test functions.
  • Correct caller counts move the gate both ways — some changes that used to
    pass now fail, and vice versa.

Not fixed, deliberately

FileMetadata.ParsedAt is set from time.Now() and never read, so graph.json
differs on every index. Removing it is a schema change for a cosmetic gain.

Scope note

I read every file in internal/. The parts skimmed rather than traced
line-by-line are the SCIP protobuf decoding internals and the tree-sitter walk
functions. There may be defects in there I did not see.

🤖 Generated with Claude Code

https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs

Anuragp22 and others added 6 commits July 28, 2026 15:44
Every Go TestXxx is an exported name, so the ladder's "exported/public
function" fallback tagged every test in the blast radius MEDIUM. The
review score's -6 test penalty was far too small to sink them, so on a
change whose only direct callers are tests, the entire top of the
ranking a reviewer is told to read first was test code.

Add a rung above the export fallback: a test with no production callers
is LOW. Guarded on zero production callers so it cannot shadow any rule
above it, all of which require at least one.

Measured against the indexed graph, top-of-list becomes real code:

  seed             before            after
  linesOverlap     15 med,  4 low    2 med, 17 low
  canonicalPath    44 med, 21 low    5 med, 60 low
  matchPattern     16 med,  9 low    3 med, 22 low

Note this changes the CI contract for test-only impact: a change whose
blast radius is entirely tests now exits 0 rather than 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs
docs/tracescope-motion.html: a single self-contained page (React +
Framer Motion via importmap, no build step) explaining the tool in the
order the code runs it — index-time, analyse-time, then the mechanisms
that decide whether any of it is true.

Every figure is measured, not asserted. Graph counts come from the
committed .tracescope/graph.json; scenario results come from replaying
graph/query.go, analyzer/risk_scorer.go and analyzer/blast_radius.go
against it; eval numbers come from docs/EVALUATION.md. Where a diagram
draws a subset, it prints the real N beside it. No SCIP node counts are
quoted, because no SCIP-built graph of this repo is checked in.

Each stage states its own limitation, including the unflattering ones:
23% call-site resolution, a HIGH gate only 2 of 283 functions can reach,
and hand-picked scoring constants the eval shows are close to inert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs
Build appended a CALLS edge for every call expression, so a function
calling another eleven times produced eleven edges. Caller counts come
from counting those edges (analyzer.buildCallerCountMaps), so that
function was reported as having eleven callers, and could cross the
HIGH threshold on its own.

Measured on this repo's own graph: 928 CALLS edges collapse to 705
distinct pairs — 223 were duplicates. Four functions were rated HIGH
purely because of the inflation:

  resolveUnique     12 -> 2 distinct callers
  findChildContent  21 -> 7
  httpError         11 -> 5
  Match              6 -> 4

The SCIP backend already deduped this way (scipGraphBuilder.addEdge),
so the two backends disagreed about risk for identical code.

ResolutionStats still counts call sites — its unresolved and ambiguous
counters are a per-reference diagnostic, not an edge count. Where one
pair is reached both heuristically and exactly, the surviving edge is
exact: an exact resolution anywhere is stronger evidence the edge is real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs
findChildContent scans direct children only, so for a chained call like
logger.output.write() the object is itself a member_expression and no
identifier child exists. The receiver came back empty, which made the
call indistinguishable from a bare write() — and bare-name resolution
then bound it to any local function named write, with EXACT confidence.
A fabricated edge, reported as a certainty. Python already handled this
correctly via flattenAttribute; JS and TS did not.

Flatten the object chain instead, so logger.output.write() keeps the
receiver "logger.output" and this.helper() keeps "this". An object with
no name at all (f().c()) is marked "?" rather than left empty, since an
empty receiver is what triggers the bare-name path.

Also stop claiming EXACT for any call that still falls through to
bare-name matching with a receiver present. The match may be right — a
same-file method call whose receiver type go/types could not infer — but
x.Foo() matching some definition of Foo chosen without knowing what x is
is not a certainty. It now resolves as heuristic, so the review score
discounts it and the reviewer is told to check the path.

Measured by re-indexing this repo: RiskExitError.Error went from 75
inbound edges (3 of them EXACT) to 46, all heuristic. Unresolved calls
rose 3059 -> 3181, which is the intended trade: fewer invented edges,
more admitted gaps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs
serve ignored graph_path. It passed no GraphFile, so the server fell
back to ./.tracescope/graph.json and silently ignored the configured
path that analyze, why and hotspots all honour via loadGraph.

The server advertised GET /api/why, which was never registered and
returned 404. Replaced with the routes that do exist (/api/repo,
/api/analyze/diff).

The HTTP server had no timeouts — the zero-value http.Server has none,
so a stalled client could hold a connection open indefinitely. Write
and idle timeouts are generous because analysing a large diff is slow.

SaveCache wrote a fixed path+".tmp", so two indexes against the same
tree could rename a half-written cache into place. Uses a unique temp
file now, matching graph.Store.Save.

A Python class whose name could not be extracted returned without
walking its body, dropping every call inside it from the graph.

git log fields were separated by "|", which an author name may contain;
that shifted every later field and left the date unparseable. Uses 0x1f.
The dead alternate-format fallback went with it — %aI is RFC3339, so a
failure means the field was not a date, and the zero time renders as
"unknown" rather than inventing a timestamp.

Not fixed, deliberately: FileMetadata.ParsedAt is set from time.Now()
and never read anywhere, so graph.json differs on every index even when
nothing changed. Removing it is a schema change for a cosmetic gain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs
The audit that produced this page found two defects in the graph
builder, and fixing them changed the graph it describes. Every figure
here is re-derived from a rebuild at HEAD.

  nodes        614 -> 659      (repo also grew)
  CALLS edges  928 -> 766      duplicates removed
  unresolved  3059 -> 3204     chained JS/TS calls no longer mis-bind
  rated HIGH     7 -> 6        four phantoms out, three real ones in

The gate scenario changed outright: Codeowners.Match reached HIGH only
because six call sites from four callers were counted as six callers.
Replaced with cleanDiffPath -> ParseUnifiedDiff, which is HIGH on five
genuine production callers.

The stale-graph stage no longer claims the warning fires today — the
artifact is now bound to HEAD. It records what it looked like when it
did fire instead, which is a better demonstration than an assertion.

Dropped the mis-resolution example that named doublestar.Match binding
to Codeowners.Match: that edge no longer exists, because it was one of
the fabricated ones the parser fix removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3274c1fa5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/graph/builder.go
Comment on lines 781 to +782
if id, status := resolveUnique(sameFileMatches); id != "" {
return id, EdgeConfidenceExact, resolutionResolved
return id, bareConfidence, resolutionResolved

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop resolving receiver calls through the bare-name fallback

When a receiver-aware lookup fails, this still lets calls such as logger.output.write() resolve to an unrelated, uniquely named local write function; changing bareConfidence to heuristic only relabels the fabricated CALLS edge, and graph traversal and caller counting still consume it. This means the chained JS/TS false-positive that the parser change is intended to eliminate remains whenever the property name has a same-file match; unresolved receiver calls should not use this bare-name fallback, at least for JS/TS chained or unnameable receivers.

Useful? React with 👍 / 👎.

Comment on lines +306 to +307
G.callSites = G.rExact + G.rHeur + G.rAmb + G.rUnres; // 4031
G.resolvedPct = Math.round((G.calls / G.callSites) * 1000) / 10;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Calculate the resolution rate from resolved call sites

After CALLS-edge deduplication, G.calls is the number of distinct caller/callee edges while G.rExact and G.rHeur count resolved call sites, so dividing G.calls by all call sites mixes incompatible units and displays 18.0% instead of approximately 23.5% for the values above. Use (G.rExact + G.rHeur) / G.callSites; the adjacent 4031 comment is also inconsistent with the listed counters, which sum to 4246.

Useful? React with 👍 / 👎.

@Anuragp22

Anuragp22 commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

TraceScope — Blast Radius

Metric Value
Changed files 16
Changed functions 28
Affected functions 57
Risk 1 high, 7 medium, 7 low
Resolution confidence 853 exact, 150 heuristic, 46 ambiguous skipped, 3234 unresolved
Max depth 5

Reviewer Focus

Prioritize high-score public/runtime paths first; manually inspect heuristic or ambiguous paths before merging.

Score Risk Function File Owner Confidence Why Impact path Inspect first
112 HIGH Analyze internal/analyzer/blast_radius.go:95 Anuragp22 exact exported function with 5 production callers Score -> Analyze Check API contract and direct callers first.
74 MEDIUM LookupFileAuthors internal/ownership/git_log.go:22 Anuragp22 exact direct exported dependency (1 production callers) gitLogLastAuthor -> LookupFileAuthors Check API contract and direct callers first.
74 MEDIUM PostGitHubComment internal/output/github.go:273 Anuragp22 exact direct exported dependency (1 production callers) FormatMarkdownComment -> PostGitHubComment Check API contract and direct callers first.
73 MEDIUM ResolveOwnership internal/ownership/ownership.go:16 Anuragp22 exact moderately connected (3 production callers) gitLogLastAuthor -> LookupFileAuthors -> ResolveOwnership Inspect implementation and covered tests along this path.
68 MEDIUM Parse internal/parser/python.go:21 Anuragp22 exact exported/public function walk -> Parse Check API contract and direct callers first.

Resolution Diagnostics

Status Kind Location Symbol Receiver Detail
unresolved call cmd/tracescope/main.go:13 Fprintln fmt no call target matched parser output and import bindings
unresolved call cmd/tracescope/main.go:15 Exit os no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:57 Sprintf fmt no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:100 len - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:101 len - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:114 make - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:114 len - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:114 len - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:115 make - no call target matched parser output and import bindings
unresolved call internal/analyzer/blast_radius.go:117 make - no call target matched parser output and import bindings

Showing first 10 of 200 diagnostics.

🔴 High Risk

Function File Score Callers Depth Confidence Why path
Analyze internal/analyzer/blast_radius.go:95 112 12 1 exact Score -> Analyze

🟡 Medium Risk

Function File Score Callers Depth Confidence Why path
LookupFileAuthors internal/ownership/git_log.go:22 74 1 1 exact gitLogLastAuthor -> LookupFileAuthors
PostGitHubComment internal/output/github.go:273 74 1 1 exact FormatMarkdownComment -> PostGitHubComment
ResolveOwnership internal/ownership/ownership.go:16 73 3 2 exact gitLogLastAuthor -> LookupFileAuthors -> ResolveOwnership
Parse internal/parser/python.go:21 68 0 1 exact walk -> Parse
Parse internal/parser/javascript.go:20 62 0 2 exact parseCallExpr -> walk -> Parse
Parse internal/parser/typescript.go:25 62 0 2 exact parseCallExpr -> walk -> Parse
Run internal/eval/eval.go:86 57 3 3 heuristic Score -> Analyze -> replayCommit -> Run
🟢 Low Risk (7)
Function File Score Callers Depth Confidence Why path
walk internal/parser/typescript.go:49 47 2 1 exact parseCallExpr -> walk
walk internal/parser/javascript.go:39 47 2 1 exact parseCallExpr -> walk
walkForCalls internal/parser/javascript.go:240 47 2 1 exact parseCallExpr -> walkForCalls
walkForTSCalls internal/parser/typescript.go:303 47 2 1 exact parseCallExpr -> walkForTSCalls
buildGraphAt internal/eval/eval.go:266 36 1 1 heuristic Build -> buildGraphAt
BenchmarkComputeBlastRadius_LargeFanInGraph internal/graph/builder_benchmark_test.go:25 32 0 1 exact Build -> BenchmarkComputeBlastRadius_LargeFanInGraph
TestBuildFromSCIP_MatchesParserFallbackBlastRadiusShape internal/graph/scip_test.go:118 32 0 1 exact Build -> TestBuildFromSCIP_MatchesParserFallbackBlastRadiusShape

Showing top 15 of 57 affected functions.


Generated by TraceScope

Running TraceScope's own PR review over this branch surfaced the
placeholder in user-facing output:

  unresolved call ?.filter  (web/app/analyze/page.tsx:37)
  unresolved call ?.split   (web/app/analyze/page.tsx:56)

Those are remote.split("/").filter(Boolean) and parts.slice(idx).join("/")
— methods chained onto a call result. Treating them as having a receiver
is correct, and unresolved is the honest answer. But "?" renders as
"?.filter", which reads as JavaScript optional chaining and points a
reviewer at a language feature that is not there.

Named the sentinel "(expr)" behind a documented constant shared by both
parsers, and pinned it in the test so it cannot drift back to something
that collides with JS syntax.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d80e51de88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +293 to +294
default:
return right

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve expression roots in nested member receivers

When a chain is rooted in an expression, such as make().client.send(), the recursive call returns an empty left for make() and this branch returns only client. The resolver then treats that property as a real receiver and can bind send to an imported client or a qualified client.send definition, fabricating an exact or heuristic CALLS edge. Propagate the unnameable-expression state instead of converting the trailing property into a standalone receiver.

Useful? React with 👍 / 👎.

Comment thread internal/cmd/serve.go
Comment on lines 46 to +49
return server.ListenAndServe(server.Config{
Host: serveHost,
Port: servePort,
Host: serveHost,
Port: servePort,
GraphFile: graphFile,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Pass the repository root when serving an ancestor graph

When serve is launched from a repository subdirectory, config loading finds the ancestor .tracescope.yaml and this now loads its graph correctly, but RepoRoot remains unset, so ListenAndServe defaults it to the subdirectory. For SCIP graphs whose node paths are repository-relative, ownership runs git log with those paths relative to the subdirectory and misses authors, while LoadCodeowners also searches below the actual root; resolve and pass RepoRoot alongside GraphFile.

Useful? React with 👍 / 👎.

Graph nodes store absolute paths from whichever machine ran
`tracescope index`, and FormatMarkdownComment printed them raw. Running
the tool on its own pull request put this in a public comment:

  C:\Users\anura\Downloads\Devlopment\TraceScope\internal\analyzer\blast_radius.go:95

Render paths relative to the working directory instead — in CI that is
the repository root — across the risk tables and the diagnostics table.
The terminal output already did this via shortPathCwd; the markdown
surface never got the same treatment.

A path that is not under the base keeps its absolute form rather than
becoming a ../../ fragment that looks repo-relative but is not. Every
path is slash-normalised either way, so Windows separators cannot reach
the comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8PMni59sFvQFLAQyABGEs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61f2b7404f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/graph/builder.go
Comment on lines +366 to +370
if confidence == EdgeConfidenceHeuristic {
gd.ResolutionStats.HeuristicCallEdges++
} else {
gd.ResolutionStats.ExactCallEdges++
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep resolution counters consistent across graph backends

When a caller invokes the same callee multiple times, these counters increment before the pair is deduplicated, so exact_call_edges/heuristic_call_edges count call sites even though the parser graph contains one edge; the SCIP backend instead increments them only after its edge-set deduplication. A mixed heuristic/exact pair can even increment both counters while the surviving edge is exact, making the persisted resolution statistics and terminal/GitHub confidence summaries backend-dependent. Count the surviving edge once (adjusting on confidence upgrade), or introduce explicitly named call-site counters for the per-reference diagnostics.

Useful? React with 👍 / 👎.

@Anuragp22
Anuragp22 merged commit ad6c837 into master Jul 28, 2026
4 of 5 checks passed
@Anuragp22
Anuragp22 deleted the fix/graph-correctness-audit branch July 28, 2026 12:04
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