From 14482e2ae696e9ea963be0314a0050a952cd3bb3 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 13:29:17 -0700 Subject: [PATCH] fix(tsconfig): stop four library tsconfigs from shadowing the workspace baseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 '/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 '/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 '/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 '', 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 '/libs/design-tokens/src/index.ts' exists - use it as a name resolution result. Module name '@threadplane/design-tokens' was successfully resolved to '/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 --- libs/cockpit-registry/tsconfig.json | 3 +- libs/cockpit-runtime-bridge/tsconfig.json | 3 +- libs/example-layouts/tsconfig.json | 3 +- libs/growth/tsconfig.json | 1 - scripts/tsconfig-path-inheritance.spec.mjs | 107 +++++++++++++++++++++ 5 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 scripts/tsconfig-path-inheritance.spec.mjs diff --git a/libs/cockpit-registry/tsconfig.json b/libs/cockpit-registry/tsconfig.json index 190834ef3..a29e3d9a4 100644 --- a/libs/cockpit-registry/tsconfig.json +++ b/libs/cockpit-registry/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": false, - "emitDeclarationOnly": false, - "baseUrl": "." + "emitDeclarationOnly": false }, "files": [], "include": [], diff --git a/libs/cockpit-runtime-bridge/tsconfig.json b/libs/cockpit-runtime-bridge/tsconfig.json index 190834ef3..a29e3d9a4 100644 --- a/libs/cockpit-runtime-bridge/tsconfig.json +++ b/libs/cockpit-runtime-bridge/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": false, - "emitDeclarationOnly": false, - "baseUrl": "." + "emitDeclarationOnly": false }, "files": [], "include": [], diff --git a/libs/example-layouts/tsconfig.json b/libs/example-layouts/tsconfig.json index df5104e30..da190b437 100644 --- a/libs/example-layouts/tsconfig.json +++ b/libs/example-layouts/tsconfig.json @@ -5,8 +5,7 @@ "noPropertyAccessFromIndexSignature": true, "module": "preserve", "emitDeclarationOnly": false, - "composite": false, - "baseUrl": "." + "composite": false }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, diff --git a/libs/growth/tsconfig.json b/libs/growth/tsconfig.json index c97c0cd2f..da21bb446 100644 --- a/libs/growth/tsconfig.json +++ b/libs/growth/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "baseUrl": ".", "composite": false, "emitDeclarationOnly": false }, diff --git a/scripts/tsconfig-path-inheritance.spec.mjs b/scripts/tsconfig-path-inheritance.spec.mjs new file mode 100644 index 000000000..faccb1692 --- /dev/null +++ b/scripts/tsconfig-path-inheritance.spec.mjs @@ -0,0 +1,107 @@ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +// Guard for a latent module-resolution defect. +// +// `tsconfig.base.json` declares the workspace `paths` map with entries that are +// relative to the workspace root (`libs/design-tokens/src/index.ts`). A project +// tsconfig that extends the base and re-declares `"baseUrl": "."` overrides the +// inherited base directory, because `baseUrl` outranks the implicit +// `pathsBasePath`. Every inherited substitution is then probed under the +// *library* folder (`libs//libs/design-tokens/src/index.ts`), misses, and +// only resolves because the npm workspace symlink in `node_modules` happens to +// land on the same source file. Builds stay green, so nothing catches it. +// +// A project that declares its own `paths` (including an empty `{}`) is exempt: +// there `baseUrl` does real directory-resolution work and shadows nothing. + +const workspaceRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const baseConfigPath = join(workspaceRoot, 'tsconfig.base.json'); + +function readConfig(configPath) { + const parsed = ts.parseConfigFileTextToJson(configPath, readFileSync(configPath, 'utf8')); + expect(parsed.error, `failed to parse ${relative(workspaceRoot, configPath)}`).toBeUndefined(); + return parsed.config ?? {}; +} + +/** + * Walk the `extends` chain from `configPath` upwards, stopping at (and + * excluding) `tsconfig.base.json`. Returns null when the chain never reaches + * the base config — such a project does not inherit the workspace `paths`. + */ +function chainBelowBase(configPath) { + const chain = []; + let current = configPath; + for (let hop = 0; hop < 10; hop += 1) { + if (current === baseConfigPath) return chain; + const config = readConfig(current); + chain.push({ path: current, config }); + if (typeof config.extends !== 'string') return null; + const next = resolve(dirname(current), config.extends); + current = existsSync(next) ? next : `${next}.json`; + if (!existsSync(current)) return null; + } + return null; +} + +function libraryTsconfigs() { + const found = []; + for (const entry of readdirSync(join(workspaceRoot, 'libs'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const libDir = join(workspaceRoot, 'libs', entry.name); + for (const file of readdirSync(libDir)) { + if (file.startsWith('tsconfig') && file.endsWith('.json')) found.push(join(libDir, file)); + } + } + return found.sort(); +} + +describe('library tsconfig path inheritance', () => { + it('never shadows the workspace baseUrl the inherited paths map resolves against', () => { + const offenders = []; + for (const configPath of libraryTsconfigs()) { + const chain = chainBelowBase(configPath); + if (chain === null) continue; + const declaresOwnPaths = chain.some((link) => link.config.compilerOptions?.paths !== undefined); + if (declaresOwnPaths) continue; + const shadowing = chain.find((link) => link.config.compilerOptions?.baseUrl !== undefined); + if (shadowing) offenders.push(relative(workspaceRoot, shadowing.path)); + } + expect([...new Set(offenders)]).toEqual([]); + }); + + it.each([ + 'libs/cockpit-runtime-bridge', + 'libs/growth', + 'libs/example-layouts', + 'libs/cockpit-registry', + ])('resolves @threadplane/design-tokens through the paths map from %s', (libDir) => { + const configPath = join(workspaceRoot, libDir, 'tsconfig.json'); + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, { + ...ts.sys, + onUnRecoverableConfigFileDiagnostic: (diagnostic) => { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')); + }, + getCurrentDirectory: () => workspaceRoot, + useCaseSensitiveFileNames: true, + }); + expect(parsed, `could not parse ${libDir}/tsconfig.json`).toBeDefined(); + + const containingFile = join(workspaceRoot, libDir, 'src', 'index.ts'); + const resolved = ts.resolveModuleName( + '@threadplane/design-tokens', + containingFile, + parsed.options, + ts.sys, + ).resolvedModule; + + expect(resolved?.resolvedFileName).toBe(join(workspaceRoot, 'libs/design-tokens/src/index.ts')); + // A `packageId` means TypeScript fell through the `paths` substitution and + // found the source only via the npm workspace symlink under node_modules. + expect(resolved?.packageId, 'resolved via node_modules symlink, not the paths map').toBeUndefined(); + }); +});