Merge latest master into tui-staging-merge-1

This commit is contained in:
Tianyi Cui
2026-07-22 17:25:35 +08:00
52 changed files with 612 additions and 349 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # 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.md: 43ba5708d1216c37a7ad7e2904df7d2a6baf016d
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: dcc9213c6b3a088b8b8bce2a442c5232ed5b7d0b 2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3b33ff870d745584d2988bb6a7eb1a31e56ec3da

View File

@@ -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. 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 ### Build pipeline and artifacts

View File

@@ -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()` 集合外的名称会失败。 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` 覆盖。
### 构建管线与产物 ### 构建管线与产物

View File

@@ -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. 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. 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. **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 ## 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). - 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. - 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. - API reports remain available to revisit if the packages are ever published externally.

View File

@@ -2,24 +2,26 @@
Status: implemented 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 ## 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. 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 ## 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. - 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. - 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. - 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. - 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. - 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 ## 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. - 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)). - 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)).

View File

@@ -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. - 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. - 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). 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 ## 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`). - 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. - 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.

View File

@@ -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. - 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`). - 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. - 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.

View File

@@ -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. - 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. - 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. - 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 ### 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 ## 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 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. - 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. - 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.

View File

@@ -33,7 +33,7 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11
## Consequences ## 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. - 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. - 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. - `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.

View File

@@ -8,7 +8,7 @@ The repository had no single reference for the names, descriptions, and JSON Sch
## Decision ## 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 `## <package>` 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 `## <package>` 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) ### Why boot, not parse (the crux)
@@ -47,7 +47,7 @@ Schema blocks use ` ```json `, not a bespoke `ts`-family fence. `doc-typecheck`
## Consequences ## 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. - 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. - 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. - 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.

View File

@@ -10,7 +10,7 @@ The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-ch
## Decision ## 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: The contract:
@@ -32,7 +32,7 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr
## Consequences ## 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 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. - 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. - `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate.

View File

@@ -28,7 +28,7 @@ This supersedes the hand-copies: the session.md `hook/*` table, the compact READ
## Consequences ## 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. - 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 `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. - 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.

View File

@@ -36,7 +36,7 @@ Three exemption families keep the gate from demanding boilerplate, in the spirit
## Consequences ## 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. - 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. - 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. - 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.

View File

@@ -32,7 +32,7 @@ The package README `## Config` sections stay. The overlap is accepted deliberate
## Consequences ## 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. - 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. - 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. - `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.

View File

