Sfoglia il codice sorgente

Merge remote-tracking branch 'origin/master' into worktree-process-service-seam

Tianyi Cui 2 mesi fa
parent
commit
7bdb90aebb
26 ha cambiato i file con 494 aggiunte e 304 eliminazioni
  1. 3 3
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
  2. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
  3. 1 1
      .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
  4. 3 3
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml
  5. 3 3
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
  6. 3 3
      .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md
  7. 6 0
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml
  8. 45 0
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
  9. 45 0
      .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md
  10. 2 2
      .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml
  11. 33 0
      .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md
  12. 33 0
      .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md
  13. 0 38
      .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md
  14. 0 38
      .agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md
  15. 1 1
      .github/AGENTS.md
  16. 205 22
      .github/workflows/ci.yml
  17. 5 16
      scripts/dev-web.ts
  18. 5 3
      scripts/doc-typecheck.ts
  19. 47 14
      scripts/markdown.ts
  20. 0 55
      scripts/md-fences.ts
  21. 13 18
      scripts/publint-all.ts
  22. 7 18
      scripts/verify-built-package-invariants.mjs
  23. 7 11
      scripts/verify-client-domain-graph.ts
  24. 3 7
      scripts/verify-package-paths.ts
  25. 6 16
      scripts/verify-runtime-closure.ts
  26. 17 31
      scripts/verify-type-equiv.ts

+ 3 - 3
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-21-serial-cross-platform-ci-reference.md: 5433d2c51831ce61d06a16ee0b0ed982911f9218
-2026-07-21-serial-cross-platform-ci-reference.zh.md: 041d53d13e14354c995e4b65defce94a97646b0a
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
+2026-07-21-serial-cross-platform-ci-reference.md: 5eac1bc1c47c7309942b5615bc98a7fed893f346
+2026-07-21-serial-cross-platform-ci-reference.zh.md: 35fb761023fe7be081bf7d9591a53ed98b6e3abc

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md

@@ -24,7 +24,7 @@ The macOS reference runs the ordinary Vitest project in forked processes. Node 2
 
 Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the three serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
 
-The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. Required pull-request jobs use the same portable Linux and Windows capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
+The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md

@@ -24,7 +24,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上
 
 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行三个串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
 
-可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用相同的可移植 Linux 和 Windows 容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
+可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
 
 ## 曾考虑的替代方案
 

+ 3 - 3
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-23-portable-required-pull-request-ci.md: d1002c7d9db7cd8bbed3bdfda8a773a4b124bf16
-2026-07-23-portable-required-pull-request-ci.zh.md: fedfc6b9c982ace5ece430c52db23c22ec5119d4
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md
+2026-07-23-portable-required-pull-request-ci.md: 1a6939e8386e381cba114a7be71993a644457a45
+2026-07-23-portable-required-pull-request-ci.zh.md: cf0af769f9e740a2c9285caf4be05023371578d9

+ 3 - 3
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md

@@ -12,9 +12,9 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei
 
 ## Decision
 
-[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs on standard `windows-2025` with single-worker bounds, keeping the complete Windows contract independent of enterprise Windows allocation. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
+[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 jobs, plus the stable `all checks passed` aggregate, on repo-restricted enterprise 32-core pools. The aggregate performs no checkout or repository gate, but sharing the enterprise pool prevents the required verdict from introducing a separate standard-hosted billing dependency after its substantive jobs have already succeeded. The required Windows job runs Windows Node under Wine on standard `ubuntu-latest` for the blocking surfaces ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)), keeping the pull-request Windows contract independent of any Windows runner allocation; the complete native-kernel Windows inventory lives in the master serial reference. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request.
 
-The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
+The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / wine blocking` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when a remaining enterprise Linux label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result.
 
 The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md) owns the current primary topology and its measurements. The [serial cross-platform reference](2026-07-21-serial-cross-platform-ci-reference.md) remains the independent standard-hosted completeness check, and the manual larger-runner suites retain size comparisons without expanding the ordinary required matrix.
 
@@ -30,6 +30,6 @@ The [larger-runner decision](2026-07-22-evidence-based-larger-hosted-runners.md)
 
 ## Consequences
 
-Ordinary pull requests spend enterprise capacity on the Linux critical path while standard Windows trades longer runtime for independent allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
+Ordinary pull requests spend enterprise capacity on the Linux critical path while the Wine-hosted Windows job keeps its verdict on standard Linux allocation. A live exact-head run proves the same commands that branch protection consumes; queue delay is reported separately from each job's `startedAt` to `completedAt` execution interval.
 
 Standard compatibility and required Windows jobs remain useful when enterprise allocation is degraded, but they do not make a blocked required Linux job or aggregate green. Recovering Linux availability may require restoring the complete standard-hosted topology; changing a pool definition's status alone is insufficient evidence that it can receive work.

+ 3 - 3
.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md

@@ -12,9 +12,9 @@ Status: implemented
 
 ## 决策
 
-[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `windows-2025` 上运行,并采用单工作线程上限,使完整的 Windows 契约不依赖企业级 Windows 运行器分配。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
+[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业,以及稳定的 `all checks passed` 聚合流程。该聚合流程不执行代码检出或仓库门禁;但让它与所依赖的实质性作业共用企业级运行器池,可以避免这些作业已经成功后,必需判定结果又引入一项单独的标准托管计费依赖。必需的 Windows 作业在标准 `ubuntu-latest` 上通过 Wine 运行 Windows Node 以覆盖阻断表面([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md)),使拉取请求的 Windows 契约不依赖任何 Windows 运行器分配;完整的原生内核 Windows 清单归 master 串行参考流程所有。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。
 
-两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
+两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / wine blocking` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。剩余的企业级 Linux 运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。
 
 当前主拓扑及其测量结果由[大型运行器决策](2026-07-22-evidence-based-larger-hosted-runners.md)记录。[跨平台串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)继续作为独立的标准托管完整性检查,手动大型运行器套件则保留规格比较,同时不扩大普通必需矩阵。
 
@@ -30,6 +30,6 @@ Status: implemented
 
 ## 后果
 
-普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而标准托管 Windows 作业则以更长的运行时间换取不依赖企业池的运行器分配。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
+普通拉取请求会将企业级运行器容量用于 Linux 关键路径,而 Wine 托管的 Windows 作业让其判定保持在标准 Linux 运行器分配上。一次实际的分支头精确运行能够证明分支保护使用的同一组命令;排队延迟与每个作业从 `startedAt` 到 `completedAt` 的执行区间分开报告。
 
 企业级运行器分配能力下降时,标准兼容性作业和必需的 Windows 作业仍能提供有用证据,但无法让受阻的必需 Linux 作业或聚合流程变绿。恢复 Linux 可用性时,可能需要恢复完整的标准托管拓扑;仅改变运行器池定义的状态,不足以证明它可以接收作业。

+ 6 - 0
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
+2026-07-27-wine-windows-gates-experiment.md: aab8aecdfca06c1f15641044a071015f543a84b6
+2026-07-27-wine-windows-gates-experiment.zh.md: 5239b185e1e0c63aa626ee3f20f3f298c0c8579d

+ 45 - 0
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md

