Merge remote-tracking branch 'origin/master' into worktree/ci-native-windows-20260808
This commit is contained in:
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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,却让请求继续运行,就不满足此约定。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -57,10 +57,11 @@ export function deadline(
|
||||
export interface IdleWatchdog {
|
||||
readonly signal: AbortSignal
|
||||
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
|
||||
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.
|
||||
|
||||
|
||||
@@ -57,10 +57,11 @@ export function deadline(
|
||||
export interface IdleWatchdog {
|
||||
readonly signal: AbortSignal
|
||||
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
|
||||
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——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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、工作流和代码运行时入口路径执行已构建应用的冒烟测试。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 则对每个推送版本提供一次全面信号。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 负责。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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。要移除这个传递依赖,需要使用原生替代方案,或另行决定采用能够同时取代兼容插件的格式化器;这不属于本次工作流简化的范围。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+16
-1
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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 检查。
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -7,6 +7,7 @@
|
||||
config:
|
||||
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
|
||||
thinking: disabled
|
||||
streamIdleTimeoutMs: 150
|
||||
- id: agent-spine
|
||||
config:
|
||||
agents:
|
||||
|
||||
@@ -83,12 +83,21 @@ async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
|
||||
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<void>(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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-3
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<StreamChunk> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<BufferSource>): AsyncGenerator<string> {
|
||||
export async function* parseSse(
|
||||
stream: ReadableStream<BufferSource>,
|
||||
onComment?: (comment: string) => void,
|
||||
): AsyncGenerator<string> {
|
||||
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
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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', () => {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 请求。
|
||||
|
||||
## 哪些操作不设置超时
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface IdleWatchdog {
|
||||
* @returns the iterator's next result.
|
||||
*/
|
||||
next<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>>
|
||||
/** 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<T>(iterator: AsyncIterator<T>): Promise<IteratorResult<T>> {
|
||||
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
|
||||
|
||||
@@ -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<IteratorResult<number>>()
|
||||
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()
|
||||
|
||||
Generated
-105
@@ -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':
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+131
-41
@@ -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<string, unknown> {
|
||||
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,
|
||||
)
|
||||
})
|
||||
+45
-3
@@ -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]
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user