@@ -2,47 +2,30 @@
Status: implemented 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 ## 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. 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.
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.
The unit-suite gate is the sharpest instance of the parallel runner's own pressure. At vitest's all-core default it oversubscribed the machine against its concurrent siblings — build, snapshot, and the doc leaves — and the resulting CPU starvation blew the 5s per-test timeout on subprocess-spawning tests (the hooks bridges spawn shells; the sandbox probe `spawnSync`-es a launcher) with a shifting victim set from run to run.
## Decision ## 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/<group>/<pkg>` 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. 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)).
The unit-suite (`test`) gate runs `vitest` with a pool bounded to half the available cores by default; `DSH_TEST_MAX_WORKERS` overrides it, mirroring the coverage gate's `DSH_COVERAGE_MAX_WORKERS`. The bound lives only in the `pre-push` mode's gate; CI runs the coverage gate instead, so it never touches CI timing.
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` 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)).
## Alternatives considered ## 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. - **Keep aggregate jobs serial** simpler execution but makes wall clock equal the sum of independent checks and repeats command-wrapper startup.
- **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. - **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.
- **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** — parallelizes work but loses per-gate timing, deterministic failure grouping, and straightforward signal handling.
- **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` job per package** — exposes maximum package parallelism but creates a hand-maintained package inventory that drifts when packages change.
- **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 repositories only by gambling with process count, memory pressure, package tarball creation, and readable logs.
- **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.
- **Leave the `test` gate at vitest's all-core default** - matches a standalone `pnpm run test`, but under the pre-push runner it overlaps three sibling gates and oversubscribes the machine, so subprocess-spawning tests intermittently blow their 5s timeout; bounding the pool trades a slower isolated `test` gate for a stable one.
- **Raise the per-test timeout instead of bounding workers** - would cover the vitest-level timeouts, but the sandbox probe's own 5s `spawnSync` budget is a real-time product default the test asserts against, not a vitest timeout, so only lowering the concurrent process count keeps it green.
## Consequences ## 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` 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.
`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.
The bounded `test` gate runs slower in isolation than a full-machine `vitest run` but no longer starves its siblings, so the hook stops producing spurious per-test timeout failures at its default concurrency. A machine shared with other heavy processes can still spike past saturation beyond this hook's control; `DSH_TEST_MAX_WORKERS` and `DSH_GATE_CONCURRENCY` let a contributor tighten the footprint further when that happens.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-21-doc-sync-through-gate-scheduler.md: b79df2dd7d3515cb0434ac672f7f87c3271d900b 2026-07-21-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2
2026-07-21-doc-sync-through-gate-scheduler.zh.md: 9395244e3c7700166ad87c49219073210c66bc7e 2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef

View File

@@ -6,13 +6,13 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md)
## Problem ## 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 ## 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 ## Alternatives considered

View File

@@ -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 目录。
## 考虑过的替代方案 ## 考虑过的替代方案

View File

@@ -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

View File

@@ -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.

View File

@@ -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缓存已预热时推送的关键路径是增量类型检查。钩子耗时只作为开发观察数据和 PRPull Request证据记录不设置会受主机负载与缓存状态影响的计时测试。
从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符CI 则对每个推送版本提供一次全面信号。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-22-cross-platform-test-fixtures.md: 83af904db5d004366021d4ba6bead656ff813dae 2026-07-22-cross-platform-test-fixtures.md: 6217aabfdbe8f14f869004c8dafb7e19f4b7443a
2026-07-22-cross-platform-test-fixtures.zh.md: 3570c393f8d2fc3344aa43ff0eb8291500d07e1c 2026-07-22-cross-platform-test-fixtures.zh.md: 43942ec0468df822d04b39e318010c2b260c734f

View File

@@ -6,7 +6,7 @@ English | [中文](2026-07-22-cross-platform-test-fixtures.zh.md)
## Problem ## 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. 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. 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. 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.
## Alternatives considered ## 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. **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. **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 ## 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 successful synchronous result keeps disposal bounded and makes descendant exit observable before cleanup returns, while a failed tree kill remains visible to the disposer.

View File

@@ -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 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。 把 fixture 语法当成产品行为,要么会误报回归,要么会促使生产代码引入抹去原生路径语义的归一化。
@@ -14,18 +14,20 @@ Status: implemented
测试平台无关行为时,使用宿主的 `node:path``node:url` API 构造绝对路径与 `file:` URI再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。 测试平台无关行为时,使用宿主的 `node:path``node:url` API 构造绝对路径与 `file:` URI再根据契约要求断言原生绝对输出或稳定的工作区相对输出。无效 URI fixture 使用一种在所有受支持平台上都会被 `fileURLToPath()` 拒绝的编码形式。
需要使父进程写端失败的子进程 fixture 会同时关闭 CRT 文件描述符和持有子进程 stdin 的 libuv 句柄。这种方式在以 POSIX 文件描述符为后端的进程和以 Windows 管道为后端的进程上固定了连接失败契约,同时让子进程存活足够长的时间,以区分管道故障与进程退出。 传输故障测试会注入连接的消息写入器,并传入与真实 Node 流相同的异步写入回调错误。生产写入器仍会把分帧消息写入子进程 stdin。这种方式让真实子进程保持存活使测试无需触及平台特有的管道句柄也能确定性地区分传输故障与进程退出。
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为 语言服务器的资源清理会终止整棵后代进程树POSIX 使用负数进程组 IDWindows 同步执行 `taskkill /T /F`。Windows 只会忽略 taskkill 返回的「进程树已经不存在」状态;命令执行失败、权限错误及其他终止进程树的失败仍属于资源清理失败。只读的提供方查询仅在选定的池化传输于该次查询开始前或执行期间失效时重试一次;服务器仍存活时返回的错误不会重放。终端测试会等待可观察的渲染输出,不假设一次事件循环轮转已经足够
对于真正仅存在于 POSIX 的原语,测试只在该用例上排除 Windows。相邻的跨平台用例仍会固定拒绝非普通文件、不可用命令和无法访问的工作目录的行为。Windows 上受支持的路径仍受逐文件覆盖率门禁约束,不会随测试文件一起排除。
## 曾考虑的替代方案 ## 曾考虑的替代方案
**将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为外部路径是原生绝对路径UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。 **将所有路径和 URI 归一化为 POSIX 字符串。**这会使断言保持一致,但也会改变正确的 Windows 行为外部路径是原生绝对路径UNC 文件 URI 有效,而且已配置的主目录会按照宿主路径规则解析。
**在 Windows 上通过兼容性 shell 运行 POSIX fixture。**这种兼容环境测试的文件系统与进程语义不同于产品实际使用的原生 Node 运行时 **操纵子进程管道内部状态,直至写入失败。**CRT 描述符与 libuv 句柄在不同宿主和 Node 版本上的所有权不同,因此这种做法测试的是未文档化的 fixture 机制,而非连接的写入失败契约
**在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture相关契约仍保持覆盖。 **在 Windows 上跳过整个测试文件或包。**过宽的排除会隐藏受支持的行为。只排除无法在 Windows 上构造相应状态的单项 fixture相关契约仍保持覆盖。
## 后果 ## 后果
可移植 fixture 略显冗长,因为预期路径要从共享的原生常量派生。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。管道故障 fixture 依赖 Node 测试运行时的句柄形态,但这种依赖仅存在于脚本化的子进程内;因此,这类 fixture 验证的是真实的父进程侧流行为,而不是对它进行 mock 可移植 fixture 需要更显式地构造,因为预期路径要从共享的原生常量派生,传输故障则通过狭窄的写入器 seam 注入。仅适用于特定平台的排除项必须配有相邻的跨平台断言,以继续覆盖相应的产品行为。协议级优雅关停失败后Windows 上的资源清理依赖宿主的 `taskkill` 命令命令同步执行成功时dispose 的完成边界明确并确保清理返回前即可观察到后代进程退出若进程树终止失败dispose 的调用方仍能观察到该失败

View File

@@ -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. 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). 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)). 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 ## Manual checks

View File

@@ -102,7 +102,7 @@ Diff the sibling branch against `origin/master`, not against the current PR bran
## Validation And PR Hygiene ## 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: When opening or updating a PR, summarize:

View File

@@ -1,13 +1,13 @@
--- ---
name: dsh-pre-push-checks 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 # 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. 1. Confirm the checkout and branch.
@@ -16,88 +16,80 @@ git status --short --branch
git rev-parse --show-toplevel git rev-parse --show-toplevel
``` ```
2. Inspect the outgoing diff. 2. Inspect the diff against its actual base.
```sh ```sh
git diff --stat git diff --stat
git diff --name-only origin/$(git branch --show-current)...HEAD 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.
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 ```sh
pnpm run typecheck pnpm exec vitest run packages/<group>/<package>/tests/<behavior>.spec.ts \
pnpm run lint --coverage \
pnpm run test:coverage --coverage.include='packages/<group>/<package>/src/**/*.ts'
``` ```
Why `test:coverage`, not only `test`: CI enforces per-file 100% coverage. A branch can pass `pnpm run test` and still fail CI. 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.
## Add Gates By Touched Surface 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:
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.
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 ```sh
pnpm run test:snapshot pnpm exec vitest related packages/<group>/<package>/src/<changed>.ts \
--run \
--coverage \
--coverage.include='packages/<group>/<package>/src/<changed>.ts'
``` ```
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. `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.
```sh ## Full local rehearsal
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. 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.
```sh ## Handle failures
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. If a relevant check fails, stop and fix or explain the blocker. Do not push and hope CI differs.
## 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 failure looks environment-specific, prove it: If a failure looks environment-specific, prove it:
- Record the exact command, failing test, and platform-specific mismatch. - Record the exact command, failing test, and platform-specific mismatch.
- Confirm the relevant non-platform gates pass. - Confirm the relevant non-platform evidence.
- Prefer fixing the test for cross-platform determinism if the test is part of the required local gate. - Prefer fixing cross-platform nondeterminism when the check is required.
- 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. - 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. Run the selected relevant checks once.
2. Commit normally and inspect any files changed by the pre-commit fixer before continuing.
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. 3. Push normally so the incremental typecheck hook runs.
2. Let the normal pre-commit hook run. If it changes files, inspect and commit or amend the change intentionally rather than hiding it. 4. Verify the remote ref matches local `HEAD`.
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.
```sh ```sh
git rev-parse HEAD origin/$(git branch --show-current) 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 ```sh
gh pr checks 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.

View File

@@ -1,4 +1,4 @@
interface: interface:
display_name: "DSH Pre-Push Checks" 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." default_prompt: "Use $dsh-pre-push-checks before pushing this DeepSeek Harness branch."

View File

@@ -48,7 +48,7 @@ Package groups: [packages/README.md](packages/README.md).
```sh ```sh
pnpm install # pnpm workspaces, node ^22.19 || >=24 pnpm install # pnpm workspaces, node ^22.19 || >=24
pnpm run test # vitest unit tests 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:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY
pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t <name> pnpm run test:snapshot # keyless ACP/headless/TUI replay vs expected outputs; filter: -t <name>
pnpm run test:snapshot:record # re-record expected outputs (needs key) 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. 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 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.
```sh - 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.
set -euo pipefail - 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.
pnpm run typecheck - `test:coverage`, not `test`, is the CI coverage gate ([why](docs/testing.md)).
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/tui-demo/tests/built-bin.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.
## Secrets / .env ## 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. - **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. - **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)). - **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)). - **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. - **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)). - **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)). - 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 ## Defensive patterns

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
development.md: f0db7fbcb4a9df98e83d6c1edd5610e5cc4dd517 development.md: 10406cebae1bf83fff663903b1478c9acb8476a1
development.zh.md: 62e16479a49d5548e1fbd773dabca5bd741a24fe development.zh.md: 50051ffd631518b37c3ad96f5fd3830cf6893ec9

