fix(impact): resolve tsconfig/jsconfig path aliases in the import graph - #158
Merged
Merged
Conversation
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>
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
marked this pull request as ready for review
September 24, 2026 04:19
…-paths Signed-off-by: Claude <noreply@anthropic.com> # Conflicts: # CHANGELOG.md
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.
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.resolveSpecreturnednullfor 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 reportedresolved: 82, external: 1455, unresolved: 0, so the loss was invisible.forge impactfound:src/components/v2/primitives.tsxsrc/lib/whmcs.tssrc/lib/utils.tsThat left the substrate blast radius, the enforce gate's blast threshold, stale-doc hints and
predictedTestsempty, and they looked "safe".Change.
src/scope.jsparseJsonc(text),loadPathAliases(root)andmatchPathAlias(spec, aliases).resolveSpec(fromRel, spec, fileSet, aliases = [])now resolves a bare spec the way tsc does. It tries only the best-matchingpathspattern (an exact match, otherwise the longest prefix), then thebaseUrllookup. Both use the same candidate expansion as relative imports: exact file,.js→.tstwin, extensionless, thenindex.*.tsconfig.json, orjsconfig.jsonwhen there is no tsconfig."**/*.ts"inincludecannot swallow the"@/*"key the way a regex comment stripper does.extendsis 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.jsoncbase or an extensionless base loads.baseUrlandpathsare anchored the way tsc anchors them.localImportsanddirectedImportGraph, which scope, rank, collide and the docs repo-map use.src/atlas.jsresolveEdgestreats a spec under a local alias as local, so a missing file counts asunresolved/not-found, notexternal.*, the implicitbaseUrlrule, and a rule whose targets all point into node_modules, dist or outside the repo.buildpassesloadPathAliases(root).ATLAS_VERSIONgoes from 3 to 4, so existing atlases rebuild.bench/impact_cases.mjs:test/path_aliases.test.jsimports and callsisStale, so it is added as a labeled dependent. Line references in the header comments are re-derived.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'stsconfig.app.json), package.jsonimportsand workspace packages.ARCHITECTURE.md(atlas section) is updated.CHANGELOG.mdhas an entry under[Unreleased]> Fixed.Measured recall of direct importers, before → after:
nextFilesintest/fixtures/impact_repos.mjs):@/lib/legacy-pricing.impactonsrc/lib/utils.ts: 0/66 → 66/66src/lib/whmcs.ts: 0/22 → 22/22src/components/v2/primitives.tsx: 1/135 → 135/135@/.paths: atlas stats and edge count are identical.evalImpact, no report written):Tests added
test/path_aliases.test.jshas 16 tests:parseJsonc: comments, trailing commas, BOM, and no stripping inside strings.@/*with a JSONC tsconfig.~/*through jsconfig.baseUrl-only config.extends: no extension, anchoring to the base config's directory, a childbaseUrl, array order, a package base, and a cycle.extendsto a.jsoncbase, and to an existing extensionless base.baseUrlor target outside the repo is never local..js→.tstwin,index.*, second targets, no fall-through to shorter patterns,?query,#aliases,$&treated literally, and escaping the repo.localImportsand the file graph.unresolved.*, a node_modules target, thebaseUrlfallback) staysexternal.predictImpactend to end.nextFiles,NEXT_IMPORTERS) is intest/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 testpasses (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 checkpasses (Biome lint + format)feat:/fix:/docs:…)CHANGELOG.mdupdated under## [Unreleased]forge substrate,forge impact, router/gate, or MCP substrate toolsRisk & rollback
forge impact/substrate, so the change reaches all of them.pathsorbaseUrl, blast radii get much larger, as intended. The enforce gate's blast threshold can therefore trigger where it did not before.pathsrule 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.git revertthe two commits (d70d510, 0b286c3).ATLAS_VERSIONgoes 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 typecheckpassespathsvalues 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.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