diff --git a/packages/adapters/javascript-typescript/src/usage/aliases.test.ts b/packages/adapters/javascript-typescript/src/usage/aliases.test.ts index 88f8cd77..1fe8dd32 100644 --- a/packages/adapters/javascript-typescript/src/usage/aliases.test.ts +++ b/packages/adapters/javascript-typescript/src/usage/aliases.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { memoryHandle } from "../testing/fs-handle.js"; -import { AliasResolver, MAX_CONFIG_BYTES, joinPath } from "./aliases.js"; +import { AliasResolver, MAX_CONFIG_BYTES, MAX_EXTENDS_DEPTH, joinPath } from "./aliases.js"; const resolver = (files: Record) => new AliasResolver(memoryHandle(files), Object.keys(files)); @@ -84,6 +84,215 @@ describe("AliasResolver", () => { assert.equal(await internal(files, "apps/b", "apps/b/src/y"), true); }); + it("merges every local base in an extends array, later entries per option (#862)", async () => { + // base.json sets baseUrl "."; config/paths.json sets paths pkg -> src/pkg.ts. + // Real TS 5.9.3 merges both bases: the substitution resolves against the + // merged root baseUrl, not against config/. + const files = { + "tsconfig.json": `{"extends":["./base.json","./config/paths.json"],"compilerOptions":{}}`, + "base.json": `{"compilerOptions":{"baseUrl":"."}}`, + "config/paths.json": `{"compilerOptions":{"paths":{"pkg":["src/pkg.ts"]}}}`, + "config/src/pkg.ts": "", + "node_modules/pkg/index.js": "", + }; + // src/pkg.ts does not exist at the root baseUrl, so pkg stays a package. + assert.equal(await internal(files, ".", "pkg"), false); + // With the target at the root baseUrl the merged alias does apply. + assert.equal(await internal({ ...files, "src/pkg.ts": "" }, ".", "pkg"), true); + }); + + it("merges a shared base reached through a diamond of extends (#862)", async () => { + const files = { + "tsconfig.json": `{"extends":["./b.json","./c.json"],"compilerOptions":{}}`, + "b.json": `{"extends":"./d.json"}`, + "c.json": `{"extends":"./d.json","compilerOptions":{"paths":{"@c/*":["src/*"]}}}`, + "d.json": `{"compilerOptions":{"baseUrl":"."}}`, + "src/x.ts": "", + "lib/y.ts": "", + }; + // c's paths resolve against d's baseUrl, inherited through both arms. + assert.equal(await internal(files, ".", "@c/x"), true); + assert.equal(await internal(files, ".", "lib/y"), true); + }); + + it("a shared base repeated across many arms counts once (#862)", async () => { + // 17 arms each extending shared.json, then last.json with baseUrl: 19 + // distinct bases, so nothing is capped. Real tsc 5.9.3 retains last's + // baseUrl and resolves pkg internally. + const files: Record = { + "tsconfig.json": `{"extends":[${Array.from({ length: 17 }, (_, i) => `"./arm${i}.json"`).join(",")},"./last.json"],"compilerOptions":{}}`, + "shared.json": "{}", + "last.json": `{"compilerOptions":{"baseUrl":"."}}`, + "pkg/index.ts": "", + }; + for (let i = 0; i < 17; i++) files[`arm${i}.json`] = `{"extends":"./shared.json"}`; + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.ok(config); + assert.equal(r.isInternal("pkg", config), true); + assert.deepEqual(r.limitations, []); + }); + + it("a cycle beside a repeated shared base keeps the distinct-base cache (#862)", async () => { + // cycle.json extends itself: the cycle is cut and noted, but the acyclic + // arms must keep their cache - 20 distinct bases, so nothing is capped. + const files: Record = { + "tsconfig.json": `{"extends":["./cycle.json",${Array.from({ length: 17 }, (_, i) => `"./arm${i}.json"`).join(",")},"./last.json"],"compilerOptions":{}}`, + "cycle.json": `{"extends":"./cycle.json"}`, + "shared.json": "{}", + "last.json": `{"compilerOptions":{"baseUrl":"."}}`, + "pkg/index.ts": "", + }; + for (let i = 0; i < 17; i++) files[`arm${i}.json`] = `{"extends":"./shared.json"}`; + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.ok(config); + assert.equal(r.isInternal("pkg", config), true); + assert.deepEqual( + r.limitations.map((e) => [e.kind, e.file]), + [["tsconfig-extends-cycle", "tsconfig.json"]], + ); + }); + + it("a cycle below a shared base does not leak across paths (#862)", async () => { + // b3 <-> b4: b4's merged baseUrl depends on the path taken, so no merge + // whose subtree touched the cycle may be cached. An uncached active-stack + // traversal ends with baseUrl d1 from the root's last base b2. + const files: Record = { + "tsconfig.json": `{"extends":["./b0.json","./b1.json","./b2.json"],"compilerOptions":{}}`, + "b0.json": `{"extends":["./b1.json","./b2.json"]}`, + "b1.json": `{"extends":"./b4.json","compilerOptions":{"baseUrl":"d1"}}`, + "b2.json": `{"extends":["./b3.json","./b0.json"]}`, + "b3.json": `{"extends":"./b4.json","compilerOptions":{"baseUrl":"d3"}}`, + "b4.json": `{"extends":"./b3.json"}`, + }; + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.ok(config); + assert.equal(config.baseUrl, "d1"); + assert.ok(r.limitations.some((e) => e.kind === "tsconfig-extends-cycle")); + }); + + it("a base reached deep then shallow is not served a depth-truncated cache (#862)", async () => { + // b0->...->b6->shared reaches shared at the depth cap, where its own base + // last is cut; the direct root->shared visit must still merge last. + const files: Record = { + "tsconfig.json": `{"extends":["./b0.json","./shared.json"],"compilerOptions":{}}`, + "shared.json": `{"extends":"./last.json"}`, + "last.json": `{"compilerOptions":{"baseUrl":"."}}`, + "pkg/index.ts": "", + }; + for (let i = 0; i < 7; i++) + files[`b${i}.json`] = `{"extends":"./${i === 6 ? "shared" : `b${i + 1}`}.json"}`; + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.ok(config); + assert.equal(r.isInternal("pkg", config), true); + assert.deepEqual( + r.limitations.map((e) => [e.kind, e.file]), + [["tsconfig-extends-too-deep", "tsconfig.json"]], + ); + }); + + it("bounds extends branching: too many bases fail closed with a limitation (#862)", async () => { + const files: Record = { + "tsconfig.json": `{"extends":[${Array.from({ length: 33 }, (_, i) => `"./b${i}.json"`).join(",")}],"compilerOptions":{}}`, + "pkg/index.ts": "", + }; + for (let i = 0; i < 33; i++) + files[`b${i}.json`] = i === 32 ? `{"compilerOptions":{"baseUrl":"."}}` : "{}"; + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.ok(config); + // The 33rd base carries baseUrl: past the breadth cap it is not read, so + // pkg is not internal and the run records the exhaustion. + assert.equal(r.isInternal("pkg", config), false); + assert.deepEqual( + r.limitations.map((e) => [e.kind, e.file]), + [["tsconfig-extends-too-broad", "tsconfig.json"]], + ); + }); + + it("cached extends merges match an uncached reference on random small graphs (#862)", async () => { + // Deterministic PRNG: a failure prints its graph and reproduces exactly. + const mulberry32 = (seed: number) => () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + interface Node { + extends: string[]; + baseUrl?: string; + } + // The uncached active-stack traversal every cached run must agree with. + // Entries are "./"-relative to root-level files, like the fixtures. + const reference = ( + graph: Record, + file: string, + stack: readonly string[], + ): string | undefined => { + if (stack.includes(file)) return undefined; + if (stack.length > MAX_EXTENDS_DEPTH) return undefined; + const node = graph[file]; + if (!node) return undefined; + let merged: string | undefined; + for (const base of node.extends) { + const b = reference(graph, base.slice(2), [...stack, file]); + if (b !== undefined) merged = b; + } + if (node.baseUrl !== undefined) merged = node.baseUrl; + return merged; + }; + const rand = mulberry32(862); + for (let g = 0; g < 1000; g++) { + const graph: Record = {}; + const files: Record = {}; + const emit = (name: string, bases: string[], baseUrl?: string) => { + const node: Node = { extends: bases }; + if (baseUrl !== undefined) node.baseUrl = baseUrl; + graph[name] = node; + files[name] = JSON.stringify({ + ...(bases.length ? { extends: bases } : {}), + ...(baseUrl !== undefined ? { compilerOptions: { baseUrl } } : {}), + }); + }; + if (rand() < 0.5) { + // Chain mode: a spine longer than MAX_EXTENDS_DEPTH plus random + // cross-links, so some visits hit the depth cap and others do not. + const m = 9 + Math.floor(rand() * 5); // 9..13 nodes + const cnames = Array.from({ length: m }, (_, i) => `c${i}.json`); + const cpick = (): string => `./${cnames[Math.floor(rand() * m)]!}`; + for (let i = 0; i < m; i++) { + const bases: string[] = []; + if (i + 1 < m) bases.push(`./c${i + 1}.json`); + if (rand() < 0.4) bases.push(cpick()); + emit(cnames[i]!, bases, rand() < 0.5 ? `d-c${i}` : undefined); + } + const roots = [`./${cnames[0]!}`]; + if (rand() < 0.6) roots.push(cpick()); // shallow revisit of a chain node + emit("tsconfig.json", roots, rand() < 0.3 ? "d-root" : undefined); + } else { + const count = 2 + Math.floor(rand() * 6); // 2..7 bases, caps never trip + const names = Array.from({ length: count }, (_, i) => `n${i}.json`); + const pick = (): string => + rand() < 0.15 ? "./missing.json" : `./${names[Math.floor(rand() * count)]!}`; + for (const name of [...names, "tsconfig.json"]) { + emit( + name, + Array.from({ length: Math.floor(rand() * 4) }, pick), + rand() < 0.5 ? `d-${name}` : undefined, + ); + } + } + const expected = reference(graph, "tsconfig.json", []); + const r = resolver(files); + const config = await r.configFor("tsconfig.json"); + assert.equal(config?.baseUrl, expected, `graph ${g}: ${JSON.stringify(graph)}`); + } + }); + it("follows extends into a workspace package (#145): subpath, bare name, tsconfig field", async () => { const files = { "package.json": `{"name":"root","private":true}`, diff --git a/packages/adapters/javascript-typescript/src/usage/aliases.ts b/packages/adapters/javascript-typescript/src/usage/aliases.ts index b5c07765..fa161a88 100644 --- a/packages/adapters/javascript-typescript/src/usage/aliases.ts +++ b/packages/adapters/javascript-typescript/src/usage/aliases.ts @@ -33,6 +33,8 @@ import type { Evidence, RepositoryHandle } from "@ghostdeps/core"; export const MAX_CONFIG_BYTES = 256 * 1024; /** Maximum `extends` chain length followed. */ export const MAX_EXTENDS_DEPTH = 8; +/** Maximum distinct `extends` bases merged per config resolution (#862). */ +export const MAX_EXTENDS_BASES = 32; /** Maximum `paths` entries honoured per config; the rest are ignored with a limitation. */ export const MAX_PATH_ENTRIES = 1_000; /** Maximum targets considered per `paths` entry. */ @@ -103,7 +105,7 @@ function own(record: Record, key: string): unknown { interface ParsedConfigFile { config: RawConfig; - extendsFile: string | undefined; + extendsFiles: string[]; } /** "" or "/" for a package-style extends value. */ @@ -139,6 +141,28 @@ interface RawConfig { } /** Alias resolution for one repository listing. Create one per scan. */ +/** A later config replaces only the options it defines. */ +function overrideOptions(target: RawConfig, source: RawConfig): void { + if (source.baseUrl !== undefined) target.baseUrl = source.baseUrl; + if (source.paths === undefined) return; + target.paths = source.paths; + if (source.pathsDir !== undefined) target.pathsDir = source.pathsDir; +} + +function cacheMerge( + cache: Map>, + file: string, + depth: number, + merged: RawConfig, +): void { + let byDepth = cache.get(file); + if (!byDepth) { + byDepth = new Map(); + cache.set(file, byDepth); + } + byDepth.set(depth, merged); +} + export class AliasResolver { private readonly files: Set; private readonly dirs: Set; @@ -251,43 +275,77 @@ export class AliasResolver { } private async resolveEffective(configFile: string): Promise { - const chain: RawConfig[] = []; + // TS 5 array extends: every resolvable base is merged in array order and + // a later base replaces only the options it defines (#862). The walk is a + // DAG, not a chain: cycles are detected against the current path, depth + // against MAX_EXTENDS_DEPTH, and breadth against MAX_EXTENDS_BASES counted + // in a DISTINCT-seen set, separate from the cache. Merges are cached per + // remaining depth budget (keyed by stack length, so a depth-truncated + // merge never serves a shallower visit and a full merge never serves a + // deeper one) and their effect still applied at every edge in order, so + // a shared base repeated across many arms costs one slot. A merge whose + // subtree touched a cycle cut depends on the path taken, so it is never + // cached and neither is any ancestor's merge; acyclic sibling subtrees + // still cache. All caps fail closed (the unmerged options stay unknown). + const cache = new Map>(); const seen = new Set(); - let current: string | undefined = configFile; - let depth = 0; - while (current !== undefined) { - if (seen.has(current)) { + let breadthNoted = false; + const merge = async ( + file: string, + stack: readonly string[], + ): Promise<{ config: RawConfig | undefined; cycleTainted: boolean }> => { + const hit = cache.get(file)?.get(stack.length); + if (hit !== undefined) return { config: hit, cycleTainted: false }; + if (stack.includes(file)) { this.limit( "tsconfig-extends-cycle", `${configFile} has a circular extends chain`, configFile, ); - break; + return { config: undefined, cycleTainted: true }; } - if (depth > MAX_EXTENDS_DEPTH) { + if (stack.length > MAX_EXTENDS_DEPTH) { this.limit( "tsconfig-extends-too-deep", `${configFile} extends more than ${MAX_EXTENDS_DEPTH} levels; the rest was not read`, configFile, ); - break; + return { config: undefined, cycleTainted: false }; } - seen.add(current); - depth += 1; - const parsed = await this.readRaw(current); - if (!parsed) break; - chain.push(parsed.config); - current = parsed.extendsFile; - } - // Nearest config wins: walk from the base outward. - const merged: RawConfig = {}; - for (const raw of chain.reverse()) { - if (raw.baseUrl !== undefined) merged.baseUrl = raw.baseUrl; - if (raw.paths !== undefined) { - merged.paths = raw.paths; - if (raw.pathsDir !== undefined) merged.pathsDir = raw.pathsDir; + if (stack.length > 0 && !seen.has(file)) { + if (seen.size >= MAX_EXTENDS_BASES) { + if (!breadthNoted) { + breadthNoted = true; + this.limit( + "tsconfig-extends-too-broad", + `${configFile} extends more than ${MAX_EXTENDS_BASES} distinct bases; the rest were not read`, + configFile, + ); + } + return { config: undefined, cycleTainted: false }; + } + seen.add(file); } - } + const parsed = await this.readRaw(file); + if (!parsed) return { config: undefined, cycleTainted: false }; + const merged: RawConfig = {}; + let tainted = false; + for (const base of parsed.extendsFiles) { + const res = await merge(base, [...stack, file]); + // A cycle cut anywhere below makes this merge path-dependent: it and + // every ancestor stay uncached, while sibling subtrees computed from + // scratch keep their cache. + if (res.cycleTainted) tainted = true; + if (res.config === undefined) continue; + overrideOptions(merged, res.config); + } + overrideOptions(merged, parsed.config); + if (!tainted) cacheMerge(cache, file, stack.length, merged); + return { config: merged, cycleTainted: tainted }; + }; + const root = await merge(configFile, []); + const merged = root.config; + if (merged === undefined) return undefined; const out: AliasConfig = { configFile, paths: merged.paths ?? [] }; if (merged.baseUrl !== undefined) out.baseUrl = merged.baseUrl; const pathsBase = merged.baseUrl ?? merged.pathsDir; @@ -370,26 +428,24 @@ export class AliasResolver { raw.pathsDir = dir; } } - return { config: raw, extendsFile: await this.localExtends(own(config, "extends"), dir, file) }; + return { + config: raw, + extendsFiles: await this.localExtends(own(config, "extends"), dir, file), + }; } /** - * The nearest `extends` base that is a listed repository file: a relative - * path, a path inside a workspace package of this repository (#145), or a - * file inside an installed node_modules package (#276). node_modules bases - * that are not installed are skipped and recorded for the run note. + * Every resolvable `extends` base, in array order (#862): relative paths, + * paths inside workspace packages of this repository (#145), or files + * inside installed node_modules packages (#276). node_modules bases that + * are not installed are skipped and recorded for the run note. */ - private async localExtends( - value: unknown, - dir: string, - file: string, - ): Promise { + private async localExtends(value: unknown, dir: string, file: string): Promise { const list = typeof value === "string" ? [value] : Array.isArray(value) ? value : []; - // TS 5 array extends: later entries override earlier ones, so the last local one is the nearest base. - let found: string | undefined; - // Every entry is looked at, so each unread package base is recorded even - // when a nearer base was found. - for (const entry of [...list].reverse()) { + const bases: string[] = []; + // TS 5 array extends: later entries override earlier ones per option, so + // every resolvable entry is returned for the merge. + for (const entry of list) { if (typeof entry !== "string") continue; if (entry.startsWith("./") || entry.startsWith("../")) { const target = joinPath(dir, entry); @@ -403,7 +459,7 @@ export class AliasResolver { // A relative path into node_modules: read it when the listing has // the file (installed); otherwise record the base for the run note. if (hit !== undefined) { - if (found === undefined) found = hit; + bases.push(hit); } else { const nm = target.split("/").indexOf("node_modules"); const pkg = packageName( @@ -416,24 +472,24 @@ export class AliasResolver { } continue; } - if (found !== undefined) continue; - if (hit !== undefined) found = hit; + if (hit !== undefined) bases.push(hit); continue; } const pkg = packageName(entry); if (pkg === undefined) continue; if ((await this.workspacePackages()).has(pkg)) { - if (found === undefined) found = await this.workspaceExtends(entry, file); + const resolved = await this.workspaceExtends(entry, file); + if (resolved !== undefined) bases.push(resolved); continue; } const resolved = await this.nodeModulesExtends(entry, dir); if (resolved !== undefined) { - if (found === undefined) found = resolved; + bases.push(resolved); } else { this.notePackageBase(pkg); } } - return found; + return bases; } /**