From 3e6fbccffaa55120189b61b3b2ae800585d79104 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:58:27 +0800 Subject: [PATCH] refactor(dev-infra): trim gate plan surfaces --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 2 +- .../2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 37 +------ scripts/run-gates.ts | 96 ++++++------------- 5 files changed, 37 insertions(+), 104 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 98e78e752a..aa9b6d0cbc 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b -2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c +2026-07-27-replayable-gate-plans.md: 8a42ae3c89a3f75bb248f78583f6ef825af3241e +2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index a312481a4d..8a42ae3c89 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -14,7 +14,7 @@ Operators also need the scheduler-owned environment and dependency context for a [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract. -Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn, so inspection never enumerates or bakes in inherited values; values under secret-like names are redacted. +Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection therefore never enumerates or bakes in inherited values; values under secret-like names are redacted. `--only ` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run -- --only `, which restores dependency and environment semantics through the scheduler. diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index c213068f54..572ef1ce13 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -14,7 +14,7 @@ Status: implemented [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。 -每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查结果因此不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 1a86ccd064..d112bccd89 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,10 +1,4 @@ -import { - mkdtempSync, - rmSync, - symlinkSync, -} from 'node:fs' import { spawnSync } from 'node:child_process' -import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -15,7 +9,6 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, - isMainModule, listedGatePlan, parseCliRequest, replayCommand, @@ -28,13 +21,9 @@ import { type GateResult, } from './run-gates.ts' -const temporaryRoots: string[] = [] const repositoryRoot = join(import.meta.dirname, '..') -afterEach(() => { - vi.unstubAllEnvs() - for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) -}) +afterEach(() => vi.unstubAllEnvs()) function gate(id: string, options: Partial = {}): Gate { return { @@ -64,12 +53,6 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-run-gates-'): string { - const root = mkdtempSync(join(tmpdir(), prefix)) - temporaryRoots.push(root) - return root -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -172,10 +155,10 @@ describe('gate plan validation', () => { describe('gate plan inspection and replay', () => { it('parses package-script separators, list JSON, and focused runs', () => { expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ - kind: 'run', mode: 'check-all', list: true, json: true, + mode: 'check-all', list: true, json: true, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ - kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', + mode: 'check-all', list: false, json: false, only: 'snapshot', }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') @@ -252,15 +235,6 @@ describe('gate plan inspection and replay', () => { }) }) - it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { - const temporary = temporaryRoot('dsh-run-gates-entry-') - const entry = join(temporary, 'run-gates.ts') - symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) - - expect(isMainModule(entry)).toBe(true) - expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) - }) - it('renders a cross-platform scheduler replay and labels focused evidence', () => { const subject = plan([gate('snapshot')]) expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot') @@ -269,14 +243,13 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append, set, and unset operations only when spawning', () => { + it('resolves append and set operations only when spawning', () => { const resolved = resolveGateEnvironment(gate('subject', { env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, - REMOVE_ME: { operation: 'unset' }, }, - }), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' }) + }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) expect(resolved).toEqual({ NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', MODE: 'lib', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1f6d18c2e0..9a8367ec79 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -6,38 +6,38 @@ * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { realpathSync } from 'node:fs' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' -const MODES = [ - 'ci-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', -] as const +const MODE_SCRIPTS = { + 'ci-primary': 'check:ci', + 'ci-static': 'check:ci:static', + 'ci-lint': 'check:ci:lint', + 'ci-coverage': 'check:ci:coverage', + 'ci-snapshot': 'check:ci:snapshot', + 'ci-artifacts': 'check:ci:artifacts', + 'ci-consumers': 'check:ci:consumers', + 'ci-windows-blocking': 'check:ci:windows-blocking', + 'ci-windows-complete': 'check:ci:windows-complete', + 'ci-windows-observational': 'check:ci:windows-observational', + 'node-compat': 'check:node-compat', + 'check-all': 'check:all', + 'doc-sync': 'doc-sync', +} as const /** A named aggregate exposed by the gate runner. */ -export type Mode = typeof MODES[number] +export type Mode = keyof typeof MODE_SCRIPTS + +const MODES = Object.keys(MODE_SCRIPTS) as Mode[] type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = | { operation: 'set'; value: string } - | { operation: 'unset' } - | { operation: 'append'; value: string; separator?: string } + | { operation: 'append'; value: string } /** A command and its dependency metadata inside one gate plan. */ export interface Gate { @@ -91,18 +91,13 @@ export interface ResolvedConcurrency { } interface RunRequest { - kind: 'run' mode: Mode list: boolean json: boolean only?: string } -interface ListedEnvironmentOverride { - operation: GateEnvironmentOverride['operation'] - value?: string - separator?: string -} +type ListedEnvironmentOverride = GateEnvironmentOverride interface ListedGate { id: string @@ -126,24 +121,11 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const MODE_SCRIPTS: Record = { - 'ci-primary': 'check:ci', - 'ci-static': 'check:ci:static', - 'ci-lint': 'check:ci:lint', - 'ci-coverage': 'check:ci:coverage', - 'ci-snapshot': 'check:ci:snapshot', - 'ci-artifacts': 'check:ci:artifacts', - 'ci-consumers': 'check:ci:consumers', - 'ci-windows-blocking': 'check:ci:windows-blocking', - 'ci-windows-complete': 'check:ci:windows-complete', - 'ci-windows-observational': 'check:ci:windows-observational', - 'node-compat': 'check:node-compat', - 'check-all': 'check:all', - 'doc-sync': 'doc-sync', +const entry = process.argv[1] +if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { + process.exitCode = await main(process.argv.slice(2)) } -if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) - async function main(args: string[]): Promise { const request = parseCliRequest(args) const completePlan = gatePlanForMode(request.mode) @@ -174,21 +156,6 @@ async function main(args: string[]): Promise { : 0 } -/** - * Decide whether this module is the process entry, including through a symlinked path. - * @param entry - process entry path to compare with this module. - * @returns Whether the entry resolves to this module. - */ -export function isMainModule(entry: string | undefined = process.argv[1]): boolean { - if (entry === undefined) return false - if (import.meta.url === pathToFileURL(resolve(entry)).href) return true - try { - return import.meta.url === pathToFileURL(realpathSync(entry)).href - } catch { - return false - } -} - /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. @@ -220,7 +187,7 @@ export function parseCliRequest(args: readonly string[]): RunRequest { } if (json && !list) throw new Error('run-gates: --json requires --list.') if (list && only !== undefined) throw new Error('run-gates: --list and --only are mutually exclusive.') - return { kind: 'run', mode, list, json, ...only === undefined ? {} : { only } } + return { mode, list, json, ...only === undefined ? {} : { only } } } function parseMode(raw: string | undefined): Mode { @@ -798,13 +765,8 @@ function listedEnvironment( ): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { - const value = 'value' in override - ? { value: sensitiveEnvironmentName(name) ? '' : override.value } - : {} - const separator = override.operation === 'append' && override.separator !== undefined - ? { separator: override.separator } - : {} - return [name, { operation: override.operation, ...value, ...separator }] + const value = sensitiveEnvironmentName(name) ? '' : override.value + return [name, { operation: override.operation, value }] })) } @@ -874,15 +836,13 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'unset') { - Reflect.deleteProperty(resolved, name) - } else if (override.operation === 'set') { + if (override.operation === 'set') { resolved[name] = override.value } else { const current = resolved[name] resolved[name] = current === undefined || current === '' ? override.value - : `${current}${override.separator ?? ' '}${override.value}` + : `${current} ${override.value}` } } return resolved