Skip to content

fix(ci): scan the repo once in the self-import gate test, not twice - #5411

Merged
os-support-ai merged 1 commit into
mainfrom
claude/issue-5402-coverage-timeout
Aug 20, 2026
Merged

fix(ci): scan the repo once in the self-import gate test, not twice#5411
os-support-ai merged 1 commit into
mainfrom
claude/issue-5402-coverage-timeout

Conversation

@os-support-ai

Copy link
Copy Markdown
Collaborator

Fixes#5402

ci.yml's Test (coverage) job has failed 100% of the time since 2026-08-16T12:48Z — 51 completed jobs, 0 successes, 50 of them on scripts/__tests__/check-package-self-import.test.ts with Test timed out in 15000ms. Codecov has received nothing for four days.

It is not a race — it is one full repo parse done twice, times a 7x coverage constant

The test asserts two things about this repository: that it is green under the repository's own exemption table, and that it is green under no exemptions at all. Those were two calls to analyze(repoRoot) — the same full TypeScript parse of every source file of every workspace package, performed twice — and one of them sat inside a 15-second it().

Measured here: 44 packages, 3,094 files, 30.3 MB of source, 15,550 module specifiers. moduleSpecifiers() full-parses each with ts.createSourceFile(..., setParentNodes, ScriptKind.TSX). Per-test timings for the file, from --reporter=verbose:

without coverageunder coverage
the two live specimens in this repository stay unflagged8,782 ms (59% of the 15 s budget)41,268 ms — FAIL
every other test in the fileunder 10 msunder 10 ms
file duration20.05 s84.81 s

So the whole cost is that one test, and it was already at 59% of budget green.

The constant factor is compile-order, not the counters.@vitest/coverage-v8 arms Profiler.startPreciseCoverage({ callCount: true, detailed: true }) in the worker before test modules and their dependencies compile. V8 emits block counters at compile time and does so isolate-wide, so node_modules/typescript is instrumented too — coverage.exclude filters the report, never the instrumentation. Two controlled runs of the identical parse over the identical corpus:

coverage armed AFTER typescript is compiled and warmed
before precise coverage : 4254 ms under precise coverage : 4184 ms (no cost)
coverage armed BEFORE typescript is compiled (the order vitest uses)
COV=0 pass1 4919 ms pass2 4105 ms
COV=1 pass1 34941 ms pass2 32230 ms (7.1x - 7.9x)

That is the whole mechanism, and it answers the question the issue asked: the test does real work proportional to repo size, and v8 coverage multiplies it by ~7 on the TypeScript parser specifically. Not a race, nothing nondeterministic — every one of the 51 jobs failed.

The fix: scan once, judge many

The parse does not depend on the exemption table at all — only the verdict does. So scripts/check-package-self-import.mjs now separates them:

  • scanRepository(root) — the expensive, exemption-independent parse, returning sites (every self-import found, with full finding detail) plus the counters.
  • judgeScan(scan, exemptions) — pure: a filter over sites plus the stale-exemption audit. Microseconds.
  • analyze(root, { exemptions }) is now exactly judgeScan(scanRepository(root), exemptions) — same signature, same behaviour.

The test scans once at module scope and judges that one scan per assertion.

under --coveragebeforeafter
the two live specimens...41,268 ms → FAIL1 ms → pass
total test time for the file41.31 s0.077 s
file duration84.81 s45.16 s
result1 failed / 23 passed28 passed

The file's total work halves, because the second identical 30 MB parse is gone. No timeout was raised, nothing is skipped, quarantined or deleted, and coverage is not disabled for anything.

analyze() is unchanged — verified, not asserted

  • CLI output byte-identical to origin/main's version, both run with --root over this repository (diff clean, same exit code).
  • Differential over 14 cases: old vs new analyze() compared on findings, counters and packages, over a fixture tree and this repository, across all six exemption-table shapes (empty, covering, stale-file, stale-specifier, no-reason, no-specifiers) plus the default-table path. All identical.

Four new tests pin the property that makes sharing one scan safe: judging re-reads nothing (it judges a scan whose site names a file that does not exist), does not mutate or consume the scan, hands out findings the caller cannot use to corrupt it, and analyze() still equals the composition.

Reverse verification

Predicted before running, then run. The test imports ../check-package-self-import.mjs by relative path — a plain ESM source file transformed in-memory by vitest, never through a package exports map or a dist/ — so no build artifact sits between the edit and the thing under test on either leg, and no rebuild is possible or needed.

Ablating only the test-file half (git checkout origin/main -- scripts/__tests__/check-package-self-import.test.ts, keeping the gate split):

predictedobserved
failed tests11
which onethe two live specimens..., Test timed out in 15000ms at :140:3verbatim
totals24 tests, 23 passed24 tests, 23 passed
file duration~85 s86.05 s
coverage/ directoryabsentabsent