View File

@@ -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. 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 ```sh
pnpm run build 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 ## Environment variables
@@ -56,14 +56,14 @@ DEEPSEEK_BASE_URL=https://... # optional
## Git hooks ## 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-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs 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-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. 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 ## CI gates

View File

@@ -35,13 +35,13 @@ pnpm run typecheck
首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references因此 vendor 代码在它自己的 tsconfig 设置下被检查。 首次类型检查会执行 package/vendor 的构建图,以及根目录下用于示例、测试和脚本的 no-emit `tsconfig.json` 项目图。根图使用同一份源码 `paths` 映射,但依赖 project references因此 vendor 代码在它自己的 tsconfig 设置下被检查。
如果准备从新克隆或新 worktree 推送,还需要构建一次: 如果相关的本地检查需要使用构建后的包产物,请先构建一次:
```sh ```sh
pnpm run build 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 钩子 ## Git 钩子
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
- `pre-commit` 运行对暂存文件的 ESLint 修复`pnpm run typecheck` vendor manifest元数据清单守卫 - `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest元数据清单守卫
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene``pnpm run doc-sync` 的各成员门禁 - `pre-push` 运行仓库增量类型检查
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md` 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 门禁 ## CI 门禁

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
README.md: bd5d8c08a4c474a13342b6b60800cfe0d31e110b README.md: c4ddf44ad2497b4ff371918356ab1ec0698c7049
README.zh.md: a53ab8d9d6053b39def34505038504fefc80a3f9 README.zh.md: 4a31af4fdee4db2d0362cf9117a6eef4fea32393

