Files
deepseek-harness/scripts/gen-tsconfig-paths.ts
T
Yichen Jiang c4c3c32931 fix(infra): assert alias coverage and harden the generator entry guard
Review found the CLI entry guard compared import.meta.url against a
concatenated file:// URL. That fails whenever import.meta.url encodes
something process.argv[1] does not — a repository path containing a
space, or any Windows drive path — and it fails silently: the script
exits 0 having done nothing, so verify-tsconfig-paths would pass
without checking exactly where it is needed. Running a copy from a
directory whose name contains a space reproduces it. The guard now uses
the comparison the repository's seven other generators already use.

Deleting the group wildcards also removed the fallback that resolved a
package nobody had aliased, so the generator now asserts coverage:
every workspace package with a src directory must be mapped, and
--check names any that is not. That assertion immediately found four
packages named dsh-<group>-<directory> whose siblings carry hand-written
aliases while they did not, so they reached built lib/ output through
the workspace symlink. They now carry aliases too, which takes the
resolution differences in this branch from seven to eleven.

Two comments in tsconfig.base.json still pointed at the deleted
wildcards; they now state why those aliases stay hand-written. The
package-inventory proposal recorded the wildcard collapse as current,
so both notes are cross-linked as partial supersession.
2026-08-27 19:39:21 +08:00

239 lines
9.8 KiB
TypeScript

/**
* 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/<group>/<suffix>/src`,
* so it only ever resolved a package whose declared name is exactly
* `@deepseek-ai/dsh-<directory>`. 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<string, PackageAlias & { directory: 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 !== `${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<string> {
const keys = new Set<string>()
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>,
): 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>): 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<string> {
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<string>()
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.')
}
}