From fd5931752cbae16b2db39ae289dbf6f3d04413e4 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 6 Jul 2026 01:28:20 +0800 Subject: [PATCH 01/10] ci: add Windows test job (windows-2025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Windows CI job alongside the existing Linux checks. Runs the full test suite (without the Linux-only coverage gate) plus typecheck, lint, doc-sync, build, hygiene, and demo smoke under PowerShell. Developer Mode is enabled via registry for symlink support (fs-local tests, verify-node-next-types). Per the windows-support RFC transition plan: step (2) — non-required Windows CI job to observe stability. --- .github/workflows/ci.yml | 81 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 884a0417e3..399d517d29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,10 +160,9 @@ jobs: - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest - # Windows build lane: install + `pnpm run build` (tsc -b + tsdown) on native - # Windows. Windows path/shell support is still partial, so this lane covers - # the build surface only — tests and gates are not run here yet. Wired into - # all-checks-passed so a native-Windows build regression cannot land silently. + # Blocking Windows build lane: keep the already-green native build protected + # while the broader observational gate job below exposes the remaining + # portability work without blocking mainline merges. windows-build: runs-on: windows-2025 name: windows / build @@ -183,11 +182,79 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build + # Observational Windows mirror of the Linux gates. Snapshot stays Linux-only + # while its replay goldens remain platform-specific. This job intentionally + # stays out of all-checks-passed.needs. + windows-gates: + runs-on: windows-2025 + name: windows node 24 + env: + DSH_GATE_CONCURRENCY: '2' + DSH_PUBLINT_CONCURRENCY: '8' + DSH_COVERAGE_MAX_WORKERS: '4' + DSH_ESLINT_CACHE: '1' + steps: + - uses: actions/checkout@v6 + + - name: Enable Developer Mode (symlink support) + shell: powershell + run: >- + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" + /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Enable corepack (pnpm) + shell: powershell + run: corepack enable + + - name: Resolve pnpm store path + id: pnpm-store + shell: powershell + run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' + + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + shell: powershell + run: pnpm install --frozen-lockfile + + - uses: actions/cache@v4 + with: + path: .cache/eslint + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- + + - name: Run static gates + shell: powershell + run: pnpm run check:ci:static + + - name: Run lint gates + shell: powershell + run: pnpm run check:ci:lint + + - name: Run coverage gates + shell: powershell + run: pnpm run check:ci:coverage + + - name: Run artifact gates + shell: powershell + run: pnpm run check:ci:artifacts + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and - # node versions evolve. Every other job in THIS workflow must be listed in - # `needs` (`needs` cannot reach across workflow files; e2e.yml stays its own - # check). `if: always()` is load-bearing: without it a failed dependency + # node versions evolve. Every blocking job in THIS workflow must be listed in + # `needs`; explicitly observational jobs such as windows-gates stay out + # (`needs` cannot reach across workflow files; e2e.yml stays its own check). + # `if: always()` is load-bearing: without it a failed dependency # would SKIP this job, and GitHub counts a skipped required check as passing # — so this job always runs and fails on any non-success result, including # 'cancelled' and 'skipped'. From 007001677d1e0ba817d10d59ee35e85c073757d8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 8 Jul 2026 12:45:45 +0800 Subject: [PATCH 02/10] ci(windows): split the Windows lane to mirror Linux's lane structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows CI was a single job running the full ci-windows inventory (check:ci:windows), while Linux splits into 5 lanes (static/lint/coverage/ snapshot/artifacts) per the parallel-gates RFC. The single-job shape was a transitional artifact from when Windows CI was added as a non-required observation lane; its rationale ('keep gate parallelism modest so coverage is not starved') conflated run-gates intra-job concurrency (DSH_GATE_CONCURRENCY) with GitHub job fan-out — orthogonal concerns. Split Windows into 4 lanes mirroring Linux (snapshot absent: its goldens are Linux-recorded and self-skip on Windows). Each lane is a separate GitHub job so a Windows regression is attributable to one lane, not buried in one job's log. Concurrency is NOT throttled versus Linux: the lane is non-blocking (continue-on-error), and the observational stance is to actively expose Windows-arm issues rather than hide them behind reduced parallelism. - scripts/run-gates.ts: add ci-windows:static/lint/coverage/artifacts modes; ci-windows (full inventory) is retained as the local one-process entry, symmetric with Linux's ci-primary. - .github/workflows/ci.yml: windows job becomes a matrix over the 4 lanes. - package.json: check:ci:windows:{static,lint,coverage,artifacts} scripts. - AGENTS.md + windows-support RFC: document the per-lane, non-blocking, unthrottled stance. Verified: scripts/caohuanqi-private/run-ci.py --windows (full check:ci:windows) — all gates green except the known hooks-claude bridge.spec waitFor timeout (pre-existing Windows subprocess-timing flake, unrelated). --- .github/workflows/ci.yml | 61 +++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 399d517d29..88abf56a3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,17 +182,45 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build - # Observational Windows mirror of the Linux gates. Snapshot stays Linux-only - # while its replay goldens remain platform-specific. This job intentionally - # stays out of all-checks-passed.needs. + # Observational Windows mirror of the Linux gate lanes. Snapshot stays + # Linux-only while its replay goldens remain platform-specific. Splitting the + # lanes makes failures attributable without changing their non-gating role. windows-gates: runs-on: windows-2025 - name: windows node 24 + name: windows node 24 / ${{ matrix.lane }} env: - DSH_GATE_CONCURRENCY: '2' - DSH_PUBLINT_CONCURRENCY: '8' - DSH_COVERAGE_MAX_WORKERS: '4' - DSH_ESLINT_CACHE: '1' + DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} + DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} + DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} + DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} + strategy: + fail-fast: false + matrix: + include: + - lane: static + command: pnpm run check:ci:static + gate_concurrency: '4' + publint_concurrency: '8' + coverage_max_workers: '' + eslint_cache: '' + - lane: lint + command: pnpm run check:ci:lint + gate_concurrency: '1' + publint_concurrency: '8' + coverage_max_workers: '' + eslint_cache: '1' + - lane: coverage + command: pnpm run check:ci:coverage + gate_concurrency: '1' + publint_concurrency: '8' + coverage_max_workers: '4' + eslint_cache: '' + - lane: artifacts + command: pnpm run check:ci:artifacts + gate_concurrency: '3' + publint_concurrency: '8' + coverage_max_workers: '' + eslint_cache: '' steps: - uses: actions/checkout@v6 @@ -216,6 +244,7 @@ jobs: run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - uses: actions/cache@v4 + if: matrix.lane == 'lint' with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -233,21 +262,9 @@ jobs: restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- - - name: Run static gates + - name: Run gates shell: powershell - run: pnpm run check:ci:static - - - name: Run lint gates - shell: powershell - run: pnpm run check:ci:lint - - - name: Run coverage gates - shell: powershell - run: pnpm run check:ci:coverage - - - name: Run artifact gates - shell: powershell - run: pnpm run check:ci:artifacts + run: ${{ matrix.command }} # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and From cb69ca80d68be66ead5e1ea0de44ec6f89a35775 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 8 Jul 2026 14:57:20 +0800 Subject: [PATCH 03/10] =?UTF-8?q?ci(windows):=20run=20the=20observational?= =?UTF-8?q?=20gate=20wrapper=20in=20pwsh=20=E2=80=94=20an=20MSYS=20parent?= =?UTF-8?q?=20leaks=20into=20the=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane-split merge moved the Run gates step to `shell: bash`, which broke it twice over. First, GHA's bash shell runs with -e, so a failing gate aborted the step before the ::warning::/exit 0 lines — the lane went red X instead of the intended yellow warning. Second, and worse, Git Bash as the PARENT of the gate run leaks MSYS environment into the suite itself, producing 8 real test failures the pwsh-launched runs (and the DSec VM runs) never saw: - bash exports PWD; the MSYS runtime rewrites it to Windows form for native children, dsh-bash-local's adaptEnv passes it through, and the executor's MSYS bash adopts it — `pwd` prints `D:/a/...` where the tests (and the executor's MSYS dialect) expect `/d/a/...` (7 tests). - cygwin enables SeBackupPrivilege on the runner's admin token; children inherit the enabled state, and libuv's FILE_FLAG_BACKUP_SEMANTICS read opens then pierce the dwShareMode=0 lock the jsonl EBUSY test holds — loadLive resolves instead of rejecting (1 test). Evidence: run 28918325498 (pwsh step, pre-merge) failed only the two hooks dispose tests since fixed by f8fd8c00; run 28921741006 (bash step) fixed those and failed exactly the 8 above, with zero relevant source diff between them. Fix: run the wrapper in pwsh — a native command's failure doesn't abort pwsh, so $LASTEXITCODE capture + ::warning:: + exit 0 works without an errexit dance, and the gates start from a native Windows shell as they do everywhere else Windows CI has been validated. Docs: the windows-support RFC drops the stale continue-on-error wording (replaced by the warning wrapper) and records the launch-environment limitation — native shell required today; making an MSYS parent a supported launch environment (PWD scrub in adaptEnv, privilege-explicit tests) is a future improvement direction. --- .github/workflows/ci.yml | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88abf56a3f..205252d1fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,8 +183,8 @@ jobs: run: pnpm run build # Observational Windows mirror of the Linux gate lanes. Snapshot stays - # Linux-only while its replay goldens remain platform-specific. Splitting the - # lanes makes failures attributable without changing their non-gating role. + # Linux-only while its replay goldens remain platform-specific. The wrapper + # runs from native PowerShell 7 so an MSYS parent cannot leak into the suite. windows-gates: runs-on: windows-2025 name: windows node 24 / ${{ matrix.lane }} @@ -225,7 +225,7 @@ jobs: - uses: actions/checkout@v6 - name: Enable Developer Mode (symlink support) - shell: powershell + shell: pwsh run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" @@ -235,12 +235,12 @@ jobs: node-version: ${{ env.PRIMARY_NODE_VERSION }} - name: Enable corepack (pnpm) - shell: powershell + shell: pwsh run: corepack enable - name: Resolve pnpm store path id: pnpm-store - shell: powershell + shell: pwsh run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - uses: actions/cache@v4 @@ -252,7 +252,7 @@ jobs: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - name: Install (immutable) - shell: powershell + shell: pwsh run: pnpm install --frozen-lockfile - uses: actions/cache@v4 @@ -263,8 +263,13 @@ jobs: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint- - name: Run gates - shell: powershell - run: ${{ matrix.command }} + shell: pwsh + run: | + ${{ matrix.command }} + if ($LASTEXITCODE -ne 0) { + Write-Output "::warning::Windows lane '${{ matrix.lane }}' failed (exit $LASTEXITCODE) — observational, does not block merge. See logs above." + } + exit 0 # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and From be4a441ecd26ecdcf9658dcded14b1a601e55a4e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Wed, 8 Jul 2026 15:59:15 +0800 Subject: [PATCH 04/10] ci(windows): non-blocking via continue-on-error; drop the warning wrapper and the demo test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ::warning:: wrapper kept the lane job green on failure — honest about not gating merges, but a Windows regression was visible only as an annotation buried in the run summary. GitHub has no yellow job state, so the choice is green+annotation (too hidden) or a red X on a non-required job (visible, still non-blocking). Take the red X: job-level continue-on-error, plain 'Run gates' step, one less wrapper. The step stays on the runner's native pwsh — never shell: bash — per the MSYS-parent leak recorded in the windows-support RFC. Also remove the temporary Windows-only failing demo test that exercised the wrapper's annotation path (REVERT ME commit a496b9ae). --- .github/workflows/ci.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 205252d1fc..fa1ac40f49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -161,7 +161,7 @@ jobs: run: uv run --python 3.10 --group test --project python/sdk pytest # Blocking Windows build lane: keep the already-green native build protected - # while the broader observational gate job below exposes the remaining + # while the broader observational gate matrix below exposes the remaining # portability work without blocking mainline merges. windows-build: runs-on: windows-2025 @@ -182,10 +182,12 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build - # Observational Windows mirror of the Linux gate lanes. Snapshot stays - # Linux-only while its replay goldens remain platform-specific. The wrapper - # runs from native PowerShell 7 so an MSYS parent cannot leak into the suite. + # Observational, non-blocking Windows mirror of the Linux gate lanes. Snapshot + # stays Linux-only while its replay goldens remain platform-specific. Run the + # gates from native PowerShell: an MSYS parent would change the environment + # being measured. This job intentionally stays out of all-checks-passed.needs. windows-gates: + continue-on-error: true runs-on: windows-2025 name: windows node 24 / ${{ matrix.lane }} env: @@ -244,7 +246,6 @@ jobs: run: '"path=$(pnpm store path --silent)" >> $env:GITHUB_OUTPUT' - uses: actions/cache@v4 - if: matrix.lane == 'lint' with: path: ${{ steps.pnpm-store.outputs.path }} key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} @@ -256,6 +257,7 @@ jobs: run: pnpm install --frozen-lockfile - uses: actions/cache@v4 + if: matrix.lane == 'lint' with: path: .cache/eslint key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }} @@ -264,12 +266,7 @@ jobs: - name: Run gates shell: pwsh - run: | - ${{ matrix.command }} - if ($LASTEXITCODE -ne 0) { - Write-Output "::warning::Windows lane '${{ matrix.lane }}' failed (exit $LASTEXITCODE) — observational, does not block merge. See logs above." - } - exit 0 + run: ${{ matrix.command }} # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and From ae7b132f62402e8bf3a0244de0bd8c9607e7ef29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:47:29 +0800 Subject: [PATCH 05/10] fix: launch pnpm gates without a Windows shell --- scripts/run-gates.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 4de4742c9d..da87a84db9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -96,8 +96,7 @@ function pnpmScript(id: string, script: string, options: Partial = {}): Ga return { id, label: options.label ?? script, - command: pnpmBin(), - args: ['run', script], + ...pnpmInvocation(['run', script]), ...options, } } @@ -106,14 +105,18 @@ function pnpmExec(id: string, args: string[], options: Partial = {}): Gate return { id, label: options.label ?? `pnpm exec ${args.join(' ')}`, - command: pnpmBin(), - args: ['exec', ...args], + ...pnpmInvocation(['exec', ...args]), ...options, } } -function pnpmBin(): string { - return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +function pnpmInvocation(args: string[]): Pick { + const entrypoint = process.env.npm_execpath + if (entrypoint === undefined || entrypoint === '') { + throw new Error('run-gates: npm_execpath is unavailable; invoke the runner through a pnpm package script.') + } + // Windows cannot spawn the pnpm.cmd shim directly; the JavaScript entrypoint keeps every host shell-free. + return { command: process.execPath, args: [entrypoint, ...args] } } function nodeOptions(...options: string[]): string { @@ -297,8 +300,7 @@ function demoSmokeGate(options: { needs?: string[] } = {}): Gate { return { id: 'demo-smoke', label: 'demo smoke', - command: pnpmBin(), - args: ['run', 'demo:echo'], + ...pnpmInvocation(['run', 'demo:echo']), input: 'echo ci smoke\n', ...dependencyOptions, verify: async (result) => { From ae8aedb2fd4942a346f3347b7140f5ff16769554 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 6 Jul 2026 02:28:44 +0800 Subject: [PATCH 06/10] fix: normalize glob paths with split(sep).join('/') on Windows glob/globSync returns host-separator paths on Windows. Nine scripts that consume these paths for split('/'), manifest-key comparison, startsWith/includes exclusion checks, or committed-output rendering now normalize with .map(s => s.split(sep).join('/')) at ingestion. This replaces the previous replaceAll('\\\\', '/') with an explicit, self-documenting OS-separator-to-POSIX conversion. --- scripts/gen-config-catalog.ts | 4 ++-- scripts/gen-cordis-catalog.ts | 6 +++--- scripts/gen-persistence-catalog.ts | 6 +++--- scripts/package-graph.ts | 4 ++-- scripts/rfc-index.ts | 4 ++-- scripts/verify-package-readme-limitations.ts | 4 ++-- scripts/verify-package-readme-model-experience.ts | 4 ++-- scripts/verify-type-equiv.ts | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 650319e358..a0f3909a36 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -8,7 +8,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { dirname, resolve, sep } from 'node:path' import ts from 'typescript' import { LINK_MAP } from './gen-cordis-catalog.ts' import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts' @@ -581,7 +581,7 @@ export function collectConfigCatalog(scanRoot: string = root): CatalogEntry[] { // workspace-package imports while individual packages are still being walked. const pkgDirByName = new Map() const manifests: { dir: string; pkg: string }[] = [] - for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).sort()) { + for (const manifestRel of globSync('packages/*/*/package.json', { cwd: scanRoot }).map(path => path.split(sep).join('/')).sort()) { const dir = manifestRel.slice(0, -'/package.json'.length) const manifest = JSON.parse(readFileSync(resolve(scanRoot, manifestRel), 'utf8')) as { name?: string; os?: string[]; cpu?: string[] } const pkg = manifest.name diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a32107b53b..05a308f1ac 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -6,7 +6,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' @@ -129,7 +129,7 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] const violations: string[] = [] - for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Events')) continue @@ -183,7 +183,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] const violations: string[] = [] - for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('interface Context')) continue diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 5c93d8875e..9469081607 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -7,7 +7,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' @@ -117,7 +117,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { const violations: string[] = [] const seen = new Map() let owningDecl: string | null = null - for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('SessionEventMap')) continue @@ -194,7 +194,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] { */ export function collectSurfaceEventTypes(scanRoot: string = root): string[] { const found: { names: string[]; source: string }[] = [] - for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()) { + for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') if (!text.includes('SurfaceEventType')) continue diff --git a/scripts/package-graph.ts b/scripts/package-graph.ts index 5e84e52b86..0853b5c1ca 100644 --- a/scripts/package-graph.ts +++ b/scripts/package-graph.ts @@ -6,7 +6,7 @@ */ import { globSync, readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { dirname, resolve, sep } from 'node:path' const SCOPE = '@deepseek-ai/dsh-' @@ -33,7 +33,7 @@ export interface PackageGraphNode { */ export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] { const packages: PackageGraphNode[] = [] - for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) { + for (const rel of globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort()) { const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as { name: string peerDependencies?: Record diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index 2d2b5ac84c..a8d7868bce 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -7,7 +7,7 @@ */ import { readFileSync, readdirSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { globSync } from 'node:fs' export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc') @@ -58,7 +58,7 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { } } for (const lifecycle of LIFECYCLES) { - for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) { + for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).map(path => path.split(sep).join('/')).sort()) { const segs = match.split('/') // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue diff --git a/scripts/verify-package-readme-limitations.ts b/scripts/verify-package-readme-limitations.ts index 2b995aab6b..042db1787c 100644 --- a/scripts/verify-package-readme-limitations.ts +++ b/scripts/verify-package-readme-limitations.ts @@ -6,7 +6,7 @@ */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import { markdownHeadingLines, markdownProseLines } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -30,7 +30,7 @@ function isLimitationsLike(headingText: string): boolean { ) } -const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() +const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort() const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) const failures: string[] = [] diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 8bd2d615ed..5c128954f6 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -6,7 +6,7 @@ */ import { existsSync, globSync, readFileSync } from 'node:fs' -import { relative, resolve } from 'node:path' +import { relative, resolve, sep } from 'node:path' import { markdownHeadingLines, markdownProseLines, type MarkdownProseLine } from './markdown.ts' const root = resolve(import.meta.dirname, '..') @@ -145,7 +145,7 @@ for (const line of readFileSync(resolve(root, 'docs/tool-catalog.md'), 'utf8').s } const failures: Failure[] = [] -const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).sort() +const packageJsons = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.split(sep).join('/')).sort() const scannedPackages = new Set(packageJsons.map(path => path.slice(0, -'/package.json'.length))) let structuredCount = 0 let contextSurfaceCount = 0 diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 729fd632cd..3e2b6f568b 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -5,7 +5,7 @@ */ import { globSync, readFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { resolve, sep } from 'node:path' import ts from 'typescript' const root = resolve(import.meta.dirname, '..') @@ -121,7 +121,7 @@ const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.sym // as an orphan rather than silently skipped. const docSet = new Set() for (const pattern of MARKDOWN_GLOBS) { - for (const match of globSync(pattern, { cwd: root })) docSet.add(match) + for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/')) } const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks) From 30a209aab5bf6be6337a371936fe1118798824e1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 7 Jul 2026 01:22:30 +0800 Subject: [PATCH 07/10] fix(e2e): reach child quiescence before temp-dir cleanup in built-bin smokes The acp built-bin smoke killed its child and immediately rm'd the temp consumer dir; POSIX tolerates unlinking a live process's cwd, Windows fails EBUSY while the child still holds its cwd and session-log handles (the CI windows job's only red step). Await the child's exit after SIGKILL and give both smokes' rm a brief retry for the OS handle-release lag. --- packages/examples/acp-demo/tests/built-bin.e2e.ts | 15 +++++++++++++-- .../examples/stdio-demo/tests/built-bin.e2e.ts | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 7b5749fee5..9e799b29db 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -95,8 +95,19 @@ let consumer: string | undefined let child: ReturnType | undefined afterEach(async () => { - if (child !== undefined) { child.kill('SIGKILL'); child = undefined } - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + if (child !== undefined) { + const proc = child + child = undefined + // Windows retains the child's cwd and session-log handles until process + // teardown completes, so await exit before removing the temp directory. + if (proc.exitCode === null && proc.signalCode === null) { + const exited = new Promise((resolve) => { proc.once('exit', () => { resolve() }) }) + proc.kill('SIGKILL') + await exited + } + } + // Windows can briefly retain released handles after exit; retry removal. + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) consumer = undefined }) diff --git a/packages/examples/stdio-demo/tests/built-bin.e2e.ts b/packages/examples/stdio-demo/tests/built-bin.e2e.ts index fdbeb2b8e4..ec14440b18 100644 --- a/packages/examples/stdio-demo/tests/built-bin.e2e.ts +++ b/packages/examples/stdio-demo/tests/built-bin.e2e.ts @@ -116,7 +116,8 @@ function runBuiltBin(cwd: string, configArg: string, line: string): Promise<{ st let consumer: string | undefined afterEach(async () => { - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true }) + // Windows can briefly retain released handles after exit; retry removal. + if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) consumer = undefined }) From 82794c56815729ce43cc35f5697e5b1054bb7e0e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:24:30 +0800 Subject: [PATCH 08/10] ci(windows): exclude runtime smoke from static gates --- scripts/run-gates.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index da87a84db9..7278ca51f5 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -197,13 +197,18 @@ function ciStaticGates(): Gate[] { pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - demoSmokeGate(), + ...staticDemoSmokeGates(), ...docSyncLeafGates(), pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }), pnpmScript('knip', 'knip'), ] } +function staticDemoSmokeGates(): Gate[] { + // Native Windows session persistence is outside the gates-only support scope. + return process.platform === 'win32' ? [] : [demoSmokeGate()] +} + function ciArtifactGates(): Gate[] { return [ pnpmScript('build', 'build'), From 47858df1ef8d88163659df4538f893ce14f05775 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:58:34 +0800 Subject: [PATCH 09/10] ci(windows): defer coverage lane --- .github/workflows/ci.yml | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1ac40f49..bac25b07cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,10 +182,11 @@ jobs: - name: Build (tsc -b + tsdown) run: pnpm run build - # Observational, non-blocking Windows mirror of the Linux gate lanes. Snapshot - # stays Linux-only while its replay goldens remain platform-specific. Run the - # gates from native PowerShell: an MSYS parent would change the environment - # being measured. This job intentionally stays out of all-checks-passed.needs. + # Observational, non-blocking Windows static, lint, and artifact lanes. Coverage + # and snapshot stay Linux-only until their platform-specific runtime failures + # have dedicated support. Run the gates from native PowerShell: an MSYS parent + # would change the environment being measured. This job intentionally stays + # out of all-checks-passed.needs. windows-gates: continue-on-error: true runs-on: windows-2025 @@ -193,7 +194,6 @@ jobs: env: DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }} DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }} - DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }} DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }} strategy: fail-fast: false @@ -203,25 +203,16 @@ jobs: command: pnpm run check:ci:static gate_concurrency: '4' publint_concurrency: '8' - coverage_max_workers: '' eslint_cache: '' - lane: lint command: pnpm run check:ci:lint gate_concurrency: '1' publint_concurrency: '8' - coverage_max_workers: '' eslint_cache: '1' - - lane: coverage - command: pnpm run check:ci:coverage - gate_concurrency: '1' - publint_concurrency: '8' - coverage_max_workers: '4' - eslint_cache: '' - lane: artifacts command: pnpm run check:ci:artifacts gate_concurrency: '3' publint_concurrency: '8' - coverage_max_workers: '' eslint_cache: '' steps: - uses: actions/checkout@v6 From 32cfd6c51330062a9648d80f4c9172a72ca63237 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:59:00 +0800 Subject: [PATCH 10/10] fix: normalize remaining Windows gate paths --- scripts/repo-files.ts | 9 +++++---- scripts/verify-translation-pairing.ts | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts index e6d50e7627..8642b9963f 100644 --- a/scripts/repo-files.ts +++ b/scripts/repo-files.ts @@ -1,7 +1,7 @@ /** Shared repository file discovery and line-oriented reference scanning. */ import { globSync, readFileSync, realpathSync } from 'node:fs' -import { relative, resolve } from 'node:path' +import { relative, resolve, sep } from 'node:path' /** One authored path plus its canonical target for symlink deduplication. */ export interface RepoFile { @@ -37,8 +37,9 @@ export function uniqueRepoFiles( const files: RepoFile[] = [] for (const pattern of patterns) { for (const match of globSync(pattern, { cwd: root })) { - if (isExcluded(match)) continue - const abs = resolve(root, match) + const repoPath = match.split(sep).join('/') + if (isExcluded(repoPath)) continue + const abs = resolve(root, repoPath) const real = realpathSync(abs) if (seen.has(real)) continue seen.add(real) @@ -65,7 +66,7 @@ export function findReferenceViolations( normalize: (raw: string) => string, isViolation: (ref: string) => boolean, ): ReferenceViolation[] { - const file = relative(root, absPath) + const file = relative(root, absPath).split(sep).join('/') const out: ReferenceViolation[] = [] const lines = readFileSync(absPath, 'utf8').split('\n') for (let i = 0; i < lines.length; i++) { diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index eaeacb34d2..4aaf7de79b 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -9,7 +9,7 @@ import { createHash } from 'node:crypto' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' -import { basename, join, resolve } from 'node:path' +import { basename, join, resolve, sep } from 'node:path' import { fromMarkdown } from 'mdast-util-from-markdown' import { gfmFromMarkdown } from 'mdast-util-gfm' import { gfm } from 'micromark-extension-gfm' @@ -176,7 +176,7 @@ function parse(content: string): Nodes { // Enumerate the scope once. const files = new Set() for (const pattern of SCOPE_PATTERNS) { - for (const match of globSync(pattern, { cwd: root })) files.add(match) + for (const match of globSync(pattern, { cwd: root })) files.add(match.split(sep).join('/')) } const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()