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