@@ -0,0 +1,45 @@
+# Agent Note: Wine-run Windows blocking gates on Linux runners
+
+Status: implemented
+
+English | [中文](2026-07-27-wine-windows-gates-experiment.zh.md)
+
+## Problem
+
+The pull-request Windows lane exists to prove the two blocking win32 surfaces — the workspace build and the production site — and it ran on hosted `windows-2025`, the slowest job in the required matrix: 7–9 minutes against 1.5–2.5 for the Linux jobs, so the Windows VM's boot, setup, and filesystem costs dominated every pull request's critical path.
+
+The question the experiment answered: can a plain Linux runner produce an equivalent win32 signal for the blocking surfaces at Linux wall clock, so no Windows VM sits on the pull-request path at all?
+
+## Decision
+
+The required pull-request `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) (`windows node 24 / wine blocking`) runs the blocking gate commands on `ubuntu-latest` under Wine with real Windows binaries: a checksum-verified win-x64 Node.js executes `tsc -b`, `tsdown`, and the VitePress production build, so the win32 branches of the toolchain — backslash path handling, `CreateProcess` spawn semantics, PE loading of `@esbuild/win32-x64`, and the rolldown/rollup MSVC `.node` addons — actually execute. The master `serial-windows` job is untouched: the complete native-kernel inventory, including the observational portability gates this lane does not run, still executes on real `windows-2025` on every master push.
+
+Dependencies install natively on Linux with `supportedArchitectures` extended to win32-x64, which materializes the Windows platform packages in the same store; the cmd-shim layer is bypassed by invoking each tool's JavaScript entrypoint directly, the same processes `run-gates` ultimately spawns. `nodeLinker: hoisted` is load-bearing, not stylistic: the independent prototype in [PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) kept pnpm's default isolated layout — including a faithful offline Windows-pnpm re-install over a Linux-prefetched store — and Windows Node under Wine still could not resolve `@esbuild/win32-x64` or load the koffi prebuild through the isolated symlink chain, failing before any repository gate ran. A flat layout with real files is what makes the gates reachable at all; #689's checksum pinning is adopted, while its Windows-pnpm-installs-the-tree goal is explicitly given up (the install contract stays Linux-tested here).
+
+The lane holds the wall clock of the Linux CI jobs through four levers: the master-refreshed pnpm store cache (restore-only, same key as the Linux jobs), Wine provisioning (apt install, Windows Node download, `wineboot`) running concurrently with `pnpm install`, the two blocking surfaces running concurrently — the same shape `run-gates` gives them on native Windows — and an apt-archive cache keyed on the runner image, seeded from master by the `wine apt cache` job so every pull request restores from the default-branch scope.
+
+Four environment constraints shape the job, each found as a red run: Ubuntu's `wine64` package alone puts nothing on PATH (install `wine`, the dispatcher); Node under Wine cannot attach stdio to the Actions runner's pipes (`Socket open EBADF` at bootstrap — every invocation routes stdio through a file); Wine does not realpath pnpm's isolated-layout Unix symlinks (the hoisted layout above); and Wine cannot create Windows symlinks (`ENOTSUP` from VitePress's `linkVue` — the `vue` link is laid down host-side before the gate).
+
+## Measured results
+
+Measured on 2026-07-27, warm caches, pull-request trigger, standard 2-core `ubuntu-latest`: 2m46s end-to-end — setup and cache restores ~17s, concurrent install+provision 33s, concurrent gates 110s — against 1.5–2.5 minutes for the Linux CI jobs and 7–9 minutes for the replaced `windows-2025` job. A cold-cache run pays roughly one extra minute. An 8-core benchmark leg was defined during the experiment but never left the restricted `dsh-ubuntu-*` pool's queue; the standard-runner number met the target, so no larger box is used.
+
+## Alternatives considered
+
+**Keep the hosted `windows-2025` pull-request job (status quo).** Nothing wrong with its signal, only its latency: 7–9 minutes for two build commands, the slowest required job in the matrix. It survives as the master serial reference, where completeness matters more than latency.
+
+**A full Windows guest under QEMU/KVM inside the Linux runner.** Real NT kernel, so full fidelity including case-insensitive NTFS and ConPTY — but tens of minutes of image download and unattended install before the first gate runs (40m19s measured end-to-end on the sibling experiment branch `exp/kvm-windows-ci`). Promotable only with disk-image caching that pressures the Actions cache budget.
+
+**Windows pnpm performing the install under Wine ([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689)).** The higher-fidelity variant of this same idea: MinGit and pnpm staged into the prefix, a Linux prefetch filling the store, then `pnpm install --offline` run by Windows Node so the install contract itself executes as win32. It reached the install but not the gates — Wine's networking could not reach the registry directly, and the isolated `node_modules` layout defeated resolution of the Windows platform packages even after a clean offline install. This lane trades that fidelity away (hoisted layout, Linux-side install) to reach the gates; the two records are complementary halves of the same verdict.
+
+**Filesystem-semantics lanes on Linux (casefolded ext4, filename lint).** Catches the highest-frequency Windows breakage class for near-zero cost but proves nothing about win32 binaries. Explored as the sibling experiment branch `exp/casefold-windows-ci`; complementary to, not competitive with, this lane.
+
+**Windows containers.** Not possible: Windows containers require a Windows host kernel; a hosted Linux runner cannot run them.
+
+**Dropping the Windows lane.** Rejected — win32 is a first-class product target: the koffi-backed DACL and durable-namespace modules, ConPTY-based PTY sessions, and Windows path policy all ship in `packages/`.
+
+## Consequences
+
+Every pull request's Windows verdict now arrives in Linux-job time on free standard capacity, and no Windows VM allocation sits on the pull-request critical path; `all checks passed` consumes the same `windows` job id it always did.
+
+What the trade costs: Wine reimplements Win32 over a case-sensitive ext4 — NTFS case-insensitivity, real DACLs, ConPTY, and crash-durability semantics are not proved here, and the observational portability inventory (duplication, publint, node-next types, built-package invariants on win32) no longer runs on pull requests at all. The master `serial-windows` reference owns all of that: a Wine-green pull request can still fail the native-kernel master run, and that failure mode is accepted as post-merge. The lane also inherits Wine-specific divergences as permanent job structure — file-routed stdio, the host-side `vue` link, the hoisted layout — so a future toolchain change that depends on isolated-layout semantics or in-process symlink creation will surface here first as a Wine failure rather than a product failure, and triage must classify it as such. If Wine reds ever recur without product cause, the recorded fallback is reverting the `windows` job to the pre-Wine `windows-2025` definition preserved in git history.

+ 45 - 0
.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.zh.md

@@ -0,0 +1,45 @@
+# Agent Note: 在 Linux runner 上用 Wine 运行 Windows 阻断门禁
+
+Status: implemented
+
+[English](2026-07-27-wine-windows-gates-experiment.md) | 中文
+
+## 问题
+
+Pull request 的 Windows 通道存在的意义是证明两个阻断性 win32 表面——workspace 构建与生产站点——它此前运行在托管 `windows-2025` 上,是必需矩阵中最慢的作业:7–9 分钟,对照 Linux 作业的 1.5–2.5 分钟,因此 Windows VM 的启动、准备与文件系统开销主导了每个 pull request 的关键路径。
+
+实验回答的问题是:一台普通 Linux runner 能否以 Linux 墙钟为阻断表面产出等效的 win32 信号,让 pull request 路径上完全没有 Windows VM?
+
+## 决策
+
+[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 pull request `windows` 作业(`windows node 24 / wine blocking`)在 `ubuntu-latest` 上通过 Wine 用真实 Windows 二进制运行阻断门禁命令:校验和验证过的 win-x64 Node.js 执行 `tsc -b`、`tsdown` 与 VitePress 生产构建,因此工具链的 win32 分支——反斜杠路径处理、`CreateProcess` 派生语义、`@esbuild/win32-x64` 的 PE 加载、以及 rolldown/rollup 的 MSVC `.node` 插件——都真正执行。master 的 `serial-windows` 作业原封不动:完整的原生内核清单,包括本通道不运行的观察性可移植性门禁,仍在每次 master push 时于真实 `windows-2025` 上执行。
+
+依赖在 Linux 上原生安装,`supportedArchitectures` 扩展到 win32-x64,使 Windows 平台包物化进同一个 store;通过直接调用各工具的 JavaScript 入口绕开 cmd-shim 层,这正是 `run-gates` 最终派生的那些进程。`nodeLinker: hoisted` 是承重的,不是风格问题:[PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689) 的独立原型保留了 pnpm 默认的 isolated 布局——包括在 Linux 预取的 store 上忠实地用 Windows pnpm 离线重装——而 Wine 下的 Windows Node 依然无法穿过 isolated 符号链接链解析 `@esbuild/win32-x64` 或加载 koffi 预编译产物,在任何仓库门禁运行前就失败了。扁平的真实文件布局才让门禁变得可达;本通道采纳了 #689 的校验和固定,同时明确放弃其"Windows pnpm 安装依赖树"的目标(安装契约在此仍由 Linux 侧验证)。
+
+该通道靠四个杠杆保持 Linux CI 作业的墙钟:master 刷新的 pnpm store 缓存(只恢复,与 Linux 作业同键)、Wine 供给(apt 安装、Windows Node 下载、`wineboot`)与 `pnpm install` 并发运行、两个阻断表面并发运行——与 `run-gates` 在原生 Windows 上给它们的形状相同——以及按 runner 镜像为键的 apt 归档缓存,由 master 的 `wine apt cache` 作业播种,使每个 pull request 都能从默认分支作用域恢复。
+
+四条环境约束塑造了该作业,每条都以一次红色运行被发现:Ubuntu 的 `wine64` 包本身不往 PATH 放任何东西(要装 `wine` 调度器);Wine 下的 Node 无法把 stdio 接到 Actions runner 的管道上(引导期 `Socket open EBADF`——所有调用都经文件中转 stdio);Wine 不对 pnpm isolated 布局的 Unix 符号链接做 realpath(即上文的 hoisted 布局);Wine 无法创建 Windows 符号链接(VitePress 的 `linkVue` 报 `ENOTSUP`——`vue` 链接在门禁前由宿主侧铺好)。
+
+## 实测结果
+
+2026-07-27 实测,热缓存,pull request 触发,标准 2 核 `ubuntu-latest`:端到端 2 分 46 秒——准备与缓存恢复约 17 秒,并发安装+供给 33 秒,并发门禁 110 秒——对照 Linux CI 作业的 1.5–2.5 分钟与被替换的 `windows-2025` 作业的 7–9 分钟。冷缓存约多付一分钟。实验期间定义过 8 核基准腿,但它从未离开受限 `dsh-ubuntu-*` 池的队列;标准 runner 的数字已达标,故不使用更大的机器。
+
+## 考虑过的替代方案
+
+**保留托管 `windows-2025` 的 pull request 作业(现状)。** 其信号没有问题,问题只在延迟:为两条构建命令花 7–9 分钟,是必需矩阵中最慢的作业。它作为 master 串行参照存续——在那里完整性比延迟更重要。
+
+**在 Linux runner 内用 QEMU/KVM 跑完整 Windows 客户机。** 真实 NT 内核,保真度完整,包括大小写不敏感的 NTFS 与 ConPTY——但首个门禁运行前要花数十分钟下载镜像并做无人值守安装(兄弟实验分支 `exp/kvm-windows-ci` 实测端到端 40 分 19 秒)。只有配上会挤压 Actions 缓存预算的磁盘镜像缓存才可晋升。
+
+**在 Wine 下由 Windows pnpm 执行安装([PR #689](https://github.com/deepseek-harness/deepseek-harness/pull/689))。** 同一想法的更高保真度变体:把 MinGit 与 pnpm 放进 prefix,用 Linux 预取填充 store,再由 Windows Node 运行 `pnpm install --offline`,让安装契约本身以 win32 身份执行。它到达了安装但没到达门禁——Wine 的网络无法直接访问 registry,且 isolated 的 `node_modules` 布局即便在干净的离线安装后也挫败了 Windows 平台包的解析。本通道用掉这份保真度(hoisted 布局、Linux 侧安装)来换取门禁可达;两份记录是同一裁决互补的两半。
+
+**Linux 上的文件系统语义通道(casefold ext4、文件名 lint)。** 以近零成本捕获最高频的 Windows 破坏类别,但对 win32 二进制什么也证明不了。作为兄弟实验分支 `exp/casefold-windows-ci` 探索;与本通道互补而非竞争。
+
+**Windows 容器。** 不可行:Windows 容器要求 Windows 宿主内核;托管 Linux runner 无法运行。
+
+**砍掉 Windows 通道。** 已否决——win32 是一等产品目标:基于 koffi 的 DACL 与持久命名空间模块、基于 ConPTY 的 PTY 会话、以及 Windows 路径策略都随 `packages/` 交付。
+
+## 结果
+
+每个 pull request 的 Windows 裁决现在以 Linux 作业的时间在免费标准容量上到达,pull request 关键路径上不再有任何 Windows VM 分配;`all checks passed` 消费的仍是原来的 `windows` 作业 id。
+
+这笔交易的代价:Wine 在大小写敏感的 ext4 之上重实现 Win32——NTFS 大小写不敏感、真实 DACL、ConPTY 与崩溃持久性语义在此都未被证明,且观察性可移植性清单(duplication、publint、node-next 类型、win32 上的构建包不变量)完全不再于 pull request 上运行。master 的 `serial-windows` 参照拥有这一切:Wine 绿灯的 pull request 仍可能在原生内核的 master 运行上失败,该失败模式被接受为合并后处理。该通道还把 Wine 特有的分歧继承为永久的作业结构——文件中转的 stdio、宿主侧的 `vue` 链接、hoisted 布局——因此未来依赖 isolated 布局语义或进程内符号链接创建的工具链变更会先在这里以 Wine 失败而非产品失败的形式浮现,分诊必须如此归类。若 Wine 红灯在无产品原因的情况下反复出现,记录在案的退路是把 `windows` 作业还原为 git 历史中保存的 Wine 之前的 `windows-2025` 定义。

+ 2 - 2
.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml → .agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 0bf32e01ea407e2718f8ec39ca962587a37df9cc
-2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: ba9f157a61ffdc415acf9b2a61857026fc2c8bf1
+2026-07-26-consolidate-gate-scripts-on-existing-deps.md: 6370c8f92eff7296327e941e698ec4f733100bb2
+2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md: 3587c92e0e9655d8b38d24d184c1c68c44b131d4

+ 33 - 0
.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md

@@ -0,0 +1,33 @@
+# Agent Note: Consolidate gate scripts on already-present deps and builtins
+
+Status: implemented
+
+English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md)
+
+## Problem
+
+The `scripts/` gates mostly used the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-rolled what a sibling gate already did with an existing dependency or builtin:
+
+- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) were two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracted fences by visiting mdast `code` nodes — and `markdownProseLines` in `scripts/markdown.ts` itself parsed to mdast but then hand-tracked fence state with a second regex. The regex scanners only recognized backtick fences at column 0, so they silently disagreed with the mdast-based gates on tilde and indented fences.
+- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) stepped argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already used the `node:util` `parseArgs` builtin.
+- **Hand-rolled directory walks.** Five sites re-derived nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template.
+
+No new dependency was needed anywhere; every replacement is an existing devDep or a Node builtin.
+
+## Decision
+
+- A shared mdast fence helper, `markdownFences` in `scripts/markdown.ts`, visits `code` nodes for the language, full info string, body, and 1-based opening-fence line; `doc-typecheck.ts` and `verify-type-equiv.ts` extract fences through it. `md-fences.ts` and the duplicated `extractEquivBlocks` scanner are deleted, and `markdownProseLines` derives fenced lines from the parsed `code` nodes' positions instead of a second regex.
+- Both CLIs parse argv via `parseArgs`; unknown options and missing values still fail loud, with `parseArgs`'s own error text instead of the bespoke usage strings.
+- The five straggler walks use `globSync`. The walks in `check-workspace-constraints.ts` and `clean.ts` stay: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report.
+
+## Alternatives considered
+
+- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these were stragglers, not a gap.
+- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer.
+- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation was a latent inconsistency between sibling gates.
+
+## Consequences
+
+- One fence parser: every markdown gate now classifies fences through mdast, so tilde, indented, and 4-backtick container fences behave identically everywhere. The docs tree contained no fence shape the regex scanners mishandled, so gate results are unchanged on the tree that landed the swap: `pnpm run doc-sync` and each rewritten gate ran before and after with byte-identical output (`doc-typecheck` block/opt-out counts, `verify-type-equiv` match counts, `publint`, `verify-built-package-invariants`, `verify-runtime-closure`, `verify-package-paths`, `verify-client-domain-graph`, and both package-README prose gates).
+- `verify-type-equiv` still rejects an unterminated type-equivalence fence: mdast silently closes an unterminated block at end-of-file (its comparisons could then pass), so the shared helper reports whether a closing delimiter exists and the gate errors on an unclosed block, preserving the removed scanner's rejection. The `doc-typecheck` scanner never had that error path.
+- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin, accepted in exchange for deleting the two bespoke parsers. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the replaced parsers.)

+ 33 - 0
.agents/notes/implemented/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md

@@ -0,0 +1,33 @@
+# Agent Note: 把门禁脚本统一到已有依赖与内置模块上
+
+Status: implemented
+
+[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文
+
+## 问题
+
+`scripts/` 下的门禁大多本已在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本曾手写同类门禁早已用既有依赖或内置模块完成的事情:
+
+- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)曾是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 早已通过访问 mdast `code` 节点来提取代码围栏;`scripts/markdown.ts` 自己的 `markdownProseLines` 也曾先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。
+- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)曾手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)早已在使用 `node:util` 的内置 `parseArgs`。
+- **手写的目录遍历。**五处代码曾各自重写 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。
+
+所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。
+
+## 决策
+
+- `scripts/markdown.ts` 中的共享 mdast 围栏辅助函数 `markdownFences` 访问 `code` 节点,读取语言、完整 info string、块体以及以 1 起始的开围栏行号;`doc-typecheck.ts` 和 `verify-type-equiv.ts` 通过它提取代码围栏。`md-fences.ts` 和重复的 `extractEquivBlocks` 扫描器已删除,`markdownProseLines` 也改为从解析出的 `code` 节点位置推导围栏内的行,而不再用第二个正则。
+- 两个 CLI 都改用 `parseArgs` 解析 argv;未知选项和缺失取值仍然大声失败,只是错误文案换成了 `parseArgs` 自带的文本,而非原先手写的用法字符串。
+- 那五处掉队的目录遍历改用 `globSync`。`check-workspace-constraints.ts` 和 `clean.ts` 中的遍历保留:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。
+
+## 曾考虑的替代方案
+
+- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。
+- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。
+- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。
+
+## 后果
+
+- 只剩一个围栏解析器:所有 markdown 门禁现在都经由 mdast 归类代码围栏,因此波浪线围栏、缩进围栏和四反引号容器围栏在各处的行为完全一致。文档树中不存在正则扫描器处理有误的围栏形态,所以在落地这次替换的代码树上门禁结果不变:`pnpm run doc-sync` 及每个被改写的门禁在改动前后各跑一遍,输出逐字节相同(`doc-typecheck` 的块数/opt-out 计数、`verify-type-equiv` 的匹配计数、`publint`、`verify-built-package-invariants`、`verify-runtime-closure`、`verify-package-paths`、`verify-client-domain-graph`,以及两个包 README 散文门禁)。
+- `verify-type-equiv` 仍然拒绝未闭合的类型等价围栏:mdast 会在文件末尾静默闭合未闭合的代码块(其比较随后可能通过),因此共享辅助函数会报告闭合定界符是否存在,门禁在块未闭合时报错,保留了被删扫描器的这条拒绝路径。`doc-typecheck` 的扫描器本来就没有这条错误路径。
+- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例,作为删除两份手写解析器的交换被接受。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与被替换的解析器行为一致。)

+ 0 - 38
.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.md

@@ -1,38 +0,0 @@
-# Agent Note: Consolidate gate scripts on already-present deps and builtins
-
-Status: proposed
-
-English | [中文](2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md)
-
-## Problem
-
-The `scripts/` gates mostly use the right tools (`node:fs` `globSync` in 15+ gates, mdast/micromark in the markdown gates), but a handful of stragglers hand-roll what a sibling gate already does with an existing dependency or builtin:
-
-- **Duplicated fence scanners.** `scripts/md-fences.ts` (~55 lines, consumed by `doc-typecheck.ts`) and `extractEquivBlocks` in `scripts/verify-type-equiv.ts` (~39 lines) are two copies of the same regex line-scanner for fenced code blocks, while `scripts/verify-mermaid.ts` already extracts fences by visiting mdast `code` nodes via the shared `scripts/markdown.ts` helpers — and `markdownProseLines` in `markdown.ts` itself parses to mdast but then hand-tracks fence state with a second regex. The regex scanners only recognize backtick fences at column 0, so they silently disagree with the mdast-based gates on tilde and indented fences.
-- **Hand-rolled argv parsing.** `parseOptions` in `scripts/publint-all.ts` and its near-identical copy in `scripts/verify-built-package-invariants.mjs` (~26 lines) step argv indexes manually, while sibling scripts (`verify-runtime-closure.ts`, `build-exe-for-python-sdk.ts`, `packages/sdk/scripts/src/args.ts`) already use the `node:util` `parseArgs` builtin.
-- **Hand-rolled directory walks.** Five sites re-derive nested `readdirSync` walks that `globSync` covers: `verify-runtime-closure.ts` (packages + vendor manifests), `dev-web.ts` `discoverPluginDirs`, `verify-package-paths.ts` `realPackageNames`, `verify-client-domain-graph.ts` `listSources`, and `publint-all.ts` `addPath` (~55–65 lines total). `scripts/package-invariants.ts` shows the one-line `globSync` template.
-
-No new dependency is needed anywhere; every replacement is an existing devDep or a Node builtin.
-
-## Proposal
-
-- Extract a shared ~10–15-line mdast fence helper (visiting `code` nodes for `lang`, `meta`, `value`, `position.start.line`) into `scripts/markdown.ts`; rewrite `doc-typecheck.ts` and `verify-type-equiv.ts` onto it; delete `md-fences.ts` and the duplicated scanner; drop the redundant fence regex in `markdownProseLines`.
-- Replace both `parseOptions` copies with `parseArgs`.
-- Replace the five straggler walks with `globSync`. Keep the walks in `check-workspace-constraints.ts` and `clean.ts`: they need dirent-level detail to diagnose malformed trees, which glob-by-pattern cannot report.
-
-## Alternatives considered
-
-- **A new glob/walking dependency (`tinyglobby`, `fdir`).** Rejected: the builtin already won repo-wide; these are stragglers, not a gap.
-- **`p-map` for `publint-all.ts`'s ~19-line ordered worker pool.** Deliberately left out: one new devDep for one small deletion is at the edge of the [dependency policy](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md) bar, and the pool's requirements (bounded workers, deterministic order, env override) are documented in the [parallel-gates note](../../implemented/process/2026-07-06-parallel-pre-push-gates.md). Fold it in only if `p-map` earns a second consumer.
-- **Leaving the fence scanners.** Rejected: two drifting copies of a parser beside a third correct implementation is exactly the duplication the shared `markdown.ts` helper exists to prevent, and the column-0-backtick-only limitation is a latent inconsistency between sibling gates.
-
-## Acceptance criteria
-
-- `md-fences.ts` is gone; `doc-typecheck` and `verify-type-equiv` extract fences through `scripts/markdown.ts`; `pnpm run doc-sync` passes with unchanged results on the current tree (any delta traces to a fence shape the regex scanners mishandled).
-- Both CLIs parse via `parseArgs`; unknown options still fail loud.
-- The five walk sites use `globSync`; the gates they feed pass unchanged.
-
-## Risks
-
-- Behavioral deltas on pathological markdown: mdast honors tilde/indented fences the regex scanners ignored, so `doc-typecheck`'s opt-out ratio could shift if any stray fence shape exists in the docs tree; verify by running `doc-sync` before/after.
-- `parseArgs` keeps the last value of a duplicated option instead of erroring — a dev-tool edge case the tests don't pin. (Strict mode still rejects a `--`-prefixed token where a value is expected, matching the current parsers.)

+ 0 - 38
.agents/notes/proposed/simplification/2026-07-26-consolidate-gate-scripts-on-existing-deps.zh.md

@@ -1,38 +0,0 @@
-# Agent Note: 把门禁脚本统一到已有依赖与内置模块上
-
-Status: proposed
-
-[English](2026-07-26-consolidate-gate-scripts-on-existing-deps.md) | 中文
-
-## 问题
-
-`scripts/` 下的门禁大多已经在用正确的工具(15 个以上的门禁使用 `node:fs` 的 `globSync`,markdown 门禁使用 mdast/micromark),但少数几个掉队的脚本仍在手写同类门禁早已用既有依赖或内置模块完成的事情:
-
-- **重复的围栏扫描器。**`scripts/md-fences.ts`(约 55 行,由 `doc-typecheck.ts` 消费)和 `scripts/verify-type-equiv.ts` 中的 `extractEquivBlocks`(约 39 行)是同一个围栏代码块正则行扫描器的两份拷贝,而 `scripts/verify-mermaid.ts` 已经通过共享的 `scripts/markdown.ts` 辅助函数访问 mdast `code` 节点来提取代码围栏;`markdown.ts` 自己的 `markdownProseLines` 也是先解析成 mdast,再用第二个正则手工跟踪围栏状态。这两个正则扫描器只识别第 0 列的反引号围栏,因此在波浪线围栏和缩进围栏上与基于 mdast 的门禁悄悄不一致。
-- **手写的 argv 解析。**`scripts/publint-all.ts` 中的 `parseOptions` 和 `scripts/verify-built-package-invariants.mjs` 中与之几乎相同的拷贝(约 26 行)手工推进 argv 下标,而同类脚本(`verify-runtime-closure.ts`、`build-exe-for-python-sdk.ts`、`packages/sdk/scripts/src/args.ts`)已经在使用 `node:util` 的内置 `parseArgs`。
-- **手写的目录遍历。**五处代码各自重写了 `globSync` 已覆盖的嵌套 `readdirSync` 遍历:`verify-runtime-closure.ts` 对 packages 与 vendor manifest(元数据清单)的扫描、`dev-web.ts` 的 `discoverPluginDirs`、`verify-package-paths.ts` 的 `realPackageNames`、`verify-client-domain-graph.ts` 的 `listSources`,以及 `publint-all.ts` 的 `addPath`(合计约 55–65 行)。`scripts/package-invariants.ts` 展示了一行式的 `globSync` 模板。
-
-所有替换都不需要引入新依赖;每一处替换用的都是既有的 devDependency 或 Node 内置模块。
-
-## 提案
-
-- 在 `scripts/markdown.ts` 中提取一个约 10–15 行的共享 mdast 围栏辅助函数(访问 `code` 节点,读取 `lang`、`meta`、`value`、`position.start.line`);把 `doc-typecheck.ts` 和 `verify-type-equiv.ts` 改写到它上面;删除 `md-fences.ts` 和重复的扫描器;去掉 `markdownProseLines` 中冗余的围栏正则。
-- 用 `parseArgs` 替换两份 `parseOptions` 拷贝。
-- 用 `globSync` 替换那五处掉队的目录遍历。保留 `check-workspace-constraints.ts` 和 `clean.ts` 中的遍历:它们需要 dirent 级别的细节来诊断结构异常的目录树,按模式匹配的 glob 报告不了这些信息。
-
-## 曾考虑的替代方案
-
-- **新的 glob/目录遍历依赖(`tinyglobby`、`fdir`)。**不予采纳:内置模块已在全仓库范围内胜出;这几处只是掉队者,不是能力缺口。
-- **用 `p-map` 替换 `publint-all.ts` 中约 19 行的有序 worker 池。**刻意未纳入:为一次小删除引入一个新 devDependency,正处在[依赖策略](../../implemented/process/2026-07-26-dependencies-over-hand-rolling.md)门槛的边缘,而且该池的需求(worker 数量有界、确定性顺序、环境变量覆盖)已记录在[并行 pre-push 门禁决策记录](../../implemented/process/2026-07-06-parallel-pre-push-gates.md)中。仅当 `p-map` 赢得第二个消费方时再顺带纳入。
-- **保留这两个围栏扫描器。**不予采纳:在第三个正确实现旁边放着两份逐渐漂移的解析器拷贝,正是共享的 `markdown.ts` 辅助函数要防止的那种重复;「只认第 0 列反引号」的限制也是同类门禁之间的潜在不一致。
-
-## 验收标准
-
-- `md-fences.ts` 已删除;`doc-typecheck` 与 `verify-type-equiv` 通过 `scripts/markdown.ts` 提取代码围栏;`pnpm run doc-sync` 在当前代码树上通过且结果不变(如有差异,必须能追溯到正则扫描器处理有误的某种围栏形态)。
-- 两个 CLI 都改用 `parseArgs` 解析;未知选项仍然大声失败。
-- 五处遍历代码改用 `globSync`;它们供给的门禁保持原样通过。
-
-## 风险
-
-- 病态 markdown 上的行为差异:mdast 会承认正则扫描器忽略的波浪线围栏和缩进围栏,因此如果文档树中存在任何零散的此类围栏形态,`doc-typecheck` 的 opt-out 比例可能变化;应在改动前后分别运行 `doc-sync` 加以验证。
-- `parseArgs` 对重复出现的选项保留最后一个值而不报错——一个测试未固定的开发工具边缘用例。(严格模式下,需要取值处遇到以 `--` 开头的 token 仍会拒绝,与现有解析器行为一致。)

+ 1 - 1
.github/AGENTS.md

@@ -1,3 +1,3 @@
 # AGENTS.md — GitHub Actions
 
-Run Windows jobs under native `pwsh`.
+Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is not one of them: it runs Windows Node under Wine on hosted Linux, so its steps are bash — see the [Wine lane Agent Note](../.agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md).

+ 205 - 22
.github/workflows/ci.yml

@@ -281,41 +281,224 @@ jobs:
       - name: Run complete keyless Python suite
         run: uv run --python 3.10 --group test --project python/sdk pytest
 
-  # One standard Windows box shares setup across the required build/site checks
-  # and the observational portability inventory. Serial worker bounds keep this
-  # recovery path portable; Linux owns duplicate lint, coverage, and snapshots.
+  # The required pull-request Windows signal: the two blocking win32 surfaces
+  # (workspace build, production site) execute with real, checksum-verified
+  # Windows Node under Wine on standard hosted Linux. The master
+  # serial-windows job below keeps the complete native-kernel inventory —
+  # including the observational portability gates this lane does not run —
+  # on real windows-2025. Direct tool entrypoints stand in for pnpm's cmd
+  # shims, which a Linux-side install does not create; layout, fidelity
+  # limits, and measured timings live in
+  # .agents/notes/implemented/process/2026-07-27-wine-windows-gates-experiment.md
   windows:
     if: github.event_name == 'pull_request'
-    runs-on: windows-2025
-    name: windows node 24 / complete
+    runs-on: ubuntu-latest
+    name: windows node 24 / wine blocking
+    timeout-minutes: 15
     env:
-      DSH_COVERAGE_MAX_WORKERS: '1'
-      DSH_GATE_CONCURRENCY: '1'
-      DSH_PUBLINT_CONCURRENCY: '1'
+      WINEDEBUG: '-all'
+      WINEARCH: win64
+      # Skip Wine Mono / Gecko installers: Node needs neither.
+      WINEDLLOVERRIDES: 'mscoree,mshtml='
     steps:
       - uses: actions/checkout@v6
-
-      - name: Enable Developer Mode (symlink support)
-        shell: pwsh
-        run: >-
-          reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
-          /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
+        with:
+          persist-credentials: false
 
       - uses: actions/setup-node@v6
         with:
           node-version: ${{ env.PRIMARY_NODE_VERSION }}
 
-      # Extracting the many-file pnpm store cache is slower than a clean install,
-      # and saving it adds more latency after gates.
-      - name: Enable corepack and install (immutable)
-        shell: pwsh
+      - uses: actions/cache/restore@v4
+        with:
+          path: /home/runner/.local/share/pnpm/store/v11
+          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+          restore-keys: |
+            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
+      # Master's wine-apt-cache job seeds the default-branch scope every pull
+      # request can read; a save from this job only reaches reruns of the
+      # same merge ref.
+      - name: Compose Wine apt cache key
+        id: wine-cache-key
+        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
+
+      - uses: actions/cache@v4
+        with:
+          path: ~/wine-debs
+          key: ${{ steps.wine-cache-key.outputs.key }}
+
+      - name: Install dependencies and provision Wine concurrently
         run: |
           corepack enable
-          pnpm install --frozen-lockfile
 
-      - name: Run blocking and observational Windows gates concurrently
-        shell: pwsh
-        run: pnpm run check:ci:windows-complete
+          # Windows-lane install-time overrides. supportedArchitectures
+          # additionally materializes the win32-x64 platform packages
+          # (@esbuild/win32-x64, rolldown and rollup MSVC bindings) the
+          # Windows toolchain resolves at runtime; nodeLinker: hoisted lays
+          # node_modules out flat with real files because Windows Node under
+          # Wine does not realpath pnpm's isolated-layout symlinks. Neither
+          # override is recorded in the lockfile, so --frozen-lockfile stays
+          # valid. --ignore-scripts skips Linux lifecycle scripts no gate in
+          # this lane loads; the win32 binaries ship prebuilt.
+          cat >> pnpm-workspace.yaml <<'EOF'
+
+          nodeLinker: hoisted
+          supportedArchitectures:
+            os: [current, win32]
+            cpu: [current, x64]
+          EOF
+
+          pnpm install --frozen-lockfile --ignore-scripts &
+          install_pid=$!
+
+          provision_wine() {
+            set -euo pipefail
+            # Wine from the apt cache when present; else download the full
+            # dependency closure once and keep it for the next run. The
+            # `wine` dispatcher package (not bare `wine64`) is what puts a
+            # binary on PATH.
+            if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
+              sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
+            else
+              sudo apt-get update
+              sudo apt-get install -y --no-install-recommends --download-only wine
+              mkdir -p "$HOME/wine-debs"
+              cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
+              sudo apt-get install -y --no-install-recommends wine
+            fi
+            WINE_BIN=''
+            for candidate in "$(command -v wine || true)" "$(command -v wine64 || true)" /usr/lib/wine/wine64; do
+              if [ -n "$candidate" ] && [ -x "$candidate" ]; then WINE_BIN="$candidate"; break; fi
+            done
+            [ -n "$WINE_BIN" ] || { echo '::error::no wine binary found after install'; exit 1; }
+            echo "WINE_BIN=$WINE_BIN" >> "$GITHUB_ENV"
+
+            # Windows Node for the repo's primary line, checksum-verified
+            # against the same dist directory.
+            version=$(curl -fsSL https://nodejs.org/dist/index.json \
+              | jq -r --arg p "v${PRIMARY_NODE_VERSION}." '[.[] | select(.version | startswith($p))][0].version')
+            echo "Windows Node: $version"
+            curl -fsSL -o "$RUNNER_TEMP/node-win.zip" \
+              "https://nodejs.org/dist/${version}/node-${version}-win-x64.zip"
+            curl -fsSL "https://nodejs.org/dist/${version}/SHASUMS256.txt" \
+              | awk -v a="node-${version}-win-x64.zip" '$2 == a { print $1 "  '"$RUNNER_TEMP"'/node-win.zip" }' \
+              | sha256sum --check -
+            unzip -q "$RUNNER_TEMP/node-win.zip" -d "$RUNNER_TEMP/node-win"
+            echo "NODE_WIN=$RUNNER_TEMP/node-win/node-${version}-win-x64/node.exe" >> "$GITHUB_ENV"
+
+            "$WINE_BIN" wineboot --init || true
+            wineserver -w || true
+          }
+          provision_wine &
+          wine_pid=$!
+
+          install_status=0
+          wait "$install_pid" || install_status=$?
+          wine_status=0
+          wait "$wine_pid" || wine_status=$?
+          if (( install_status != 0 )); then exit "$install_status"; fi
+          exit "$wine_status"
+
+      - name: Resolve entrypoints, link vue, smoke Windows Node
+        run: |
+          # Node under Wine cannot attach stdio to the Actions runner's pipes
+          # (Socket open EBADF at bootstrap), so every invocation runs through
+          # this wrapper: stdio to a regular file, replayed after exit.
+          cat > "$RUNNER_TEMP/wine-node.sh" <<'SH'
+          #!/usr/bin/env bash
+          set -u
+          log="$1"; shift
+          "$WINE_BIN" "$NODE_WIN" "$@" < /dev/null > "$log" 2>&1
+          status=$?
+          tail -n 300 "$log"
+          exit "$status"
+          SH
+          chmod +x "$RUNNER_TEMP/wine-node.sh"
+
+          resolve() {
+            local name="$1"; shift
+            for p in "$@"; do
+              if [ -f "$p" ]; then echo "$name=$PWD/$p" >> "$GITHUB_ENV"; return 0; fi
+            done
+            echo "::error::$name not found at any of: $*"; return 1
+          }
+          resolve TSC_JS node_modules/typescript/bin/tsc
+          resolve TSDOWN_JS node_modules/tsdown/dist/run.mjs
+          resolve VITEPRESS_JS website/node_modules/vitepress/bin/vitepress.js node_modules/vitepress/bin/vitepress.js
+
+          # VitePress links vue into the site's node_modules at build time;
+          # Wine cannot CREATE Windows symlinks (ENOTSUP) but follows
+          # pre-existing Unix ones, so lay the link down host-side.
+          if [ -d node_modules/vue ] && [ ! -e website/node_modules/vue ]; then
+            mkdir -p website/node_modules
+            ln -s ../../node_modules/vue website/node_modules/vue
+          fi
+
+          "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/smoke.log" -p "'smoke: ' + process.platform + ' ' + process.arch + ' ' + process.version"
+
+      # The two blocking surfaces run concurrently, the same shape run-gates
+      # gives ci-windows-blocking on native Windows: `build` = tsc -b then
+      # tsdown, `production site` = the VitePress build. Both statuses are
+      # captured so one failure cannot hide the other's result.
+      - name: Run blocking Windows gates concurrently under Wine
+        run: |
+          build_gate() {
+            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsc.log" "$TSC_JS" -b --pretty false || return $?
+            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/tsdown.log" "$TSDOWN_JS"
+          }
+          site_gate() {
+            cd website
+            "$RUNNER_TEMP/wine-node.sh" "$RUNNER_TEMP/site.log" "$VITEPRESS_JS" build .
+          }
+          start=$SECONDS
+          build_gate > "$RUNNER_TEMP/build-gate.out" 2>&1 &
+          build_pid=$!
+          site_gate > "$RUNNER_TEMP/site-gate.out" 2>&1 &
+          site_pid=$!
+          build_status=0
+          wait "$build_pid" || build_status=$?
+          site_status=0
+          wait "$site_pid" || site_status=$?
+          echo "== build gate (exit $build_status, $((SECONDS - start))s elapsed) =="
+          tail -n 120 "$RUNNER_TEMP/build-gate.out"
+          echo "== production site gate (exit $site_status, $((SECONDS - start))s elapsed) =="
+          tail -n 120 "$RUNNER_TEMP/site-gate.out"
+          if (( build_status != 0 )); then exit "$build_status"; fi
+          exit "$site_status"
+
+      - name: Shut down wineserver
+        if: always()
+        run: wineserver -k 2>/dev/null || true
+
+  # Master seeds the Wine apt-archive cache in the default-branch scope,
+  # which every pull request's windows job can restore; saves from
+  # pull-request runs are scoped to their own merge ref and help nobody
+  # else. Runs in seconds when the image version already has a cache.
+  wine-apt-cache:
+    if: github.event_name == 'push' && github.ref == 'refs/heads/master'
+    name: wine apt cache
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - name: Compose Wine apt cache key
+        id: wine-cache-key
+        run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
+
+      - uses: actions/cache@v4
+        id: wine-cache
+        with:
+          path: ~/wine-debs
+          key: ${{ steps.wine-cache-key.outputs.key }}
+
+      - name: Download the Wine dependency closure
+        if: steps.wine-cache.outputs.cache-hit != 'true'
+        run: |
+          sudo apt-get update
+          sudo apt-get install -y --no-install-recommends --download-only wine
+          mkdir -p "$HOME/wine-debs"
+          cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/"
+          du -sh "$HOME/wine-debs"
 
   # Master pushes run only the serial reference jobs below.
   # Each host executes the complete, unsharded primary Node aggregate with one

+ 5 - 16
scripts/dev-web.ts

@@ -17,8 +17,8 @@
  * `watch` through API-level inline config (tsdown workspace mode fills inline
  * keys under each package's file config, and no package config defines it).
  */
-import { readdirSync, readFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { globSync, readFileSync } from 'node:fs'
+import { dirname, join, sep } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { build } from 'tsdown'
 
@@ -33,20 +33,9 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
  */
 function discoverPluginDirs(): string[] {
   const dirs: string[] = []
-  for (const group of readdirSync(join(repoRoot, 'packages'), { withFileTypes: true })) {
-    if (!group.isDirectory()) continue
-    for (const pkg of readdirSync(join(repoRoot, 'packages', group.name), { withFileTypes: true })) {
-      if (!pkg.isDirectory()) continue
-      let manifest: { dshClient?: { platform?: unknown } }
-      try {
-        manifest = JSON.parse(
-          readFileSync(join(repoRoot, 'packages', group.name, pkg.name, 'package.json'), 'utf8'),
-        ) as { dshClient?: { platform?: unknown } }
-      } catch {
-        continue // no package.json (support dirs, scratch): not a workspace package
-      }
-      if (manifest.dshClient?.platform === 'web') dirs.push(`packages/${group.name}/${pkg.name}`)
-    }
+  for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
+    const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
+    if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
   }
   return dirs
 }

+ 5 - 3
scripts/doc-typecheck.ts

@@ -10,7 +10,7 @@ import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node
 import { join, relative, resolve } from 'node:path'
 import ts from 'typescript'
 import { builtDeclarationPath } from './doc-typecheck-paths.ts'
-import { extractFences } from './md-fences.ts'
+import { markdownFences } from './markdown.ts'
 import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
 import { isArchivedAgentNotePath } from './repo-files.ts'
 
@@ -46,8 +46,10 @@ const KIND_BY_INFO: Record<string, BlockKind> = {
 /** Extract every recognized TypeScript fence from one Markdown file. */
 function extractBlocks(absPath: string): Block[] {
   const file = relative(root, absPath)
-  return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
-    .map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
+  return markdownFences(readFileSync(absPath, 'utf8')).flatMap((fence) => {
+    const kind = KIND_BY_INFO[fence.info]
+    return kind === undefined ? [] : [{ file, line: fence.line, kind, code: fence.code }]
+  })
 }
 
 const configHost: ts.ParseConfigFileHost = {

+ 47 - 14
scripts/markdown.ts

@@ -21,6 +21,24 @@ export interface MarkdownHeadingLine extends MarkdownProseLine {
   text: string
 }
 
+/** One code block from a parsed Markdown source. */
+export interface MarkdownFence {
+  /** 1-based source line of the opening fence. */
+  line: number
+  /** Info-string language (its first word), null on a bare or indented block. */
+  lang: string | null
+  /** Full info string (e.g. `ts ignore-check`), '' on a bare or indented block. */
+  info: string
+  /** Block body without the fence delimiters. */
+  code: string
+  /**
+   * Whether a closing fence delimiter terminates the block — mdast silently
+   * closes an unterminated fence at end of file. False on indented
+   * (non-fenced) blocks, whose end line is code.
+   */
+  closed: boolean
+}
+
 /** Parse GitHub-flavored Markdown with the repository's standard extensions. */
 export function parseMarkdown(source: string): Nodes {
   return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
@@ -38,6 +56,26 @@ export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | v
   }
 }
 
+/**
+ * Extract every parsed code block with its info string, in document order.
+ * @param source - Markdown source to scan.
+ * @returns each block's opening line, language, info string, and body.
+ */
+export function markdownFences(source: string): MarkdownFence[] {
+  const lines = source.split('\n')
+  const fences: MarkdownFence[] = []
+  visitMarkdown(parseMarkdown(source), (node) => {
+    if (node.type !== 'code' || node.position === undefined) return
+    const lang = node.lang ?? null
+    const meta = node.meta ?? ''
+    const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}`
+    const endLine = lines[node.position.end.line - 1] ?? ''
+    const closed = /^ {0,3}(`{3,}|~{3,})\s*$/.test(endLine)
+    fences.push({ line: node.position.start.line, lang, info, code: node.value, closed })
+  })
+  return fences
+}
+
 /** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
 function renderedText(node: Nodes): string {
   if (node.type === 'text' || node.type === 'inlineCode') return node.value
@@ -115,27 +153,22 @@ function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRang
 }
 
 /**
- * Return source lines outside backtick or tilde fences and HTML comments.
+ * Return source lines outside code blocks and HTML comments.
  * @param source - Markdown source whose prose should be retained verbatim.
  * @returns unfenced lines with their original 1-based locations.
  */
 export function markdownProseLines(source: string): MarkdownProseLine[] {
-  let fence: { marker: '`' | '~'; length: number } | undefined
-  const kept: MarkdownProseLine[] = []
   const rawLines = source.split('\n')
   const comments = htmlCommentRanges(source, rawLines)
+  const fenced = new Set<number>()
+  visitMarkdown(parseMarkdown(source), (node) => {
+    if (node.type !== 'code' || node.position === undefined) return
+    for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line)
+  })
+  const kept: MarkdownProseLine[] = []
   rawLines.forEach((raw, i) => {
-    const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
-    if (token !== undefined) {
-      const marker = token[0] as '`' | '~'
-      if (fence === undefined) {
-        fence = { marker, length: token.length }
-      } else if (marker === fence.marker && token.length >= fence.length) {
-        fence = undefined
-      }
-      return
-    }
-    if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
+    if (fenced.has(i + 1)) return
+    if (hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
       kept.push({ index: i + 1, raw })
     }
   })

