diff --git a/CHANGELOG.md b/CHANGELOG.md index ba97dd57f..d1821fac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Import aliases defined in a shared TypeScript config are now picked up. Nx-style monorepos keep every `@scope/...` alias in a `tsconfig.base.json` that the root `tsconfig.json` only inherits through `extends`, so CodeGraph found no aliases at all and every cross-package import fell back to matching on name alone — which quietly attaches results to unrelated symbols that happen to share a name, exactly where a monorepo needs `codegraph_impact` and `codegraph_callers` to be right. Chains several configs deep, a config inherited from a package in `node_modules`, and a `baseUrl` declared in an inherited config are all followed now, and a `tsconfig.base.json` is read directly when the root `tsconfig.json` is only a project-references shell or isn't there at all. Re-index after upgrading. (#1534) + - Looking a symbol up by name no longer reads the whole graph. Every search made one full pass over all indexed symbols for each word you typed, and a question that named several symbols made two more passes per name — including for a word that matches nothing, which is the common case. The cost therefore grew with the size of the project, and it was paid again on every message when the prompt hook is enabled. These lookups now go through the name index instead. Results are identical; only the time to get them changes, and it no longer grows with the project. - Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored. diff --git a/__tests__/tsconfig-extends-aliases.test.ts b/__tests__/tsconfig-extends-aliases.test.ts new file mode 100644 index 000000000..8010c0811 --- /dev/null +++ b/__tests__/tsconfig-extends-aliases.test.ts @@ -0,0 +1,182 @@ +/** + * `compilerOptions.paths` behind an `extends` chain (#1534). + * + * Nx-style TypeScript monorepos keep every alias in `tsconfig.base.json` and + * let the root `tsconfig.json` inherit it with a bare `"extends"`. The v1 + * loader read only the root file's own `compilerOptions`, so those repos got + * `null` back — every cross-package import fell through to name-based + * matching, silently, with no unresolved-import warning. + * + * What is locked in here: + * - `paths` is picked up through one and through several `extends` hops + * - `extends` targets resolve as relative paths (with or without `.json`), + * and as `node_modules` package specifiers + * - inherited `paths` resolve against the config that DECLARED them (a base + * config one directory down must not have its targets read as root-relative) + * - an explicit `baseUrl` still wins, and is itself relative to the file that + * declared it + * - the nearest config wins: a child's own `paths` replaces (not merges with) + * the parent's, which is what `tsc` does + * - a cyclic `extends` terminates instead of blowing the stack + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { loadProjectAliases, applyAliases } from '../src/resolution/path-aliases'; + +function write(file: string, content: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, typeof content === 'string' ? content : JSON.stringify(content, null, 2)); +} + +describe('tsconfig `extends` chains (#1534)', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tsextends-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('picks up paths from an extended tsconfig.base.json (Nx layout)', () => { + write(path.join(root, 'tsconfig.base.json'), { + compilerOptions: { + baseUrl: '.', + paths: { '@scope/lib-name': ['libs/lib-name/src/index.ts'], '@scope/*': ['libs/*/src/index.ts'] }, + }, + }); + write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.base.json', compilerOptions: {} }); + + const aliases = loadProjectAliases(root); + expect(aliases).not.toBeNull(); + expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']); + expect(applyAliases('@scope/other', aliases!, root)).toEqual(['libs/other/src/index.ts']); + }); + + it('follows a multi-hop chain and accepts an extensionless relative target', () => { + write(path.join(root, 'tsconfig.root.json'), { + compilerOptions: { baseUrl: '.', paths: { '@app/*': ['packages/*/src'] } }, + }); + write(path.join(root, 'tsconfig.mid.json'), { extends: './tsconfig.root' }); + write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.mid.json' }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('@app/ui', aliases!, root)).toEqual(['packages/ui/src']); + }); + + it('resolves an `extends` package specifier through node_modules', () => { + write(path.join(root, 'node_modules/@acme/tsconfig/tsconfig.json'), { + // Anchored at the package's own directory: node_modules/@acme/tsconfig + compilerOptions: { paths: { '@acme/*': ['../../../src/*'] } }, + }); + write(path.join(root, 'tsconfig.json'), { extends: '@acme/tsconfig' }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('@acme/thing', aliases!, root)).toEqual(['src/thing']); + }); + + it('resolves inherited paths against the config that declared them, not the root', () => { + // No baseUrl anywhere: tsc anchors `paths` at the declaring config's own + // directory. Reading `src/*` as root-relative would silently point every + // alias at the wrong tree. + write(path.join(root, 'config/tsconfig.base.json'), { + compilerOptions: { paths: { '~/*': ['src/*'] } }, + }); + write(path.join(root, 'tsconfig.json'), { extends: './config/tsconfig.base.json' }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('~/foo', aliases!, root)).toEqual(['config/src/foo']); + }); + + it('honours an inherited baseUrl relative to the file that declared it', () => { + write(path.join(root, 'config/tsconfig.base.json'), { + compilerOptions: { baseUrl: '..', paths: { '~/*': ['src/*'] } }, + }); + write(path.join(root, 'tsconfig.json'), { extends: './config/tsconfig.base.json' }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('~/foo', aliases!, root)).toEqual(['src/foo']); + }); + + it('lets the nearest config override inherited paths and baseUrl', () => { + write(path.join(root, 'tsconfig.base.json'), { + compilerOptions: { baseUrl: 'base-dir', paths: { '@x/*': ['from-base/*'] } }, + }); + write(path.join(root, 'tsconfig.json'), { + extends: './tsconfig.base.json', + compilerOptions: { baseUrl: 'own-dir', paths: { '@x/*': ['from-child/*'] } }, + }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('@x/y', aliases!, root)).toEqual(['own-dir/from-child/y']); + }); + + it('terminates on a cyclic extends chain and still uses what it reached', () => { + // The paths live INSIDE the cycle, so this only passes if the chain is + // actually walked — and only returns at all if the cycle is cut. + write(path.join(root, 'tsconfig.json'), { extends: './a.json' }); + write(path.join(root, 'a.json'), { + extends: './b.json', + compilerOptions: { paths: { '@cycle/*': ['from-a/*'] } }, + }); + write(path.join(root, 'b.json'), { extends: './a.json' }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('@cycle/x', aliases!, root)).toEqual(['from-a/x']); + }); + + it('falls back to tsconfig.base.json behind a solution-style root config', () => { + // What `nrwl/nx` itself ships: the root tsconfig.json is a project- + // references shell with no `extends` and no `paths`, so following the + // chain from it reaches nothing. The aliases are all in the base. + write(path.join(root, 'tsconfig.base.json'), { + compilerOptions: { baseUrl: '.', paths: { '@scope/*': ['libs/*/src/index.ts'] } }, + }); + write(path.join(root, 'tsconfig.json'), { + compileOnSave: false, + files: [], + include: [], + references: [{ path: './libs/lib-name' }], + }); + + const aliases = loadProjectAliases(root); + expect(aliases).not.toBeNull(); + expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']); + }); + + it('falls back to tsconfig.base.json when no root tsconfig.json exists', () => { + // The classic Nx integrated layout: only per-project tsconfigs and a + // base at the root. Nothing to follow an `extends` chain from. + write(path.join(root, 'tsconfig.base.json'), { + compilerOptions: { baseUrl: '.', paths: { '@scope/*': ['libs/*/src/index.ts'] } }, + }); + + const aliases = loadProjectAliases(root); + expect(aliases).not.toBeNull(); + expect(applyAliases('@scope/lib-name', aliases!, root)).toEqual(['libs/lib-name/src/index.ts']); + }); + + it('still prefers the root tsconfig.json when both files carry paths', () => { + // Precedence guard for the fallback: base is consulted only when the + // root config yields nothing. + write(path.join(root, 'tsconfig.base.json'), { + compilerOptions: { baseUrl: '.', paths: { '@x/*': ['from-base/*'] } }, + }); + write(path.join(root, 'tsconfig.json'), { + compilerOptions: { baseUrl: '.', paths: { '@x/*': ['from-root/*'] } }, + }); + + const aliases = loadProjectAliases(root); + expect(applyAliases('@x/y', aliases!, root)).toEqual(['from-root/y']); + }); + + it('still returns null when nothing in the chain declares paths', () => { + write(path.join(root, 'tsconfig.base.json'), { compilerOptions: { strict: true } }); + write(path.join(root, 'tsconfig.json'), { extends: './tsconfig.base.json' }); + + expect(loadProjectAliases(root)).toBeNull(); + }); +}); diff --git a/src/resolution/path-aliases.ts b/src/resolution/path-aliases.ts index 362baac75..73e366e7d 100644 --- a/src/resolution/path-aliases.ts +++ b/src/resolution/path-aliases.ts @@ -11,11 +11,13 @@ * ignored — every import through an alias was treated as unresolvable * unless it happened to match the small hard-coded fallback list. * - * Scope deliberately small for v1: - * - reads tsconfig.json, then jsconfig.json - * - honours top-level `compilerOptions.baseUrl` and `compilerOptions.paths` + * Scope: + * - reads tsconfig.json, then jsconfig.json, then tsconfig.base.json + * - honours `compilerOptions.baseUrl` and `compilerOptions.paths` + * - follows `extends` chains, nearest config wins (#1534) — Nx-style + * monorepos keep every alias in a `tsconfig.base.json` the root + * config merely inherits, so without this they resolved nothing * - supports `*` wildcard (the only TS-supported wildcard) - * - does NOT follow `extends` chains yet (most projects don't need it) * - does NOT read Vite/webpack/Rollup configs (separate follow-up) * * The file is parsed as JSON-with-comments-tolerant — tsconfigs in the @@ -104,12 +106,118 @@ function stripJsonc(src: string): string { } interface RawTsconfig { + extends?: string | string[]; compilerOptions?: { baseUrl?: string; paths?: Record; }; } +/** + * The `baseUrl`/`paths` a config ends up with once its `extends` chain has + * been folded in. `pathsDir` is the directory of the config that actually + * declared `paths` — with no `baseUrl` anywhere, tsc anchors the targets + * there, not at the project root. + */ +interface EffectiveOptions { + baseUrl?: string; + paths?: Record; + pathsDir?: string; +} + +/** Guards against a pathological chain; real ones are 1-3 deep. */ +const MAX_EXTENDS_DEPTH = 32; + +/** + * Locate an `extends` target the way tsc does: `./x`-style values are + * relative to the referencing config, anything else is a node_modules + * package specifier resolved by walking up from that config. A missing + * `.json` extension is implied, and a bare package name means its + * `tsconfig.json`. + */ +function resolveExtendsTarget(spec: string, fromDir: string): string | null { + const isFile = (p: string): boolean => { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } + }; + + if (spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec)) { + const base = path.resolve(fromDir, spec); + for (const cand of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) { + if (isFile(cand)) return cand; + } + return null; + } + + let dir = fromDir; + for (;;) { + const base = path.join(dir, 'node_modules', spec); + for (const cand of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) { + if (isFile(cand)) return cand; + } + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * Read `filePath` and fold its `extends` chain into a single set of + * effective options. Parents are applied first and the nearest config + * wins — tsc replaces `paths` wholesale rather than merging it. + * + * `stack` holds the configs currently being resolved, so a cycle + * (`a extends b extends a`) stops instead of recursing forever. + */ +function loadEffectiveOptions( + filePath: string, + stack: Set, + depth: number +): EffectiveOptions | null { + const abs = path.resolve(filePath); + if (stack.has(abs) || depth > MAX_EXTENDS_DEPTH) { + logDebug('path-aliases: extends chain cycle or too deep', { filePath: abs, depth }); + return null; + } + const raw = readTsconfigLike(abs); + if (!raw) return null; + + stack.add(abs); + const dir = path.dirname(abs); + const effective: EffectiveOptions = {}; + + const parents = typeof raw.extends === 'string' ? [raw.extends] : (raw.extends ?? []); + for (const spec of parents) { + if (typeof spec !== 'string') continue; + const target = resolveExtendsTarget(spec, dir); + if (!target) { + logDebug('path-aliases: unresolved extends', { from: abs, spec }); + continue; + } + const inherited = loadEffectiveOptions(target, stack, depth + 1); + if (!inherited) continue; + if (inherited.baseUrl !== undefined) effective.baseUrl = inherited.baseUrl; + if (inherited.paths !== undefined) { + effective.paths = inherited.paths; + effective.pathsDir = inherited.pathsDir; + } + } + stack.delete(abs); + + const co = raw.compilerOptions ?? {}; + // Both are relative to the file that declared them, not to whichever + // config started the chain. + if (typeof co.baseUrl === 'string') effective.baseUrl = path.resolve(dir, co.baseUrl); + if (co.paths && typeof co.paths === 'object') { + effective.paths = co.paths; + effective.pathsDir = dir; + } + return effective; +} + function readTsconfigLike(filePath: string): RawTsconfig | null { try { const raw = fs.readFileSync(filePath, 'utf-8'); @@ -143,26 +251,40 @@ function splitWildcard(pattern: string): { * resolver does it via {@link aliasCache}). */ export function loadProjectAliases(projectRoot: string): AliasMap | null { - const candidates = ['tsconfig.json', 'jsconfig.json']; - let raw: RawTsconfig | null = null; + // `tsconfig.base.json` comes last on purpose: when a root `tsconfig.json` + // exists it stays authoritative and reaches the base through `extends`. + // The fallback is for the Nx layouts where that never happens — a + // solution-style root config (`references`, no `extends`, no `paths`), or + // no root `tsconfig.json` at all. + const candidates = ['tsconfig.json', 'jsconfig.json', 'tsconfig.base.json']; + let effective: EffectiveOptions | null = null; let usedFile: string | null = null; for (const name of candidates) { const p = path.join(projectRoot, name); - if (fs.existsSync(p)) { - raw = readTsconfigLike(p); - if (raw) { - usedFile = name; - break; - } + if (!fs.existsSync(p)) continue; + const opts = loadEffectiveOptions(p, new Set(), 0); + if (!opts) continue; + // Remember the first readable config so a `paths`-less project still + // logs the file it was judged on, but keep looking: a config that + // contributes no aliases must not shadow one that does. + if (!effective) { + effective = opts; + usedFile = name; + } + if (opts.paths) { + effective = opts; + usedFile = name; + break; } } - if (!raw) return null; + if (!effective) return null; - const co = raw.compilerOptions ?? {}; - const baseUrlRel = co.baseUrl ?? '.'; - const baseUrl = path.resolve(projectRoot, baseUrlRel); + // With no explicit baseUrl, `paths` targets are relative to the config that + // declared them — which is the project root only when that config is the + // root one (the pre-`extends` assumption). + const baseUrl = effective.baseUrl ?? effective.pathsDir ?? projectRoot; - const paths = co.paths; + const paths = effective.paths; if (!paths || typeof paths !== 'object') { // baseUrl alone isn't an "alias" per se; with no paths we'd just // be redirecting the whole tree. Skip — the existing resolver