Uh oh!
There was an error while loading. Please reload this page.
fix(ci): scan the repo once in the self-import gate test, not twice - #5411
Merged
Conversation
`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_01RV6yuVCxymHYE16PL9vQkEos-support-ai
marked this pull request as ready for review
August 20, 2026 15:15
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#5402
ci.yml'sTest (coverage)job has failed 100% of the time since 2026-08-16T12:48Z — 51 completed jobs, 0 successes, 50 of them onscripts/__tests__/check-package-self-import.test.tswithTest 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-secondit().Measured here: 44 packages, 3,094 files, 30.3 MB of source, 15,550 module specifiers.
moduleSpecifiers()full-parses each withts.createSourceFile(..., setParentNodes, ScriptKind.TSX). Per-test timings for the file, from--reporter=verbose:the two live specimens in this repository stay unflaggedSo 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-v8armsProfiler.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, sonode_modules/typescriptis instrumented too —coverage.excludefilters the report, never the instrumentation. Two controlled runs of the identical parse over the identical corpus: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.mjsnow separates them:scanRepository(root)— the expensive, exemption-independent parse, returningsites(every self-import found, with full finding detail) plus the counters.judgeScan(scan, exemptions)— pure: a filter oversitesplus the stale-exemption audit. Microseconds.analyze(root, { exemptions })is now exactlyjudgeScan(scanRepository(root), exemptions)— same signature, same behaviour.The test scans once at module scope and judges that one scan per assertion.
--coveragethe two live specimens...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 assertedorigin/main's version, both run with--rootover this repository (diffclean, same exit code).analyze()compared onfindings,countersandpackages, 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.mjsby relative path — a plain ESM source file transformed in-memory by vitest, never through a packageexportsmap or adist/— 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):the two live specimens...,Test timed out in 15000msat:140:3coverage/directoryRestore leg: 28 passed, 44.87 s,
coverage/present. Both predictions exact.Second symptom: no
coverage/directory — a consequence, not a second defectThe 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:coverage.reportOnFailuredefaults tofalseandvitest.config.mtsdoes 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:
coverage/directorycoverage/withcoverage-final.jsonSo 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.ymlis 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:origin/mainwas merged in (fast-forward,fd227eae1) — my branch point predated the #5394 fix, which is whyquick-reference-current-release-4143.test.tswas 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'ssetParentNodesargument is passedtrueinscripts/check-phantom-dependencies.mjs's sharedmoduleSpecifiers(), and nothing needs it —node.getStart(source)takes the source file explicitly andts.forEachChilddoes 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 withcheck:phantom-depsand 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