diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml index b8540c3319..faa51b3d21 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 587f2d26eee922e91c8797018964f983890eb8ec -2026-06-21-bounded-llm-request-recovery.zh.md: dfc5e3ab67b62042c37f05199ed39bf47ded0c5d +2026-06-21-bounded-llm-request-recovery.md: 013cf5d77b0cddc38af8bcf15f13142022cb3f75 +2026-06-21-bounded-llm-request-recovery.zh.md: 9b025eeed208d42b5ca14fdec33e45aade50f0d2 diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md index 587f2d26ee..013cf5d77b 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md @@ -74,9 +74,9 @@ Adapters perform one provider request per `stream()` call. The pi-ai adapter rem ### Bound stalled streams where they can be stopped -Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to the next valid `StreamChunk`; time a consumer spends between `next()` calls is not provider idle time. +Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with the five-minute prior-art default cited above. The interval is capped at Node's maximum timer delay so it cannot be clamped to one millisecond. It covers each outstanding iterator `next()` from demand to adapter-recognized provider activity; time a consumer spends between `next()` calls is not provider idle time. DeepSeek SSE comments count as transport activity but never become `StreamChunk` values or session-log events. -`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. +`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Out-of-band transport activity calls `pulse()` to rearm an outstanding demand without yielding a value. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer. Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract. diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md index dfc5e3ab67..9b025eeed2 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md @@ -74,9 +74,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在能够终止停滞流的位置施加边界 -每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到下一条有效 `StreamChunk` 到达为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。 +每个适配器都公开一个经过验证的 `streamIdleTimeoutMs` 配置字段,默认值采用上文引用的五分钟先例。该间隔不超过 Node 的最大定时器延迟,因此不会被钳制为 1 毫秒。它覆盖每个尚未完成的迭代器 `next()`:从消费方请求下一项开始,到适配器识别到提供方活动为止;消费方在两次 `next()` 调用之间花费的时间不属于提供方空闲时间。DeepSeek SSE(Server-Sent Events)注释计为传输活动,但绝不会成为 `StreamChunk` 值或会话日志事件。 -`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 +`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。带外传输活动会调用 `pulse()`,在不产生值的情况下为尚未完成的需求重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。 边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此约定。 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml index 24a047b540..8b3d66c3b0 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 63463a76a65743436d4e78479800c19e257a42de -2026-07-06-timeout-deadline-library.zh.md: c47e6d4e6ed0bec368fca8857e5bd4d7229b6b0e +2026-07-06-timeout-deadline-library.md: 1afb8e326356f012498eed778e9bab1b0be64ea5 +2026-07-06-timeout-deadline-library.zh.md: c6992ba5463122e4eb4ebd1d904d0c0906140353 diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md index 63463a76a6..1afb8e3263 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md @@ -57,10 +57,11 @@ export function deadline( export interface IdleWatchdog { readonly signal: AbortSignal next(iterator: AsyncIterator): Promise> + pulse(): void [Symbol.dispose](): void } -/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */ +/** Arm only while one iterator `next()` is outstanding; rearm on later demand or out-of-band activity. */ export function idleWatchdog( upstream: AbortSignal | undefined, timeoutMs: number, @@ -71,7 +72,7 @@ export function idleWatchdog( export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` fuses an upstream signal with a one-shot timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. `idleWatchdog` instead requires a positive finite interval, keeps one stable fused signal for the entire stream, and arms its timer only while one iterator `next()` is outstanding; resolution disarms it, later demand rearms it, concurrent demand fails, and disposal clears the active arm. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. +`deadline` fuses an upstream signal with a one-shot timer through `AbortSignal.any`, adds a typed `TimeoutReason`, and exposes disposable timer cleanup. Non-positive timeouts are an internal no-timeout sentinel for backend-owned background work; external hints pass through `clampTimeout` and must be positive and finite. Without a timer or upstream signal, the function returns a never-aborting signal with the same disposal shape. `idleWatchdog` instead requires a positive finite interval, keeps one stable fused signal for the entire stream, and arms its timer only while one iterator `next()` is outstanding; resolution disarms it, later demand rearms it, and `pulse()` rearms that same outstanding demand after out-of-band transport activity. A pulse outside outstanding demand or after disposal is a no-op; concurrent demand fails, and disposal clears the active arm. Providers translate timeout reasons into seam-specific results. `timeoutOf(signal, code)` scopes classification so an outer nested deadline is treated as upstream cancellation rather than the inner capability's timeout. ### The division of labor @@ -79,7 +80,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): |---|---| | Validate request hint and clamp default/max | `dsh-timeout` (`clampTimeout`) — pure arithmetic plus the shared positive-finite request contract | | Arm one-shot timer, abort on deadline, carry reason, fuse with upstream cancel | `dsh-timeout` (`deadline`) | -| Arm and rearm only around outstanding iterator demand | `dsh-timeout` (`idleWatchdog`) | +| Arm and rearm only around outstanding iterator demand, including out-of-band activity | `dsh-timeout` (`idleWatchdog`) | | Clear the timer | `dsh-timeout` (`[Symbol.dispose]` on either primitive) | | Classify the first abort reason after abort | `dsh-timeout` (`timeoutOf`) | | **Actually terminate the work** | the capability's implementation | @@ -92,7 +93,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - **web_fetch** — the tool stays validate-and-forward; the provider's hand-rolled controller + `setTimeout` + manual listener + `finally` + `signal.reason` recovery is replaced by provider-owned `deadline`/`timeoutOf`. A pre-aborted upstream signal still throws `WEB_ABORTED` up front; otherwise `fetch` runs against the fused `d.signal`, and `translateAbortOrNetwork` classifies a thrown error by the signal (`timeoutOf` → `WEB_FETCH_TIMEOUT`, else aborted → `WEB_ABORTED`, else network → `WEB_PROVIDER_ERROR`). The public error-code contract is unchanged, and `TimeoutReason` never crosses the web seam as the public error. - **bash** — `resolve()` clamps the request into an explicit spec. Foreground `run()` creates the deadline and passes its signal to process execution, whose existing abort listener performs the process-group kill. The executor classifies the first abort as timeout or cancellation. Background starts remain timeout-free and forward only upstream cancellation. -- **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`. +- **LLM adapters** — `dsh-llm-deepseek` and `dsh-llm-pi-ai` wrap actual transport iteration with `idleWatchdog`. The five-minute configured interval covers only outstanding provider demand, not time the downstream consumer spends between chunks. The direct DeepSeek adapter also pulses that outstanding demand when its SSE parser observes a comment, without yielding the comment as a `StreamChunk` or writing it to the session log. The pi-ai SDK does not expose comment activity to its adapter, so that path can rearm only when the SDK yields. The stable signal reaches `fetch` or the SDK for the whole call, so timeout closes the underlying request and maps to `TIMEOUT`, while an earlier caller abort maps to `ABORTED`. ## Consequences @@ -100,7 +101,7 @@ The signal only *notifies*; termination is always the listener's job, and the li - `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. This is the one deviation from the literal proposal shape (which passed `timeoutMs: 0` into `runBash`); an always-0 field read by nothing is dead weight under the per-file coverage gate. - web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`. - `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met). -- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate. +- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. Adapters that can observe out-of-band transport activity may pulse an outstanding demand; suppressed activity remains invisible to the watchdog. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate. Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; future ripgrep-backed fs discovery tools can consume the same provider-owned deadline shape once they exist; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job. diff --git a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md index c47e6d4e6e..c6992ba546 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.zh.md @@ -57,10 +57,11 @@ export function deadline( export interface IdleWatchdog { readonly signal: AbortSignal next(iterator: AsyncIterator): Promise> + pulse(): void [Symbol.dispose](): void } -/** Arm only while one iterator `next()` is outstanding, then rearm on later demand. */ +/** Arm only while one iterator `next()` is outstanding; rearm on later demand or out-of-band activity. */ export function idleWatchdog( upstream: AbortSignal | undefined, timeoutMs: number, @@ -71,7 +72,7 @@ export function idleWatchdog( export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): TimeoutReason | undefined ``` -`deadline` 通过 `AbortSignal.any` 将上游信号与一次性定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。`idleWatchdog` 则要求正有限的间隔,在整个流期间保持一个稳定的融合信号,并且只在一个迭代器 `next()` 尚未结算时启动定时器;结算会解除定时器,后续 demand 会重新启动,并发 demand 会失败,dispose 会清除当前 arm。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 +`deadline` 通过 `AbortSignal.any` 将上游信号与一次性定时器融合,附加一个类型化的 `TimeoutReason`,并暴露可 dispose(资源释放)的定时器清理。非正数超时是内部的「无超时」哨兵,用于后端拥有的后台任务;外部提示经过 `clampTimeout`,必须为正有限值。既无定时器也无上游信号时,函数返回一个永不中止的信号,具有相同的 disposal 形状。`idleWatchdog` 则要求正有限的间隔,在整个流期间保持一个稳定的融合信号,并且只在一个迭代器 `next()` 尚未结算时启动定时器;结算会解除定时器,后续 demand 会重新启动,带外传输活动发生后,`pulse()` 则会为同一个尚未结算的 demand 重新启动定时器。若没有尚未结算的 demand,或已经 dispose,pulse 不执行任何操作;并发 demand 会失败,dispose 会清除当前 arm。提供方将超时原因转译为 seam 特定的结果。`timeoutOf(signal, code)` 限定分类范围,使外层嵌套的 deadline 被视为上游取消而非内层能力自身的超时。 ### 职责划分 @@ -79,7 +80,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): |---|---| | 校验请求提示并钳位默认值/最大值 | `dsh-timeout`(`clampTimeout`):纯算术加共享的正有限请求约定 | | 启动一次性定时器、到期中止、携带 reason、与上游取消融合 | `dsh-timeout`(`deadline`) | -| 仅围绕未结算的迭代器 demand 启动和重启 | `dsh-timeout`(`idleWatchdog`) | +| 仅围绕未结算的迭代器 demand 启动和重启,带外活动也会触发重启 | `dsh-timeout`(`idleWatchdog`) | | 清除定时器 | `dsh-timeout`(任一原语的 `[Symbol.dispose]`) | | 中止后对首个 abort reason 进行分类 | `dsh-timeout`(`timeoutOf`) | | **实际终止工作** | 各能力的实现 | @@ -92,7 +93,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - **web_fetch**:工具层保持校验并转发;提供方手写的 controller + `setTimeout` + 手动监听器 + `finally` + `signal.reason` 恢复被替换为提供方自有的 `deadline`/`timeoutOf`。已预先中止的上游信号仍然立即抛出 `WEB_ABORTED`;否则 `fetch` 使用融合后的 `d.signal` 运行,`translateAbortOrNetwork` 根据信号分类抛出的错误(`timeoutOf` → `WEB_FETCH_TIMEOUT`,否则已中止 → `WEB_ABORTED`,否则网络错误 → `WEB_PROVIDER_ERROR`)。公开的错误码约定不变,`TimeoutReason` 永远不会作为公开错误跨越 web seam。 - **bash**:`resolve()` 将请求钳位为显式规格。前台 `run()` 创建 deadline 并将其信号传给进程执行,后者既有的 abort 监听器执行进程组 kill。执行器将首个 abort 分类为超时或取消。后台启动保持无超时,仅转发上游取消。 -- **LLM(大语言模型)适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 +- **LLM(大语言模型)适配器**:`dsh-llm-deepseek` 和 `dsh-llm-pi-ai` 用 `idleWatchdog` 包装实际的传输迭代。配置的五分钟间隔只覆盖尚未结算的提供方 demand,不包括下游消费方在分片之间花费的时间。DeepSeek 直连适配器还会在其 SSE(Server-Sent Events)解析器观察到注释时,对该项尚未结算的 demand 调用 `pulse()`;该注释既不会作为 `StreamChunk` 产出,也不会写入会话日志。pi-ai SDK 不会向其适配器暴露注释活动,因此该路径只能在 SDK 产出值时重新启动定时器。稳定信号在整个调用期间传给 `fetch` 或 SDK,因此超时会关闭底层请求并映射为 `TIMEOUT`,而更早的调用方中止映射为 `ABORTED`。 ## 后果 @@ -100,7 +101,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。这是与字面提案形状(向 `runBash` 传入 `timeoutMs: 0`)的唯一偏差;一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。 - web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。 - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 -- 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 +- 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;未来基于 ripgrep 的文件系统发现工具可以在实现后消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml index e8cdb21a40..3134a16824 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-11-quality-gates.md -2026-06-11-quality-gates.md: 60db7ba5cfa8184c0fcce764aa027f32a9b721ab -2026-06-11-quality-gates.zh.md: 10585977820ba8e4f4bc6e251d484dc657d0558c +2026-06-11-quality-gates.md: 83cba5c867f71471d421ab935e1f53b430b01c43 +2026-06-11-quality-gates.zh.md: 1162b3836424bd47a8d35bd754a7983ac2e9bebe diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.md index 60db7ba5cf..83cba5c867 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.md @@ -19,7 +19,7 @@ Every mechanically checkable AGENTS.md promise gets a command that exits non-zer - jscpd detects cross-file clones in package production TypeScript and repository scripts; narrow source-range exceptions document deliberately parallel implementations. - Per-file 100% coverage on `packages/*/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. - knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. -- lefthook pre-commit applies formatting-only ESLint fixes before Oxlint validation and native fixes, 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. +- lefthook pre-commit applies project-free Oxlint validation and [safe fixes with a bounded retry](2026-08-09-oxlint-only-fix-workflow.md), 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 diff --git a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md index 1058597782..1162b38364 100644 --- a/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md +++ b/.agents/notes/implemented/process/2026-06-11-quality-gates.zh.md @@ -19,7 +19,7 @@ Status: implemented - jscpd 检测包的生产 TypeScript 代码与仓库脚本中的跨文件克隆;窄范围的源码区间例外用于记录有意为之的并行实现。 - `packages/*/*/src` 下按文件 100% 覆盖率(v8);不可达的防御性守卫使用 `/* v8 ignore */ ` 并注明理由,而非删除。 - knip(死代码/依赖)、publint(包的正确性)、workspace 约束(workspace 规则:private、cordis peer+dev、统一版本、ESM),以及对构建出的包声明文件进行 NodeNext 消费方类型检查。 -- lefthook pre-commit 先应用仅用于格式化的 ESLint 修复,再执行 Oxlint 验证和原生修复,拒绝已暂存的空白问题并检查 vendor manifest(元数据清单);pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 +- lefthook pre-commit 执行不加载项目的 Oxlint 验证,并应用带[一次有界重试](2026-08-09-oxlint-only-fix-workflow.md)的安全修复,拒绝已暂存的空白问题并检查 vendor manifest(元数据清单);pre-push 运行增量类型检查。CI 在 Node 22.19/24/26 上运行完整矩阵,并对 Headless、TUI、ACP(Agent Client Protocol)、JSON-RPC、工作流和代码运行时入口路径执行已构建应用的冒烟测试。 ## 后果 diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml index 0121a65219..701e850d7f 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md -2026-07-22-fast-local-git-hooks.md: af8a9380fc3cbf4f672ca0b32faa7b187522cf11 -2026-07-22-fast-local-git-hooks.zh.md: 1b2c3bf42a65f35d96c266f5f3afb63f26039388 +2026-07-22-fast-local-git-hooks.md: c3d3579b2e91419fad64f0e64a10926d2362881e +2026-07-22-fast-local-git-hooks.zh.md: f85daa9c74a23987cc560f14c5e087fdfb3d7f5e diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md index af8a9380fc..c3d3579b2e 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.md @@ -12,7 +12,7 @@ Fast hooks still need to reject cheap, high-confidence defects before work leave ## Decision -[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a formatting-only ESLint config fixes and re-stages changed JavaScript and TypeScript, a project-free [Oxlint](2026-07-29-oxlint-linter.md) profile validates those files and applies native safe fixes, `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push runs `pnpm run typecheck`, which prepares the generated Host TypeRT contracts before the Client incremental typecheck. +[lefthook.yml](../../../../lefthook.yml) keeps both hooks as bounded local checkpoints. Pre-commit runs sequentially: a project-free [Oxlint](2026-07-29-oxlint-linter.md) profile validates changed JavaScript and TypeScript, applies safe fixes with a [bounded retry](2026-08-09-oxlint-only-fix-workflow.md), and re-stages them; `git diff --cached --check` rejects staged whitespace errors, and the vendor manifest guard checks vendored-source metadata. Pre-push runs `pnpm run typecheck`, which prepares the generated Host TypeRT contracts before the Client incremental typecheck. Pre-commit does not run type analysis, tests, snapshots, documentation checks, builds, hygiene, or the gate scheduler. Pre-push adds only the Host contract build required by repository typecheck. The opt-in `check:all` package script selects the `check-all` scheduler inventory in [scripts/run-gates.ts](../../../../scripts/run-gates.ts) independently of the hooks; it is a contributor command, not an agent instruction. @@ -27,10 +27,10 @@ This decision supersedes the local-hook portion of [Parallel pre-push gates](202 - **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 auto-fix workflow; the formatting-only pass and Lefthook's `stage_fixed` preserve it without making ESLint a repository correctness runner or duplicating `git add`. +- **Make staged lint check-only** — avoids hook-side mutation, but contributors intentionally retain the auto-fix workflow; Oxlint's bounded retry and Lefthook's `stage_fixed` preserve it without a separate formatter or duplicate `git add`. ## Consequences -Normal commits take the project-free staged formatter-and-lint critical path, and warm pushes take the prepared incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. 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. +Normal commits take the project-free staged Oxlint fix-and-validate critical path, and warm pushes take the prepared incremental typecheck critical path. Contributors retain a one-command opt-in rehearsal without widening the hook critical paths or the agent-required validation set. Hook latency is observed in development and PR evidence rather than enforced by a timing test whose result would depend on host load and cache state. Local publication no longer proves the exhaustive repository matrix. Agents must select relevant behavioral evidence, reviewers must evaluate whether that selection matches the diff, and CI supplies the comprehensive signal once per pushed revision. diff --git a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md index 1b2c3bf42a..f85daa9c74 100644 --- a/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-fast-local-git-hooks.zh.md @@ -12,7 +12,7 @@ agent(智能体)已经会运行能够覆盖自身改动的测试和检查, ## 决策 -[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:仅用于格式化的 ESLint 配置修复改动过的 JavaScript 和 TypeScript 文件并重新暂存,不加载项目的 [Oxlint](2026-07-29-oxlint-linter.md) 配置验证这些文件并应用原生安全修复,`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 运行 `pnpm run typecheck`;该命令会先准备好生成的 Host TypeRT 约定,再运行 Client 增量类型检查。 +[lefthook.yml](../../../../lefthook.yml) 将两个钩子都保留为有界的本地检查点。Pre-commit 按顺序运行:不加载项目的 [Oxlint](2026-07-29-oxlint-linter.md) 配置验证改动过的 JavaScript 和 TypeScript 文件,应用带[一次有界重试](2026-08-09-oxlint-only-fix-workflow.md)的安全修复,并重新暂存这些文件;`git diff --cached --check` 拒绝暂存 diff 中的空白错误,vendor manifest(元数据清单)守卫检查 vendor 源码元数据。Pre-push 运行 `pnpm run typecheck`;该命令会先准备好生成的 Host TypeRT 约定,再运行 Client 增量类型检查。 Pre-commit 不运行类型分析、测试、快照、文档检查、构建、`hygiene` 或门禁调度器。Pre-push 只增加仓库类型检查所需的 Host 约定构建。可选运行的 `check:all` 包脚本独立于这些钩子,从 [scripts/run-gates.ts](../../../../scripts/run-gates.ts) 中选择 `check-all` 调度器清单;它是贡献者命令,而非对 agent 的指令。 @@ -27,10 +27,10 @@ agent 检查待推送的 diff,并仅运行一次能够覆盖其行为的最小 - **保留全量 pre-push 套件并优化其调度器**——能够最早提供全面信号,但仍会重复 agent 已选取的证据和 CI,且无关失败仍会阻塞推送。 - **完全移除 pre-push**——推送成本最低,但会失去 TypeScript 在多个提交之后提供的快速跨文件保证。 - **在 pre-commit 中保留类型检查**——更早捕获类型错误,但每次中间提交都要承担开销,而不是只在推送时运行一次;暂存文件 lint 已经覆盖提交本身的语法与风格边界。 -- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留自动修复工作流;仅用于格式化的流程和 Lefthook 的 `stage_fixed` 会保留该工作流,而不会让 ESLint 成为仓库正确性检查运行器,也无需重复执行 `git add`。 +- **将暂存文件 lint 设为仅检查模式**——避免钩子修改文件,但贡献者有意保留自动修复工作流;Oxlint 的一次有界重试和 Lefthook 的 `stage_fixed` 会保留该工作流,无需单独的格式化器,也无需重复执行 `git add`。 ## 结果 -普通提交的关键路径是不加载项目的暂存文件格式化与 lint,缓存已预热时推送的关键路径是经过准备的增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 +普通提交的关键路径是不加载项目的暂存文件 Oxlint 修复与验证,缓存已预热时推送的关键路径是经过准备的增量类型检查。贡献者仍可选择用一条命令完整演练,且不会扩展钩子关键路径或 agent 必须运行的验证集合。钩子耗时只作为开发观察数据和 PR(Pull Request)证据记录,不设置会受主机负载与缓存状态影响的计时测试。 从本地推送成功不再能证明仓库完整矩阵已通过。agent 必须选择相关的行为证据,评审人必须判断该选择是否与 diff 相符,CI 则对每个推送版本提供一次全面信号。 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml index 9115979c19..851b2a3674 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-29-oxlint-linter.md -2026-07-29-oxlint-linter.md: 3e3de24be33b8f9461cea2967b0fa18f59ddb52a -2026-07-29-oxlint-linter.zh.md: dae8c7451bd3fbe9296508491aaa51f1118abe92 +2026-07-29-oxlint-linter.md: 15bdd4302745fdf3d1a2bd0d28fbc3d4b9fc50bb +2026-07-29-oxlint-linter.zh.md: 8e3d69cdce2cc05346149acbd49f9b0b75ac6098 diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md index 3e3de24be3..15bdd43027 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.md @@ -12,25 +12,25 @@ A faster runner cannot justify losing rules. The migration must preserve the str ## Decision -The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative type-aware repository lint configuration. The project-free [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) profile inherits its source rules but disables type analysis for the bounded pre-commit path. The `lint` package script, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts); `lint:fix` and lefthook first invoke the formatting-only [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs). The direct `eslint` and `@typescript-eslint/parser` development dependencies exist only for this parser-without-project formatting pass; their exact versions pin the tested parser/fixer pairing, and that config contains no correctness or type-aware rules. +The root [`.oxlintrc.json`](../../../../.oxlintrc.json) is the authoritative type-aware repository lint configuration. The project-free [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) profile inherits its source rules, disables type analysis for the bounded pre-commit path, and re-includes preserved TypeGraph fixtures that the type-aware backend cannot analyze. The `lint` and `lint:fix` package scripts, gate scheduler, CI, and lefthook invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts); the [Oxlint-only fix workflow](2026-08-09-oxlint-only-fix-workflow.md) owns multipass plugin fixes and supersedes the separate formatting fallback. `options.typeAware` enables `oxlint-tsgolint`. Its backend performs per-file TypeScript-project discovery: package sources use their package projects, host tests/examples/website use `tsconfig.host.json`, and client tests plus `scripts/client-bundle-purity.spec.ts` use `tsconfig.client.json`. The program-less root solution is never flattened. Oxlint's `--tsconfig` override affects import resolution but is ignored by type-aware linting, so this repository does not set it. The configuration explicitly carries the migrated strict-type-checked rules and repository overrides instead of enabling broad Oxlint categories whose contents may change. `typescript/no-unnecessary-condition` remains enabled from Oxlint's nursery set because it was an enforced repository rule before migration. -Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations but does not execute their fixers, so the formatting-only ESLint pass owns only the corresponding auto-fixes; an executable parity check keeps those fixable rule definitions aligned while `max-len` remains validation-only. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace, and unused directives remain warnings; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. +Oxlint's JavaScript-plugin compatibility layer runs `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` so the existing formatting and file-local duplicate-logic rules remain enforced. The compatibility layer reports `@stylistic` violations and executes their safe fixes; `max-len` remains validation-only. Owned-source suppressions use `oxlint-*` directives and the `typescript/*` namespace, and unused directives remain warnings; vendored sources keep their upstream directives because Oxlint excludes `vendor/**`. -CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit applies the formatting-only ESLint fixes, runs project-free Oxlint validation and native safe fixes, accepts selections containing only ignored files, and re-stages the result through lefthook. Public `lint` and CI retain the complete type-aware rules after preparing generated declarations. +CI does not restore or save a lint-result cache. `DSH_OXLINT_THREADS` makes the shared runner pass the same bound to Oxlint's `--threads` option and the type-aware backend's `GOMAXPROCS` environment variable; ordinary local runs use both defaults. Pre-commit runs project-free Oxlint validation and safe fixes with one bounded retry, accepts selections containing only ignored files, and re-stages the result through lefthook. Public `lint` and CI retain the complete type-aware rules after preparing generated declarations. ## Verification The migrated configuration reports the same clean owned-source baseline after resolving two analyzer differences: one redundant test assertion was removed, while one structural cast required by `tsc` carries a narrow Oxlint suppression. A one-time audit against the exact deleted ESLint configuration blob established source 88-to-88, examples 87-to-87, and tests 83-to-83 after the rule-name translations. The committed fingerprint pins those audited Oxlint profiles and the complete override shape; it neither executes the deleted configuration nor propagates later upstream preset changes. Evaluating `typescript-eslint@8.61.0` also confirms that `strictTypeChecked` did not enable `@typescript-eslint/no-empty-function`; the deleted tests-only `off` entry was inert. -Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin the staged profile's project-free inheritance, unused-suppression reporting, ignored-only staged selections, formatter/validator rule parity, and final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. +Executable contract tests require type-aware diagnostics from the package, host, and client projects; assert the client-only script's project; reject unmatched fallback analysis; and exercise the Stylistic, SonarJS, and nursery compatibility paths. They also pin the staged profile's project-free inheritance and TypeGraph coverage, unused-suppression reporting, ignored-only staged selections, the complete Stylistic rule set, and convergent final formatted bytes. Runner tests pin both worker controls, and typecheck confirms that migration-driven source edits preserve the TypeScript programs. ## Alternatives considered -**Run both linters repository-wide.** Every correctness rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. A repository-wide ESLint fallback would preserve the slower project-service setup and two correctness configurations without adding a check; the retained ESLint pass is deliberately limited to project-free staged formatting. +**Run both linters repository-wide.** Every correctness rule is available through Oxlint's native rules, nursery rule, or JavaScript-plugin compatibility layer. A repository-wide ESLint fallback would preserve the slower project-service setup and two correctness configurations without adding a check. -**Rely on compatibility-layer fixes.** The layer reports the established `@stylistic` rules but does not apply their fixes under either Oxlint fix mode. Keeping the narrow staged formatter preserves the contributor contract without broadening ESLint back into a repository linter. +**Use a separate formatter.** The migration retained a narrow ESLint pass because the compatibility-layer fixes were treated as unavailable. The [Oxlint-only fix workflow](2026-08-09-oxlint-only-fix-workflow.md) supersedes that part of the decision with one bounded retry after the pinned toolchain proved it could execute the same fixes. **Drop @stylistic or SonarJS rules that are not native.** This would remove dependencies but weaken the mechanical quality contract. The compatibility layer preserves those rules until native replacements can be evaluated as a separate decision. @@ -42,4 +42,4 @@ Local migration measurements reduced a clean type-aware lint run from about 61 s Type-aware diagnostics now come from the TypeScript Go analyzer bundled through `oxlint-tsgolint`, so edge-case inference can differ from typescript-eslint even when `tsc` accepts the same program. Lint and typecheck remain separate required evidence. -The JavaScript-plugin compatibility API, staged profile, and staged formatter are additional boundaries to maintain. Commits defer type-aware diagnostics to public lint and CI, pay one project-free ESLint startup before Oxlint, and avoid depending on generated declarations. The root development graph retains ESLint plus the TypeScript parser. Repository-wide validation, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. +The JavaScript-plugin compatibility API and staged profile are additional boundaries to maintain. Commits defer type-aware diagnostics to public lint and CI and avoid depending on generated declarations. Repository-wide validation, fixes, type-aware analysis, cache policy, worker control, and inline directives remain Oxlint-owned. diff --git a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md index dae8c7451b..8e3d69cdce 100644 --- a/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md +++ b/.agents/notes/implemented/process/2026-07-29-oxlint-linter.zh.md @@ -12,25 +12,25 @@ Status: implemented ## 决策 -根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库类型感知 lint 配置的权威来源。不加载项目的 [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) 配置继承其源码规则,但会为有界的 pre-commit 路径禁用类型分析。`lint` 包脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint;`lint:fix` 和 lefthook 会先调用仅用于格式化的 [`eslint.format.config.mjs`](../../../../eslint.format.config.mjs)。直接的 `eslint` 和 `@typescript-eslint/parser` 开发依赖仅用于这次不加载项目的格式化流程;其精确版本锁定经过测试的解析器与修复器配对,该配置不包含正确性规则或类型感知规则。 +根目录的 [`.oxlintrc.json`](../../../../.oxlintrc.json) 是仓库类型感知 lint 配置的权威来源。不加载项目的 [`.oxlintrc.staged.json`](../../../../.oxlintrc.staged.json) 配置继承其源码规则,为有界的 pre-commit 路径禁用类型分析,并重新纳入类型感知后端无法分析但需保留的 TypeGraph fixture(测试前置数据)。`lint` 与 `lint:fix` 包脚本、门禁调度器、CI 和 lefthook 通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint;[仅使用 Oxlint 的修复工作流](2026-08-09-oxlint-only-fix-workflow.md)负责多轮插件修复,并取代单独的格式化回退路径。 `options.typeAware` 启用 `oxlint-tsgolint`。其后端按文件发现 TypeScript 项目:包源码使用各自的包项目,host 测试、示例和网站使用 `tsconfig.host.json`,client 测试及 `scripts/client-bundle-purity.spec.ts` 使用 `tsconfig.client.json`。不含程序的根解决方案绝不会被扁平化。Oxlint 的 `--tsconfig` 覆盖项会影响导入解析,但类型感知 lint 会忽略它,因此本仓库不设置该选项。该配置显式载入迁移后的严格类型检查规则和仓库覆盖配置,而不启用内容可能发生变化的 Oxlint 宽泛类别。`typescript/no-unnecessary-condition` 仍从 Oxlint 的 nursery 规则集中启用,因为它在迁移前就是仓库强制执行的规则。 -Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规,但不会执行其修复器,因此仅用于格式化的 ESLint 流程只负责相应的自动修复;一项可执行检查确保这些可修复规则定义保持一致,而 `max-len` 仅用于验证。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间,未使用的指令仍作为警告报告;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 +Oxlint 的 JavaScript 插件兼容层运行 `@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs`,从而继续强制执行现有的格式和文件内重复逻辑规则。兼容层会报告 `@stylistic` 违规并执行其安全修复;`max-len` 仍仅用于验证。自有源码中的抑制指令使用 `oxlint-*` 指令和 `typescript/*` 命名空间,未使用的指令仍作为警告报告;vendor 源码保留其上游指令,因为 Oxlint 会排除 `vendor/**`。 -CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 应用仅用于格式化的 ESLint 修复,运行不加载项目的 Oxlint 验证并应用原生安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。公共 `lint` 和 CI 会先准备生成的声明,并保留完整的类型感知规则。 +CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行器将同一上限传给 Oxlint 的 `--threads` 选项和类型感知后端的 `GOMAXPROCS` 环境变量;普通本地运行对两者均采用默认值。Pre-commit 运行不加载项目的 Oxlint 验证,应用带一次有界重试的安全修复,接受仅含已忽略文件的文件选择,并通过 lefthook 重新暂存结果。公共 `lint` 和 CI 会先准备生成的声明,并保留完整的类型感知规则。 ## 验证 解决两处分析器差异后,迁移后的配置报告与迁移前一致的自有源码无问题基线:移除了一项冗余测试断言,而 `tsc` 要求的一处结构性类型转换使用了窄范围的 Oxlint 抑制指令。以已删除 ESLint 配置的精确 blob 为基准进行的一次性审核在完成规则名映射后确认:源码为 88 项对 88 项,示例为 87 项对 87 项,测试为 83 项对 83 项。已提交的指纹锁定这些经审核的 Oxlint 规则配置及完整的覆盖结构;它既不执行已删除的配置,也不纳入后续的上游预设变更。对 `typescript-eslint@8.61.0` 的评估还确认,`strictTypeChecked` 并未启用 `@typescript-eslint/no-empty-function`;已删除、仅用于测试的 `off` 条目不起作用。 -可执行约定测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定暂存配置不加载项目的继承行为、未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、格式化器与验证器之间的规则一致性,以及最终格式化后的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 +可执行约定测试要求包、host 和 client 项目产生类型感知诊断,断言 client 专用脚本所用的项目,拒绝未匹配的回退分析,并检验 Stylistic、SonarJS 和 nursery 兼容路径。它们还锁定暂存配置不加载项目的继承行为与 TypeGraph fixture 覆盖、未使用抑制指令的报告行为、仅选择已忽略暂存文件的情况、完整的 Stylistic 规则集,以及收敛后最终格式化的字节。运行器测试锁定两项工作线程控制,类型检查则确认迁移引发的源码改动没有破坏 TypeScript 程序。 ## 考虑过的替代方案 -**在全仓库范围内同时运行两个 linter。** 所有正确性规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。在全仓库范围启用 ESLint 回退会保留较慢的项目服务初始化和两套正确性配置,却不会增加任何检查;保留的 ESLint 流程被刻意限制为不加载项目的暂存文件格式化。 +**在全仓库范围内同时运行两个 linter。** 所有正确性规则均可通过 Oxlint 原生规则、nursery 规则或 JavaScript 插件兼容层获得。在全仓库范围启用 ESLint 回退会保留较慢的项目服务初始化和两套正确性配置,却不会增加任何检查。 -**依赖兼容层修复。** 兼容层会报告既有的 `@stylistic` 规则,但在 Oxlint 的两种修复模式下都不会应用这些规则的修复。保留窄范围的暂存文件格式化器,可以在不将 ESLint 扩张回仓库 linter 的情况下维持贡献者约定。 +**使用单独的格式化器。** 迁移保留了窄范围的 ESLint 流程,因为当时认为兼容层无法执行修复。锁定版本的工具链证明能够执行相同修复后,[仅使用 Oxlint 的修复工作流](2026-08-09-oxlint-only-fix-workflow.md)以一次有界重试取代了该部分决策。 **移除尚无原生实现的 @stylistic 或 SonarJS 规则。** 这会移除依赖,但也会削弱机械质量约定。兼容层会保留这些规则,直到能够通过单独决策评估原生替代规则。 @@ -42,4 +42,4 @@ CI 不恢复或保存 lint 结果缓存。`DSH_OXLINT_THREADS` 使共享运行 类型感知诊断现在来自通过 `oxlint-tsgolint` 捆绑的 TypeScript Go 分析器,因此即使 `tsc` 接受同一程序,边界场景下的类型推断也可能与 typescript-eslint 不同。lint 与类型检查仍是两项相互独立的必要证据。 -JavaScript 插件兼容 API、暂存配置和暂存文件格式化器是需要维护的额外边界。每次提交把类型感知诊断留给公共 lint 和 CI,在 Oxlint 之前启动一次不加载项目的 ESLint,并避免依赖生成的声明。根目录开发依赖图仍保留 ESLint 和 TypeScript 解析器。全仓库验证、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 +JavaScript 插件兼容 API 和暂存配置是需要维护的额外边界。每次提交把类型感知诊断留给公共 lint 和 CI,并避免依赖生成的声明。全仓库验证、修复、类型感知分析、缓存政策、工作线程控制和内联指令仍由 Oxlint 负责。 diff --git a/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.i18n.yaml new file mode 100644 index 0000000000..824c74bd7f --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.md +2026-08-09-oxlint-only-fix-workflow.md: bc38812db101499d980c38cb6c1dc5cba32e5cd6 +2026-08-09-oxlint-only-fix-workflow.zh.md: 91674837dd7c0c2da8d5b6214082dc7a2f2f0fac diff --git a/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.md b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.md new file mode 100644 index 0000000000..bc38812db1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.md @@ -0,0 +1,37 @@ +# Agent Note: Oxlint-only fix workflow + +Status: implemented + +English | [中文](2026-08-09-oxlint-only-fix-workflow.zh.md) + +## Problem + +The [repository linter migration](2026-07-29-oxlint-linter.md) retained a formatting-only ESLint invocation because Oxlint's JavaScript-plugin bridge was treated as validation-only. The pinned Oxlint toolchain executes the safe fixers supplied by `@stylistic/eslint-plugin`, so the separate formatter duplicates a configuration boundary, command startup, and direct `eslint` plus `@typescript-eslint/parser` dependencies. + +A single Oxlint invocation is not an equivalent replacement. Overlapping plugin fixes can apply one change while leaving a newly exposed diagnostic; the repository's `semi` and `object-curly-spacing` fixture requires a second pass before it is clean. The workflow must retry that case without printing obsolete first-pass diagnostics. + +## Decision + +All repository lint and fix workflows invoke Oxlint through [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts). Normal validation remains one process with inherited output. An invocation containing `--fix`, `--fix-suggestions`, or `--fix-dangerously` captures the first Oxlint result; success emits its stdout and stderr on their original channels, while a completed non-zero run discards its potentially obsolete diagnostics and runs the same command once more with inherited output. The runner re-raises a child signal instead of retrying or converting it to an exit code, and the second process completion is final. + +The `lint:fix` package script and staged lefthook job use that runner directly. The type-aware root profile still ignores preserved TypeGraph fixture shapes that `oxlint-tsgolint` cannot analyze; the project-free staged profile re-includes that directory, carries its intentional `any` and quote exceptions, and applies its style fixes before the full type-aware fix pass. The formatting-only ESLint configuration and the direct `eslint` and `@typescript-eslint/parser` development dependencies are absent. `@stylistic/eslint-plugin` and `eslint-plugin-sonarjs` remain Oxlint JavaScript plugins because they preserve enforced rules; pnpm still installs ESLint as their declared peer, but no repository configuration or workflow invokes it. + +## Verification + +The executable lint contract drives a deliberately overlapping style violation through the repository runner and requires a successful exit plus exact final bytes. The same contract pins the complete Stylistic rule set, project-free TypeGraph fixture coverage, the package scripts, the staged hook command, the deleted formatter configuration, and the absence of direct ESLint parser and runner dependencies. Existing executable probes continue to cover the Stylistic and SonarJS compatibility plugins, project-free staged validation, and type-aware project discovery. + +## Alternatives considered + +**Keep the formatting-only ESLint pass.** This preserves ESLint's built-in multipass behavior but retains a second runner, a duplicated formatting config, and direct dependencies after Oxlint can execute the same plugin fixes. + +**Run Oxlint once with `--fix`.** This is simpler, but overlapping safe fixes can leave the command partially formatted and non-zero even though another identical pass completes it. + +**Adopt Oxfmt.** A formatter migration changes the repository's output contract and would create an unrelated formatting diff. It remains a separate decision from removing the redundant ESLint execution path. + +**Remove the JavaScript compatibility plugins.** This would eliminate their ESLint peer graph but would also drop the enforced Stylistic and SonarJS rules. Dependency-tree purity does not justify weakening the quality contract. + +## Consequences + +Contributors, package scripts, hooks, and CI have one lint runner and one rule configuration. The staged profile repeats the root ignore list so it can re-include formatter-only fixtures without exposing vendor or generated files. A completed non-zero fix invocation always pays for one retry, including a stable unfixable error. The first pass buffers each output stream up to 64 MiB so obsolete diagnostics are not printed when the retry succeeds; process creation and capture failures, including that limit being exceeded, surface immediately without a retry. + +The dependency lock can still contain ESLint through plugin peer resolution. Removing that transitive package requires native replacements or a formatter decision that also replaces the compatibility plugins; it is not part of the workflow simplification. diff --git a/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.zh.md b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.zh.md new file mode 100644 index 0000000000..91674837dd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-09-oxlint-only-fix-workflow.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 仅使用 Oxlint 的修复工作流 + +Status: implemented + +[English](2026-08-09-oxlint-only-fix-workflow.md) | 中文 + +## 问题 + +[仓库 linter 迁移](2026-07-29-oxlint-linter.md)保留了一次仅用于格式化的 ESLint 调用,因为当时认为 Oxlint 的 JavaScript 插件桥接层只能用于校验。固定版本的 Oxlint 工具链能够执行 `@stylistic/eslint-plugin` 提供的安全修复,因此单独的格式化器重复引入了配置边界、命令启动过程,以及对 `eslint` 和 `@typescript-eslint/parser` 的直接依赖。 + +单次调用 Oxlint 并不能实现等价替代。相互重叠的插件修复可能先应用一项变更,却留下由此新暴露的诊断;仓库中包含 `semi` 和 `object-curly-spacing` 违规的 fixture(测试前置数据)需要运行第二轮才能完全修复。工作流必须重试这种情况,同时不得打印第一轮中已经失效的诊断。 + +## 决策 + +仓库的所有 lint 与修复工作流都通过 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) 调用 Oxlint。普通校验仍由单个进程执行,并直接继承输出。包含 `--fix`、`--fix-suggestions` 或 `--fix-dangerously` 的调用会捕获第一次 Oxlint 的运行结果;如果成功,就通过原有通道输出其 stdout 和 stderr;如果进程正常结束但状态非零,则丢弃其中可能已经失效的诊断,随后以继承输出的方式再运行一次相同命令。子进程被信号终止时,运行器会重新触发该信号,而不会重试或将其转换为退出码;第二个进程的结束结果作为最终结果。 + +`lint:fix` 包脚本和处理暂存文件的 lefthook 作业直接使用该运行器。类型感知的根配置仍会忽略 `oxlint-tsgolint` 无法分析、需要保留原始形态的 TypeGraph fixture;不加载项目的暂存配置会重新纳入该目录,保留其中有意设置的 `any` 与引号规则例外,并在完整的类型感知修复轮次之前应用其样式修复。仓库中不存在仅用于格式化的 ESLint 配置,也不直接依赖 `eslint` 和 `@typescript-eslint/parser` 这两个开发依赖。`@stylistic/eslint-plugin` 和 `eslint-plugin-sonarjs` 仍作为 Oxlint 的 JavaScript 插件保留,因为它们承载了已强制执行的规则;pnpm 仍会将 ESLint 作为这些插件声明的对等依赖(peer dependency)进行安装,但仓库中的配置和工作流均不会调用它。 + +## 验证 + +可执行 lint 约定将一项特意构造、修复相互重叠的样式违规交给仓库运行器处理,并要求运行器成功退出且最终字节完全一致。同一约定还会固定完整的 Stylistic 规则集、不加载项目时对 TypeGraph fixture 的覆盖、包脚本、暂存文件钩子命令、已删除的格式化器配置,以及 ESLint 解析器和运行器不存在直接依赖这一事实。现有的可执行探针继续覆盖 Stylistic 和 SonarJS 兼容插件、不加载项目的暂存文件校验,以及类型感知的项目发现。 + +## 考虑过的替代方案 + +**保留仅用于格式化的 ESLint 轮次。** 这会保留 ESLint 内置的多轮修复行为,但在 Oxlint 已能执行相同插件修复的情况下,仍需维护第二个运行器、重复的格式化配置和直接依赖。 + +**仅运行一次带 `--fix` 的 Oxlint。** 这样更简单,但相互重叠的安全修复可能使命令只完成部分格式化并以非零状态退出,即使再次运行同一命令就能完成修复。 + +**采用 Oxfmt。** 迁移格式化器会改变仓库的输出约定,并产生无关的格式化 diff。它与移除冗余 ESLint 执行路径是两项独立决策。 + +**移除 JavaScript 兼容插件。** 这会消除这些插件引入的 ESLint 对等依赖图,但也会移除强制执行的 Stylistic 和 SonarJS 规则。追求依赖树纯净不能成为削弱质量约定的理由。 + +## 结果 + +贡献者、包脚本、钩子和 CI 统一使用一个 lint 运行器和一份规则配置。暂存配置会重复根配置的忽略清单,以便重新纳入仅用于格式化的 fixture,同时避免将 vendor 或生成文件纳入检查。修复进程正常结束但状态非零时,始终会额外执行一次重试;稳定存在且无法修复的错误也不例外。第一轮的每条输出流最多缓冲 64 MiB,因此重试成功时不会打印已经失效的诊断;进程创建或输出捕获失败(包括超出该上限)时会立即报告错误,不会重试。 + +依赖锁中仍可能因插件的对等依赖解析而包含 ESLint。要移除这个传递依赖,需要使用原生替代方案,或另行决定采用能够同时取代兼容插件的格式化器;这不属于本次工作流简化的范围。 diff --git a/.oxlintrc.json b/.oxlintrc.json index ed470ecd70..70f53fd4cd 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -298,6 +298,24 @@ "jsPlugins": [ "@stylistic/eslint-plugin" ] + }, + { + // The project-free staged profile re-includes this syntax-coverage fixture. + "files": [ + "packages/typert/generator/tests/fixtures/type-model/**/*.{ts,tsx}" + ], + "rules": { + "typescript/no-explicit-any": "off" + } + }, + { + // TypeGraph coverage must retain source-authored syntax that the normal quote rule forbids. + "files": [ + "packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts" + ], + "rules": { + "@stylistic/quotes": "off" + } } ] } diff --git a/.oxlintrc.staged.json b/.oxlintrc.staged.json index db79a933fa..ca693f67ae 100644 --- a/.oxlintrc.staged.json +++ b/.oxlintrc.staged.json @@ -3,5 +3,20 @@ "extends": ["./.oxlintrc.json"], "options": { "typeAware": false - } + }, + "ignorePatterns": [ + "**/lib/**", + "**/node_modules/**", + "**/.sessions/**", + ".claude/**", + "**/.doc-typecheck-*/**", + "**/.node-next-types-*/**", + "website/.generated/**", + "vendor/**", + "native/**", + "**/*.js", + "**/*.mjs", + "**/*.config.ts", + "packages/client/tsdown.client.ts" + ] } diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 48f04c9799..55c753b45d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -139,7 +139,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | -| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | | [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | @@ -148,7 +147,6 @@ External packages **directly declared** only by repository tooling, test infrast | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | | [`debug`](https://github.com/debug-js/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | -| [`eslint`](https://github.com/eslint/eslint) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 5e2895c8ba..84cff8fa96 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: f7084403b30e8a6d12c4ef2b886f8fb61ed39b26 -development.zh.md: 56df93beddbb128cac8e0bcf2adaa9dcfb72c751 +development.md: 8b6c148d87fdd6288695b0029b9dfcc0139d2144 +development.zh.md: 55ca013cc0dd901500e340a7447057de27665f61 diff --git a/docs/development.md b/docs/development.md index f7084403b3..8b6c148d87 100644 --- a/docs/development.md +++ b/docs/development.md @@ -106,7 +106,7 @@ The installer probes the exact Node/tsx driver entrypoint before publishing its lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` verifies staged pairing records against the staged owner blobs, applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit. - `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check. diff --git a/docs/development.zh.md b/docs/development.zh.md index 56df93bedd..55ca013cc0 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -106,7 +106,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查; - `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。 diff --git a/eslint.format.config.mjs b/eslint.format.config.mjs deleted file mode 100644 index 5681688e81..0000000000 --- a/eslint.format.config.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import stylistic from '@stylistic/eslint-plugin' -import parser from '@typescript-eslint/parser' - -// Oxlint's JavaScript-plugin compatibility layer reports these rules but does -// not execute their fixers. Keep this config formatting-only: Oxlint remains -// the authoritative repository linter after this pass applies safe fixes. -export default [ - { - ignores: [ - '**/lib/**', - '**/node_modules/**', - '**/.sessions/**', - '.claude/**', - '**/.doc-typecheck-*/**', - '**/.node-next-types-*/**', - // Do not mirror Oxlint's contract-fixture ignore: those files must reach this formatter. - 'website/.generated/**', - 'vendor/**', - 'native/**', - '**/*.js', - '**/*.mjs', - '**/*.config.ts', - 'packages/client/tsdown.client.ts', - ], - }, - { - files: ['**/*.{ts,tsx,mts,cts}'], - languageOptions: { - parser, - parserOptions: { - sourceType: 'module', - }, - }, - plugins: { - '@stylistic': stylistic, - }, - rules: { - '@stylistic/indent': ['error', 2], - '@stylistic/semi': ['error', 'never'], - '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], - '@stylistic/comma-dangle': ['error', 'always-multiline'], - '@stylistic/eol-last': ['error', 'always'], - '@stylistic/no-trailing-spaces': 'error', - '@stylistic/object-curly-spacing': ['error', 'always'], - '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], - '@stylistic/member-delimiter-style': ['error', { - multiline: { delimiter: 'none' }, - singleline: { delimiter: 'semi', requireLast: false }, - }], - }, - }, - { - // TypeGraph coverage must retain source-authored syntax that the normal quote rule forbids. - files: ['packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'], - rules: { - '@stylistic/quotes': 'off', - }, - }, -] diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index 274a8804ee..930363cf37 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -7,6 +7,7 @@ config: baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL thinking: disabled + streamIdleTimeoutMs: 150 - id: agent-spine config: agents: diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index ff00d42b6b..e4fd9873aa 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -83,12 +83,21 @@ async function deepseekDefaultsServer(): Promise { request.on('end', () => { requests.push(JSON.parse(body) as JsonObject) response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.end([ - 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) + let keepAlives = 3 + const write = (): void => { + if (keepAlives-- > 0) { + response.write(': keep-alive\n\n') + setTimeout(write, 60) + return + } + response.end([ + 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', + 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', + 'data: [DONE]', + '', + ].join('\n\n')) + } + setTimeout(write, 60) }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -504,7 +513,7 @@ describe('headless stream-json snapshots', () => { `) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => { + it('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { const server = await deepseekDefaultsServer() try { const result = await runLoaderSmoke({ diff --git a/lefthook.yml b/lefthook.yml index 0e78d16252..0ea7f4e537 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -8,13 +8,6 @@ pre-commit: glob: '*.i18n.yaml' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} - - name: format (staged) - glob: '*.{ts,tsx,mts,cts,mjs}' - exclude: - - 'vendor/*/src/**' - run: node_modules/.bin/eslint --config eslint.format.config.mjs --fix --no-warn-ignored {staged_files} - stage_fixed: true - - name: lint (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: diff --git a/package.json b/package.json index 864814ffe6..d417354739 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lint": "npm run build:lib:host && npm run lint:contracts-ready", "lint:contracts-ready": "tsx scripts/run-oxlint.ts .", "lint:fix": "npm run build:lib:host && npm run lint:fix:contracts-ready", - "lint:fix:contracts-ready": "eslint --config eslint.format.config.mjs --fix . && tsx scripts/run-oxlint.ts . --fix", + "lint:fix:contracts-ready": "tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix", "duplication": "jscpd --config .jscpd.json packages scripts", "test": "vitest run", "test:coverage": "vitest run --coverage", @@ -139,10 +139,8 @@ "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", "@types/spdx-expression-parse": "^4.0.0", - "@typescript-eslint/parser": "8.61.0", "@vitest/coverage-v8": "^4.1.8", "@yarnpkg/cli-dist": "4.17.1", - "eslint": "10.5.0", "eslint-plugin-sonarjs": "^4.1.0", "execa": "^10.0.0", "fast-check": "^4.8.0", diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index e784f2c28b..23b6cf6305 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md -README.md: 6ad674ebdf8da4fd927a9499e80e06462d3c0dfb -README.zh.md: ca51a6f56ab2fa3bf47ca4f400a1474440bb39a4 +README.md: a21f9f0464e9d43d2091bd446eb123d4d0990c3d +README.zh.md: 2c45f2144694785590b339642cb62b62a1aa4198 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 6ad674ebdf..a21f9f0464 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -45,7 +45,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und `thinking: disabled` is a deployment lock that publishes only `off` with `off` as its default. Omitting `reasoningEffort` or configuring it as `off` is valid; configuring `high` or `max` fails plugin loading, and a direct per-request attempt to enable thinking fails before network I/O. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. -`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. +`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. DeepSeek SSE comments rearm an outstanding read as transport activity but never become `StreamChunk` values or session-log events. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; it registers the configured policy as provider metadata, and `dsh-llm-retry` separately executes it at durable agent-step boundaries. ## Dynamic configuration (settings + credentials) diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index ca51a6f56a..2c45f21446 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -45,7 +45,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: `thinking: disabled` 是部署锁定:它只公布 `off`,并以 `off` 为默认值。省略 `reasoningEffort` 或将其配置为 `off` 均有效;配置 `high` 或 `max` 会使插件加载失败,直接按请求启用思考也会在网络 I/O 前失败。携带 `GenerateOptions.purpose: 'session-title'` 的请求也会强制禁用思考并省略已解析的推理强度,将有界输出保留给可见标题文本,不改变会话或压缩(compaction)默认值。 -`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 +`streamIdleTimeoutMs` 会限制每次未完成提供方读取,包括初始 `fetch`,但不计入消费方在分片间花费的时间。DeepSeek SSE 注释会作为传输活动使尚未完成的读取重新布防,但绝不会成为 `StreamChunk` 值或会话日志事件。同一个稳定的 abort 信号会在整个调用期间传递给请求与 body reader;过期会停止传输并抛出 `LlmError('TIMEOUT')`,较早的调用方 abort 则抛出 `LlmError('ABORTED')`。适配器每次 `stream()` 调用恰好发起一次提供方请求;它把已配置策略注册为提供方元数据,再由 `dsh-llm-retry` 在持久化的 agent(智能体)步骤边界单独执行该策略。 ## 动态配置(settings + credentials) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 1c3306472e..1d284f7ade 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -216,7 +216,13 @@ export class DeepSeekAdapter extends LlmAdapter { ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]) using watchdog = idleWatchdog(upstream, connection.streamIdleTimeoutMs, STREAM_IDLE_TIMEOUT_CODE) - const iterator = this.request(options, watchdog.signal, connection, apiKey)[Symbol.asyncIterator]() + const iterator = this.request( + options, + watchdog.signal, + connection, + apiKey, + () => { watchdog.pulse() }, + )[Symbol.asyncIterator]() let exhausted = false try { while (true) { @@ -257,6 +263,7 @@ export class DeepSeekAdapter extends LlmAdapter { signal: AbortSignal, connection: DeepSeekConnectionOptions, apiKey: string, + onComment: () => void, ): AsyncIterable { const body = serializeRequest(options, connection.defaults) // Prepared outside the try so the TRANSPORT label below covers exactly the @@ -322,6 +329,6 @@ export class DeepSeekAdapter extends LlmAdapter { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') } - yield* translate(parseSse(response.body)) + yield* translate(parseSse(response.body, onComment)) } } diff --git a/packages/llm/llm-deepseek/src/sse.ts b/packages/llm/llm-deepseek/src/sse.ts index a8807a857c..9126a18940 100644 --- a/packages/llm/llm-deepseek/src/sse.ts +++ b/packages/llm/llm-deepseek/src/sse.ts @@ -1,11 +1,12 @@ /** * Decode an SSE byte stream into event `data` payloads. Framing — chunk * reassembly, UTF-8/CRLF/BOM handling, comment and non-data field skipping, - * multi-`data:` joining — is `eventsource-parser`'s; this module keeps only - * the DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns - * final flushing, and EOF before it raises {@link LlmError}. Framing is - * spec-strict: an event dispatches only on its blank-line terminator, so an - * unterminated tail at EOF is truncation, not a flushable payload. + * multi-`data:` joining — is `eventsource-parser`'s. Comments are reported + * only through an optional transport-activity callback. This module keeps the + * DeepSeek protocol: the literal `[DONE]` is yielded so the caller owns final + * flushing, and EOF before it raises {@link LlmError}. Framing is spec-strict: + * an event dispatches only on its blank-line terminator, so an unterminated + * tail at EOF is truncation, not a flushable payload. * * @module dsh-llm-deepseek/sse */ @@ -21,12 +22,16 @@ export const DONE = '[DONE]' * value and returns; throws `LlmError('STREAM_CLOSED')` when the stream ends * without it (truncated response — the model call cannot be trusted). * @param stream - raw SSE bytes; reads may split anywhere, including mid-UTF-8 sequence. + * @param onComment - optional transport-activity callback; comments never enter the yielded payload stream. * @returns each event's data payload in arrival order, the `[DONE]` sentinel last. */ -export async function* parseSse(stream: ReadableStream): AsyncGenerator { +export async function* parseSse( + stream: ReadableStream, + onComment?: (comment: string) => void, +): AsyncGenerator { const events = stream .pipeThrough(new TextDecoderStream()) - .pipeThrough(new EventSourceParserStream()) + .pipeThrough(new EventSourceParserStream({ onComment })) for await (const { data } of events) { yield data if (data === DONE) return diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index dbf0eb83b4..99e2ce6a8c 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -548,6 +548,40 @@ describe('DeepSeekAdapter against a mock server', () => { fetchSpy.mockRestore() } }) + + it('keeps an idle provider read alive through SSE comments', async () => { + vi.useFakeTimers() + const encoder = new TextEncoder() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(() => { + const body = new ReadableStream({ + start(controller) { + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 75) + setTimeout(() => { controller.enqueue(encoder.encode(': keep-alive\n\n')) }, 150) + setTimeout(() => { + controller.enqueue(encoder.encode(textEvents.map(event => `data: ${event}\n\n`).join(''))) + controller.close() + }, 225) + }, + }) + return Promise.resolve(new Response(body, { status: 200 })) + }) + const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 }) + try { + const chunks: string[] = [] + const drain = (async () => { + for await (const chunk of adapter.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + chunks.push(chunk.type) + } + })() + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await vi.advanceTimersByTimeAsync(75) + await expect(drain).resolves.toBeUndefined() + expect(chunks).toEqual(['block-start', 'text-delta', 'block-end', 'usage', 'finish']) + } finally { + fetchSpy.mockRestore() + } + }) }) describe('plugin registration and config', () => { diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 7ebb494a4d..67160d8079 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -31,6 +31,16 @@ describe('parseSse', () => { expect(events).toEqual(['{"a":1}', DONE]) }) + it('reports comments out of band without yielding them', async () => { + const comments: string[] = [] + const events = await collect(parseSse( + bytes(': keep-alive\n\ndata: {"a":1}\n\ndata: [DONE]\n\n'), + (comment) => { comments.push(comment) }, + )) + expect(comments).toEqual(['keep-alive']) + expect(events).toEqual(['{"a":1}', DONE]) + }) + it('stops yielding after DONE even when more data follows', async () => { const events = await collect(parseSse(bytes('data: [DONE]\n\ndata: {"late":1}\n\n'))) expect(events).toEqual([DONE]) diff --git a/packages/util/timeout/README.i18n.yaml b/packages/util/timeout/README.i18n.yaml index de4a85e9c9..c1ebb419ca 100644 --- a/packages/util/timeout/README.i18n.yaml +++ b/packages/util/timeout/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/timeout/README.md -README.md: 0ff5550ef7ea6b8315a6a529a4b8b503162a8f12 -README.zh.md: 79d7ee674209b0324ae9428c1a04fda8a2547db5 +README.md: e0d3595e7771fd19c808e89d440e69bca40da286 +README.zh.md: 99b7f281c2af7746fc2a105ee08df870fd3a9583 diff --git a/packages/util/timeout/README.md b/packages/util/timeout/README.md index 0ff5550ef7..e0d3595e77 100644 --- a/packages/util/timeout/README.md +++ b/packages/util/timeout/README.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | Validate the caller's optional positive-finite hint, fill from `def`, cap at `max`. Throws (with `name`) on a non-positive/non-finite hint. | | `deadline(upstream, timeoutMs, code)` | Fuse `upstream` cancellation with a timeout into one `AbortSignal` (`AbortSignal.any`); the timeout carries a `TimeoutReason`. `[Symbol.dispose]` clears the timer. | -| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand rearms; disposal clears; concurrent demand rejects. | +| `idleWatchdog(upstream, timeoutMs, code)` | Keep one stable fused signal and arm only while its guarded async-iterator `next()` is outstanding. Resolution disarms; later demand or `pulse()` activity rearms; disposal clears; concurrent demand rejects. | | `MAX_TIMER_DELAY_MS` | Largest delay Node schedules without clamping it to one millisecond (`2_147_483_647`). Timer-owning config must not exceed it. | | `timeoutOf(signal \| { reason }, code?)` | Recover the `TimeoutReason` from an aborted signal/error, else `undefined` — the timeout-vs-cancel classifier. Pass `code` to match only THIS deadline's timer (see nesting below). | | `TimeoutReason` | The internal reason (`code` + `timeoutMs`) stamped on a timeout abort. Not a public error — providers translate it into their own error/field. | @@ -48,7 +48,7 @@ The signal only *notifies* — the caller MUST attach its own termination (`d.si Pass your own `code` to `timeoutOf` so classification composes under nesting. When `upstream` is itself a deadline signal, `AbortSignal.any` preserves its `TimeoutReason` if that timer fires first. Scoping to your code makes a foreign timeout read as an ordinary upstream cancel instead of claiming that the local timer expired. -For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. +For a streamed transport, create one `idleWatchdog`, pass its stable `signal` into the transport, and call `watchdog.next(iterator)` for each provider read. Call `watchdog.pulse()` when transport activity does not yield an iterator value. The interval must be positive, finite, and no greater than `MAX_TIMER_DELAY_MS`; Node otherwise clamps it to one millisecond. It measures only outstanding demand, so no timer runs while downstream code renders or otherwise waits before asking for the next chunk. The primitive still only notifies, so the transport must observe the stable signal; the DeepSeek and pi-ai adapters prove that timeout closes their real response body or SDK request. ## What does NOT get a timeout diff --git a/packages/util/timeout/README.zh.md b/packages/util/timeout/README.zh.md index 79d7ee6742..99b7f281c2 100644 --- a/packages/util/timeout/README.zh.md +++ b/packages/util/timeout/README.zh.md @@ -18,7 +18,7 @@ import { clampTimeout, deadline, idleWatchdog, MAX_TIMER_DELAY_MS, timeoutOf, Ti |---|---| | `clampTimeout(requested, def, max, name?)` | 验证调用方可选的、值为正且有限的提示,从 `def` 填充,并限制在 `max` 以内。如果提示为非正数或非有限数,则抛出错误(包含 `name`)。 | | `deadline(upstream, timeoutMs, code)` | 将 `upstream` 取消与超时融合为一个 `AbortSignal`(`AbortSignal.any`);超时携带 `TimeoutReason`。`[Symbol.dispose]` 清除 timer。 | -| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | +| `idleWatchdog(upstream, timeoutMs, code)` | 保持一个稳定的融合信号,并且只在受保护的异步迭代器 `next()` 尚未完成时启动 timer。完成后停止 timer;后续需求或 `pulse()` 活动会重新启动 timer;dispose(资源释放)时清除;并发需求被拒绝。 | | `MAX_TIMER_DELAY_MS` | Node 在不将延迟限制为 1 毫秒时可调度的最大延迟(`2_147_483_647`)。负责 timer 的配置不得超过该值。 | | `timeoutOf(signal \| { reason }, code?)` | 从已中止的信号/错误中恢复 `TimeoutReason`,否则返回 `undefined`,即超时与取消的分类器。传入 `code` 可仅匹配这个 deadline 的 timer(见下文的嵌套)。 | | `TimeoutReason` | 标记在超时中止上的内部原因(`code` + `timeoutMs`)。它不是公开错误;提供方将其转换为自己的错误/字段。 | @@ -48,7 +48,7 @@ export async function runWithDeadline(upstream: AbortSignal | undefined, timeout 将你自己的 `code` 传给 `timeoutOf`,使分类可在嵌套场景中正确组合。当 `upstream` 本身是 deadline 信号时,如果该 timer 先触发,`AbortSignal.any` 会保留它的 `TimeoutReason`。将匹配范围限定为你的 code,会把外部超时视为普通的 upstream 取消,而不会声称本地 timer 已到期。 -对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 +对于流式传输,创建一个 `idleWatchdog`,将其稳定的 `signal` 传给传输层,并为提供方的每次读取调用 `watchdog.next(iterator)`。当传输活动不产生迭代器值时,调用 `watchdog.pulse()`。间隔必须为正有限数,且不得超过 `MAX_TIMER_DELAY_MS`;否则 Node 会将其限制为 1 毫秒。它只对尚未完成的读取请求计时,因此当下游代码进行渲染或在请求下一个分片前以其他方式等待时,timer 不会运行。该原语仍然只会通知,因此传输层必须观察稳定信号;DeepSeek 和 pi-ai 适配器证明,超时会关闭它们的真实响应正文或 SDK 请求。 ## 哪些操作不设置超时 diff --git a/packages/util/timeout/src/index.ts b/packages/util/timeout/src/index.ts index a9bd47eb08..3fc4d2387b 100644 --- a/packages/util/timeout/src/index.ts +++ b/packages/util/timeout/src/index.ts @@ -72,6 +72,8 @@ export interface IdleWatchdog { * @returns the iterator's next result. */ next(iterator: AsyncIterator): Promise> + /** Rearm an outstanding demand after transport activity that yields no iterator value; otherwise a no-op. */ + pulse(): void /** Clear an armed timer; safe to call once at the owning stream's exit. */ [Symbol.dispose](): void } @@ -135,15 +137,20 @@ export function idleWatchdog( let outstanding = false let disposed = false + const arm = (): void => { + if (timer !== undefined) clearTimeout(timer) + timer = setTimeout(() => { + timeout.abort(new TimeoutReason(code, timeoutMs)) + }, timeoutMs) + } + return { signal, async next(iterator: AsyncIterator): Promise> { if (disposed) throw new Error('idleWatchdog is disposed') if (outstanding) throw new Error('idleWatchdog next is already outstanding') outstanding = true - timer = setTimeout(() => { - timeout.abort(new TimeoutReason(code, timeoutMs)) - }, timeoutMs) + arm() try { return await iterator.next() } finally { @@ -152,6 +159,10 @@ export function idleWatchdog( outstanding = false } }, + pulse(): void { + if (disposed || !outstanding) return + arm() + }, [Symbol.dispose](): void { if (disposed) return disposed = true diff --git a/packages/util/timeout/tests/timeout.spec.ts b/packages/util/timeout/tests/timeout.spec.ts index 11779c915f..af0389d3d7 100644 --- a/packages/util/timeout/tests/timeout.spec.ts +++ b/packages/util/timeout/tests/timeout.spec.ts @@ -229,6 +229,28 @@ describe('idleWatchdog', () => { await expect(secondNext).rejects.toBe(stableSignal.reason) }) + it('rearms outstanding demand on an out-of-band activity pulse', async () => { + vi.useFakeTimers() + const pending = Promise.withResolvers>() + const watchdog = idleWatchdog(undefined, 100, 'LLM_STREAM_IDLE_TIMEOUT') + watchdog.pulse() + await vi.advanceTimersByTimeAsync(1_000) + expect(watchdog.signal.aborted).toBe(false) + + const next = watchdog.next({ next: () => pending.promise }) + await vi.advanceTimersByTimeAsync(99) + watchdog.pulse() + await vi.advanceTimersByTimeAsync(99) + expect(watchdog.signal.aborted).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(timeoutOf(watchdog.signal, 'LLM_STREAM_IDLE_TIMEOUT')).toMatchObject({ timeoutMs: 100 }) + pending.reject(watchdog.signal.reason) + await expect(next).rejects.toBe(watchdog.signal.reason) + + watchdog[Symbol.dispose]() + watchdog.pulse() + }) + it('keeps an earlier upstream abort distinct from its own timeout', async () => { vi.useFakeTimers() const upstream = new AbortController() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd2eab469c..da3703e5ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,18 +41,12 @@ importers: '@types/spdx-expression-parse': specifier: ^4.0.0 version: 4.0.0 - '@typescript-eslint/parser': - specifier: 8.61.0 - version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) '@vitest/coverage-v8': specifier: ^4.1.8 version: 4.1.8(vitest@4.1.8) '@yarnpkg/cli-dist': specifier: 4.17.1 version: 4.17.1 - eslint: - specifier: 10.5.0 - version: 10.5.0(jiti@2.7.0) eslint-plugin-sonarjs: specifier: ^4.1.0 version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) @@ -9962,53 +9956,10 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/parser@8.61.0': - resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/project-service@8.61.0': - resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/scope-manager@8.61.0': - resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.61.0': - resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.65.0': - resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.61.0': - resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - - '@typescript-eslint/visitor-keys@8.61.0': - resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.3': resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} @@ -15039,64 +14990,8 @@ snapshots: dependencies: '@types/node': 22.20.0 - '@typescript-eslint/parser@8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.61.0 - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.0 - debug: 4.4.3 - eslint: 10.5.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@6.0.3) - '@typescript-eslint/types': 8.65.0 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.61.0': - dependencies: - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 - - '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/types@8.61.0': {} - '@typescript-eslint/types@8.65.0': {} - - '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) - '@typescript-eslint/types': 8.61.0 - '@typescript-eslint/visitor-keys': 8.61.0 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.4 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.61.0': - dependencies: - '@typescript-eslint/types': 8.61.0 - eslint-visitor-keys: 5.0.1 - '@ungap/structured-clone@1.3.3': {} '@upsetjs/venn.js@2.0.0': diff --git a/scripts/lint-rule-fingerprint.spec.ts b/scripts/lint-rule-fingerprint.spec.ts index a28afcc8cf..b1f57eba7b 100644 --- a/scripts/lint-rule-fingerprint.spec.ts +++ b/scripts/lint-rule-fingerprint.spec.ts @@ -85,7 +85,7 @@ describe('Oxlint repository rule fingerprint', () => { const overrides: readonly unknown[] = parsed.overrides it('pins the complete override shape', () => { - expect(overrides).toHaveLength(6) + expect(overrides).toHaveLength(8) }) it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => { diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 7592780881..ccb226fb41 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -1,14 +1,15 @@ import { spawnSync } from 'node:child_process' import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { join, relative } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { fileURLToPath } from 'node:url' import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript' import { describe, expect, it } from 'vitest' const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) -const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url)) const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) +const tsxCli = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url)) function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) @@ -18,11 +19,11 @@ function isUnknownArray(value: unknown): value is unknown[] { return Array.isArray(value) } -function runStagedFormatter(paths: readonly string[]) { - return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], { +function runRepositoryOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [tsxCli, 'scripts/run-oxlint.ts', ...args], { cwd: repositoryRoot, encoding: 'utf8', - env: { ...process.env, NO_COLOR: '1' }, + env: { ...process.env, NO_COLOR: '1', ...env }, }) } @@ -150,7 +151,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + } }, 20_000) - it('keeps formatter rules aligned with Oxlint validation', async () => { + it('keeps the complete stylistic contract in Oxlint', async () => { const oxlintPath = join(repositoryRoot, '.oxlintrc.json') const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8')) if (result.error !== undefined) { @@ -160,27 +161,67 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) { throw new Error('.oxlintrc.json must contain an overrides array') } + expect(parsed.ignorePatterns).toEqual(expect.arrayContaining([ + 'packages/typert/generator/tests/fixtures/type-model/**', + ])) const stylisticOverride = parsed.overrides.find((value: unknown) => isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules) if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) { throw new Error('.oxlintrc.json must contain the @stylistic validator override') } - const validatorRules = { ...stylisticOverride.rules } - const maxLen = validatorRules['@stylistic/max-len'] - delete validatorRules['@stylistic/max-len'] + expect(stylisticOverride.rules).toMatchObject({ + '@stylistic/indent': ['error', 2], + '@stylistic/semi': ['error', 'never'], + '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/eol-last': ['error', 'always'], + '@stylistic/no-trailing-spaces': 'error', + '@stylistic/object-curly-spacing': ['error', 'always'], + '@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }], + '@stylistic/member-delimiter-style': ['error', { + multiline: { delimiter: 'none' }, + singleline: { delimiter: 'semi', requireLast: false }, + }], + '@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }], + }) + const typeGraphOverride = parsed.overrides.find((value: unknown) => + isRecord(value) + && isUnknownArray(value.files) + && value.files.includes('packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts')) + expect(typeGraphOverride).toMatchObject({ + rules: { '@stylistic/quotes': 'off' }, + }) + }) - const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href - const formatterModule = await import(formatterUrl) as unknown - if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) { - throw new Error('eslint.format.config.mjs must default-export a config array') - } - const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules)) - if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) { - throw new Error('eslint.format.config.mjs must contain a rules object') + it('checks preserved TypeGraph syntax without type-aware analysis', () => { + const result = runOxlint([ + '--config', + '.oxlintrc.staged.json', + 'packages/typert/generator/tests/fixtures/type-model', + ]) + + expect(result.error).toBeUndefined() + expect(result.status, normalizedOutput(result)).toBe(0) + }) + + it('keeps repository lint workflows Oxlint-only', async () => { + const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as unknown + if (!isRecord(packageJson) || !isRecord(packageJson.scripts) || !isRecord(packageJson.devDependencies)) { + throw new Error('package.json must contain scripts and devDependencies objects') } - expect(validatorRules).toStrictEqual(formatterOverride.rules) - expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }]) + expect(packageJson.scripts['lint:contracts-ready']).toBe('tsx scripts/run-oxlint.ts .') + expect(packageJson.scripts['lint:fix:contracts-ready']).toBe( + 'tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix', + ) + expect(packageJson.devDependencies).not.toHaveProperty('eslint') + expect(packageJson.devDependencies).not.toHaveProperty('@typescript-eslint/parser') + expect(existsSync(join(repositoryRoot, 'eslint.format.config.mjs'))).toBe(false) + + const lefthook = await readFile(join(repositoryRoot, 'lefthook.yml'), 'utf8') + expect(lefthook).toContain('scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix') + expect(lefthook).not.toContain('node_modules/.bin/eslint') + expect(lefthook).not.toContain('eslint.format.config.mjs') }) it('reports an unused suppression', async () => { @@ -227,10 +268,13 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + if (result.error !== undefined) { throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n')) } - expect(result.config).toMatchObject({ + const stagedConfig = result.config as unknown + if (!isRecord(stagedConfig)) throw new Error('.oxlintrc.staged.json must contain a config object') + expect(stagedConfig).toMatchObject({ extends: ['./.oxlintrc.json'], options: { typeAware: false }, }) + expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**') const suffix = randomUUID() const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) @@ -254,30 +298,76 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + } }) - it('applies staged stylistic fixes before Oxlint validation', async () => { + it('preserves successful fix output channels', async () => { const suffix = randomUUID() - const configPath = await writeContractConfig(suffix) - const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`) - const path = join(directory, 'fix.ts') + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) try { - await mkdir(directory, { recursive: true }) - await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n') - - const relativePath = relative(repositoryRoot, path) - const formatResult = runStagedFormatter([relativePath]) - const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath]) - - expect(formatResult.error).toBeUndefined() - expect(formatResult.status, normalizedOutput(formatResult)).toBe(0) - expect(lintResult.error).toBeUndefined() - expect(lintResult.status, normalizedOutput(lintResult)).toBe(0) - await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n') - } finally { - await Promise.all([ - rm(directory, { recursive: true, force: true }), - rm(configPath, { force: true }), + await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n') + const result = runRepositoryOxlint([ + '--config', + '.oxlintrc.staged.json', + '--format', + 'unix', + '--fix', + relative(repositoryRoot, path), ]) + + expect(result.error).toBeUndefined() + expect(result.status, normalizedOutput(result)).toBe(0) + expect(result.stdout).toContain('Unused oxlint-disable directive') + expect(result.stderr).toBe('') + } finally { + await rm(path, { force: true }) } - }, 20_000) + }) + + it('prints only the final diagnostics when a fix retry still fails', async () => { + const suffix = randomUUID() + const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`) + + try { + await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`) + const result = runRepositoryOxlint([ + '--config', + '.oxlintrc.staged.json', + '--format', + 'unix', + '--fix', + relative(repositoryRoot, path), + ]) + const output = normalizedOutput(result) + + expect(result.error).toBeUndefined() + expect(result.status, output).toBe(1) + expect(output.match(/@stylistic\(max-len\)/g)).toHaveLength(1) + } finally { + await rm(path, { force: true }) + } + }) + + it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])( + 'converges overlapping staged stylistic fixes through Oxlint under %s', + async (fixFlag) => { + const suffix = randomUUID() + const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`) + const path = join(directory, 'fix.ts') + + try { + await mkdir(directory, { recursive: true }) + await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n') + + const relativePath = relative(repositoryRoot, path) + const lintResult = runRepositoryOxlint(['--config', '.oxlintrc.staged.json', fixFlag, relativePath]) + + expect(lintResult.error).toBeUndefined() + expect(lintResult.status, normalizedOutput(lintResult)).toBe(0) + expect(normalizedOutput(lintResult)).not.toContain('@stylistic') + await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n') + } finally { + await rm(directory, { recursive: true, force: true }) + } + }, + 20_000, + ) }) diff --git a/scripts/run-oxlint.ts b/scripts/run-oxlint.ts index e833f75c69..13ed84799f 100644 --- a/scripts/run-oxlint.ts +++ b/scripts/run-oxlint.ts @@ -3,6 +3,12 @@ import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url)) +const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024 +const FIX_FLAGS = new Set(['--fix', '--fix-dangerously', '--fix-suggestions']) + +function isFixInvocation(args: readonly string[]): boolean { + return args.some(arg => FIX_FLAGS.has(arg)) +} /** Complete Oxlint child-process arguments and environment. */ export interface OxlintInvocation { @@ -32,14 +38,50 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro } } +function completeFrom(result: { readonly signal: NodeJS.Signals | null; readonly status: number | null }): void { + if (result.signal !== null) { + process.kill(process.pid, result.signal) + return + } + process.exitCode = result.status ?? 1 +} + function main(): void { const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env) - const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], { + if (!isFixInvocation(invocation.args)) { + const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], { + env: invocation.env, + stdio: 'inherit', + }) + if (result.error !== undefined) throw result.error + completeFrom(result) + return + } + + const first = spawnSync(process.execPath, [oxlintCli, ...invocation.args], { + encoding: 'utf8', + env: invocation.env, + maxBuffer: MAX_CAPTURED_OUTPUT_BYTES, + }) + if (first.error !== undefined) throw first.error + if (first.signal !== null) { + completeFrom(first) + return + } + if (first.status === 0) { + process.stdout.write(first.stdout) + process.stderr.write(first.stderr) + process.exitCode = 0 + return + } + + // Overlapping JS-plugin fixes can expose one more fixable diagnostic after the first pass. + const second = spawnSync(process.execPath, [oxlintCli, ...invocation.args], { env: invocation.env, stdio: 'inherit', }) - if (result.error !== undefined) throw result.error - process.exitCode = result.status ?? 1 + if (second.error !== undefined) throw second.error + completeFrom(second) } const entrypoint = process.argv[1] diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index ded84487bf..0bd65e93e4 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`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.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe 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.\n\nApart from the scoped staged-record verification, 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.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI shape. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; see the [`api-remotes` README](../packages/api/remotes/README.md) for the complete boundary.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already owns an explicit dependency on the TypeRT contract pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate topology, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`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.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe 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.\n\nApart from the scoped staged-record verification, 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.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 形态。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;完整边界见 [`api-remotes` README](../packages/api/remotes/README.md)。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本以调用它的公共命令或调度器门禁已经显式依赖 TypeRT 约定 pass 或完整构建为前提。双 aggregate 拓扑见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档约定](i18n/README.md#the-pairing-contract)。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user",