2026-07-06 01:47:13 +08:00
|
|
|
/**
|
2026-07-28 15:33:20 +08:00
|
|
|
* Run local and CI quality gates with bounded in-process scheduling.
|
2026-07-06 01:47:13 +08:00
|
|
|
*
|
2026-07-27 21:27:42 +08:00
|
|
|
* Package scripts own public aggregate names; this runner owns their validated
|
2026-07-28 15:33:20 +08:00
|
|
|
* dependency graphs, scheduler environment, and process diagnostics.
|
|
|
|
|
* @see ../.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md
|
2026-07-06 01:47:13 +08:00
|
|
|
*/
|
|
|
|
|
import { spawn } from 'node:child_process'
|
|
|
|
|
import { availableParallelism } from 'node:os'
|
2026-07-27 23:47:00 +08:00
|
|
|
import { resolve } from 'node:path'
|
2026-07-06 01:47:13 +08:00
|
|
|
import { performance } from 'node:perf_hooks'
|
2026-07-31 02:24:37 +08:00
|
|
|
import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts'
|
2026-07-27 21:27:42 +08:00
|
|
|
|
|
|
|
|
/** A named aggregate exposed by the gate runner. */
|
2026-07-28 15:33:20 +08:00
|
|
|
export type Mode =
|
|
|
|
|
| 'ci-primary'
|
2026-07-30 11:16:37 +08:00
|
|
|
| 'ci-linux-primary'
|
2026-07-28 15:33:20 +08:00
|
|
|
| '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'
|
2026-07-06 01:47:13 +08:00
|
|
|
|
2026-07-28 00:13:52 +08:00
|
|
|
type GateResultStatus = 'passed' | 'failed' | 'skipped'
|
|
|
|
|
type GateState = 'pending' | 'running' | GateResultStatus
|
2026-07-06 01:47:13 +08:00
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
/** A command and its dependency metadata inside one aggregate. */
|
2026-07-27 21:27:42 +08:00
|
|
|
export interface Gate {
|
2026-07-06 01:47:13 +08:00
|
|
|
id: string
|
|
|
|
|
label: string
|
2026-07-15 23:43:10 +08:00
|
|
|
displayCommand: string
|
2026-07-06 01:47:13 +08:00
|
|
|
command: string
|
|
|
|
|
args: string[]
|
|
|
|
|
needs?: string[]
|
2026-07-28 15:33:20 +08:00
|
|
|
env?: Record<string, string | undefined>
|
2026-07-22 15:44:11 +08:00
|
|
|
allowFailure?: boolean
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
/** The observed outcome of one gate process. */
|
|
|
|
|
export interface GateResult {
|
2026-07-06 01:47:13 +08:00
|
|
|
gate: Gate
|
2026-07-28 00:13:52 +08:00
|
|
|
status: GateResultStatus
|
2026-07-06 01:47:13 +08:00
|
|
|
durationMs: number
|
2026-07-15 23:43:10 +08:00
|
|
|
output: GateOutputChunk[]
|
2026-07-06 01:47:13 +08:00
|
|
|
exitCode: number | null
|
2026-07-27 21:27:42 +08:00
|
|
|
signalCode: NodeJS.Signals | null
|
2026-07-06 01:47:13 +08:00
|
|
|
error?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 23:43:10 +08:00
|
|
|
interface GateOutputChunk {
|
|
|
|
|
stream: 'stdout' | 'stderr'
|
|
|
|
|
text: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 01:47:13 +08:00
|
|
|
interface RunningGate {
|
|
|
|
|
gate: Gate
|
|
|
|
|
promise: Promise<GateResult>
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
interface ConcurrencyDefault {
|
2026-07-15 23:42:00 +08:00
|
|
|
workers: number
|
|
|
|
|
source: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
type GateExecutor = (gate: Gate) => Promise<GateResult>
|
2026-07-27 23:47:00 +08:00
|
|
|
type ResultObserver = (result: GateResult) => void
|
2026-07-27 21:27:42 +08:00
|
|
|
|
2026-07-06 01:47:13 +08:00
|
|
|
const root = resolve(import.meta.dirname, '..')
|
2026-07-28 00:12:16 +08:00
|
|
|
if (import.meta.main) {
|
2026-07-27 23:58:27 +08:00
|
|
|
process.exitCode = await main(process.argv.slice(2))
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function main(args: string[]): Promise<number> {
|
2026-07-28 15:33:20 +08:00
|
|
|
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'
|
2026-07-27 21:27:42 +08:00
|
|
|
const startedAt = performance.now()
|
2026-07-28 15:33:20 +08:00
|
|
|
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
|
2026-07-27 21:27:42 +08:00
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
const results = await runGates(gates, maxConcurrency, runGate, printResult)
|
|
|
|
|
printSummary(results, performance.now() - startedAt)
|
2026-07-27 21:27:42 +08:00
|
|
|
return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))
|
|
|
|
|
? 1
|
|
|
|
|
: 0
|
2026-07-22 15:44:11 +08:00
|
|
|
}
|
2026-07-06 01:47:13 +08:00
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
function parseMode(raw: string | undefined): Mode {
|
2026-07-28 15:33:20 +08:00
|
|
|
switch (raw) {
|
|
|
|
|
case 'ci-primary':
|
2026-07-30 11:16:37 +08:00
|
|
|
case 'ci-linux-primary':
|
2026-07-28 15:33:20 +08:00
|
|
|
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(
|
2026-07-30 11:16:37 +08:00
|
|
|
`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)}.`,
|
2026-07-28 15:33:20 +08:00
|
|
|
)
|
|
|
|
|
}
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
/**
|
|
|
|
|
* 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' }
|
2026-07-22 17:37:43 +08:00
|
|
|
// Local modes cap workers: several doc gates each build a full ts.Program,
|
2026-07-21 11:07:50 +08:00
|
|
|
// so an uncapped default on a large host trades wall clock for memory blowups.
|
2026-07-28 15:33:20 +08:00
|
|
|
const localCap = selectedMode === 'check-all' || selectedMode === 'doc-sync'
|
2026-07-21 11:07:50 +08:00
|
|
|
const modeLimit = localCap ? Math.min(4, available) : available
|
2026-07-15 23:42:00 +08:00
|
|
|
return {
|
2026-07-28 15:33:20 +08:00
|
|
|
workers: Math.min(total, modeLimit),
|
2026-07-21 11:07:50 +08:00
|
|
|
source: localCap
|
2026-07-28 15:33:20 +08:00
|
|
|
? `${available} available CPU(s), ${selectedMode} cap 4`
|
2026-07-15 23:42:00 +08:00
|
|
|
: `${available} available CPU(s)`,
|
|
|
|
|
}
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
function concurrencyFromEnv(name: string, fallback: number): number {
|
|
|
|
|
const raw = process.env[name]
|
2026-07-06 01:47:13 +08:00
|
|
|
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,
|
2026-07-15 23:43:10 +08:00
|
|
|
displayCommand: `pnpm run ${script}`,
|
2026-07-15 17:47:29 +08:00
|
|
|
...pnpmInvocation(['run', script]),
|
2026-07-06 01:47:13 +08:00
|
|
|
...options,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
2026-07-15 23:43:10 +08:00
|
|
|
displayCommand: `pnpm exec ${args.join(' ')}`,
|
2026-07-15 17:47:29 +08:00
|
|
|
...pnpmInvocation(['exec', ...args]),
|
2026-07-06 01:47:13 +08:00
|
|
|
...options,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 17:47:29 +08:00
|
|
|
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] }
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
/**
|
2026-07-28 15:33:20 +08:00
|
|
|
* Construct the complete gate list for a named aggregate.
|
2026-07-27 21:27:42 +08:00
|
|
|
* @param selected - aggregate mode to construct.
|
2026-07-28 15:33:20 +08:00
|
|
|
* @returns the aggregate's gate graph.
|
2026-07-27 21:27:42 +08:00
|
|
|
*/
|
2026-07-28 15:33:20 +08:00
|
|
|
export function gatesForMode(selected: Mode): Gate[] {
|
2026-07-06 01:47:13 +08:00
|
|
|
switch (selected) {
|
|
|
|
|
case 'ci-primary':
|
|
|
|
|
return ciPrimaryGates()
|
2026-07-30 11:16:37 +08:00
|
|
|
case 'ci-linux-primary':
|
|
|
|
|
return [...ciPrimaryGates(), webSnapshotGate(['built-package-invariants'])]
|
2026-07-06 02:22:22 +08:00
|
|
|
case 'ci-static':
|
2026-07-30 19:02:10 +08:00
|
|
|
return ciStaticGates({ ownsBuild: false })
|
2026-07-06 02:39:26 +08:00
|
|
|
case 'ci-lint':
|
2026-07-06 02:59:50 +08:00
|
|
|
return [
|
|
|
|
|
lintGate(),
|
2026-07-13 23:42:54 +08:00
|
|
|
pnpmScript('duplication', 'duplication'),
|
2026-07-06 02:59:50 +08:00
|
|
|
]
|
2026-07-06 02:22:22 +08:00
|
|
|
case 'ci-coverage':
|
2026-07-31 02:24:37 +08:00
|
|
|
return coverageGates()
|
2026-07-06 02:22:22 +08:00
|
|
|
case 'ci-snapshot':
|
2026-07-22 17:53:01 +08:00
|
|
|
return [pnpmScript('build', 'build'), snapshotGate()]
|
2026-07-06 02:22:22 +08:00
|
|
|
case 'ci-artifacts':
|
|
|
|
|
return ciArtifactGates()
|
2026-07-27 21:27:42 +08:00
|
|
|
case 'ci-consumers':
|
|
|
|
|
return ciConsumerGates()
|
2026-07-22 14:42:43 +08:00
|
|
|
case 'ci-windows-blocking':
|
|
|
|
|
return ciWindowsBlockingGates()
|
2026-07-22 15:44:11 +08:00
|
|
|
case 'ci-windows-complete':
|
|
|
|
|
return ciWindowsCompleteGates()
|
2026-07-22 14:42:43 +08:00
|
|
|
case 'ci-windows-observational':
|
|
|
|
|
return ciWindowsObservationalGates()
|
2026-07-06 01:47:13 +08:00
|
|
|
case 'node-compat':
|
2026-07-22 16:10:52 +08:00
|
|
|
return nodeCompatGates()
|
2026-07-22 18:27:31 +08:00
|
|
|
case 'check-all':
|
2026-07-22 17:37:43 +08:00
|
|
|
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'],
|
2026-07-28 15:33:20 +08:00
|
|
|
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
2026-07-22 17:37:43 +08:00
|
|
|
}),
|
|
|
|
|
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
|
|
|
|
]
|
2026-07-21 11:07:50 +08:00
|
|
|
case 'doc-sync':
|
|
|
|
|
return docSyncLeafGates()
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ciPrimaryGates(): Gate[] {
|
|
|
|
|
return [
|
2026-07-13 16:34:09 +08:00
|
|
|
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('constraints', 'constraints'),
|
2026-07-19 22:13:50 +08:00
|
|
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
2026-07-14 21:25:58 +08:00
|
|
|
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('typecheck', 'typecheck'),
|
2026-07-06 02:59:50 +08:00
|
|
|
lintGate(),
|
2026-07-13 23:42:54 +08:00
|
|
|
pnpmScript('duplication', 'duplication'),
|
2026-07-31 02:24:37 +08:00
|
|
|
...coverageGates(),
|
2026-07-22 17:53:01 +08:00
|
|
|
...nodeCompatSmokeGates(),
|
2026-07-17 15:42:35 +08:00
|
|
|
snapshotGate(),
|
2026-07-06 01:47:13 +08:00
|
|
|
...docSyncLeafGates(),
|
|
|
|
|
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
|
|
|
|
pnpmScript('knip', 'knip'),
|
2026-07-22 23:58:37 +08:00
|
|
|
// 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'] }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
|
|
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
|
|
|
label: 'node-next types',
|
|
|
|
|
needs: ['build'],
|
|
|
|
|
}),
|
2026-07-21 00:25:38 +08:00
|
|
|
builtPackageInvariantsGate(['build']),
|
2026-07-06 01:47:13 +08:00
|
|
|
builtBinSmokeGate(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 16:10:52 +08:00
|
|
|
function nodeCompatGates(): Gate[] {
|
2026-07-27 14:46:08 +08:00
|
|
|
const typecheck = flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK')
|
|
|
|
|
? []
|
|
|
|
|
: [pnpmScript('typecheck', 'typecheck')]
|
|
|
|
|
if (runningNodeMajor() !== 22) {
|
|
|
|
|
return [...typecheck, ...nodeCompatSmokeGates()]
|
|
|
|
|
}
|
2026-07-22 16:10:52 +08:00
|
|
|
return [
|
2026-07-27 14:46:08 +08:00
|
|
|
...typecheck,
|
|
|
|
|
pnpmScript('build', 'build', {
|
|
|
|
|
...typecheck.length === 0 ? {} : { needs: ['typecheck'] },
|
|
|
|
|
}),
|
|
|
|
|
pnpmScript('build:web', 'build:web', {
|
|
|
|
|
label: 'Web frontend build',
|
|
|
|
|
needs: ['build'],
|
|
|
|
|
}),
|
|
|
|
|
...nodeCompatSmokeGates({ cliSmoke: true }),
|
2026-07-22 16:10:52 +08:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 14:46:08 +08:00
|
|
|
function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
|
|
|
|
const gates: Gate[] = [
|
2026-07-22 16:10:52 +08:00
|
|
|
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' }),
|
2026-07-29 13:15:24 +08:00
|
|
|
pnpmExec('dsh-source-launch-smoke', [
|
|
|
|
|
'vitest',
|
|
|
|
|
'run',
|
|
|
|
|
'apps/cli/tests/source-launch.compat.spec.ts',
|
|
|
|
|
], { label: 'dsh source-launch smoke' }),
|
2026-07-30 21:49:33 +08:00
|
|
|
pnpmExec('vitest-jsdom-smoke', [
|
|
|
|
|
'vitest',
|
|
|
|
|
'run',
|
|
|
|
|
'scripts/vitest-environment.compat.spec.ts',
|
|
|
|
|
], { label: 'Vitest jsdom smoke' }),
|
2026-07-22 16:10:52 +08:00
|
|
|
]
|
2026-07-27 14:46:08 +08:00
|
|
|
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
|
2026-07-22 16:10:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-30 19:02:10 +08:00
|
|
|
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
2026-07-06 02:22:22 +08:00
|
|
|
return [
|
2026-07-13 16:34:09 +08:00
|
|
|
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
2026-07-06 02:22:22 +08:00
|
|
|
pnpmScript('constraints', 'constraints'),
|
2026-07-19 22:13:50 +08:00
|
|
|
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
|
2026-07-14 21:25:58 +08:00
|
|
|
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
|
2026-07-30 19:02:10 +08:00
|
|
|
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
|
2026-07-21 21:28:49 +08:00
|
|
|
...docSyncLeafGates({
|
2026-07-30 19:02:10 +08:00
|
|
|
includeDocTypecheck: options.ownsBuild,
|
|
|
|
|
...options.ownsBuild
|
|
|
|
|
? {
|
|
|
|
|
docTypecheckNeeds: ['build'],
|
|
|
|
|
docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
|
|
|
|
}
|
|
|
|
|
: {},
|
2026-07-21 21:28:49 +08:00
|
|
|
docsBuildScript: 'docs:build:mpa',
|
|
|
|
|
}),
|
2026-07-06 02:22:22 +08:00
|
|
|
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'],
|
|
|
|
|
}),
|
2026-07-21 00:25:38 +08:00
|
|
|
builtPackageInvariantsGate(['build']),
|
2026-07-06 02:22:22 +08:00
|
|
|
builtBinSmokeGate(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
function ciConsumerGates(): Gate[] {
|
2026-07-30 19:02:10 +08:00
|
|
|
const builtTree = ['build']
|
|
|
|
|
const validatedBuild = ['built-package-invariants']
|
2026-07-27 21:27:42 +08:00
|
|
|
return [
|
2026-07-30 19:02:10 +08:00
|
|
|
pnpmScript('build', 'build'),
|
|
|
|
|
pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }),
|
|
|
|
|
pnpmScript('publint', 'publint', { needs: builtTree }),
|
|
|
|
|
builtPackageInvariantsGate(['publint']),
|
2026-07-28 00:36:05 +08:00
|
|
|
pnpmScript('lint-and-duplication', 'check:ci:lint', {
|
|
|
|
|
label: 'lint and duplication',
|
2026-07-30 19:02:10 +08:00
|
|
|
needs: validatedBuild,
|
|
|
|
|
}),
|
|
|
|
|
snapshotGate(validatedBuild),
|
|
|
|
|
webSnapshotGate(validatedBuild),
|
|
|
|
|
pnpmScript('doc-typecheck', 'doc-typecheck', {
|
|
|
|
|
needs: validatedBuild,
|
|
|
|
|
env: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' },
|
2026-07-28 00:36:05 +08:00
|
|
|
}),
|
2026-07-27 21:27:42 +08:00
|
|
|
pnpmScript('node-next-types', 'verify-node-next-types', {
|
|
|
|
|
label: 'node-next types',
|
2026-07-30 19:02:10 +08:00
|
|
|
needs: validatedBuild,
|
2026-07-27 21:27:42 +08:00
|
|
|
}),
|
2026-07-30 19:02:10 +08:00
|
|
|
builtBinSmokeGate(validatedBuild),
|
2026-07-27 21:27:42 +08:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-30 10:23:05 +08:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 14:42:43 +08:00
|
|
|
function ciWindowsBlockingGates(): Gate[] {
|
|
|
|
|
return [
|
|
|
|
|
pnpmScript('windows-build', 'build', { label: 'build' }),
|
|
|
|
|
pnpmScript('windows-site', 'docs:build', { label: 'production site' }),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 15:44:11 +08:00
|
|
|
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,
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 14:42:43 +08:00
|
|
|
function ciWindowsObservationalGates(): Gate[] {
|
|
|
|
|
return [
|
2026-07-30 19:02:10 +08:00
|
|
|
...ciStaticGates({ ownsBuild: true }),
|
2026-07-23 16:13:33 +08:00
|
|
|
// Linux owns required lint, coverage, and snapshots; Windows omits those duplicates.
|
2026-07-22 14:42:43 +08:00
|
|
|
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(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 22:38:20 +08:00
|
|
|
function lintGate(): Gate {
|
2026-07-29 14:32:11 +08:00
|
|
|
const raw = process.env.DSH_OXLINT_THREADS
|
2026-07-29 23:39:49 +08:00
|
|
|
return pnpmScript('lint', 'lint', raw === undefined || raw === ''
|
|
|
|
|
? {}
|
|
|
|
|
: { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` })
|
2026-07-22 14:53:46 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-31 02:24:37 +08:00
|
|
|
// 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).
|
2026-07-31 02:53:54 +08:00
|
|
|
//
|
|
|
|
|
// 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)}`],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-31 02:24:37 +08:00
|
|
|
function coverageGates(): Gate[] {
|
2026-07-31 02:53:54 +08:00
|
|
|
const workers = coverageWorkerArgs()
|
2026-07-31 02:24:37 +08:00
|
|
|
return [
|
|
|
|
|
pnpmExec('coverage', [
|
|
|
|
|
'vitest',
|
|
|
|
|
'run',
|
|
|
|
|
'--coverage',
|
2026-07-31 02:53:54 +08:00
|
|
|
...workers.instrumented,
|
2026-07-31 02:24:37 +08:00
|
|
|
], {
|
|
|
|
|
label: 'test:coverage',
|
|
|
|
|
env: { [COVERAGE_EXEMPT_ENV]: '1' },
|
|
|
|
|
}),
|
|
|
|
|
pnpmExec('coverage-exempt-heavy', [
|
|
|
|
|
'vitest',
|
|
|
|
|
'run',
|
|
|
|
|
...coverageExemptHeavySuites.map(suite => suite.filter),
|
2026-07-31 02:53:54 +08:00
|
|
|
...workers.exempt,
|
2026-07-31 02:24:37 +08:00
|
|
|
], {
|
|
|
|
|
label: 'test:coverage-exempt-heavy',
|
|
|
|
|
}),
|
|
|
|
|
]
|
2026-07-06 02:39:26 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-23 22:41:53 +08:00
|
|
|
// 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.
|
2026-07-30 19:02:10 +08:00
|
|
|
// Callers wait either on `build` or on a validation gate that transitively owns that build.
|
2026-07-27 21:27:42 +08:00
|
|
|
function snapshotGate(needs: string[] = ['build']): Gate {
|
2026-07-17 15:42:35 +08:00
|
|
|
return pnpmScript('snapshot', 'test:snapshot', {
|
2026-07-28 15:33:20 +08:00
|
|
|
env: { DSH_EXAMPLE_MODE: 'lib' },
|
2026-07-27 21:27:42 +08:00
|
|
|
needs,
|
2026-07-06 02:39:26 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 00:25:38 +08:00
|
|
|
function builtPackageInvariantsGate(needs?: string[]): Gate {
|
|
|
|
|
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
|
|
|
|
|
label: 'built package invariants',
|
|
|
|
|
...needs === undefined ? {} : { needs },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 02:39:26 +08:00
|
|
|
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}`]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 20:15:09 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 17:37:43 +08:00
|
|
|
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: {
|
2026-07-30 19:02:10 +08:00
|
|
|
includeDocTypecheck?: boolean
|
2026-07-22 17:37:43 +08:00
|
|
|
docTypecheckNeeds?: string[]
|
2026-07-28 15:33:20 +08:00
|
|
|
docTypecheckEnv?: Record<string, string | undefined>
|
2026-07-21 21:28:49 +08:00
|
|
|
docsBuildScript?: 'docs:build' | 'docs:build:mpa'
|
2026-07-22 17:37:43 +08:00
|
|
|
} = {}): Gate[] {
|
|
|
|
|
const docTypecheckOptions: Partial<Gate> = {}
|
|
|
|
|
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
|
|
|
|
|
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
|
2026-07-06 01:47:13 +08:00
|
|
|
return [
|
2026-07-30 19:02:10 +08:00
|
|
|
...options.includeDocTypecheck === false
|
|
|
|
|
? []
|
|
|
|
|
: [pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions)],
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
2026-07-07 20:30:34 +08:00
|
|
|
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
2026-07-07 20:30:34 +08:00
|
|
|
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
|
|
|
|
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
2026-07-14 23:14:51 +08:00
|
|
|
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
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' }),
|
2026-07-12 02:12:36 +08:00
|
|
|
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('mermaid', 'verify-mermaid'),
|
2026-07-19 22:50:49 +08:00
|
|
|
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification' }),
|
|
|
|
|
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format' }),
|
2026-07-26 23:06:00 +08:00
|
|
|
pnpmScript('archived-agent-notes', 'verify-archived-agent-notes', { label: 'archived agent notes' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
2026-07-14 23:09:01 +08:00
|
|
|
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
|
|
|
|
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
2026-07-21 21:05:39 +08:00
|
|
|
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.
|
2026-07-21 21:28:49 +08:00
|
|
|
pnpmScript('docs-site-build', options.docsBuildScript ?? 'docs:build', { label: 'documentation build' }),
|
2026-07-14 00:22:52 +08:00
|
|
|
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
2026-07-06 01:47:13 +08:00
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
2026-07-06 01:47:13 +08:00
|
|
|
return pnpmExec('built-bin-smoke', [
|
|
|
|
|
'vitest',
|
|
|
|
|
'run',
|
|
|
|
|
'--config',
|
|
|
|
|
'vitest.e2e.config.ts',
|
2026-07-20 20:16:41 +08:00
|
|
|
'examples/headless-agent/tests/keyless-smoke.e2e.ts',
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
'apps/cli/tests/tui-keyless-smoke.e2e.ts',
|
2026-07-15 21:21:24 +08:00
|
|
|
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
2026-07-15 15:57:57 +08:00
|
|
|
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
2026-07-14 08:55:38 +08:00
|
|
|
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
2026-07-09 21:34:52 +08:00
|
|
|
// The worker-entry packages' built bundles: the only automated proof
|
2026-07-13 20:55:47 +08:00
|
|
|
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
2026-07-09 21:34:52 +08:00
|
|
|
// (the e2e lane runs unbuilt, so these files self-skip there).
|
2026-07-09 19:06:55 +08:00
|
|
|
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
|
feat: add the worker-thread code runtime (dsh-code-runtime-worker)
The shipped backend of the code-execution seam, per the Code Mode RFC's
worker-thread section: one fresh Node worker per run, executing the
model's TypeScript after a host-side type-strip (wrapped in an
async-function shell so top-level return/await parse, sliced back out
position-preserved), bindings bridged over the message port under
hostile-peer rules (own-property name lookup, at-most-once replies,
post-settlement drops, null-prototype namespaces), logs streamed eagerly
with an in-band truncation marker, and two independent budgets — measured
event-loop busy time (computeMs) plus a never-pausing wall ceiling
(maxWallMs) — funneling into worker.terminate(). env: {} and execArgv: []
keep the isolate hermetic; disposal aborts in-flight runs and awaits
worker exits.
The worker entry loads unbuilt via Node's native type stripping
(src/worker.ts, erasable-only) and ships built as a sibling tsdown bundle
(lib/worker.js); tests/built-lib.e2e.ts pins the built load path under
plain node and joins the built-artifact smoke gate. Unit suites cover the
bootstrap in-process (fake port) and the runtime over real workers,
per-file 100%.
2026-07-08 11:00:06 +08:00
|
|
|
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
2026-07-06 01:47:13 +08:00
|
|
|
], {
|
|
|
|
|
label: 'built-bin smoke',
|
2026-07-27 21:27:42 +08:00
|
|
|
needs,
|
2026-07-28 15:33:20 +08:00
|
|
|
env: { DSH_EXAMPLE_MODE: 'lib' },
|
2026-07-27 21:27:42 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-28 15:33:20 +08:00
|
|
|
* Reject a gate list whose graph cannot be executed unambiguously.
|
|
|
|
|
* @param gates - complete aggregate to validate.
|
2026-07-27 21:27:42 +08:00
|
|
|
*/
|
2026-07-28 15:33:20 +08:00
|
|
|
function validateGateGraph(gates: readonly Gate[]): void {
|
|
|
|
|
if (gates.length === 0) throw new Error('run-gates: gate graph has no gates.')
|
2026-07-27 21:27:42 +08:00
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
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)
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
2026-07-28 15:33:20 +08:00
|
|
|
for (const gate of gates) {
|
2026-07-27 21:27:42 +08:00
|
|
|
for (const dependency of gate.needs ?? []) {
|
|
|
|
|
if (!ids.has(dependency)) {
|
2026-07-28 15:33:20 +08:00
|
|
|
throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`)
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
const cycle = findDependencyCycle(gates)
|
|
|
|
|
if (cycle !== undefined) throw new Error(`run-gates: dependency cycle: ${cycle.join(' -> ')}.`)
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-28 15:33:20 +08:00
|
|
|
* Validate and run one aggregate before the injected executor can start a child.
|
|
|
|
|
* @param gates - complete aggregate to execute.
|
2026-07-27 21:27:42 +08:00
|
|
|
* @param maxActive - maximum concurrent child count.
|
|
|
|
|
* @param execute - child-process executor.
|
2026-07-27 23:47:00 +08:00
|
|
|
* @param observe - result observer invoked when each gate settles.
|
2026-07-28 15:33:20 +08:00
|
|
|
* @returns results in aggregate order.
|
2026-07-27 21:27:42 +08:00
|
|
|
*/
|
2026-07-28 15:33:20 +08:00
|
|
|
export async function runGates(
|
|
|
|
|
gates: Gate[],
|
2026-07-27 21:27:42 +08:00
|
|
|
maxActive: number,
|
|
|
|
|
execute: GateExecutor,
|
|
|
|
|
observe: ResultObserver = () => {},
|
|
|
|
|
): Promise<GateResult[]> {
|
2026-07-28 15:33:20 +08:00
|
|
|
validateGateGraph(gates)
|
2026-07-27 21:27:42 +08:00
|
|
|
if (!Number.isSafeInteger(maxActive) || maxActive < 1) {
|
|
|
|
|
throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`)
|
|
|
|
|
}
|
2026-07-28 15:33:20 +08:00
|
|
|
const states = new Map<string, GateState>(gates.map(gate => [gate.id, 'pending']))
|
2026-07-06 01:47:13 +08:00
|
|
|
const results = new Map<string, GateResult>()
|
|
|
|
|
const running: RunningGate[] = []
|
|
|
|
|
|
|
|
|
|
for (;;) {
|
|
|
|
|
let madeProgress = false
|
|
|
|
|
while (running.length < maxActive) {
|
2026-07-28 15:33:20 +08:00
|
|
|
const ready = gates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
|
2026-07-06 01:47:13 +08:00
|
|
|
if (ready === undefined) break
|
|
|
|
|
states.set(ready.id, 'running')
|
2026-07-27 21:27:42 +08:00
|
|
|
running.push({ gate: ready, promise: execute(ready) })
|
2026-07-06 01:47:13 +08:00
|
|
|
console.log(`run-gates: start ${ready.label}`)
|
|
|
|
|
madeProgress = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (running.length === 0) {
|
2026-07-28 15:33:20 +08:00
|
|
|
let pending = gates.filter(gate => states.get(gate.id) === 'pending')
|
2026-07-28 00:12:16 +08:00
|
|
|
while (pending.length > 0) {
|
|
|
|
|
const gate = pending.find(item => (item.needs ?? []).some((id) => {
|
|
|
|
|
const state = states.get(id)
|
|
|
|
|
return state === 'failed' || state === 'skipped'
|
|
|
|
|
}))
|
2026-07-28 15:33:20 +08:00
|
|
|
if (gate === undefined) throw new Error('run-gates: validated graph stalled without a failed dependency.')
|
2026-07-28 00:12:16 +08:00
|
|
|
const failedDeps = (gate.needs ?? []).filter((id) => {
|
|
|
|
|
const state = states.get(id)
|
|
|
|
|
return state === 'failed' || state === 'skipped'
|
|
|
|
|
})
|
2026-07-06 01:47:13 +08:00
|
|
|
const result: GateResult = {
|
|
|
|
|
gate,
|
|
|
|
|
status: 'skipped',
|
|
|
|
|
durationMs: 0,
|
2026-07-15 23:43:10 +08:00
|
|
|
output: [],
|
2026-07-06 01:47:13 +08:00
|
|
|
exitCode: null,
|
2026-07-27 21:27:42 +08:00
|
|
|
signalCode: null,
|
2026-07-06 01:47:13 +08:00
|
|
|
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
|
|
|
|
}
|
|
|
|
|
states.set(gate.id, 'skipped')
|
|
|
|
|
results.set(gate.id, result)
|
2026-07-27 23:47:00 +08:00
|
|
|
observe(result)
|
2026-07-28 00:12:16 +08:00
|
|
|
pending = pending.filter(item => item !== gate)
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
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)
|
2026-07-27 23:47:00 +08:00
|
|
|
observe(settled.result)
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
return gates.map((gate) => {
|
2026-07-06 01:47:13 +08:00
|
|
|
const result = results.get(gate.id)
|
|
|
|
|
if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`)
|
|
|
|
|
return result
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 00:13:52 +08:00
|
|
|
function dependenciesPassed(gate: Gate, states: Map<string, GateState>): boolean {
|
2026-07-06 01:47:13 +08:00
|
|
|
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
/**
|
|
|
|
|
* Execute one gate through the real shell-free child-process boundary.
|
|
|
|
|
* @param gate - command and scheduler environment to execute.
|
2026-07-28 15:33:20 +08:00
|
|
|
* @returns the complete process outcome.
|
2026-07-27 21:27:42 +08:00
|
|
|
*/
|
|
|
|
|
export async function runGate(gate: Gate): Promise<GateResult> {
|
2026-07-06 01:47:13 +08:00
|
|
|
const started = performance.now()
|
2026-07-15 23:43:10 +08:00
|
|
|
const output: GateOutputChunk[] = []
|
|
|
|
|
let spawnError: string | undefined
|
2026-07-06 01:47:13 +08:00
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
const outcome = await new Promise<{
|
|
|
|
|
exitCode: number | null
|
|
|
|
|
signalCode: NodeJS.Signals | null
|
|
|
|
|
}>((resolveExit) => {
|
2026-07-06 01:47:13 +08:00
|
|
|
const child = spawn(gate.command, gate.args, {
|
|
|
|
|
cwd: root,
|
2026-07-28 15:33:20 +08:00
|
|
|
env: { ...process.env, ...gate.env },
|
2026-07-06 01:47:13 +08:00
|
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
|
|
|
})
|
|
|
|
|
child.stdout.setEncoding('utf8')
|
|
|
|
|
child.stderr.setEncoding('utf8')
|
2026-07-15 23:43:10 +08:00
|
|
|
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}`
|
2026-07-27 21:27:42 +08:00
|
|
|
resolveExit({ exitCode: null, signalCode: null })
|
|
|
|
|
})
|
|
|
|
|
child.on('close', (exitCode, signalCode) => {
|
|
|
|
|
resolveExit({ exitCode, signalCode })
|
2026-07-15 23:43:10 +08:00
|
|
|
})
|
2026-07-28 15:33:20 +08:00
|
|
|
child.stdin.end()
|
2026-07-06 01:47:13 +08:00
|
|
|
})
|
2026-07-27 21:27:42 +08:00
|
|
|
const { exitCode, signalCode } = outcome
|
2026-07-06 01:47:13 +08:00
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
const status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed'
|
2026-07-06 01:47:13 +08:00
|
|
|
const result: GateResult = {
|
|
|
|
|
gate,
|
|
|
|
|
status,
|
|
|
|
|
durationMs: performance.now() - started,
|
2026-07-15 23:43:10 +08:00
|
|
|
output,
|
2026-07-06 01:47:13 +08:00
|
|
|
exitCode,
|
2026-07-27 21:27:42 +08:00
|
|
|
signalCode,
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
2026-07-28 15:33:20 +08:00
|
|
|
if (spawnError !== undefined) result.error = spawnError
|
2026-07-06 01:47:13 +08:00
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 21:27:42 +08:00
|
|
|
/**
|
|
|
|
|
* 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(', ')
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
function printResult(result: GateResult): void {
|
2026-07-27 21:27:42 +08:00
|
|
|
const verbose = process.env.DSH_GATE_VERBOSE === '1'
|
2026-07-06 01:47:13 +08:00
|
|
|
const seconds = (result.durationMs / 1000).toFixed(2)
|
2026-07-15 23:43:10 +08:00
|
|
|
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} ==`)
|
2026-07-27 21:27:42 +08:00
|
|
|
if (result.status !== 'passed') {
|
|
|
|
|
console.error(`command: ${result.gate.displayCommand}`)
|
2026-07-28 00:12:16 +08:00
|
|
|
console.error(`outcome: ${formatGateResultReason(result)}`)
|
2026-07-27 21:27:42 +08:00
|
|
|
}
|
2026-07-15 23:43:10 +08:00
|
|
|
printOutput(result.output)
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:33:20 +08:00
|
|
|
function printSummary(results: GateResult[], durationMs: number): void {
|
2026-07-06 01:47:13 +08:00
|
|
|
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.`)
|
2026-07-15 23:43:10 +08:00
|
|
|
|
|
|
|
|
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)
|
2026-07-27 21:27:42 +08:00
|
|
|
const reason = formatGateResultReason(result)
|
2026-07-22 15:44:11 +08:00
|
|
|
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
|
|
|
|
|
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
|
2026-07-28 15:33:20 +08:00
|
|
|
console.error(` ${result.gate.displayCommand}`)
|
2026-07-15 23:43:10 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-07-06 01:47:13 +08:00
|
|
|
}
|