Skip to content

Commit c725c47

Browse files
os-zhuangclaude
andauthored
fix(ci): teach the completeness guard turbo's failing-task header, so a red shard's own package stops being the one it cannot grade (#10466)
Under grouped log order turbo folds each task into a collapsible `::group::` EXCEPT the one it surfaces as the run's failure, which gets a bare `<pkg>:test` header and no `::endgroup::`. The guard read the `<pkg>:test:` stream prefix and the group header but not that third shape, so the FAILING package -- the one it exists to grade -- was the one package whose summary it could not attribute. Its "refuse to guess" backstop then withheld judgement on every candidate and exited 0. Measured on run 32391924279 (Test Core (3/3), job 96499888763, complete 31839-line log): the guard printed `OK (24 of 29 ...)` directly under a note naming @objectstack/example-showcase as ungraded. That is the #10032 shape -- a green that reads like a clearance -- reproduced by the change that was supposed to close it. - parseSummaries understands the bare header, and turbo's end-of-run roster closes it (that block has no ::endgroup:: of its own). - classifyShard never lets the stray-summary backstop excuse a package turbo named in `Failed:`. Rule A cannot cost a green shard: it needs turbo to have named a failed task, which already failed the run. - An ungraded package downgrades the verdict word from OK to PARTIAL and raises a ::warning:: annotation, instead of being a note nobody reads. The original zero-output event remains unreproduced and undiagnosed; the 2026-08-20 recurrence was not one -- that suite streamed ~940 lines and a complete summary. Part of #10032 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b214e3 commit c725c47

2 files changed

Lines changed: 235 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -514,14 +514,39 @@ jobs:
514514
# (2/3) failed naming @objectstack/example-showcase#test while this guard
515515
# printed OK, because the failing package was the one that printed
516516
# nothing — its complete 5083-line job log mentions that package twice,
517-
# both times in the error summary, with no vitest output anywhere. The
518-
# dichotomy is true again only because green now covers both questions.
517+
# both times in the error summary, with no vitest output anywhere.
518+
#
519+
# ⛔ AND THE REPLACEMENT WAS STILL BLIND, FOR A SECOND REASON — measured
520+
# on run 32391924279 (`Test Core (3/3)`, job 96499888763, complete 31839-
521+
# line log). Under grouped log order turbo folds each task into a
522+
# collapsible `::group::` EXCEPT the one it surfaces as the run's failure,
523+
# which gets a bare `<pkg>:test` header instead. The guard read groups and
524+
# prefixes but not that third shape, so the FAILING package — the one it
525+
# exists to grade — was the one package whose summary it could not
526+
# attribute, and its "refuse to guess" backstop then withheld judgement on
527+
# every candidate and exited 0. It printed `OK (24 of 29 …)` under a note
528+
# naming @objectstack/example-showcase as ungraded: the same green-that-
529+
# reads-like-a-clearance, one turn of the screw further out.
530+
# ⇒ The guard now knows all three header shapes, a package named in
531+
# turbo's `Failed:` roster is judged even when the log holds strays it
532+
# cannot place, and an ungraded package downgrades the verdict word from
533+
# OK to PARTIAL and raises a ::warning:: annotation. Only with all three
534+
# does green cover both questions — which is what makes the dichotomy
535+
# above true. ⚠ A red HERE can never turn a green shard red: it needs
536+
# turbo to have named a failed task, and that already failed the run.
519537
#
520538
# ⚠ The guard does NOT charge a silent package to this shard when turbo
521539
# simply never reached it — turbo stops scheduling on the first failure,
522540
# so an ordinary red suite leaves later packages unrun. Those are printed
523541
# as notes. See the script header for the two exemptions and the two
524542
# rules that decide red.
543+
#
544+
# ⚠ NOT diagnosed, by either pass: the original zero-output event itself.
545+
# It has never been reproduced. The 2026-08-20 recurrence was NOT one —
546+
# that failing suite streamed ~940 lines and a complete summary
547+
# (`Test Files 21 passed (21)` / `Tests 342 passed (342)`), and failed on
548+
# a vitest teardown error, not on silence. Read a "zero output" claim as
549+
# a hypothesis until someone has grepped the WHOLE log for it.
525550
- name: Test completeness guard
526551
if: always()
527552
run: |

scripts/check-test-completeness.mjs

Lines changed: 208 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,46 @@ export function stripAnsi(raw) {
145145
// `@objectstack/spec` reported 415 files and 11045 tests, and the guard said it
146146
// had reported nothing). The group header is turbo's own statement of whose
147147
// output follows, so it is read as the primary attribution, not a heuristic.
148+
//
149+
// ⛔ THIRD SHAPE, and it is the one that matters most here: THE FAILING TASK
150+
// IS NOT WRAPPED IN A GROUP. Under grouped order turbo folds each task into a
151+
// collapsible `::group::`, EXCEPT the task it surfaces as the run's failure --
152+
// that one gets a bare, red-coloured header and no `::endgroup::`, so the
153+
// failure is readable in the GitHub UI without expanding anything:
154+
// `\x1b[;31m@objectstack/example-showcase:test\x1b[;0m`
155+
// ` Test Files 21 passed (21)`
156+
// ` Tests 342 passed (342)`
157+
//
158+
// So the ONE package whose output the guard most needs to grade -- the one that
159+
// failed -- was the one package it could not attribute. Measured twice, byte for
160+
// byte: in a controlled 4-package turbo 2.10.10 sandbox, and in the real
161+
// incident (run 32391924279, `Test Core (3/3)`, job 96499888763, complete
162+
// 31839-line log, line 23907). In that log the pattern `^<no-space>:test$`
163+
// matches 24 lines: 23 are `##[group]` headers, and the 24th is this bare one.
164+
//
165+
// ⭐ WHAT IT COST, which is the whole reason this file exists. Anonymous
166+
// summaries put every candidate into the "refuse to guess" backstop below, so
167+
// Rule A -- the #10032 rule -- was disabled for the failing package on EVERY red
168+
// shard. That run printed, and it is what a clearance looks like:
169+
//
170+
// check-test-completeness: note: 3 scheduled package(s) could not be matched
171+
// to a summary ... @objectstack/runtime, @objectstack/cli,
172+
// @objectstack/example-showcase.
173+
// check-test-completeness: OK (24 of 29 scheduled package(s) reported ...)
174+
//
175+
// The note even names its own cause -- "the log carries a shape neither the
176+
// `<pkg>:test:` prefix nor turbo's `::group::<pkg>:test` header covers, that is
177+
// the bug to fix" -- and then exits 0. This is that fix.
178+
//
179+
// ⚠ One more measured turbo property, load-bearing for Rule A: `Failed:` names
180+
// only the task turbo surfaced, NOT every task that failed. With two failing
181+
// tasks in one run the roster listed one; the other failed inside an ordinary
182+
// `::group::`. Bare header <=> named in `Failed:` held in every run observed.
148183
const GROUP_OPEN = /^(?:::group::|##\[group\])(.+?)\s*$/;
149184
const GROUP_CLOSE = /^(?:::endgroup::|##\[endgroup\])\s*$/;
185+
// A line that is nothing but `<pkg>:test`. Reached only after the two markers
186+
// above have had their turn, so a `::group::` header can never land here.
187+
const BARE_TASK_HEADER = /^(\S+):test$/;
150188

151189
export function parseSummaries(text) {
152190
const rows = [];
@@ -166,6 +204,20 @@ export function parseSummaries(text) {
166204
group = sep > 0 && label.slice(sep + 1) === 'test' ? label.slice(0, sep) : null;
167205
continue;
168206
}
207+
// The failing task's bare header. `##[group]` / `::group::` spellings have
208+
// already been consumed above, and the guard belts-and-braces that here so a
209+
// future marker spelling cannot be read as a package name.
210+
const bare = line.match(BARE_TASK_HEADER);
211+
if (bare && !bare[1].includes('::') && !bare[1].startsWith('#')) {
212+
group = bare[1];
213+
continue;
214+
}
215+
// turbo's end-of-run roster closes the failing task's block, which has no
216+
// `::endgroup::` of its own. Nothing after it belongs to any package.
217+
if (TASKS.test(line) || FAILED.test(line)) {
218+
group = null;
219+
continue;
220+
}
169221
const m = line.match(SUMMARY);
170222
if (!m) continue;
171223
const [, pkg, kind, tallies, declared] = m;
@@ -252,18 +304,39 @@ export function classifyShard({ scheduled, reported, anonymousReports = 0, faile
252304
// the one error worse than not attributing at all. One candidate is not a
253305
// guess (there is nothing else it could belong to); more than one is.
254306
const candidates = expected.filter((name) => !reported.has(name));
307+
308+
// ⛔ THE BACKSTOP MUST NEVER SWALLOW RULE A. A package turbo named in
309+
// `Failed:` is judged even when stray summaries are present, because that
310+
// pairing IS the #10032 signature and excusing it is the exact green that
311+
// this guard exists to stop -- measured on run 32391924279, where the
312+
// backstop turned the failing package into a note and exited 0.
313+
//
314+
// This cannot cost a green shard: Rule A only fires when turbo already
315+
// reported a failed task, so the test step has already failed and the shard
316+
// is already red. The worst case is an extra, wrongly-worded paragraph on a
317+
// run that was going to be red anyway; the alternative is a clearance on the
318+
// one run where the guard had something to say. The wording below stays
319+
// honest about which of the two it is.
320+
const failedCandidates = candidates.filter((name) => failed.has(name));
321+
const otherCandidates = candidates.filter((name) => !failed.has(name));
255322
let unattributable = [];
256323
let judged = candidates;
257-
if (anonymousReports > 0 && candidates.length === 1) {
258-
judged = [];
259-
} else if (anonymousReports > 0 && candidates.length > 1) {
260-
unattributable = candidates;
261-
judged = [];
324+
if (anonymousReports > 0) {
325+
judged = failedCandidates;
326+
if (otherCandidates.length > 1) unattributable = otherCandidates;
262327
}
263328

264329
for (const name of judged) {
265330
if (failed.has(name)) {
266-
silent.push({ name, why: 'turbo reported this task FAILED, and the log holds no vitest summary for it' });
331+
silent.push({
332+
name,
333+
why:
334+
anonymousReports > 0
335+
? `turbo reported this task FAILED, and no summary in this log could be attributed to it ` +
336+
`(${anonymousReports} unattributed summary line(s) present -- this log carries a task-header ` +
337+
`shape this guard does not understand, so it cannot tell a silent suite from a misread one)`
338+
: 'turbo reported this task FAILED, and the log holds no vitest summary for it',
339+
});
267340
} else if (runCompleted === true) {
268341
silent.push({ name, why: 'the run completed with every task successful, and this package reported nothing' });
269342
} else {
@@ -499,6 +572,50 @@ function selfTest({ quiet = false } = {}) {
499572
'grouped: an explicit prefix lost to the enclosing group',
500573
);
501574

575+
// -- THE FAILING TASK'S BARE HEADER: turbo's third shape. --
576+
// The exact bytes from run 32391924279 / job 96499888763, line 23907 (and
577+
// byte-identical in a 4-package turbo 2.10.10 sandbox), followed by the two
578+
// summary lines that actually sat under it.
579+
const bareFail = stripAnsi(
580+
['\x1B[;31m@objectstack/example-showcase:test\x1B[;0m', ' Test Files 21 passed (21)', ' Tests 342 passed (342)'].join('\n'),
581+
);
582+
eq(
583+
parseSummaries(bareFail).map((r) => [r.pkg, r.kind, r.counted, r.declared]),
584+
[['@objectstack/example-showcase', 'Test Files', 21, 21], ['@objectstack/example-showcase', 'Tests', 342, 342]],
585+
'bare header: the failing task\'s summary was not attributed to it',
586+
);
587+
// The next task's group must take over -- the bare block has no ::endgroup::.
588+
eq(
589+
parseSummaries(['@objectstack/example-showcase:test', ' Tests 342 passed (342)', '::group::@objectstack/runtime:test', ' Tests 10 passed (10)'].join('\n')).map((r) => r.pkg),
590+
['@objectstack/example-showcase', '@objectstack/runtime'],
591+
'bare header: attribution leaked into the next task\'s group',
592+
);
593+
// ...and turbo's own end-of-run roster closes it, so nothing after the
594+
// failing block can be charged to the failing package.
595+
eq(
596+
parseSummaries(['@objectstack/example-showcase:test', 'Failed: @objectstack/example-showcase#test', ' Tests 5 passed (5)'].join('\n')).map((r) => r.pkg),
597+
['(vitest)'],
598+
'bare header: attribution survived turbo\'s Failed: roster',
599+
);
600+
eq(
601+
parseSummaries([' Tasks: 1 successful, 4 total', ' Tests 5 passed (5)'].join('\n')).map((r) => r.pkg),
602+
['(vitest)'],
603+
'bare header: a summary after the Tasks roster was attributed',
604+
);
605+
// A `:build` bare header attributes nothing, exactly like a build group.
606+
eq(
607+
parseSummaries('@objectstack/spec:build\n Tests 5 passed (5)').map((r) => r.pkg),
608+
['(vitest)'],
609+
'bare header: a bare build header was read as a test header',
610+
);
611+
// Marker spellings must never be mistaken for a package name, whichever
612+
// branch above happens to consume them.
613+
eq(parseSummaries('::group::@objectstack/spec:test\n Tests 5 passed (5)').map((r) => r.pkg), ['@objectstack/spec'], 'bare header: ::group:: fell through to the bare branch');
614+
eq(parseSummaries('##[group]@objectstack/spec:test\n Tests 5 passed (5)').map((r) => r.pkg), ['@objectstack/spec'], 'bare header: ##[group] fell through to the bare branch');
615+
// Ordinary prose that merely ends in `:test` needs a space to be safe; a
616+
// no-space line is turbo's header and nothing else. Both directions pinned.
617+
eq(parseSummaries('see foo:test\n Tests 5 passed (5)').map((r) => r.pkg), ['(vitest)'], 'bare header: a phrase ending in :test was read as a header');
618+
502619
// -- Anonymous summaries: attribute only when there is nothing to guess. --
503620
const anon = (over) =>
504621
classifyShard({
@@ -540,6 +657,56 @@ function selfTest({ quiet = false } = {}) {
540657
'anonymous: the #10032 case stopped being red',
541658
);
542659

660+
// ⭐ THE REGRESSION THIS FIX EXISTS FOR. Run 32391924279 in miniature: three
661+
// candidates, one of them named in `Failed:`, and stray summaries present
662+
// because the failing task's header was not understood. The backstop used to
663+
// excuse ALL THREE and exit 0. Rule A must survive it, naming only the
664+
// failed one, and the other two stay ungraded notes rather than reds.
665+
const backstop = classifyShard({
666+
scheduled: ['showcase', 'runtime', 'cli'],
667+
reported: new Set(),
668+
anonymousReports: 2,
669+
failed: new Set(['showcase']),
670+
runCompleted: false,
671+
describe: describe({
672+
showcase: { hasTestScript: true, testFileCount: 21 },
673+
runtime: { hasTestScript: true, testFileCount: 9 },
674+
cli: { hasTestScript: true, testFileCount: 9 },
675+
}),
676+
});
677+
eq(backstop.silent.map((s) => s.name), ['showcase'], 'backstop: a FAILED package was excused by the stray-summary backstop');
678+
eq(backstop.unattributable, ['runtime', 'cli'], 'backstop: the ungraded non-failed packages were lost');
679+
eq(backstop.notReached, [], 'backstop: an ungraded package was also counted as never reached');
680+
if (!backstop.silent[0].why.includes('unattributed summary line(s) present')) {
681+
throw new Error('backstop: the red overclaims silence when stray summaries are present');
682+
}
683+
// ...and with no stray summaries the wording goes back to the plain claim.
684+
eq(
685+
classifyShard({
686+
scheduled: ['showcase'],
687+
reported: new Set(),
688+
anonymousReports: 0,
689+
failed: new Set(['showcase']),
690+
runCompleted: false,
691+
describe: describe({ showcase: { hasTestScript: true, testFileCount: 21 } }),
692+
}).silent.map((s) => s.why),
693+
['turbo reported this task FAILED, and the log holds no vitest summary for it'],
694+
'backstop: the plain Rule A wording changed',
695+
);
696+
// A failed package that DID report is still an ordinary failure, strays or not.
697+
eq(
698+
classifyShard({
699+
scheduled: ['showcase', 'runtime'],
700+
reported: new Set(['showcase']),
701+
anonymousReports: 3,
702+
failed: new Set(['showcase']),
703+
runCompleted: false,
704+
describe: describe({ showcase: { hasTestScript: true, testFileCount: 21 }, runtime: { hasTestScript: true, testFileCount: 9 } }),
705+
}).silent,
706+
[],
707+
'backstop: a reported failure was charged as silent',
708+
);
709+
543710
if (!quiet) console.log('check-test-completeness: self-test OK');
544711
}
545712

@@ -627,22 +794,36 @@ function main() {
627794
console.log(`check-test-completeness: note: ${n} was scheduled but never reached -- the run stopped before it.`);
628795
}
629796
if (shard.unattributable.length > 0) {
630-
console.log(
631-
`check-test-completeness: note: ${shard.unattributable.length} scheduled package(s) could not be ` +
632-
`matched to a summary, and the log holds unattributed summaries, so completeness is NOT graded for ` +
633-
`them: ${shard.unattributable.join(', ')}. Attributing a stray summary by guesswork would be worse ` +
634-
'than this gap. If you are seeing this, the log carries a shape neither the `<pkg>:test:` prefix nor ' +
635-
'turbo\'s `::group::<pkg>:test` header covers -- that is the bug to fix.',
636-
);
797+
const note =
798+
`${shard.unattributable.length} scheduled package(s) could not be ` +
799+
`matched to a summary, and the log holds unattributed summaries, so completeness is NOT graded for ` +
800+
`them: ${shard.unattributable.join(', ')}. Attributing a stray summary by guesswork would be worse ` +
801+
'than this gap. None of them was named in turbo\'s `Failed:` roster -- a failed package is judged ' +
802+
'even here, never excused. If you are seeing this, the log carries a shape none of the three this ' +
803+
'guard knows covers (`<pkg>:test:` prefix, `::group::<pkg>:test` header, bare `<pkg>:test` header) ' +
804+
'-- that is the bug to fix.';
805+
console.log(`check-test-completeness: note: ${note}`);
806+
// A note in a 30k-line log is not seen; that is how the last one sat
807+
// unread under a green. An annotation is.
808+
if (process.env.GITHUB_ACTIONS === 'true') {
809+
console.log(`::warning::check-test-completeness: ${note}`);
810+
}
637811
}
638812
}
639813

640814
if (holes.length === 0 && silent.length === 0) {
641815
if (shard) {
642-
const expected = shard.scheduledCount - shard.exempt.length - shard.notReached.length;
816+
const ungraded = shard.unattributable.length;
817+
const expected = shard.scheduledCount - shard.exempt.length - shard.notReached.length - ungraded;
818+
// ⛔ NOT "OK" when something went ungraded. The word is the payload: the
819+
// whole #10032 family is a green sentence read as a clearance, and
820+
// "OK (24 of 29 ... all accounted for)" printed directly under a note
821+
// saying three packages were not graded is exactly that sentence.
643822
console.log(
644-
`check-test-completeness: OK (${expected} of ${shard.scheduledCount} scheduled package(s) ` +
645-
`reported, ${shard.exempt.length} had nothing to run, ${shard.notReached.length} never reached; ` +
823+
`check-test-completeness: ${ungraded > 0 ? 'PARTIAL' : 'OK'} (${expected} of ${shard.scheduledCount} ` +
824+
`scheduled package(s) reported, ${shard.exempt.length} had nothing to run, ` +
825+
`${shard.notReached.length} never reached` +
826+
`${ungraded > 0 ? `, ${ungraded} NOT GRADED` : ''}; ` +
646827
`${rows.filter((r) => r.kind === 'Tests').reduce((s, r) => s + r.declared, 0)} test(s) declared and all accounted for).`,
647828
);
648829
process.exit(0);
@@ -666,9 +847,9 @@ function main() {
666847
}
667848

668849
if (silent.length > 0) {
669-
const were = silent.length === 1 ? 'package was' : 'packages were';
850+
const has = silent.length === 1 ? 'package has' : 'packages have';
670851
console.error(
671-
`check-test-completeness: ${silent.length} ${were} scheduled on this shard and reported no vitest summary\n`,
852+
`check-test-completeness: ${silent.length} scheduled ${has} no vitest summary this log can account for\n`,
672853
);
673854
for (const s of silent) {
674855
console.error(` • ${s.name} -- ${s.why}`);
@@ -687,9 +868,15 @@ level before writing a summary, or its captured output was lost.
687868
Precedent: #10032, where \`Test Core (2/3)\` failed naming
688869
@objectstack/example-showcase#test and the complete 5083-line job log contained
689870
no other mention of that package -- no summary, no FAIL, no test name -- while
690-
this guard printed OK. The mechanism was never reproduced and is still unknown;
691-
what this red buys is that the next occurrence names itself instead of sending
692-
triage to a wrong hypothesis.`);
871+
this guard printed OK. That original zero-output event was never reproduced and
872+
is still undiagnosed; what this red buys is that the next occurrence names
873+
itself instead of sending triage to a wrong hypothesis.
874+
875+
⚠ If the reason above cites UNATTRIBUTED summary lines, read this red as "the
876+
guard could not grade it", not as "the suite printed nothing" -- the summary may
877+
be sitting in the log under a task header this guard does not parse. Check the
878+
lines around the package's own header before concluding anything about the
879+
suite, and fix the header shape here.`);
693880
}
694881

695882
if (holes.length > 0) {

0 commit comments

Comments
 (0)