`loadProjectAliases()` read only the root `tsconfig.json` / `jsconfig.json` own `compilerOptions`, so an Nx-style monorepo — every alias declared in a `tsconfig.base.json` — got `null` back and every cross-package import fell through to name-based matching. Silently: no unresolved-import warning, and the results still look precise. Two things were missing, and either one alone leaves a common Nx layout broken: Fold the `extends` chain into the effective options before building the alias map. Relative and `node_modules` package specifiers both resolve, the nearest config wins (tsc replaces `paths` rather than merging), and a config already on the current chain is not re-entered, so `a extends b extends a` terminates instead of recursing forever. `paths` are anchored at `baseUrl` when one is declared — itself relative to the config that declared it — and otherwise at the directory of the config that declared the `paths`, which is what tsc does and what keeps an inherited `src/*` from being read as root-relative. Read `tsconfig.base.json` as a last candidate. A root `tsconfig.json` is still authoritative when it exists and reaches the base through `extends`; the fallback covers the layouts where that never happens — a solution-style root config (`references`, no `extends`, no `paths`, which is what nx's own repository ships) or no root `tsconfig.json` at all. A candidate that contributes no aliases no longer shadows a later one that does. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Colby McHenry <me@colbymchenry.com>
This commit is contained in:
co-authored by
Claude Opus 5
Colby McHenry
parent
9219967e43
commit
474f051d3c
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user