Restore leg: 28 passed, 44.87 s, coverage/ present. Both predictions exact.

Second symptom: no coverage/ directory — a consequence, not a second defect

The issue reports the live unsharded recipe leaving no coverage/ directory at all on a failing suite (2 of 2), while the blob-report path produced a complete report every time. Mechanism found, and it is not mysterious:

vitest/dist/chunks/coverage.DM_a_rWm.js:791
if (!this.options.reportOnFailure) await this.cleanAfterRun();
vitest/dist/chunks/defaults.9aQKnqFk.js:22
reportOnFailure: false,

coverage.reportOnFailure defaults to false and vitest.config.mts does not set it, so the v8 provider deletes the reports directory whenever the run is red. The blob-report path escapes it because merge-reports is a separate, green vitest invocation.

Demonstrated on the two legs above — same command, same tree, same configured reporters:

  • red run (ablated) → no coverage/ directory
  • green run (fixed) → coverage/ with coverage-final.json

So this needed no separate fix: it is downstream of the red suite, and it clears when the suite goes green. Whether red runs should still emit partial coverage (reportOnFailure: true) is a policy call for the coverage lane rather than a defect — noted for #5403, and .github/workflows/ci.yml is untouched here as that file is #5403's.

Verification

Run from the repo root. Gate union re-derived from the actual changed paths and run at the final commit f1f0da7c3:

scripts/check-package-self-import.mjs
scripts/__tests__/check-package-self-import.test.ts
.changeset/self-import-gate-scan-once-5402.md
check:self-import PASS check:phantom-deps PASS check:control-bytes PASS
check:esm-specifiers PASS type-check:coverage PASS lint:coverage PASS
type-check:scripts PASS changeset no-major/fixed/presence PASS eslint PASS
pnpm exec vitest run scripts/__tests__/ 58 files, 1553 tests, all passed
pnpm exec vitest run scripts/__tests__/check-package-self-import.test.ts --coverage 28 passed, largest test 15 ms

origin/main was merged in (fast-forward, fd227eae1) — my branch point predated the #5394 fix, which is why quick-reference-current-release-4143.test.ts was red before the merge and is green after. That failure was never mine.

Changeset: empty frontmatter — this is CI tooling and publishes nothing, declared explicitly rather than left undeclared, matching the house form.

Not addressed here

While measuring I found that ts.createSourceFile's setParentNodes argument is passed true in scripts/check-phantom-dependencies.mjs's shared moduleSpecifiers(), and nothing needs it — node.getStart(source) takes the source file explicitly and ts.forEachChild does not walk parents. Measured 3,600 ms vs 4,800 ms over the same 3,094 files with byte-identical output on every one. That is a further ~25% off the dominant cost of two gates, but it changes a parser shared with check:phantom-deps and so widens the verification surface beyond this card. Filed as #5410 (unassigned, finding); out of scope here, and this card is closed without it.

Generated by Claude Code


Generated by Claude Code

`ci.yml`'s `Test (coverage)` job has failed 100% of the time since
2026-08-16T12:48Z — 51 completed jobs, 0 successes, 50 of them on
`scripts/__tests__/check-package-self-import.test.ts` with
`Test timed out in 15000ms`. Codecov has received nothing since.
It is not a race. The test performs a full TypeScript parse of every
source file of every workspace package (~3,100 files, ~30 MB, ~15,500
module specifiers) TWICE for identical inputs: once in the
`describe('objectui itself')` body and once inside the `it()` at line
140, because the two assertions differ only in which exemption table
they judge under — and the parse does not depend on that table at all.
One scan costs 4.1-4.9 s uninstrumented and 32-35 s under v8 coverage.
`@vitest/coverage-v8` arms `Profiler.startPreciseCoverage({ callCount,
detailed })` in the worker BEFORE test modules and their dependencies
compile; V8 emits those block counters at compile time and does so
isolate-wide, so `node_modules/typescript` is instrumented as well —
`coverage.exclude` filters the report, never the instrumentation.
Starting coverage AFTER the same code is compiled costs nothing
(4254 ms -> 4184 ms), which is what makes the compile-order the
mechanism rather than the counters themselves.
So: split the gate's expensive, exemption-independent `scanRepository()`
from the pure `judgeScan()`, and have the test scan once at module scope
and judge it per assertion. `analyze()` keeps its signature and its
behaviour — verified differentially against the previous implementation
over a fixture tree and this repository across all six exemption-table
shapes, with byte-identical CLI output.
Measured on this file under `--coverage`:
before the timed test 41268 ms -> FAIL, file 84.81 s
after the timed test 1 ms -> pass, file 45.16 s
No timeout was raised, nothing is skipped or quarantined, and coverage
is not disabled for anything.
Fixes#5402
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci.ymlTest (coverage) has failed 100% of the time since 2026-08-16 — one test times out under v8 coverage, and Codecov has received nothing since

2 participants

@os-support-ai@claude