+ 0 - 55
scripts/md-fences.ts

@@ -1,55 +0,0 @@
-/**
- * Shared fenced-code-block extractor for the Markdown doc gates
- * (currently `doc-typecheck.ts`; future Markdown gates can share it). One scanner, per-gate
- * classification: each gate maps a fence info string (` ```ts `,
- * ` ```yaml ignore-check `, …) to its own kind tag and receives every
- * classified block with its 1-based opening-fence line.
- */
-
-import { readFileSync } from 'node:fs'
-
-/** One extracted fenced block, classified by the caller's `classify`. */
-export interface Fence<K> {
-  /** 1-based line of the opening fence. */
-  line: number
-  kind: K
-  code: string
-}
-
-/**
- * Extract every fenced block of `absPath` whose info string `classify` maps
- * to a kind. Blocks classified `null` are skipped (their bodies are still
- * consumed, so an unrelated fence can never leak into a tracked one).
- *
- * @param absPath — absolute path of the Markdown file.
- * @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
- *   null for fences this gate does not track.
- * @returns the classified blocks in document order.
- */
-export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
-  const lines = readFileSync(absPath, 'utf8').split('\n')
-  const blocks: Fence<K>[] = []
-  let open: { line: number; kind: K; body: string[] } | null = null
-  let skipping = false
-
-  lines.forEach((raw, i) => {
-    const fence = /^```(\s*)(\S.*)?$/.exec(raw)
-    if (!fence) {
-      if (open) open.body.push(raw)
-      return
-    }
-    if (open) {
-      blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
-      open = null
-      return
-    }
-    if (skipping) {
-      skipping = false
-      return
-    }
-    const kind = classify((fence[2] ?? '').trim())
-    if (kind !== null) open = { line: i + 1, kind, body: [] }
-    else skipping = true
-  })
-  return blocks
-}

+ 13 - 18
scripts/publint-all.ts

@@ -2,19 +2,23 @@
 
 import {
   globSync,
-  readFileSync,
   readdirSync,
+  readFileSync,
   statSync,
 } from 'node:fs'
 import { availableParallelism } from 'node:os'
 import { dirname, relative, resolve, sep } from 'node:path'
+import { parseArgs } from 'node:util'
 import { publint, type Message, type PackFile } from 'publint'
 import { formatMessage } from 'publint/utils'
 
 const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
 const repositoryRoot = resolve(import.meta.dirname, '..')
-const options = parseOptions(process.argv.slice(2))
-const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
+const { values: options } = parseArgs({
+  args: process.argv.slice(2),
+  options: { 'packages-root': { type: 'string' } },
+})
+const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
 
 interface PackageTarget {
   path: string
@@ -88,7 +92,12 @@ function publicationFiles(target: PackageTarget): PackFile[] {
 function addPath(path: string, paths: Set<string>): void {
   const stat = statSync(path)
   if (stat.isDirectory()) {
-    for (const entry of readdirSync(path)) addPath(resolve(path, entry), paths)
+    // readdirSync, not globSync: `**/*` skips dot-prefixed segments, but npm
+    // pack publishes dotfiles inside included directories, and this view must
+    // match what npm publishes.
+    for (const entry of readdirSync(path, { recursive: true, withFileTypes: true })) {
+      if (entry.isFile()) paths.add(resolve(entry.parentPath, entry.name))
+    }
   } else if (stat.isFile()) {
     paths.add(path)
   }
@@ -144,20 +153,6 @@ function printResult(result: PublintResult): void {
   if (result.status === 'passed' && result.messages.length === 0) console.log('All good!')
 }
 
-function parseOptions(args: string[]): Map<string, string> {
-  const parsed = new Map<string, string>()
-  for (let index = 0; index < args.length; index += 2) {
-    const name = args[index]
-    const value = args[index + 1]
-    if (name !== '--packages-root' || value === undefined || value.startsWith('--')) {
-      throw new Error(`publint-all: expected [--packages-root PATH], got ${JSON.stringify(args)}.`)
-    }
-    if (parsed.has(name)) throw new Error(`publint-all: duplicate option ${name}.`)
-    parsed.set(name, value)
-  }
-  return parsed
-}
-
 const packages = workspacePackages()
 const concurrency = publintConcurrency(packages.length)
 console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)

+ 7 - 18
scripts/verify-built-package-invariants.mjs

@@ -13,11 +13,15 @@ import {
 } from 'node:fs'
 import { dirname, resolve } from 'node:path'
 import { pathToFileURL } from 'node:url'
+import { parseArgs } from 'node:util'
 
 const repositoryRoot = resolve(import.meta.dirname, '..')
-const options = parseOptions(process.argv.slice(2))
-const packagesRoot = resolve(options.get('--packages-root') ?? repositoryRoot)
-const loaderUrl = options.get('--loader-url')
+const { values: options } = parseArgs({
+  args: process.argv.slice(2),
+  options: { 'packages-root': { type: 'string' }, 'loader-url': { type: 'string' } },
+})
+const packagesRoot = resolve(options['packages-root'] ?? repositoryRoot)
+const loaderUrl = options['loader-url']
   ?? pathToFileURL(resolve(repositoryRoot, 'vendor/loader/lib/index.js')).href
 const failures = []
 const manifests = globSync('packages/*/*/package.json', { cwd: packagesRoot }).sort()
@@ -77,21 +81,6 @@ if (failures.length > 0) {
 
 console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
 
-function parseOptions(args) {
-  const allowed = new Set(['--packages-root', '--loader-url'])
-  const parsed = new Map()
-  for (let index = 0; index < args.length; index += 2) {
-    const name = args[index]
-    const value = args[index + 1]
-    if (!allowed.has(name) || value === undefined || value.startsWith('--')) {
-      throw new Error(`verify-built-package-invariants: expected [--packages-root PATH] [--loader-url URL], got ${JSON.stringify(args)}.`)
-    }
-    if (parsed.has(name)) throw new Error(`verify-built-package-invariants: duplicate option ${name}.`)
-    parsed.set(name, value)
-  }
-  return parsed
-}
-
 function copyDeclaredLibFiles(packageDir, stagedPackageDir, files) {
   for (const pattern of files) {
     if (!pattern.startsWith('lib/')) continue

+ 7 - 11
scripts/verify-client-domain-graph.ts

@@ -14,8 +14,8 @@
  *   pnpm exec tsx scripts/verify-client-domain-graph.ts
  */
 
-import { readdirSync, readFileSync, statSync } from 'node:fs'
-import { join, resolve } from 'node:path'
+import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
+import { join, resolve, sep } from 'node:path'
 
 const root = resolve(import.meta.dirname, '..')
 const CLIENT_DIR = join(root, 'packages/client')
@@ -28,15 +28,11 @@ const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
 interface Violation { file: string; imported: string; reason: string }
 
 /** Recursively list .ts/.tsx files under dir (relative paths). */
-function listSources(dir: string, prefix = ''): string[] {
-  const out: string[] = []
-  for (const name of readdirSync(dir)) {
-    const full = join(dir, name)
-    const rel = prefix ? `${prefix}/${name}` : name
-    if (statSync(full).isDirectory()) out.push(...listSources(full, rel))
-    else if (/\.tsx?$/.test(name) && !/\.legacy\./.test(name)) out.push(rel)
-  }
-  return out
+function listSources(dir: string): string[] {
+  return globSync('**/*.{ts,tsx}', { cwd: dir })
+    .map(rel => rel.split(sep).join('/'))
+    .filter(rel => !/\.legacy\./.test(rel.slice(rel.lastIndexOf('/') + 1)))
+    .sort()
 }
 
 /** First path segment of a client-relative file, or '' for top-level files. */

+ 3 - 7
scripts/verify-package-paths.ts

@@ -5,7 +5,7 @@
  * outside the check.
  */
 
-import { existsSync, readdirSync } from 'node:fs'
+import { existsSync, globSync } from 'node:fs'
 import { resolve } from 'node:path'
 import {
   findReferenceViolations,
@@ -41,12 +41,8 @@ const isExcluded = (p: string): boolean =>
  */
 function realPackageNames(): Set<string> {
   const names = new Set<string>()
-  const pkgRoot = resolve(root, 'packages')
-  for (const group of readdirSync(pkgRoot, { withFileTypes: true })) {
-    if (!group.isDirectory()) continue
-    for (const pkg of readdirSync(resolve(pkgRoot, group.name), { withFileTypes: true })) {
-      if (pkg.isDirectory()) names.add(pkg.name)
-    }
+  for (const pkg of globSync('packages/*/*', { cwd: root, withFileTypes: true })) {
+    if (pkg.isDirectory()) names.add(pkg.name)
   }
   return names
 }

+ 6 - 16
scripts/verify-runtime-closure.ts

@@ -3,8 +3,9 @@
  * peer in its dependency graph. With auto peer installation disabled, a missing
  * root peer can otherwise fail only when Cordis loads the packaged plugin.
  */
-import { readFile, readdir } from 'node:fs/promises'
-import { join, resolve } from 'node:path'
+import { globSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
 import { parseArgs } from 'node:util'
 
 interface PackageManifest {
@@ -72,15 +73,9 @@ if (failures.length > 0) {
 console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
 
 async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
-  const paths: string[] = []
-  for (const group of await childDirectories(join(root, 'packages'))) {
-    for (const packageDir of await childDirectories(join(root, 'packages', group))) {
-      paths.push(join(root, 'packages', group, packageDir, 'package.json'))
-    }
-  }
-  for (const packageDir of await childDirectories(join(root, 'vendor'))) {
-    paths.push(join(root, 'vendor', packageDir, 'package.json'))
-  }
+  const paths = globSync(['packages/*/*/package.json', 'vendor/*/package.json'], { cwd: root })
+    .sort()
+    .map(relative => resolve(root, relative))
   const result = new Map<string, WorkspacePackage>()
   for (const path of paths) {
     const manifest = await loadManifest(path)
@@ -89,11 +84,6 @@ async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
   return result
 }
 
-async function childDirectories(path: string): Promise<string[]> {
-  const entries = await readdir(path, { withFileTypes: true })
-  return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
-}
-
 async function loadManifest(path: string): Promise<PackageManifest> {
   return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
 }

+ 17 - 31
scripts/verify-type-equiv.ts

@@ -11,6 +11,7 @@
 import { globSync, readFileSync, existsSync } from 'node:fs'
 import { resolve, sep } from 'node:path'
 import ts from 'typescript'
+import { markdownFences } from './markdown.ts'
 import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
 import { isArchivedAgentNotePath } from './repo-files.ts'
 
@@ -81,42 +82,27 @@ function blockSymbol(code: string): string | null {
 
 /** Extract every source-equivalence block from one Markdown file. */
 function extractEquivBlocks(docRel: string): EquivBlock[] {
-  const text = readFileSync(resolve(root, docRel), 'utf8')
-  const lines = text.split('\n')
   const blocks: EquivBlock[] = []
-  let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
-
-  for (let i = 0; i < lines.length; i++) {
-    const raw = lines[i] ?? ''
-    const fence = /^```(\s*)(\S.*)?$/.exec(raw)
-    if (!fence) {
-      if (open) open.body.push(raw)
-      continue
+  for (const fence of markdownFences(readFileSync(resolve(root, docRel), 'utf8'))) {
+    if (fence.info === 'ts type-equiv public-api') {
+      throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — use the concise \`ts public-api\` fence`)
     }
-    if (open) {
-      const code = open.body.join('\n')
-      const symbol = blockSymbol(code)
-      if (!symbol) {
-        throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
-      }
-      blocks.push({
-        doc: docRel,
-        line: open.line,
-        symbol,
-        code,
-        ...(open.projection === undefined ? {} : { projection: open.projection }),
-      })
-      open = null
-      continue
+    if (fence.info !== 'ts type-equiv' && fence.info !== 'ts public-api') continue
+    if (!fence.closed) {
+      throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — unterminated type-equivalence fence (missing closing \`\`\`)`)
     }
-    const info = (fence[2] ?? '').trim()
-    if (info === 'ts type-equiv public-api') {
-      throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
+    const symbol = blockSymbol(fence.code)
+    if (symbol === null) {
+      throw new Error(`verify-type-equiv: ${docRel}:${fence.line} — type-equiv block has no parseable interface/type/class declaration`)
     }
-    if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
-    if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
+    blocks.push({
+      doc: docRel,
+      line: fence.line,
+      symbol,
+      code: fence.code,
+      ...(fence.info === 'ts public-api' ? { projection: 'public-api' as const } : {}),
+    })
   }
-  if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
   return blocks
 }