mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
859 lines
31 KiB
TypeScript
859 lines
31 KiB
TypeScript
/**
|
|
* Run local and CI quality gates with bounded in-process scheduling.
|
|
*
|
|
* Package scripts own public aggregate names; this runner owns their validated
|
|
* dependency graphs, scheduler environment, and process diagnostics.
|
|
* @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
|
|
*/
|
|
import { spawn } from 'node:child_process'
|
|
import { availableParallelism } from 'node:os'
|
|
import { resolve } from 'node:path'
|
|
import { performance } from 'node:perf_hooks'
|
|
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
|
|
|
|
/** A named aggregate exposed by the gate runner. */
|
|
export type Mode =
|
|
| 'ci-primary'
|
|
| 'ci-linux-primary'
|
|
| 'ci-static'
|
|
| 'ci-lint'
|
|
| 'ci-coverage'
|
|
| 'ci-snapshot'
|
|
| 'ci-artifacts'
|
|
| 'ci-consumers'
|
|
| 'ci-windows-blocking'
|
|
| 'ci-windows-complete'
|
|
| 'ci-windows-observational'
|
|
| 'node-compat'
|
|
| 'check-all'
|
|
| 'doc-sync'
|
|
|
|
type GateResultStatus = 'passed' | 'failed' | 'skipped'
|
|
type GateState = 'pending' | 'running' | GateResultStatus
|
|
|
|
/** A command and its dependency metadata inside one aggregate. */
|
|
export interface Gate {
|
|
id: string
|
|
label: string
|
|
displayCommand: string
|
|
command: string
|
|
args: string[]
|
|
needs?: string[]
|
|
env?: Record<string, string | undefined>
|
|
allowFailure?: boolean
|
|
}
|
|
|
|
/** The observed outcome of one gate process. */
|
|
export interface GateResult {
|
|
gate: Gate
|
|
status: GateResultStatus
|
|
durationMs: number
|
|
output: GateOutputChunk[]
|
|
exitCode: number | null
|
|
signalCode: NodeJS.Signals | null
|
|
error?: string
|
|
}
|
|
|
|
interface GateOutputChunk {
|
|
stream: 'stdout' | 'stderr'
|
|
text: string
|
|
}
|
|
|
|
interface RunningGate {
|
|
gate: Gate
|
|
promise: Promise<GateResult>
|
|
}
|
|
|
|
interface ConcurrencyDefault {
|
|
workers: number
|
|
source: string
|
|
}
|
|
|
|
type GateExecutor = (gate: Gate) => Promise<GateResult>
|
|
type ResultObserver = (result: GateResult) => void
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
if (import.meta.main) {
|
|
process.exitCode = await main(process.argv.slice(2))
|
|
}
|
|
|
|
async function main(args: string[]): Promise<number> {
|
|
const mode = parseMode(args[0])
|
|
const gates = gatesForMode(mode)
|
|
const concurrencyDefault = defaultConcurrency(mode, gates.length)
|
|
const concurrencyOverride = process.env.DSH_GATE_CONCURRENCY
|
|
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', concurrencyDefault.workers)
|
|
const concurrencySource = concurrencyOverride === undefined || concurrencyOverride === ''
|
|
? concurrencyDefault.source
|
|
: '$DSH_GATE_CONCURRENCY'
|
|
const startedAt = performance.now()
|
|
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
|
|
|
|
const results = await runGates(gates, maxConcurrency, runGate, printResult)
|
|
printSummary(results, performance.now() - startedAt)
|
|
return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))
|
|
? 1
|
|
: 0
|
|
}
|
|
|
|
function parseMode(raw: string | undefined): Mode {
|
|
switch (raw) {
|
|
case 'ci-primary':
|
|
case 'ci-linux-primary':
|
|
case 'ci-static':
|
|
case 'ci-lint':
|
|
case 'ci-coverage':
|
|
case 'ci-snapshot':
|
|
case 'ci-artifacts':
|
|
case 'ci-consumers':
|
|
case 'ci-windows-blocking':
|
|
case 'ci-windows-complete':
|
|
case 'ci-windows-observational':
|
|
case 'node-compat':
|
|
case 'check-all':
|
|
case 'doc-sync':
|
|
return raw
|
|
default:
|
|
throw new Error(
|
|
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | check-all | doc-sync, got ${JSON.stringify(raw)}.`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the default worker count for one aggregate.
|
|
* @param selectedMode - aggregate whose resource posture applies.
|
|
* @param total - number of gates in the aggregate.
|
|
* @param available - host CPU availability for ordinary modes.
|
|
* @returns the default worker count and its diagnostic source.
|
|
*/
|
|
export function defaultConcurrency(
|
|
selectedMode: Mode,
|
|
total: number,
|
|
available = availableParallelism(),
|
|
): ConcurrencyDefault {
|
|
if (selectedMode === 'ci-consumers') return { workers: total, source: 'ci-consumers gate count' }
|
|
// Local modes cap workers: several doc gates each build a full ts.Program,
|
|
// so an uncapped default on a large host trades wall clock for memory blowups.
|
|
const localCap = selectedMode === 'check-all' || selectedMode === 'doc-sync'
|
|
const modeLimit = localCap ? Math.min(4, available) : available
|
|
return {
|
|
workers: Math.min(total, modeLimit),
|
|
source: localCap
|
|
? `${available} available CPU(s), ${selectedMode} cap 4`
|
|
: `${available} available CPU(s)`,
|
|
}
|
|
}
|
|
|
|
function concurrencyFromEnv(name: string, fallback: number): number {
|
|
const raw = process.env[name]
|
|
if (raw === undefined || raw === '') return fallback
|
|
const parsed = Number.parseInt(raw, 10)
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
throw new Error(`run-gates: ${name} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Gate {
|
|
return {
|
|
id,
|
|
label: options.label ?? script,
|
|
displayCommand: `pnpm run ${script}`,
|
|
...pnpmInvocation(['run', script]),
|
|
...options,
|
|
}
|
|
}
|
|
|
|
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
|
return {
|
|
id,
|
|
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
|
displayCommand: `pnpm exec ${args.join(' ')}`,
|
|
...pnpmInvocation(['exec', ...args]),
|
|
...options,
|
|
}
|
|
}
|
|
|
|
function pnpmInvocation(args: string[]): Pick<Gate, 'command' | 'args'> {
|
|
const entrypoint = process.env.npm_execpath
|
|
if (entrypoint === undefined || entrypoint === '') {
|
|
throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.')
|
|
}
|
|
// Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free.
|
|
return { command: process.execPath, args: [entrypoint, ...args] }
|
|
}
|
|
|
|
/**
|
|
* Construct the complete gate list for a named aggregate.
|
|
* @param selected - aggregate mode to construct.
|
|
* @returns the aggregate's gate graph.
|
|
*/
|
|
export function gatesForMode(selected: Mode): Gate[] {
|
|
switch (selected) {
|
|
case 'ci-primary':
|
|
return ciPrimaryGates()
|
|
case 'ci-linux-primary':
|
|
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
|
|
case 'ci-static':
|
|
return ciStaticGates({ ownsBuild: false })
|
|
case 'ci-lint':
|
|
return [
|
|
lintGate(),
|
|
pnpmScript('duplication', 'duplication'),
|
|
]
|
|
case 'ci-coverage':
|
|
return coverageGates()
|
|
case 'ci-snapshot':
|
|
return [pnpmScript('build', 'build'), snapshotGate()]
|
|
case 'ci-artifacts':
|
|
return ciArtifactGates()
|
|
case 'ci-consumers':
|
|
return ciConsumerGates()
|
|
case 'ci-windows-blocking':
|
|
return ciWindowsBlockingGates()
|
|
case 'ci-windows-complete':
|
|
return ciWindowsCompleteGates()
|
|
case 'ci-windows-observational':
|
|
return ciWindowsObservationalGates()
|
|
case 'node-compat':
|
|
return nodeCompatGates()
|
|
case 'check-all':
|
|
return [
|
|
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
|
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
|
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
|
|
pnpmScript('test', 'test'),
|
|
pnpmScript('duplication', 'duplication'),
|
|
snapshotGate(),
|
|
pnpmScript('build', 'build'),
|
|
pnpmScript('build:web', 'build:web'),
|
|
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
|
...docSyncLeafGates({
|
|
docTypecheckNeeds: ['build'],
|
|
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
|
}),
|
|
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
|
]
|
|
case 'doc-sync':
|
|
return docSyncLeafGates()
|
|
}
|
|
}
|
|
|
|
function ciPrimaryGates(): Gate[] {
|
|
return [
|
|
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
|
pnpmScript('constraints', 'constraints'),
|
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
|
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
|
pnpmScript('typecheck', 'typecheck'),
|
|
lintGate(),
|
|
pnpmScript('duplication', 'duplication'),
|
|
...coverageGates(),
|
|
...nodeCompatSmokeGates(),
|
|
snapshotGate(),
|
|
...docSyncLeafGates(),
|
|
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
|
pnpmScript('knip', 'knip'),
|
|
// typecheck and build now drive the same root solution graph; without the
|
|
// dependency two concurrent `tsc -b` runs race the same tsbuildinfo files.
|
|
// The tsc step is an incremental no-op after typecheck.
|
|
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
|
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
label: 'node-next types',
|
|
needs: ['build'],
|
|
}),
|
|
builtPackageInvariantsGate(['build']),
|
|
builtBinSmokeGate(),
|
|
]
|
|
}
|
|
|
|
function nodeCompatGates(): Gate[] {
|
|
const typecheck = flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK')
|
|
? []
|
|
: [pnpmScript('typecheck', 'typecheck')]
|
|
if (runningNodeMajor() !== 22) {
|
|
return [...typecheck, ...nodeCompatSmokeGates()]
|
|
}
|
|
return [
|
|
...typecheck,
|
|
pnpmScript('build', 'build', {
|
|
...typecheck.length === 0 ? {} : { needs: ['typecheck'] },
|
|
}),
|
|
pnpmScript('build:web', 'build:web', {
|
|
label: 'Web frontend build',
|
|
needs: ['build'],
|
|
}),
|
|
...nodeCompatSmokeGates({ cliSmoke: true }),
|
|
]
|
|
}
|
|
|
|
function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
|
const gates: Gate[] = [
|
|
pnpmExec('source-worker-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
|
], { label: 'source worker smoke' }),
|
|
pnpmExec('jsonl-zstd-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
|
|
], { label: 'JSONL Zstandard smoke' }),
|
|
pnpmExec('dsh-source-launch-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'apps/cli/tests/source-launch.compat.spec.ts',
|
|
], { label: 'dsh source-launch smoke' }),
|
|
pnpmExec('vitest-jsdom-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'scripts/vitest-environment.compat.spec.ts',
|
|
], { label: 'Vitest jsdom smoke' }),
|
|
]
|
|
if (options.cliSmoke) {
|
|
gates.push(
|
|
pnpmExec('cli-lazy-search-startup-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'apps/cli/tests/lazy-search-startup.compat.spec.ts',
|
|
], {
|
|
label: 'CLI lazy-search startup smoke',
|
|
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
|
|
needs: ['build:web'],
|
|
}),
|
|
)
|
|
}
|
|
return gates
|
|
}
|
|
|
|
/** Active Node major used to scope version-specific compatibility contracts. */
|
|
function runningNodeMajor(): number {
|
|
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
|
|
if (!Number.isSafeInteger(major)) {
|
|
throw new Error(`run-gates: cannot parse Node version ${JSON.stringify(process.versions.node)}.`)
|
|
}
|
|
return major
|
|
}
|
|
|
|
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
|
return [
|
|
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
|
pnpmScript('constraints', 'constraints'),
|
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
|
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
|
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
|
|
...docSyncLeafGates({
|
|
includeDocTypecheck: options.ownsBuild,
|
|
...options.ownsBuild
|
|
? {
|
|
docTypecheckNeeds: ['build'],
|
|
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
|
}
|
|
: {},
|
|
docsBuildScript: 'docs:build:mpa',
|
|
}),
|
|
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
|
pnpmScript('knip', 'knip'),
|
|
]
|
|
}
|
|
|
|
function ciArtifactGates(): Gate[] {
|
|
return [
|
|
pnpmScript('build', 'build'),
|
|
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
label: 'node-next types',
|
|
needs: ['build'],
|
|
}),
|
|
builtPackageInvariantsGate(['build']),
|
|
builtBinSmokeGate(),
|
|
]
|
|
}
|
|
|
|
function ciConsumerGates(): Gate[] {
|
|
const builtTree = ['build']
|
|
const validatedBuild = ['built-package-invariants']
|
|
return [
|
|
pnpmScript('build', 'build'),
|
|
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
|
|
pnpmScript('publint', 'publint', { needs: builtTree }),
|
|
builtPackageInvariantsGate(['publint']),
|
|
pnpmScript('lint-and-duplication', 'check:ci:lint', {
|
|
label: 'lint and duplication',
|
|
needs: validatedBuild,
|
|
}),
|
|
snapshotGate(validatedBuild),
|
|
webSnapshotGate(validatedBuild),
|
|
pnpmScript('doc-typecheck', 'doc-typecheck', {
|
|
needs: validatedBuild,
|
|
env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
|
}),
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
label: 'node-next types',
|
|
needs: validatedBuild,
|
|
}),
|
|
builtBinSmokeGate(validatedBuild),
|
|
]
|
|
}
|
|
|
|
function webSnapshotGate(needs: string[]): Gate {
|
|
return pnpmScript('web-snapshot', 'test:web:built', {
|
|
label: 'web browser snapshot',
|
|
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
|
env: { DSH_SNAPSHOT: 'replay' },
|
|
needs,
|
|
})
|
|
}
|
|
|
|
function ciWindowsBlockingGates(): Gate[] {
|
|
return [
|
|
pnpmScript('windows-build', 'build', { label: 'build' }),
|
|
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
|
]
|
|
}
|
|
|
|
function ciWindowsCompleteGates(): Gate[] {
|
|
const observational = ciWindowsObservationalGates()
|
|
// The required production site replaces the observational MPA build; both
|
|
// VitePress modes write the same output directory and cannot overlap.
|
|
.filter(gate => gate.id !== 'build' && gate.id !== 'docs-site-build')
|
|
.map(gate => ({ ...gate, allowFailure: true }))
|
|
return [
|
|
pnpmScript('build', 'build'),
|
|
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
|
...observational,
|
|
]
|
|
}
|
|
|
|
function ciWindowsObservationalGates(): Gate[] {
|
|
return [
|
|
...ciStaticGates({ ownsBuild: true }),
|
|
// Linux owns required lint, coverage, and snapshots; Windows omits those duplicates.
|
|
pnpmScript('duplication', 'duplication'),
|
|
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
label: 'node-next types',
|
|
needs: ['build'],
|
|
}),
|
|
builtPackageInvariantsGate(['build']),
|
|
builtBinSmokeGate(),
|
|
]
|
|
}
|
|
|
|
function lintGate(): Gate {
|
|
const raw = process.env.DSH_OXLINT_THREADS
|
|
return pnpmScript('lint', 'lint', raw === undefined || raw === ''
|
|
? {}
|
|
: { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` })
|
|
}
|
|
|
|
// The heavy suites run uninstrumented beside the thresholded gate: their
|
|
// compiler- and subprocess-bound fixtures pay a multiple of their runtime
|
|
// under v8 instrumentation while contributing nothing the thresholds need
|
|
// (membership contract in scripts/coverage-exempt.ts).
|
|
//
|
|
// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel
|
|
// gates split it instead of each claiming it whole (the failover pool's
|
|
// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt
|
|
// gate's wall clock is dominated by its longest single file, so it takes the
|
|
// small share. A budget of 1 gives each gate 1 worker; lanes that need a
|
|
// strict total of one (the serial reference jobs) also set
|
|
// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all.
|
|
function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } {
|
|
const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers')
|
|
if (flag === undefined) return { instrumented: [], exempt: [] }
|
|
const total = Number.parseInt(flag.split('=')[1] ?? '', 10)
|
|
const exempt = Math.max(1, Math.floor(total / 3))
|
|
const instrumented = Math.max(1, total - exempt)
|
|
return {
|
|
instrumented: [`--maxWorkers=${String(instrumented)}`],
|
|
exempt: [`--maxWorkers=${String(exempt)}`],
|
|
}
|
|
}
|
|
|
|
function coverageGates(): Gate[] {
|
|
const workers = coverageWorkerArgs()
|
|
return [
|
|
pnpmExec('coverage', [
|
|
'vitest',
|
|
'run',
|
|
'--coverage',
|
|
...workers.instrumented,
|
|
], {
|
|
label: 'test:coverage',
|
|
env: { [COVERAGE_EXEMPT_ENV]: '1' },
|
|
}),
|
|
pnpmExec('coverage-exempt-heavy', [
|
|
'vitest',
|
|
'run',
|
|
...coverageExemptHeavySuites.map(suite => suite.filter),
|
|
...workers.exempt,
|
|
], {
|
|
label: 'test:coverage-exempt-heavy',
|
|
}),
|
|
]
|
|
}
|
|
|
|
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
|
|
// plugins via real exports); repository-script snapshots execute their real source entry path.
|
|
// Callers wait either on `build` or on a validation gate that transitively owns that build.
|
|
function snapshotGate(needs: string[] = ['build']): Gate {
|
|
return pnpmScript('snapshot', 'test:snapshot', {
|
|
env: { DSH_EXAMPLE_MODE: 'lib' },
|
|
needs,
|
|
})
|
|
}
|
|
|
|
function builtPackageInvariantsGate(needs?: string[]): Gate {
|
|
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
|
|
label: 'built package invariants',
|
|
...needs === undefined ? {} : { needs },
|
|
})
|
|
}
|
|
|
|
function positiveIntArg(envName: string, flag: string): string[] {
|
|
const raw = process.env[envName]
|
|
if (raw === undefined || raw === '') return []
|
|
const parsed = Number.parseInt(raw, 10)
|
|
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
|
throw new Error(`run-gates: ${envName} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
|
}
|
|
return [`${flag}=${raw}`]
|
|
}
|
|
|
|
function flagEnabled(envName: string): boolean {
|
|
const raw = process.env[envName]
|
|
if (raw === undefined || raw === '') return false
|
|
if (raw !== '1') throw new Error(`run-gates: ${envName} must be 1 when set, got ${JSON.stringify(raw)}.`)
|
|
return true
|
|
}
|
|
|
|
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
|
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
|
return [
|
|
pnpmScript('knip', 'knip'),
|
|
pnpmScript('publint', 'publint', artifactOptions),
|
|
pnpmScript('constraints', 'constraints'),
|
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
|
builtPackageInvariantsGate(options.artifactNeeds),
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
label: 'node-next types',
|
|
...artifactOptions,
|
|
}),
|
|
]
|
|
}
|
|
|
|
function docSyncLeafGates(options: {
|
|
includeDocTypecheck?: boolean
|
|
docTypecheckNeeds?: string[]
|
|
docTypecheckEnv?: Record<string, string | undefined>
|
|
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
|
|
} = {}): Gate[] {
|
|
const docTypecheckOptions: Partial<Gate> = {}
|
|
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
|
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
|
|
return [
|
|
...options.includeDocTypecheck === false
|
|
? []
|
|
: [pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions)],
|
|
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
|
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
|
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
|
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
|
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
|
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
|
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
|
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
|
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
|
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
|
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
|
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
|
|
pnpmScript('mermaid', 'verify-mermaid'),
|
|
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
|
|
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
|
|
pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }),
|
|
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
|
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
|
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
|
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
|
pnpmExec('docs-site-projection', ['vitest', 'run', 'scripts/project-doc-site.spec.ts'], {
|
|
label: 'documentation projection',
|
|
}),
|
|
// Keep the VitePress build itself in one gate because projection rewrites website/.generated.
|
|
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
|
|
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
|
]
|
|
}
|
|
|
|
function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
|
return pnpmExec('built-bin-smoke', [
|
|
'vitest',
|
|
'run',
|
|
'--config',
|
|
'vitest.e2e.config.ts',
|
|
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
|
'apps/cli/tests/tui-keyless-smoke.e2e.ts',
|
|
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
|
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
|
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
|
// The worker-entry packages' built bundles: the only automated proof
|
|
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
|
// (the e2e lane runs unbuilt, so these files self-skip there).
|
|
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
|
|
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
|
], {
|
|
label: 'built-bin smoke',
|
|
needs,
|
|
env: { DSH_EXAMPLE_MODE: 'lib' },
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Reject a gate list whose graph cannot be executed unambiguously.
|
|
* @param gates - complete aggregate to validate.
|
|
*/
|
|
function validateGateGraph(gates: readonly Gate[]): void {
|
|
if (gates.length === 0) throw new Error('run-gates: gate graph has no gates.')
|
|
|
|
const ids = new Set<string>()
|
|
for (const gate of gates) {
|
|
if (ids.has(gate.id)) throw new Error(`run-gates: duplicate gate id ${JSON.stringify(gate.id)}.`)
|
|
ids.add(gate.id)
|
|
}
|
|
for (const gate of gates) {
|
|
for (const dependency of gate.needs ?? []) {
|
|
if (!ids.has(dependency)) {
|
|
throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`)
|
|
}
|
|
}
|
|
}
|
|
|
|
const cycle = findDependencyCycle(gates)
|
|
if (cycle !== undefined) throw new Error(`run-gates: dependency cycle: ${cycle.join(' -> ')}.`)
|
|
}
|
|
|
|
function findDependencyCycle(gates: readonly Gate[]): string[] | undefined {
|
|
const byId = new Map(gates.map(gate => [gate.id, gate]))
|
|
const complete = new Set<string>()
|
|
const active = new Map<string, number>()
|
|
const path: string[] = []
|
|
|
|
const visit = (id: string): string[] | undefined => {
|
|
if (complete.has(id)) return undefined
|
|
const cycleStart = active.get(id)
|
|
if (cycleStart !== undefined) return [...path.slice(cycleStart), id]
|
|
const gate = byId.get(id)
|
|
if (gate === undefined) return undefined
|
|
|
|
active.set(id, path.length)
|
|
path.push(id)
|
|
for (const dependency of gate.needs ?? []) {
|
|
const cycle = visit(dependency)
|
|
if (cycle !== undefined) return cycle
|
|
}
|
|
path.pop()
|
|
active.delete(id)
|
|
complete.add(id)
|
|
return undefined
|
|
}
|
|
|
|
for (const gate of gates) {
|
|
const cycle = visit(gate.id)
|
|
if (cycle !== undefined) return cycle
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Validate and run one aggregate before the injected executor can start a child.
|
|
* @param gates - complete aggregate to execute.
|
|
* @param maxActive - maximum concurrent child count.
|
|
* @param execute - child-process executor.
|
|
* @param observe - result observer invoked when each gate settles.
|
|
* @returns results in aggregate order.
|
|
*/
|
|
export async function runGates(
|
|
gates: Gate[],
|
|
maxActive: number,
|
|
execute: GateExecutor,
|
|
observe: ResultObserver = () => {},
|
|
): Promise<GateResult[]> {
|
|
validateGateGraph(gates)
|
|
if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
|
|
throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`)
|
|
}
|
|
const states = new Map<string, GateState>(gates.map(gate => [gate.id, 'pending']))
|
|
const results = new Map<string, GateResult>()
|
|
const running: RunningGate[] = []
|
|
|
|
for (;;) {
|
|
let madeProgress = false
|
|
while (running.length < maxActive) {
|
|
const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
|
|
if (ready === undefined) break
|
|
states.set(ready.id, 'running')
|
|
running.push({ gate: ready, promise: execute(ready) })
|
|
console.log(`run-gates: start ${ready.label}`)
|
|
madeProgress = true
|
|
}
|
|
|
|
if (running.length === 0) {
|
|
let pending = gates.filter(gate => states.get(gate.id) === 'pending')
|
|
while (pending.length > 0) {
|
|
const gate = pending.find(item => (item.needs ?? []).some((id) => {
|
|
const state = states.get(id)
|
|
return state === 'failed' || state === 'skipped'
|
|
}))
|
|
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
|
|
const failedDeps = (gate.needs ?? []).filter((id) => {
|
|
const state = states.get(id)
|
|
return state === 'failed' || state === 'skipped'
|
|
})
|
|
const result: GateResult = {
|
|
gate,
|
|
status: 'skipped',
|
|
durationMs: 0,
|
|
output: [],
|
|
exitCode: null,
|
|
signalCode: null,
|
|
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
|
}
|
|
states.set(gate.id, 'skipped')
|
|
results.set(gate.id, result)
|
|
observe(result)
|
|
pending = pending.filter(item => item !== gate)
|
|
}
|
|
break
|
|
}
|
|
|
|
if (!madeProgress) {
|
|
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
|
|
running.splice(running.indexOf(settled.item), 1)
|
|
states.set(settled.item.gate.id, settled.result.status)
|
|
results.set(settled.item.gate.id, settled.result)
|
|
observe(settled.result)
|
|
}
|
|
}
|
|
|
|
return gates.map((gate) => {
|
|
const result = results.get(gate.id)
|
|
if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`)
|
|
return result
|
|
})
|
|
}
|
|
|
|
function dependenciesPassed(gate: Gate, states: Map<string, GateState>): boolean {
|
|
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
|
|
}
|
|
|
|
/**
|
|
* Execute one gate through the real shell-free child-process boundary.
|
|
* @param gate - command and scheduler environment to execute.
|
|
* @returns the complete process outcome.
|
|
*/
|
|
export async function runGate(gate: Gate): Promise<GateResult> {
|
|
const started = performance.now()
|
|
const output: GateOutputChunk[] = []
|
|
let spawnError: string | undefined
|
|
|
|
const outcome = await new Promise<{
|
|
exitCode: number | null
|
|
signalCode: NodeJS.Signals | null
|
|
}>((resolveExit) => {
|
|
const child = spawn(gate.command, gate.args, {
|
|
cwd: root,
|
|
env: { ...process.env, ...gate.env },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', (chunk: string) => {
|
|
output.push({ stream: 'stdout', text: chunk })
|
|
})
|
|
child.stderr.on('data', (chunk: string) => {
|
|
output.push({ stream: 'stderr', text: chunk })
|
|
})
|
|
child.on('error', (error) => {
|
|
spawnError = `failed to start command: ${error.message}`
|
|
resolveExit({ exitCode: null, signalCode: null })
|
|
})
|
|
child.on('close', (exitCode, signalCode) => {
|
|
resolveExit({ exitCode, signalCode })
|
|
})
|
|
child.stdin.end()
|
|
})
|
|
const { exitCode, signalCode } = outcome
|
|
|
|
const status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed'
|
|
const result: GateResult = {
|
|
gate,
|
|
status,
|
|
durationMs: performance.now() - started,
|
|
output,
|
|
exitCode,
|
|
signalCode,
|
|
}
|
|
if (spawnError !== undefined) result.error = spawnError
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Format every independently observed failure fact for the aggregate summary.
|
|
* @param result - unsuccessful gate result.
|
|
* @returns error, exit, and signal facts without allowing one to hide another.
|
|
*/
|
|
export function formatGateResultReason(result: GateResult): string {
|
|
const facts: string[] = []
|
|
if (result.error !== undefined) facts.push(result.error)
|
|
if (result.exitCode !== null) facts.push(`exit ${result.exitCode}`)
|
|
if (result.signalCode !== null) facts.push(`signal ${result.signalCode}`)
|
|
return facts.length === 0 ? 'no exit code or signal' : facts.join(', ')
|
|
}
|
|
|
|
function printResult(result: GateResult): void {
|
|
const verbose = process.env.DSH_GATE_VERBOSE === '1'
|
|
const seconds = (result.durationMs / 1000).toFixed(2)
|
|
if (result.status === 'passed' && !verbose) {
|
|
console.log(`run-gates: PASS ${result.gate.label} (${seconds}s)`)
|
|
return
|
|
}
|
|
|
|
const heading = `${result.status.toUpperCase()} ${result.gate.label} (${seconds}s)`
|
|
const writeHeading = result.status === 'passed' ? console.log : console.error
|
|
writeHeading(`\n== ${heading} ==`)
|
|
if (result.status !== 'passed') {
|
|
console.error(`command: ${result.gate.displayCommand}`)
|
|
console.error(`outcome: ${formatGateResultReason(result)}`)
|
|
}
|
|
printOutput(result.output)
|
|
}
|
|
|
|
function printSummary(results: GateResult[], durationMs: number): void {
|
|
const passed = results.filter(result => result.status === 'passed').length
|
|
const failed = results.filter(result => result.status === 'failed').length
|
|
const skipped = results.filter(result => result.status === 'skipped').length
|
|
const seconds = (durationMs / 1000).toFixed(2)
|
|
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
|
|
|
|
const unsuccessful = results.filter(result => result.status === 'failed' || result.status === 'skipped')
|
|
if (unsuccessful.length === 0) return
|
|
|
|
console.error('run-gates: unsuccessful gates:')
|
|
for (const result of unsuccessful) {
|
|
const duration = (result.durationMs / 1000).toFixed(2)
|
|
const reason = formatGateResultReason(result)
|
|
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
|
|
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
|
console.error(` ${result.gate.displayCommand}`)
|
|
}
|
|
}
|
|
|
|
function printOutput(output: GateOutputChunk[]): void {
|
|
for (const chunk of output) {
|
|
if (chunk.stream === 'stdout') process.stdout.write(chunk.text)
|
|
else process.stderr.write(chunk.text)
|
|
}
|
|
}
|