From e9ed7193d8d58085e8a8ce1307efec2d9bd4477e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:24:37 +0800 Subject: [PATCH 1/3] ci: run coverage-exempt heavy suites uninstrumented in parallel The coverage lane's wall clock was pinned by a few compiler- and subprocess-bound suites whose v8 instrumentation tax is a multiple of their runtime while contributing nothing the per-file thresholds need: typert generator fixtures (whole-workspace compiler analysis; its src is threshold-excluded) and three scripts/ child-process fixture suites (scripts/ sources are never coverage-measured; in-process imports are covered by their owning package tests). Split ci-coverage into two parallel gates: the instrumented run sets DSH_COVERAGE_EXEMPT_HEAVY=1 and vitest.config.ts drops the exempt suites from both projects (CLI --exclude cannot reach per-project include resolution); a second uninstrumented gate runs exactly those suites, so the aggregate still executes every test. Membership contract and the filter/exclude pairs live in scripts/coverage-exempt.ts. Local 6-worker A/B: instrumented gate 900s -> 260s wall; exempt gate 262s wall runs beside it, so the lane converges near the slower of the two (~4.4min vs ~7min single-gate). DSH_GATE_CONCURRENCY now has two schedulable gates in this lane. --- scripts/coverage-exempt.ts | 41 ++++++++++++++++++++++++++++++++++++++ scripts/run-gates.ts | 38 +++++++++++++++++++++++++---------- vitest.config.ts | 13 +++++++++++- 3 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 scripts/coverage-exempt.ts diff --git a/scripts/coverage-exempt.ts b/scripts/coverage-exempt.ts new file mode 100644 index 0000000000..8f20f54424 --- /dev/null +++ b/scripts/coverage-exempt.ts @@ -0,0 +1,41 @@ +/** + * Heavy suites the coverage aggregate runs uninstrumented in a parallel gate. + * Membership contract: a suite qualifies only when every coverage-measured + * file it executes in-process (`coverage.include` spans package src trees; + * typert generator src is threshold-excluded in vitest.config.ts) is already + * fully covered by other suites, so removing it from the instrumented run + * changes no threshold outcome. The aggregate still runs every listed suite + * plain beside the instrumented gate, so correctness signal is unchanged — + * only the v8 instrumentation tax on compiler- and subprocess-heavy fixtures + * is dropped. + */ + +/** One coverage-exempt suite: a Vitest CLI filter and its exclude glob. */ +export interface CoverageExemptSuite { + /** Positional file filter selecting the suite in the uninstrumented gate. */ + readonly filter: string + /** Exclude glob removing the suite from the instrumented gate. */ + readonly exclude: string +} + +/** + * Set to `1` by the instrumented coverage gate; vitest.config.ts then drops + * the exempt suites from every project. CLI `--exclude` cannot express this: + * it does not reach per-project include resolution. + */ +export const COVERAGE_EXEMPT_ENV = 'DSH_COVERAGE_EXEMPT_HEAVY' + +/** Coverage-exempt heavy suites; keep filter and exclude selecting the same files. */ +export const coverageExemptHeavySuites: readonly CoverageExemptSuite[] = [ + // Whole-workspace compiler analysis per case — the lane's longest tail. + // Generator src is threshold-excluded; tools-catalog's registry and + // tool-cordis imports are fully covered by those packages' own tests. + { + filter: 'packages/typert/generator/tests/', + exclude: 'packages/typert/generator/tests/**', + }, + // Real child-process fixtures over scripts/ sources, which coverage never measures. + { filter: 'scripts/install-lefthook.spec.ts', exclude: 'scripts/install-lefthook.spec.ts' }, + { filter: 'scripts/oxlint-contract.spec.ts', exclude: 'scripts/oxlint-contract.spec.ts' }, + { filter: 'scripts/change-scope.spec.ts', exclude: 'scripts/change-scope.spec.ts' }, +] diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 46bc72c833..0289625959 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -9,6 +9,7 @@ import { spawn } from 'node:child_process' import { availableParallelism } from 'node:os' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' +import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './coverage-exempt.ts' /** A named aggregate exposed by the gate runner. */ export type Mode = @@ -202,7 +203,7 @@ export function gatesForMode(selected: Mode): Gate[] { pnpmScript('duplication', 'duplication'), ] case 'ci-coverage': - return [coverageGate()] + return coverageGates() case 'ci-snapshot': return [pnpmScript('build', 'build'), snapshotGate()] case 'ci-artifacts': @@ -248,7 +249,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), - coverageGate(), + ...coverageGates(), ...nodeCompatSmokeGates(), snapshotGate(), ...docSyncLeafGates(), @@ -407,15 +408,30 @@ function lintGate(): Gate { : { displayCommand: `DSH_OXLINT_THREADS=${raw} pnpm run lint` }) } -function coverageGate(): Gate { - return pnpmExec('coverage', [ - 'vitest', - 'run', - '--coverage', - ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), - ], { - label: 'test:coverage', - }) +// The heavy suites run uninstrumented beside the thresholded gate: their +// compiler- and subprocess-bound fixtures pay a multiple of their runtime +// under v8 instrumentation while contributing nothing the thresholds need +// (membership contract in scripts/coverage-exempt.ts). +function coverageGates(): Gate[] { + return [ + pnpmExec('coverage', [ + 'vitest', + 'run', + '--coverage', + ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), + ], { + label: 'test:coverage', + env: { [COVERAGE_EXEMPT_ENV]: '1' }, + }), + pnpmExec('coverage-exempt-heavy', [ + 'vitest', + 'run', + ...coverageExemptHeavySuites.map(suite => suite.filter), + ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), + ], { + label: 'test:coverage-exempt-heavy', + }), + ] } // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, diff --git a/vitest.config.ts b/vitest.config.ts index c49f55bea8..d31dca5d3f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,6 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' +import { COVERAGE_EXEMPT_ENV, coverageExemptHeavySuites } from './scripts/coverage-exempt.ts' // Resolution facade shared by every plugin instance below: tsconfig.base.json // has no include, which vite-tsconfig-paths treats as match-all, so its paths @@ -37,6 +38,12 @@ const testIncludes = [ 'scripts/**/*.spec.ts', ] +// The instrumented coverage gate sets this env; the exempt heavy suites then +// run beside it uninstrumented (membership contract in scripts/coverage-exempt.ts). +const coverageExemptExcludes = process.env[COVERAGE_EXEMPT_ENV] === '1' + ? coverageExemptHeavySuites.map(suite => suite.exclude) + : [] + // These suites exercise process-global state, process APIs, or timing-sensitive process I/O // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. @@ -73,6 +80,7 @@ export default defineConfig({ exclude: [ ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), ...processBoundTests, + ...coverageExemptExcludes, ], }, }, @@ -83,7 +91,10 @@ export default defineConfig({ pool: 'forks', setupFiles: ['./scripts/test-invariants.ts'], include: processBoundTests, - exclude: windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + exclude: [ + ...windowsUnsupportedPackages.map(path => `${path}/tests/**/*.spec.ts`), + ...coverageExemptExcludes, + ], }, }, ], From c368a89dc1e511384a58e7da3eec2972c7ff9d48 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:50:03 +0800 Subject: [PATCH 2/3] doc: record the coverage-exempt heavy suites decision Agent Note (both languages, pairing recorded): what is exempted, the per-entry reconciliation showing no threshold input changes, the membership contract, and why the per-file 100% thresholds police the roster automatically. --- ...-31-coverage-exempt-heavy-suites.i18n.yaml | 6 ++ ...2026-07-31-coverage-exempt-heavy-suites.md | 61 +++++++++++++++++++ ...6-07-31-coverage-exempt-heavy-suites.zh.md | 61 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 .agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md create mode 100644 .agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml new file mode 100644 index 0000000000..9d3ac28ed2 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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-31-coverage-exempt-heavy-suites.md +2026-07-31-coverage-exempt-heavy-suites.md: 7235a5193554947ecf71f62d522d09f4e21cb1da +2026-07-31-coverage-exempt-heavy-suites.zh.md: b739e4494ae8d240b0e35109920a49876ebd222d diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md new file mode 100644 index 0000000000..7235a51935 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md @@ -0,0 +1,61 @@ +# Agent Note: Coverage-exempt heavy suites + +Status: implemented + +English | [中文](2026-07-31-coverage-exempt-heavy-suites.zh.md) + +## Problem + +The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handful of heavy test files: in a local 6-worker full-suite profile, 555 test files aggregated 1595 seconds, with `packages/typert/generator/tests/type-model.spec.ts` alone at 885 seconds and the top 10 files holding 84% of the aggregate. These suites share one shape — every case performs whole-workspace compiler analysis or drives real subprocess fixtures — and v8 instrumentation multiplies exactly that kind of runtime. + +The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information. + +## Decision + +The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax: + +- **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged. +- **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole. + +`scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift. + +### The roster, reconciled entry by entry + +A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited: + +| Exempt suite | Measured code executed in-process | Who carries the coverage | +| --- | --- | --- | +| All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with | +| tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) | +| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry | + +### Membership contract + +A new exemption must satisfy both: every measured file the suite executes in-process is already fully covered by other suites (or threshold-excluded), and the filter and exclude select exactly the same file set. The contract text lives beside the roster in the same file. + +### The gate polices the roster automatically + +The per-file 100% thresholds are themselves the roster's guard; a wrong roster cannot pass silently: + +- If a future exempt suite in fact solely covers some measured file, the instrumented gate goes red on the spot (that file drops below 100%). +- The converse holds too: new code covered only by an exempt suite turns the gate red immediately. + +Coverage-result invariance therefore does not rest on humans maintaining the roster, in line with the misconfiguration-fails-loud convention. The only thing given up is that the exempt suites' own execution no longer produces coverage data — the table above shows that data was entirely redundant, so the final report is file-for-file identical in threshold terms. + +## Alternatives considered + +- **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it. +- **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction. +- **Cross-runner sharding (`--shard` + blob merge).** Would compress the wall clock further but adds matrix, artifact-pipeline, and merge-job complexity; with the split landed the lane sits near 2 minutes, which does not justify the cost. Revisit if the suite grows substantially. +- **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal. + +## Verification + +Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction. + +## Consequences + +- The coverage lane's gate segment drops from about 7 minutes to about 96 seconds with no change in threshold outcome or executed test set. +- `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through. +- Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently. +- The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail. diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md new file mode 100644 index 0000000000..b739e4494a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md @@ -0,0 +1,61 @@ +# Agent Note: 覆盖率豁免重型套件 + +Status: implemented + +[English](2026-07-31-coverage-exempt-heavy-suites.md) | 中文 + +## Problem + +CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试文件钉死:本地 6-worker 全量剖析中,555 个测试文件聚合 1595 秒,其中 `packages/typert/generator/tests/type-model.spec.ts` 一个文件占 885 秒,前 10 个文件占聚合时长的 84%。这类套件的共同点是每个用例都做全工作区编译器分析或真实子进程 fixture,v8 插桩把这类代码的运行时间放大数倍。 + +关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。 + +## Decision + +`ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税: + +- **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。 +- **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。 + +`scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格契约与 filter/exclude 配对,防止两侧漂移。 + +### 豁免名单与逐项对账 + +一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对: + +| 豁免套件 | 进程内执行的被度量代码 | 覆盖由谁接住 | +| --- | --- | --- | +| typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 | +| 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) | +| `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 | + +### 成员资格契约 + +新增豁免必须同时满足:套件进程内执行的每个被度量文件都已由其他套件满覆盖(或在阈值排除名单内);filter 与 exclude 选中完全相同的文件集。契约文本随名单同文件维护。 + +### 门禁自动守卫名单正确性 + +per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默通过: + +- 若未来某个豁免套件实际独家覆盖着某个被度量文件,插桩 gate 当场红(该文件跌破 100%); +- 反向同理:出现"只有豁免套件才覆盖"的新代码,同样立刻红。 + +因此覆盖率结果的不变性不依赖人工维护名单,符合"misconfiguration fails loud"约定。唯一失去的是豁免套件自身的执行不再产出覆盖数据——由上表可知这些数据全部冗余,最终报告在阈值意义上逐文件相同。 + +## Alternatives considered + +- **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。 +- **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。 +- **跨 runner 分片(`--shard` + blob 合并)。** 能进一步压墙钟但引入 matrix、artifact 管道与合并 job 的复杂度;拆分落地后 lane 已到约 2 分钟,不值得付。若未来套件规模再涨可重新评估。 +- **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。 + +## Verification + +CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。 + +## Consequences + +- 覆盖率 lane 的 gate 段从约 7 分钟降到约 96 秒,阈值结果与执行测试集均无变化。 +- `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。 +- 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。 +- 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。 From ea6eb85162fc94d59a5cc64953274971faee3d36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:53:54 +0800 Subject: [PATCH 3/3] fix(ci): review follow-ups for the coverage lane split - Split DSH_COVERAGE_MAX_WORKERS between the two parallel gates (instrumented gets ~2/3, exempt gets ~1/3, both at least 1) so the lane never exceeds the budget the failover pool's 8 x 6-instance bound assumes; a budget of 1 pairs with DSH_GATE_CONCURRENCY=1 on the serial reference lanes, which already prevents gate overlap. - Fail loud on a set-but-not-'1' DSH_COVERAGE_EXEMPT_HEAVY value in vitest.config.ts instead of silently ignoring it. - Add coverage-exempt.spec.ts: each roster entry's filter and exclude must select the same non-empty spec set and entries must not overlap, so a renamed suite breaks the gate instead of silently returning to the instrumented run with a stale roster. --- scripts/coverage-exempt.spec.ts | 55 +++++++++++++++++++++++++++++++++ scripts/run-gates.ts | 25 +++++++++++++-- vitest.config.ts | 7 ++++- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 scripts/coverage-exempt.spec.ts diff --git a/scripts/coverage-exempt.spec.ts b/scripts/coverage-exempt.spec.ts new file mode 100644 index 0000000000..5ee854e954 --- /dev/null +++ b/scripts/coverage-exempt.spec.ts @@ -0,0 +1,55 @@ +/** + * Mechanical guard for the coverage-exempt roster: each entry's positional + * filter and exclude glob must select the same non-empty file set out of the + * repository's spec inventory, so a renamed suite cannot silently fall out of + * the uninstrumented gate while its exclude goes stale. + */ + +import { globSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { coverageExemptHeavySuites } from './coverage-exempt.ts' + +const root = resolve(import.meta.dirname, '..') + +/** The spec inventory mirrored from vitest.config.ts testIncludes. */ +const allSpecs = new Set([ + ...globSync('packages/*/*/tests/**/*.spec.ts', { cwd: root }), + ...globSync('packages/*/*/tests/**/*.spec.tsx', { cwd: root }), + ...globSync('apps/*/tests/**/*.spec.ts', { cwd: root }), + ...globSync('examples/*/tests/**/*.spec.ts', { cwd: root }), + ...globSync('scripts/**/*.spec.ts', { cwd: root }), +].map(path => path.replaceAll('\\', '/'))) + +function excludeMatches(exclude: string): string[] { + return globSync(exclude, { cwd: root }) + .map(path => path.replaceAll('\\', '/')) + .filter(path => allSpecs.has(path)) + .sort() +} + +function filterMatches(filter: string): string[] { + return [...allSpecs].filter(spec => spec.startsWith(filter)).sort() +} + +describe('coverage-exempt roster', () => { + it.each(coverageExemptHeavySuites.map(suite => [suite.filter, suite] as const))( + 'filter and exclude select the same non-empty spec set for %s', + (_filter, suite) => { + const fromExclude = excludeMatches(suite.exclude) + const fromFilter = filterMatches(suite.filter) + expect(fromExclude.length).toBeGreaterThan(0) + expect(fromFilter).toEqual(fromExclude) + }, + ) + + it('entries never overlap, so no suite is double-run or double-excluded', () => { + const seen = new Map() + for (const suite of coverageExemptHeavySuites) { + for (const spec of excludeMatches(suite.exclude)) { + expect(seen.get(spec), `${spec} matched by ${seen.get(spec) ?? ''} and ${suite.exclude}`).toBeUndefined() + seen.set(spec, suite.exclude) + } + } + }) +}) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 0289625959..5d5eb46285 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -412,13 +412,34 @@ function lintGate(): Gate { // compiler- and subprocess-bound fixtures pay a multiple of their runtime // under v8 instrumentation while contributing nothing the thresholds need // (membership contract in scripts/coverage-exempt.ts). +// +// DSH_COVERAGE_MAX_WORKERS is the lane's worker budget, so the two parallel +// gates split it instead of each claiming it whole (the failover pool's +// 8 x 6-instance bound assumes one lane never exceeds its value). The exempt +// gate's wall clock is dominated by its longest single file, so it takes the +// small share. A budget of 1 gives each gate 1 worker; lanes that need a +// strict total of one (the serial reference jobs) also set +// DSH_GATE_CONCURRENCY=1, which keeps the gates from overlapping at all. +function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { + const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') + if (flag === undefined) return { instrumented: [], exempt: [] } + const total = Number.parseInt(flag.split('=')[1] ?? '', 10) + const exempt = Math.max(1, Math.floor(total / 3)) + const instrumented = Math.max(1, total - exempt) + return { + instrumented: [`--maxWorkers=${String(instrumented)}`], + exempt: [`--maxWorkers=${String(exempt)}`], + } +} + function coverageGates(): Gate[] { + const workers = coverageWorkerArgs() return [ pnpmExec('coverage', [ 'vitest', 'run', '--coverage', - ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), + ...workers.instrumented, ], { label: 'test:coverage', env: { [COVERAGE_EXEMPT_ENV]: '1' }, @@ -427,7 +448,7 @@ function coverageGates(): Gate[] { 'vitest', 'run', ...coverageExemptHeavySuites.map(suite => suite.filter), - ...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'), + ...workers.exempt, ], { label: 'test:coverage-exempt-heavy', }), diff --git a/vitest.config.ts b/vitest.config.ts index d31dca5d3f..db44166e6c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -40,7 +40,12 @@ const testIncludes = [ // The instrumented coverage gate sets this env; the exempt heavy suites then // run beside it uninstrumented (membership contract in scripts/coverage-exempt.ts). -const coverageExemptExcludes = process.env[COVERAGE_EXEMPT_ENV] === '1' +// A set-but-not-'1' value is a misconfiguration, not a silent no-op. +const coverageExemptRaw = process.env[COVERAGE_EXEMPT_ENV] +if (coverageExemptRaw !== undefined && coverageExemptRaw !== '' && coverageExemptRaw !== '1') { + throw new Error(`vitest config: ${COVERAGE_EXEMPT_ENV} must be '1' or unset, got ${JSON.stringify(coverageExemptRaw)}.`) +} +const coverageExemptExcludes = coverageExemptRaw === '1' ? coverageExemptHeavySuites.map(suite => suite.exclude) : []