Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
182 changes: 182 additions & 0 deletions __tests__/tsconfig-extends-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
156 changes: 139 additions & 17 deletions src/resolution/path-aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,12 +106,118 @@ function stripJsonc(src: string): string {
}

interface RawTsconfig {
extends?: string | string[];
compilerOptions?: {
baseUrl?: string;
paths?: Record<string, string[]>;
};
}

/**
* 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<string, string[]>;
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<string>,
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');
Expand Down Expand Up @@ -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
Expand Down