Files
deepseek-harness/scripts/compile-referenced-projects.ts
T
imccyu 04af9a1df7 perf(build): generate Typert contracts without bundling for typecheck and lint
Type checks only need Host declarations and Typert-generated
declarations, but typecheck, lint, lint:fix, doc-typecheck, and the CI
typert-contracts gate rebuilt the whole Host tsdown pass, Desktop bundle
included, to obtain them. A source-only scripts/generate-typert.ts now
runs the same generator and artifact writer as the tsdown plugin, beside
Host package compilation: Typert reads only sources once the missing
@deepseek-ai/dsh-client-file-upload/types path alias exists, and
rejectBuildOutputs fails the pass when a workspace lib/ declaration
enters its program. typecheck runs as a build.ts scope that checks both
aggregates in build mode after their own face compiles.

Refs #5852
2026-10-12 11:06:49 +08:00

274 lines
12 KiB
TypeScript

/**
* Compile and bundle package projects without repeating aggregate test and script typechecks.
* Contract steps replace the Host bundle with the bundle-free Typert pass for consumers that read
* only declarations.
*/
import { spawn, spawnSync } from 'node:child_process'
import { relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { parseArgs } from 'node:util'
import ts from 'typescript'
import { pnpmCommand } from './release/process.ts'
type CompilationMode = 'checked' | 'benchmark-emit'
/** Identifier of one package-library build step. */
export type LibraryStepId = 'host-src' | 'host-bundle' | 'host-typert' | 'desktop-bundle' | 'client-src' | 'client-bundle'
/** One package-library build step as a shell-free child-process invocation. */
export interface LibraryStep {
readonly id: LibraryStepId
/** Name used in progress and failure reports. */
readonly label: string
/** Short command shown in failure reports; reference lists are summarized. */
readonly displayCommand: string
readonly command: string
readonly args: readonly string[]
/** Steps that must succeed first; together they repeat the serial library order. */
readonly needs: readonly LibraryStepId[]
}
/**
* Run one package-manager command serially with the inherited build environment.
* @param root - Repository root supplying the command's working directory.
* @param args - Package-manager arguments, without shell quoting.
* @throws If the command cannot start or exits unsuccessfully.
*/
export function runPnpmCommand(root: string, args: readonly string[]): void {
const [command, ...prefix] = pnpmCommand()
const result = spawnSync(command, [...prefix, ...args], { cwd: root, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) {
throw new Error(`compile-referenced-projects: pnpm ${args.join(' ')} exited with ${String(result.status ?? result.signal)}`)
}
}
/**
* Resolve the Node arguments that compile one aggregate's project references with the serial TypeScript builder.
* @param root - Repository root containing the aggregate tsconfig.
* @param face - Independent compiler face whose package projects are emitted.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @returns Arguments for `process.execPath`, including the heap limit the Host face needs.
* @throws If the aggregate configuration is invalid, has no references, or can emit files.
*/
export function referencedProjectsCompilerArgs(
root: string,
face: 'host' | 'client',
mode: CompilationMode = 'checked',
): string[] {
const configPath = resolve(root, `tsconfig.${face}.json`)
const config = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
})
if (config === undefined) throw new Error(`compile-referenced-projects: cannot read ${configPath}`)
const references = config.projectReferences
if (references === undefined || references.length === 0) {
throw new Error(`compile-referenced-projects: ${configPath} requires non-empty project references`)
}
if (config.errors.length > 0) {
throw new Error(config.errors
.map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
.join('\n'))
}
if (config.options.noEmit !== true) {
throw new Error(`compile-referenced-projects: ${configPath} requires noEmit: true to exclude its aggregate program`)
}
return [
...hostHeapArgs(face),
fileURLToPath(import.meta.resolve('typescript/bin/tsc')),
'-b',
...mode === 'benchmark-emit' ? ['--noCheck'] : [],
// Relative arguments keep the complete workspace graph below Windows' command-line limit.
...references.map(reference => relative(root, reference.path)),
]
}
/**
* Resolve the Node arguments that type-check one aggregate's own test and script program in project mode.
* Project mode reads the declarations that its references already emitted and never compiles a reference,
* so missing package outputs surface as compiler diagnostics.
* @param face - Compiler face whose aggregate program is checked.
* @returns Arguments for `process.execPath`, run from the repository root.
*/
export function aggregateCheckArgs(face: 'host' | 'client'): string[] {
return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-p', `tsconfig.${face}.json`]
}
/**
* Resolve the Node arguments that type-check one aggregate's own test and script program in build mode.
* Build mode skips the program when its tsbuildinfo is current; callers run it only after the
* aggregate's references are compiled, so it finds every reference up to date and compiles none.
* @param face - Compiler face whose aggregate program is checked.
* @returns Arguments for `process.execPath`, run from the repository root.
*/
export function aggregateBuildArgs(face: 'host' | 'client'): string[] {
return [...hostHeapArgs(face), fileURLToPath(import.meta.resolve('typescript/bin/tsc')), '-b', `tsconfig.${face}.json`]
}
function hostHeapArgs(face: 'host' | 'client'): string[] {
return face === 'host' ? ['--max-old-space-size=4096'] : []
}
/**
* Compile one aggregate's project references with the serial TypeScript builder.
* @param root - Repository root containing the aggregate tsconfig.
* @param face - Independent compiler face whose package projects are emitted.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @throws If the aggregate emits files, its configuration is invalid, or package compilation fails.
*/
export function compileReferencedProjects(
root: string,
face: 'host' | 'client',
mode: CompilationMode = 'checked',
): void {
runStep(root, compilerStep(root, face, mode, []))
}
function compilerStep(
root: string,
face: 'host' | 'client',
mode: CompilationMode,
needs: readonly LibraryStepId[],
): LibraryStep {
return {
id: face === 'host' ? 'host-src' : 'client-src',
label: `${face} compiler`,
displayCommand: `tsc -b${mode === 'benchmark-emit' ? ' --noCheck' : ''} <tsconfig.${face}.json references>`,
command: process.execPath,
args: referencedProjectsCompilerArgs(root, face, mode),
needs,
}
}
// Typert reads workspace packages only from source (generate-typert.ts
// rejects build outputs), so it needs no compiled package. It runs under tsx's
// ESM hook rather than through `pnpm exec`, whose startup would lengthen the
// critical path before Client compilation.
function typertStep(): LibraryStep {
return {
id: 'host-typert',
label: 'Host Typert',
displayCommand: 'tsx scripts/generate-typert.ts',
command: process.execPath,
args: ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('generate-typert.ts', import.meta.url))],
needs: [],
}
}
function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], needs: readonly LibraryStepId[]): LibraryStep {
const [command, ...prefix] = pnpmCommand()
return { id, label, displayCommand: `pnpm ${args.join(' ')}`, command, args: [...prefix, ...args], needs }
}
/**
* List the package-library build steps in Host, Desktop, then Client dependency order.
* Host bundling runs Typert, whose Remote declarations Client compilation imports.
* @param root - Repository root containing the compiler and bundle configurations.
* @param faces - `host` lists Host compilation and bundling, which produce the Typert outputs; `all` adds Desktop and Client.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @returns The ordered steps; each step needs its predecessor.
* @throws If an aggregate configuration is invalid.
*/
export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] {
const host: LibraryStep[] = [
compilerStep(root, 'host', mode, []),
pnpmStep('host-bundle', 'Host bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'host'], ['host-src']),
]
if (faces === 'host') return host
return [
...host,
pnpmStep('desktop-bundle', 'Desktop bundle', ['--filter', '@deepseek-ai/dsh-desktop', 'run', 'bundle'], ['host-bundle']),
compilerStep(root, 'client', mode, ['desktop-bundle']),
pnpmStep('client-bundle', 'Client bundle', ['exec', 'tsdown', '--config-loader', 'native', '--env.DSH_BUILD_FACE', 'client'], ['client-src']),
]
}
/**
* List the bundle-free package steps that produce every declaration the type checkers, oxlint, and
* documentation checks read: Host compilation and the Typert pass, which writes the generated
* Typert declarations from source without bundling, run independently; Client compilation, which
* imports both, needs the two. Unlike {@link libraryBuildSteps}, no step writes runnable bundles.
* @param root - Repository root containing the compiler configurations.
* @param faces - `host` lists Host compilation and the Typert pass; `all` adds Client compilation.
* @returns The steps in report order.
* @throws If an aggregate configuration is invalid.
*/
export function contractSteps(root: string, faces: 'host' | 'all'): LibraryStep[] {
const host = [compilerStep(root, 'host', 'checked', []), typertStep()]
if (faces === 'host') return host
return [...host, compilerStep(root, 'client', 'checked', ['host-src', 'host-typert'])]
}
function runStep(root: string, step: LibraryStep): void {
const result = spawnSync(step.command, step.args, { cwd: root, stdio: 'inherit' })
if (result.error !== undefined) throw result.error
if (result.status !== 0) throw stepFailure(step, result.status ?? result.signal)
}
function runStepConcurrently(root: string, step: LibraryStep): Promise<void> {
return new Promise((settle, fail) => {
const child = spawn(step.command, step.args, { cwd: root, stdio: 'inherit' })
child.once('error', fail)
child.once('close', (code, signal) => {
if (code === 0) settle()
else fail(stepFailure(step, code ?? signal))
})
})
}
function stepFailure(step: LibraryStep, exit: number | NodeJS.Signals | null): Error {
return new Error(`compile-referenced-projects: ${step.label} exited with ${String(exit)}`)
}
/**
* Emit package libraries serially in Host, Desktop, then Client dependency order.
* @param root - Repository root containing the compiler and bundle configurations.
* @param hostOnly - Stop after Host compilation and bundling, which produce the Typert outputs.
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
* @throws If any compiler or bundler fails.
*/
export function buildLibraryArtifacts(root: string, hostOnly: boolean, mode: CompilationMode = 'checked'): void {
for (const step of libraryBuildSteps(root, hostOnly ? 'host' : 'all', mode)) runStep(root, step)
}
/**
* Compile Host package projects and, concurrently, write the generated Typert declarations
* without bundling. Both steps run to completion even when the other fails.
* @param root - Repository root containing the compiler configuration; the Typert pass runs there.
* @returns A promise that settles after both steps exit.
* @throws The failed step's error, or an `AggregateError` naming both steps when both fail.
*/
export async function buildHostContracts(root: string): Promise<void> {
const results = await Promise.allSettled(contractSteps(root, 'host').map(step => runStepConcurrently(root, step)))
const failures: unknown[] = []
for (const result of results) if (result.status === 'rejected') failures.push(result.reason)
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, 'compile-referenced-projects: host compiler and Host Typert both failed')
}
if (import.meta.main) {
const usage = 'compile-referenced-projects: expected exactly one mode: host, client, host-contracts, or host-libraries'
const { positionals } = parseArgs({ allowPositionals: true })
const [mode] = positionals
if (positionals.length !== 1) throw new Error(usage)
const root = resolve(import.meta.dirname, '..')
switch (mode) {
case 'host':
case 'client':
compileReferencedProjects(root, mode)
break
case 'host-contracts':
await buildHostContracts(root)
break
case 'host-libraries':
buildLibraryArtifacts(root, true)
break
default:
throw new Error(usage)
}
}