View File

@@ -21,7 +21,7 @@ This repo's documentation is read by people and agents both inside and outside t
## The gate: verify-translation-pairing ## 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. 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. 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.

View File

@@ -21,7 +21,7 @@
## 门禁verify-translation-pairing ## 门禁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` 列出的每个文件都有完整配对。 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
2. 任何已存在的配对——无论是否 required——都完整且一致三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。 2. 任何已存在的配对——无论是否 required——都完整且一致三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格行列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。

View File

@@ -1,25 +1,23 @@
# Git hooks (lefthook). Hooks call the same package.json scripts CI runs # Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full
# one source of truth; the hook is just an earlier, faster checkpoint. # repository-wide gate matrix.
# Install: `pnpm exec lefthook install` (runs automatically via postinstall). # Install: `pnpm exec lefthook install` (runs automatically via postinstall).
pre-commit: pre-commit:
parallel: true
jobs: jobs:
- name: lint (staged) - name: lint (staged)
glob: '*.{ts,mts,cts,mjs}' glob: '*.{ts,mts,cts,mjs}'
exclude: exclude:
- 'vendor/*/src/**' - '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 stage_fixed: true
- name: typecheck - name: whitespace (staged)
glob: '*.ts' run: git diff --cached --check
run: pnpm run typecheck
- name: vendor manifest guard - name: vendor manifest guard
run: scripts/check-vendor-manifest.sh run: scripts/check-vendor-manifest.sh
pre-push: pre-push:
jobs: jobs:
- name: full check - name: typecheck
run: pnpm run check:pre-push run: node_modules/.bin/tsc -b tsconfig.json --pretty false

View File

@@ -33,7 +33,6 @@
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
"check:node-compat": "tsx scripts/run-gates.ts node-compat", "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", "knip": "knip --treat-config-hints-as-errors",
"publint": "tsx scripts/publint-all.ts", "publint": "tsx scripts/publint-all.ts",
"doc-typecheck": "tsx scripts/doc-typecheck.ts", "doc-typecheck": "tsx scripts/doc-typecheck.ts",

View File

