From 4bb60b10692f98c1869da8cdc3e62c23e08c7968 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:42 +0800 Subject: [PATCH 01/13] feat(dev-infra): make gate plans inspectable and replayable --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 6 + .../2026-07-27-replayable-gate-plans.md | 43 + .../2026-07-27-replayable-gate-plans.zh.md | 43 + .github/workflows/ci.yml | 39 +- package.json | 1 + scripts/gate-log-helper.mjs | 220 ++++ scripts/publint-all.spec.ts | 6 + scripts/run-gates.spec.ts | 523 +++++++++ scripts/run-gates.ts | 1011 +++++++++++++++-- 9 files changed, 1758 insertions(+), 134 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md create mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md create mode 100644 scripts/gate-log-helper.mjs create mode 100644 scripts/run-gates.spec.ts 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 new file mode 100644 index 0000000000..3966caf9cf --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 604fb87ebe91f8ebd606d128e8927535502b6ea9 +2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 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 new file mode 100644 index 0000000000..604fb87ebe --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -0,0 +1,43 @@ +# Agent Note: Validated, self-describing, replayable gate plans + +Status: implemented + +English | [中文](2026-07-27-replayable-gate-plans.zh.md) + +## Problem + +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. + +The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. + +## Decision + +[`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 (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake 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. + +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. + +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. + +## Verification + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. + +## Alternatives considered + +- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. +- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. +- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. +- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. +- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. + +## Consequences + +The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. + +Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. + +Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. 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 new file mode 100644 index 0000000000..e24d77ff62 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 经过验证、自描述、可回放的门禁计划 + +Status: implemented + +[English](2026-07-27-replayable-gate-plans.md) | 中文 + +## 问题 + +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 + +Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 + +## 决策 + +[`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 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 + +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 + +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 + +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 + +## 验证 + +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 + +## 曾考虑的替代方案 + +- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 +- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 +- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 +- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 +- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 + +## 后果 + +调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 + +后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 + +保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aecff76a7d..3639333861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,44 +240,7 @@ jobs: exit "$sandbox_status" - name: Run compatibility, snapshot, and artifact gates - run: | - pnpm run check:ci:lint & - lint_pid=$! - pnpm run check:node-compat & - compat_pid=$! - DSH_EXAMPLE_MODE=lib pnpm run test:snapshot & - snapshot_pid=$! - pnpm run publint & - publint_pid=$! - pnpm run verify-node-next-types & - node_next_pid=$! - pnpm run verify-built-package-invariants & - built_invariants_pid=$! - DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \ - examples/headless-agent/tests/keyless-smoke.e2e.ts \ - examples/tui-agent/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 \ - packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \ - packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts & - built_bin_pid=$! - - final_status=0 - capture_status() { - local child_status=0 - wait "$1" || child_status=$? - if (( final_status == 0 && child_status != 0 )); then - final_status=$child_status - fi - } - for child_pid in \ - "$lint_pid" "$compat_pid" "$snapshot_pid" \ - "$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid" - do - capture_status "$child_pid" - done - exit "$final_status" + run: pnpm run check:ci:consumers node-compat: diff --git a/package.json b/package.json index d74a780925..a004a99d85 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", + "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs new file mode 100644 index 0000000000..363b538f70 --- /dev/null +++ b/scripts/gate-log-helper.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +/** Pin the repository and each log-path component before creating or operating on private logs. */ + +import { constants } from 'node:fs' +import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' +import { isAbsolute, sep } from 'node:path' + +const MAX_REQUEST_BYTES = 8 * 1024 * 1024 +const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ + +function errorCode(error) { + return typeof error === 'object' && error !== null && 'code' in error + ? error.code + : undefined +} + +async function readRequest() { + const chunks = [] + let bytes = 0 + for await (const chunk of process.stdin) { + bytes += chunk.length + if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') + chunks.push(chunk) + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function assertInteger(value, label, minimum) { + if (!Number.isSafeInteger(value) || value < minimum) { + throw new Error(`${label} must be an integer of at least ${minimum}`) + } +} + +function assertLogName(name) { + if (typeof name !== 'string' || !LOG_NAME.test(name)) { + throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) + } +} + +function assertRequest(request) { + if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') + switch (request.operation) { + case 'write': + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + return + case 'prune': + assertInteger(request.retain, 'retain', 0) + return + case 'clean': + return + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +function assertIdentity(value, label) { + if ( + typeof value !== 'object' + || value === null + || typeof value.dev !== 'string' + || typeof value.ino !== 'string' + ) { + throw new Error(`missing expected ${label} identity`) + } +} + +function identityOf(metadata) { + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +function sameIdentity(metadata, expected) { + return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino +} + +async function assertPinnedRepository(repository) { + if ( + typeof repository !== 'object' + || repository === null + || typeof repository.root !== 'string' + || !isAbsolute(repository.root) + || typeof repository.relative !== 'string' + || repository.relative === '' + || repository.relative === '..' + || repository.relative.startsWith(`..${sep}`) + || isAbsolute(repository.relative) + ) { + throw new Error('invalid repository-relative gate-log path') + } + assertIdentity(repository.identity, 'repository') + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { + throw new Error('gate-log repository identity changed before the helper started') + } + const rootMetadata = await lstat(repository.root, { bigint: true }) + if ( + !rootMetadata.isDirectory() + || rootMetadata.isSymbolicLink() + || !sameIdentity(rootMetadata, repository.identity) + ) { + throw new Error('gate-log repository root is not a real directory') + } +} + +async function enterLogDirectory(relativePath, create) { + const traversed = [] + for (const component of relativePath.split(sep)) { + if (component === '' || component === '.' || component === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + } + traversed.push(component) + let componentMetadata + try { + componentMetadata = await lstat(component, { bigint: true }) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + if (!create) return undefined + try { + await mkdir(component, { mode: 0o700 }) + } catch (mkdirError) { + if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + } + componentMetadata = await lstat(component, { bigint: true }) + } + const shown = traversed.join('/') + if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { + throw new Error(`gate-log path component is not a real directory: ${shown}`) + } + const expected = identityOf(componentMetadata) + process.chdir(component) + const pinnedMetadata = await stat('.', { bigint: true }) + if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { + throw new Error(`gate-log path component identity changed before pinning: ${shown}`) + } + } + await chmod('.', 0o700) + return identityOf(await stat('.', { bigint: true })) +} + +async function removeOldLogs(retain, newest) { + assertInteger(retain, 'retain', 0) + const entries = await readdir('.', { withFileTypes: true }) + const logs = [] + for (const entry of entries) { + if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue + let metadata + try { + metadata = await lstat(entry.name, { bigint: true }) + } catch (error) { + if (errorCode(error) === 'ENOENT') continue + throw error + } + if (!metadata.isFile() || metadata.isSymbolicLink()) continue + logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) + } + logs.sort((left, right) => { + if (left.name === newest) return 1 + if (right.name === newest) return -1 + if (left.mtimeNs < right.mtimeNs) return -1 + if (left.mtimeNs > right.mtimeNs) return 1 + return left.name.localeCompare(right.name) + }) + const removed = [] + for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { + try { + await unlink(entry.name) + removed.push(entry.name) + } catch (error) { + if (errorCode(error) !== 'ENOENT') throw error + } + } + return removed +} + +async function writeLog(request) { + assertLogName(request.filename) + assertInteger(request.retention, 'retention', 1) + if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') + const handle = await open( + request.filename, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ) + try { + await handle.writeFile(request.content, 'utf8') + await handle.chmod(0o600) + } finally { + await handle.close() + } + const removed = await removeOldLogs(request.retention, request.filename) + return { filename: request.filename, removed } +} + +async function main() { + const request = await readRequest() + assertRequest(request) + await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + if (directory === undefined) return { removed: [] } + switch (request.operation) { + case 'write': { + const result = await writeLog(request) + return { ...result, directory } + } + case 'prune': + return { directory, removed: await removeOldLogs(request.retain) } + case 'clean': + return { directory, removed: await removeOldLogs(0) } + default: + throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) + } +} + +try { + process.stdout.write(`${JSON.stringify(await main())}\n`) +} catch (error) { + process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 +} diff --git a/scripts/publint-all.spec.ts b/scripts/publint-all.spec.ts index 22dd80d6b0..9e6d4ef26e 100644 --- a/scripts/publint-all.spec.ts +++ b/scripts/publint-all.spec.ts @@ -58,4 +58,10 @@ describe('publint package runner', () => { expect(result.status).toBe(1) expect(result.stdout).toContain('unpublished.js') }) + + it('rejects a public export whose built file is missing', () => { + const result = run(fixture('./lib/missing.js')) + expect(result.status).toBe(1) + expect(result.stdout).toContain('missing.js') + }) }) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts new file mode 100644 index 0000000000..49c1aab675 --- /dev/null +++ b/scripts/run-gates.spec.ts @@ -0,0 +1,523 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} 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 { + cleanGateFailureLogs, + executeGatePlan, + failureLogUnavailableReason, + formatGateFailureLog, + formatGatePlanJson, + formatGatePlanList, + formatGateResultReason, + formatOnlyNotice, + gateDependencyClosure, + gatePlanForMode, + listedGatePlan, + limitGateFailureLog, + parseCliRequest, + pruneGateLogs, + replayCommand, + resolveGateEnvironment, + resolvePlanConcurrency, + runGate, + validateGatePlan, + writeGateFailureLog, + type Gate, + type GatePlan, + 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 }) +}) + +function gate(id: string, options: Partial = {}): Gate { + return { + id, + label: id, + displayCommand: `run ${id}`, + command: process.execPath, + args: ['-e', ''], + ...options, + } +} + +function plan(gates: Gate[]): GatePlan { + return { mode: 'check-all', script: 'check:all', gates } +} + +function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { + return { + gate: subject, + status, + durationMs: 10, + stdout: '', + stderr: '', + output: [], + exitCode: status === 'passed' ? 0 : 1, + signalCode: null, + } +} + +function temporaryRoot(prefix = 'dsh-gate-logs-'): 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' + try { + return action() + } finally { + if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath') + else process.env.npm_execpath = previous + } +} + +describe('gate plan validation', () => { + it.each([ + '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)('constructs a valid non-empty %s plan', (mode) => { + const subject = withPnpmEntrypoint(() => gatePlanForMode(mode)) + expect(() => { + validateGatePlan(subject) + }).not.toThrow() + }) + + it.each([ + ['empty', plan([]), /plan has no gates/], + ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], + ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], + ])('rejects %s before starting a child', async (_label, invalid, message) => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message) + expect(execute).not.toHaveBeenCalled() + }) + + it('rejects an invalid plan worker bound', () => { + expect(() => { + validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 }) + }).toThrow( + 'maxWorkers must be a positive integer', + ) + }) + + it('rejects an executor request above the plan worker ceiling before starting a child', async () => { + const execute = vi.fn(async (subject: Gate) => resultFor(subject)) + await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow( + 'exceeds the check-all plan ceiling 1', + ) + expect(execute).not.toHaveBeenCalled() + }) + + it('selects a target with its transitive dependencies in canonical plan order', () => { + const subject = plan([ + gate('prepare'), + gate('build', { needs: ['prepare'] }), + gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }), + gate('unrelated'), + ]) + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot']) + expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) + +describe('gate plan inspection and replay', () => { + it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ + kind: 'run', 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', + }) + expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) + expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') + expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') + }) + + it('renders deterministic human and stable JSON fields without inherited environment values', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subject = plan([ + gate('prepare'), + gate('subject', { + needs: ['prepare'], + allowFailure: true, + env: { + Z_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + }, + }), + ]) + + const json = formatGatePlanJson(subject) + expect(formatGatePlanJson(subject)).toBe(json) + expect(json).not.toContain('ambient-secret') + expect(json).not.toContain('scheduler-secret') + expect(JSON.parse(json)).toEqual({ + version: 1, + mode: 'check-all', + script: 'check:all', + scope: 'complete', + maxWorkers: null, + gates: [ + { id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true }, + { + id: 'subject', + label: 'subject', + command: 'run subject', + needs: ['prepare'], + env: { + ACCESS_TOKEN: { operation: 'set', value: '' }, + NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, + Z_MODE: { operation: 'set', value: 'lib' }, + }, + blocking: false, + }, + ], + }) + expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject') + expect(formatGatePlanList(subject)).toContain('needs: prepare') + expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)') + }) + + it('emits one clean JSON object through the documented silent package-script entry', () => { + const result = spawnSync('pnpm', [ + '--silent', + 'run', + 'check:ci:consumers', + '--', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + timeout: 10_000, + }) + if (result.error !== undefined) throw result.error + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ + version: 1, + mode: 'ci-consumers', + script: 'check:ci:consumers', + scope: 'complete', + maxWorkers: 7, + }) + }) + + 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') + expect(formatOnlyNotice(subject, 'snapshot')).toBe( + 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', + ) + }) + + it('resolves append, set, and unset 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' }) + expect(resolved).toEqual({ + NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', + MODE: 'lib', + INHERITED: 'kept', + }) + }) + + it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => { + const subjectGate = gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + }) + const result = await runGate(subjectGate) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') + expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') + }) +}) + +describe('gate failure logs', () => { + it('records attributable scheduler metadata without inherited secrets', () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') + const subjectGate = gate('snapshot', { + env: { + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, + }, + }) + const subject = plan([subjectGate]) + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: 'failure details\n' }], + stderr: 'failure details\n', + } + const log = formatGateFailureLog(subject, failure) + expect(log).toContain('replay: pnpm run check:all -- --only snapshot') + expect(log).toContain('DSH_EXAMPLE_MODE') + expect(log).toContain('') + expect(log).toContain('[stderr]\nfailure details') + expect(log).not.toContain('ambient-secret') + expect(log).not.toContain('scheduler-secret') + }) + + it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + const failure = resultFor(subjectGate, 'failed') + + const first = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }) + const second = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', + }) + const third = await writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + }) + + expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) + expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') + expect(statSync(directory).mode & 0o777).toBe(0o700) + expect(statSync(third).mode & 0o777).toBe(0o600) + expect(() => statSync(first)).toThrow() + await expect(writeGateFailureLog(subject, failure, { + directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', + })).rejects.toThrow('EEXIST') + await cleanGateFailureLogs(directory, repositoryRoot) + expect(readdirSync(directory)).toEqual([]) + }) + + it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('build:web') + const path = await writeGateFailureLog( + plan([subjectGate]), + resultFor(subjectGate, 'failed'), + { + directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', + }, + ) + expect(path.slice(directory.length + 1)).toContain('-build-web-') + expect(path.slice(directory.length + 1)).not.toContain(':') + }) + + it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 256, + unique: 'bounded', + now: new Date('2026-07-27T00:00:00Z'), + platform: 'linux', + }) + const content = readFileSync(path, 'utf8') + + expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) + expect(content).toContain('[run-gates log truncated: original-bytes=') + expect(content).toContain('max-bytes=256') + expect(content).toContain('last detail') + expect(content).not.toContain('\uFFFD') + expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) + }) + + it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + const failure: GateResult = { + ...resultFor(subjectGate, 'failed'), + output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], + } + const path = await writeGateFailureLog(plan([subjectGate]), failure, { + directory, + repositoryRoot, + retention: 1, + maxBytes: 400_000, + unique: 'control-heavy', + platform: 'linux', + }) + + expect(statSync(path).size).toBeLessThanOrEqual(400_000) + expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') + }) + + it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { + const auditRoot = temporaryRoot('dsh-gate-symlink-') + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(repositoryRoot) + mkdirSync(join(external, 'gates'), { recursive: true }) + const victim = join(external, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') + const subjectGate = gate('subject') + const message = 'gate-log path component is a symbolic link: .cache' + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', + })).rejects.toThrow(message) + await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) + await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) + expect(existsSync(victim)).toBe(true) + }) + + it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + mkdirSync(directory, { recursive: true }) + mkdirSync(external) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') + if (victim !== undefined) { + mkdirSync(join(external, 'gates')) + writeFileSync(victim, 'keep\n') + } + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + symlinkSync(external, cache, 'dir') + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(existsSync(join(external, 'gates'))).toBe(false) + } else { + expect(readFileSync(victim, 'utf8')).toBe('keep\n') + expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it('uses a console-only fallback on Windows before creating a retention directory', async () => { + const repositoryRoot = temporaryRoot() + const directory = join(repositoryRoot, '.cache/gates') + const subjectGate = gate('subject') + expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') + expect(failureLogUnavailableReason('linux')).toBeUndefined() + + await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, repositoryRoot, platform: 'win32', + })).rejects.toThrow('retained failure logs are disabled on Windows') + expect(existsSync(directory)).toBe(false) + }) +}) + +describe('Node 24 consumer plan', () => { + it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => { + const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers')) + validateGatePlan(subject) + expect(subject.maxWorkers).toBe(7) + expect(listedGatePlan(subject).maxWorkers).toBe(7) + expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({ + workers: 7, + source: 'ci-consumers plan default 7', + }) + expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({ + workers: 4, + source: '$DSH_GATE_CONCURRENCY', + }) + expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({ + workers: 7, + source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7', + }) + expect(subject.gates.map(item => item.id)).toEqual([ + 'lint-and-duplication', + 'node-compat', + 'snapshot', + 'publint', + 'node-next-types', + 'built-package-invariants', + 'built-bin-smoke', + ]) + expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { + expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + } + expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([ + 'snapshot', + 'publint', + 'built-package-invariants', + ]) + expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({ + DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, + }) + }) +}) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index ae11479274..268c2ffd0f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,44 +1,69 @@ /** - * Run local and CI quality gates with bounded in-process scheduling. + * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * - * The gate vocabulary stays in package.json; this runner only decides which - * independent commands can overlap and which commands wait for built artifacts. + * Package scripts own public aggregate names; this runner owns their validated + * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { resolve } from 'node:path' +import { isAbsolute, relative, resolve, sep } 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 + +/** A named aggregate exposed by the gate runner. */ +export type Mode = typeof MODES[number] -type Mode = - | 'ci-primary' - | 'ci-static' - | 'ci-lint' - | 'ci-coverage' - | 'ci-snapshot' - | 'ci-artifacts' - | 'ci-windows-blocking' - | 'ci-windows-complete' - | 'ci-windows-observational' - | 'node-compat' - | 'pre-push' - | 'check-all' - | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' -interface Gate { +/** 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 } + +/** A command and its dependency metadata inside one gate plan. */ +export interface Gate { id: string label: string displayCommand: string command: string args: string[] needs?: string[] - env?: Record + env?: Record input?: string verify?: (result: GateResult) => Promise allowFailure?: boolean } -interface GateResult { +/** A complete executable aggregate and the package script that owns its diagnostics. */ +export interface GatePlan { + mode: Mode + script: string + gates: Gate[] + maxWorkers?: number +} + +/** The observed outcome of one gate process. */ +export interface GateResult { gate: Gate status: GateStatus durationMs: number @@ -46,7 +71,10 @@ interface GateResult { stderr: string output: GateOutputChunk[] exitCode: number | null + signalCode: NodeJS.Signals | null error?: string + logPath?: string + logError?: string } interface GateOutputChunk { @@ -59,71 +87,199 @@ interface RunningGate { promise: Promise } -interface ConcurrencyDefault { +/** The effective worker count and the facts that selected it. */ +export interface ResolvedConcurrency { workers: number source: string } +interface RunRequest { + kind: 'run' + mode: Mode + list: boolean + json: boolean + only?: string +} + +interface CleanLogsRequest { + kind: 'clean-logs' +} + +type CliRequest = RunRequest | CleanLogsRequest + +interface ListedEnvironmentOverride { + operation: GateEnvironmentOverride['operation'] + value?: string + separator?: string +} + +interface ListedGate { + id: string + label: string + command: string + needs: string[] + env: Record + blocking: boolean +} + +interface ListedPlan { + version: 1 + mode: Mode + script: string + scope: 'complete' + maxWorkers: number | null + gates: ListedGate[] +} + +interface GateLogDirectoryIdentity { + dev: string + ino: string +} + +type GateLogHelperRequest = + | { operation: 'write'; filename: string; content: string; retention: number } + | { operation: 'prune'; retain: number } + | { operation: 'clean' } + +interface GateLogHelperResult { + directory?: GateLogDirectoryIdentity + filename?: string + removed: string[] +} + +type GateExecutor = (gate: Gate) => Promise +type ResultObserver = (result: GateResult) => Promise | void + const root = resolve(import.meta.dirname, '..') -const mode = parseMode(process.argv[2]) -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 verbose = process.env.DSH_GATE_VERBOSE === '1' -const startedAt = performance.now() +const gateLogRoot = resolve(root, '.cache/gates') +const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') +const GATE_LOG_RETENTION = 20 +const GATE_LOG_MAX_BYTES = 1_048_576 +const MIN_GATE_LOG_MAX_BYTES = 128 +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 concurrencySource = concurrencyOverride === undefined || concurrencyOverride === '' - ? concurrencyDefault.source - : '$DSH_GATE_CONCURRENCY' -console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) +if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) -const results = await runGates(gates, maxConcurrency) -printSummary(results, performance.now() - startedAt) +async function main(args: string[]): Promise { + const request = parseCliRequest(args) + if (request.kind === 'clean-logs') { + await cleanGateFailureLogs() + console.log('run-gates: cleared retained logs in .cache/gates/.') + return 0 + } -if (results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped'))) { - process.exit(1) + const completePlan = gatePlanForMode(request.mode) + validateGatePlan(completePlan) + if (request.list) { + console.log(request.json ? formatGatePlanJson(completePlan) : formatGatePlanList(completePlan)) + return 0 + } + + const plan = request.only === undefined + ? completePlan + : { ...completePlan, gates: gateDependencyClosure(completePlan, request.only) } + validateGatePlan(plan) + if (request.only !== undefined) console.log(formatOnlyNotice(completePlan, request.only)) + + const concurrency = resolvePlanConcurrency(plan, process.env.DSH_GATE_CONCURRENCY) + const maxConcurrency = concurrency.workers + const concurrencySource = concurrency.source + const startedAt = performance.now() + console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) + + const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { + await attachFailureLog(completePlan, result) + printResult(completePlan, result) + }) + printSummary(completePlan, results, performance.now() - startedAt) + return results.some(result => result.gate.allowFailure !== true && (result.status === 'failed' || result.status === 'skipped')) + ? 1 + : 0 +} + +function isMainModule(): boolean { + const entry = process.argv[1] + return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +} + +/** + * Parse one runner invocation without constructing or starting its plan. + * @param args - command-line arguments after the script entrypoint. + * @returns the validated run or cleanup request. + */ +export function parseCliRequest(args: readonly string[]): CliRequest { + if (args[0] === '--clean-logs') { + if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') + return { kind: 'clean-logs' } + } + + const mode = parseMode(args[0]) + let list = false + let json = false + let only: string | undefined + const firstOption = args[1] === '--' ? 2 : 1 + for (let index = firstOption; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--list') { + if (list) throw new Error('run-gates: --list may be specified only once.') + list = true + } else if (arg === '--json') { + if (json) throw new Error('run-gates: --json may be specified only once.') + json = true + } else if (arg === '--only') { + if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') + const id = args[index + 1] + if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') + only = id + index += 1 + } else { + throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) + } + } + 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 } } } function parseMode(raw: string | undefined): Mode { - switch (raw) { - case 'ci-primary': - case 'ci-static': - case 'ci-lint': - case 'ci-coverage': - case 'ci-snapshot': - case 'ci-artifacts': - case 'ci-windows-blocking': - case 'ci-windows-complete': - case 'ci-windows-observational': - case 'node-compat': - case 'pre-push': - case 'check-all': - case 'doc-sync': - return raw - default: - throw new Error( - `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | ci-windows-blocking | ci-windows-complete | ci-windows-observational | node-compat | pre-push | check-all | doc-sync, got ${JSON.stringify(raw)}.`, - ) - } + if (MODES.includes(raw as Mode)) return raw as Mode + throw new Error(`run-gates: expected mode ${MODES.join(' | ')}, got ${JSON.stringify(raw)}.`) } -function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { - const available = availableParallelism() +function defaultConcurrency(plan: GatePlan, available: number): ResolvedConcurrency { + if (plan.maxWorkers !== undefined) { + return { + workers: Math.min(plan.gates.length, plan.maxWorkers), + source: `${plan.mode} plan default ${plan.maxWorkers}`, + } + } // 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 === 'pre-push' || selectedMode === 'check-all' || selectedMode === 'doc-sync' + const localCap = plan.mode === 'check-all' || plan.mode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { - workers: Math.min(total, modeLimit), + workers: Math.min(plan.gates.length, modeLimit), source: localCap - ? `${available} available CPU(s), ${selectedMode} cap 4` + ? `${available} available CPU(s), ${plan.mode} cap 4` : `${available} available CPU(s)`, } } -function concurrencyFromEnv(name: string, fallback: number): number { - const raw = process.env[name] +function concurrencyFromValue(name: string, raw: string | undefined, fallback: number): number { if (raw === undefined || raw === '') return fallback const parsed = Number.parseInt(raw, 10) if (!Number.isSafeInteger(parsed) || parsed < 1) { @@ -132,6 +288,33 @@ function concurrencyFromEnv(name: string, fallback: number): number { return parsed } +/** + * Resolve a plan's default, optional environment request, and hard worker ceiling. + * @param plan - validated complete or diagnostic plan. + * @param override - optional `DSH_GATE_CONCURRENCY` value. + * @param available - host CPU availability for modes without a plan-owned default. + * @returns the effective worker count and its inspectable source. + */ +export function resolvePlanConcurrency( + plan: GatePlan, + override: string | undefined, + available = availableParallelism(), +): ResolvedConcurrency { + validateGatePlan(plan) + const defaultValue = defaultConcurrency(plan, available) + const requested = concurrencyFromValue('DSH_GATE_CONCURRENCY', override, defaultValue.workers) + const workers = Math.min(requested, plan.maxWorkers ?? requested) + const requestedSource = override === undefined || override === '' + ? defaultValue.source + : '$DSH_GATE_CONCURRENCY' + return { + workers, + source: workers === requested + ? requestedSource + : `${requestedSource}, ${plan.mode} plan cap ${String(plan.maxWorkers)}`, + } +} + function pnpmScript(id: string, script: string, options: Partial = {}): Gate { return { id, @@ -161,8 +344,18 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -function nodeOptions(...options: string[]): string { - return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') +/** + * Construct the complete plan for a named aggregate without executing it. + * @param selected - aggregate mode to construct. + * @returns the aggregate's package-script identity and gate graph. + */ +export function gatePlanForMode(selected: Mode): GatePlan { + return { + mode: selected, + script: MODE_SCRIPTS[selected], + gates: gatesForMode(selected), + ...selected === 'ci-consumers' ? { maxWorkers: 7 } : {}, + } } function gatesForMode(selected: Mode): Gate[] { @@ -182,6 +375,8 @@ function gatesForMode(selected: Mode): Gate[] { 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': @@ -190,7 +385,6 @@ function gatesForMode(selected: Mode): Gate[] { return ciWindowsObservationalGates() case 'node-compat': return nodeCompatGates() - case 'pre-push': return [] case 'check-all': return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -204,7 +398,7 @@ function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] @@ -273,7 +467,7 @@ function ciStaticGates(): Gate[] { pnpmScript('build', 'build'), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -294,6 +488,23 @@ function ciArtifactGates(): Gate[] { ] } +function ciConsumerGates(): Gate[] { + const publicArtifacts = ['publint'] + const restoredBuild = ['built-package-invariants'] + return [ + pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), + snapshotGate(restoredBuild), + pnpmScript('publint', 'publint'), + pnpmScript('node-next-types', 'verify-node-next-types', { + label: 'node-next types', + needs: restoredBuild, + }), + builtPackageInvariantsGate(publicArtifacts), + builtBinSmokeGate(restoredBuild), + ] +} + function ciWindowsBlockingGates(): Gate[] { return [ pnpmScript('windows-build', 'build', { label: 'build' }), @@ -343,17 +554,17 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { 'content', ], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } if (concurrencyArgs.length > 0) { return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { label: 'lint', - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, + env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, }) } @@ -381,11 +592,11 @@ function coverageGate(): Gate { // 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. -// CI and check-all already build before either class runs, so the suite waits on `build`. -function snapshotGate(): Gate { +// Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. +function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { - env: { DSH_EXAMPLE_MODE: 'lib' }, - needs: ['build'], + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + needs, }) } @@ -430,7 +641,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] - docTypecheckEnv?: Record + docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} @@ -468,7 +679,7 @@ function docSyncLeafGates(options: { ] } -function builtBinSmokeGate(): Gate { +function builtBinSmokeGate(needs: string[] = ['build']): Gate { return pnpmExec('built-bin-smoke', [ 'vitest', 'run', @@ -486,12 +697,582 @@ function builtBinSmokeGate(): Gate { 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', ], { label: 'built-bin smoke', - needs: ['build'], - env: { DSH_EXAMPLE_MODE: 'lib' }, + needs, + env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, }) } -async function runGates(allGates: Gate[], maxActive: number): Promise { +/** + * Reject a plan whose graph cannot be executed unambiguously. + * @param plan - complete or diagnostic plan to validate. + */ +export function validateGatePlan(plan: GatePlan): void { + const errors: string[] = [] + if (plan.gates.length === 0) errors.push('plan has no gates') + if (plan.maxWorkers !== undefined && (!Number.isSafeInteger(plan.maxWorkers) || plan.maxWorkers < 1)) { + errors.push(`maxWorkers must be a positive integer, got ${JSON.stringify(plan.maxWorkers)}`) + } + + const counts = new Map() + for (const gate of plan.gates) { + counts.set(gate.id, (counts.get(gate.id) ?? 0) + 1) + if (!/^[a-z0-9][a-z0-9:-]*$/.test(gate.id)) { + errors.push(`gate id ${JSON.stringify(gate.id)} must contain only lowercase letters, digits, colons, and hyphens`) + } + } + for (const [id, count] of counts) { + if (count > 1) errors.push(`duplicate gate id ${JSON.stringify(id)}`) + } + + const ids = new Set(counts.keys()) + for (const gate of plan.gates) { + for (const dependency of gate.needs ?? []) { + if (!ids.has(dependency)) { + errors.push(`gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}`) + } + } + } + + const cycle = findDependencyCycle(plan.gates) + if (cycle !== undefined) errors.push(`dependency cycle: ${cycle.join(' -> ')}`) + if (errors.length > 0) { + throw new Error(`run-gates: invalid ${plan.mode} plan:\n${errors.map(error => ` - ${error}`).join('\n')}`) + } +} + +function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { + const byId = new Map(gates.map(gate => [gate.id, gate])) + const complete = new Set() + const active = new Map() + 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 +} + +/** + * Return one target and all of its transitive dependencies in canonical plan order. + * @param plan - validated complete owning plan. + * @param targetId - gate selected for diagnostic execution. + * @returns the target's dependency closure in owning-plan order. + */ +export function gateDependencyClosure(plan: GatePlan, targetId: string): Gate[] { + validateGatePlan(plan) + const byId = new Map(plan.gates.map(gate => [gate.id, gate])) + if (!byId.has(targetId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(targetId)}.`) + } + + const selected = new Set() + const include = (id: string): void => { + if (selected.has(id)) return + const gate = byId.get(id) + if (gate === undefined) throw new Error(`run-gates: missing validated dependency ${JSON.stringify(id)}.`) + for (const dependency of gate.needs ?? []) include(dependency) + selected.add(id) + } + include(targetId) + return plan.gates.filter(gate => selected.has(gate.id)) +} + +/** + * Produce the stable machine-readable view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the versioned environment-redacted plan view. + */ +export function listedGatePlan(plan: GatePlan): ListedPlan { + validateGatePlan(plan) + return { + version: 1, + mode: plan.mode, + script: plan.script, + scope: 'complete', + maxWorkers: plan.maxWorkers ?? null, + gates: plan.gates.map(listedGate), + } +} + +function listedGate(gate: Gate): ListedGate { + return { + id: gate.id, + label: gate.label, + command: gate.displayCommand, + needs: [...gate.needs ?? []], + env: listedEnvironment(gate.env), + blocking: gate.allowFailure !== true, + } +} + +function listedEnvironment( + environment: Readonly> | undefined, +): 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 }] + })) +} + +function sensitiveEnvironmentName(name: string): boolean { + return /(key|secret|token|password|credential)/i.test(name) +} + +/** + * Render the deterministic human-readable view used by `--list`. + * @param plan - complete plan to inspect. + * @returns the formatted plan. + */ +export function formatGatePlanList(plan: GatePlan): string { + const listed = listedGatePlan(plan) + const lines = [ + `run-gates: complete ${listed.mode} plan (pnpm run ${listed.script})`, + `max workers: ${listed.maxWorkers === null ? '(host and gate count)' : listed.maxWorkers}`, + ] + for (const gate of listed.gates) { + lines.push(`- ${gate.id} [${gate.blocking ? 'blocking' : 'non-blocking'}] ${gate.label}`) + lines.push(` command: ${gate.command}`) + lines.push(` needs: ${gate.needs.length === 0 ? '(none)' : gate.needs.join(', ')}`) + lines.push(` env: ${Object.keys(gate.env).length === 0 ? '(none)' : JSON.stringify(gate.env)}`) + } + return lines.join('\n') +} + +/** + * Render the stable JSON view used by `--list --json`. + * @param plan - complete plan to inspect. + * @returns the formatted JSON object. + */ +export function formatGatePlanJson(plan: GatePlan): string { + return JSON.stringify(listedGatePlan(plan), null, 2) +} + +/** + * Render the package-script command that restores a gate's scheduler context. + * @param plan - complete owning plan. + * @param gateId - gate to replay with its dependencies. + * @returns a shell-independent pnpm command. + */ +export function replayCommand(plan: GatePlan, gateId: string): string { + validateGatePlan(plan) + if (!plan.gates.some(gate => gate.id === gateId)) { + throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) + } + return `pnpm run ${plan.script} -- --only ${gateId}` +} + +/** + * Explain that a focused run is diagnostic rather than the complete aggregate. + * @param plan - complete owning plan. + * @param gateId - selected diagnostic gate. + * @returns the partial-evidence notice. + */ +export function formatOnlyNotice(plan: GatePlan, gateId: string): string { + return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` +} + +/** + * Resolve only scheduler-declared environment operations against the spawn environment. + * @param gate - gate whose operations to apply. + * @param inherited - environment inherited by the runner. + * @returns the child environment without mutating the inherited object. + */ +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') { + resolved[name] = override.value + } else { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current}${override.separator ?? ' '}${override.value}` + } + } + return resolved +} + +/** + * Run a validated plan; invalid input rejects before the injected executor can start a child. + * @param plan - complete or diagnostic plan to execute. + * @param maxActive - maximum concurrent child count. + * @param execute - child-process executor. + * @param observe - serialized result observer. + * @returns results in canonical plan order. + */ +export async function executeGatePlan( + plan: GatePlan, + maxActive: number, + execute: GateExecutor, + observe: ResultObserver = () => {}, +): Promise { + validateGatePlan(plan) + if (!Number.isSafeInteger(maxActive) || maxActive < 1) { + throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) + } + if (plan.maxWorkers !== undefined && maxActive > plan.maxWorkers) { + throw new Error(`run-gates: max concurrency ${maxActive} exceeds the ${plan.mode} plan ceiling ${plan.maxWorkers}.`) + } + return runGates(plan.gates, maxActive, execute, observe) +} + +/** + * Format one private failure log without consulting or enumerating the inherited environment. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @returns attributable metadata and interleaved output. + */ +export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { + const gate = listedGate(result.gate) + const lines = [ + 'run-gates failure log', + `mode: ${plan.mode}`, + `gate: ${gate.id}`, + `status: ${result.status}`, + `blocking: ${gate.blocking}`, + `command: ${gate.command}`, + `replay: ${replayCommand(plan, gate.id)}`, + `scheduler environment: ${JSON.stringify(gate.env)}`, + `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, + `signal: ${result.signalCode ?? 'none'}`, + ] + if (result.error !== undefined) lines.push(`error: ${result.error}`) + lines.push('', 'interleaved output:') + for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) + return `${lines.join('\n')}\n` +} + +/** + * Explain why retained logs are unavailable on a platform. + * @param platform - host platform to evaluate. + * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. + */ +export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { + return platform === 'win32' + ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' + : undefined +} + +/** + * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. + * @param content - complete formatted failure log. + * @param maxBytes - maximum encoded byte length. + * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. + */ +export function limitGateFailureLog(content: string, maxBytes: number): string { + if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { + throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) + } + const originalBytes = Buffer.byteLength(content) + if (originalBytes <= maxBytes) return content + + const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` + const available = maxBytes - Buffer.byteLength(marker) + if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') + const prefixBytes = Math.ceil(available / 2) + const suffixBytes = available - prefixBytes + return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` +} + +function utf8Prefix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let end = maxBytes + while (end > 0) { + const byte = encoded[end] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + end -= 1 + } + return encoded.subarray(0, end).toString('utf8') +} + +function utf8Suffix(content: string, maxBytes: number): string { + const encoded = Buffer.from(content) + if (encoded.length <= maxBytes) return content + let start = encoded.length - maxBytes + while (start < encoded.length) { + const byte = encoded[start] + if (byte === undefined || (byte & 0xc0) !== 0x80) break + start += 1 + } + return encoded.subarray(start).toString('utf8') +} + +/** + * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. + * @param plan - complete owning plan. + * @param result - failed child outcome. + * @param options - injectable storage, bound, clock, identity, and platform seams. + * @returns the absolute log path. + */ +export async function writeGateFailureLog( + plan: GatePlan, + result: GateResult, + options: { + directory?: string + repositoryRoot?: string + retention?: number + maxBytes?: number + unique?: string + now?: Date + platform?: NodeJS.Platform + beforeHelper?: () => Promise | void + } = {}, +): Promise { + const directory = options.directory ?? gateLogRoot + const repositoryRoot = options.repositoryRoot ?? root + const retention = options.retention ?? GATE_LOG_RETENTION + const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES + const unique = options.unique ?? randomUUID() + const now = options.now ?? new Date() + const unavailable = failureLogUnavailableReason(options.platform) + if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) + if (!Number.isSafeInteger(retention) || retention < 1) { + throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') + const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') + if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') + const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') + const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` + const helperResult = await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + { + operation: 'write', + filename, + content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), + retention, + }, + options.beforeHelper, + ) + if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') + return resolve(directory, filename) +} + +async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { + const relativeTarget = relative(repositoryRoot, target) + if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { + throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) + } + + const rootMetadata = await lstat(repositoryRoot) + if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { + throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) + } + let current = repositoryRoot + for (const component of relativeTarget.split(sep)) { + current = resolve(current, component) + let metadata + try { + metadata = await lstat(current) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return + throw error + } + const shown = relative(repositoryRoot, current).split(sep).join('/') + if (metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) + } + if (!metadata.isDirectory()) { + throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) + } + } +} + +function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === code +} + +async function readDirectoryIdentity(directory: string): Promise { + let metadata + try { + metadata = await lstat(directory, { bigint: true }) + } catch (error: unknown) { + if (hasErrorCode(error, 'ENOENT')) return undefined + throw error + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) + } + return { dev: String(metadata.dev), ino: String(metadata.ino) } +} + +async function runGateLogHelper( + directory: string, + repositoryRoot: string, + repositoryIdentity: GateLogDirectoryIdentity, + request: GateLogHelperRequest, + beforeHelper: (() => Promise | void) | undefined, +): Promise { + await beforeHelper?.() + const payload = JSON.stringify({ + ...request, + repository: { + root: repositoryRoot, + relative: relative(repositoryRoot, directory), + identity: repositoryIdentity, + }, + }) + const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { + const child = spawn(process.execPath, [gateLogHelper], { + cwd: repositoryRoot, + env: {}, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (status) => { + resolveResult({ status, stdout, stderr }) + }) + child.stdin.on('error', (error: NodeJS.ErrnoException) => { + if (error.code !== 'EPIPE') reject(error) + }) + child.stdin.end(payload) + }) + if (result.status !== 0) { + throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) + } + let parsed: unknown + try { + parsed = JSON.parse(result.stdout) + } catch { + throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) + } + if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') + await assertRepoLocalLogPath(repositoryRoot, directory) + const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if ( + currentRepositoryIdentity === undefined + || currentRepositoryIdentity.dev !== repositoryIdentity.dev + || currentRepositoryIdentity.ino !== repositoryIdentity.ino + ) { + throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') + } + if (parsed.directory !== undefined) { + const currentDirectoryIdentity = await readDirectoryIdentity(directory) + if ( + currentDirectoryIdentity === undefined + || currentDirectoryIdentity.dev !== parsed.directory.dev + || currentDirectoryIdentity.ino !== parsed.directory.ino + ) { + throw new Error('run-gates: gate-log directory identity changed while the helper was running.') + } + } else if (request.operation === 'write') { + throw new Error('run-gates: gate-log helper did not return the created directory identity.') + } + return parsed +} + +function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { + if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false + if (!value.removed.every(entry => typeof entry === 'string')) return false + if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false + return !('directory' in value) + || value.directory === undefined + || isGateLogDirectoryIdentity(value.directory) +} + +function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { + return typeof value === 'object' + && value !== null + && 'dev' in value + && typeof value.dev === 'string' + && 'ino' in value + && typeof value.ino === 'string' +} + +/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ +export async function cleanGateFailureLogs( + directory = gateLogRoot, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) +} + +/** + * Remove older scheduler log files until at most `retain` remain. + * @param directory - private log directory. + * @param retain - number of newest log files to preserve. + * @param repositoryRoot - repository boundary containing the log directory. + * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. + */ +export async function pruneGateLogs( + directory: string, + retain: number, + repositoryRoot = root, + beforeHelper?: () => Promise | void, +): Promise { + if (!Number.isSafeInteger(retain) || retain < 0) { + throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) + } + await assertRepoLocalLogPath(repositoryRoot, directory) + const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) + if (repositoryIdentity === undefined) return + await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) +} + +async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { + if (result.status !== 'failed') return + try { + const path = await writeGateFailureLog(plan, result) + result.logPath = relative(root, path).split(sep).join('/') + } catch (error: unknown) { + result.logError = error instanceof Error ? error.message : String(error) + } +} + +async function runGates( + allGates: Gate[], + maxActive: number, + execute: GateExecutor, + observe: ResultObserver, +): Promise { const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -502,7 +1283,7 @@ async function runGates(allGates: Gate[], maxActive: number): Promise states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) if (ready === undefined) break states.set(ready.id, 'running') - running.push({ gate: ready, promise: runGate(ready) }) + running.push({ gate: ready, promise: execute(ready) }) console.log(`run-gates: start ${ready.label}`) madeProgress = true } @@ -519,11 +1300,12 @@ async function runGates(allGates: Gate[], maxActive: number): Promise): boolea return (gate.needs ?? []).every(id => states.get(id) === 'passed') } -async function runGate(gate: Gate): Promise { +/** + * Execute one gate through the real shell-free child-process boundary. + * @param gate - command and scheduler environment to execute. + * @returns the complete process and verification outcome. + */ +export async function runGate(gate: Gate): Promise { const started = performance.now() let stdout = '' let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined - const exitCode = await new Promise((resolveExit) => { + 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 }, + env: resolveGateEnvironment(gate, process.env), stdio: ['pipe', 'pipe', 'pipe'], }) child.stdout.setEncoding('utf8') @@ -573,18 +1363,21 @@ async function runGate(gate: Gate): Promise { }) child.on('error', (error) => { spawnError = `failed to start command: ${error.message}` - resolveExit(null) + resolveExit({ exitCode: null, signalCode: null }) + }) + child.on('close', (exitCode, signalCode) => { + resolveExit({ exitCode, signalCode }) }) - child.on('close', resolveExit) if (gate.input !== undefined) child.stdin.end(gate.input) else child.stdin.end() }) + const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && spawnError === undefined ? 'passed' : 'failed' + let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode }) + await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode, signalCode }) } catch (verifyError: unknown) { status = 'failed' error = verifyError instanceof Error ? verifyError.message : String(verifyError) @@ -599,12 +1392,27 @@ async function runGate(gate: Gate): Promise { stderr, output, exitCode, + signalCode, } if (error !== undefined) result.error = error return result } -function printResult(result: GateResult): void { +/** + * 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(plan: GatePlan, 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)`) @@ -614,12 +1422,22 @@ function printResult(result: GateResult): void { 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}`) + if (result.status !== 'passed') { + const environment = listedGate(result.gate).env + console.error(`command: ${result.gate.displayCommand}`) + if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) { + console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) + console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') + } + if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) + } printOutput(result.output) if (result.error !== undefined) console.error(result.error) } -function printSummary(results: GateResult[], durationMs: number): void { +function printSummary(plan: GatePlan, 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 @@ -632,10 +1450,11 @@ function printSummary(results: GateResult[], durationMs: number): void { console.error('run-gates: unsuccessful gates:') for (const result of unsuccessful) { const duration = (result.durationMs / 1000).toFixed(2) - const reason = result.error ?? (result.exitCode === null ? 'no exit code' : `exit ${result.exitCode}`) + 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}`) + console.error(` replay: ${replayCommand(plan, result.gate.id)}`) + if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } From 2e210c369bff058f335417c7224e8fdfce815f58 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:54:58 +0800 Subject: [PATCH 02/13] fix(dev-infra): pin replay log path identities --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 4 +- .../2026-07-27-replayable-gate-plans.zh.md | 4 +- scripts/gate-log-helper.mjs | 57 +++++++--- scripts/run-gates.spec.ts | 107 ++++++++++++++++++ scripts/run-gates.ts | 88 ++++++++++---- 6 files changed, 226 insertions(+), 38 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 3966caf9cf..4b12a533b2 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: 604fb87ebe91f8ebd606d128e8927535502b6ea9 -2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 +2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 +2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 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 604fb87ebe..0b052352f5 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 @@ -18,13 +18,13 @@ Every mode supports deterministic `--list` output and a versioned stable `--list `--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. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered 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 e24d77ff62..0c9f20d723 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 @@ -18,13 +18,13 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 `check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs index 363b538f70..0c1e4fa3af 100644 --- a/scripts/gate-log-helper.mjs +++ b/scripts/gate-log-helper.mjs @@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) { throw new Error('invalid repository-relative gate-log path') } assertIdentity(repository.identity, 'repository') + const names = repository.relative.split(sep) + if (!Array.isArray(repository.components) || repository.components.length !== names.length) { + throw new Error('invalid gate-log path-component plan') + } + for (let index = 0; index < names.length; index += 1) { + const component = repository.components[index] + if ( + typeof component !== 'object' + || component === null + || component.name !== names[index] + || !('identity' in component) + ) { + throw new Error('invalid gate-log path-component plan') + } + if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) + } const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { throw new Error('gate-log repository identity changed before the helper started') @@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) { ) { throw new Error('gate-log repository root is not a real directory') } + return repository.components } -async function enterLogDirectory(relativePath, create) { +async function enterLogDirectory(components, create) { const traversed = [] - for (const component of relativePath.split(sep)) { - if (component === '' || component === '.' || component === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`) + for (const component of components) { + if (component.name === '' || component.name === '.' || component.name === '..') { + throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) } - traversed.push(component) + traversed.push(component.name) let componentMetadata + let created = false try { - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) } catch (error) { if (errorCode(error) !== 'ENOENT') throw error + if (component.identity !== null) { + throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) + } if (!create) return undefined try { - await mkdir(component, { mode: 0o700 }) + await mkdir(component.name, { mode: 0o700 }) } catch (mkdirError) { - if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + if (errorCode(mkdirError) === 'EEXIST') { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + throw mkdirError } - componentMetadata = await lstat(component, { bigint: true }) + componentMetadata = await lstat(component.name, { bigint: true }) + created = true + } + if (component.identity === null && !created) { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { + throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) } const shown = traversed.join('/') if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { throw new Error(`gate-log path component is not a real directory: ${shown}`) } - const expected = identityOf(componentMetadata) - process.chdir(component) + const expected = component.identity ?? identityOf(componentMetadata) + process.chdir(component.name) const pinnedMetadata = await stat('.', { bigint: true }) if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { throw new Error(`gate-log path component identity changed before pinning: ${shown}`) @@ -195,8 +226,8 @@ async function writeLog(request) { async function main() { const request = await readRequest() assertRequest(request) - await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + const components = await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(components, request.operation === 'write') if (directory === undefined) return { removed: [] } switch (request.operation) { case 'write': { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 49c1aab675..46d41a5df8 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -25,6 +25,7 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, + isMainModule, listedGatePlan, limitGateFailureLog, parseCliRequest, @@ -240,6 +241,15 @@ 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') @@ -465,6 +475,103 @@ describe('gate failure logs', () => { } }) + it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + for (const operation of ['write', 'prune', 'clean'] as const) { + const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) + const repositoryRoot = join(auditRoot, 'repository') + const external = join(auditRoot, 'external') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + const displacedCache = join(repositoryRoot, '.cache-pinned') + const externalCache = join(external, 'cache') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') + if (victim !== undefined) writeFileSync(victim, 'keep\n') + const swapAncestor = (): void => { + renameSync(cache, displacedCache) + renameSync(externalCache, cache) + } + + let invocation: Promise + if (operation === 'write') { + invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper: swapAncestor, + }) + } else if (operation === 'prune') { + invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) + } else { + invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) + } + + await expect(invocation).rejects.toThrow('gate-log helper') + if (victim === undefined) { + expect(readdirSync(join(cache, 'gates'))).toEqual([]) + } else { + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + } + expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') + } + }) + + it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { + const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalCache = join(auditRoot, 'external-cache') + const cache = join(repositoryRoot, '.cache') + const directory = join(cache, 'gates') + mkdirSync(repositoryRoot) + mkdirSync(join(externalCache, 'gates'), { recursive: true }) + const victim = join(externalCache, 'gates/victim.log') + writeFileSync(victim, 'keep\n') + const subjectGate = gate('subject') + + const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot, + retention: 1, + unique: 'missing-swap', + platform: 'linux', + beforeHelper: () => { + renameSync(externalCache, cache) + }, + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) + }) + + it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { + const auditRoot = temporaryRoot('dsh-gate-root-swap-') + const repositoryRoot = join(auditRoot, 'repository') + const externalRoot = join(auditRoot, 'external-repository') + const displacedRoot = join(auditRoot, 'repository-pinned') + const directory = join(repositoryRoot, '.cache/gates') + mkdirSync(directory, { recursive: true }) + mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) + writeFileSync(join(directory, 'old.log'), 'old private log\n') + writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') + + const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { + renameSync(repositoryRoot, displacedRoot) + renameSync(externalRoot, repositoryRoot) + }) + + await expect(invocation).rejects.toThrow('gate-log helper') + expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') + expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') + }) + it('uses a console-only fallback on Windows before creating a retention directory', async () => { const repositoryRoot = temporaryRoot() const directory = join(repositoryRoot, '.cache/gates') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 268c2ffd0f..08a524556e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -7,6 +7,7 @@ */ import { spawn } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { realpathSync } from 'node:fs' import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' import { isAbsolute, relative, resolve, sep } from 'node:path' @@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity { ino: string } +interface GateLogPathComponent { + name: string + identity: GateLogDirectoryIdentity | null +} + +interface GateLogPathPlan { + repositoryIdentity: GateLogDirectoryIdentity + pathComponents: GateLogPathComponent[] +} + type GateLogHelperRequest = | { operation: 'write'; filename: string; content: string; retention: number } | { operation: 'prune'; retain: number } @@ -211,9 +222,19 @@ async function main(args: string[]): Promise { : 0 } -function isMainModule(): boolean { - const entry = process.argv[1] - return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +/** + * 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 + } } /** @@ -1058,9 +1079,7 @@ export async function writeGateFailureLog( if (!Number.isSafeInteger(retention) || retention < 1) { throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') @@ -1070,6 +1089,7 @@ export async function writeGateFailureLog( directory, repositoryRoot, repositoryIdentity, + pathComponents, { operation: 'write', filename, @@ -1082,24 +1102,37 @@ export async function writeGateFailureLog( return resolve(directory, filename) } -async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { +async function inspectRepoLocalLogPath( + repositoryRoot: string, + target: string, +): Promise { const relativeTarget = relative(repositoryRoot, target) if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) } - const rootMetadata = await lstat(repositoryRoot) + const rootMetadata = await lstat(repositoryRoot, { bigint: true }) if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) } + const components: GateLogPathComponent[] = [] let current = repositoryRoot + let missing = false for (const component of relativeTarget.split(sep)) { current = resolve(current, component) + if (missing) { + components.push({ name: component, identity: null }) + continue + } let metadata try { - metadata = await lstat(current) + metadata = await lstat(current, { bigint: true }) } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return + if (hasErrorCode(error, 'ENOENT')) { + missing = true + components.push({ name: component, identity: null }) + continue + } throw error } const shown = relative(repositoryRoot, current).split(sep).join('/') @@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P if (!metadata.isDirectory()) { throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) } + components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) + } + return { + repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, + pathComponents: components, } } @@ -1134,6 +1172,7 @@ async function runGateLogHelper( directory: string, repositoryRoot: string, repositoryIdentity: GateLogDirectoryIdentity, + pathComponents: GateLogPathComponent[], request: GateLogHelperRequest, beforeHelper: (() => Promise | void) | undefined, ): Promise { @@ -1144,6 +1183,7 @@ async function runGateLogHelper( root: repositoryRoot, relative: relative(repositoryRoot, directory), identity: repositoryIdentity, + components: pathComponents, }, }) const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { @@ -1181,7 +1221,7 @@ async function runGateLogHelper( throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) } if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await assertRepoLocalLogPath(repositoryRoot, directory) + await inspectRepoLocalLogPath(repositoryRoot, directory) const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) if ( currentRepositoryIdentity === undefined @@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs( repositoryRoot = root, beforeHelper?: () => Promise | void, ): Promise { - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'clean' }, + beforeHelper, + ) } /** @@ -1251,10 +1296,15 @@ export async function pruneGateLogs( if (!Number.isSafeInteger(retain) || retain < 0) { throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'prune', retain }, + beforeHelper, + ) } async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { From 0fabbd72ad8f4bbb5e453642b8052daa774e6000 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:05:21 +0800 Subject: [PATCH 03/13] test(dev-infra): share gate log swap setup --- scripts/run-gates.spec.ts | 70 +++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 32 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 46d41a5df8..13a37d0070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -83,6 +83,30 @@ function temporaryRoot(prefix = 'dsh-gate-logs-'): string { return root } +function invokeGateLogOperation( + operation: 'write' | 'prune' | 'clean', + subjectGate: Gate, + directory: string, + root: string, + beforeHelper: () => void, +): Promise { + switch (operation) { + case 'write': + return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { + directory, + repositoryRoot: root, + retention: 1, + unique: operation, + platform: 'linux', + beforeHelper, + }) + case 'prune': + return pruneGateLogs(directory, 0, root, beforeHelper) + case 'clean': + return cleanGateFailureLogs(directory, root, beforeHelper) + } +} + function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -426,7 +450,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) @@ -448,21 +471,13 @@ describe('gate failure logs', () => { symlinkSync(external, cache, 'dir') } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { @@ -477,7 +492,6 @@ describe('gate failure logs', () => { it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { const subjectGate = gate('subject') - const subject = plan([subjectGate]) for (const operation of ['write', 'prune', 'clean'] as const) { const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) @@ -497,21 +511,13 @@ describe('gate failure logs', () => { renameSync(externalCache, cache) } - let invocation: Promise - if (operation === 'write') { - invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper: swapAncestor, - }) - } else if (operation === 'prune') { - invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor) - } else { - invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor) - } + const invocation = invokeGateLogOperation( + operation, + subjectGate, + directory, + repositoryRoot, + swapAncestor, + ) await expect(invocation).rejects.toThrow('gate-log helper') if (victim === undefined) { From 3f27434f388d100d88333f1acc6c0cf3a4eaa2d1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:47:00 +0800 Subject: [PATCH 04/13] refactor(dev-infra): drop retained gate logs --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 28 +- .../2026-07-27-replayable-gate-plans.zh.md | 28 +- scripts/gate-log-helper.mjs | 251 ---------- scripts/run-gates.spec.ts | 349 ++------------ scripts/run-gates.ts | 429 +----------------- 6 files changed, 70 insertions(+), 1019 deletions(-) delete mode 100644 scripts/gate-log-helper.mjs 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 4b12a533b2..98e78e752a 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: 0b052352f54c98506c135077a471ffaf35c50009 -2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 +2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b +2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c 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 0b052352f5..a312481a4d 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 @@ -6,33 +6,37 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) ## Problem -Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident. +Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. ## Decision [`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 (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake 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, so inspection 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. -On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow. +The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, a symlinked script entry remains executable, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks, repository-root and component real-directory replacements, expected-missing directory insertion, and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered -- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. -- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. -- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. -- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time. -- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry. +**Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. + +**Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. + +**Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. + +**Persist child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record. + +**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates. ## Consequences @@ -40,4 +44,4 @@ The scheduler owns a small CLI and a versioned JSON schema that must evolve deli Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. -Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete. +Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. 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 0c9f20d723..c213068f54 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 @@ -6,33 +6,37 @@ Status: implemented ## 问题 -仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。 +仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 ## 决策 [`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 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 +每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。启动专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程之前,一次预验证会记录仓库根目录的设备号和 inode,以及每个仓库相对路径组件的身份或缺失状态。辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认每个已有身份和缺失状态仍然匹配,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建预期缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,根目录或路径组件是符号链接、真实目录被替换,或验证后意外出现目录时,操作都会在修改前失败,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、通过符号链接调用的脚本入口仍可执行、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接、仓库根目录和路径组件中的真实目录替换、原本应缺失的目录被插入,以及确定性触发的写入、裁剪和清理上层目录替换,都无法创建外部日志目录或触达外部受害文件;UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 -- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 -- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 -- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 -- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。 -- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。 +**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 + +**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 + +**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 + +**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。 + +**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。 ## 后果 @@ -40,4 +44,4 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 -保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。 +缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs deleted file mode 100644 index 0c1e4fa3af..0000000000 --- a/scripts/gate-log-helper.mjs +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env node -/** Pin the repository and each log-path component before creating or operating on private logs. */ - -import { constants } from 'node:fs' -import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises' -import { isAbsolute, sep } from 'node:path' - -const MAX_REQUEST_BYTES = 8 * 1024 * 1024 -const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/ - -function errorCode(error) { - return typeof error === 'object' && error !== null && 'code' in error - ? error.code - : undefined -} - -async function readRequest() { - const chunks = [] - let bytes = 0 - for await (const chunk of process.stdin) { - bytes += chunk.length - if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit') - chunks.push(chunk) - } - return JSON.parse(Buffer.concat(chunks).toString('utf8')) -} - -function assertInteger(value, label, minimum) { - if (!Number.isSafeInteger(value) || value < minimum) { - throw new Error(`${label} must be an integer of at least ${minimum}`) - } -} - -function assertLogName(name) { - if (typeof name !== 'string' || !LOG_NAME.test(name)) { - throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`) - } -} - -function assertRequest(request) { - if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object') - switch (request.operation) { - case 'write': - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - return - case 'prune': - assertInteger(request.retain, 'retain', 0) - return - case 'clean': - return - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -function assertIdentity(value, label) { - if ( - typeof value !== 'object' - || value === null - || typeof value.dev !== 'string' - || typeof value.ino !== 'string' - ) { - throw new Error(`missing expected ${label} identity`) - } -} - -function identityOf(metadata) { - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -function sameIdentity(metadata, expected) { - return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino -} - -async function assertPinnedRepository(repository) { - if ( - typeof repository !== 'object' - || repository === null - || typeof repository.root !== 'string' - || !isAbsolute(repository.root) - || typeof repository.relative !== 'string' - || repository.relative === '' - || repository.relative === '..' - || repository.relative.startsWith(`..${sep}`) - || isAbsolute(repository.relative) - ) { - throw new Error('invalid repository-relative gate-log path') - } - assertIdentity(repository.identity, 'repository') - const names = repository.relative.split(sep) - if (!Array.isArray(repository.components) || repository.components.length !== names.length) { - throw new Error('invalid gate-log path-component plan') - } - for (let index = 0; index < names.length; index += 1) { - const component = repository.components[index] - if ( - typeof component !== 'object' - || component === null - || component.name !== names[index] - || !('identity' in component) - ) { - throw new Error('invalid gate-log path-component plan') - } - if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`) - } - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) { - throw new Error('gate-log repository identity changed before the helper started') - } - const rootMetadata = await lstat(repository.root, { bigint: true }) - if ( - !rootMetadata.isDirectory() - || rootMetadata.isSymbolicLink() - || !sameIdentity(rootMetadata, repository.identity) - ) { - throw new Error('gate-log repository root is not a real directory') - } - return repository.components -} - -async function enterLogDirectory(components, create) { - const traversed = [] - for (const component of components) { - if (component.name === '' || component.name === '.' || component.name === '..') { - throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`) - } - traversed.push(component.name) - let componentMetadata - let created = false - try { - componentMetadata = await lstat(component.name, { bigint: true }) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - if (component.identity !== null) { - throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`) - } - if (!create) return undefined - try { - await mkdir(component.name, { mode: 0o700 }) - } catch (mkdirError) { - if (errorCode(mkdirError) === 'EEXIST') { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - throw mkdirError - } - componentMetadata = await lstat(component.name, { bigint: true }) - created = true - } - if (component.identity === null && !created) { - throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) - } - if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) { - throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`) - } - const shown = traversed.join('/') - if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) { - throw new Error(`gate-log path component is not a real directory: ${shown}`) - } - const expected = component.identity ?? identityOf(componentMetadata) - process.chdir(component.name) - const pinnedMetadata = await stat('.', { bigint: true }) - if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) { - throw new Error(`gate-log path component identity changed before pinning: ${shown}`) - } - } - await chmod('.', 0o700) - return identityOf(await stat('.', { bigint: true })) -} - -async function removeOldLogs(retain, newest) { - assertInteger(retain, 'retain', 0) - const entries = await readdir('.', { withFileTypes: true }) - const logs = [] - for (const entry of entries) { - if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue - let metadata - try { - metadata = await lstat(entry.name, { bigint: true }) - } catch (error) { - if (errorCode(error) === 'ENOENT') continue - throw error - } - if (!metadata.isFile() || metadata.isSymbolicLink()) continue - logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs }) - } - logs.sort((left, right) => { - if (left.name === newest) return 1 - if (right.name === newest) return -1 - if (left.mtimeNs < right.mtimeNs) return -1 - if (left.mtimeNs > right.mtimeNs) return 1 - return left.name.localeCompare(right.name) - }) - const removed = [] - for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) { - try { - await unlink(entry.name) - removed.push(entry.name) - } catch (error) { - if (errorCode(error) !== 'ENOENT') throw error - } - } - return removed -} - -async function writeLog(request) { - assertLogName(request.filename) - assertInteger(request.retention, 'retention', 1) - if (typeof request.content !== 'string') throw new Error('gate-log content must be a string') - const handle = await open( - request.filename, - constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, - 0o600, - ) - try { - await handle.writeFile(request.content, 'utf8') - await handle.chmod(0o600) - } finally { - await handle.close() - } - const removed = await removeOldLogs(request.retention, request.filename) - return { filename: request.filename, removed } -} - -async function main() { - const request = await readRequest() - assertRequest(request) - const components = await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(components, request.operation === 'write') - if (directory === undefined) return { removed: [] } - switch (request.operation) { - case 'write': { - const result = await writeLog(request) - return { ...result, directory } - } - case 'prune': - return { directory, removed: await removeOldLogs(request.retain) } - case 'clean': - return { directory, removed: await removeOldLogs(0) } - default: - throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`) - } -} - -try { - process.stdout.write(`${JSON.stringify(await main())}\n`) -} catch (error) { - process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`) - process.exitCode = 1 -} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 13a37d0070..1a86ccd064 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,24 +1,14 @@ import { - existsSync, - mkdirSync, mkdtempSync, - readFileSync, - readdirSync, - renameSync, rmSync, - statSync, symlinkSync, - writeFileSync, } 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 { - cleanGateFailureLogs, executeGatePlan, - failureLogUnavailableReason, - formatGateFailureLog, formatGatePlanJson, formatGatePlanList, formatGateResultReason, @@ -27,15 +17,12 @@ import { gatePlanForMode, isMainModule, listedGatePlan, - limitGateFailureLog, parseCliRequest, - pruneGateLogs, replayCommand, resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, - writeGateFailureLog, type Gate, type GatePlan, type GateResult, @@ -77,36 +64,12 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate } } -function temporaryRoot(prefix = 'dsh-gate-logs-'): string { +function temporaryRoot(prefix = 'dsh-run-gates-'): string { const root = mkdtempSync(join(tmpdir(), prefix)) temporaryRoots.push(root) return root } -function invokeGateLogOperation( - operation: 'write' | 'prune' | 'clean', - subjectGate: Gate, - directory: string, - root: string, - beforeHelper: () => void, -): Promise { - switch (operation) { - case 'write': - return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot: root, - retention: 1, - unique: operation, - platform: 'linux', - beforeHelper, - }) - case 'prune': - return pruneGateLogs(directory, 0, root, beforeHelper) - case 'clean': - return cleanGateFailureLogs(directory, root, beforeHelper) - } -} - function withPnpmEntrypoint(action: () => T): T { const previous = process.env.npm_execpath process.env.npm_execpath = '/private/pnpm.cjs' @@ -167,6 +130,31 @@ describe('gate plan validation', () => { expect(execute).not.toHaveBeenCalled() }) + it('reports a settled failure before an unrelated gate finishes', async () => { + const first = gate('first') + const second = gate('second') + const settle = new Map void>() + const observed: string[] = [] + const execution = executeGatePlan( + plan([first, second]), + 2, + subject => new Promise(resolve => settle.set(subject.id, resolve)), + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + const settleFirst = settle.get(first.id) + const settleSecond = settle.get(second.id) + if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start') + settleFirst(resultFor(first, 'failed')) + await vi.waitFor(() => { + expect(observed).toEqual(['first:failed']) + }) + settleSecond(resultFor(second)) + + await expect(execution).resolves.toHaveLength(2) + expect(observed).toEqual(['first:failed', 'second:passed']) + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -182,14 +170,13 @@ describe('gate plan validation', () => { }) describe('gate plan inspection and replay', () => { - it('parses package-script separators, list JSON, focused runs, and cleanup', () => { + 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, }) expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot', }) - expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' }) expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list') expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode') }) @@ -307,288 +294,6 @@ describe('gate plan inspection and replay', () => { expect(result.exitCode).toBeNull() expect(result.signalCode).toBe('SIGTERM') expect(formatGateResultReason(result)).toBe('signal SIGTERM') - expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM') - }) -}) - -describe('gate failure logs', () => { - it('records attributable scheduler metadata without inherited secrets', () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') - const subjectGate = gate('snapshot', { - env: { - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, - }, - }) - const subject = plan([subjectGate]) - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: 'failure details\n' }], - stderr: 'failure details\n', - } - const log = formatGateFailureLog(subject, failure) - expect(log).toContain('replay: pnpm run check:all -- --only snapshot') - expect(log).toContain('DSH_EXAMPLE_MODE') - expect(log).toContain('') - expect(log).toContain('[stderr]\nfailure details') - expect(log).not.toContain('ambient-secret') - expect(log).not.toContain('scheduler-secret') - }) - - it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const subject = plan([subjectGate]) - const failure = resultFor(subjectGate, 'failed') - - const first = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }) - const second = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux', - }) - const third = await writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - }) - - expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort()) - expect(readFileSync(third, 'utf8')).toContain('run-gates failure log') - expect(statSync(directory).mode & 0o777).toBe(0o700) - expect(statSync(third).mode & 0o777).toBe(0o600) - expect(() => statSync(first)).toThrow() - await expect(writeGateFailureLog(subject, failure, { - directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux', - })).rejects.toThrow('EEXIST') - await cleanGateFailureLogs(directory, repositoryRoot) - expect(readdirSync(directory)).toEqual([]) - }) - - it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('build:web') - const path = await writeGateFailureLog( - plan([subjectGate]), - resultFor(subjectGate, 'failed'), - { - directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux', - }, - ) - expect(path.slice(directory.length + 1)).toContain('-build-web-') - expect(path.slice(directory.length + 1)).not.toContain(':') - }) - - it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 256, - unique: 'bounded', - now: new Date('2026-07-27T00:00:00Z'), - platform: 'linux', - }) - const content = readFileSync(path, 'utf8') - - expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256) - expect(content).toContain('[run-gates log truncated: original-bytes=') - expect(content).toContain('max-bytes=256') - expect(content).toContain('last detail') - expect(content).not.toContain('\uFFFD') - expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256)) - }) - - it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - const failure: GateResult = { - ...resultFor(subjectGate, 'failed'), - output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }], - } - const path = await writeGateFailureLog(plan([subjectGate]), failure, { - directory, - repositoryRoot, - retention: 1, - maxBytes: 400_000, - unique: 'control-heavy', - platform: 'linux', - }) - - expect(statSync(path).size).toBeLessThanOrEqual(400_000) - expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD') - }) - - it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => { - const auditRoot = temporaryRoot('dsh-gate-symlink-') - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(repositoryRoot) - mkdirSync(join(external, 'gates'), { recursive: true }) - const victim = join(external, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir') - const subjectGate = gate('subject') - const message = 'gate-log path component is a symbolic link: .cache' - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux', - })).rejects.toThrow(message) - await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message) - await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message) - expect(existsSync(victim)).toBe(true) - }) - - it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - mkdirSync(directory, { recursive: true }) - mkdirSync(external) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log') - if (victim !== undefined) { - mkdirSync(join(external, 'gates')) - writeFileSync(victim, 'keep\n') - } - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - symlinkSync(external, cache, 'dir') - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(existsSync(join(external, 'gates'))).toBe(false) - } else { - expect(readFileSync(victim, 'utf8')).toBe('keep\n') - expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log']) - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { - const subjectGate = gate('subject') - - for (const operation of ['write', 'prune', 'clean'] as const) { - const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-swap-`) - const repositoryRoot = join(auditRoot, 'repository') - const external = join(auditRoot, 'external') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - const displacedCache = join(repositoryRoot, '.cache-pinned') - const externalCache = join(external, 'cache') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log') - if (victim !== undefined) writeFileSync(victim, 'keep\n') - const swapAncestor = (): void => { - renameSync(cache, displacedCache) - renameSync(externalCache, cache) - } - - const invocation = invokeGateLogOperation( - operation, - subjectGate, - directory, - repositoryRoot, - swapAncestor, - ) - - await expect(invocation).rejects.toThrow('gate-log helper') - if (victim === undefined) { - expect(readdirSync(join(cache, 'gates'))).toEqual([]) - } else { - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - } - expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n') - } - }) - - it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => { - const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalCache = join(auditRoot, 'external-cache') - const cache = join(repositoryRoot, '.cache') - const directory = join(cache, 'gates') - mkdirSync(repositoryRoot) - mkdirSync(join(externalCache, 'gates'), { recursive: true }) - const victim = join(externalCache, 'gates/victim.log') - writeFileSync(victim, 'keep\n') - const subjectGate = gate('subject') - - const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, - repositoryRoot, - retention: 1, - unique: 'missing-swap', - platform: 'linux', - beforeHelper: () => { - renameSync(externalCache, cache) - }, - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log']) - }) - - it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => { - const auditRoot = temporaryRoot('dsh-gate-root-swap-') - const repositoryRoot = join(auditRoot, 'repository') - const externalRoot = join(auditRoot, 'external-repository') - const displacedRoot = join(auditRoot, 'repository-pinned') - const directory = join(repositoryRoot, '.cache/gates') - mkdirSync(directory, { recursive: true }) - mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true }) - writeFileSync(join(directory, 'old.log'), 'old private log\n') - writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n') - - const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => { - renameSync(repositoryRoot, displacedRoot) - renameSync(externalRoot, repositoryRoot) - }) - - await expect(invocation).rejects.toThrow('gate-log helper') - expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n') - expect(readFileSync(join(displacedRoot, '.cache/gates/old.log'), 'utf8')).toBe('old private log\n') - }) - - it('uses a console-only fallback on Windows before creating a retention directory', async () => { - const repositoryRoot = temporaryRoot() - const directory = join(repositoryRoot, '.cache/gates') - const subjectGate = gate('subject') - expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console') - expect(failureLogUnavailableReason('linux')).toBeUndefined() - - await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), { - directory, repositoryRoot, platform: 'win32', - })).rejects.toThrow('retained failure logs are disabled on Windows') - expect(existsSync(directory)).toBe(false) }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 08a524556e..1f6d18c2e0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -2,15 +2,13 @@ * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. * * Package scripts own public aggregate names; this runner owns their validated - * dependency graphs, scheduler environment, replay diagnostics, and private logs. + * dependency graphs, scheduler environment, and replay diagnostics. * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md */ import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { realpathSync } from 'node:fs' -import { lstat } from 'node:fs/promises' import { availableParallelism } from 'node:os' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { pathToFileURL } from 'node:url' @@ -74,8 +72,6 @@ export interface GateResult { exitCode: number | null signalCode: NodeJS.Signals | null error?: string - logPath?: string - logError?: string } interface GateOutputChunk { @@ -102,12 +98,6 @@ interface RunRequest { only?: string } -interface CleanLogsRequest { - kind: 'clean-logs' -} - -type CliRequest = RunRequest | CleanLogsRequest - interface ListedEnvironmentOverride { operation: GateEnvironmentOverride['operation'] value?: string @@ -132,41 +122,10 @@ interface ListedPlan { gates: ListedGate[] } -interface GateLogDirectoryIdentity { - dev: string - ino: string -} - -interface GateLogPathComponent { - name: string - identity: GateLogDirectoryIdentity | null -} - -interface GateLogPathPlan { - repositoryIdentity: GateLogDirectoryIdentity - pathComponents: GateLogPathComponent[] -} - -type GateLogHelperRequest = - | { operation: 'write'; filename: string; content: string; retention: number } - | { operation: 'prune'; retain: number } - | { operation: 'clean' } - -interface GateLogHelperResult { - directory?: GateLogDirectoryIdentity - filename?: string - removed: string[] -} - type GateExecutor = (gate: Gate) => Promise -type ResultObserver = (result: GateResult) => Promise | void +type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const gateLogRoot = resolve(root, '.cache/gates') -const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs') -const GATE_LOG_RETENTION = 20 -const GATE_LOG_MAX_BYTES = 1_048_576 -const MIN_GATE_LOG_MAX_BYTES = 128 const MODE_SCRIPTS: Record = { 'ci-primary': 'check:ci', 'ci-static': 'check:ci:static', @@ -187,12 +146,6 @@ if (isMainModule()) process.exitCode = await main(process.argv.slice(2)) async function main(args: string[]): Promise { const request = parseCliRequest(args) - if (request.kind === 'clean-logs') { - await cleanGateFailureLogs() - console.log('run-gates: cleared retained logs in .cache/gates/.') - return 0 - } - const completePlan = gatePlanForMode(request.mode) validateGatePlan(completePlan) if (request.list) { @@ -212,8 +165,7 @@ async function main(args: string[]): Promise { const startedAt = performance.now() console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) - const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => { - await attachFailureLog(completePlan, result) + const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => { printResult(completePlan, result) }) printSummary(completePlan, results, performance.now() - startedAt) @@ -240,14 +192,9 @@ export function isMainModule(entry: string | undefined = process.argv[1]): boole /** * Parse one runner invocation without constructing or starting its plan. * @param args - command-line arguments after the script entrypoint. - * @returns the validated run or cleanup request. + * @returns the validated run request. */ -export function parseCliRequest(args: readonly string[]): CliRequest { - if (args[0] === '--clean-logs') { - if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.') - return { kind: 'clean-logs' } - } - +export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) let list = false let json = false @@ -946,7 +893,7 @@ export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv) * @param plan - complete or diagnostic plan to execute. * @param maxActive - maximum concurrent child count. * @param execute - child-process executor. - * @param observe - serialized result observer. + * @param observe - result observer invoked when each gate settles. * @returns results in canonical plan order. */ export async function executeGatePlan( @@ -965,358 +912,6 @@ export async function executeGatePlan( return runGates(plan.gates, maxActive, execute, observe) } -/** - * Format one private failure log without consulting or enumerating the inherited environment. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @returns attributable metadata and interleaved output. - */ -export function formatGateFailureLog(plan: GatePlan, result: GateResult): string { - const gate = listedGate(result.gate) - const lines = [ - 'run-gates failure log', - `mode: ${plan.mode}`, - `gate: ${gate.id}`, - `status: ${result.status}`, - `blocking: ${gate.blocking}`, - `command: ${gate.command}`, - `replay: ${replayCommand(plan, gate.id)}`, - `scheduler environment: ${JSON.stringify(gate.env)}`, - `exit code: ${result.exitCode === null ? 'none' : result.exitCode}`, - `signal: ${result.signalCode ?? 'none'}`, - ] - if (result.error !== undefined) lines.push(`error: ${result.error}`) - lines.push('', 'interleaved output:') - for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text) - return `${lines.join('\n')}\n` -} - -/** - * Explain why retained logs are unavailable on a platform. - * @param platform - host platform to evaluate. - * @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported. - */ -export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined { - return platform === 'win32' - ? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console' - : undefined -} - -/** - * Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata. - * @param content - complete formatted failure log. - * @param maxBytes - maximum encoded byte length. - * @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker. - */ -export function limitGateFailureLog(content: string, maxBytes: number): string { - if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) { - throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`) - } - const originalBytes = Buffer.byteLength(content) - if (originalBytes <= maxBytes) return content - - const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n` - const available = maxBytes - Buffer.byteLength(marker) - if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.') - const prefixBytes = Math.ceil(available / 2) - const suffixBytes = available - prefixBytes - return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}` -} - -function utf8Prefix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let end = maxBytes - while (end > 0) { - const byte = encoded[end] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - end -= 1 - } - return encoded.subarray(0, end).toString('utf8') -} - -function utf8Suffix(content: string, maxBytes: number): string { - const encoded = Buffer.from(content) - if (encoded.length <= maxBytes) return content - let start = encoded.length - maxBytes - while (start < encoded.length) { - const byte = encoded[start] - if (byte === undefined || (byte & 0xc0) !== 0x80) break - start += 1 - } - return encoded.subarray(start).toString('utf8') -} - -/** - * Write one exclusive owner-only POSIX failure log and keep only the newest bounded set. - * @param plan - complete owning plan. - * @param result - failed child outcome. - * @param options - injectable storage, bound, clock, identity, and platform seams. - * @returns the absolute log path. - */ -export async function writeGateFailureLog( - plan: GatePlan, - result: GateResult, - options: { - directory?: string - repositoryRoot?: string - retention?: number - maxBytes?: number - unique?: string - now?: Date - platform?: NodeJS.Platform - beforeHelper?: () => Promise | void - } = {}, -): Promise { - const directory = options.directory ?? gateLogRoot - const repositoryRoot = options.repositoryRoot ?? root - const retention = options.retention ?? GATE_LOG_RETENTION - const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES - const unique = options.unique ?? randomUUID() - const now = options.now ?? new Date() - const unavailable = failureLogUnavailableReason(options.platform) - if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`) - if (!Number.isSafeInteger(retention) || retention < 1) { - throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - const timestamp = now.toISOString().replaceAll(/[:.]/g, '-') - const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '') - if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.') - const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-') - const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log` - const helperResult = await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { - operation: 'write', - filename, - content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes), - retention, - }, - options.beforeHelper, - ) - if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.') - return resolve(directory, filename) -} - -async function inspectRepoLocalLogPath( - repositoryRoot: string, - target: string, -): Promise { - const relativeTarget = relative(repositoryRoot, target) - if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) { - throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`) - } - - const rootMetadata = await lstat(repositoryRoot, { bigint: true }) - if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) { - throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`) - } - const components: GateLogPathComponent[] = [] - let current = repositoryRoot - let missing = false - for (const component of relativeTarget.split(sep)) { - current = resolve(current, component) - if (missing) { - components.push({ name: component, identity: null }) - continue - } - let metadata - try { - metadata = await lstat(current, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) { - missing = true - components.push({ name: component, identity: null }) - continue - } - throw error - } - const shown = relative(repositoryRoot, current).split(sep).join('/') - if (metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`) - } - if (!metadata.isDirectory()) { - throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`) - } - components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } }) - } - return { - repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) }, - pathComponents: components, - } -} - -function hasErrorCode(error: unknown, code: string): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === code -} - -async function readDirectoryIdentity(directory: string): Promise { - let metadata - try { - metadata = await lstat(directory, { bigint: true }) - } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return undefined - throw error - } - if (!metadata.isDirectory() || metadata.isSymbolicLink()) { - throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`) - } - return { dev: String(metadata.dev), ino: String(metadata.ino) } -} - -async function runGateLogHelper( - directory: string, - repositoryRoot: string, - repositoryIdentity: GateLogDirectoryIdentity, - pathComponents: GateLogPathComponent[], - request: GateLogHelperRequest, - beforeHelper: (() => Promise | void) | undefined, -): Promise { - await beforeHelper?.() - const payload = JSON.stringify({ - ...request, - repository: { - root: repositoryRoot, - relative: relative(repositoryRoot, directory), - identity: repositoryIdentity, - components: pathComponents, - }, - }) - const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { - const child = spawn(process.execPath, [gateLogHelper], { - cwd: repositoryRoot, - env: {}, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (chunk: string) => { - stdout += chunk - }) - child.stderr.on('data', (chunk: string) => { - stderr += chunk - }) - child.on('error', reject) - child.on('close', (status) => { - resolveResult({ status, stdout, stderr }) - }) - child.stdin.on('error', (error: NodeJS.ErrnoException) => { - if (error.code !== 'EPIPE') reject(error) - }) - child.stdin.end(payload) - }) - if (result.status !== 0) { - throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`) - } - let parsed: unknown - try { - parsed = JSON.parse(result.stdout) - } catch { - throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`) - } - if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.') - await inspectRepoLocalLogPath(repositoryRoot, directory) - const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if ( - currentRepositoryIdentity === undefined - || currentRepositoryIdentity.dev !== repositoryIdentity.dev - || currentRepositoryIdentity.ino !== repositoryIdentity.ino - ) { - throw new Error('run-gates: repository root identity changed while the gate-log helper was running.') - } - if (parsed.directory !== undefined) { - const currentDirectoryIdentity = await readDirectoryIdentity(directory) - if ( - currentDirectoryIdentity === undefined - || currentDirectoryIdentity.dev !== parsed.directory.dev - || currentDirectoryIdentity.ino !== parsed.directory.ino - ) { - throw new Error('run-gates: gate-log directory identity changed while the helper was running.') - } - } else if (request.operation === 'write') { - throw new Error('run-gates: gate-log helper did not return the created directory identity.') - } - return parsed -} - -function isGateLogHelperResult(value: unknown): value is GateLogHelperResult { - if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false - if (!value.removed.every(entry => typeof entry === 'string')) return false - if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false - return !('directory' in value) - || value.directory === undefined - || isGateLogDirectoryIdentity(value.directory) -} - -function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity { - return typeof value === 'object' - && value !== null - && 'dev' in value - && typeof value.dev === 'string' - && 'ino' in value - && typeof value.ino === 'string' -} - -/** Clear retained logs through a subprocess that pins the repository and each path component before use. */ -export async function cleanGateFailureLogs( - directory = gateLogRoot, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'clean' }, - beforeHelper, - ) -} - -/** - * Remove older scheduler log files until at most `retain` remain. - * @param directory - private log directory. - * @param retain - number of newest log files to preserve. - * @param repositoryRoot - repository boundary containing the log directory. - * @param beforeHelper - test seam invoked after identity capture and before subprocess spawn. - */ -export async function pruneGateLogs( - directory: string, - retain: number, - repositoryRoot = root, - beforeHelper?: () => Promise | void, -): Promise { - if (!Number.isSafeInteger(retain) || retain < 0) { - throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) - } - const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) - await runGateLogHelper( - directory, - repositoryRoot, - repositoryIdentity, - pathComponents, - { operation: 'prune', retain }, - beforeHelper, - ) -} - -async function attachFailureLog(plan: GatePlan, result: GateResult): Promise { - if (result.status !== 'failed') return - try { - const path = await writeGateFailureLog(plan, result) - result.logPath = relative(root, path).split(sep).join('/') - } catch (error: unknown) { - result.logError = error instanceof Error ? error.message : String(error) - } -} - async function runGates( allGates: Gate[], maxActive: number, @@ -1355,7 +950,7 @@ async function runGates( } states.set(gate.id, 'skipped') results.set(gate.id, result) - await observe(result) + observe(result) } break } @@ -1365,7 +960,7 @@ async function runGates( running.splice(running.indexOf(settled.item), 1) states.set(settled.item.gate.id, settled.result.status) results.set(settled.item.gate.id, settled.result) - await observe(settled.result) + observe(settled.result) } } @@ -1477,11 +1072,6 @@ function printResult(plan: GatePlan, result: GateResult): void { console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) { - console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`) - console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs') - } - if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`) } printOutput(result.output) if (result.error !== undefined) console.error(result.error) @@ -1504,7 +1094,6 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number) const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : '' console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`) console.error(` replay: ${replayCommand(plan, result.gate.id)}`) - if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`) } } 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 05/13] 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 From 55d6ab52207e3f8092047f46608c95f487636001 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:12:16 +0800 Subject: [PATCH 06/13] fix(dev-infra): harden gate runner execution --- ...2026-07-27-replayable-gate-plans.i18n.yaml | 4 +- .../2026-07-27-replayable-gate-plans.md | 6 +- .../2026-07-27-replayable-gate-plans.zh.md | 8 +-- scripts/run-gates.spec.ts | 70 ++++++++++++++++--- scripts/run-gates.ts | 47 +++++++++---- 5 files changed, 103 insertions(+), 32 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 aa9b6d0cbc..f61cb168d2 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: 8a42ae3c89a3f75bb248f78583f6ef825af3241e -2026-07-27-replayable-gate-plans.zh.md: 572ef1ce131a0ced8d723e1caa822fe47556fcee +2026-07-27-replayable-gate-plans.md: fc4f883d74d765d173a6d1419c2e75f44bd1966f +2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef 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 8a42ae3c89..fc4f883d74 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 @@ -8,13 +8,13 @@ English | [中文](2026-07-27-replayable-gate-plans.zh.md) Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. -Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was complete. +Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing later commands to consume restored artifacts before publint and built-package invariant checks established their public and runtime-closure contracts. ## Decision [`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 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. +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. Gate-level spawn overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection serializes those operations without resolving them against inherited values; values under secret-like declared 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. @@ -24,7 +24,7 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co ## Verification -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, direct and symlinked entries emit one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. ## Alternatives considered 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 572ef1ce13..d6c6eb9649 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 @@ -8,15 +8,15 @@ Status: implemented 仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 -故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许在任何命令确认下载产物完整之前消费恢复后的构建产物。 +故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许后续命令在 publint 和已构建包(package)不变式检查确立恢复后产物的公开契约与运行时闭包契约之前,就消费这些产物。 ## 决策 [`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 覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查会序列化这些操作,而不会结合继承值进行解析;声明的名称若疑似机密,其值会被脱敏。 -`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 +`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 @@ -24,7 +24,7 @@ Status: implemented ## 验证 -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 +[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、直接入口和符号链接入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 ## 曾考虑的替代方案 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index d112bccd89..142a221070 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -12,7 +14,6 @@ import { listedGatePlan, parseCliRequest, replayCommand, - resolveGateEnvironment, resolvePlanConcurrency, runGate, validateGatePlan, @@ -89,6 +90,7 @@ describe('gate plan validation', () => { it.each([ ['empty', plan([]), /plan has no gates/], ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], + ['unsafe ids', plan([gate('unsafe id')]), /gate id "unsafe id" must contain only lowercase letters/], ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], ])('rejects %s before starting a child', async (_label, invalid, message) => { @@ -138,6 +140,27 @@ describe('gate plan validation', () => { expect(observed).toEqual(['first:failed', 'second:passed']) }) + it('propagates dependency skips in causal order', async () => { + const leaf = gate('leaf', { needs: ['middle'] }) + const middle = gate('middle', { needs: ['root'] }) + const rootGate = gate('root') + const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) + const observed: string[] = [] + + const results = await executeGatePlan( + plan([leaf, middle, rootGate]), + 1, + execute, + result => observed.push(`${result.gate.id}:${result.status}`), + ) + + expect(execute).toHaveBeenCalledOnce() + expect(execute).toHaveBeenCalledWith(rootGate) + expect(observed).toEqual(['root:failed', 'middle:skipped', 'leaf:skipped']) + expect(results.find(result => result.gate === middle)?.error).toBe('dependency failed or skipped: root') + expect(results.find(result => result.gate === leaf)?.error).toBe('dependency failed or skipped: middle') + }) + it('selects a target with its transitive dependencies in canonical plan order', () => { const subject = plan([ gate('prepare'), @@ -235,6 +258,32 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('executes when the script entry path is a symlink', () => { + const temporary = mkdtempSync(join(tmpdir(), 'dsh-run-gates-entry-')) + const entry = join(temporary, 'run-gates.ts') + try { + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + entry, + 'ci-consumers', + '--list', + '--json', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: process.env.npm_execpath ?? '/private/pnpm.cjs' }, + timeout: 10_000, + }) + + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toMatchObject({ mode: 'ci-consumers', maxWorkers: 7 }) + } finally { + rmSync(temporary, { recursive: true, force: true }) + } + }) + 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') @@ -243,17 +292,22 @@ describe('gate plan inspection and replay', () => { ) }) - it('resolves append and set operations only when spawning', () => { - const resolved = resolveGateEnvironment(gate('subject', { + it('applies append and set operations through the child spawn environment', async () => { + vi.stubEnv('NODE_OPTIONS', '--trace-warnings') + vi.stubEnv('INHERITED', 'kept') + const result = await runGate(gate('subject', { + args: ['-e', 'process.stdout.write(JSON.stringify({ nodeOptions: process.env.NODE_OPTIONS, mode: process.env.MODE, inherited: process.env.INHERITED }))'], env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, MODE: { operation: 'set', value: 'lib' }, }, - }), { NODE_OPTIONS: '--trace-warnings', INHERITED: 'kept' }) - expect(resolved).toEqual({ - NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192', - MODE: 'lib', - INHERITED: 'kept', + })) + + expect(result.status).toBe('passed') + expect(JSON.parse(result.stdout)).toEqual({ + nodeOptions: '--trace-warnings --max-old-space-size=8192', + mode: 'lib', + inherited: 'kept', }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9a8367ec79..2b376d6882 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,7 +9,6 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' -import { pathToFileURL } from 'node:url' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -121,8 +120,7 @@ type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void const root = resolve(import.meta.dirname, '..') -const entry = process.argv[1] -if (entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href) { +if (import.meta.main) { process.exitCode = await main(process.argv.slice(2)) } @@ -833,21 +831,31 @@ export function formatOnlyNotice(plan: GatePlan, gateId: string): string { * @param inherited - environment inherited by the runner. * @returns the child environment without mutating the inherited object. */ -export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const resolved = { ...inherited } for (const [name, override] of Object.entries(gate.env ?? {})) { - if (override.operation === 'set') { - resolved[name] = override.value - } else { - const current = resolved[name] - resolved[name] = current === undefined || current === '' - ? override.value - : `${current} ${override.value}` + switch (override.operation) { + case 'set': + resolved[name] = override.value + break + case 'append': { + const current = resolved[name] + resolved[name] = current === undefined || current === '' + ? override.value + : `${current} ${override.value}` + break + } + default: + assertNever(override) } } return resolved } +function assertNever(value: never): never { + throw new Error(`run-gates: unreachable value ${JSON.stringify(value)}.`) +} + /** * Run a validated plan; invalid input rejects before the injected executor can start a child. * @param plan - complete or diagnostic plan to execute. @@ -894,9 +902,17 @@ async function runGates( } if (running.length === 0) { - const pending = allGates.filter(gate => states.get(gate.id) === 'pending') - for (const gate of pending) { - const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed') + let pending = allGates.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 plan 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', @@ -911,6 +927,7 @@ async function runGates( states.set(gate.id, 'skipped') results.set(gate.id, result) observe(result) + pending = pending.filter(item => item !== gate) } break } @@ -1031,10 +1048,10 @@ function printResult(plan: GatePlan, result: GateResult): void { const environment = listedGate(result.gate).env console.error(`command: ${result.gate.displayCommand}`) if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) + console.error(`outcome: ${formatGateResultReason(result)}`) console.error(`replay: ${replayCommand(plan, result.gate.id)}`) } printOutput(result.output) - if (result.error !== undefined) console.error(result.error) } function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { From 6b19be28430902558bcfce5f46adfaafbba6e31f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:11 +0800 Subject: [PATCH 07/13] refactor(dev-infra): use Node gate argv parsing --- scripts/run-gates.ts | 38 +++++++++++++------------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2b376d6882..fbcc3232ef 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { parseArgs } from 'node:util' const MODE_SCRIPTS = { 'ci-primary': 'check:ci', @@ -96,14 +97,12 @@ interface RunRequest { only?: string } -type ListedEnvironmentOverride = GateEnvironmentOverride - interface ListedGate { id: string label: string command: string needs: string[] - env: Record + env: Record blocking: boolean } @@ -161,28 +160,17 @@ async function main(args: string[]): Promise { */ export function parseCliRequest(args: readonly string[]): RunRequest { const mode = parseMode(args[0]) - let list = false - let json = false - let only: string | undefined - const firstOption = args[1] === '--' ? 2 : 1 - for (let index = firstOption; index < args.length; index += 1) { - const arg = args[index] - if (arg === '--list') { - if (list) throw new Error('run-gates: --list may be specified only once.') - list = true - } else if (arg === '--json') { - if (json) throw new Error('run-gates: --json may be specified only once.') - json = true - } else if (arg === '--only') { - if (only !== undefined) throw new Error('run-gates: --only may be specified only once.') - const id = args[index + 1] - if (id === undefined || id.startsWith('--')) throw new Error('run-gates: --only requires a gate id.') - only = id - index += 1 - } else { - throw new Error(`run-gates: unsupported argument ${JSON.stringify(arg)}.`) - } - } + const optionArgs = args[1] === '--' ? args.slice(2) : args.slice(1) + const { values: { list, json, only } } = parseArgs({ + args: optionArgs, + options: { + list: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + only: { type: 'string' }, + }, + strict: true, + allowPositionals: false, + }) 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 { mode, list, json, ...only === undefined ? {} : { only } } From 4338b2725cc2ec7b18c10e8ab29af7a361ea10d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:13:52 +0800 Subject: [PATCH 08/13] fix(dev-infra): distinguish gate results from states --- scripts/run-gates.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fbcc3232ef..8feaf576e5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -32,7 +32,8 @@ export type Mode = keyof typeof MODE_SCRIPTS const MODES = Object.keys(MODE_SCRIPTS) as Mode[] -type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' +type GateResultStatus = 'passed' | 'failed' | 'skipped' +type GateState = 'pending' | 'running' | GateResultStatus /** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ export type GateEnvironmentOverride = @@ -64,7 +65,7 @@ export interface GatePlan { /** The observed outcome of one gate process. */ export interface GateResult { gate: Gate - status: GateStatus + status: GateResultStatus durationMs: number stdout: string stderr: string @@ -874,7 +875,7 @@ async function runGates( execute: GateExecutor, observe: ResultObserver, ): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) + const states = new Map(allGates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] @@ -936,7 +937,7 @@ async function runGates( }) } -function dependenciesPassed(gate: Gate, states: Map): boolean { +function dependenciesPassed(gate: Gate, states: Map): boolean { return (gate.needs ?? []).every(id => states.get(id) === 'passed') } @@ -983,7 +984,7 @@ export async function runGate(gate: Gate): Promise { }) const { exitCode, signalCode } = outcome - let status: GateStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' + let status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' let error = spawnError if (status === 'passed' && gate.verify !== undefined) { try { From 32645aaa294e7a3e8386ca4f3c6ec6cd612d7cea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:17:09 +0800 Subject: [PATCH 09/13] fix(dev-infra): repair gate listing type --- scripts/run-gates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 8feaf576e5..bc0b3d9476 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -749,7 +749,7 @@ function listedGate(gate: Gate): ListedGate { function listedEnvironment( environment: Readonly> | undefined, -): Record { +): Record { if (environment === undefined) return {} return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { const value = sensitiveEnvironmentName(name) ? '' : override.value From e71aa49a26925c6df2a34b6e0a203d6c9d438c60 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:19:46 +0800 Subject: [PATCH 10/13] test(dev-infra): exercise replay diagnostics --- scripts/run-gates.spec.ts | 27 +++++++++++++++++++-------- scripts/run-gates.ts | 4 ++-- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 142a221070..898a637e6a 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -8,12 +8,10 @@ import { formatGatePlanJson, formatGatePlanList, formatGateResultReason, - formatOnlyNotice, gateDependencyClosure, gatePlanForMode, listedGatePlan, parseCliRequest, - replayCommand, resolvePlanConcurrency, runGate, validateGatePlan, @@ -284,12 +282,25 @@ describe('gate plan inspection and replay', () => { } }) - 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') - expect(formatOnlyNotice(subject, 'snapshot')).toBe( - 'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.', - ) + it('prints focused-run context and replay through a real failure block', () => { + const result = spawnSync(process.execPath, [ + '--import', + 'tsx', + join(repositoryRoot, 'scripts/run-gates.ts'), + 'ci-lint', + '--only', + 'duplication', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + env: { ...process.env, npm_execpath: join(repositoryRoot, 'scripts/missing-pnpm-entrypoint.cjs') }, + timeout: 10_000, + }) + + expect(result.status).toBe(1) + expect(result.stdout).toContain('partial diagnostic evidence; the complete owning mode is pnpm run check:ci:lint') + expect(result.stderr).toContain('outcome: exit 1') + expect(result.stderr).toContain('replay: pnpm run check:ci:lint -- --only duplication') }) it('applies append and set operations through the child spawn environment', async () => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index bc0b3d9476..b5137b836b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -796,7 +796,7 @@ export function formatGatePlanJson(plan: GatePlan): string { * @param gateId - gate to replay with its dependencies. * @returns a shell-independent pnpm command. */ -export function replayCommand(plan: GatePlan, gateId: string): string { +function replayCommand(plan: GatePlan, gateId: string): string { validateGatePlan(plan) if (!plan.gates.some(gate => gate.id === gateId)) { throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) @@ -810,7 +810,7 @@ export function replayCommand(plan: GatePlan, gateId: string): string { * @param gateId - selected diagnostic gate. * @returns the partial-evidence notice. */ -export function formatOnlyNotice(plan: GatePlan, gateId: string): string { +function formatOnlyNotice(plan: GatePlan, gateId: string): string { return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` } From 5b79a43d4cf4d9b219a2d35b8d0017ba594f0af1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:36:05 +0800 Subject: [PATCH 11/13] fix(dev-infra): serialize lint after invariant staging --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../implemented/process/2026-07-27-replayable-gate-plans.md | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- scripts/run-gates.spec.ts | 1 + scripts/run-gates.ts | 5 ++++- 5 files changed, 10 insertions(+), 6 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 f61cb168d2..dc28813ed1 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: fc4f883d74d765d173a6d1419c2e75f44bd1966f -2026-07-27-replayable-gate-plans.zh.md: d6c6eb9649120c7dfbfb6cd0c935e915c62f40ef +2026-07-27-replayable-gate-plans.md: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 +2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 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 fc4f883d74..2b912024ce 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 @@ -20,7 +20,7 @@ Every mode supports deterministic `--list` output and a versioned stable `--list The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. +The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`. Source compatibility smokes may overlap the validation stages; lint and duplication wait for built-package invariant validation so ESLint cannot traverse its transient staged package views, then may overlap downstream consumers. ## Verification @@ -42,6 +42,6 @@ The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level co The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. -Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. +Later artifact consumers and lint start only after publint and built-package invariant validation, so ESLint cannot traverse the verifier's transient staged views and those downstream gates may overlap one another. Source compatibility smokes still overlap both validation stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. 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 d6c6eb9649..0d356825c9 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 @@ -20,7 +20,7 @@ Status: implemented 调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。 +`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段。源码兼容性冒烟测试可以与验证阶段并行;lint 和 duplication 会等待已构建包不变式验证,以免 ESLint 遍历验证过程中临时暂存的包视图,之后可以与下游消费方并行。 ## 验证 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 898a637e6a..344f678374 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -364,6 +364,7 @@ describe('Node 24 consumer plan', () => { ]) expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.gates.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b5137b836b..a517a19127 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -414,7 +414,10 @@ function ciConsumerGates(): Gate[] { const publicArtifacts = ['publint'] const restoredBuild = ['built-package-invariants'] return [ - pnpmScript('lint-and-duplication', 'check:ci:lint', { label: 'lint and duplication' }), + pnpmScript('lint-and-duplication', 'check:ci:lint', { + label: 'lint and duplication', + needs: restoredBuild, + }), pnpmScript('node-compat', 'check:node-compat', { label: 'Node compatibility' }), snapshotGate(restoredBuild), pnpmScript('publint', 'publint'), From 02f9b55699423f11a5730f5a3ffa39f7f5f6f62e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:37:42 +0800 Subject: [PATCH 12/13] docs(dev-infra): synchronize gate plan consequences --- .../process/2026-07-27-replayable-gate-plans.i18n.yaml | 4 ++-- .../process/2026-07-27-replayable-gate-plans.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 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 dc28813ed1..771b3270bd 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: a45b4e056a57b0d4c55b5f0a5d61c85e10cc0f92 -2026-07-27-replayable-gate-plans.zh.md: 0d356825c9727dd404d78249619c15ccf2dfbea2 +2026-07-27-replayable-gate-plans.md: 2b912024ce337063cf677532e85d64f4fd8ab4a7 +2026-07-27-replayable-gate-plans.zh.md: 26bf4632ea84292ed48ce0cf8405d06e5bfa4aa9 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 0d356825c9..26bf4632ea 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 @@ -42,6 +42,6 @@ Status: implemented 调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 -后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 +后续产物消费方和 lint 只在 publint 和已构建包不变式验证通过后才启动,因此 ESLint 不会遍历验证器临时暂存的视图,而这些下游门禁可以彼此并行。源码兼容性冒烟测试仍可与这两个验证阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 From cbbd888cab03fde636f8319d7393a59773b35c74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:33:20 +0800 Subject: [PATCH 13/13] simplify gate graph validation --- ...26-07-06-parallel-pre-push-gates.i18n.yaml | 6 +- .../2026-07-06-parallel-pre-push-gates.md | 12 +- .../2026-07-06-parallel-pre-push-gates.zh.md | 12 +- ...2026-07-27-replayable-gate-plans.i18n.yaml | 6 - .../2026-07-27-replayable-gate-plans.md | 47 -- .../2026-07-27-replayable-gate-plans.zh.md | 47 -- scripts/run-gates.spec.ts | 343 ++---------- scripts/run-gates.ts | 521 ++++-------------- 8 files changed, 189 insertions(+), 805 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md delete mode 100644 .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml index cfa2cafeb6..9821b8fea4 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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 -2026-07-06-parallel-pre-push-gates.md: 0c3311b259a2fcf00deb4eed491c301a0c330186 -2026-07-06-parallel-pre-push-gates.zh.md: 6949237d2e025034162f66950033d3ad6ecf11ea +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +2026-07-06-parallel-pre-push-gates.md: d86642b7feb82908ec792db0c6a3da403cfc79fd +2026-07-06-parallel-pre-push-gates.zh.md: 0425cf1a01b56604a07be366dd46d3920c5fb487 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md index 0c3311b259..d86642b7fe 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md @@ -12,12 +12,18 @@ Aggregate jobs such as documentation synchronization hide long sequential chains ## Decision -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, respects artifact dependencies, buffers attributable output, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI, `doc-sync`, and the opt-in `check:all` command. It expands named modes into leaf gates, rejects empty or ambiguous dependency graphs before starting a child, respects artifact dependencies, buffers attributable output, reports exit and signal outcomes independently, and accepts `DSH_GATE_CONCURRENCY` when a caller needs a different worker bound. + +The Node 24 consumer job is one seven-gate mode rather than a shell-owned process pool. Its default worker count equals its gate count while dependencies control readiness: `publint` precedes built-package invariant validation, and snapshot replay, NodeNext type checks, built-bin smokes, and lint wait for that validation. Lint waits because the invariant verifier temporarily stages package views that ESLint must not traverse; source compatibility checks can overlap the validation chain. [scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers packages from `packages//` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block. The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygiene` stays an aggregate `&&` chain, while `doc-sync` owns its member list in the scheduler ([doc-sync through the gate scheduler](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md)). +## Verification + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) rejects invalid graphs before the executor runs, pins the consumer inventory and dependency edges, and exercises signal termination through a real child process. [scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) rejects a missing public export before downstream artifact consumers run. + ## Alternatives considered - **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup. @@ -28,6 +34,8 @@ The per-gate package scripts remain the vocabulary for ad hoc local runs. `hygie ## Consequences -Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. The cost is a custom scheduler with an explicit mode inventory. +Scheduler-backed commands take the slowest dependency chain instead of the sum of independent gates and report the gate that dominates. Invalid graphs fail before partial execution. The cost is a custom scheduler with an explicit mode inventory. + +The consumer validation chain delays restored-artifact consumers and lint until the shared artifact view is known-good and transient staging is gone; those downstream gates can still overlap one another. `publint-all.ts` is asynchronous and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md index 6949237d2e..0425cf1a01 100644 --- a/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md +++ b/.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.zh.md @@ -12,12 +12,18 @@ Status: implemented ## 决策 -[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,遵守产物依赖,缓冲可归因的输出,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 拥有 CI、`doc-sync` 和选择启用的 `check:all` 命令所使用的有界调度器。它将具名模式展开为叶子门禁,在启动子进程前拒绝空的或有歧义的依赖图,遵守产物依赖,缓冲可归因的输出,分别报告退出结果与信号结果,并在调用方需要不同 worker 上限时接受 `DSH_GATE_CONCURRENCY`。 + +Node 24 消费方 job 采用单个包含七道门禁的模式,而非由 shell 管理的进程池。其默认 worker 数等于门禁数,但门禁是否就绪由依赖关系控制:`publint` 先于已构建包不变式验证运行,快照回放、NodeNext 类型检查、built-bin 冒烟测试和 lint 则等待该验证完成。lint 之所以等待,是因为不变式验证器会临时暂存包视图,而 ESLint 不得遍历这些视图;源码兼容性检查可以与这条验证链重叠运行。 [scripts/publint-all.ts](../../../../scripts/publint-all.ts) 从 `packages//` 发现包,并以根据 `availableParallelism()` 确定大小的 worker 池运行 `publint`。`DSH_PUBLINT_CONCURRENCY` 可以针对资源配置不同的本地机器和 CI runner 限制或提高 worker 数量。结果按包缓冲,并按确定性的包顺序打印,因此并行执行不会打乱各包的日志块。 各门禁的包脚本仍是临时本地运行所用的词汇。`hygiene` 继续作为聚合 `&&` 链,而 `doc-sync` 在调度器中拥有其成员列表([通过门禁调度器运行 doc-sync](../../archived/process/2026-07-21-doc-sync-through-gate-scheduler.md))。 +## 验证 + +[scripts/run-gates.spec.ts](../../../../scripts/run-gates.spec.ts) 在执行器运行前拒绝无效图,锁定消费方清单和依赖边,并通过真实子进程验证信号终止。[scripts/publint-all.spec.ts](../../../../scripts/publint-all.spec.ts) 在下游产物消费方运行前拒绝缺失的公开导出。 + ## 曾考虑的替代方案 - **保持聚合 job 串行**:执行更简单,但墙钟时间等于各独立检查之和,并重复启动命令包装器。 @@ -28,6 +34,8 @@ Status: implemented ## 后果 -由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。代价是维护一个具有显式模式清单的定制调度器。 +由调度器支持的命令耗时取最慢依赖链,而非各独立门禁之和,并会报告主导耗时的门禁。无效图会直接失败,不会先执行其中一部分。代价是维护一个具有显式模式清单的定制调度器。 + +这条验证链会让已恢复产物的下游消费方和 lint 延后启动,直至共享产物视图经确认有效且临时暂存已清除;这些下游门禁仍可彼此重叠运行。 `publint-all.ts` 采用异步执行并缓冲命令输出,而不是实时继承 stdio。换来的是具有稳定输出顺序的包级并行,以及用于资源调节的单一环境变量。 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 deleted file mode 100644 index 771b3270bd..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# 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: 2b912024ce337063cf677532e85d64f4fd8ab4a7 -2026-07-27-replayable-gate-plans.zh.md: 26bf4632ea84292ed48ce0cf8405d06e5bfa4aa9 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 deleted file mode 100644 index 2b912024ce..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: Validated, self-describing, replayable gate plans - -Status: implemented - -English | [中文](2026-07-27-replayable-gate-plans.zh.md) - -## Problem - -Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. - -Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing later commands to consume restored artifacts before publint and built-package invariant checks established their public and runtime-closure contracts. - -## Decision - -[`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. Gate-level spawn overrides remain declarative until spawn and support only the forms current plans use: setting a value or appending one with a space. Inspection serializes those operations without resolving them against inherited values; values under secret-like declared 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. - -The scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted. - -The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the prior process pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`. Source compatibility smokes may overlap the validation stages; lint and duplication wait for built-package invariant validation so ESLint cannot traverse its transient staged package views, then may overlap downstream consumers. - -## Verification - -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, direct and symlinked entries emit one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool. - -## Alternatives considered - -**Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran. - -**Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident. - -**Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction. - -**Persist child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record. - -**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates. - -## Consequences - -The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate. - -Later artifact consumers and lint start only after publint and built-package invariant validation, so ESLint cannot traverse the verifier's transient staged views and those downstream gates may overlap one another. Source compatibility smokes still overlap both validation stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results. - -Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command. 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 deleted file mode 100644 index 26bf4632ea..0000000000 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 经过验证、自描述、可回放的门禁计划 - -Status: implemented - -[English](2026-07-27-replayable-gate-plans.md) | 中文 - -## 问题 - -仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。 - -故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。Node 24 消费方作业却曾自行管理一套独立的 shell 进程池,造成命令、并发度、环境和失败收集重复维护,并允许后续命令在 publint 和已构建包(package)不变式检查确立恢复后产物的公开契约与运行时闭包契约之前,就消费这些产物。 - -## 决策 - -[`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 覆盖在 spawn 之前保持声明式,并且只支持当前计划使用的两种形式:设置值,或以空格分隔后追加值。检查会序列化这些操作,而不会结合继承值进行解析;声明的名称若疑似机密,其值会被脱敏。 - -`--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 - -调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码和信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化。 - -`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段。源码兼容性冒烟测试可以与验证阶段并行;lint 和 duplication 会等待已构建包不变式验证,以免 ESLint 遍历验证过程中临时暂存的包视图,之后可以与下游消费方并行。 - -## 验证 - -[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、直接入口和符号链接入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 7 条命令的清单、工作进程默认值与上限,以及恢复后构建产物验证的依赖关系。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。 - -## 曾考虑的替代方案 - -**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。 - -**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。 - -**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。 - -**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。 - -**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。 - -## 后果 - -调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。 - -后续产物消费方和 lint 只在 publint 和已构建包不变式验证通过后才启动,因此 ESLint 不会遍历验证器临时暂存的视图,而这些下游门禁可以彼此并行。源码兼容性冒烟测试仍可与这两个验证阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。 - -缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成。 diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 344f678374..38b7d96a0c 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -1,29 +1,14 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, symlinkSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { - executeGatePlan, - formatGatePlanJson, - formatGatePlanList, + defaultConcurrency, formatGateResultReason, - gateDependencyClosure, - gatePlanForMode, - listedGatePlan, - parseCliRequest, - resolvePlanConcurrency, + gatesForMode, runGate, - validateGatePlan, + runGates, type Gate, - type GatePlan, type GateResult, } from './run-gates.ts' -const repositoryRoot = join(import.meta.dirname, '..') - -afterEach(() => vi.unstubAllEnvs()) - function gate(id: string, options: Partial = {}): Gate { return { id, @@ -35,17 +20,11 @@ function gate(id: string, options: Partial = {}): Gate { } } -function plan(gates: Gate[]): GatePlan { - return { mode: 'check-all', script: 'check:all', gates } -} - function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult { return { gate: subject, status, durationMs: 10, - stdout: '', - stderr: '', output: [], exitCode: status === 'passed' ? 0 : 1, signalCode: null, @@ -63,7 +42,7 @@ function withPnpmEntrypoint(action: () => T): T { } } -describe('gate plan validation', () => { +describe('gate graph validation', () => { it.each([ 'ci-primary', 'ci-static', @@ -78,282 +57,54 @@ describe('gate plan validation', () => { 'node-compat', 'check-all', 'doc-sync', - ] as const)('constructs a valid non-empty %s plan', (mode) => { - const subject = withPnpmEntrypoint(() => gatePlanForMode(mode)) - expect(() => { - validateGatePlan(subject) - }).not.toThrow() + ] as const)('constructs and executes preflight for a valid non-empty %s graph', async (mode) => { + const subject = withPnpmEntrypoint(() => gatesForMode(mode)) + const execute = vi.fn(async (item: Gate) => resultFor(item)) + + await expect(runGates(subject, subject.length, execute)).resolves.toHaveLength(subject.length) }) it.each([ - ['empty', plan([]), /plan has no gates/], - ['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/], - ['unsafe ids', plan([gate('unsafe id')]), /gate id "unsafe id" must contain only lowercase letters/], - ['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/], - ['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/], - ])('rejects %s before starting a child', async (_label, invalid, message) => { + ['empty', [], /gate graph has no gates/], + ['duplicate ids', [gate('same'), gate('same')], /duplicate gate id "same"/], + ['unknown dependencies', [gate('subject', { needs: ['missing'] })], /depends on unknown gate "missing"/], + ['cycles', [gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })], /dependency cycle: first -> second -> first/], + ] as const)('rejects %s before starting a child', async (_label, invalid, message) => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) - await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message) + + await expect(runGates([...invalid], 1, execute)).rejects.toThrow(message) expect(execute).not.toHaveBeenCalled() }) - it('rejects an invalid plan worker bound', () => { - expect(() => { - validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 }) - }).toThrow( - 'maxWorkers must be a positive integer', - ) - }) - - it('rejects an executor request above the plan worker ceiling before starting a child', async () => { + it('rejects an invalid worker count before starting a child', async () => { const execute = vi.fn(async (subject: Gate) => resultFor(subject)) - await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow( - 'exceeds the check-all plan ceiling 1', - ) + + await expect(runGates([gate('subject')], 0, execute)).rejects.toThrow('max concurrency must be a positive integer') expect(execute).not.toHaveBeenCalled() }) - it('reports a settled failure before an unrelated gate finishes', async () => { - const first = gate('first') - const second = gate('second') - const settle = new Map void>() - const observed: string[] = [] - const execution = executeGatePlan( - plan([first, second]), - 2, - subject => new Promise(resolve => settle.set(subject.id, resolve)), - result => observed.push(`${result.gate.id}:${result.status}`), - ) - - const settleFirst = settle.get(first.id) - const settleSecond = settle.get(second.id) - if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start') - settleFirst(resultFor(first, 'failed')) - await vi.waitFor(() => { - expect(observed).toEqual(['first:failed']) - }) - settleSecond(resultFor(second)) - - await expect(execution).resolves.toHaveLength(2) - expect(observed).toEqual(['first:failed', 'second:passed']) - }) - - it('propagates dependency skips in causal order', async () => { - const leaf = gate('leaf', { needs: ['middle'] }) - const middle = gate('middle', { needs: ['root'] }) - const rootGate = gate('root') + it('skips dependents after their prerequisite fails', async () => { + const dependent = gate('dependent', { needs: ['root'] }) + const root = gate('root') const execute = vi.fn(async (subject: Gate) => resultFor(subject, 'failed')) - const observed: string[] = [] - const results = await executeGatePlan( - plan([leaf, middle, rootGate]), - 1, - execute, - result => observed.push(`${result.gate.id}:${result.status}`), - ) + const results = await runGates([dependent, root], 1, execute) expect(execute).toHaveBeenCalledOnce() - expect(execute).toHaveBeenCalledWith(rootGate) - expect(observed).toEqual(['root:failed', 'middle:skipped', 'leaf:skipped']) - expect(results.find(result => result.gate === middle)?.error).toBe('dependency failed or skipped: root') - expect(results.find(result => result.gate === leaf)?.error).toBe('dependency failed or skipped: middle') - }) - - it('selects a target with its transitive dependencies in canonical plan order', () => { - const subject = plan([ - gate('prepare'), - gate('build', { needs: ['prepare'] }), - gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }), - gate('unrelated'), - ]) - expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot']) - expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({ - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - }) + expect(execute).toHaveBeenCalledWith(root) + expect(results[0]).toMatchObject({ gate: dependent, status: 'skipped', error: 'dependency failed or skipped: root' }) }) }) -describe('gate plan inspection and replay', () => { - it('parses package-script separators, list JSON, and focused runs', () => { - expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({ - mode: 'check-all', list: true, json: true, - }) - expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({ - 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') - }) +describe('Node 24 consumer graph', () => { + it('owns the seven-command pool and orders restored-artifact consumers', () => { + const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) - it('renders deterministic human and stable JSON fields without inherited environment values', () => { - vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret') - const subject = plan([ - gate('prepare'), - gate('subject', { - needs: ['prepare'], - allowFailure: true, - env: { - Z_MODE: { operation: 'set', value: 'lib' }, - ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' }, - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - }, - }), - ]) - - const json = formatGatePlanJson(subject) - expect(formatGatePlanJson(subject)).toBe(json) - expect(json).not.toContain('ambient-secret') - expect(json).not.toContain('scheduler-secret') - expect(JSON.parse(json)).toEqual({ - version: 1, - mode: 'check-all', - script: 'check:all', - scope: 'complete', - maxWorkers: null, - gates: [ - { id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true }, - { - id: 'subject', - label: 'subject', - command: 'run subject', - needs: ['prepare'], - env: { - ACCESS_TOKEN: { operation: 'set', value: '' }, - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - Z_MODE: { operation: 'set', value: 'lib' }, - }, - blocking: false, - }, - ], - }) - expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject') - expect(formatGatePlanList(subject)).toContain('needs: prepare') - expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)') - }) - - it('emits one clean JSON object through the documented silent package-script entry', () => { - const result = spawnSync('pnpm', [ - '--silent', - 'run', - 'check:ci:consumers', - '--', - '--list', - '--json', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - shell: process.platform === 'win32', - timeout: 10_000, - }) - if (result.error !== undefined) throw result.error - expect(result.status, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ - version: 1, - mode: 'ci-consumers', - script: 'check:ci:consumers', - scope: 'complete', - maxWorkers: 7, - }) - }) - - it.skipIf(process.platform === 'win32')('executes when the script entry path is a symlink', () => { - const temporary = mkdtempSync(join(tmpdir(), 'dsh-run-gates-entry-')) - const entry = join(temporary, 'run-gates.ts') - try { - symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) - const result = spawnSync(process.execPath, [ - '--import', - 'tsx', - entry, - 'ci-consumers', - '--list', - '--json', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - env: { ...process.env, npm_execpath: process.env.npm_execpath ?? '/private/pnpm.cjs' }, - timeout: 10_000, - }) - - expect(result.status, result.stderr).toBe(0) - expect(JSON.parse(result.stdout)).toMatchObject({ mode: 'ci-consumers', maxWorkers: 7 }) - } finally { - rmSync(temporary, { recursive: true, force: true }) - } - }) - - it('prints focused-run context and replay through a real failure block', () => { - const result = spawnSync(process.execPath, [ - '--import', - 'tsx', - join(repositoryRoot, 'scripts/run-gates.ts'), - 'ci-lint', - '--only', - 'duplication', - ], { - cwd: repositoryRoot, - encoding: 'utf8', - env: { ...process.env, npm_execpath: join(repositoryRoot, 'scripts/missing-pnpm-entrypoint.cjs') }, - timeout: 10_000, - }) - - expect(result.status).toBe(1) - expect(result.stdout).toContain('partial diagnostic evidence; the complete owning mode is pnpm run check:ci:lint') - expect(result.stderr).toContain('outcome: exit 1') - expect(result.stderr).toContain('replay: pnpm run check:ci:lint -- --only duplication') - }) - - it('applies append and set operations through the child spawn environment', async () => { - vi.stubEnv('NODE_OPTIONS', '--trace-warnings') - vi.stubEnv('INHERITED', 'kept') - const result = await runGate(gate('subject', { - args: ['-e', 'process.stdout.write(JSON.stringify({ nodeOptions: process.env.NODE_OPTIONS, mode: process.env.MODE, inherited: process.env.INHERITED }))'], - env: { - NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' }, - MODE: { operation: 'set', value: 'lib' }, - }, - })) - - expect(result.status).toBe('passed') - expect(JSON.parse(result.stdout)).toEqual({ - nodeOptions: '--trace-warnings --max-old-space-size=8192', - mode: 'lib', - inherited: 'kept', - }) - }) - - it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => { - const subjectGate = gate('terminated', { - args: ['-e', "process.kill(process.pid, 'SIGTERM')"], - }) - const result = await runGate(subjectGate) - - expect(result.status).toBe('failed') - expect(result.exitCode).toBeNull() - expect(result.signalCode).toBe('SIGTERM') - expect(formatGateResultReason(result)).toBe('signal SIGTERM') - }) -}) - -describe('Node 24 consumer plan', () => { - it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => { - const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers')) - validateGatePlan(subject) - expect(subject.maxWorkers).toBe(7) - expect(listedGatePlan(subject).maxWorkers).toBe(7) - expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({ + expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ workers: 7, - source: 'ci-consumers plan default 7', + source: 'ci-consumers gate count', }) - expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({ - workers: 4, - source: '$DSH_GATE_CONCURRENCY', - }) - expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({ - workers: 7, - source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7', - }) - expect(subject.gates.map(item => item.id)).toEqual([ + expect(subject.map(item => item.id)).toEqual([ 'lint-and-duplication', 'node-compat', 'snapshot', @@ -362,19 +113,25 @@ describe('Node 24 consumer plan', () => { 'built-package-invariants', 'built-bin-smoke', ]) - expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined() - expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) - expect(subject.gates.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) + expect(subject.find(item => item.id === 'publint')?.needs).toBeUndefined() + expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) + expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants']) for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) { - expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) + expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } - expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([ - 'snapshot', - 'publint', - 'built-package-invariants', - ]) - expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({ - DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' }, - }) + expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' }) + }) +}) + +describe('gate process outcomes', () => { + it.skipIf(process.platform === 'win32')('reports signal termination independently from exit status', async () => { + const result = await runGate(gate('terminated', { + args: ['-e', "process.kill(process.pid, 'SIGTERM')"], + })) + + expect(result.status).toBe('failed') + expect(result.exitCode).toBeNull() + expect(result.signalCode).toBe('SIGTERM') + expect(formatGateResultReason(result)).toBe('signal SIGTERM') }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a517a19127..aa2378b24f 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -1,46 +1,35 @@ /** - * Construct, inspect, and run local and CI quality-gate plans with bounded scheduling. + * 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 replay diagnostics. - * @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md + * 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 { parseArgs } from 'node:util' - -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 = keyof typeof MODE_SCRIPTS - -const MODES = Object.keys(MODE_SCRIPTS) as Mode[] +export type Mode = + | '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' type GateResultStatus = 'passed' | 'failed' | 'skipped' type GateState = 'pending' | 'running' | GateResultStatus -/** One scheduler-owned environment operation, resolved against inherited values only at spawn time. */ -export type GateEnvironmentOverride = - | { operation: 'set'; value: string } - | { operation: 'append'; value: string } - -/** A command and its dependency metadata inside one gate plan. */ +/** A command and its dependency metadata inside one aggregate. */ export interface Gate { id: string label: string @@ -48,27 +37,15 @@ export interface Gate { command: string args: string[] needs?: string[] - env?: Record - input?: string - verify?: (result: GateResult) => Promise + env?: Record allowFailure?: boolean } -/** A complete executable aggregate and the package script that owns its diagnostics. */ -export interface GatePlan { - mode: Mode - script: string - gates: Gate[] - maxWorkers?: number -} - /** The observed outcome of one gate process. */ export interface GateResult { gate: Gate status: GateResultStatus durationMs: number - stdout: string - stderr: string output: GateOutputChunk[] exitCode: number | null signalCode: NodeJS.Signals | null @@ -85,37 +62,11 @@ interface RunningGate { promise: Promise } -/** The effective worker count and the facts that selected it. */ -export interface ResolvedConcurrency { +interface ConcurrencyDefault { workers: number source: string } -interface RunRequest { - mode: Mode - list: boolean - json: boolean - only?: string -} - -interface ListedGate { - id: string - label: string - command: string - needs: string[] - env: Record - blocking: boolean -} - -interface ListedPlan { - version: 1 - mode: Mode - script: string - scope: 'complete' - maxWorkers: number | null - gates: ListedGate[] -} - type GateExecutor = (gate: Gate) => Promise type ResultObserver = (result: GateResult) => void @@ -125,83 +76,74 @@ if (import.meta.main) { } async function main(args: string[]): Promise { - const request = parseCliRequest(args) - const completePlan = gatePlanForMode(request.mode) - validateGatePlan(completePlan) - if (request.list) { - console.log(request.json ? formatGatePlanJson(completePlan) : formatGatePlanList(completePlan)) - return 0 - } - - const plan = request.only === undefined - ? completePlan - : { ...completePlan, gates: gateDependencyClosure(completePlan, request.only) } - validateGatePlan(plan) - if (request.only !== undefined) console.log(formatOnlyNotice(completePlan, request.only)) - - const concurrency = resolvePlanConcurrency(plan, process.env.DSH_GATE_CONCURRENCY) - const maxConcurrency = concurrency.workers - const concurrencySource = concurrency.source + 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: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) + console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`) - const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => { - printResult(completePlan, result) - }) - printSummary(completePlan, results, performance.now() - startedAt) + 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 } -/** - * Parse one runner invocation without constructing or starting its plan. - * @param args - command-line arguments after the script entrypoint. - * @returns the validated run request. - */ -export function parseCliRequest(args: readonly string[]): RunRequest { - const mode = parseMode(args[0]) - const optionArgs = args[1] === '--' ? args.slice(2) : args.slice(1) - const { values: { list, json, only } } = parseArgs({ - args: optionArgs, - options: { - list: { type: 'boolean', default: false }, - json: { type: 'boolean', default: false }, - only: { type: 'string' }, - }, - strict: true, - allowPositionals: false, - }) - 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 { mode, list, json, ...only === undefined ? {} : { only } } -} - function parseMode(raw: string | undefined): Mode { - if (MODES.includes(raw as Mode)) return raw as Mode - throw new Error(`run-gates: expected mode ${MODES.join(' | ')}, got ${JSON.stringify(raw)}.`) + switch (raw) { + case 'ci-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-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)}.`, + ) + } } -function defaultConcurrency(plan: GatePlan, available: number): ResolvedConcurrency { - if (plan.maxWorkers !== undefined) { - return { - workers: Math.min(plan.gates.length, plan.maxWorkers), - source: `${plan.mode} plan default ${plan.maxWorkers}`, - } - } +/** + * 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 = plan.mode === 'check-all' || plan.mode === 'doc-sync' + const localCap = selectedMode === 'check-all' || selectedMode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { - workers: Math.min(plan.gates.length, modeLimit), + workers: Math.min(total, modeLimit), source: localCap - ? `${available} available CPU(s), ${plan.mode} cap 4` + ? `${available} available CPU(s), ${selectedMode} cap 4` : `${available} available CPU(s)`, } } -function concurrencyFromValue(name: string, raw: string | undefined, fallback: number): number { +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) { @@ -210,33 +152,6 @@ function concurrencyFromValue(name: string, raw: string | undefined, fallback: n return parsed } -/** - * Resolve a plan's default, optional environment request, and hard worker ceiling. - * @param plan - validated complete or diagnostic plan. - * @param override - optional `DSH_GATE_CONCURRENCY` value. - * @param available - host CPU availability for modes without a plan-owned default. - * @returns the effective worker count and its inspectable source. - */ -export function resolvePlanConcurrency( - plan: GatePlan, - override: string | undefined, - available = availableParallelism(), -): ResolvedConcurrency { - validateGatePlan(plan) - const defaultValue = defaultConcurrency(plan, available) - const requested = concurrencyFromValue('DSH_GATE_CONCURRENCY', override, defaultValue.workers) - const workers = Math.min(requested, plan.maxWorkers ?? requested) - const requestedSource = override === undefined || override === '' - ? defaultValue.source - : '$DSH_GATE_CONCURRENCY' - return { - workers, - source: workers === requested - ? requestedSource - : `${requestedSource}, ${plan.mode} plan cap ${String(plan.maxWorkers)}`, - } -} - function pnpmScript(id: string, script: string, options: Partial = {}): Gate { return { id, @@ -266,21 +181,16 @@ function pnpmInvocation(args: string[]): Pick { return { command: process.execPath, args: [entrypoint, ...args] } } -/** - * Construct the complete plan for a named aggregate without executing it. - * @param selected - aggregate mode to construct. - * @returns the aggregate's package-script identity and gate graph. - */ -export function gatePlanForMode(selected: Mode): GatePlan { - return { - mode: selected, - script: MODE_SCRIPTS[selected], - gates: gatesForMode(selected), - ...selected === 'ci-consumers' ? { maxWorkers: 7 } : {}, - } +function nodeOptions(...options: string[]): string { + return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ') } -function gatesForMode(selected: Mode): Gate[] { +/** + * 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() @@ -320,7 +230,7 @@ function gatesForMode(selected: Mode): Gate[] { ...hygieneLeafGates({ artifactNeeds: ['build'] }), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), ] @@ -389,7 +299,7 @@ function ciStaticGates(): Gate[] { pnpmScript('build', 'build'), ...docSyncLeafGates({ docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: { operation: 'set', value: '1' } }, + docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, docsBuildScript: 'docs:build:mpa', }), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), @@ -479,17 +389,17 @@ function lintGate(eslintTargets: readonly string[] = ['.']): Gate { 'content', ], { label: 'lint', - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } if (concurrencyArgs.length > 0) { return pnpmExec('lint', ['eslint', ...eslintTargets, ...concurrencyArgs], { label: 'lint', - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } return pnpmScript('lint', 'lint', { - env: { NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' } }, + env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') }, }) } @@ -520,7 +430,7 @@ function coverageGate(): Gate { // Build-owning modes wait on `build`; a restored-artifact mode passes its validation dependency. function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { - env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + env: { DSH_EXAMPLE_MODE: 'lib' }, needs, }) } @@ -566,7 +476,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(options: { docTypecheckNeeds?: string[] - docTypecheckEnv?: Record + docTypecheckEnv?: Record docsBuildScript?: 'docs:build' | 'docs:build:mpa' } = {}): Gate[] { const docTypecheckOptions: Partial = {} @@ -623,46 +533,32 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { ], { label: 'built-bin smoke', needs, - env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } }, + env: { DSH_EXAMPLE_MODE: 'lib' }, }) } /** - * Reject a plan whose graph cannot be executed unambiguously. - * @param plan - complete or diagnostic plan to validate. + * Reject a gate list whose graph cannot be executed unambiguously. + * @param gates - complete aggregate to validate. */ -export function validateGatePlan(plan: GatePlan): void { - const errors: string[] = [] - if (plan.gates.length === 0) errors.push('plan has no gates') - if (plan.maxWorkers !== undefined && (!Number.isSafeInteger(plan.maxWorkers) || plan.maxWorkers < 1)) { - errors.push(`maxWorkers must be a positive integer, got ${JSON.stringify(plan.maxWorkers)}`) - } +function validateGateGraph(gates: readonly Gate[]): void { + if (gates.length === 0) throw new Error('run-gates: gate graph has no gates.') - const counts = new Map() - for (const gate of plan.gates) { - counts.set(gate.id, (counts.get(gate.id) ?? 0) + 1) - if (!/^[a-z0-9][a-z0-9:-]*$/.test(gate.id)) { - errors.push(`gate id ${JSON.stringify(gate.id)} must contain only lowercase letters, digits, colons, and hyphens`) - } + const ids = new Set() + 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 [id, count] of counts) { - if (count > 1) errors.push(`duplicate gate id ${JSON.stringify(id)}`) - } - - const ids = new Set(counts.keys()) - for (const gate of plan.gates) { + for (const gate of gates) { for (const dependency of gate.needs ?? []) { if (!ids.has(dependency)) { - errors.push(`gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}`) + throw new Error(`run-gates: gate ${JSON.stringify(gate.id)} depends on unknown gate ${JSON.stringify(dependency)}.`) } } } - const cycle = findDependencyCycle(plan.gates) - if (cycle !== undefined) errors.push(`dependency cycle: ${cycle.join(' -> ')}`) - if (errors.length > 0) { - throw new Error(`run-gates: invalid ${plan.mode} plan:\n${errors.map(error => ` - ${error}`).join('\n')}`) - } + const cycle = findDependencyCycle(gates) + if (cycle !== undefined) throw new Error(`run-gates: dependency cycle: ${cycle.join(' -> ')}.`) } function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { @@ -698,194 +594,31 @@ function findDependencyCycle(gates: readonly Gate[]): string[] | undefined { } /** - * Return one target and all of its transitive dependencies in canonical plan order. - * @param plan - validated complete owning plan. - * @param targetId - gate selected for diagnostic execution. - * @returns the target's dependency closure in owning-plan order. - */ -export function gateDependencyClosure(plan: GatePlan, targetId: string): Gate[] { - validateGatePlan(plan) - const byId = new Map(plan.gates.map(gate => [gate.id, gate])) - if (!byId.has(targetId)) { - throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(targetId)}.`) - } - - const selected = new Set() - const include = (id: string): void => { - if (selected.has(id)) return - const gate = byId.get(id) - if (gate === undefined) throw new Error(`run-gates: missing validated dependency ${JSON.stringify(id)}.`) - for (const dependency of gate.needs ?? []) include(dependency) - selected.add(id) - } - include(targetId) - return plan.gates.filter(gate => selected.has(gate.id)) -} - -/** - * Produce the stable machine-readable view used by `--list --json`. - * @param plan - complete plan to inspect. - * @returns the versioned environment-redacted plan view. - */ -export function listedGatePlan(plan: GatePlan): ListedPlan { - validateGatePlan(plan) - return { - version: 1, - mode: plan.mode, - script: plan.script, - scope: 'complete', - maxWorkers: plan.maxWorkers ?? null, - gates: plan.gates.map(listedGate), - } -} - -function listedGate(gate: Gate): ListedGate { - return { - id: gate.id, - label: gate.label, - command: gate.displayCommand, - needs: [...gate.needs ?? []], - env: listedEnvironment(gate.env), - blocking: gate.allowFailure !== true, - } -} - -function listedEnvironment( - environment: Readonly> | undefined, -): Record { - if (environment === undefined) return {} - return Object.fromEntries(Object.entries(environment).sort(([left], [right]) => left.localeCompare(right)).map(([name, override]) => { - const value = sensitiveEnvironmentName(name) ? '' : override.value - return [name, { operation: override.operation, value }] - })) -} - -function sensitiveEnvironmentName(name: string): boolean { - return /(key|secret|token|password|credential)/i.test(name) -} - -/** - * Render the deterministic human-readable view used by `--list`. - * @param plan - complete plan to inspect. - * @returns the formatted plan. - */ -export function formatGatePlanList(plan: GatePlan): string { - const listed = listedGatePlan(plan) - const lines = [ - `run-gates: complete ${listed.mode} plan (pnpm run ${listed.script})`, - `max workers: ${listed.maxWorkers === null ? '(host and gate count)' : listed.maxWorkers}`, - ] - for (const gate of listed.gates) { - lines.push(`- ${gate.id} [${gate.blocking ? 'blocking' : 'non-blocking'}] ${gate.label}`) - lines.push(` command: ${gate.command}`) - lines.push(` needs: ${gate.needs.length === 0 ? '(none)' : gate.needs.join(', ')}`) - lines.push(` env: ${Object.keys(gate.env).length === 0 ? '(none)' : JSON.stringify(gate.env)}`) - } - return lines.join('\n') -} - -/** - * Render the stable JSON view used by `--list --json`. - * @param plan - complete plan to inspect. - * @returns the formatted JSON object. - */ -export function formatGatePlanJson(plan: GatePlan): string { - return JSON.stringify(listedGatePlan(plan), null, 2) -} - -/** - * Render the package-script command that restores a gate's scheduler context. - * @param plan - complete owning plan. - * @param gateId - gate to replay with its dependencies. - * @returns a shell-independent pnpm command. - */ -function replayCommand(plan: GatePlan, gateId: string): string { - validateGatePlan(plan) - if (!plan.gates.some(gate => gate.id === gateId)) { - throw new Error(`run-gates: ${plan.mode} has no gate ${JSON.stringify(gateId)}.`) - } - return `pnpm run ${plan.script} -- --only ${gateId}` -} - -/** - * Explain that a focused run is diagnostic rather than the complete aggregate. - * @param plan - complete owning plan. - * @param gateId - selected diagnostic gate. - * @returns the partial-evidence notice. - */ -function formatOnlyNotice(plan: GatePlan, gateId: string): string { - return `run-gates: --only ${gateId} is partial diagnostic evidence; the complete owning mode is pnpm run ${plan.script}.` -} - -/** - * Resolve only scheduler-declared environment operations against the spawn environment. - * @param gate - gate whose operations to apply. - * @param inherited - environment inherited by the runner. - * @returns the child environment without mutating the inherited object. - */ -function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const resolved = { ...inherited } - for (const [name, override] of Object.entries(gate.env ?? {})) { - switch (override.operation) { - case 'set': - resolved[name] = override.value - break - case 'append': { - const current = resolved[name] - resolved[name] = current === undefined || current === '' - ? override.value - : `${current} ${override.value}` - break - } - default: - assertNever(override) - } - } - return resolved -} - -function assertNever(value: never): never { - throw new Error(`run-gates: unreachable value ${JSON.stringify(value)}.`) -} - -/** - * Run a validated plan; invalid input rejects before the injected executor can start a child. - * @param plan - complete or diagnostic plan to execute. + * 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 canonical plan order. + * @returns results in aggregate order. */ -export async function executeGatePlan( - plan: GatePlan, +export async function runGates( + gates: Gate[], maxActive: number, execute: GateExecutor, observe: ResultObserver = () => {}, ): Promise { - validateGatePlan(plan) + validateGateGraph(gates) if (!Number.isSafeInteger(maxActive) || maxActive < 1) { throw new Error(`run-gates: max concurrency must be a positive integer, got ${JSON.stringify(maxActive)}.`) } - if (plan.maxWorkers !== undefined && maxActive > plan.maxWorkers) { - throw new Error(`run-gates: max concurrency ${maxActive} exceeds the ${plan.mode} plan ceiling ${plan.maxWorkers}.`) - } - return runGates(plan.gates, maxActive, execute, observe) -} - -async function runGates( - allGates: Gate[], - maxActive: number, - execute: GateExecutor, - observe: ResultObserver, -): Promise { - const states = new Map(allGates.map(gate => [gate.id, 'pending'])) + const states = new Map(gates.map(gate => [gate.id, 'pending'])) const results = new Map() const running: RunningGate[] = [] for (;;) { let madeProgress = false while (running.length < maxActive) { - const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states)) + 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) }) @@ -894,13 +627,13 @@ async function runGates( } if (running.length === 0) { - let pending = allGates.filter(gate => states.get(gate.id) === 'pending') + 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 plan stalled without a failed dependency.') + 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' @@ -909,8 +642,6 @@ async function runGates( gate, status: 'skipped', durationMs: 0, - stdout: '', - stderr: '', output: [], exitCode: null, signalCode: null, @@ -933,7 +664,7 @@ async function runGates( } } - return allGates.map((gate) => { + 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 @@ -947,12 +678,10 @@ function dependenciesPassed(gate: Gate, states: Map): boolean /** * Execute one gate through the real shell-free child-process boundary. * @param gate - command and scheduler environment to execute. - * @returns the complete process and verification outcome. + * @returns the complete process outcome. */ export async function runGate(gate: Gate): Promise { const started = performance.now() - let stdout = '' - let stderr = '' const output: GateOutputChunk[] = [] let spawnError: string | undefined @@ -962,17 +691,15 @@ export async function runGate(gate: Gate): Promise { }>((resolveExit) => { const child = spawn(gate.command, gate.args, { cwd: root, - env: resolveGateEnvironment(gate, process.env), + env: { ...process.env, ...gate.env }, stdio: ['pipe', 'pipe', 'pipe'], }) child.stdout.setEncoding('utf8') child.stderr.setEncoding('utf8') child.stdout.on('data', (chunk: string) => { - stdout += chunk output.push({ stream: 'stdout', text: chunk }) }) child.stderr.on('data', (chunk: string) => { - stderr += chunk output.push({ stream: 'stderr', text: chunk }) }) child.on('error', (error) => { @@ -982,33 +709,20 @@ export async function runGate(gate: Gate): Promise { child.on('close', (exitCode, signalCode) => { resolveExit({ exitCode, signalCode }) }) - if (gate.input !== undefined) child.stdin.end(gate.input) - else child.stdin.end() + child.stdin.end() }) const { exitCode, signalCode } = outcome - let status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' - let error = spawnError - if (status === 'passed' && gate.verify !== undefined) { - try { - await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, output, exitCode, signalCode }) - } catch (verifyError: unknown) { - status = 'failed' - error = verifyError instanceof Error ? verifyError.message : String(verifyError) - } - } - + const status: GateResultStatus = exitCode === 0 && signalCode === null && spawnError === undefined ? 'passed' : 'failed' const result: GateResult = { gate, status, durationMs: performance.now() - started, - stdout, - stderr, output, exitCode, signalCode, } - if (error !== undefined) result.error = error + if (spawnError !== undefined) result.error = spawnError return result } @@ -1025,7 +739,7 @@ export function formatGateResultReason(result: GateResult): string { return facts.length === 0 ? 'no exit code or signal' : facts.join(', ') } -function printResult(plan: GatePlan, result: GateResult): void { +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) { @@ -1037,16 +751,13 @@ function printResult(plan: GatePlan, result: GateResult): void { const writeHeading = result.status === 'passed' ? console.log : console.error writeHeading(`\n== ${heading} ==`) if (result.status !== 'passed') { - const environment = listedGate(result.gate).env console.error(`command: ${result.gate.displayCommand}`) - if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`) console.error(`outcome: ${formatGateResultReason(result)}`) - console.error(`replay: ${replayCommand(plan, result.gate.id)}`) } printOutput(result.output) } -function printSummary(plan: GatePlan, results: GateResult[], durationMs: number): void { +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 @@ -1062,7 +773,7 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number) 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(` replay: ${replayCommand(plan, result.gate.id)}`) + console.error(` ${result.gate.displayCommand}`) } }