Skip to content

Commit 41900ad

Browse files
bloveclaude
andauthored
fix(tsconfig): stop four library tsconfigs from shadowing the workspace baseUrl (#1071)
`libs/cockpit-runtime-bridge`, `libs/growth`, `libs/example-layouts` and `libs/cockpit-registry` each re-declared `"baseUrl": "."` while extending `tsconfig.base.json` and declaring no `paths` of their own. `baseUrl` outranks the implicit `pathsBasePath`, so every inherited substitution from the base `paths` map — which is workspace-root-relative, e.g. `libs/design-tokens/src/index.ts` — was probed under the library folder instead of the repo root. `npx tsc --traceResolution` on a probe file in each library, before: 'baseUrl' option is set to '<root>/libs/example-layouts', using this value to resolve non-relative module name '@threadplane/design-tokens'. Trying substitution 'libs/design-tokens/src/index.ts', candidate module location: 'libs/design-tokens/src/index.ts'. File '<root>/libs/example-layouts/libs/design-tokens/src/index.ts' does not exist. Loading module '@threadplane/design-tokens' from 'node_modules' folder… Module name '@threadplane/design-tokens' was successfully resolved to '<root>/libs/design-tokens/src/index.ts' with Package ID '@threadplane/design-tokens/src/index.ts@0.0.35'. and after: 'baseUrl' option is set to '<root>', using this value to resolve non-relative module name '@threadplane/design-tokens'. Trying substitution 'libs/design-tokens/src/index.ts', candidate module location: 'libs/design-tokens/src/index.ts'. File '<root>/libs/design-tokens/src/index.ts' exists - use it as a name resolution result. Module name '@threadplane/design-tokens' was successfully resolved to '<root>/libs/design-tokens/src/index.ts'. The builds were green either way only because the npm workspace symlink under `node_modules/@threadplane/*` happens to land on the same source file; the `Package ID` in the before-trace is the tell that resolution went through `node_modules` rather than the paths map. `baseUrl` in `tsconfig.base.json` itself stays: Nx's `createTmpTsConfig` writes build tsconfigs whose `paths` carry non-relative `dist/libs/...` entries, and with no `baseUrl` in the chain TypeScript raises TS5090 and discards the whole map. `libs/growth-capture` and `apps/growth-research` pair `baseUrl` with their own `"paths": {}`, where it does real directory-resolution work, and `apps/website` declares a complete `paths` of its own — all left alone. `scripts/tsconfig-path-inheritance.spec.mjs` guards both halves: a structural check that no library tsconfig inheriting the base `paths` re-declares `baseUrl`, and a resolution check through the TypeScript API asserting `@threadplane/design-tokens` resolves from each of the four libraries with no `packageId` — that is, through the paths map and not the node_modules symlink. Both assertions fail when the `baseUrl` line is put back. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5e2eafd commit 41900ad

5 files changed

Lines changed: 110 additions & 7 deletions

File tree

libs/cockpit-registry/tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
44
"composite": false,
5-
"emitDeclarationOnly": false,
6-
"baseUrl": "."
5+
"emitDeclarationOnly": false
76
},
87
"files": [],
98
"include": [],

libs/cockpit-runtime-bridge/tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
44
"composite": false,
5-
"emitDeclarationOnly": false,
6-
"baseUrl": "."
5+
"emitDeclarationOnly": false
76
},
87
"files": [],
98
"include": [],