@@ -85,7 +85,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const located = await capture(dir()) const located = await capture(dir())
expect(located.payload.transcript_path).toBe(located.expected) expect(located.payload.transcript_path).toBe(located.expected)
expect((await capture()).payload.transcript_path).toBe('') 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 () => { it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
const d = dir() const d = dir()

View File

@@ -76,7 +76,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const located = await capture(dir()) const located = await capture(dir())
expect(located.payload.transcript_path).toBe(located.expected) expect(located.payload.transcript_path).toBe(located.expected)
expect((await capture()).payload.transcript_path).toBeNull() 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 () => { it('UserPromptSubmit block (exit 2) → rejected turn; default reason on empty stderr', async () => {
const d = dir() const d = dir()

View File

@@ -7,9 +7,10 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
## What it does ## 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. - 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 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. - 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. - 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`. 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. - 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 ## Configuration

View File

@@ -8,7 +8,7 @@
*/ */
import type { ChildProcessByStdio } from 'node:child_process' 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 type { Readable, Writable } from 'node:stream'
import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import { encodeMessage, MessageDecoder } from './framing.ts' import { encodeMessage, MessageDecoder } from './framing.ts'
@@ -36,6 +36,132 @@ interface Pending {
reject: (error: Error) => void 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 POSIX group signaling 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)
/** 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)
}
/**
* 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 === TASKKILL_TREE_NOT_FOUND_STATUS) return
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)
}
/**
* 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<unknown> = yieldToEventLoop,
): Promise<boolean> {
while (isAlive()) {
if (signal?.aborted) return false
await yieldNow()
}
return true
}
/**
* 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.
* @param operations - host operations.
*/
export function signalProcessTree(
platform: NodeJS.Platform,
pid: number,
signal: NodeJS.Signals,
operations: ProcessTreeOperations,
): void {
if (platform === 'win32') {
operations.taskkill(pid)
return
}
try {
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. */ /** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection { export class LspConnection {
private readonly child: ChildProcessByStdio<Writable, Readable, Readable> private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
@@ -50,14 +176,16 @@ export class LspConnection {
/** /**
* @param spec - how to launch the server and answer its config requests. * @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 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( constructor(
private readonly spec: ConnectionSpec, private readonly spec: ConnectionSpec,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>, private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) { ) {
this.decoder = new MessageDecoder(spec.maxMessageBytes) this.decoder = new MessageDecoder(spec.maxMessageBytes)
// `detached` puts the server in its own process group so teardown can signal the WHOLE group // `detached` gives teardown a process-tree root: POSIX signals its negative process-group id,
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver). // while Windows passes the root pid to taskkill /T so helpers such as tsserver cannot outlive it.
this.child = spawn(spec.command, [...spec.args], { this.child = spawn(spec.command, [...spec.args], {
cwd: spec.cwd, cwd: spec.cwd,
env: spec.env, env: spec.env,
@@ -94,6 +222,20 @@ export class LspConnection {
return this.stderr.toString('utf8') 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
}
/**
* 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. * Send a request and await its result.
* @param method - the JSON-RPC method. * @param method - the JSON-RPC method.
@@ -147,50 +289,38 @@ export class LspConnection {
return this.nextId 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 { 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 { 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. * @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<boolean> { async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
while (this.processGroupAlive()) { return await waitForTreeExit(this.processTreeAlive.bind(this), signal)
if (signal?.aborted) return false
await yieldToEventLoop()
}
return true
} }
/** /** Signal the whole process tree. */
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the private signalTree(sig: NodeJS.Signals): void {
* direct child if the group send fails. Never throws — teardown races process exit.
*/
private signalGroup(sig: NodeJS.Signals): void {
const pid = this.child.pid const pid = this.child.pid
if (pid === undefined) return if (pid === undefined) return
try { signalProcessTree(process.platform, pid, sig, {
process.kill(-pid, sig) signal: signalProcessGroup,
} catch { killChild: this.child.kill.bind(this.child),
// The group is gone (already exited) or could not be signalled; try the direct child. taskkill: taskkillProcessTree,
try { })
this.child.kill(sig)
} catch {
// Already dead; nothing to signal.
}
}
} }
/** Whether the detached process group still has at least one member. */ /** Whether the detached tree's root or POSIX process group is still alive. */
private processGroupAlive(): boolean { private processTreeAlive(): boolean {
const pid = this.child.pid const pid = this.child.pid
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */ /* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
if (pid === undefined) return false if (pid === undefined) return false
@@ -218,7 +348,7 @@ export class LspConnection {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and // 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. // SIGKILL the whole group so helper processes don't outlive the leader.
this.fail(asError(error)) this.fail(asError(error))
this.signalGroup('SIGKILL') this.signalTree('SIGKILL')
return return
} }
for (const message of messages) this.dispatch(message) for (const message of messages) this.dispatch(message)
@@ -293,7 +423,7 @@ export class LspConnection {
reject(error) reject(error)
} }
try { 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 /* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */ nonconforming Writable implementation throwing synchronously. */
} catch (error) { } catch (error) {

View File

@@ -2,9 +2,9 @@
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table * 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 * 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 * 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 it, and replaces a selected transport that fails before or during the next read-only
* through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no * query. Providers read sources through Node APIs in the host namespace (not `ctx.fs`)
* sandbox confinement. * and trust their configured servers — no sandbox confinement.
* *
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
* unregisters from `ctx.lsp` and tears down every live server. * unregisters from `ctx.lsp` and tears down every live server.
@@ -221,15 +221,23 @@ class LocalLspProvider implements LspProvider {
// synchronous get-or-create so every spawned process remains owned by teardown. // synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal) this.assertActive(signal)
let instance = this.instanceFor(workspace) let instance = this.instanceFor(workspace)
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = this.instanceFor(workspace)
}
try { try {
return await instance.query(request, source, signal) return await instance.query(request, source, signal)
} catch (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 { } finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive. // Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) this.evictIfCurrent(workspace, instance) if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
}
} }
}) })
} }

View File

@@ -17,7 +17,7 @@ import type {
import { deadline } from '@deepseek-ai/dsh-timeout' import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts' import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.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 { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts' import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import { import {
@@ -39,6 +39,15 @@ export interface InstanceSpec extends ConnectionSpec {
readonly killGraceMs: number 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 * 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. * pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
@@ -58,9 +67,10 @@ export class LspInstance {
/** /**
* @param spec - the launch, initialize, and teardown parameters. * @param spec - the launch, initialize, and teardown parameters.
* @param writer - optional connection writer used by transport conformance tests.
*/ */
constructor(private readonly spec: InstanceSpec) { constructor(private readonly spec: InstanceSpec, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params)) this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize() this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits // A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler. // it; queries attach the real handler.
@@ -70,7 +80,16 @@ export class LspInstance {
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */ /** Synchronous liveness check: true once the process has closed or the instance was disposed. */
get dead(): boolean { get dead(): boolean {
return this.processClosed || this.disposed 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)
} }
/** /**
@@ -84,7 +103,12 @@ export class LspInstance {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query // 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 // 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. // 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 // 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 // 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. // on the wait does not deserialize the queue.
@@ -272,7 +296,7 @@ export class LspInstance {
try { try {
await this.gracefulShutdown(shutdownDeadline.signal) await this.gracefulShutdown(shutdownDeadline.signal)
} catch { } 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 { } finally {
shutdownDeadline[Symbol.dispose]() shutdownDeadline[Symbol.dispose]()
} }
@@ -286,20 +310,20 @@ export class LspInstance {
await abortable(this.connection.closed, signal) 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<void> { private async forceTerminate(): Promise<void> {
this.connection.terminate() this.connection.terminate()
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE') const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
let groupExited: boolean let treeExited: boolean
try { try {
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal) treeExited = await this.connection.waitForProcessTreeExit(graceDeadline.signal)
} finally { } finally {
graceDeadline[Symbol.dispose]() graceDeadline[Symbol.dispose]()
} }
if (!groupExited) this.connection.kill() escalateProcessTree(treeExited, this.connection.kill.bind(this.connection))
await Promise.all([ await Promise.all([
this.connection.closed, this.connection.closed,
this.connection.waitForProcessGroupExit(), this.connection.waitForProcessTreeExit(),
]) ])
} }
} }

View File

@@ -1,6 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local' import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
import {
signalProcessGroup,
signalProcessTree,
taskkillProcessTree,
waitForTreeExit,
} 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)) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
@@ -53,6 +65,12 @@ describe('LspConnection', () => {
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/) 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 () => { it('answers a server workspace/configuration request from static config', async () => {
const seen: SeenRequest[] = [] const seen: SeenRequest[] = []
const conn = connect( const conn = connect(
@@ -125,7 +143,7 @@ describe('LspConnection', () => {
}) })
/** Spawn a raw connection running an inline node script as the "server". */ /** 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({ const conn = new LspConnection({
command: process.execPath, command: process.execPath,
args: ['-e', script], args: ['-e', script],
@@ -134,7 +152,7 @@ function connectScript(script: string, maxStderrBytes = 100_000): LspConnection
maxMessageBytes: 16_000_000, maxMessageBytes: 16_000_000,
maxStderrBytes, maxStderrBytes,
configuration: null, configuration: null,
}, () => Promise.resolve(null)) }, () => Promise.resolve(null), writer)
open.push(conn) open.push(conn)
return conn return conn
} }
@@ -209,13 +227,13 @@ describe('LspConnection edge behavior', () => {
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/) 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 () => { it('rejects a pending request when child stdin fails but the process stays alive', async () => {
const conn = connectScript('const stdin=process.stdin; require("node:fs").closeSync(0); stdin._handle?.close(); setInterval(()=>{}, 1000)') const failure = new Error('fixture stdin failure')
await new Promise<void>(resolve => setTimeout(resolve, 100)) const writer: ConnectionWriter = (_stdin, _message, done) => {
const timeout = new Promise<never>((_resolve, reject) => { queueMicrotask(() => { done(failure) })
setTimeout(() => { reject(new Error('request timed out')) }, 1000) }
}) const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/) 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 () => { it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
@@ -230,6 +248,72 @@ 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('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)
expect(operations.taskkill).toHaveBeenCalledWith(42)
expect(operations.signal).not.toHaveBeenCalled()
signalProcessTree('linux', 42, 'SIGKILL', operations)
expect(operations.signal).toHaveBeenCalledWith(-42, 'SIGKILL')
})
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') })
expect(() => { signalProcessTree('win32', 42, 'SIGTERM', fallback) }).toThrow(/taskkill unavailable/)
expect(fallback.killChild).not.toHaveBeenCalled()
})
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/)
})
})
/** 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. */ /** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> { async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now() const start = Date.now()

View File

@@ -16,8 +16,6 @@
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. * - 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_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized. * - 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_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_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 * - 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). * 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 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 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 openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1' 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 exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' 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 (method === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n') if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause() if (pauseStdinAfterInitialized) process.stdin.pause()
if (closeStdinAfterInitialized) closeStdinPipe()
return return
} }
if (method === 'textDocument/didClose') return if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) { if (method?.startsWith('textDocument/')) {
if (hang) return if (hang) return
const reply = (): void => { const reply = (): void => {
if (closeStdinAfterReply) closeStdinPipe()
if (errorReply) { if (errorReply) {
send({ id, error: { code: -32000, message: 'server refused the request' } }) send({ id, error: { code: -32000, message: 'server refused the request' } })
} else { } else {
@@ -171,13 +165,6 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (id !== undefined) send({ id, result: null }) 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. */ /** Append one teardown event when the fixture is configured to expose process ordering. */
function markExit(event: string): void { function markExit(event: string): void {
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`) if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
@@ -209,6 +196,6 @@ function send(message: Record<string, unknown>): void {
// Keep the event loop alive. // Keep the event loop alive.
process.stdin.resume() process.stdin.resume()
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) { if (pauseStdinAfterInitialized) {
setInterval(() => {}, 1000) setInterval(() => {}, 1000)
} }

View File

@@ -1,9 +1,12 @@
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 { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url' import { pathToFileURL, fileURLToPath } from 'node:url'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' 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 { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp' import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
@@ -26,7 +29,11 @@ afterEach(async () => {
await rm(root, { recursive: true, force: true }) await rm(root, { recursive: true, force: true })
}) })
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance { function makeInstance(
env: Record<string, string> = {},
overrides: Partial<InstanceSpec> = {},
writer?: ConnectionWriter,
): LspInstance {
const instance = new LspInstance({ const instance = new LspInstance({
command: process.execPath, command: process.execPath,
args: [fixtureServer], args: [fixtureServer],
@@ -39,7 +46,7 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
shutdownTimeoutMs: 200, shutdownTimeoutMs: 200,
killGraceMs: 200, killGraceMs: 200,
...overrides, ...overrides,
}) }, writer)
live.push(instance) live.push(instance)
return instance return instance
} }
@@ -200,18 +207,26 @@ describe('LspInstance query and abort', () => {
expect(instance.dead).toBe(true) expect(instance.dead).toBe(true)
}) })
it.skipIf(process.platform === 'win32')('terminates when stdin fails during the didOpen write', async () => { it('terminates when stdin fails during the didOpen write', async () => {
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose; const instance = makeInstance({}, {
// 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' }, {
shutdownTimeoutMs: 100, shutdownTimeoutMs: 100,
killGraceMs: 100, killGraceMs: 100,
}) }, failingWriter('textDocument/didOpen'))
await expect(run(instance, 'goToDefinition')).rejects.toThrow() await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true) 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 () => { it('rejects when the server lacks the operation capability', async () => {
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) 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/) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
@@ -225,11 +240,10 @@ describe('LspInstance query and abort', () => {
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/) 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({ const instance = makeInstance({
LSP_FAKE_DEF: 'null', LSP_FAKE_DEF: 'null',
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1', }, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations', kind: 'locations',
locations: [], locations: [],
@@ -240,6 +254,14 @@ describe('LspInstance query and abort', () => {
}) })
describe('LspInstance disposal', () => { 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 () => { it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log') const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({ const instance = makeInstance({
@@ -281,7 +303,7 @@ describe('LspInstance disposal', () => {
await expect(instance.dispose()).resolves.toBeUndefined() 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 marker = join(root, 'helper.pid')
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);' const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");' const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
@@ -298,6 +320,7 @@ describe('LspInstance disposal', () => {
await first await first
} finally { } finally {
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL') if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
await waitForProcessExit(helperPid)
} }
}) })
@@ -322,6 +345,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<void> {
const started = Date.now()
while (processAlive(pid)) {
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
await new Promise<void>(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. */ /** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> { async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now() const started = Date.now()

View File

@@ -122,9 +122,15 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('rejects a non-utf-16 position encoding at initialize', async () => { it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' }) 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/) 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() await ctx.fiber.dispose()
}) })
@@ -237,7 +243,7 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose() 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 // 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 // instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first. // failing once on the closed connection first.

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { delimiter, join } from 'node:path'
import { Context } from 'cordis' import { Context } from 'cordis'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local' 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', { await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp', command: 'fake-lsp',
args: [], args: [],
env: { PATH: `::${join(root, 'empty')}` }, env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
extensionToLanguage: { '.ts': 'typescript' }, extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/was not found on PATH/) }))).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose() await ctx.fiber.dispose()

View File

@@ -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. */ /** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */
export type ExampleMode = 'src' | 'lib' 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' export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE'
/** /**

View File

@@ -321,7 +321,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result) 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 let now = 0
const result = await setup({ const result = await setup({
contextWindow: 100, contextWindow: 100,
@@ -447,14 +447,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
{ inputTokens: 500, outputTokens: 8 }, { inputTokens: 500, outputTokens: 8 },
{ turn: 3, step: 1 }, { turn: 3, step: 1 },
) )
await tick() await vi.waitFor(() => {
expect(result.terminal.output).toContain('final live answer')
})
expect(result.terminal.output).toContain('Enter sends steering, Esc cancels') expect(result.terminal.output).toContain('Enter sends steering, Esc cancels')
expect(result.terminal.output).toContain('Steering') expect(result.terminal.output).toContain('Steering')
expect(result.terminal.output).toContain('user context') expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked') expect(result.terminal.output).toContain('Prompt blocked')
expect(result.terminal.output).toContain('Turn cancelled') expect(result.terminal.output).toContain('Turn cancelled')
expect(result.terminal.output).toContain('final live answer')
expect(result.terminal.progress).toContain(true) expect(result.terminal.progress).toContain(true)
result.session.append('assistant/chunk', { result.session.append('assistant/chunk', {

View File

@@ -17,7 +17,6 @@ type Mode =
| 'ci-snapshot' | 'ci-snapshot'
| 'ci-artifacts' | 'ci-artifacts'
| 'node-compat' | 'node-compat'
| 'pre-push'
| 'doc-sync' | 'doc-sync'
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped' type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
@@ -87,21 +86,20 @@ function parseMode(raw: string | undefined): Mode {
case 'ci-snapshot': case 'ci-snapshot':
case 'ci-artifacts': case 'ci-artifacts':
case 'node-compat': case 'node-compat':
case 'pre-push':
case 'doc-sync': case 'doc-sync':
return raw return raw
default: default:
throw new Error( 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 { function defaultConcurrency(selectedMode: Mode, total: number): ConcurrencyDefault {
const available = availableParallelism() 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. // 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 const modeLimit = localCap ? Math.min(4, available) : available
return { return {
workers: Math.min(total, modeLimit), 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', 'packages/session-persistence/session-persistence-jsonl/tests/zstd.compat.spec.ts',
], { label: 'JSONL Zstandard smoke' }), ], { 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' }),
prePushTestGate(),
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': case 'doc-sync':
return docSyncLeafGates() return docSyncLeafGates()
} }
@@ -294,23 +277,9 @@ function coverageGate(): Gate {
}) })
} }
// The pre-push runner overlaps this all-core vitest gate with sibling gates (build, snapshot,
// doc leaves). Bound its pool to half the cores by default (>=2 workers) so it does not
// oversubscribe them; DSH_TEST_MAX_WORKERS overrides it, mirroring coverageGate's
// DSH_COVERAGE_MAX_WORKERS. Pre-push only — CI runs the coverage gate — so the bound never
// touches CI timing. Failure mode and rationale in the parallel pre-push gates Agent Note
// (.agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md).
function prePushTestGate(): Gate {
const override = positiveIntArg('DSH_TEST_MAX_WORKERS', '--maxWorkers')
const workers = override.length > 0
? override
: [`--maxWorkers=${Math.max(2, Math.floor(availableParallelism() / 2))}`]
return pnpmExec('test', ['vitest', 'run', ...workers], { label: 'test' })
}
// The snapshot suite boots the example bins in `lib` mode (built artifact under plain Node, // 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 // plugins via real exports). CI pairs it with `build`, so it exercises what ships rather than
// than the tsx/source path dev uses. It therefore waits on `build`. // the tsx/source path dev uses and therefore waits on `build`.
function snapshotGate(): Gate { function snapshotGate(): Gate {
return pnpmScript('snapshot', 'test:snapshot', { return pnpmScript('snapshot', 'test:snapshot', {
env: { DSH_EXAMPLE_MODE: 'lib' }, env: { DSH_EXAMPLE_MODE: 'lib' },
@@ -335,30 +304,9 @@ function positiveIntArg(envName: string, flag: string): string[] {
return [`${flag}=${raw}`] return [`${flag}=${raw}`]
} }
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { function docSyncLeafGates(): Gate[] {
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
return [ return [
pnpmScript('knip', 'knip'), pnpmScript('doc-typecheck', 'doc-typecheck'),
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<string, string | undefined>
} = {}): Gate[] {
const docTypecheckOptions: Partial<Gate> = {}
if (options.docTypecheckNeeds !== undefined) docTypecheckOptions.needs = options.docTypecheckNeeds
if (options.docTypecheckEnv !== undefined) docTypecheckOptions.env = options.docTypecheckEnv
return [
pnpmScript('doc-typecheck', 'doc-typecheck', docTypecheckOptions),
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }), pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }), pnpmScript('cordis-api', 'verify-cordis-api', { label: 'cordis api' }),
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }), pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),

View File

@@ -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({ export default defineConfig({
// Native path resolution reads each package's nearest tsconfig, but only the root defines // 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 // 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/bin.ts',
'packages/*/*/src/worker.ts', 'packages/*/*/src/worker.ts',
...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`), ...windowsUnsupportedPackages.map(path => `${path}/src/**/*.ts`),
...windowsCoverageExclusions,
], ],
// 100% or it doesn't merge (docs/testing.md: excessive tests are welcome). // 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. // Per-file so a well-covered big file can't subsidize a bare one.