From 5da343126ccb71228492cf535a1700f28fac0ee0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:23:44 +0800 Subject: [PATCH 1/7] fix(windows): restore LSP and TUI coverage --- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +- ...2026-07-22-cross-platform-test-fixtures.md | 12 +- ...6-07-22-cross-platform-test-fixtures.zh.md | 12 +- packages/lsp/lsp-local/README.md | 3 +- packages/lsp/lsp-local/src/connection.ts | 157 ++++++++++++++---- packages/lsp/lsp-local/src/index.ts | 14 +- packages/lsp/lsp-local/src/instance.ts | 21 +-- .../lsp/lsp-local/tests/connection.spec.ts | 80 +++++++-- .../lsp/lsp-local/tests/fixture-server.ts | 17 +- packages/lsp/lsp-local/tests/instance.spec.ts | 47 ++++-- .../lsp/lsp-local/tests/lifecycle.spec.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 7 +- vitest.config.ts | 12 -- 13 files changed, 281 insertions(+), 107 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index d1b133cb72..511b66e345 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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 -2026-07-22-cross-platform-test-fixtures.md: 83af904db5d004366021d4ba6bead656ff813dae -2026-07-22-cross-platform-test-fixtures.zh.md: 3570c393f8d2fc3344aa43ff0eb8291500d07e1c +2026-07-22-cross-platform-test-fixtures.md: 56deaf6306e15c6cf17e83fcbaf36137e5c543f4 +2026-07-22-cross-platform-test-fixtures.zh.md: f61441e2dbe86fa5a580e666fcd08d23b0fadf0b diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 83af904db5..56deaf6306 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md) ## Problem -The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and numeric file descriptor `0` is not the sole owner of Node's pipe-backed child stdin. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture. +The unit and coverage suites run on Windows, macOS, and Linux, but a platform-neutral behavior can be hidden behind a platform-specific fixture. Literal POSIX paths become drive-relative paths on Windows, a hosted `file:` URI can be a valid UNC path there, and child-pipe closure or event-loop scheduling does not settle at the same point on every host. POSIX-only filesystem states such as FIFOs, executable mode bits, and directory search bits have no direct Windows fixture. Treating fixture syntax as product behavior either reports false regressions or encourages production normalization that erases native path semantics. @@ -14,18 +14,20 @@ Treating fixture syntax as product behavior either reports false regressions or Tests of platform-neutral behavior construct absolute paths and `file:` URIs with the host's `node:path` and `node:url` APIs, then assert native absolute output or stable workspace-relative output as the contract requires. Invalid-URI fixtures use encodings rejected by `fileURLToPath()` on every supported platform. -Subprocess fixtures that require the parent write side to fail close both the CRT descriptor and the libuv handle owning child stdin. This pins the connection failure contract across POSIX descriptor-backed and Windows pipe-backed processes while keeping the child alive long enough to distinguish pipe failure from process exit. +Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. +Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows, with a direct-child fallback when the tree is already gone. A read-only provider query retries once only when its pooled transport becomes dead after the liveness check; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. + +Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. ## Alternatives considered **Normalize all paths and URIs to POSIX strings.** This would make assertions uniform but would change correct Windows behavior: external paths are native absolute paths, UNC file URIs are valid, and configured homes resolve through the host path rules. -**Run POSIX fixtures through a compatibility shell on Windows.** A compatibility environment would test different filesystem and process semantics from the native Node runtime exercised by the product. +**Manipulate child-pipe internals until a write fails.** CRT descriptors and libuv handles have different ownership across hosts and Node versions, so this would test undocumented fixture machinery instead of the connection's write-failure contract. **Skip whole files or packages on Windows.** Broad exclusions would hide supported behavior. Only the individual fixture whose state cannot exist on Windows is excluded; the surrounding contract remains covered. ## Consequences -Portable fixtures are slightly more verbose because expected paths derive from shared native constants. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Pipe-failure fixtures depend on Node's test-runtime handle shape, but that dependency stays inside the scripted child and proves the real parent-side stream behavior rather than mocking it. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index 3570c393f8..f61441e2db 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;在 Node 中,编号为 `0` 的文件描述符也不是子进程管道型 stdin 的唯一持有者。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。 +单元测试与覆盖率测试套件会在 Windows、macOS 和 Linux 上运行,但平台无关行为可能被平台特有的 fixture(测试前置数据)掩盖。字面 POSIX 路径在 Windows 上会变成相对于驱动器的路径;带主机名的 `file:` URI 在 Windows 上可能是有效的 UNC 路径;子进程管道关闭或事件循环调度在不同宿主上的稳定时点也不一致。FIFO、可执行模式位和目录搜索权限位等仅存在于 POSIX 的文件系统状态,在 Windows 上没有可直接构造的 fixture。 把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。 @@ -14,18 +14,20 @@ Status: implemented 测试平台无关行为时,使用宿主的 `node:path` 和 `node:url` API 构造绝对路径与 `file:` URI,再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。 -需要使父进程写端失败的子进程 fixture 会同时关闭 CRT 文件描述符和持有子进程 stdin 的 libuv 句柄。这种方式在以 POSIX 文件描述符为后端的进程和以 Windows 管道为后端的进程上固定了连接失败契约,同时让子进程存活足够长的时间,以区分管道故障与进程退出。 +传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`;若进程树已经不存在,则回退到直接终止子进程。只读的提供方查询仅在池化传输于存活检查后失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 + +对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 ## 曾考虑的替代方案 **将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为:外部路径是原生绝对路径,UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。 -**在 Windows 上通过兼容性 shell 运行 POSIX fixture。**这种兼容环境测试的文件系统与进程语义不同于产品实际使用的原生 Node 运行时。 +**操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约。 **在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture;相关契约仍保持覆盖。 ## 后果 -可移植 fixture 略显冗长,因为预期路径需要从共享的原生常量派生。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。管道故障 fixture 依赖 Node 测试运行时的句柄形态,但这种依赖仅存在于脚本化的子进程内;因此,这类 fixture 验证的是真实的父进程侧流行为,而不是对它进行 mock。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;同步取得命令结果让 dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 85cc7945dd..269fe6b666 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. -- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the transport becomes dead between the pool's liveness check and a read-only query, the provider evicts it and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. +- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`, with a direct-child fallback for teardown races. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index ae725b56f7..201cd87bdd 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -8,7 +8,7 @@ */ import type { ChildProcessByStdio } from 'node:child_process' -import { spawn } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { encodeMessage, MessageDecoder } from './framing.ts' @@ -36,6 +36,105 @@ interface Pending { reject: (error: Error) => void } +/** + * Write one JSON-RPC message to the child stdin. + * @param stdin - the spawned server stdin. + * @param message - the unencoded JSON-RPC message. + * @param done - callback that reports asynchronous stream settlement. + */ +export type ConnectionWriter = ( + stdin: Writable, + message: unknown, + done: (error?: Error | null) => void, +) => void + +/** Host operations used to signal a detached process tree. */ +export interface ProcessTreeOperations { + /** Signal a POSIX process group. */ + readonly signal: (target: number, signal: NodeJS.Signals) => void + /** Signal the direct child when group/tree signalling is unavailable. */ + readonly killChild: (signal: NodeJS.Signals) => void + /** Terminate a Windows process tree by root pid. */ + readonly taskkill: (pid: number) => void +} + +/** Narrow taskkill runner result used by the Windows process-tree adapter. */ +export interface TaskkillResult { + /** Process exit status, or null when spawning failed. */ + readonly status: number | null + /** Spawn failure, when the executable could not run. */ + readonly error?: Error +} + +/** Invoke a command synchronously for the Windows taskkill adapter. */ +export type TaskkillRunner = ( + command: string, + args: string[], + options: { stdio: 'ignore' }, +) => TaskkillResult + +/** Invoke the host process-signal primitive for a POSIX process group. */ +export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => boolean + +const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) + +const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { + stdin.write(encodeMessage(message), done) +} + +/** + * Terminate one Windows process tree and wait for taskkill to finish. + * @param pid - root process id. + * @param run - command runner; tests inject results without requiring Windows. + */ +export function taskkillProcessTree( + pid: number, + run: TaskkillRunner = spawnSync, +): void { + const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) +} + +/** + * Signal one POSIX process group through an injectable host primitive. + * @param target - negative process-group id. + * @param signal - requested signal. + * @param run - host signal runner; tests inject it without touching real processes. + */ +export function signalProcessGroup( + target: number, + signal: NodeJS.Signals, + run: ProcessSignalRunner = processSignalRunner, +): void { + run(target, signal) +} + +/** + * Signal a detached process tree with platform-correct semantics and a direct-child fallback. + * @param platform - host platform. + * @param pid - detached root process id. + * @param signal - requested termination signal. + * @param operations - host operations. + */ +export function signalProcessTree( + platform: NodeJS.Platform, + pid: number, + signal: NodeJS.Signals, + operations: ProcessTreeOperations, +): void { + try { + if (platform === 'win32') operations.taskkill(pid) + else operations.signal(-pid, signal) + } catch { + try { + operations.killChild(signal) + } catch { + // The direct child already exited; teardown remains idempotent. + } + } +} + /** A live JSON-RPC endpoint bound to one child process. */ export class LspConnection { private readonly child: ChildProcessByStdio @@ -50,14 +149,16 @@ export class LspConnection { /** * @param spec - how to launch the server and answer its config requests. * @param onServerRequest - answers a server→client request; rejects to send an error response. + * @param writer - message writer; tests inject callback failures without relying on OS pipe races. */ constructor( private readonly spec: ConnectionSpec, private readonly onServerRequest: (method: string, params: unknown) => Promise, + private readonly writer: ConnectionWriter = writeConnectionMessage, ) { this.decoder = new MessageDecoder(spec.maxMessageBytes) - // `detached` puts the server in its own process group so teardown can signal the WHOLE group - // (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). + // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id, + // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it. this.child = spawn(spec.command, [...spec.args], { cwd: spec.cwd, env: spec.env, @@ -94,6 +195,11 @@ export class LspConnection { return this.stderr.toString('utf8') } + /** Whether the transport has failed even if the child close event has not arrived yet. */ + get failed(): boolean { + return this.closeReason !== undefined + } + /** * Send a request and await its result. * @param method - the JSON-RPC method. @@ -147,23 +253,23 @@ export class LspConnection { return this.nextId } - /** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */ + /** Request termination of the server's process tree. */ terminate(): void { - this.signalGroup('SIGTERM') + this.signalTree('SIGTERM') } - /** Send SIGKILL to the server's process group. */ + /** Force termination of the server's process tree. */ kill(): void { - this.signalGroup('SIGKILL') + this.signalTree('SIGKILL') } /** - * Wait until the owned process group has no members. + * Wait until the owned process tree has exited. * @param signal - optional bound for the wait. - * @returns `true` when the group exited, or `false` when the signal aborted first. + * @returns `true` when the tree exited, or `false` when the signal aborted first. */ - async waitForProcessGroupExit(signal?: AbortSignal): Promise { - while (this.processGroupAlive()) { + async waitForProcessTreeExit(signal?: AbortSignal): Promise { + while (this.processTreeAlive()) { if (signal?.aborted) return false await yieldToEventLoop() } @@ -171,26 +277,21 @@ export class LspConnection { } /** - * Signal the whole process group (negative pid) so helper processes are reached; fall back to the - * direct child if the group send fails. Never throws — teardown races process exit. + * Signal the whole process tree so helper processes are reached; fall back to the direct child if + * tree signaling fails. Never throws because teardown races process exit. */ - private signalGroup(sig: NodeJS.Signals): void { + private signalTree(sig: NodeJS.Signals): void { const pid = this.child.pid if (pid === undefined) return - try { - process.kill(-pid, sig) - } catch { - // The group is gone (already exited) or could not be signalled; try the direct child. - try { - this.child.kill(sig) - } catch { - // Already dead; nothing to signal. - } - } + signalProcessTree(process.platform, pid, sig, { + signal: signalProcessGroup, + killChild: this.child.kill.bind(this.child), + taskkill: taskkillProcessTree, + }) } - /** Whether the detached process group still has at least one member. */ - private processGroupAlive(): boolean { + /** Whether the detached tree's root or POSIX process group is still alive. */ + private processTreeAlive(): boolean { const pid = this.child.pid /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ if (pid === undefined) return false @@ -218,7 +319,7 @@ export class LspConnection { // A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and // SIGKILL the whole group so helper processes don't outlive the leader. this.fail(asError(error)) - this.signalGroup('SIGKILL') + this.signalTree('SIGKILL') return } for (const message of messages) this.dispatch(message) @@ -293,7 +394,7 @@ export class LspConnection { reject(error) } try { - this.child.stdin.write(encodeMessage(message), done) + this.writer(this.child.stdin, message, done) /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a nonconforming Writable implementation throwing synchronously. */ } catch (error) { diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 095d761624..340d5a4df0 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -2,9 +2,9 @@ * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table * of server commands and registers one isolated provider for each entry. Every provider lazily * single-flights one server process per canonical workspace realpath, serves transient-open queries - * through it, and evicts a crashed process so a later query can replace it. Providers read sources - * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no - * sandbox confinement. + * through it, and replaces a transport that dies between a pool liveness check and the next + * read-only query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) + * and trust their configured servers — no sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * unregisters from `ctx.lsp` and tears down every live server. @@ -227,6 +227,14 @@ class LocalLspProvider implements LspProvider { } try { return await instance.query(request, source, signal) + } catch (error) { + // A child can die after the pre-query liveness check but before or during the next write. + // Queries are read-only, so replace a newly failed transport once and retry transparently. + if (!instance.dead) throw error + this.evictIfCurrent(workspace, instance) + this.assertActive(signal) + instance = this.instanceFor(workspace) + return await instance.query(request, source, signal) } finally { // Drop a crashed slot only when it still owns this instance; a replacement must survive. if (instance.dead) this.evictIfCurrent(workspace, instance) diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 74381c1483..02bdd160d4 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -17,7 +17,7 @@ import type { import { deadline } from '@deepseek-ai/dsh-timeout' import { abortable, abortError } from './abort.ts' import { LspConnection } from './connection.ts' -import type { ConnectionSpec } from './connection.ts' +import type { ConnectionSpec, ConnectionWriter } from './connection.ts' import type { HostSource } from './host.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import { @@ -58,9 +58,10 @@ export class LspInstance { /** * @param spec - the launch, initialize, and teardown parameters. + * @param writer - optional connection writer used by transport conformance tests. */ - constructor(private readonly spec: InstanceSpec) { - this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) + constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) { + this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer) this.ready = this.initialize() // A handshake rejection must not surface as an unhandled rejection before the first query awaits // it; queries attach the real handler. @@ -70,7 +71,7 @@ export class LspInstance { /** Synchronous liveness check: true once the process has closed or the instance was disposed. */ get dead(): boolean { - return this.processClosed || this.disposed + return this.processClosed || this.disposed || this.connection.failed } /** @@ -272,7 +273,7 @@ export class LspInstance { try { await this.gracefulShutdown(shutdownDeadline.signal) } catch { - // Graceful shutdown failed or timed out; process-group cleanup below remains authoritative. + // Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative. } finally { shutdownDeadline[Symbol.dispose]() } @@ -286,20 +287,20 @@ export class LspInstance { await abortable(this.connection.closed, signal) } - /** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */ + /** Terminate the tree, escalate after `killGraceMs`, then await leader and helper exit. */ private async forceTerminate(): Promise { this.connection.terminate() const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') - let groupExited: boolean + let treeExited: boolean try { - groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) + treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal) } finally { graceDeadline[Symbol.dispose]() } - if (!groupExited) this.connection.kill() + if (!treeExited) this.connection.kill() await Promise.all([ this.connection.closed, - this.connection.waitForProcessGroupExit(), + this.connection.waitForProcessTreeExit(), ]) } } diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 464848bbf5..18cb7a1bce 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -1,6 +1,17 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { fileURLToPath } from 'node:url' import { LspConnection } from '@deepseek-ai/dsh-lsp-local' +import { + signalProcessGroup, + signalProcessTree, + taskkillProcessTree, +} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import type { + ConnectionWriter, + ProcessSignalRunner, + ProcessTreeOperations, + TaskkillRunner, +} from '@deepseek-ai/dsh-lsp-local/src/connection.ts' const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -125,7 +136,7 @@ describe('LspConnection', () => { }) /** Spawn a raw connection running an inline node script as the "server". */ -function connectScript(script: string, maxStderrBytes = 100_000): LspConnection { +function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection { const conn = new LspConnection({ command: process.execPath, args: ['-e', script], @@ -134,7 +145,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection maxMessageBytes: 16_000_000, maxStderrBytes, configuration: null, - }, () => Promise.resolve(null)) + }, () => Promise.resolve(null), writer) open.push(conn) return conn } @@ -209,13 +220,13 @@ describe('LspConnection edge behavior', () => { await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) }) - it.skipIf(process.platform === 'win32')('rejects a pending request when child stdin closes but the process stays alive', async () => { - const conn = connectScript('const stdin=process.stdin; require("node:fs").closeSync(0); stdin._handle?.close(); setInterval(()=>{}, 1000)') - await new Promise(resolve => setTimeout(resolve, 100)) - const timeout = new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('request timed out')) }, 1000) - }) - await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) + it('rejects a pending request when child stdin fails but the process stays alive', async () => { + const failure = new Error('fixture stdin failure') + const writer: ConnectionWriter = (_stdin, _message, done) => { + queueMicrotask(() => { done(failure) }) + } + const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer) + await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/) }) it('ignores a frame that is neither a valid request nor a numeric-id response', async () => { @@ -230,6 +241,55 @@ describe('LspConnection edge behavior', () => { }) }) +describe('process-tree signaling', () => { + it('forwards POSIX process-group signals through the host runner', () => { + const run: ProcessSignalRunner = vi.fn(() => true) + signalProcessGroup(-42, 'SIGKILL', run) + expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') + }) + + it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { + const operations = fakeProcessTreeOperations() + signalProcessTree('win32', 42, 'SIGTERM', operations) + expect(operations.taskkill).toHaveBeenCalledWith(42) + expect(operations.signal).not.toHaveBeenCalled() + + signalProcessTree('linux', 42, 'SIGKILL', operations) + expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') + }) + + it('falls back to the direct child and tolerates an already-dead child', () => { + const fallback = fakeProcessTreeOperations() + vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) + signalProcessTree('win32', 42, 'SIGTERM', fallback) + expect(fallback.killChild).toHaveBeenCalledWith('SIGTERM') + + const gone = fakeProcessTreeOperations() + vi.mocked(gone.signal).mockImplementation(() => { throw new Error('group gone') }) + vi.mocked(gone.killChild).mockImplementation(() => { throw new Error('child gone') }) + expect(() => { signalProcessTree('linux', 42, 'SIGKILL', gone) }).not.toThrow() + }) + + it('runs taskkill for the full tree and rejects command failures', () => { + const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) + taskkillProcessTree(42, success) + expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) + + const spawnFailure = new Error('cannot spawn taskkill') + expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) + expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) + }) +}) + +/** Create observable process-tree operations without touching host processes. */ +function fakeProcessTreeOperations(): ProcessTreeOperations { + return { + signal: vi.fn(), + killChild: vi.fn(), + taskkill: vi.fn(), + } +} + /** Poll a predicate until it holds or a deadline elapses. */ async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { const start = Date.now() diff --git a/packages/lsp/lsp-local/tests/fixture-server.ts b/packages/lsp/lsp-local/tests/fixture-server.ts index 2b7fb76b2f..1a30ed5628 100644 --- a/packages/lsp/lsp-local/tests/fixture-server.ts +++ b/packages/lsp/lsp-local/tests/fixture-server.ts @@ -16,8 +16,6 @@ * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received. * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. - * - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes the stdin pipe after initialization. - * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes the stdin pipe before the first query response. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of @@ -28,7 +26,7 @@ * Run: node fixture-server.ts (Node's erasable TypeScript syntax support). */ -import { appendFileSync, closeSync } from 'node:fs' +import { appendFileSync } from 'node:fs' const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 @@ -40,8 +38,6 @@ const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) const openMarker = process.env.LSP_FAKE_OPEN_MARKER const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' -const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1' -const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' @@ -146,14 +142,12 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (method === 'initialized') { if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') if (pauseStdinAfterInitialized) process.stdin.pause() - if (closeStdinAfterInitialized) closeStdinPipe() return } if (method === 'textDocument/didClose') return if (method?.startsWith('textDocument/')) { if (hang) return const reply = (): void => { - if (closeStdinAfterReply) closeStdinPipe() if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }) } else { @@ -171,13 +165,6 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul if (id !== undefined) send({ id, result: null }) } -/** Close both the CRT descriptor and libuv handle that can own a platform's child-stdin pipe. */ -function closeStdinPipe(): void { - const stdin = process.stdin as NodeJS.ReadStream & { _handle?: { close(): void } } - closeSync(0) - stdin._handle?.close() -} - /** Append one teardown event when the fixture is configured to expose process ordering. */ function markExit(event: string): void { if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) @@ -209,6 +196,6 @@ function send(message: Record): void { // Keep the event loop alive. process.stdin.resume() -if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { +if (pauseStdinAfterInitialized) { setInterval(() => {}, 1000) } diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 343233c4f5..cfa8120dbc 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -4,6 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' +import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' +import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' @@ -26,7 +28,11 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function makeInstance(env: Record = {}, overrides: Partial = {}): LspInstance { +function makeInstance( + env: Record = {}, + overrides: Partial = {}, + writer?: ConnectionWriter, +): LspInstance { const instance = new LspInstance({ command: process.execPath, args: [fixtureServer], @@ -39,7 +45,7 @@ function makeInstance(env: Record = {}, overrides: Partial { expect(instance.dead).toBe(true) }) - it.skipIf(process.platform === 'win32')('terminates when stdin fails during the didOpen write', async () => { - // Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; - // the instance must still become dead so its provider can replace it. - await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000)) - const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, { + it('terminates when stdin fails during the didOpen write', async () => { + const instance = makeInstance({}, { shutdownTimeoutMs: 100, killGraceMs: 100, - }) + }, failingWriter('textDocument/didOpen')) await expect(run(instance, 'goToDefinition')).rejects.toThrow() expect(instance.dead).toBe(true) }) @@ -225,11 +228,10 @@ describe('LspInstance query and abort', () => { await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) }) - it.skipIf(process.platform === 'win32')('keeps a settled result but awaits teardown when didClose cannot be written', async () => { + it('keeps a settled result but awaits teardown when didClose cannot be written', async () => { const instance = makeInstance({ LSP_FAKE_DEF: 'null', - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', - }, { shutdownTimeoutMs: 100, killGraceMs: 100 }) + }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose')) await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], @@ -281,7 +283,7 @@ describe('LspInstance disposal', () => { await expect(instance.dispose()).resolves.toBeUndefined() }) - it.skipIf(process.platform === 'win32')('awaits a surviving process-group helper on every concurrent dispose', async () => { + it('awaits a surviving process-tree helper on every concurrent dispose', async () => { const marker = join(root, 'helper.pid') const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' @@ -298,6 +300,7 @@ describe('LspInstance disposal', () => { await first } finally { if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') + await waitForProcessExit(helperPid) } }) @@ -322,6 +325,26 @@ function processAlive(pid: number): boolean { } } +/** Wait until a process id disappears so temporary-workspace cleanup cannot race handle release. */ +async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise { + const started = Date.now() + while (processAlive(pid)) { + if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** Write normally except for one method whose callback receives a deterministic transport error. */ +function failingWriter(method: string): ConnectionWriter { + return (stdin, message, done) => { + if ((message as { method?: unknown }).method === method) { + queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) }) + return + } + stdin.write(encodeMessage(message), done) + } +} + /** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */ async function waitForFile(path: string, timeoutMs = 3000): Promise { const started = Date.now() diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 7ba76d03de..826905ed26 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -237,7 +237,7 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) - it.skipIf(process.platform === 'win32')('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { + it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => { // The first query succeeds, then the server exits before the second arrives, leaving a dead // instance in the pool. The next query must evict-and-replace it and still succeed, rather than // failing once on the closed connection first. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fc4f92dfad..2b48e9de1f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -198,7 +198,7 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(result) }) - it.skipIf(process.platform === 'win32')('renders its header, footer, replay, streaming answer, todos, and status', async () => { + it('renders its header, footer, replay, streaming answer, todos, and status', async () => { let now = 0 const result = await setup({ contextWindow: 100, @@ -320,7 +320,9 @@ describe('pi-tui chat lifecycle and transcript', () => { { inputTokens: 500, outputTokens: 8 }, { turn: 3, step: 1 }, ) - await tick() + await vi.waitFor(() => { + expect(result.terminal.output).toContain('final live answer') + }) expect(result.terminal.output).toContain('◒ Working · 8s') expect(result.terminal.output).toContain('esc interrupt') @@ -328,7 +330,6 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Turn cancelled') - expect(result.terminal.output).toContain('final live answer') expect(result.terminal.progress).toContain(true) result.session.append('assistant/chunk', { diff --git a/vitest.config.ts b/vitest.config.ts index 8baa7c7b32..c5b5c06d11 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,17 +11,6 @@ const windowsUnsupportedPackages = process.platform === 'win32' ] : [] -// These files retain 100% per-file coverage on POSIX, where their process-pipe and terminal timing -// tests are deterministic; Windows skips those cases and must not fail solely on their uncovered paths. -const windowsCoverageExclusions = process.platform === 'win32' - ? [ - 'packages/lsp/lsp-local/src/connection.ts', - 'packages/lsp/lsp-local/src/index.ts', - 'packages/lsp/lsp-local/src/instance.ts', - 'packages/ui/tui/src/index.ts', - ] - : [] - export default defineConfig({ // Native path resolution reads each package's nearest tsconfig, but only the root defines // workspace paths. Keep this plugin pinned to the root map so unbuilt bare package imports resolve @@ -44,7 +33,6 @@ export default defineConfig({ 'packages/*/*/src/bin.ts', 'packages/*/*/src/worker.ts', ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), - ...windowsCoverageExclusions, ], // 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // Per-file so a well-covered big file can't subsidize a bare one. From 7b6b544243b1109d23a21e7e10d9db1cd62d2389 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:34:26 +0800 Subject: [PATCH 2/7] test(windows): cover teardown decisions deterministically --- packages/lsp/lsp-local/src/connection.ts | 25 +++++++++++++++---- packages/lsp/lsp-local/src/index.ts | 4 --- packages/lsp/lsp-local/src/instance.ts | 11 +++++++- .../lsp/lsp-local/tests/connection.spec.ts | 14 +++++++++++ packages/lsp/lsp-local/tests/instance.spec.ts | 11 +++++++- 5 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 201cd87bdd..7ed3e09054 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -110,6 +110,25 @@ export function signalProcessGroup( run(target, signal) } +/** + * Wait until a process-tree liveness probe reports exit. + * @param isAlive - process-tree liveness probe. + * @param signal - optional bound for the wait. + * @param yieldNow - event-loop yield primitive. + * @returns `true` when the tree exited, or `false` when the signal aborted first. + */ +export async function waitForTreeExit( + isAlive: () => boolean, + signal?: AbortSignal, + yieldNow: () => Promise = yieldToEventLoop, +): Promise { + while (isAlive()) { + if (signal?.aborted) return false + await yieldNow() + } + return true +} + /** * Signal a detached process tree with platform-correct semantics and a direct-child fallback. * @param platform - host platform. @@ -269,11 +288,7 @@ export class LspConnection { * @returns `true` when the tree exited, or `false` when the signal aborted first. */ async waitForProcessTreeExit(signal?: AbortSignal): Promise { - while (this.processTreeAlive()) { - if (signal?.aborted) return false - await yieldToEventLoop() - } - return true + return await waitForTreeExit(this.processTreeAlive.bind(this), signal) } /** diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 340d5a4df0..7a4b1b6b6d 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -221,10 +221,6 @@ class LocalLspProvider implements LspProvider { // synchronous get-or-create so every spawned process remains owned by teardown. this.assertActive(signal) let instance = this.instanceFor(workspace) - if (instance.dead) { - this.evictIfCurrent(workspace, instance) - instance = this.instanceFor(workspace) - } try { return await instance.query(request, source, signal) } catch (error) { diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index 02bdd160d4..eecc5d4e5e 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec { readonly killGraceMs: number } +/** + * Force-kill a process tree only when graceful termination did not make it exit. + * @param treeExited - whether the tree exited within its grace period. + * @param forceKill - forceful process-tree termination primitive. + */ +export function escalateProcessTree(treeExited: boolean, forceKill: () => void): void { + if (!treeExited) forceKill() +} + /** * A single initialized server process. Not exported as a provider — the provider single-flights and * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down. @@ -297,7 +306,7 @@ export class LspInstance { } finally { graceDeadline[Symbol.dispose]() } - if (!treeExited) this.connection.kill() + escalateProcessTree(treeExited, this.connection.kill.bind(this.connection)) await Promise.all([ this.connection.closed, this.connection.waitForProcessTreeExit(), diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 18cb7a1bce..9fea82b43f 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -5,6 +5,7 @@ import { signalProcessGroup, signalProcessTree, taskkillProcessTree, + waitForTreeExit, } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' import type { ConnectionWriter, @@ -248,6 +249,19 @@ describe('process-tree signaling', () => { expect(run).toHaveBeenCalledWith(-42, 'SIGKILL') }) + it('waits for tree exit and stops when its bound aborts', async () => { + const isAlive = vi.fn() + .mockReturnValueOnce(true) + .mockReturnValue(false) + const yieldNow = vi.fn(() => Promise.resolve()) + await expect(waitForTreeExit(isAlive, undefined, yieldNow)).resolves.toBe(true) + expect(yieldNow).toHaveBeenCalledOnce() + + const controller = new AbortController() + controller.abort() + await expect(waitForTreeExit(() => true, controller.signal, yieldNow)).resolves.toBe(false) + }) + it('uses taskkill for a Windows tree and a negative pid for a POSIX group', () => { const operations = fakeProcessTreeOperations() signalProcessTree('win32', 42, 'SIGTERM', operations) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index cfa8120dbc..d08a431192 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -6,6 +6,7 @@ import { pathToFileURL, fileURLToPath } from 'node:url' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-local/src/connection.ts' +import { escalateProcessTree } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' @@ -242,6 +243,14 @@ describe('LspInstance query and abort', () => { }) describe('LspInstance disposal', () => { + it('escalates only when the process tree survives its grace period', () => { + const forceKill = vi.fn() + escalateProcessTree(false, forceKill) + expect(forceKill).toHaveBeenCalledOnce() + escalateProcessTree(true, forceKill) + expect(forceKill).toHaveBeenCalledOnce() + }) + it('lets a server finish protocol exit before signal escalation', async () => { const marker = join(root, 'graceful-exit.log') const instance = makeInstance({ From 2a8e7c661d2a9937993e1a8d67fa47818d8e1990 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:42:11 +0800 Subject: [PATCH 3/7] test(windows): use native PATH delimiter --- packages/lsp/lsp-local/tests/provider.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 7a969781f3..829a84264b 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { delimiter, join } from 'node:path' import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' @@ -57,7 +57,7 @@ describe('lsp-local provider resolution', () => { await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], - env: { PATH: `::${join(root, 'empty')}` }, + env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` }, extensionToLanguage: { '.ts': 'typescript' }, }))).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() From 769710cfb990fa93376cfaa3e9042be38f028f9f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:56:29 +0800 Subject: [PATCH 4/7] fix(lsp): make transport recovery ownership-safe --- ...-22-cross-platform-test-fixtures.i18n.yaml | 4 +-- ...2026-07-22-cross-platform-test-fixtures.md | 4 +-- ...6-07-22-cross-platform-test-fixtures.zh.md | 4 +-- packages/lsp/lsp-local/README.md | 4 +-- packages/lsp/lsp-local/src/connection.ts | 30 ++++++++++++++----- packages/lsp/lsp-local/src/index.ts | 18 ++++++----- packages/lsp/lsp-local/src/instance.ts | 16 +++++++++- .../lsp/lsp-local/tests/connection.spec.ts | 28 +++++++++++------ packages/lsp/lsp-local/tests/instance.spec.ts | 11 +++++++ .../lsp/lsp-local/tests/lifecycle.spec.ts | 10 +++++-- 10 files changed, 94 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml index 511b66e345..f5fc9ecef6 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.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 -2026-07-22-cross-platform-test-fixtures.md: 56deaf6306e15c6cf17e83fcbaf36137e5c543f4 -2026-07-22-cross-platform-test-fixtures.zh.md: f61441e2dbe86fa5a580e666fcd08d23b0fadf0b +2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a +2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md index 56deaf6306..6217aabfdb 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.md @@ -16,7 +16,7 @@ Tests of platform-neutral behavior construct absolute paths and `file:` URIs wit Transport-failure tests inject the connection's message writer and deliver the same asynchronous write callback error that a real Node stream would report. The production writer still writes framed messages to child stdin. This keeps a real child alive while the test deterministically distinguishes transport failure from process exit without reaching into platform-specific pipe handles. -Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows, with a direct-child fallback when the tree is already gone. A read-only provider query retries once only when its pooled transport becomes dead after the liveness check; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. +Language-server teardown targets the whole descendant tree through a negative process-group id on POSIX and synchronous `taskkill /T /F` on Windows. Windows suppresses only taskkill's already-absent-tree status; command, permission, and other tree-kill failures remain teardown failures. A read-only provider query retries once only when its selected pooled transport fails before or during that query; errors from a still-live server are not replayed. Terminal tests wait for their observable rendered output instead of assuming one event-loop turn is sufficient. Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on that case. Adjacent cross-platform cases continue to pin non-regular file rejection, unavailable command rejection, and inaccessible working-directory rejection. Supported Windows paths remain inside the per-file coverage gate rather than being excluded with their test files. @@ -30,4 +30,4 @@ Tests for a genuinely POSIX-only primitive use a narrow Windows exclusion on tha ## Consequences -Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns. +Portable fixtures are slightly more explicit because expected paths derive from shared native constants and transport failures enter through a narrow writer seam. Platform-only exclusions require a neighboring cross-platform assertion for the product behavior they support. Windows teardown depends on the host `taskkill` command after graceful protocol shutdown has failed; a successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer. diff --git a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md index f61441e2db..43942ec046 100644 --- a/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md +++ b/.agents/notes/implemented/testing/2026-07-22-cross-platform-test-fixtures.zh.md @@ -16,7 +16,7 @@ Status: implemented 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活,使测试无需触及平台特有的管道句柄,也能确定性地区分传输故障与进程退出。 -语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`;若进程树已经不存在,则回退到直接终止子进程。只读的提供方查询仅在池化传输于存活检查后失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 +语言服务器的资源清理会终止整棵后代进程树:POSIX 使用负数进程组 ID,Windows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够。 对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;同步取得命令结果让 dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出。 +可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后,Windows 上的资源清理依赖宿主的 `taskkill` 命令;命令同步执行成功时,dispose 的完成边界明确,并确保清理返回前即可观察到后代进程退出;若进程树终止失败,dispose 的调用方仍能观察到该失败。 diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 269fe6b666..7c6c05b7df 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -7,10 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. -- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the transport becomes dead between the pool's liveness check and a read-only query, the provider evicts it and retries that query once on a fresh process. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. -- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`, with a direct-child fallback for teardown races. +- After protocol shutdown fails, terminates the server's descendant tree through POSIX process-group signaling or synchronous Windows `taskkill /T /F`. Windows suppresses only taskkill's already-absent-tree result; command, permission, and other tree-kill failures remain visible. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration diff --git a/packages/lsp/lsp-local/src/connection.ts b/packages/lsp/lsp-local/src/connection.ts index 7ed3e09054..1103c4dbd2 100644 --- a/packages/lsp/lsp-local/src/connection.ts +++ b/packages/lsp/lsp-local/src/connection.ts @@ -52,7 +52,7 @@ export type ConnectionWriter = ( export interface ProcessTreeOperations { /** Signal a POSIX process group. */ readonly signal: (target: number, signal: NodeJS.Signals) => void - /** Signal the direct child when group/tree signalling is unavailable. */ + /** Signal the direct child when POSIX group signaling is unavailable. */ readonly killChild: (signal: NodeJS.Signals) => void /** Terminate a Windows process tree by root pid. */ readonly taskkill: (pid: number) => void @@ -78,6 +78,9 @@ export type ProcessSignalRunner = (target: number, signal: NodeJS.Signals) => bo const processSignalRunner: ProcessSignalRunner = process.kill.bind(process) +/** taskkill status for "process not found": the requested process tree is already absent. */ +const TASKKILL_TREE_NOT_FOUND_STATUS = 128 + const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => { stdin.write(encodeMessage(message), done) } @@ -93,6 +96,7 @@ export function taskkillProcessTree( ): void { const result = run('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' }) if (result.error !== undefined) throw result.error + if (result.status === TASKKILL_TREE_NOT_FOUND_STATUS) return if (result.status !== 0) throw new Error(`taskkill exited with status ${String(result.status)}`) } @@ -130,7 +134,8 @@ export async function waitForTreeExit( } /** - * Signal a detached process tree with platform-correct semantics and a direct-child fallback. + * Signal a detached process tree with platform-correct semantics. POSIX falls back to the direct + * child; Windows requires taskkill to reach the full tree. * @param platform - host platform. * @param pid - detached root process id. * @param signal - requested termination signal. @@ -142,9 +147,12 @@ export function signalProcessTree( signal: NodeJS.Signals, operations: ProcessTreeOperations, ): void { + if (platform === 'win32') { + operations.taskkill(pid) + return + } try { - if (platform === 'win32') operations.taskkill(pid) - else operations.signal(-pid, signal) + operations.signal(-pid, signal) } catch { try { operations.killChild(signal) @@ -219,6 +227,15 @@ export class LspConnection { return this.closeReason !== undefined } + /** + * Test whether a caught error is this connection's retained fatal transport cause. + * @param error - error caught by the instance or provider. + * @returns `true` only when this connection produced that exact failure. + */ + failedWith(error: unknown): boolean { + return this.closeReason === error + } + /** * Send a request and await its result. * @param method - the JSON-RPC method. @@ -291,10 +308,7 @@ export class LspConnection { return await waitForTreeExit(this.processTreeAlive.bind(this), signal) } - /** - * Signal the whole process tree so helper processes are reached; fall back to the direct child if - * tree signaling fails. Never throws because teardown races process exit. - */ + /** Signal the whole process tree. */ private signalTree(sig: NodeJS.Signals): void { const pid = this.child.pid if (pid === undefined) return diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index 7a4b1b6b6d..dda3558130 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -2,8 +2,8 @@ * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table * of server commands and registers one isolated provider for each entry. Every provider lazily * single-flights one server process per canonical workspace realpath, serves transient-open queries - * through it, and replaces a transport that dies between a pool liveness check and the next - * read-only query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) + * through it, and replaces a selected transport that fails before or during the next read-only + * query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`) * and trust their configured servers — no sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal @@ -224,16 +224,20 @@ class LocalLspProvider implements LspProvider { try { return await instance.query(request, source, signal) } catch (error) { - // A child can die after the pre-query liveness check but before or during the next write. - // Queries are read-only, so replace a newly failed transport once and retry transparently. - if (!instance.dead) throw error + // A selected child can have died while idle or fail during the next write. Queries are + // read-only, so replace that transport once and retry transparently. + if (!instance.isTransportFailure(error)) throw error + await instance.dispose() this.evictIfCurrent(workspace, instance) this.assertActive(signal) instance = this.instanceFor(workspace) return await instance.query(request, source, signal) } finally { - // Drop a crashed slot only when it still owns this instance; a replacement must survive. - if (instance.dead) this.evictIfCurrent(workspace, instance) + // Reach quiescence before dropping a dead slot; a replacement must survive this ownership check. + if (instance.dead) { + await instance.dispose() + this.evictIfCurrent(workspace, instance) + } } }) } diff --git a/packages/lsp/lsp-local/src/instance.ts b/packages/lsp/lsp-local/src/instance.ts index eecc5d4e5e..266dd3c59f 100644 --- a/packages/lsp/lsp-local/src/instance.ts +++ b/packages/lsp/lsp-local/src/instance.ts @@ -83,6 +83,15 @@ export class LspInstance { return this.processClosed || this.disposed || this.connection.failed } + /** + * Test whether a caught query error came from this instance's transport. + * @param error - error caught by the provider. + * @returns `true` only for the connection's retained fatal transport cause. + */ + isTransportFailure(error: unknown): boolean { + return this.connection.failedWith(error) + } + /** * Run one query through the serialized queue. * @param request - the resolved provider query. @@ -94,7 +103,12 @@ export class LspInstance { // Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up // rather than block on the shared tail forever. - const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal)) + const run = abortable(this.queue, signal) + .then(() => this.runQuery(request, source, signal)) + .catch(async (error: unknown) => { + if (this.isTransportFailure(error)) await this.startTeardown() + throw error + }) // Keep the tail alive regardless of this query's outcome so the next caller still serializes. The // tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up // on the wait does not deserialize the queue. diff --git a/packages/lsp/lsp-local/tests/connection.spec.ts b/packages/lsp/lsp-local/tests/connection.spec.ts index 9fea82b43f..aa7e819cb6 100644 --- a/packages/lsp/lsp-local/tests/connection.spec.ts +++ b/packages/lsp/lsp-local/tests/connection.spec.ts @@ -65,6 +65,12 @@ describe('LspConnection', () => { await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) }) + it('treats signaling an already-closed child as a teardown race', async () => { + const conn = connectScript('') + await conn.closed + expect(() => { conn.kill() }).not.toThrow() + }) + it('answers a server workspace/configuration request from static config', async () => { const seen: SeenRequest[] = [] const conn = connect( @@ -272,23 +278,27 @@ describe('process-tree signaling', () => { expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL') }) - it('falls back to the direct child and tolerates an already-dead child', () => { + it('surfaces a Windows taskkill failure without downgrading to the direct child', () => { const fallback = fakeProcessTreeOperations() vi.mocked(fallback.taskkill).mockImplementation(() => { throw new Error('taskkill unavailable') }) - signalProcessTree('win32', 42, 'SIGTERM', fallback) - expect(fallback.killChild).toHaveBeenCalledWith('SIGTERM') - - const gone = fakeProcessTreeOperations() - vi.mocked(gone.signal).mockImplementation(() => { throw new Error('group gone') }) - vi.mocked(gone.killChild).mockImplementation(() => { throw new Error('child gone') }) - expect(() => { signalProcessTree('linux', 42, 'SIGKILL', gone) }).not.toThrow() + expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/) + expect(fallback.killChild).not.toHaveBeenCalled() }) - it('runs taskkill for the full tree and rejects command failures', () => { + it('tolerates a POSIX tree-signaling race after the direct child is already gone', () => { + const posixGone = fakeProcessTreeOperations() + vi.mocked(posixGone.signal).mockImplementation(() => { throw new Error('group gone') }) + vi.mocked(posixGone.killChild).mockImplementation(() => { throw new Error('child gone') }) + expect(() => { signalProcessTree('linux', 42, 'SIGKILL', posixGone) }).not.toThrow() + }) + + it('runs taskkill for the full tree, accepts an absent tree, and rejects command failures', () => { const success: TaskkillRunner = vi.fn(() => ({ status: 0 })) taskkillProcessTree(42, success) expect(success).toHaveBeenCalledWith('taskkill', ['/PID', '42', '/T', '/F'], { stdio: 'ignore' }) + expect(() => { taskkillProcessTree(42, () => ({ status: 128 })) }).not.toThrow() + const spawnFailure = new Error('cannot spawn taskkill') expect(() => { taskkillProcessTree(42, () => ({ status: null, error: spawnFailure })) }).toThrow(spawnFailure) expect(() => { taskkillProcessTree(42, () => ({ status: 1 })) }).toThrow(/status 1/) diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index d08a431192..9f246e602a 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -216,6 +216,17 @@ describe('LspInstance query and abort', () => { expect(instance.dead).toBe(true) }) + it('awaits process exit before rejecting a request write failure', async () => { + const instance = makeInstance({}, { + shutdownTimeoutMs: 100, + killGraceMs: 100, + }, failingWriter('textDocument/definition')) + // The pid is observed only to prove the owned subprocess reached quiescence before rejection. + const pid = (instance as unknown as { connection: { pid: number } }).connection.pid + await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/) + expect(processAlive(pid)).toBe(false) + }) + it('rejects when the server lacks the operation capability', async () => { const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 826905ed26..47b8d78bb4 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => { await ctx.fiber.dispose() }) - it('rejects a non-utf-16 position encoding at initialize', async () => { - const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) + it('rejects a non-utf-16 position encoding at initialize without retrying', async () => { + const marker = join(root, 'initialize-rejection-exit.log') + const ctx = await mount({ + LSP_FAKE_ENCODING: 'utf-8', + LSP_FAKE_DEF: 'null', + LSP_FAKE_EXIT_MARKER: marker, + }) await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/) + expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n') await ctx.fiber.dispose() }) From 31c0589439ed9d35edde8320da1bf9a005c41a53 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:37:20 +0800 Subject: [PATCH 5/7] chore: keep local git hooks fast --- ...cutable-sdk-runtime-distribution.i18n.yaml | 4 +- ...ile-executable-sdk-runtime-distribution.md | 2 +- ...-executable-sdk-runtime-distribution.zh.md | 2 +- .../2026-06-11-doc-sync-enforcement.md | 4 +- .../process/2026-06-11-quality-gates.md | 8 +- .../2026-06-18-markdown-cross-link-lint.md | 4 +- .../2026-06-20-agent-note-classification.md | 2 +- ...2026-06-20-core-data-structures-catalog.md | 4 +- .../2026-06-20-generated-cordis-catalog.md | 2 +- .../process/2026-07-02-tool-schema-catalog.md | 4 +- ...26-07-04-cordis-jsdoc-completeness-gate.md | 4 +- .../2026-07-04-persistence-log-catalog.md | 2 +- .../2026-07-06-export-surface-jsdoc-gate.md | 2 +- .../2026-07-06-generated-config-catalog.md | 2 +- .../2026-07-06-parallel-pre-push-gates.md | 35 +++----- ...-doc-sync-through-gate-scheduler.i18n.yaml | 4 +- ...6-07-21-doc-sync-through-gate-scheduler.md | 6 +- ...7-21-doc-sync-through-gate-scheduler.zh.md | 6 +- .../2026-07-22-fast-local-git-hooks.i18n.yaml | 6 ++ .../2026-07-22-fast-local-git-hooks.md | 36 ++++++++ .../2026-07-22-fast-local-git-hooks.zh.md | 36 ++++++++ .agents/skills/dsh-code-review/SKILL.md | 2 +- .../skills/dsh-find-simplifications/SKILL.md | 2 +- .agents/skills/dsh-pre-push-checks/SKILL.md | 85 ++++++------------- .../dsh-pre-push-checks/agents/openai.yaml | 2 +- AGENTS.md | 29 ++----- docs/development.i18n.yaml | 4 +- docs/development.md | 12 +-- docs/development.zh.md | 12 +-- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- lefthook.yml | 16 ++-- package.json | 1 - .../hooks-claude/tests/coverage-cases.ts | 2 +- .../hooks/hooks-codex/tests/coverage-cases.ts | 2 +- packages/support/loader-smoke/src/index.ts | 2 +- scripts/run-gates.ts | 52 ++---------- 38 files changed, 196 insertions(+), 210 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md create mode 100644 .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 6db1ef4ece..b76ed1a9ae 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0d4686a5a233785ca4832ef068a118b484a872fe -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: dcc9213c6b3a088b8b8bce2a442c5232ed5b7d0b +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 43ba5708d1216c37a7ad7e2904df7d2a6baf016d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b33ff870d745584d2988bb6a7eb1a31e56ec3da diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 0d4686a5a2..43ba5708d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,7 +36,7 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; CI static, pre-push, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index dcc9213c6b..3b33ff870d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,7 +36,7 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;CI 静态检查、pre-push 与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 diff --git a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md index 8f32202c8f..67cdfd6771 100644 --- a/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md +++ b/.agents/notes/implemented/process/2026-06-11-doc-sync-enforcement.md @@ -13,7 +13,7 @@ Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): 1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. 2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of the fully-generated `docs/cordis-catalog/events.md` + `docs/cordis-catalog/services.md` and their `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected. -Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references. +Both run via a shared `doc-sync` package.json script that contributors invoke for relevant documentation changes and CI invokes exhaustively. The [fast local Git hooks](2026-07-22-fast-local-git-hooks.md) decision keeps this surface-selected work out of commit and push hooks. **Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the docs/AGENTS.md "one physical line per paragraph" writing rule. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. @@ -24,7 +24,7 @@ Both run via a shared `doc-sync` package.json script that the lefthook pre-push ## Consequences -- Doc drift in the checkable classes now fails the pre-push hook and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle. +- Doc drift in the checkable classes fails `doc-sync` and CI instead of waiting for a reviewer to notice. This is an instance of the "mechanical gates over prose" principle. - Making doc snippets compile costs a few stub imports/`declare`s; the `ignore-check` ratio must stay low or the gate is theater (the ratio guard enforces this). - The taxonomy check is name-only — a wrong Mode or Purpose column still needs human review. - API reports remain available to revisit if the packages are ever published externally. diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 84c3da7b95..5e1db16e52 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -2,24 +2,26 @@ Status: implemented +The hook/CI symmetry in this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md); CI remains the exhaustive enforcement path. + ## Problem This codebase is developed primarily by coding agents. Agents follow enforced gates far more reliably than prose conventions, and "a lot of work" is not a cost argument when agents do the labor. Early evidence: tests that didn't typecheck shipped (vitest doesn't typecheck) and were only caught by a review. ## Decision -Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts: +Every mechanically checkable AGENTS.md promise gets a command that exits non-zero. CI invokes the exhaustive set, while Git hooks reserve their latency budget for cheap local defects: - Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary. - ESLint strict-type-checked + @stylistic (the house style, enforced), including file-local duplicated logic checks; vendored code excluded. - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. +- lefthook pre-commit fixes staged lint, rejects staged whitespace, and checks the vendor manifest; pre-push runs incremental typecheck. CI runs the full matrix on node 22.19/24/26 plus built application smokes for the Headless, TUI, ACP, JSON-RPC, workflow, and code-runtime entry paths. ## Consequences -- Conventions survive agent turnover; violations fail fast and locally. +- Conventions survive agent turnover; cheap commit/push defects fail locally and exhaustive violations fail in CI. - The gates themselves are code to maintain; config changes are reviewed like any change. - 100%-coverage pressure can produce assertion-free tests — mutation testing is the planned counterweight (see [the mutation-testing proposal](../../proposed/testing/2026-06-11-mutation-testing.md)). diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md index 05dfed2453..e57c75575b 100644 --- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md +++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md @@ -16,7 +16,7 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr - Check a target only when it is a **relative path**. Skip scheme-qualified URLs (`https:`, `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path` — no stable base in a checkout), and pure in-page anchors (`#section`). Strip any `#fragment`/`?query`, resolve the path against the linking file's directory, and assert it exists on disk. - Report and never rewrite; exit non-zero on the first broken link found. -Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into the `doc-sync` script that the lefthook pre-push hook and CI both run, so a broken link fails locally before a push — consistent with [mechanical quality gates](2026-06-11-quality-gates.md). +Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under `.agents/skills/` (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): `README.md`, `docs/**/*.md`, `packages/*/README.md`, `AGENTS.md`, `packages/AGENTS.md`, `.agents/skills/**/*.md`, deduped by real path (the `CLAUDE.md` symlinks resolve onto the AGENTS.md files). It is wired into `doc-sync`, so relevant documentation changes and CI exercise the same broken-link check. This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped). @@ -26,6 +26,6 @@ This gate checks *existence*, not anchor validity: a link to a real file with a ## Consequences -- Renames and moves that orphan a cross-link now fail the pre-push hook and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle. +- Renames and moves that orphan a cross-link fail `doc-sync` and CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the same PR that rewrote forty links also added the check that proves none dangle. - One more fast tsx script in the `doc-sync` chain; no new dependency (the mdast/GFM stack is already in devDependencies for `verify-md-wrap`). - The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in [docs/AGENTS.md](../../../../docs/AGENTS.md) so authors know the gate exists and why. diff --git a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md index 42c4523e0c..750a3586e0 100644 --- a/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md +++ b/.agents/notes/implemented/process/2026-06-20-agent-note-classification.md @@ -43,4 +43,4 @@ Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don' - Every Agent Note sits under a class folder. A reader can browse one folder to see all simplifications or all testing decisions within a lifecycle. - Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`). - Adding a class is a deliberate act: amend the `const` in `scripts/agent-note-tree.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. -- Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see. +- Source-comment doc references are gated too — a moved or renamed doc that a `.ts` comment cites fails `verify-doc-refs` in `doc-sync` and CI, closing a drift class `verify-md-links` structurally could not see. diff --git a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md index 21b86b1812..da2b0e8c51 100644 --- a/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -32,7 +32,7 @@ The durability requirement was specific: the doc shows the **literal** current t - Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. - A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. -- Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. +- Wired into `doc-sync`, so relevant documentation changes run it locally and CI runs it with the other documentation checks. ### Maintenance is the author's job, with a gate backstop @@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de ## Consequences -- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here. +- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in `doc-sync` and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here. - The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering. - The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment. - Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist. diff --git a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md index 7de7056b33..4ca9b7e42c 100644 --- a/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/.agents/notes/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -33,7 +33,7 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11 ## Consequences -- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright. +- The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in `doc-sync` and CI. A new event with no `@mode` tag, a tag that contradicts its signature, or an unclassified signature type fails the generator outright. - Event and service-method contracts have a single home — the JSDoc at the declaration. The catalog repeats that original JSDoc inside its generated signature block and uses its description portion as entry prose, so thin source documentation yields a thin catalog entry. - The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator. - `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead. diff --git a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md index 06ab61732c..814331af8b 100644 --- a/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md +++ b/.agents/notes/implemented/process/2026-07-02-tool-schema-catalog.md @@ -8,7 +8,7 @@ The repository had no single reference for the names, descriptions, and JSON Sch ## Decision -Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## ` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so the freshness gate fires in the same lefthook pre-push and CI paths as every other doc gate. +Generate the catalog by **booting each tool plugin and reading its registered schemas**, not by parsing source. `scripts/gen-tool-catalog.ts` mounts each shipped tool package on a fresh cordis `Context` (with `SystemPrompt` + `ToolRegistry` and the injected seams the plugin's `apply` reads), calls `ctx.tools.schemas()` — exactly the `ToolSchema[]` the model is sent — disposes the context, and renders one `## ` section per package with a ` ```json ` `parameters` block per tool. It mirrors the `gen-cordis-catalog` / `gen-module-graph` CLI shape: default `--write` regenerates, `--check` fails if the committed copy is stale, output is deterministic (manifest-ordered, tools sorted by name). `verify-tool-catalog` (the `--check`) runs inside `doc-sync`, so relevant documentation changes and CI exercise the same freshness check. ### Why boot, not parse (the crux) @@ -47,7 +47,7 @@ Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck` ## Consequences -- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in the pre-push hook and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright. +- The catalog cannot drift: a tool schema change the committed file doesn't reflect fails `verify-tool-catalog` in `doc-sync` and CI. A new `tool-*` package not added to the manifest fails the completeness guard outright. - Tool description prose has a single home — the `defineTool` `description` at the source — and the generated entry is only as good as it, the same forcing function the cordis catalog applies to event JSDoc. - The generator imports and executes workspace packages (the first repo script to do so; the others only read text). It runs under `tsx` via the root `tsconfig` `paths` map, the same unbuilt-source path the demos and tests use, so it needs no build step. - A new capability seam behind a future tool means a new manifest recipe entry (which seams to mount). This is the deliberate hand-written cost called out above; it changes only when a tool package is added. diff --git a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 882ab8cf82..3945382217 100644 --- a/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/.agents/notes/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -10,7 +10,7 @@ The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-ch ## Decision -Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth). +Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, so relevant documentation changes and CI exercise the same gate without separate wiring. The contract: @@ -32,7 +32,7 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr ## Consequences -- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. +- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails `doc-sync` and CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. - The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. - The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. - `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. diff --git a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md index bd16baf8f6..bde0f08497 100644 --- a/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md +++ b/.agents/notes/implemented/process/2026-07-04-persistence-log-catalog.md @@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ ## Consequences -- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in the pre-push hook and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. +- The catalog cannot drift: a vocabulary or envelope change the committed file doesn't reflect fails `verify-persistence-catalog` in `doc-sync` and CI, and a new merged event with no JSDoc fails the generator outright — a plugin can no longer add an undocumented on-disk record type. - Event prose has a single home, the JSDoc at the declaration; the catalog preserves that JSDoc and any nested field comments without flattening or paraphrasing them. - The `SurfaceEventType` union is now structurally load-bearing for docs: renaming an event without updating the union (or vice versa) fails the generator, not just the compiler. - The badge derivation assumes the union stays a closed set of string literals with exactly one owner; a refactor away from that shape must update the generator in the same change. diff --git a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md index 452c877182..734fbeab21 100644 --- a/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md +++ b/.agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md @@ -36,7 +36,7 @@ Three exemption families keep the gate from demanding boilerplate, in the spirit ## Consequences -- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync`, which pre-push and CI already run. The 203 gaps found at adoption were filled in the same change, so the gate landed green. +- A new export cannot land undocumented: `verify-export-jsdoc` fails `doc-sync` and CI. The 203 gaps found at adoption were filled in the same change, so the gate landed green. - Exported functions must annotate return types (universal at adoption, now load-bearing) and use identifier parameters where `@param` must name them. - Seam docs are canonical: an implementation inherits its heritage docs, and behavior notes worth keeping on the implementation are additions, not requirements. - The gate builds a `ts.Program` (~6s) — the one doc gate that pays for type resolution; acceptable inside `doc-sync`, which already compiles doc snippets. diff --git a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md index 82355ab511..f876191be4 100644 --- a/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md +++ b/.agents/notes/implemented/process/2026-07-06-generated-config-catalog.md @@ -32,7 +32,7 @@ The package README `## Config` sections stay. The overlap is accepted deliberate ## Consequences -- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in pre-push and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright. +- The catalog cannot drift: a source change the committed file does not reflect fails `verify-config-catalog` in `doc-sync` and CI. An undocumented config field, an unresolvable referenced type name, or a schema key missing from the config type fails the generator outright. - Config prose now has a forcing function at the declaration: writing a new config field means writing its JSDoc, which becomes the catalog entry verbatim. - The generator hard-errors on shapes it cannot walk statically — an aliased package-local config import, a schema built by anything other than `object`/`intersect` composition, an unlisted global type name. Introducing such a shape includes teaching the generator (or the shape stays out of the repo), which is the point: the catalog stays the whole truth. - `gen-cordis-catalog.ts` exports its JSDoc/pointer helpers and `LINK_MAP` for reuse, so the two catalogs cross-link types identically and a link-map addition serves both. 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 710c37cb3a..a46b729a4d 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 @@ -2,39 +2,30 @@ Status: implemented +The local-hook portion of this record is superseded by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md). The bounded gate scheduler and package-level `publint` parallelism remain in force for CI, `doc-sync`, and explicit local commands. + ## Problem -The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent. - -Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift. - -`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state. +Aggregate jobs such as documentation synchronization hide long sequential chains whose members are read-only and independent. Duplicating their leaf inventory in workflow YAML gives future script changes multiple places to drift, while running package publication checks serially makes one gate consume time proportional to the package count. ## Decision -[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses. +[scripts/run-gates.ts](../../../../scripts/run-gates.ts) owns the bounded scheduler used by CI and `doc-sync`. 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. -The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including Agent Note classification and Agent Note format, while the runner schedules independent checks with four active top-level workers by default; `DSH_GATE_CONCURRENCY` overrides that bound. +[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 build gate makes the hook self-contained from a clean worktree. `publint`, `verify-node-next-types`, and the pre-push form of `doc-typecheck` wait for that build output, while source-only gates continue in parallel. - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list 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 the scheduler mirrors, while `doc-sync` has since moved its member list into the scheduler itself ([doc-sync through the gate scheduler](2026-07-21-doc-sync-through-gate-scheduler.md)). +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](2026-07-21-doc-sync-through-gate-scheduler.md)). ## Alternatives considered -- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule. -- **Declare one lefthook job per leaf gate** - exposes parallelism through lefthook's native job model, but it makes the hook file carry a long member list that CI cannot reuse. -- **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check. -- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about. -- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added. -- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs. +- **Keep aggregate jobs serial** — simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup. +- **Declare one CI job per leaf gate** — exposes maximum workflow parallelism but repeats checkout, setup, and install overhead and duplicates the scheduler inventory in YAML. +- **Background subcommands inside shell scripts** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling. +- **Declare one `publint` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change. +- **Run `publint` with unbounded concurrency** — minimizes elapsed time on small repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs. ## Consequences -The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports one `full check` job, and the runner reports per-gate timing inside that job, so a slow local checkpoint still points at the gate that dominates the run. +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. -The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path. - -`publint-all.ts` becomes asynchronous code 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. +`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-21-doc-sync-through-gate-scheduler.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml index 9deebd1da3..8bd4745529 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.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 -2026-07-21-doc-sync-through-gate-scheduler.md: b79df2dd7d3515cb0434ac672f7f87c3271d900b -2026-07-21-doc-sync-through-gate-scheduler.zh.md: 9395244e3c7700166ad87c49219073210c66bc7e +2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2 +2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md index b79df2dd7d..b7e41ba4ae 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md @@ -6,13 +6,13 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md) ## Problem -`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI and pre-push never enforced that catalog's freshness. +`pnpm run doc-sync` was a `&&` chain of 24 `pnpm run` subcommands. Each link paid a full pnpm wrapper start (workspace resolution, script lookup, tsx boot) before its script ran; measured on a development host, the 24 script bodies together finish in about 34 seconds while the chained form takes around 3 minutes, and the wrapper stall reproduces on local disk, so every developer and CI lane pays it, not just network-filesystem checkouts. The chain also ran serially even though the member gates are read-only and independent, and it silently drifted from [scripts/run-gates.ts](../../../../scripts/run-gates.ts): `verify-cordis-api` joined the chain when the runtime API catalog landed but was never added to `docSyncLeafGates`, so CI never enforced that catalog's freshness. ## Decision -`doc-sync` in `package.json` now delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — the same way `check:pre-push` and the `check:ci:*` scripts already do ([parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The new `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set; the chain that could drift from it is gone. Like `pre-push`, the mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. +`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. -The drift this consolidation surfaced is fixed in the same change: `docSyncLeafGates` gains the missing `verify-cordis-api` leaf, so CI and pre-push now gate the generated runtime API catalog alongside the other generated docs. +`docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md index 9395244e3c..56699747b1 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 和 pre-push 从未把关该目录的新鲜度。 +`pnpm run doc-sync` 原本是把 24 个 `pnpm run` 子命令用 `&&` 串起来的链。每一环都要先付一次完整的 pnpm 包装层启动(workspace 解析、脚本查找、tsx 启动)才轮到脚本本体;在开发机上实测,24 个脚本本体合计约 34 秒即可跑完,而链式形态耗时约 3 分钟,且包装层的停顿在本地磁盘上同样复现,因此每位开发者和每条 CI 车道都在付这笔开销,并非只有网络文件系统上的检出受影响。这条链还是串行执行的,尽管各成员门禁只读且相互独立;它也在悄悄偏离 [scripts/run-gates.ts](../../../../scripts/run-gates.ts):运行时 API 目录落地时 `verify-cordis-api` 加入了链,却从未加进 `docSyncLeafGates`,导致 CI 从未把关该目录的新鲜度。 ## 决策 -`package.json` 中的 `doc-sync` 现在委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与 `check:pre-push` 和各 `check:ci:*` 脚本的做法一致([并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。新增的 `doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源;那条可能与之漂移的链不复存在。与 `pre-push` 一样,该模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 +`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 -这次整合暴露出的漂移在同一变更中修复:`docSyncLeafGates` 补上缺失的 `verify-cordis-api` 叶子,CI 和 pre-push 从此与其他生成文档一起把关生成的运行时 API 目录。 +`docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml new file mode 100644 index 0000000000..a21afc8e6a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.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 +2026-07-22-fast-local-git-hooks.md: bab47c6479f1a2c01cbfa7152b1d610917fb6175 +2026-07-22-fast-local-git-hooks.zh.md: 7b279b1a9ad86e09ed5cf7d2470cb61ff17e09b7 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md new file mode 100644 index 0000000000..bab47c6479 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -0,0 +1,36 @@ +# Agent Note: Fast local Git hooks + +Status: implemented + +English | [中文](2026-07-22-fast-local-git-hooks.zh.md) + +## Problem + +An agent already runs the tests and checks that exercise its change, while commit, push, and CI can each repeat increasingly broad subsets of the same work. A full pre-push suite therefore delays every publication, amplifies unrelated local flakes, and gives no new signal when CI immediately runs the exhaustive matrix again. + +Fast hooks still need to reject cheap, high-confidence defects before work leaves the machine. Staged formatting, whitespace errors, missing vendored-source metadata, and repository type errors fit that boundary; unit suites, snapshots, documentation checks, builds, and package hygiene vary with the changed surface and do not. + +## Decision + +[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: ESLint fixes and re-stages changed JavaScript and TypeScript, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push invokes the repository TypeScript binary directly in incremental build mode. + +Neither hook runs tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. The `check:pre-push` package script and `pre-push` scheduler mode do not exist; [scripts/run-gates.ts](../../../../scripts/run-gates.ts) continues to own CI and `doc-sync` scheduling. + +Agents inspect the outgoing diff and run the narrowest tests and checks that cover its behavior once. CI owns exhaustive coverage, built-artifact checks, and the platform matrix. A complete local rehearsal is reserved for an explicit request, CI diagnosis, or a repository-wide change that cannot be validated credibly by narrower evidence. + +## Supersedes + +This decision supersedes the local-hook portion of [Parallel pre-push gates](2026-07-06-parallel-pre-push-gates.md) and the hook/CI symmetry in [Mechanical quality gates over prose guidelines](2026-06-11-quality-gates.md). Their CI scheduler, package-gate, and mechanical-enforcement decisions remain in force. + +## Alternatives considered + +- **Keep the full pre-push suite and optimize its scheduler** — preserves the earliest exhaustive signal but still repeats agent-selected evidence and CI, while unrelated failures continue blocking publication. +- **Remove pre-push entirely** — makes pushes cheapest but loses the fast cross-file guarantee that TypeScript provides after several commits. +- **Keep typecheck in pre-commit** — catches type errors earlier but charges every intermediate commit instead of one push; staged lint already covers the commit-local syntax and style boundary. +- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the existing auto-fix workflow; Lefthook's `stage_fixed` owns re-staging so the command does not duplicate `git add`. + +## Consequences + +Normal commits take the staged-file lint critical path, and warm pushes take the incremental typecheck critical path. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. + +Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md new file mode 100644 index 0000000000..7b279b1a9a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 快速本地 Git 钩子 + +Status: implemented + +[English](2026-07-22-fast-local-git-hooks.md) | 中文 + +## 问题 + +agent(智能体)已经会运行能够覆盖自身改动的测试和检查,而提交、推送与 CI 可能分别重复其中范围越来越广的子集。因此,全量 pre-push 套件会拖慢每次推送,放大与当前改动无关的本地偶发失败,而且 CI 紧接着再次运行完整矩阵时不会提供新信号。 + +快速钩子仍需在工作离开本机之前拦下检查成本低且把握高的缺陷。暂存文件格式问题、空白错误、vendor 源码元数据缺失与仓库类型错误符合这条边界;单元测试套件、快照、文档检查、构建与包(package)的 `hygiene` 检查则随改动范围而异,不符合这条边界。 + +## 决策 + +[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:ESLint 修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 直接调用仓库内的 TypeScript 二进制,并启用增量构建模式。 + +两个钩子都不运行测试、快照、文档检查、构建、`hygiene` 或门禁调度器。`check:pre-push` 包脚本与调度器的 `pre-push` 模式不存在;[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 继续负责 CI 和 `doc-sync` 调度。 + +agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小范围测试和检查。CI 负责全量覆盖率门禁、构建产物检查与平台矩阵。只有在明确要求、诊断 CI,或涉及全仓库的改动无法由范围更窄的证据得到可信验证时,才完整运行一遍本地检查矩阵。 + +## 取代关系 + +本决策取代[并行 pre-push 门禁](2026-07-06-parallel-pre-push-gates.md)中涉及本地钩子的部分,以及[以机械质量门禁代替文字规范](2026-06-11-quality-gates.md)中关于钩子与 CI 对称性的部分。上述记录中关于 CI 调度器、包门禁与机械化强制执行的决策继续有效。 + +## 考虑过的替代方案 + +- **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。 +- **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。 +- **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。 +- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留现有的自动修复工作流;Lefthook 的 `stage_fixed` 负责重新暂存,因此命令无需重复执行 `git add`。 + +## 结果 + +普通提交的关键路径是暂存文件 lint,缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 + +从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 925dc5b6ac..2c9fd86df5 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -24,7 +24,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — 3. **Core type docs match.** Changes to spine or seam vocabulary update the appropriate [core-data-structures](../../../docs/core-data-structures/core.md) page and any `type-equiv` entry. Internal types need no catalog entry. 4. **Registrations clean up.** Verify each new registry contribution satisfies the disposal-test contract in [packages/AGENTS.md](../../../packages/AGENTS.md). 5. **Invariant companions are semantic.** For every touched `./invariant`, require an owner event-stream or mutable-data relationship at its authoritative boundary; service or method presence, plugin metadata or effects, and fixed pure examples belong in type, load, or unit tests. Accept an empty installer when its package-specific reason establishes that no plausible runtime relationship exists; do not demand an invented check merely to eliminate emptiness ([repository rule](../../../AGENTS.md#conventions); [package contract](../../../packages/AGENTS.md)). -6. **Required gates pass.** Trust the [current readiness sequence](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready) and `pnpm run check:pre-push` for their enforced inventory; review the semantic gaps they cannot detect. +6. **Required evidence exists.** Verify the author ran the [relevant local checks](../../../AGENTS.md#run-relevant-checks-locally) for the diff and that CI covers the exhaustive matrix; review the semantic gaps neither can detect. ## Manual checks diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 2ce80f3f74..2e1ccdc3c8 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -102,7 +102,7 @@ Diff the sibling branch against `origin/master`, not against the current PR bran ## Validation And PR Hygiene -For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Before pushing, expect the pre-push hook to run module graph freshness, unit tests, snapshots, doc-sync, and hygiene. +For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`. For code comments or skill changes, also run the relevant validator when one exists. Select any other evidence from the outgoing diff; the pre-push hook contributes typecheck only. When opening or updating a PR, summarize: diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index a138c5b4b4..b2ecc6362d 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -1,13 +1,13 @@ --- name: dsh-pre-push-checks -description: Use before pushing, force-pushing, marking ready for review, claiming checks pass, or bypassing a local hook on a deepseek-harness branch, especially after merges, review fixes, package graph changes, docs/catalog updates, snapshots, e2e behavior, or built artifact changes. +description: Use before pushing, force-pushing, marking ready for review, or claiming checks pass on a deepseek-harness branch to select the smallest tests and checks that cover the outgoing diff without reflexively running the full repository suite. --- # DSH Pre-Push Checks -Use this skill to choose and run the smallest sufficient verification set before a `deepseek-harness` push. Do not treat the local pre-push hook as the full CI contract: CI also runs coverage, build, and built-bin smoke. +Use this skill to run relevant local evidence once before a `deepseek-harness` push. Git hooks are intentionally narrow: pre-commit fixes staged lint, checks staged whitespace, and guards vendored-source metadata; pre-push runs only the incremental repository typecheck. CI owns exhaustive coverage and the platform matrix. -## First Steps +## Inspect the outgoing change 1. Confirm the checkout and branch. @@ -16,88 +16,57 @@ git status --short --branch git rev-parse --show-toplevel ``` -2. Inspect the outgoing diff. +2. Inspect the diff against its actual base. ```sh git diff --stat git diff --name-only origin/$(git branch --show-current)...HEAD ``` -If the branch has no upstream or the command is not meaningful for the stack shape, use `git diff --name-only origin/master...HEAD` or the PR base branch. +If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff can affect and rerun only checks invalidated by the merge. -3. If the branch was just merged with `master`, or the user says master changed, run the gates after resolving the merge and before pushing or marking ready. Do not present a conflict-resolution commit as ready with only typecheck/lint evidence. +## Select relevant evidence -## Required Baseline +There is no universal local baseline beyond the hooks. Every behavior change needs the narrowest available test or purpose-built check that would fail for its regression; add broader checks only for surfaces the diff actually reaches. -Run these before every non-trivial push: +- **Package or script behavior:** run the owning Vitest file or focused test name. Add adjacent package tests when a shared contract changes; leave repository-wide coverage to CI unless the change is genuinely cross-cutting or the user requests it. +- **Documentation, Agent Notes, catalogs, or doc-linked comments:** run `pnpm run doc-sync`; run full lint when the documentation workflow requires it. +- **Model-, editor-, CLI-, or terminal-visible output:** run the focused keyless snapshot or real runnable-example scenario that owns the output. +- **Package manifests, public exports, build configuration, worker/bin entries, or built runtime paths:** run `pnpm run build`, the relevant hygiene checks, and the owning built-artifact smoke. +- **Real provider or agent behavior:** run the relevant `pnpm run test:e2e` target when credentials are available; never print secrets. -```sh -pnpm run typecheck -pnpm run lint -pnpm run test:coverage -``` +Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. -Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI. +## Full local rehearsal -## Add Gates By Touched Surface +Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate. -Run `pnpm run doc-sync` and `pnpm run verify-module-graph` when the diff touches Markdown docs, package manifests, package imports/exports, generated catalogs, Agent Notes, architecture docs, translation pairs, Mermaid diagrams, or comments that cite docs/packages. +## Handle failures -Run `pnpm run build` and `pnpm run hygiene` when the diff touches any package `package.json`, dependency graph, public exports, build config, declaration surface, bundled runtime path, or code that will be consumed from built `lib/`. - -Run snapshot tests when the diff changes ACP/editor-facing transcript behavior: ACP bridge updates, agent-loop observable output, tool call/result presentation, session log rendering, stdout/stderr protocol output, or snapshot fixtures. - -```sh -pnpm run test:snapshot -``` - -Run built-bin smoke tests after `pnpm run build` when app packages, app boot, package runtime imports, bin entries, loader behavior, or published artifact paths change. - -```sh -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 -``` - -Run real e2e when behavior depends on a real model/API, tool-use loop, ACP integration, prompt injection, or end-to-end agent UX. If `.env` is available, use it; do not print secrets. - -```sh -pnpm run test:e2e -``` - -Run a targeted test first for the changed package, but never use targeted tests as the only push evidence unless the change is test-only and cannot affect shared behavior. - -## Full Local CI Approximation - -Use this before high-risk pushes, after large merges, before asking for review on a major PR, or when prior pushes have caused CI churn. The authoritative command list is the root [AGENTS.md § Run the CI gates locally before marking a PR ready](../../../AGENTS.md#run-the-ci-gates-locally-before-marking-a-pr-ready); run that block rather than copying a local variant into this skill. Add `pnpm run test:e2e` when a key is available and the feature has real-agent behavior. - -## Handling Failures - -If a gate fails, stop and fix or explain the blocker. Do not push and hope CI differs. +If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs. If a failure looks environment-specific, prove it: - Record the exact command, failing test, and platform-specific mismatch. -- Confirm the relevant non-platform gates pass. -- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate. -- Bypass a local hook only when the user explicitly asks to push or agrees, and state exactly which hook failed and why it is not expected to fail on CI. +- Confirm the relevant non-platform evidence. +- Prefer fixing cross-platform nondeterminism when the check is required. +- Bypass a local hook only when the user explicitly asks or agrees, and report exactly what failed and why CI is expected to differ. -Known pattern to watch for: Linux CI and macOS local behavior can differ for shell utilities such as `sed -i`. Treat this as evidence to investigate, not as automatic permission to bypass. +## Push procedure -## Push Procedure - -1. Local commits may happen before the full gate set, but do not push, mark ready, or claim checks pass until the relevant gates pass or any blocker is explicitly documented. -2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it. -3. Push normally first so the pre-push hook can run. -4. If a local hook is bypassed after user approval, use the narrow bypass and say so in the final response. -5. After push, verify the remote ref matches local HEAD. +1. Run the selected relevant checks once. +2. Commit normally and inspect any files changed by the pre-commit fixer before continuing. +3. Push normally so the incremental typecheck hook runs. +4. Verify the remote ref matches local `HEAD`. ```sh git rev-parse HEAD origin/$(git branch --show-current) ``` -For GitHub PRs, check CI after push: +For GitHub PRs, inspect remote CI after the push: ```sh gh pr checks ``` -If checks are pending, say pending. If checks fail, inspect logs before claiming the push is good. +Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. diff --git a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml index 6ad9b63935..4a38ea4da8 100644 --- a/.agents/skills/dsh-pre-push-checks/agents/openai.yaml +++ b/.agents/skills/dsh-pre-push-checks/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "DSH Pre-Push Checks" - short_description: "Run the right DeepSeek Harness gates before push" + short_description: "Run the relevant DeepSeek Harness checks before push" default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch." diff --git a/AGENTS.md b/AGENTS.md index cf1763d3d1..a3b673b0b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Package groups: [packages/README.md](packages/README.md). ```sh pnpm install # pnpm workspaces, node ^22.19 || >=24 pnpm run test # vitest unit tests -pnpm run test:coverage # THE gating test run: per-file 100% coverage on packages/*/*/src +pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t pnpm run test:snapshot:record # re-record expected outputs (needs key) @@ -69,26 +69,13 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test. -### Run the CI gates locally before marking a PR ready +### Run relevant checks locally -Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`: +Agents MUST run the tests and checks relevant to changed behavior before pushing; use [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) to select them and report only commands actually run. -```sh -set -euo pipefail -pnpm run typecheck -pnpm run lint -pnpm run duplication -pnpm run test:coverage -pnpm run test:snapshot -pnpm run doc-sync -pnpm run website:build -pnpm run verify-module-graph -pnpm run build -pnpm run hygiene -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 -``` - -`test:coverage`, not `test`, is the gate ([why](docs/testing.md)); report only commands actually run. +- For product or tooling behavior, start with the narrowest unit, e2e, or purpose-built check that exercises the change. Add focused snapshots for model- or human-visible output, `doc-sync` for documentation, build/hygiene or built-artifact smoke for published runtime paths, and real-API e2e only when those surfaces change. +- Do not default to the entire repository suite or rerun a passing check solely because commit or push occurs. CI owns exhaustive coverage and the platform matrix; run a full local rehearsal only when the user explicitly requests it, while diagnosing CI, or when a repository-wide change cannot be validated narrowly. +- `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env @@ -115,12 +102,12 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Every non-trivial change MUST include at least one Agent Note in the same PR.** Update the owning note or add one, validate its premises against code, and exempt only mechanical/local edits ([scope](.agents/notes/README.md#when-to-write-one)). -- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or human-visible change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. +- **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). -- Files end with exactly one trailing newline; `git diff --check` (pre-push) gates it. +- Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. ## Defensive patterns diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 6f3fa83d94..20eb3b4ca1 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.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 -development.md: f0db7fbcb4a9df98e83d6c1edd5610e5cc4dd517 -development.zh.md: 62e16479a49d5548e1fbd773dabca5bd741a24fe +development.md: 10406cebae1bf83fff663903b1478c9acb8476a1 +development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9 diff --git a/docs/development.md b/docs/development.md index f0db7fbcb4..10406cebae 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,13 +35,13 @@ pnpm run typecheck That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings. -If you are preparing to push from a fresh clone or worktree, also build once: +If a relevant local check consumes built package output, build once first: ```sh pnpm run build ``` -`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs. +`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it. ## Environment variables @@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git hooks -lefthook is configured in `lefthook.yml` as an early local checkpoint before review: +lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard. -- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs runtime-closure verification, unit tests, duplication detection, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently. +- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-push` runs only the incremental repository typecheck. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. -These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests without coverage, while CI runs `pnpm run test:coverage`; CI also runs built-bin smoke tests and exercises the compatibility matrix on Node 22.19, 24, and 26. +The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix. ## CI gates diff --git a/docs/development.zh.md b/docs/development.zh.md index 62e16479a4..50051ffd63 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -35,13 +35,13 @@ pnpm run typecheck 首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references,因此 vendor 代码在它自己的 tsconfig 设置下被检查。 -如果准备从新克隆或新 worktree 推送,还需要构建一次: +如果相关的本地检查需要使用构建后的包产物,请先构建一次: ```sh pnpm run build ``` -`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件。 +`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。 ## 环境变量 @@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional ## Git 钩子 -lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: +lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest(元数据清单)守卫; -- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene` 与 `pnpm run doc-sync` 的各成员门禁。 +- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-push` 只运行仓库增量类型检查。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 -这些钩子并不与 CI 完全一致。特别是:`pre-push` 运行不带覆盖率的单元测试,而 CI 运行 `pnpm run test:coverage`;CI 还会运行 built-bin 冒烟测试,并在 Node 22.19、24 和 26 上执行兼容性矩阵。 +这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。 ## CI 门禁 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 401813b088..2a72aaa53e 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.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 -README.md: bd5d8c08a4c474a13342b6b60800cfe0d31e110b -README.zh.md: a53ab8d9d6053b39def34505038504fefc80a3f9 +README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049 +README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index bd5d8c08a4..c4ddf44ad2 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -21,7 +21,7 @@ This repo's documentation is read by people and agents both inside and outside t ## The gate: verify-translation-pairing -`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: +`pnpm run verify-translation-pairing` (part of `doc-sync`, which contributors run locally for documentation changes and CI runs exhaustively) enforces the contract mechanically: 1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. 2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index a53ab8d9d6..4a31af4fde 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -21,7 +21,7 @@ ## 门禁:verify-translation-pairing -`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约: +`pnpm run verify-translation-pairing`(`doc-sync`(文档同步门禁)的一环,贡献者会针对文档变更在本地运行,CI 则会完整运行)机械地强制执行这份契约: 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 2. 任何已存在的配对——无论是否 required——都完整且一致:三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。 diff --git a/lefthook.yml b/lefthook.yml index 53b8cc84c2..49a1499976 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,25 +1,23 @@ -# Git hooks (lefthook). Hooks call the same package.json scripts CI runs — -# one source of truth; the hook is just an earlier, faster checkpoint. +# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full +# repository-wide gate matrix. # Install: `pnpm exec lefthook install` (runs automatically via postinstall). pre-commit: - parallel: true jobs: - name: lint (staged) glob: '*.{ts,mts,cts,mjs}' exclude: - 'vendor/*/src/**' - run: node_modules/.bin/eslint --fix {staged_files} && git add {staged_files} + run: node_modules/.bin/eslint --fix {staged_files} stage_fixed: true - - name: typecheck - glob: '*.ts' - run: pnpm run typecheck + - name: whitespace (staged) + run: git diff --cached --check - name: vendor manifest guard run: scripts/check-vendor-manifest.sh pre-push: jobs: - - name: full check - run: pnpm run check:pre-push + - name: typecheck + run: node_modules/.bin/tsc -b tsconfig.json --pretty false diff --git a/package.json b/package.json index c3998bad0e..90b502cc55 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:node-compat": "tsx scripts/run-gates.ts node-compat", - "check:pre-push": "tsx scripts/run-gates.ts pre-push", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts", diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index df6d55180a..e303c589be 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -85,7 +85,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const located = await capture(dir()) expect(located.payload.transcript_path).toBe(located.expected) expect((await capture()).payload.transcript_path).toBe('') - }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => { const d = dir() diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index 9bf14da46a..d0f0df92f6 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -76,7 +76,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const located = await capture(dir()) expect(located.payload.transcript_path).toBe(located.expected) expect((await capture()).payload.transcript_path).toBeNull() - }, 15_000) // Two real agent/hook subprocess loops need loaded pre-push runner headroom. + }, 15_000) // Two real agent/hook subprocess loops need process startup and teardown headroom. it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => { const d = dir() diff --git a/packages/support/loader-smoke/src/index.ts b/packages/support/loader-smoke/src/index.ts index 36ab137f32..bcb27eb9d3 100644 --- a/packages/support/loader-smoke/src/index.ts +++ b/packages/support/loader-smoke/src/index.ts @@ -24,7 +24,7 @@ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000 /** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */ export type ExampleMode = 'src' | 'lib' -/** Environment variable selecting the mode; CI and pre-push set it to `lib`, dev leaves it unset (`src`). */ +/** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */ export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE' /** diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 2de30b76a7..91c6d5228b 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -17,7 +17,6 @@ type Mode = | 'ci-snapshot' | 'ci-artifacts' | 'node-compat' - | 'pre-push' | 'doc-sync' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' @@ -87,21 +86,20 @@ function parseMode(raw: string | undefined): Mode { case 'ci-snapshot': case 'ci-artifacts': case 'node-compat': - case 'pre-push': 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 | node-compat | pre-push | doc-sync, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | doc-sync, got ${JSON.stringify(raw)}.`, ) } } function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault { const available = availableParallelism() - // Local modes cap workers: several doc gates each build a full ts.Program, + // The local doc mode caps workers: several 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 === 'doc-sync' + const localCap = selectedMode === 'doc-sync' const modeLimit = localCap ? Math.min(4, available) : available return { workers: Math.min(total, modeLimit), @@ -191,21 +189,6 @@ function gatesForMode(selected: Mode): Gate[] { 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts', ], { label: 'JSONL Zstandard smoke' }), ] - case 'pre-push': - return [ - pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('test', 'test'), - pnpmScript('duplication', 'duplication'), - snapshotGate(), - pnpmScript('build', 'build'), - ...hygieneLeafGates({ artifactNeeds: ['build'] }), - ...docSyncLeafGates({ - docTypecheckNeeds: ['build'], - docTypecheckEnv: { DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1' }, - }), - pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), - ] case 'doc-sync': return docSyncLeafGates() } @@ -295,8 +278,8 @@ function coverageGate(): Gate { } // The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, -// plugins via real exports) — CI and pre-push already build, so they exercise what ships rather -// than the tsx/source path dev uses. It therefore waits on `build`. +// plugins via real exports). CI pairs it with `build`, so it exercises what ships rather than +// the tsx/source path dev uses and therefore waits on `build`. function snapshotGate(): Gate { return pnpmScript('snapshot', 'test:snapshot', { env: { DSH_EXAMPLE_MODE: 'lib' }, @@ -321,30 +304,9 @@ function positiveIntArg(envName: string, flag: string): string[] { return [`${flag}=${raw}`] } -function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { - const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } +function docSyncLeafGates(): Gate[] { return [ - pnpmScript('knip', 'knip'), - pnpmScript('publint', 'publint', artifactOptions), - pnpmScript('constraints', 'constraints'), - pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), - builtPackageInvariantsGate(options.artifactNeeds), - pnpmScript('node-next-types', 'verify-node-next-types', { - label: 'node-next types', - ...artifactOptions, - }), - ] -} - -function docSyncLeafGates(options: { - docTypecheckNeeds?: string[] - docTypecheckEnv?: Record -} = {}): Gate[] { - const docTypecheckOptions: Partial = {} - if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds - if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv - return [ - pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions), + pnpmScript('doc-typecheck', 'doc-typecheck'), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), From 092e1812fe3fe1a2c09c7fca4df1ca2ec0a1bc84 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:44:44 +0800 Subject: [PATCH 6/7] docs: keep agent guidance within budget --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a3b673b0b5..8ac00e2ef2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,10 +71,10 @@ When required `gh`, `pnpm`, build, test, or generator commands fail because the ### Run relevant checks locally -Agents MUST run the tests and checks relevant to changed behavior before pushing; use [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) to select them and report only commands actually run. +Agents MUST run relevant tests and checks before pushing; select them with [dsh-pre-push-checks](.agents/skills/dsh-pre-push-checks/SKILL.md) and report only commands run. -- For product or tooling behavior, start with the narrowest unit, e2e, or purpose-built check that exercises the change. Add focused snapshots for model- or human-visible output, `doc-sync` for documentation, build/hygiene or built-artifact smoke for published runtime paths, and real-API e2e only when those surfaces change. -- Do not default to the entire repository suite or rerun a passing check solely because commit or push occurs. CI owns exhaustive coverage and the platform matrix; run a full local rehearsal only when the user explicitly requests it, while diagnosing CI, or when a repository-wide change cannot be validated narrowly. +- Match evidence to the surface: focused tests for behavior, snapshots for model or user output, `doc-sync` for docs, build/hygiene and built smokes for published paths, and real-API e2e for provider behavior. +- Never default to the full suite or repeat a passing check for commit or push. CI owns exhaustive coverage and the platform matrix; rehearse all locally only by explicit request, for CI diagnosis, or for an irreducibly repository-wide change. - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)). ## Secrets / .env From 97a535dd56a112f8672b367a8fe625dfe73aa698 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:01:18 +0800 Subject: [PATCH 7/7] docs: explain focused local coverage --- .agents/skills/dsh-pre-push-checks/SKILL.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index b2ecc6362d..31f82eed94 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -37,6 +37,29 @@ There is no universal local baseline beyond the hooks. Every behavior change nee Do not manually repeat a passing check merely because commit or push follows. In particular, do not run typecheck immediately before pushing solely to duplicate the pre-push hook. +### Focus unit coverage on the affected source + +Test selection and coverage selection are separate. A Vitest file filter chooses which tests run, while the repository configuration otherwise measures every `packages/*/*/src/**/*.ts` file. When unit coverage is relevant, name both the owning tests and the source files or package whose coverage those tests must prove: + +```sh +pnpm exec vitest run packages///tests/.spec.ts \ + --coverage \ + --coverage.include='packages///src/**/*.ts' +``` + +Use an exact source file when the behavior is truly confined to one module. Repeat `--coverage.include` for multiple affected files or packages, and pass every owning test file needed to exercise that scope. The configured per-file 100% thresholds still apply inside the selected source scope. + +When the owning tests are unclear, use Vitest's dependency graph to discover a candidate set, then inspect the selected tests before treating the run as evidence: + +```sh +pnpm exec vitest related packages///src/.ts \ + --run \ + --coverage \ + --coverage.include='packages///src/.ts' +``` + +`vitest related` cannot discover behavior reached only through configuration, dynamic loading, subprocesses, workers, built artifacts, or external providers; select those owning tests explicitly. Do not use `--passWithNoTests`, lower coverage thresholds, or narrow `--coverage.include` merely to hide an uncovered affected file. If a selected package scope fails because one focused test does not cover it, add its other relevant owning tests or narrow the source scope only when the excluded modules cannot be affected by the change. + ## Full local rehearsal Run the complete local approximation only when the user explicitly requests it, while diagnosing a CI failure, or when the change spans the repository so broadly that no narrower set is credible. Use the current workflow and package scripts as the inventory; do not recreate the removed `check:pre-push` aggregate.