Skip to content

fix(impact): resolve tsconfig/jsconfig path aliases in the import graph - #158

Merged
CodeWithJuber merged 4 commits into
masterfrom
fix/impact-tsconfig-paths
Sep 24, 2026
Merged

CodeWithJuber merged 4 commits into
masterfrom
fix/impact-tsconfig-paths

Conversation

@CodeWithJuber

Copy link
Copy Markdown
Owner

What & why

Problem. An evaluation of forgekit against a real Next.js site rated this finding CRITICAL: "Impact graph ignores tsconfig path aliases (@/), so recall is 0% on Next.js repos and the stats hide it."

scope.resolveSpec returned null for every non-relative specifier. In that repo, 1,049 of 1,131 imports go through @/, and all of them were filed as external packages. The atlas reported resolved: 82, external: 1455, unresolved: 0, so the loss was invisible.

forge impact found:

  • 0 of 33 direct importers of src/components/v2/primitives.tsx
  • 0 of 22 of src/lib/whmcs.ts
  • 0 of 57 of src/lib/utils.ts

That left the substrate blast radius, the enforce gate's blast threshold, stale-doc hints and predictedTests empty, and they looked "safe".

Change.

  • src/scope.js
    • New parseJsonc(text), loadPathAliases(root) and matchPathAlias(spec, aliases).
    • resolveSpec(fromRel, spec, fileSet, aliases = []) now resolves a bare spec the way tsc does. It tries only the best-matching paths pattern (an exact match, otherwise the longest prefix), then the baseUrl lookup. Both use the same candidate expansion as relative imports: exact file, .js→.ts twin, extensionless, then index.*.
    • Config is read from the root tsconfig.json, or jsconfig.json when there is no tsconfig.
    • The JSONC parser skips string contents, so "**/*.ts" in include cannot swallow the "@/*" key the way a regex comment stripper does.
    • Relative extends is followed: a string or an array, with depth capped so a cycle stops. Like tsc, it tries the path as written before appending .json, so a .jsonc base or an extensionless base loads. baseUrl and paths are anchored the way tsc anchors them.
    • Aliases are passed through localImports and directedImportGraph, which scope, rank, collide and the docs repo-map use.
  • src/atlas.js
    • resolveEdges treats a spec under a local alias as local, so a missing file counts as unresolved/not-found, not external.
    • Three kinds of rule stay non-local, so a miss under them still counts as external: a bare *, the implicit baseUrl rule, and a rule whose targets all point into node_modules, dist or outside the repo.
    • build passes loadPathAliases(root).
    • ATLAS_VERSION goes from 3 to 4, so existing atlases rebuild.
  • bench/impact_cases.mjs: test/path_aliases.test.js imports and calls isStale, so it is added as a labeled dependent. Line references in the header comments are re-derived.
  • Docs:
    • docs/GUIDE.md (forge impact) says what is resolved and what is not. Not read: package bases, nested per-package tsconfigs, project references (the current Vite template's tsconfig.app.json), package.json imports and workspace packages.
    • ARCHITECTURE.md (atlas section) is updated.
    • The README "Impact quality" row now notes that forgekit only uses relative imports.
    • CHANGELOG.md has an entry under [Unreleased] > Fixed.

Measured recall of direct importers, before → after:

  • Next.js-like fixture (nextFiles in test/fixtures/impact_repos.mjs):
    • atlas recall: 1/13 → 13/13
    • import stats (resolved / external / unresolved / assets): 1/16/0/1 → 13/2/1/2. The one unresolved import is the intended missing @/lib/legacy-pricing.
  • Read-only copy of the evaluated site (src + tsconfig):
    • file-graph importer pairs: 81/1004 → 1004/1004
    • impact on src/lib/utils.ts: 0/66 → 66/66
    • src/lib/whmcs.ts: 0/22 → 22/22
    • src/components/v2/primitives.tsx: 1/135 → 135/135
    • atlas imports: resolved 83 / external 1279 → resolved 1010 / external 352. Every remaining external is a package; none start with @/.
  • forgekit itself, which has no paths: atlas stats and edge count are identical.
  • Bench impact quality on this repo (recomputed with evalImpact, no report written):
    • master: precision 0.178, recall 1.00, F1 0.298
    • this branch: precision 0.182, recall 1.00, F1 0.304
    • The README row (0.17 / 0.29) was not re-benchmarked here.

Tests added

  • test/path_aliases.test.js has 16 tests:
    • parseJsonc: comments, trailing commas, BOM, and no stripping inside strings.
    • @/* with a JSONC tsconfig.
    • ~/* through jsconfig.
    • Falling back from an unreadable tsconfig to jsconfig.
    • A baseUrl-only config.
    • extends: no extension, anchoring to the base config's directory, a child baseUrl, array order, a package base, and a cycle.
    • extends to a .jsonc base, and to an existing extensionless base.
    • A baseUrl or target outside the repo is never local.
    • Rule order, and which rules count as local.
    • Candidate expansion: the .js→.ts twin, index.*, second targets, no fall-through to shorter patterns, ?query, # aliases, $& treated literally, and escaping the repo.
    • localImports and the file graph.
    • Atlas recall of 13/13.
    • A missing alias target counts as unresolved.
    • A miss under a non-local rule (bare *, a node_modules target, the baseUrl fallback) stays external.
    • Editing tsconfig makes the atlas stale.
    • predictImpact end to end.
  • The Next.js-like fixture (nextFiles, NEXT_IMPORTERS) is in test/fixtures/impact_repos.mjs.

Checks run (on the committed HEAD, Node v22.22.2)

  • npm test: 1456 tests, 1453 pass, 0 fail, 3 skipped.
  • npm run check: exit 0, with 14 warnings and 2 infos. Master has the same count, and none are in the changed files.
  • npm run typecheck: exit 0.
  • node src/cli.js docs check: exit 0. It warns that the ARCHITECTURE.md repo-map block is out of date; master has the same warning.

Checklist

  • npm test passes (Node 18/20/22). It passes on Node 22.22.2 locally; Node 18 and 20 were not run, and CI covers 20 and 22.
  • npm run check passes (Biome lint + format)
  • New public functions have a test
  • Conventional commit message (feat:/fix:/docs: …)
  • CHANGELOG.md updated under ## [Unreleased]
  • No new runtime dependency (dev deps ok)
  • Substrate/docs updated if this changes forge substrate, forge impact, router/gate, or MCP substrate tools

Risk & rollback

  • Risk level: medium.
    • One resolver feeds scope, rank, collide, the docs repo-map, the atlas and forge impact/substrate, so the change reaches all of them.
    • In a repo whose root tsconfig or jsconfig declares paths or baseUrl, blast radii get much larger, as intended. The enforce gate's blast threshold can therefore trigger where it did not before.
    • Repos without aliases are unaffected. forgekit's own atlas is identical.
    • The atlas format version bump forces a one-time rebuild.
    • Known edge case, noted in a code comment: a paths rule that shadows a real package with an in-repo target counts a miss as unresolved, while tsc would fall through to node_modules. This changes a stats count only; no edge is invented.
  • Rollback plan: git revert the two commits (d70d510, 0b286c3). ATLAS_VERSION goes back to 3, so atlases rebuild on the next run. There is no data migration and nothing to clean up.

Extra checks (tick if applicable)

  • npm run typecheck passes
  • Input validated at boundaries; errors handled (no swallowing). paths values are validated: non-arrays, two-star patterns and absolute or drive-letter targets are dropped. An unreadable or malformed tsconfig is deliberately treated as having no aliases, falling back to jsconfig and then to [], and no warning is printed.
  • Authorization/ownership checked (if it touches access). Not applicable: no access control is involved.
  • Logs contain no secrets/PII. Not applicable: no logging was added.
  • If AI-assisted: I understand it, verified the package APIs, and it has tests. This change is AI-assisted and has tests, but a human reviewer should confirm the rest before ticking.

Found by an evaluation run against the HostLelo site (CodeWithJuber/my-next-app).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW


Generated by Claude Code

resolveSpec returned null for every non-relative specifier, so in a
Next.js-style repo that imports through `@/...` nearly every import was
filed as an external package: no file or symbol edges, empty blast radius,
and `unresolved: 0` in the atlas stats hiding it.

- scope.loadPathAliases(root): compilerOptions.paths + baseUrl from the
  root tsconfig.json (else jsconfig.json), parsed by a string-aware JSONC
  reader (parseJsonc) so "**/*.ts" in `include` cannot eat the "@/*" key.
  It follows relative `extends` (string or array, .json optional),
  anchoring baseUrl to the config that sets it and paths to the effective
  baseUrl, else the declaring config's directory, as tsc does.
- resolveSpec(fromRel, spec, fileSet, aliases = []): a bare spec tries the
  best `paths` pattern (exact, else the longest prefix), then the baseUrl
  lookup, through the same candidate expansion as relative specs
  (resolveFromRoot).
- atlas.resolveEdges marks a spec under a local alias as local, so a miss
  counts as unresolved, not external. A catch-all `*`, the implicit baseUrl
  rule, and aliases that only point into node_modules/dist/../ stay
  non-local.
- Aliases are threaded through scope.localImports/directedImportGraph
  (scope, rank, collide, docs repo-map) and atlas.build (impact,
  substrate). ATLAS_VERSION goes 3 -> 4 so existing graphs rebuild.

Measured recall of direct importers:
- Next.js-like fixture: 1/13 before, 13/13 after.
- Read-only copy of a real Next.js app: 81/1004 before, 1004/1004 after.
  Atlas imports went from resolved 83 / external 1279 to resolved 1010 /
  external 352 (packages only).
- forgekit itself (relative imports only): unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
… bases

Follow-up to the path-alias resolver, from review of the branch:

- bench/impact_cases.mjs: test/path_aliases.test.js imports and calls
  isStale, so the ground-truth check in test/eval.test.js failed with
  "unlabeled: ['test/path_aliases.test.js']". It is a real dependent, so
  it is labeled (isStale now has 7 files). The line references in the
  header comments are re-derived, since src/atlas.js moved.
- scope.readTsConfig: tsc tries an `extends` path as written and appends
  `.json` only when that is not a file. The old code always appended it,
  so `extends: "./tsconfig.base.jsonc"` (or an extensionless base on disk)
  was dropped along with its paths.
- Tests: a .jsonc and an extensionless base; a baseUrl or target outside
  the repo is never local; and an atlas-level check that a miss under a
  non-local rule (a bare `*`, a node_modules target, the baseUrl fallback)
  is still counted external.
- docs/GUIDE.md: the Vite claim was too broad. The current Vite template
  keeps its aliases in tsconfig.app.json behind project references, which
  are not read, so it is now listed as not supported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
Comment thread src/scope.js Fixed
Comment thread src/scope.js Fixed
CodeQL (js/incomplete-sanitization) flagged String#replace("*", …) in
resolveSpec as replacing only the first `*`. tsc allows at most one `*` per
paths target, so substitute it with indexOf/slice (which also keeps `$&`
sequences in the specifier literal) and drop targets with more than one `*`
in loadPathAliases, as tsc rejects them. Tests for both.

Signed-off-by: Claude <noreply@anthropic.com>
@CodeWithJuber
CodeWithJuber marked this pull request as ready for review September 24, 2026 04:19
…-paths

Signed-off-by: Claude <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
@CodeWithJuber
CodeWithJuber merged commit 300a922 into master Sep 24, 2026
12 checks passed
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.

3 participants