/** * Expand the workspace path aliases that a wildcard would otherwise resolve by * probing every package group in turn. * * `tsconfig.base.json` is the resolution facade for the whole repository, and * two of its aliases used one key per *group* rather than per package: * `@deepseek-ai/dsh-*` listed 49 candidate globs and `@deepseek-ai/dsh-*\/invariant` * listed 45. TypeScript and tsx try those candidates in order, so a specifier * whose package sits late in the list pays for every earlier miss. Under tsx's * ESM hook each miss is an `ERR_MODULE_NOT_FOUND` that Node decorates with a * full CommonJS resolution walk, which dominated source-launch boot. * * This generator writes one explicit entry per package into a marked region of * `tsconfig.base.json`, leaving every hand-written alias and comment outside * that region untouched. `--check` reports drift instead of writing, so a new * package that needs an alias fails a gate rather than silently resolving * through a fallback that no longer exists. * * @module scripts/gen-tsconfig-paths */ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const ROOT = fileURLToPath(new URL('..', import.meta.url)) const CONFIG = join(ROOT, 'tsconfig.base.json') const BEGIN = ' // BEGIN generated package aliases — pnpm run gen-tsconfig-paths' const END = ' // END generated package aliases' /** Package-name prefix the expanded aliases cover. */ const PREFIX = '@deepseek-ai/dsh-' /** One workspace package the generated region maps. */ interface PackageAlias { /** Bare specifier, e.g. `@deepseek-ai/dsh-session`. */ readonly specifier: string /** Repository-relative source directory, e.g. `./packages/session/session/src`. */ readonly source: string /** Whether the package carries `src/invariant.ts`, which earns a second alias. */ readonly hasInvariant: boolean } /** * Read a workspace manifest's declared name. * @param manifest - absolute path to a `package.json`. * @returns The declared name, or undefined when the file is absent or nameless. */ function packageName(manifest: string): string | undefined { let parsed: unknown try { parsed = JSON.parse(readFileSync(manifest, 'utf8')) } catch (_absentOrUnreadableManifest) { return undefined } if (typeof parsed !== 'object' || parsed === null) return undefined const name: unknown = (parsed as { name?: unknown }).name return typeof name === 'string' ? name : undefined } /** * Collect every package the removed wildcards could resolve. * * A wildcard substituted the specifier's suffix into `packages///src`, * so it only ever resolved a package whose declared name is exactly * `@deepseek-ai/dsh-`. Packages named after something other than * their directory already carry a hand-written alias and are skipped here. * * @returns Aliases sorted by specifier. * @throws When two package directories claim one specifier, which the removed * wildcards resolved by group order and an explicit map cannot express. */ export function collectPackageAliases(): PackageAlias[] { const packages = join(ROOT, 'packages') const bySpecifier = new Map() for (const group of readdirSync(packages).sort()) { const groupDir = join(packages, group) if (!statSync(groupDir).isDirectory()) continue for (const directory of readdirSync(groupDir).sort()) { const packageDir = join(groupDir, directory) const name = packageName(join(packageDir, 'package.json')) if (name === undefined || name !== `${PREFIX}${directory}`) continue if (!existsSync(join(packageDir, 'src'))) continue const previous = bySpecifier.get(name) if (previous !== undefined) { throw new Error( `gen-tsconfig-paths: ${name} is claimed by packages/${previous.directory} and packages/${group}/${directory}; ` + 'an explicit alias cannot express the group-order tiebreak the wildcard used.', ) } bySpecifier.set(name, { specifier: name, source: `./packages/${group}/${directory}/src`, hasInvariant: existsSync(join(packageDir, 'src', 'invariant.ts')), directory: `${group}/${directory}`, }) } } return [...bySpecifier.values()] .map(({ specifier, source, hasInvariant }) => ({ specifier, source, hasInvariant })) .sort((left, right) => left.specifier.localeCompare(right.specifier)) } /** * Collect every workspace package the aliases must cover. * * Unlike {@link collectPackageAliases} this keeps packages whose name does not * match their directory. The generator cannot map those — only a hand-written * alias can — but they still have to be mapped by something, because deleting * the group wildcards removed the fallback that used to catch them. * * @returns Declared names of every `@deepseek-ai/dsh-` package carrying a `src` directory. */ export function collectPackageNames(): string[] { const packages = join(ROOT, 'packages') const names: string[] = [] for (const group of readdirSync(packages).sort()) { const groupDir = join(packages, group) if (!statSync(groupDir).isDirectory()) continue for (const directory of readdirSync(groupDir).sort()) { const packageDir = join(groupDir, directory) const name = packageName(join(packageDir, 'package.json')) if (name === undefined || !name.startsWith(PREFIX)) continue if (existsSync(join(packageDir, 'src'))) names.push(name) } } return names.sort((left, right) => left.localeCompare(right)) } /** * Read the bare package specifiers a config maps, generated region included. * @param text - `tsconfig.base.json` contents. * @returns Specifiers mapped without a subpath. */ export function mappedSpecifiers(text: string): Set { const keys = new Set() for (const match of text.matchAll(/^\s*"(@deepseek-ai\/dsh-[^"/]+)":/gm)) { const key = match[1] if (key !== undefined) keys.add(key) } return keys } /** * Report packages that no alias maps. * * A package missing from `paths` still resolves — through the workspace symlink * and the package's own `exports` — but to built `lib/` output rather than to * source, which is the artifact-plane leak the explicit aliases exist to avoid. * Naming it here turns that into a gate failure instead of a silent difference. * * @param packages - every workspace package that needs an alias. * @param mapped - bare specifiers the config maps. * @returns Unmapped package names, in the order given. */ export function uncoveredPackages( packages: readonly string[], mapped: ReadonlySet, ): string[] { return packages.filter(name => !mapped.has(name)) } /** * Render the generated region's alias lines. * @param aliases - packages to map, in emission order. * @param handWritten - specifiers already mapped outside the region; a duplicate key would shadow one silently. * @returns The region body, one JSON member per line. */ export function renderAliases(aliases: readonly PackageAlias[], handWritten: ReadonlySet): string { const lines: string[] = [] for (const alias of aliases) { if (!handWritten.has(alias.specifier)) { lines.push(` ${JSON.stringify(alias.specifier)}: [${JSON.stringify(alias.source)}]`) } const invariant = `${alias.specifier}/invariant` if (alias.hasInvariant && !handWritten.has(invariant)) { lines.push(` ${JSON.stringify(invariant)}: [${JSON.stringify(`${alias.source}/invariant.ts`)}]`) } } // The region closes `paths`, so the last member carries no trailing comma. return lines.join(',\n') } /** * Replace the generated region of a config's text. * @param text - current `tsconfig.base.json` contents. * @param body - rendered alias lines. * @returns The updated contents. * @throws When the markers are missing or out of order. */ export function writeRegion(text: string, body: string): string { const begin = text.indexOf(BEGIN) const end = text.indexOf(END) if (begin < 0 || end < begin) { throw new Error(`gen-tsconfig-paths: ${CONFIG} is missing the generated-region markers.`) } return `${text.slice(0, begin)}${BEGIN}\n${body}\n${END}${text.slice(end + END.length)}` } /** * Parse the config's `paths` keys, ignoring the generated region. * @param text - current `tsconfig.base.json` contents. * @returns Specifiers mapped by hand. */ function handWrittenSpecifiers(text: string): Set { const begin = text.indexOf(BEGIN) const end = text.indexOf(END) const outside = begin < 0 || end < begin ? text : text.slice(0, begin) + text.slice(end) const keys = new Set() for (const match of outside.matchAll(/^\s*"(@deepseek-ai\/[^"]+)":/gm)) { const key = match[1] if (key !== undefined) keys.add(key) } return keys } if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) { const check = process.argv.includes('--check') const current = readFileSync(CONFIG, 'utf8') const next = writeRegion(current, renderAliases(collectPackageAliases(), handWrittenSpecifiers(current))) const uncovered = uncoveredPackages(collectPackageNames(), mappedSpecifiers(next)) if (uncovered.length > 0) { console.error( 'gen-tsconfig-paths: no alias maps ' + `${uncovered.join(', ')}; add a hand-written entry, because a package named after ` + 'something other than its directory cannot be generated.', ) process.exitCode = 1 } else if (current === next) { console.log('gen-tsconfig-paths: tsconfig.base.json package aliases are current.') } else if (check) { console.error('gen-tsconfig-paths: tsconfig.base.json is stale; run `pnpm run gen-tsconfig-paths`.') process.exitCode = 1 } else { writeFileSync(CONFIG, next) console.log('gen-tsconfig-paths: rewrote tsconfig.base.json package aliases.') } }