@@ -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.
148183const GROUP_OPEN = / ^ (?: : : g r o u p : : | # # \[ g r o u p \] ) ( .+ ?) \s * $ / ;
149184const GROUP_CLOSE = / ^ (?: : : e n d g r o u p : : | # # \[ e n d g r o u p \] ) \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 + ) : t e s t $ / ;
150188
151189export 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.
687868Precedent: #10032, where \`Test Core (2/3)\` failed naming
688869@objectstack/example-showcase#test and the complete 5083-line job log contained
689870no 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