Переглянути джерело

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.
imccyu 1 місяць тому
батько
коміт
e9ed7193d8
3 змінених файлів з 80 додано та 12 видалено
  1. 41 0
      scripts/coverage-exempt.ts
  2. 27 11
      scripts/run-gates.ts
  3. 12 1
      vitest.config.ts

+ 41 - 0
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' },
+]

+ 27 - 11
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,

+ 12 - 1
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,
+          ],
         },
       },
     ],