diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d8ab6f79..ead36fba7 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) + - C, C++, Objective-C and Rust unions are now indexed as first-class `union` nodes. A `union` declaration previously produced no symbol at all, so it never appeared in search or `codegraph_explore`, and anything attached to it disappeared with it — in Rust, every `impl SomeTrait for MyUnion` lost its edge, the methods from that impl were left pointing at a type the graph did not contain, and asking which types implement a trait quietly skipped the union ones. A union-shaped dispatch table in C now resolves its function pointers like a struct-shaped one. A `typedef union { … } Name;` in C keeps the typedef's name and is no longer mistaken for a plain type alias. Thanks @ctype-lab. Re-index after upgrading to pick up unions in existing projects. (#1515) - A long-lived index no longer drifts away from what a fresh `codegraph index` would produce. When a file gained or lost a symbol, references to that name in files the sync never touched kept pointing at the definition that was correct before the change, and — because nothing distinguished two same-named definitions — the winner could come down to the order files happened to be written, which differs between a full index and a sync. On this project's own repository, replaying 80 commits through `sync` left 5.7% of connections wrong; it is now 1.3%, and the wrong-answers-still-being-asserted half drops by 99.7%. Since call edges are what flow questions follow and what `codegraph_explore` ranks files by, this quietly degraded answers as an index aged, with nothing to indicate it. Syncing is unchanged in speed, and an edit that only changes a function's body does no extra work at all. Set `CODEGRAPH_NO_REBIND=1` to opt out. 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