Skip to content

fix(scanner): Resolve tsconfig alias targets written as "./*" - #175

Merged
JordanCoin merged 4 commits into
mainfrom
claude/codemap-graph-accuracy-173
Sep 4, 2026
Merged

fix(scanner): Resolve tsconfig alias targets written as "./*"#175
JordanCoin merged 4 commits into
mainfrom
claude/codemap-graph-accuracy-173

Conversation

@JordanCoin

@JordanCoin JordanCoin commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Second of the Graph accuracy milestone (#172). Fixes #173.

Your diagnosis was exact, so this mostly confirms it and adds one case you didn't mention.

What was wrong

resolvePathAlias substitutes the wildcard into the target, so "@/*": ["./*"] with @/lib/a1 produces ./lib/a1. The file index stores repository-relative paths with no ./ prefix, so tryExactMatch and trySuffixMatch both miss and the import resolves to nothing.

filepath.Join(baseURL, resolved) cleans the path, which is why this only ever bit projects without a baseUrl — and why "@/*": ["*"] works as the workaround.

The failure is silent. Nothing marks an unresolved alias, so a whole Next.js project reads as standalone files and the answer looks confident.

The fixture that proves it

testdata/tsconfig-alias-dotslash/ — create-next-app's default tsconfig.json, one lib/a1.ts, and two files importing it via @/lib/a1. On main:

{"file":"lib/a1.ts","importers":null,"importer_count":0,"is_hub":false}

After, the importers are app/layout.tsx and app/page.tsx — both of them, and nothing else. TestTsconfigDotSlashAliasResolvesImporters fails against unmodified resolvePathAlias with lib/a1.ts importers = [], want exactly [app/layout.tsx app/page.tsx].

Correction to an earlier version of this description: it showed that result as an ordered JSON array and called it an "exact list". The membership is exact; the order is not stable, and that is a pre-existing property of main rather than anything this PR introduces — see the comment below for twelve runs producing twelve orderings. The test compares as a set for that reason.

The case #173 didn't mention

The no-wildcard exact-alias branch has the identical bug — {"@app": ["./lib/a1"]} never resolved either. Same one-line fix, and it's covered by TestPathAliasTargetShapes/exact_alias_with_dot_slash, which also fails on main.

Every target shape, checked for regression

TestPathAliasTargetShapes pins all of them so this fix cannot break the forms that already worked. Against main, exactly the three broken ones fail and the three working ones pass:

target main after
"@/*": ["./*"]
"@/*": ["./src/*"]
"@app": ["./lib/a1"]
"@/*": ["*"]
"@/*": ["src/*"]
"@/*": ["./*"] + baseUrl

normalizeAliasTarget deliberately preserves ../shared/x rather than collapsing it, so a target escaping the project can't be rewritten into something that matches an unrelated file.

Per-ecosystem summary (for release notes)

Ecosystem Before After
TypeScript/JavaScript with tsconfig paths targets starting ./ (create-next-app default, no baseUrl) aliased imports resolved to nothing; files read as standalone resolved
All other alias shapes, and every other ecosystem unchanged unchanged

Notes

Verification

go vet ./... clean, gofmt clean, go test ./... at the main baseline (only the three known root-environment permission failures). Fixture verified end-to-end with a built binary for --importers and --importers --json, and the fixture test passes at -count=20.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo

create-next-app writes "paths": {"@/*": ["./*"]}, and has for years, so this
is the most common TypeScript layout in the wild. Substituting the wildcard
produced "./lib/a1" while the file index stores repository-relative paths with
no "./" prefix, so tryExactMatch and trySuffixMatch found nothing and a file
imported everywhere reported no importers at all.

The failure was silent: nothing marks an unresolved alias, so a whole Next.js
project read as standalone files with a confident answer.

Normalize the substituted target before matching. filepath.Join already
cleaned it whenever a baseUrl was set, which is why this only ever bit
projects without one, and why "@/*": ["*"] worked as a workaround.

The same normalization applies to the no-wildcard exact-alias branch, which
had the identical bug for targets like {"@app": ["./lib/a1"]}.

Relates to #173, #172

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo
Copilot AI lite review requested due to automatic review settings September 4, 2026 13:47

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

The graph appends importers in analysis order, which is not stable across
runs, so asserting a sequence made the alias fixture test fail on two CI
legs while passing locally. The exactness that matters here is membership:
neither a superset nor a subset of the expected importers.

The underlying nondeterminism is pre-existing and unrelated to the alias
fix; it is reported separately rather than papered over here.

Relates to #173, #172

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo

Copy link
Copy Markdown
Owner Author

CI went red on Test (macos-latest, 1.24) and Test (ubuntu-latest, 1.26) — my test, not the fix. Pushed 298dffd. But what it caught is worth more than the fix itself.

aliastarget_test.go:21: lib/a1.ts importers = [app/page.tsx app/layout.tsx],
                        want exactly [app/layout.tsx app/page.tsx]

Same members, different order. I'd asserted a sequence, so the test was reading a real property of the output as a failure.

--importers output order is nondeterministic on main

Not caused by this PR. Twelve consecutive runs of the same binary against the same unmodified fixture, on 8cda6ae:

app/sidebar.tsx,app/header.tsx,app/layout.tsx,app/footer.tsx,app/page.tsx
app/page.tsx,app/header.tsx,app/sidebar.tsx,app/footer.tsx,app/layout.tsx
app/page.tsx,app/header.tsx,app/layout.tsx,app/footer.tsx,app/sidebar.tsx
app/page.tsx,app/footer.tsx,app/header.tsx,app/layout.tsx,app/sidebar.tsx
app/layout.tsx,app/sidebar.tsx,app/page.tsx,app/header.tsx,app/footer.tsx
app/layout.tsx,app/sidebar.tsx,app/footer.tsx,app/page.tsx,app/header.tsx
app/header.tsx,app/footer.tsx,app/page.tsx,app/sidebar.tsx,app/layout.tsx
app/header.tsx,app/footer.tsx,app/page.tsx,app/layout.tsx,app/sidebar.tsx
app/header.tsx,app/footer.tsx,app/layout.tsx,app/page.tsx,app/sidebar.tsx
app/footer.tsx,app/layout.tsx,app/sidebar.tsx,app/header.tsx,app/page.tsx
app/footer.tsx,app/header.tsx,app/page.tsx,app/sidebar.tsx,app/layout.tsx
app/footer.tsx,app/header.tsx,app/layout.tsx,app/sidebar.tsx,app/page.tsx

Twelve runs, twelve orderings. fg.Importers[imported] = append(...) follows analysis order, and scanner/filegraph.go sorts nothing — there is no sort in the file at all. Imports has the same shape.

This matters for #172 beyond cosmetics: the milestone's exit test is "codemap --importers on the fixture returns the exact expected importer list", and no exact list assertion can hold while the sequence reshuffles every run. It's also the same family as #153 (blast-radius's [deps] section nondeterministic across identical runs), and it means anyone diffing codemap output between runs — or feeding it to an LLM expecting stable context — sees churn that isn't real.

What I did and didn't do

Here I only made the test compare membership rather than sequence, which is the exactness that this PR's fix is actually about (not a superset, not a subset). Sorting Imports/Importers in FileGraph is a two-line change and I'd expect it to be the right one, but it changes user-visible output ordering for everyone, so it wants its own PR, its own fixture, and your call — not a quiet ride-along in an alias fix. Say the word and it's next, before or after #147.

Full suite back at baseline; go vet and gofmt clean; the fixture test passes at -count=20.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Test (ubuntu-latest, 1.24) is red on 298dffd, and it's a different failure from the ordering one — codemap/scanner passed, so the alias fix and its test are fine. This one is not this PR's, and it's a real bug rather than a flaky test.

main_more_test.go:311: runWatchSubcommand(start) error =
  starting daemon: reading daemon readiness: unexpected end of JSON input,
  want child readiness failure

Not this PR's

This diff touches scanner/filegraph.go and a fixture. The failure is in the root package's watch-start plumbing. Compiled test binaries from both branches, alternated under CPU contention, 20 runs each:

main   : 20/20 failures
pr175  : 20/20 failures

Identical. Unloaded it's 0/15 on both, which is why local runs and most CI runs are green.

What's actually broken

waitWatchReadiness (main.go:1154) polls the readiness file and treats any successful read as final:

data, err := os.ReadFile(path)
if err == nil {
    var status watchReadiness
    if err := json.Unmarshal(data, &status); err != nil {
        return fmt.Errorf("reading daemon readiness: %w", err)   // ← gives up here
    }

A file that exists but is empty or half-written reads successfully with zero or partial bytes, so json.Unmarshal fails and the wait aborts. Only os.ErrNotExist is treated as "not ready yet".

Proved directly against the real function:

empty readiness file   -> err=reading daemon readiness: unexpected end of JSON input after 0s
partial readiness file -> err=reading daemon readiness: unexpected end of JSON input

Note the 0s — it doesn't wait out any part of the 30s timeout, it fails on the first poll. In the test the child is sh -c 'printf ... > $CODEMAP_WATCH_READINESS_FILE', and > truncates the file into existence before printf writes it; under load the poller lands in that window.

This isn't confined to the test. Any daemon that creates its readiness file non-atomically has the same window, so on a loaded machine codemap watch start can fail with unexpected end of JSON input for a daemon that started perfectly well — reporting a startup failure that didn't happen.

What I'm doing about it

Not fixing it inside this PR — it's main.go watch plumbing with no relationship to tsconfig aliases, and widening an alias fix to carry it would be the wrong shape. I'm opening it as its own small PR instead, so it fixes the root cause for every PR rather than just unblocking this one. Two halves:

  • Reader: treat an unparseable readiness file as not ready yet and keep polling until the deadline, rather than failing on the first poll. That's the robust half — it also covers a daemon writing non-atomically.
  • Writer: write the readiness file atomically via the existing runtimefile.WriteAtomic, so the window doesn't exist in the first place.

rerun-failed-jobs returns 403 for me, so I can't re-run this leg. I'll link the fix PR here when it's up and keep this one watched until it's green.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Correcting two things in my comment above before they mislead anyone.

1. The "20/20 failures on both branches" measurement was wrong. I ran relocated test binaries from outside the repo; from there they exit without running the test at all, so I was measuring my own harness, not the test. Redone properly — in-repo, via go test, under CPU contention — it's 0/10 on both main and this branch, and 15/15 pass unloaded. I cannot reproduce the CI failure locally at all.

The conclusion that it isn't this PR's still holds, on better evidence: the diff touches only scanner/filegraph.go and a fixture, codemap/scanner passed in that same CI run, and the failing test is in the root package's watch-start plumbing.

2. I overstated the production impact. I wrote that "on a loaded machine codemap watch start can fail for a daemon that started perfectly well". publishWatchReadiness already writes via temp-file + os.Rename, so codemap's own daemon never opens that window. The brittleness is reader-side only — real, but defence in depth rather than a user-facing startup bug.

What survives both corrections: the mechanism is confirmed. An empty or partial readiness file makes waitWatchReadiness return reading daemon readiness: unexpected end of JSON input after 0s, and that's the only code path producing the string CI reported. The test's child is sh -c 'printf … > "$CODEMAP_WATCH_READINESS_FILE"', and > truncates the file into existence before printf writes — exactly that window.

Fix is up as #177, standalone. It's right on its own terms — an unparseable file is a file still being written, not a failed daemon — but I'm not claiming it as proven to fix this leg, since I can't reproduce the failure to test against.

This PR's own head 298dffd has codemap/scanner green and the alias fix intact. rerun-failed-jobs still 403s for me, so I can't re-run the leg; keeping it watched.


Generated by Claude Code

claude and others added 2 commits September 4, 2026 14:08
Ported from #177, which I opened for this failure. Carrying it here so
this PR has its best chance of going green rather than waiting on that one
to merge; it becomes a no-op once main has it.

waitWatchReadiness returned on the first successful read, so a readiness
file caught mid-write failed json.Unmarshal and aborted the wait
immediately. Measured against the real function, an empty file returns
"reading daemon readiness: unexpected end of JSON input" after 0s, which
is the string TestRunWatchStartWaitsForChildReadinessFailure reported on
this PR's ubuntu-1.24 leg.

I could not reproduce that CI failure locally, so this is the plausible
cause rather than a proven one. The change is correct on its own terms
either way: an unparseable file is a file still being written, not a
daemon that failed.

Relates to #173, #172

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo
JordanCoin added a commit that referenced this pull request Sep 4, 2026
…olves

`--min-importers 1` hid every hazard on this repository, and the reason was
not the threshold. Go resolves imports at package level, and BuildFileGraph
deliberately drops an import that resolves to more than one file rather than
fanning it into an edge per file, so a file inside a multi-file Go package has
zero file-level importers by construction. `scanner/filegraph.go` scored 0 and
read as harmless. So did every same-package collision, including all six pairs
issue #134 verified by hand.

A shared file whose language resolves at package granularity is now weighted
as the files outside its package that import the package, plus the package's
other files, and the count is labelled `package importers` so it is not read
as a file-level number. Languages whose imports name files keep the file-level
count and the plain label. FileGraph.Packages is populated for Go and nothing
else, which is exactly the set this is correct for.

The cross-package term cannot come from the graph — the edges are the ones
that were dropped — so collide now keeps the scan outcome it was already
paying for and counts the raw import strings. ScanForDeps plus
BuildFileGraphFromOutcome is the same single scan BuildFileGraph was doing.

Real effect on this repository, where the default previously printed nothing:

  6 PRs  scanner/filegraph.go   73 package importers  <- #171, #174, #175, ...
  3 PRs  config/config.go       38 package importers  <- #171, #181, #182
  3 PRs  main.go                 6 package importers  <- #175, #179, #180

--min-importers now defaults to 0. A file two open PRs both change is a hazard
whatever its weight, and a default that hides hazards answers "no collisions"
on a repository full of them. The flag stays for narrowing a long list.

Three tests added: a Go fixture where two same-package files collide and carry
a non-zero package weight (with the self-import and third-party cases held
out of the count), a file-resolved language keeping file scope and its plain
label, and #134's six measured pairs proved unchanged by the weighting —
reordering them is allowed, adding or dropping one is not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcyheQmM3HCvxF5wRL3s5t
@JordanCoin
JordanCoin merged commit 4fc5412 into main Sep 4, 2026
12 checks passed
@JordanCoin
JordanCoin deleted the claude/codemap-graph-accuracy-173 branch September 4, 2026 14:46
JordanCoin added a commit that referenced this pull request Sep 4, 2026
#180)

* feat: codemap collide, rank open PRs by shared-file merge-order hazard

CI structurally cannot see cross-PR collisions: every PR is built against
main and never against its siblings. Issue #134 measured that blind spot by
merging six worktree pairs by hand, and #117/#118 shipped a miscompile
through it.

`codemap collide` reads open PRs through `gh pr list --json files`,
intersects their changed paths, and weights each shared file by the importer
count from the graph on the current checkout. The intersection is glue; the
weighting is the part that needs codemap, because only the graph knows that
a collision on a 23-importer hub is a different severity from one on a test
fixture.

Honesty rules, per the design principle in #134 (a composite inherits the
honesty of its primitives and states it with more authority):

- Importer counts are stated as facts only while graph coverage is complete.
  Degraded coverage prints "unknown importers", drops the verdict to
  TRUST LOW, and says ranking fell back to shared-file count.
- A "no collisions" answer from a degraded graph is TRUST LOW too: a negative
  finding from a partial graph is as unreliable as a positive one.
- Coverage attribution is whole-graph, not per-language. Narrowing "partial"
  to a subset of languages by matching free-text notes would hand back
  confidence the graph never claimed. #174's ResolvesFileLevelImports is the
  supported seam for per-language attribution; collideImportersKnown is the
  single function it belongs in.
- --min-importers never hides a file whose count is unknown, and never drops
  a hazard silently: the hidden count and the way to see them are printed.
- A file the graph carries no edges for at all (a YAML rule, a fixture)
  reports "not in graph" rather than a zero that reads as "nothing imports
  it".

Tests cover the pair/shared-file computation against issue #134's measured
4-PR matrix (6 of 6 pairs, including the 2-vs-3 distinction), the ranking
order, degraded coverage yielding TRUST LOW with unknown counts, and a golden
human output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcyheQmM3HCvxF5wRL3s5t

* fix(scanner): Keep the Rust fallback when cargo metadata times out

Ported verbatim from @reneleonhardt's open PR #171, which fixes this
already. Carrying it here so this PR can go green rather than waiting on
that one to merge; it becomes a no-op once main has it.

buildRustWorkspaceIndex shadowed its caller's ctx with the cargo-metadata
deadline, so once that deadline passed ctx.Err() returned DeadlineExceeded
and the whole graph build failed with a bare "context deadline exceeded"
instead of falling back to the manually derived Rust workspace. On a cold
or loaded runner three seconds is not always enough for cargo metadata, and
mcp/TestRustGraphContextHandlersDisclosePartialCoverage has now failed this
way on three separate pull requests.

Separating the metadata context from the caller's lets an expired deadline
break out of the loop and keep the fallback index, which is what the test
asserts and what a consumer needs: partial coverage disclosed, not a failed
graph.

Relates to #147, #172

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo
(cherry picked from commit 24af8fb)

* fix(watch): Treat an unparseable readiness file as not-ready-yet

waitWatchReadiness returned on the first successful read, so a readiness
file caught mid-write — existing but empty or partial — failed json.Unmarshal
and aborted the wait immediately, reporting a startup failure for a daemon
that had not finished writing. Only os.ErrNotExist counted as "not ready".

Measured against the real function: an empty file returns
"reading daemon readiness: unexpected end of JSON input" after 0s, without
waiting out any part of the 30s timeout.

Keep polling on a parse failure until the deadline, and surface the last
parse error when the deadline passes, so a file that never becomes valid
still says why rather than only that it timed out.

publishWatchReadiness already renames its payload into place atomically, so
codemap's own daemon does not open this window; the reader was brittle to
any writer that is not atomic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEUvjGsJemDFSV8nbxvxBo
(cherry picked from commit 35d2af3)

* fix(collide): Weight Go collisions at the granularity Go actually resolves

`--min-importers 1` hid every hazard on this repository, and the reason was
not the threshold. Go resolves imports at package level, and BuildFileGraph
deliberately drops an import that resolves to more than one file rather than
fanning it into an edge per file, so a file inside a multi-file Go package has
zero file-level importers by construction. `scanner/filegraph.go` scored 0 and
read as harmless. So did every same-package collision, including all six pairs
issue #134 verified by hand.

A shared file whose language resolves at package granularity is now weighted
as the files outside its package that import the package, plus the package's
other files, and the count is labelled `package importers` so it is not read
as a file-level number. Languages whose imports name files keep the file-level
count and the plain label. FileGraph.Packages is populated for Go and nothing
else, which is exactly the set this is correct for.

The cross-package term cannot come from the graph — the edges are the ones
that were dropped — so collide now keeps the scan outcome it was already
paying for and counts the raw import strings. ScanForDeps plus
BuildFileGraphFromOutcome is the same single scan BuildFileGraph was doing.

Real effect on this repository, where the default previously printed nothing:

  6 PRs  scanner/filegraph.go   73 package importers  <- #171, #174, #175, ...
  3 PRs  config/config.go       38 package importers  <- #171, #181, #182
  3 PRs  main.go                 6 package importers  <- #175, #179, #180

--min-importers now defaults to 0. A file two open PRs both change is a hazard
whatever its weight, and a default that hides hazards answers "no collisions"
on a repository full of them. The flag stays for narrowing a long list.

Three tests added: a Go fixture where two same-package files collide and carry
a non-zero package weight (with the self-import and third-party cases held
out of the count), a file-resolved language keeping file scope and its plain
label, and #134's six measured pairs proved unchanged by the weighting —
reordering them is allowed, adding or dropping one is not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcyheQmM3HCvxF5wRL3s5t

* fix(collide): rank a pair by its heaviest shared file, not the first one seen

Shared files sort by PR count first, so a pair colliding on a hub could be
reported by a fixture touched by more PRs and ranked below a lesser pair.
Found by independent review; the new test reproduces it and fails without
the change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TcyheQmM3HCvxF5wRL3s5t

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: r <r@r>
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.

scanner: tsconfig alias target "./*" (create-next-app default) never resolves; whole Next.js graph reads as standalone

3 participants