diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index ad67020c..63000d7c 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -482,6 +482,13 @@ as native skills through the plugin's `skills` directory.
`atlas.json` is the single source the impact, reuse-revalidation, and hallucination-flag
stages all read. There is no SQLite database and no `.forge/atlas.db`.
+Import specifiers resolve through **one** resolver, `src/scope.js` (`resolveSpec`), which
+the file graph (`scope`, `rank`, `collide`) and the symbol graph (`atlas`, `impact`) both
+call, so they never disagree about what a specifier points at. It follows relative paths
+and the repo-root tsconfig/jsconfig path aliases (`loadPathAliases`: `paths`, `baseUrl`,
+relative `extends`, JSONC). A spec under a local alias that misses is counted
+`unresolved`, not `external`.
+
The `RULES` table (`src/atlas.js`) is the ONE language registry — JS/TS, Python, Go,
Rust, Java, Ruby, C#, PHP, Kotlin, Swift, C/C++ as regex grammars (zero-dep; a real
parser would need tree-sitter, which the no-runtime-deps rule forbids). `CODE_EXTS =
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53e513e7..91661546 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+### Fixed
+
+- **`forge impact`, `forge atlas`, `forge scope` and `forge rank` now follow tsconfig/jsconfig path aliases.**
+ Before, every `@/…` import in a Next.js-style repo was filed as an external package, and
+ the stats reported `unresolved: 0`. On a real Next.js app, 1,004 local import pairs went
+ from 81 found (8%) to 1,004 (100%). The resolver now reads `compilerOptions.paths` and
+ `baseUrl` from the root `tsconfig.json` (else `jsconfig.json`). It parses JSONC without
+ mangling `"**/*.ts"` and follows relative `extends`. An aliased import that misses now
+ counts as unresolved, not external. The atlas format version is bumped, so existing
+ graphs rebuild.
+
## [1.3.1] - 2026-09-24
### Fixed
diff --git a/README.md b/README.md
index e1a1b302..66c785bd 100644
--- a/README.md
+++ b/README.md
@@ -350,7 +350,7 @@ The boundaries in the table below are part of each result.
| --- | ---: | --- |
| Warm impact query | 0.40 ms median | 30 runs on one JavaScript repository with a memoized adjacency index; not model latency |
| Deterministic substrate check | 886 ms median | 3 runs on one repository, warm graph, LLM disabled, on a 4-core Windows VM — wall-clock rows are machine-bound and were ~150 ms on the Linux host that produced the pre-2026-09-22 snapshot; re-run `npm run bench` on your own hardware |
-| Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository, scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 |
+| Impact quality | precision 0.17, recall 1.00, F1 0.29 (the precision 0.90 / F1 0.92 reported before 2026-09-21 do not reproduce) | 6 hand-labelled symbols in this repository (which imports only by relative path, so it does not exercise tsconfig path aliases), scored by `evalImpact` against labels re-derived by `git grep`; `impact` walks reverse dependencies transitively by default, so precision measures the transitive closure against direct-only labels; edited-file-only baseline recall 0.27 |
| Ledger replica merge | 4308 ms median | 3 runs merging two synthetic 500-claim replicas with 250 claims shared, on the same 4-core Windows VM (I/O-bound: 4–6x the Linux host's figure) |
| Python router live demonstration | 62.1% calculated cost reduction versus always-premium | 30 hand-labelled tasks, thresholds tuned to the set, real measured LLM tokens, approximate public prices; demonstration, not field benchmark |
| Python router, held-out evaluation | total spend 20.2% **higher** than always-premium; gate F1 0.37 | 80 tasks from real GitHub issues and PRs, thresholds frozen, pre-registered; refutes the row above |
diff --git a/bench/impact_cases.mjs b/bench/impact_cases.mjs
index 5d3fff19..f0ae7195 100644
--- a/bench/impact_cases.mjs
+++ b/bench/impact_cases.mjs
@@ -31,14 +31,15 @@
// - src/eval.js defines it (:28) (no other same-file caller)
// - test/eval.test.js imports { evalImpact } (:7) and calls it (:34) — the only referencer
//
-// isStale (src/atlas.js) — 6 files
-// - src/atlas.js defines it (:1026)
+// isStale (src/atlas.js) — 7 files
+// - src/atlas.js defines it (:1033)
// - src/verify.js imports { isStale } (:11) and calls it (:456)
// - src/doctor.js imports { isStale } (:18) and calls it (:249)
// - src/substrate.js imports it ALIASED (`isStale as atlasIsStale`, :11) and calls
// it twice (:177, :269) — an aliased import is still a reference
// - test/atlas.test.js imports { isStale } (:6) and calls it
// - test/atlas_resolve.test.js imports { isStale } (:11) and calls it (:187, :190)
+// - test/path_aliases.test.js imports { isStale } (:10) and calls it (:332, :334)
//
// mergeStates (src/ledger.js) — 4 files
// - src/ledger.js defines it (:796)
@@ -63,13 +64,13 @@
//
// contentHash (src/util.js) — 11 files. The widest fan-out in the set, and the case that
// used to carry a documented FALSE NEGATIVE: src/atlas.js binds it to an alias,
-// `const hash = contentHash;` at :187, with no call parentheses, and the old import regex
+// `const hash = contentHash;` at :190, with no call parentheses, and the old import regex
// captured module paths rather than named bindings, so no edge reached atlas.js. That is
// FIXED — a named import now resolves to the exact symbol node
-// (`src/atlas.js:17 imports → src/util.js:contentHash:65`), and atlas.js is predicted at
+// (`src/atlas.js:19 imports → src/util.js:contentHash:65`), and atlas.js is predicted at
// one hop. The case is kept for its fan-out, not for the miss.
// - src/util.js defines it (:65); slug() calls it (:28)
-// - src/atlas.js imports { contentHash } (:17), aliases it (:187)
+// - src/atlas.js imports { contentHash } (:19), aliases it (:190)
// - src/cortex_hook.js imports it (:9) and calls it (:98)
// - src/cost_report.js imports it (:14); routeRef() calls it (:212)
// - src/diagnose.js imports it (:15); failureSignature() calls it (:57)
@@ -102,6 +103,7 @@ export const IMPACT_CASES = [
"src/substrate.js",
"test/atlas.test.js",
"test/atlas_resolve.test.js",
+ "test/path_aliases.test.js",
],
editedFile: "src/atlas.js",
},
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index edae1233..dafce55f 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -357,6 +357,18 @@ instrument, not an everyday view: on forgekit itself the median answer goes from
to 78 of ~450 (recall 1.00, precision 0.09), so reach for it when you need "what could
conceivably be affected", not "what references this".
+Imports resolve the way TypeScript resolves them: relative specifiers, and the path
+aliases in the repo-root `tsconfig.json` (or `jsconfig.json` when there is no tsconfig)
+— `compilerOptions.paths` such as `@/*` or `~/*`, the `baseUrl` lookup, and relative
+`extends`. So an app whose root tsconfig declares `@/*` (the Next.js scaffold does) and
+imports through `@/lib/utils` gets the same edges as one that writes `../lib/utils`. An
+aliased import whose file does not exist counts as an unresolved local import (the
+`local import(s) … did not resolve` line, and `unresolvedImports` in `--json`), not as a
+package. Not read: package bases (`@tsconfig/next`), nested per-package tsconfigs,
+project references (a solution-style root with `files: []` and `references` to
+`tsconfig.app.json`, as the current Vite template ships, so its aliases are not seen),
+package.json `imports` (`#…`) and workspace packages.
+
```console
$ forge impact verifyToken
Forge impact — blast radius (hazard-aware)
diff --git a/src/atlas.js b/src/atlas.js
index 7d4a2883..1a1db42f 100644
--- a/src/atlas.js
+++ b/src/atlas.js
@@ -8,7 +8,9 @@ import { CALL_RE } from "./extract.js";
import {
jsImports,
lexOf,
+ loadPathAliases,
maskCode,
+ matchPathAlias,
pyImports,
pyModuleIndex,
resolvePyImport,
@@ -17,8 +19,9 @@ import {
import { contentHash, IGNORE_DIRS, toPosix } from "./util.js";
// Bumped whenever extraction or resolution changes shape: an atlas.json or per-file cache
-// from an older version is rebuilt, never trusted (v2 stored unresolved import specifiers).
-export const ATLAS_VERSION = 3;
+// from an older version is rebuilt, never trusted (v2 stored unresolved import specifiers;
+// v3 filed every tsconfig path-alias import as an external package).
+export const ATLAS_VERSION = 4;
const JS_RULES = [
{
@@ -779,9 +782,11 @@ function extractFile(path, root, preRead) {
/**
* Resolve raw edges against the whole graph.
* - imports: STRUCTURALLY — a JS/TS specifier through scope.resolveSpec (exact, NodeNext
- * `.js`→`.ts`, extensionless, `index.*`), a Python module through package-root qnames
- * (scope.pyModuleIndex). Never a bare-name guess: an import that does not resolve to a
- * file stays unresolved (counted), it is not pinned to whatever shares its last segment.
+ * `.js`→`.ts`, extensionless, `index.*`; relative, or through a tsconfig/jsconfig path
+ * alias), a Python module through package-root qnames (scope.pyModuleIndex). Never a
+ * bare-name guess: an import that does not resolve to a file stays unresolved (counted),
+ * it is not pinned to whatever shares its last segment. A spec under a local alias
+ * (`@/…`) that misses is unresolved, not external — it names a repo file that is absent.
* - calls/inherits: a definition in the same file, else a name this file imported, else a
* unique definition in the same LANGUAGE FAMILY. More than one candidate is ambiguous:
* the edge is dropped from traversal but marked and counted, never silently lost.
@@ -789,8 +794,9 @@ function extractFile(path, root, preRead) {
* @param {any[]} nodes
* @param {any[]} rawEdges
* @param {string[]} files repo-relative POSIX paths of every walked file
+ * @param {import("./scope.js").PathAlias[]} [aliases] scope.loadPathAliases(root)
*/
-function resolveEdges(nodes, rawEdges, files) {
+function resolveEdges(nodes, rawEdges, files, aliases = []) {
const fileSet = new Set(files);
const pyIndex = pyModuleIndex(files);
const localPyTops = new Set([...pyIndex.canonical.keys()].map((n) => n.split(".")[0]));
@@ -850,9 +856,10 @@ function resolveEdges(nodes, rawEdges, files) {
);
local = e.level > 0 || localPyTops.has(String(e.module).split(".")[0]);
} else {
- const file = resolveSpec(from, e.target, fileSet);
+ const file = resolveSpec(from, e.target, fileSet, aliases);
if (file) hits = [{ file, names: e.names || [] }];
- local = /^\.\.?(\/|$)/.test(e.target);
+ local =
+ /^\.\.?(\/|$)/.test(e.target) || Boolean(matchPathAlias(e.target, aliases)?.alias.local);
}
const base = {
source: e.source,
@@ -993,7 +1000,7 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) {
fileHashes[rel] = h;
rels.push(rel);
}
- const { edges, stats } = resolveEdges(nodes, rawEdges, rels);
+ const { edges, stats } = resolveEdges(nodes, rawEdges, rels, loadPathAliases(root));
const atlas = {
version: ATLAS_VERSION,
files: inv.files.length,
diff --git a/src/scope.js b/src/scope.js
index 683e2d2c..1f55e8ca 100644
--- a/src/scope.js
+++ b/src/scope.js
@@ -5,10 +5,10 @@
// approximate (dynamic/DI edges missed) — a real call-graph MCP is the upgrade seam.
//
// This module is also the ONE import resolver: atlas.js imports maskCode / jsImports /
-// pyImports / resolveSpec / pyModuleIndex from here, so the file graph (scope, rank, the
-// repo map) and the symbol graph (atlas, impact) can never disagree on what a specifier
-// points at.
-import { readdirSync, readFileSync } from "node:fs";
+// pyImports / resolveSpec / loadPathAliases / pyModuleIndex from here, so the file graph
+// (scope, rank, the repo map) and the symbol graph (atlas, impact) can never disagree on
+// what a specifier points at — tsconfig path aliases included.
+import { readdirSync, readFileSync, statSync } from "node:fs";
import { extname, join, posix, relative, resolve } from "node:path";
import { IGNORE_DIRS, SRC_EXT, toPosix } from "./util.js";
@@ -403,19 +403,63 @@ const TS_TWIN = {
};
/**
- * Resolve a relative JS/TS specifier the way Node + TypeScript do: exact file, the
- * NodeNext `.js`→`.ts` twin, extensionless, then `
/index.*`. Bare/package specifiers
- * return null (external — not a local edge).
+ * Resolve a JS/TS specifier the way Node + TypeScript do: exact file, the NodeNext
+ * `.js`→`.ts` twin, extensionless, then `/index.*`. A relative specifier resolves
+ * from the importing file; a bare one resolves only through the repo's tsconfig/jsconfig
+ * path aliases (loadPathAliases) — the best-matching `paths` pattern, then the `baseUrl`
+ * fallback. Anything else (a package) returns null: external, not a local edge.
* @param {string} fromRel importing file, repo-relative POSIX
* @param {string} spec
* @param {Set} fileSet repo-relative POSIX paths
+ * @param {PathAlias[]} [aliases] loadPathAliases(root) output
* @returns {string|null}
*/
-export function resolveSpec(fromRel, spec, fileSet) {
- if (!spec.startsWith("./") && !spec.startsWith("../") && spec !== "." && spec !== "..")
- return null;
- const raw = posix.normalize(posix.join(posix.dirname(fromRel), spec.split(/[?#]/)[0]));
- if (raw.startsWith("../") || raw === "..") return null; // escapes the repo
+export function resolveSpec(fromRel, spec, fileSet, aliases = []) {
+ if (spec.startsWith("./") || spec.startsWith("../") || spec === "." || spec === "..")
+ return resolveFromRoot(posix.join(posix.dirname(fromRel), stripQuery(spec)), fileSet);
+ if (!aliases.length) return null;
+ // TypeScript's order: only the BEST `paths` pattern is tried (exact, else the longest
+ // prefix), each of its targets in turn; when none is on disk, the baseUrl lookup.
+ // Function replacers: a `$&` or `$'` in the captured text is literal, not a pattern.
+ const hit = matchPathAlias(spec, aliases);
+ const tries = hit
+ ? hit.alias.targets.map((t) => (hit.alias.star ? substituteStar(t, hit.rest) : t))
+ : [];
+ for (const a of aliases)
+ if (a.fallback) for (const t of a.targets) tries.push(substituteStar(t, stripQuery(spec)));
+ for (const t of tries) {
+ const file = resolveFromRoot(t, fileSet);
+ if (file) return file;
+ }
+ return null;
+}
+
+/**
+ * Put `value` where a `paths` target's `*` is. tsc allows at most one `*` per target
+ * (loadPathAliases drops the rest), so there is exactly one or none; slicing at it
+ * (rather than String#replace) also keeps `$&`-style sequences in `value` literal.
+ * @param {string} target
+ * @param {string} value
+ * @returns {string}
+ */
+export function substituteStar(target, value) {
+ const i = target.indexOf("*");
+ return i < 0 ? target : target.slice(0, i) + value + target.slice(i + 1);
+}
+
+/** `x.svg?react` / `x.js#frag` → the path part. A LEADING `#` is an alias (`#lib/x`), kept. */
+const stripQuery = (spec) => spec.slice(0, 1) + spec.slice(1).split(/[?#]/)[0];
+
+/**
+ * The candidate expansion relative and aliased specifiers share: a repo-relative path →
+ * the file on disk it names, or null (missing, or outside the repo).
+ * @param {string} path
+ * @param {Set} fileSet
+ * @returns {string|null}
+ */
+function resolveFromRoot(path, fileSet) {
+ const raw = posix.normalize(path);
+ if (raw.startsWith("../") || raw === ".." || posix.isAbsolute(raw)) return null; // escapes the repo
const base = raw === "." ? "" : raw.replace(/\/$/, "");
const ext = posix.extname(base);
const cands = [base];
@@ -426,6 +470,196 @@ export function resolveSpec(fromRel, spec, fileSet) {
return null;
}
+// ---------------------------------------------------------------------------------------
+// Path aliases — tsconfig/jsconfig `compilerOptions.paths` + `baseUrl`. Next.js, Vite and
+// Remix scaffolds import through `@/…` / `~/…`; without these rules nearly every import in
+// such a repo resolved to nothing and was filed as an external package.
+// ---------------------------------------------------------------------------------------
+
+/**
+ * One alias rule, targets already repo-relative. `local`: a spec this rule matches names a
+ * repo file, so a miss is a broken local import (unresolved), not a package (external).
+ * The implicit baseUrl rule is `fallback` (tried last) and never `local` — it matches every
+ * bare specifier, `react` included.
+ * @typedef {{pattern:string, prefix:string, suffix:string, star:boolean, targets:string[],
+ * local:boolean, fallback?:boolean}} PathAlias
+ */
+
+/**
+ * JSON with comments and trailing commas (tsconfig's dialect) → a value. String-aware:
+ * `"**\/*.ts"` in an `include` array holds a `/*` … `*\/` pair that is NOT a comment (a
+ * regex stripper deletes the `"@/*"` key sitting between two of them).
+ * @param {string} text
+ * @returns {any} throws SyntaxError like JSON.parse on anything else malformed
+ */
+export function parseJsonc(text) {
+ let out = "";
+ const n = text.length;
+ for (let i = text.charCodeAt(0) === 0xfeff ? 1 : 0; i < n; i++) {
+ const c = text[i];
+ if (c === '"') {
+ let j = i + 1;
+ while (j < n && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
+ out += text.slice(i, j + 1);
+ i = j;
+ } else if (c === "/" && text[i + 1] === "/") {
+ while (i + 1 < n && text[i + 1] !== "\n") i++;
+ } else if (c === "/" && text[i + 1] === "*") {
+ const end = text.indexOf("*/", i + 2);
+ i = end < 0 ? n : end + 1;
+ out += " ";
+ } else if (c === ",") {
+ // A trailing comma: the next significant character closes the container. Comments
+ // were not yet stripped from the lookahead, so skip them here too.
+ let j = i + 1;
+ for (;;) {
+ while (j < n && /\s/.test(text[j])) j++;
+ if (text[j] === "/" && text[j + 1] === "/") while (j < n && text[j] !== "\n") j++;
+ else if (text[j] === "/" && text[j + 1] === "*") {
+ const end = text.indexOf("*/", j + 2);
+ j = end < 0 ? n : end + 2;
+ } else break;
+ }
+ if (text[j] !== "}" && text[j] !== "]") out += c;
+ } else out += c;
+ }
+ return JSON.parse(out);
+}
+
+// How far a chain of relative `extends` is followed (a cycle just stops here).
+const MAX_EXTENDS_DEPTH = 5;
+
+/** @param {string} abs */
+const isFile = (abs) => {
+ try {
+ return statSync(abs).isFile();
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * The `baseUrl` / `paths` a config ends up with after its relative `extends` chain,
+ * resolved the way tsc does: a child's option replaces its base's; `baseUrl` is relative to
+ * the config that sets it; `paths` resolve against the effective baseUrl, else against the
+ * directory of the config that declared them. Package bases (`@tsconfig/next`) live in
+ * node_modules and are not read.
+ * @param {string} root
+ * @param {string} rel repo-relative POSIX config path
+ * @param {number} depth
+ * @returns {{baseUrl?:string, paths?:Record, pathsBase?:string}|null}
+ */
+function readTsConfig(root, rel, depth) {
+ if (depth > MAX_EXTENDS_DEPTH) return null;
+ let cfg;
+ try {
+ cfg = parseJsonc(readFileSync(join(root, rel), "utf8"));
+ } catch {
+ return null;
+ }
+ if (!cfg || typeof cfg !== "object") return null;
+ const dir = posix.dirname(rel);
+ /** @type {{baseUrl?:string, paths?:Record, pathsBase?:string}} */
+ let out = {};
+ // TS 5 accepts an array of bases; later entries override earlier ones.
+ for (const ext of [cfg.extends].flat()) {
+ if (typeof ext !== "string" || !/^\.\.?\//.test(ext)) continue;
+ const lit = posix.normalize(posix.join(dir, toPosix(ext)));
+ if (lit.startsWith("../") || lit === "..") continue; // outside the repo
+ // tsc tries the path as written, and appends `.json` only when that is not a file —
+ // so `./tsconfig.base.jsonc` and an extensionless base that exists both load.
+ const p = lit.endsWith(".json") || isFile(join(root, lit)) ? lit : `${lit}.json`;
+ const base = readTsConfig(root, p, depth + 1);
+ if (base) out = { ...out, ...base };
+ }
+ const co = cfg.compilerOptions;
+ if (co && typeof co === "object") {
+ if (typeof co.baseUrl === "string")
+ out.baseUrl = posix.normalize(posix.join(dir, toPosix(co.baseUrl)));
+ if (co.paths && typeof co.paths === "object" && !Array.isArray(co.paths)) {
+ out.paths = co.paths;
+ out.pathsBase = dir;
+ }
+ }
+ return out;
+}
+
+/**
+ * The repo's module path aliases: `compilerOptions.paths` (+ `baseUrl`) from the root
+ * tsconfig.json, or jsconfig.json when there is no readable tsconfig (tsc's own order).
+ * JSONC-tolerant; follows relative `extends`. Rules come back in match order — exact
+ * patterns, then `*` patterns longest prefix first, then the implicit baseUrl rule.
+ * @param {string} root
+ * @returns {PathAlias[]}
+ */
+export function loadPathAliases(root) {
+ for (const name of ["tsconfig.json", "jsconfig.json"]) {
+ const opts = readTsConfig(root, name, 0);
+ if (!opts) continue;
+ const base = opts.baseUrl ?? opts.pathsBase ?? ".";
+ const inRepo = (t) =>
+ !t.startsWith("../") && t !== ".." && !t.split("/").some((seg) => IGNORE_DIRS.has(seg));
+ /** @type {PathAlias[]} */
+ const rules = [];
+ for (const [pattern, list] of Object.entries(opts.paths ?? {})) {
+ const star = pattern.indexOf("*");
+ if (!Array.isArray(list) || (star >= 0 && pattern.indexOf("*", star + 1) >= 0)) continue; // tsc: at most one `*`
+ const targets = list
+ // tsc rejects a substitution with more than one `*`; so do we.
+ .filter((t) => typeof t === "string" && !posix.isAbsolute(t) && !/^[A-Za-z]:/.test(t))
+ .filter((t) => t.indexOf("*") === t.lastIndexOf("*"))
+ .map((t) => posix.normalize(posix.join(base, toPosix(t))));
+ const prefix = star >= 0 ? pattern.slice(0, star) : pattern;
+ const suffix = star >= 0 ? pattern.slice(star + 1) : "";
+ // A bare `*` catches packages too, and a rule whose targets all sit outside the walk
+ // (node_modules, dist, ../) cannot tell a miss from a package: neither is `local`.
+ // Known edge: a rule that shadows a real package with an in-repo target
+ // (`lodash/*` → `src/shims/*`) counts a miss as unresolved, where tsc would fall
+ // through to node_modules. That only moves a stats count; no edge is invented.
+ const local = (star < 0 || prefix !== "" || suffix !== "") && targets.some(inRepo);
+ rules.push({ pattern, prefix, suffix, star: star >= 0, targets, local });
+ }
+ // Stable: equal-length prefixes keep their declaration order, as in tsc.
+ const rank = (a) => (a.star ? a.prefix.length : Number.MAX_SAFE_INTEGER);
+ rules.sort((a, b) => rank(b) - rank(a));
+ if (opts.baseUrl !== undefined)
+ rules.push({
+ pattern: "*",
+ prefix: "",
+ suffix: "",
+ star: true,
+ targets: [posix.join(opts.baseUrl, "*")],
+ local: false,
+ fallback: true,
+ });
+ return rules;
+ }
+ return [];
+}
+
+/**
+ * The `paths` rule a bare specifier falls under (the implicit baseUrl rule excluded), with
+ * what its `*` captured — or null when no pattern matches.
+ * @param {string} spec
+ * @param {PathAlias[]} aliases loadPathAliases order
+ * @returns {{alias: PathAlias, rest: string}|null}
+ */
+export function matchPathAlias(spec, aliases) {
+ const s = stripQuery(spec);
+ for (const a of aliases) {
+ if (a.fallback) continue;
+ if (!a.star) {
+ if (s === a.prefix) return { alias: a, rest: "" };
+ } else if (
+ s.length >= a.prefix.length + a.suffix.length &&
+ s.startsWith(a.prefix) &&
+ s.endsWith(a.suffix)
+ )
+ return { alias: a, rest: s.slice(a.prefix.length, s.length - a.suffix.length) };
+ }
+ return null;
+}
+
/**
* Python module index. A module's CANONICAL name is its dotted path from its package root
* (the directory above its top-most `__init__.py` package — `src/` in a src layout, the
@@ -566,9 +800,10 @@ export function resolvePyImport(fromRel, imp, index) {
* @param {string} text its source
* @param {Set} fileSet
* @param {ReturnType} [pyIndex]
+ * @param {PathAlias[]} [aliases] loadPathAliases(root) output
* @returns {Set}
*/
-export function localImports(rel, text, fileSet, pyIndex) {
+export function localImports(rel, text, fileSet, pyIndex, aliases = []) {
const ext = extname(rel);
const code = maskCode(text, ext);
const targets = new Set();
@@ -578,7 +813,7 @@ export function localImports(rel, text, fileSet, pyIndex) {
for (const r of resolvePyImport(rel, imp, index)) targets.add(r.file);
} else {
for (const imp of jsImports(code, text)) {
- const t = resolveSpec(rel, imp.spec, fileSet);
+ const t = resolveSpec(rel, imp.spec, fileSet, aliases);
if (t) targets.add(t);
}
}
@@ -619,6 +854,7 @@ export function directedImportGraph(root) {
walk(root, root, files);
const fileSet = new Set(files);
const pyIndex = pyModuleIndex(files);
+ const aliases = loadPathAliases(root);
const edges = new Map(files.map((f) => [f, new Set()]));
for (const f of files) {
let text = "";
@@ -627,7 +863,7 @@ export function directedImportGraph(root) {
} catch {
continue;
}
- edges.set(f, localImports(f, text, fileSet, pyIndex));
+ edges.set(f, localImports(f, text, fileSet, pyIndex, aliases));
}
return { nodes: files, edges };
}
diff --git a/test/fixtures/impact_repos.mjs b/test/fixtures/impact_repos.mjs
index f7c810cd..9f524f65 100644
--- a/test/fixtures/impact_repos.mjs
+++ b/test/fixtures/impact_repos.mjs
@@ -155,3 +155,66 @@ export const ts1Files = {
"src/y.ts": 'import { fx } from "./x.js";\nexport const fy = (n: number) => fx(n) + 1;\n',
"src/z.ts": 'export * from "./x.js";\n',
};
+
+// next — a Next.js-shaped repo whose code imports through the tsconfig `@/*` path alias
+// (as create-next-app scaffolds it), with one relative import mixed in. The tsconfig is
+// JSONC: comments, trailing commas, and an `include` whose "**/*.ts" contains `/*` and
+// `*/` — a naive comment regex would swallow the `"@/*"` key between them. Ground truth
+// below is every DIRECT importer, read off the sources. Traps: `@/lib/legacy-pricing` does
+// not exist (a broken LOCAL import: unresolved, not external), `next/link` and `clsx` are
+// packages (external), and the two stylesheets are assets.
+export const NEXT_IMPORTERS = {
+ "src/lib/utils.ts": [
+ "src/app/layout.tsx",
+ "src/app/pricing/page.tsx",
+ "src/components/header.tsx",
+ "src/components/ui/button.tsx",
+ "src/components/ui/card.tsx",
+ ],
+ "src/lib/whmcs.ts": [
+ "src/app/api/products/route.ts",
+ "src/app/page.tsx",
+ "src/app/pricing/page.tsx",
+ ],
+ "src/components/ui/button.tsx": ["src/components/header.tsx", "src/components/index.ts"],
+ "src/components/ui/card.tsx": ["src/app/page.tsx", "src/components/index.ts"],
+ "src/components/header.tsx": ["src/app/layout.tsx"],
+};
+export const nextFiles = {
+ "tsconfig.json": `{
+ // create-next-app defaults, plus the JSONC a hand-edited config accumulates
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "strict": true,
+ "jsx": "preserve", /* inline block comment */
+ "paths": {
+ "@/*": ["./src/*"],
+ },
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules"],
+}
+`,
+ "package.json":
+ '{ "name": "next-like", "dependencies": { "next": "16.0.0", "clsx": "2.1.1" } }\n',
+ "src/lib/utils.ts":
+ 'export function cn(...parts: string[]): string {\n return parts.filter(Boolean).join(" ");\n}\n',
+ "src/lib/whmcs.ts": "export async function getProducts(): Promise {\n return [];\n}\n",
+ "src/components/ui/button.tsx":
+ 'import { cn } from "@/lib/utils";\nexport function Button({ className }: { className?: string }) {\n return ;\n}\n',
+ "src/components/ui/card.tsx":
+ 'import { cn } from "@/lib/utils";\nexport function Card() {\n return ;\n}\n',
+ "src/components/header.tsx":
+ 'import Link from "next/link";\nimport { Button } from "@/components/ui/button";\nimport { cn } from "../lib/utils";\nexport function Header() {\n return (\n \n \n \n \n \n );\n}\n',
+ "src/components/index.ts":
+ 'export * from "@/components/ui/button";\nexport { Card } from "@/components/ui/card";\n',
+ "src/app/layout.tsx":
+ 'import "./globals.css";\nimport "@/styles/theme.css";\nimport { cn } from "@/lib/utils";\nimport { Header } from "@/components/header";\nexport default function RootLayout({ children }: { children: unknown }) {\n return (\n \n \n \n {children}\n \n \n );\n}\n',
+ "src/app/page.tsx":
+ 'import { Card } from "@/components/ui/card";\nimport { getProducts } from "@/lib/whmcs";\nexport default async function Page() {\n const products = await getProducts();\n return ;\n}\n',
+ "src/app/pricing/page.tsx":
+ 'import { clsx } from "clsx";\nimport { cn } from "@/lib/utils";\nimport { getProducts } from "@/lib/whmcs";\nimport { legacyTiers } from "@/lib/legacy-pricing";\nexport default async function Pricing() {\n const products = await getProducts();\n return ;\n}\n',
+ "src/app/api/products/route.ts":
+ 'import * as whmcs from "@/lib/whmcs";\nexport async function GET() {\n return Response.json(await whmcs.getProducts());\n}\n',
+};
diff --git a/test/path_aliases.test.js b/test/path_aliases.test.js
new file mode 100644
index 00000000..6b4ccba6
--- /dev/null
+++ b/test/path_aliases.test.js
@@ -0,0 +1,361 @@
+// tsconfig/jsconfig path aliases (`@/*`, `~/*`, baseUrl) in the ONE import resolver
+// (scope.resolveSpec) and everything built on it: the file graph (scope, rank, collide),
+// the symbol graph (atlas) and the blast radius (impact). Before this, a Next.js repo that
+// imports through `@/…` had nearly every import filed as an external package — no edges,
+// empty impact, and `unresolved: 0` in the stats hiding it.
+import assert from "node:assert/strict";
+import { writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { test } from "node:test";
+import { build, impact, isStale } from "../src/atlas.js";
+import {
+ directedImportGraph,
+ loadPathAliases,
+ localImports,
+ matchPathAlias,
+ parseJsonc,
+ resolveSpec,
+ substituteStar,
+} from "../src/scope.js";
+import { predictImpact } from "../src/substrate.js";
+import { NEXT_IMPORTERS, nextFiles, writeRepo } from "./fixtures/impact_repos.mjs";
+
+const tsconfig = (compilerOptions, extra = {}) => JSON.stringify({ compilerOptions, ...extra });
+
+// --- substituteStar ------------------------------------------------------------------
+
+test("substituteStar fills the one `*` and keeps replacement patterns literal", () => {
+ assert.equal(substituteStar("src/*", "lib/x"), "src/lib/x");
+ assert.equal(substituteStar("src/*.ts", "a"), "src/a.ts");
+ assert.equal(substituteStar("src/index.ts", "ignored"), "src/index.ts");
+ // `$&` / `$'` would be patterns in String#replace; here they stay as typed.
+ assert.equal(substituteStar("src/*", "$&$'"), "src/$&$'");
+});
+
+test("loadPathAliases drops a target with more than one `*`, as tsc does", () => {
+ const root = writeRepo({
+ "tsconfig.json": tsconfig({ baseUrl: ".", paths: { "@/*": ["src/*/*", "src/*"] } }),
+ "src/a.ts": "export const a = 1;\n",
+ });
+ const rule = loadPathAliases(root).find((r) => r.pattern === "@/*");
+ assert.deepEqual(rule?.targets, ["src/*"]);
+});
+
+// --- parseJsonc ---------------------------------------------------------------------
+
+test("parseJsonc strips comments and trailing commas, but never inside a string", () => {
+ const text = `\uFEFF{
+ // line comment with "quotes" and a trailing comma,
+ "paths": { "@/*": ["./src/*"], /* block */ },
+ "include": ["**/*.ts", "**/*.tsx",],
+ "url": "https://example.com/a//b", // a // inside a string is not a comment
+ "odd": "a,]b /* not a comment */ c\\"d",
+ }`;
+ assert.deepEqual(parseJsonc(text), {
+ paths: { "@/*": ["./src/*"] },
+ include: ["**/*.ts", "**/*.tsx"],
+ url: "https://example.com/a//b",
+ odd: 'a,]b /* not a comment */ c"d',
+ });
+ assert.throws(() => parseJsonc("{ nope }"), SyntaxError);
+});
+
+// --- loadPathAliases ------------------------------------------------------------------
+
+test('loadPathAliases keeps "@/*" when "**/*.ts" in include looks like a comment pair', () => {
+ const aliases = loadPathAliases(writeRepo(nextFiles));
+ assert.deepEqual(aliases, [
+ { pattern: "@/*", prefix: "@/", suffix: "", star: true, targets: ["src/*"], local: true },
+ ]);
+});
+
+test("loadPathAliases: ~/* from jsconfig.json when there is no tsconfig.json", () => {
+ const root = writeRepo({
+ "jsconfig.json": tsconfig({ paths: { "~/*": ["./app/*"] } }),
+ "app/utils/format.js": "export const fmt = (x) => String(x);\n",
+ "app/routes/index.jsx": 'import { fmt } from "~/utils/format";\nexport default () => fmt(1);\n',
+ });
+ const aliases = loadPathAliases(root);
+ assert.deepEqual(aliases[0].targets, ["app/*"]);
+ const g = directedImportGraph(root);
+ assert.ok(g.edges.get("app/routes/index.jsx").has("app/utils/format.js"));
+});
+
+test("loadPathAliases: an unreadable tsconfig.json falls through to jsconfig.json; none → []", () => {
+ const root = writeRepo({
+ "tsconfig.json": "{ this is not json",
+ "jsconfig.json": tsconfig({ paths: { "#lib/*": ["lib/*"] } }),
+ });
+ assert.equal(loadPathAliases(root)[0].pattern, "#lib/*");
+ assert.deepEqual(loadPathAliases(writeRepo({ "a.js": "" })), []);
+});
+
+test("loadPathAliases: baseUrl alone is a fallback rule that resolves bare paths but is not local", () => {
+ const root = writeRepo({ "tsconfig.json": tsconfig({ baseUrl: "src" }) });
+ const aliases = loadPathAliases(root);
+ assert.deepEqual(aliases, [
+ {
+ pattern: "*",
+ prefix: "",
+ suffix: "",
+ star: true,
+ targets: ["src/*"],
+ local: false,
+ fallback: true,
+ },
+ ]);
+ const files = new Set(["src/components/Button.tsx", "src/lib/api/index.ts"]);
+ assert.equal(
+ resolveSpec("src/app.tsx", "components/Button", files, aliases),
+ "src/components/Button.tsx",
+ );
+ assert.equal(resolveSpec("src/app.tsx", "lib/api", files, aliases), "src/lib/api/index.ts");
+ assert.equal(
+ resolveSpec("src/app.tsx", "react", files, aliases),
+ null,
+ "a package stays external",
+ );
+ assert.equal(matchPathAlias("react", aliases), null, "the baseUrl rule never marks a spec local");
+});
+
+test("loadPathAliases follows relative extends: paths resolve from the base config's dir", () => {
+ const root = writeRepo({
+ // no `.json` on the extends path, as tsc allows; the base sets its own baseUrl
+ "tsconfig.json": tsconfig({ strict: true }, { extends: "./config/tsconfig.base" }),
+ "config/tsconfig.base.json": tsconfig({ baseUrl: "..", paths: { "@app/*": ["src/*"] } }),
+ });
+ assert.deepEqual(loadPathAliases(root)[0].targets, ["src/*"]);
+ // `paths` with no baseUrl anywhere resolve against the config that DECLARED them.
+ const noBase = writeRepo({
+ "tsconfig.json": tsconfig({}, { extends: "./config/base.json" }),
+ "config/base.json": tsconfig({ paths: { "#lib/*": ["../lib/*"] } }),
+ });
+ assert.deepEqual(loadPathAliases(noBase)[0].targets, ["lib/*"]);
+ // A child's baseUrl re-anchors the base's paths; later array bases override earlier ones.
+ const child = writeRepo({
+ "tsconfig.json": tsconfig({ baseUrl: "packages" }, { extends: ["./a.json", "./b.json"] }),
+ "a.json": tsconfig({ paths: { "@x/*": ["old/*"] } }),
+ "b.json": tsconfig({ paths: { "@x/*": ["x/src/*"] } }),
+ });
+ assert.deepEqual(loadPathAliases(child).filter((a) => !a.fallback)[0].targets, [
+ "packages/x/src/*",
+ ]);
+ // A package base (node_modules) and a cycle are both survivable.
+ const cyc = writeRepo({
+ "tsconfig.json": tsconfig(
+ { paths: { "@/*": ["src/*"] } },
+ { extends: ["@tsconfig/next", "./loop.json"] },
+ ),
+ "loop.json": JSON.stringify({ extends: "./tsconfig.json" }),
+ });
+ assert.deepEqual(loadPathAliases(cyc)[0].targets, ["src/*"]);
+});
+
+test("loadPathAliases: extends tries the path as written before appending .json (as tsc does)", () => {
+ // A `.jsonc` base: appending `.json` blindly would look for `tsconfig.base.jsonc.json`.
+ const jsonc = writeRepo({
+ "tsconfig.json": tsconfig({}, { extends: "./tsconfig.base.jsonc" }),
+ "tsconfig.base.jsonc": `{ // shared\n "compilerOptions": { "paths": { "@/*": ["src/*"], }, }, }`,
+ });
+ assert.deepEqual(loadPathAliases(jsonc)[0].targets, ["src/*"]);
+ // An extensionless base that exists on disk wins over `.json`.
+ const bare = writeRepo({
+ "tsconfig.json": tsconfig({}, { extends: "./config/base" }),
+ "config/base": tsconfig({ paths: { "~/*": ["../app/*"] } }),
+ "config/base.json": tsconfig({ paths: { "~/*": ["../wrong/*"] } }),
+ });
+ assert.deepEqual(loadPathAliases(bare)[0].targets, ["app/*"]);
+});
+
+test("loadPathAliases: a baseUrl or target outside the repo is never local", () => {
+ const up = loadPathAliases(
+ writeRepo({ "tsconfig.json": tsconfig({ baseUrl: "..", paths: { "@/*": ["src/*"] } }) }),
+ );
+ assert.deepEqual(
+ up.map((a) => [a.pattern, a.targets, a.local]),
+ [
+ ["@/*", ["../src/*"], false],
+ ["*", ["../*"], false],
+ ],
+ );
+ const sibling = loadPathAliases(
+ writeRepo({
+ "tsconfig.json": tsconfig({ paths: { "@shared/*": ["../shared/*"], "@/*": ["./src/*"] } }),
+ }),
+ );
+ assert.deepEqual(
+ sibling.map((a) => [a.pattern, a.local]),
+ [
+ ["@shared/*", false],
+ ["@/*", true],
+ ],
+ );
+ assert.equal(resolveSpec("src/a.ts", "@shared/x", new Set(["shared/x.ts"]), sibling), null);
+});
+
+test("loadPathAliases orders exact, then longest prefix; catch-all and node_modules are not local", () => {
+ const aliases = loadPathAliases(
+ writeRepo({
+ "tsconfig.json": tsconfig({
+ paths: {
+ "*": ["types/*"],
+ "@/*": ["src/*"],
+ "@/ui/*": ["src/components/ui/*"],
+ config: ["src/config/index.ts"],
+ "vendored/*": ["node_modules/vendored/*"],
+ "a*b*": ["never/*"],
+ },
+ }),
+ }),
+ );
+ assert.deepEqual(
+ aliases.map((a) => a.pattern),
+ ["config", "vendored/*", "@/ui/*", "@/*", "*"],
+ "exact first, longest prefix next, two-star patterns dropped (tsc rejects them)",
+ );
+ const local = Object.fromEntries(aliases.map((a) => [a.pattern, a.local]));
+ assert.deepEqual(local, {
+ config: true,
+ "vendored/*": false,
+ "@/ui/*": true,
+ "@/*": true,
+ "*": false,
+ });
+ assert.deepEqual(matchPathAlias("@/ui/button?raw", aliases), {
+ alias: aliases[2],
+ rest: "button",
+ });
+});
+
+// --- resolveSpec with aliases ------------------------------------------------------------
+
+test("resolveSpec: an alias goes through the same candidate expansion as a relative spec", () => {
+ const aliases = loadPathAliases(
+ writeRepo({
+ "tsconfig.json": tsconfig({
+ baseUrl: ".",
+ paths: {
+ "@/*": ["src/*", "generated/*"],
+ "@/ui/*": ["src/components/ui/*"],
+ "#cfg": ["src/config/index.ts"],
+ },
+ }),
+ }),
+ );
+ const files = new Set([
+ "src/lib/utils.ts",
+ "src/lib/esm.ts",
+ "src/widgets/index.tsx",
+ "src/components/ui/button.tsx",
+ "src/ui/card.tsx",
+ "generated/schema.ts",
+ "src/config/index.ts",
+ "shared/tokens.ts",
+ "src/odd$&name.ts",
+ ]);
+ const r = (spec) => resolveSpec("src/app/page.tsx", spec, files, aliases);
+ assert.equal(r("@/lib/utils"), "src/lib/utils.ts", "extensionless");
+ assert.equal(r("@/lib/esm.js"), "src/lib/esm.ts", "NodeNext .js → .ts twin");
+ assert.equal(r("@/widgets"), "src/widgets/index.tsx", "directory index");
+ assert.equal(r("@/schema"), "generated/schema.ts", "second target when the first misses");
+ assert.equal(r("@/ui/button"), "src/components/ui/button.tsx", "longest prefix wins");
+ assert.equal(r("@/ui/card"), null, "tsc tries only the best pattern, not the shorter @/*");
+ assert.equal(
+ r("#cfg"),
+ "src/config/index.ts",
+ "an exact (star-less) pattern; # is not a fragment",
+ );
+ assert.equal(r("@/lib/utils?inline"), "src/lib/utils.ts", "a query suffix is stripped");
+ assert.equal(r("shared/tokens"), "shared/tokens.ts", "baseUrl fallback after paths");
+ assert.equal(r("@/../../etc/passwd"), null, "an alias cannot escape the repo");
+ assert.equal(r("@/odd$&name"), "src/odd$&name.ts", "`$&` in the capture is not a pattern");
+ assert.equal(resolveSpec("src/a.ts", "@/lib/utils", files), null, "no aliases → unchanged");
+});
+
+test("localImports and the file graph follow aliases (scope, rank and collide share it)", () => {
+ const root = writeRepo(nextFiles);
+ const g = directedImportGraph(root);
+ for (const [target, importers] of Object.entries(NEXT_IMPORTERS))
+ for (const f of importers) assert.ok(g.edges.get(f).has(target), `${f} → ${target}`);
+ const text = 'import { cn } from "@/lib/utils";\n';
+ const fileSet = new Set(["src/lib/utils.ts", "src/x.ts"]);
+ assert.deepEqual([...localImports("src/x.ts", text, fileSet)], [], "aliases are opt-in");
+ assert.deepEqual(
+ [...localImports("src/x.ts", text, fileSet, undefined, loadPathAliases(root))],
+ ["src/lib/utils.ts"],
+ );
+});
+
+// --- atlas + impact ---------------------------------------------------------------------
+
+test("atlas: a Next.js-style repo resolves every @/ import; recall 13/13 (was 1/13)", () => {
+ const atlas = build({ root: writeRepo(nextFiles) });
+ let pairs = 0;
+ for (const [target, importers] of Object.entries(NEXT_IMPORTERS)) {
+ const got = impact(atlas, target, { relations: ["reverse"] }).impactedFiles;
+ for (const f of importers) {
+ pairs += 1;
+ assert.ok(got.includes(f), `impact(${target}) missed ${f}`);
+ }
+ }
+ assert.equal(pairs, 13);
+ // Named imports through the alias bind call edges too.
+ assert.ok(impact(atlas, "getProducts").impactedFiles.includes("src/app/api/products/route.ts"));
+});
+
+test("atlas: an alias miss is counted unresolved (not external); packages and assets unchanged", () => {
+ const atlas = build({ root: writeRepo(nextFiles) });
+ assert.deepEqual(atlas.stats.imports, {
+ total: 18,
+ resolved: 13,
+ external: 2, // next/link, clsx
+ unresolved: 1, // @/lib/legacy-pricing
+ assets: 2, // ./globals.css, @/styles/theme.css
+ });
+ const miss = atlas.edges.find((e) => e.kind === "imports" && e.spec === "@/lib/legacy-pricing");
+ assert.equal(miss?.reason, "not-found");
+ assert.equal(miss?.external, undefined);
+});
+
+test("atlas: a miss under a NON-local rule (catch-all, node_modules target, baseUrl) stays external", () => {
+ const atlas = build({
+ root: writeRepo({
+ "tsconfig.json": tsconfig({
+ baseUrl: ".",
+ paths: {
+ "*": ["types/*"],
+ "vendored/*": ["node_modules/vendored/*"],
+ "@/*": ["src/*"],
+ },
+ }),
+ "src/lib/utils.ts": "export const cn = (s: string) => s;\n",
+ "src/app.ts":
+ 'import { cn } from "@/lib/utils";\nimport { v } from "vendored/thing";\nimport React from "react";\nimport { gone } from "@/lib/gone";\nexport const app = () => cn(String(v) + String(React) + String(gone));\n',
+ }),
+ });
+ assert.deepEqual(atlas.stats.imports, {
+ total: 4,
+ resolved: 1, // @/lib/utils
+ external: 2, // vendored/thing (node_modules target), react (catch-all + baseUrl)
+ unresolved: 1, // @/lib/gone
+ assets: 0,
+ });
+ const edge = (spec) => atlas.edges.find((e) => e.kind === "imports" && e.spec === spec);
+ assert.equal(edge("vendored/thing")?.external, true);
+ assert.equal(edge("react")?.external, true);
+ assert.equal(edge("@/lib/gone")?.reason, "not-found");
+});
+
+test("atlas: editing tsconfig paths makes the atlas stale, and the rebuild uses the new rules", () => {
+ const root = writeRepo(nextFiles);
+ const atlas = build({ root });
+ assert.equal(isStale(root, atlas), false);
+ writeFileSync(join(root, "tsconfig.json"), tsconfig({ paths: { "~/*": ["./src/*"] } }));
+ assert.equal(isStale(root, atlas), true);
+ assert.equal(build({ root }).stats.imports.resolved, 1, "only the relative import is left");
+});
+
+test("forge impact (predictImpact) reports alias importers end to end", () => {
+ const r = predictImpact(writeRepo(nextFiles), "src/lib/whmcs.ts", { basic: true, llm: false });
+ for (const f of NEXT_IMPORTERS["src/lib/whmcs.ts"]) assert.ok(r.impactedFiles.includes(f), f);
+});