libs/example-layouts/tsconfig.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
"noPropertyAccessFromIndexSignature": true,
66
"module": "preserve",
77
"emitDeclarationOnly": false,
8-
"composite": false,
9-
"baseUrl": "."
8+
"composite": false
109
},
1110
"angularCompilerOptions": {
1211
"enableI18nLegacyMessageIdFormat": false,

libs/growth/tsconfig.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{
22
"extends": "../../tsconfig.base.json",
33
"compilerOptions": {
4-
"baseUrl": ".",
54
"composite": false,
65
"emitDeclarationOnly": false
76
},
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
2+
import { dirname, join, relative, resolve } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
import ts from 'typescript';
6+
import { describe, expect, it } from 'vitest';
7+
8+
// Guard for a latent module-resolution defect.
9+
//
10+
// `tsconfig.base.json` declares the workspace `paths` map with entries that are
11+
// relative to the workspace root (`libs/design-tokens/src/index.ts`). A project
12+
// tsconfig that extends the base and re-declares `"baseUrl": "."` overrides the
13+
// inherited base directory, because `baseUrl` outranks the implicit
14+
// `pathsBasePath`. Every inherited substitution is then probed under the
15+
// *library* folder (`libs/<lib>/libs/design-tokens/src/index.ts`), misses, and
16+
// only resolves because the npm workspace symlink in `node_modules` happens to
17+
// land on the same source file. Builds stay green, so nothing catches it.
18+
//
19+
// A project that declares its own `paths` (including an empty `{}`) is exempt:
20+
// there `baseUrl` does real directory-resolution work and shadows nothing.
21+
22+
const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url)));
23+
const baseConfigPath = join(workspaceRoot, 'tsconfig.base.json');
24+
25+
function readConfig(configPath) {
26+
const parsed = ts.parseConfigFileTextToJson(configPath, readFileSync(configPath, 'utf8'));
27+
expect(parsed.error, `failed to parse ${relative(workspaceRoot, configPath)}`).toBeUndefined();
28+
return parsed.config ?? {};
29+
}
30+
31+
/**
32+
* Walk the `extends` chain from `configPath` upwards, stopping at (and
33+
* excluding) `tsconfig.base.json`. Returns null when the chain never reaches
34+
* the base config — such a project does not inherit the workspace `paths`.
35+
*/
36+
function chainBelowBase(configPath) {
37+
const chain = [];
38+
let current = configPath;
39+
for (let hop = 0; hop < 10; hop += 1) {
40+
if (current === baseConfigPath) return chain;
41+
const config = readConfig(current);
42+
chain.push({ path: current, config });
43+
if (typeof config.extends !== 'string') return null;
44+
const next = resolve(dirname(current), config.extends);
45+
current = existsSync(next) ? next : `${next}.json`;
46+
if (!existsSync(current)) return null;
47+
}
48+
return null;
49+
}
50+
51+
function libraryTsconfigs() {
52+
const found = [];
53+
for (const entry of readdirSync(join(workspaceRoot, 'libs'), { withFileTypes: true })) {
54+
if (!entry.isDirectory()) continue;
55+
const libDir = join(workspaceRoot, 'libs', entry.name);
56+
for (const file of readdirSync(libDir)) {
57+
if (file.startsWith('tsconfig') && file.endsWith('.json')) found.push(join(libDir, file));
58+
}
59+
}
60+
return found.sort();
61+
}
62+
63+
describe('library tsconfig path inheritance', () => {
64+
it('never shadows the workspace baseUrl the inherited paths map resolves against', () => {
65+
const offenders = [];
66+
for (const configPath of libraryTsconfigs()) {
67+
const chain = chainBelowBase(configPath);
68+
if (chain === null) continue;
69+
const declaresOwnPaths = chain.some((link) => link.config.compilerOptions?.paths !== undefined);
70+
if (declaresOwnPaths) continue;
71+
const shadowing = chain.find((link) => link.config.compilerOptions?.baseUrl !== undefined);
72+
if (shadowing) offenders.push(relative(workspaceRoot, shadowing.path));
73+
}
74+
expect([...new Set(offenders)]).toEqual([]);
75+
});
76+
77+
it.each([
78+
'libs/cockpit-runtime-bridge',
79+
'libs/growth',
80+
'libs/example-layouts',
81+
'libs/cockpit-registry',
82+
])('resolves @threadplane/design-tokens through the paths map from %s', (libDir) => {
83+
const configPath = join(workspaceRoot, libDir, 'tsconfig.json');
84+
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
85+
...ts.sys,
86+
onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
87+
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
88+
},
89+
getCurrentDirectory: () => workspaceRoot,
90+
useCaseSensitiveFileNames: true,
91+
});
92+
expect(parsed, `could not parse ${libDir}/tsconfig.json`).toBeDefined();
93+
94+
const containingFile = join(workspaceRoot, libDir, 'src', 'index.ts');
95+
const resolved = ts.resolveModuleName(
96+
'@threadplane/design-tokens',
97+
containingFile,
98+
parsed.options,
99+
ts.sys,
100+
).resolvedModule;
101+
102+
expect(resolved?.resolvedFileName).toBe(join(workspaceRoot, 'libs/design-tokens/src/index.ts'));
103+
// A `packageId` means TypeScript fell through the `paths` substitution and
104+
// found the source only via the npm workspace symlink under node_modules.
105+
expect(resolved?.packageId, 'resolved via node_modules symlink, not the paths map').toBeUndefined();
106+
});
107+
});

0 commit comments

Comments
 (0)