@@ -196,11 +196,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
196196 '7d. The producer cannot emit that stamp in the first place. writeStamp is' : 2 ,
197197 '8. A build that found no skew records it, and this gate says so honestly.' : 3 ,
198198 '12. ROUND TRIP against the real assert script: whatever it stamps, this gate' : 2 ,
199+ '13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose' : 6 ,
199200} ) ;
200201
201202// DELETING an entry silences that battery's floor exactly as effectively as
202203// zeroing it, so the roster's own size is pinned too.
203- const SELF_TEST_BATTERY_FLOOR = 10 ;
204+ const SELF_TEST_BATTERY_FLOOR = 11 ;
204205
205206// The key an assertion is filed under when no battery is open. It is not a
206207// declared battery, so it reds by the same set difference rather than silently
@@ -850,6 +851,68 @@ function selfTest() {
850851 }
851852 }
852853
854+ // 13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose
855+ // its probes with the bundle in view. A spec package publishes entries a
856+ // console never imports, and the alphabetically first unique description
857+ // can sit in one of them — on the fresh side that read a WORKING injection
858+ // as "neither spec appears", on the stale side it let a console built from
859+ // the PUBLISHED spec pass. Each fixture below puts the first unique
860+ // candidate where the bundle does not carry it, and runs the real assert
861+ // script against it.
862+ battery ( '13. THE BLIND SPOT (objectstack#20646): the build-time derivation must choose' ) ;
863+ {
864+ const assert = path . join ( ROOT , 'scripts' , 'assert-console-spec-injection.mjs' ) ;
865+ const runAssert = ( injected , vendored , dist ) =>
866+ spawnSync (
867+ process . execPath ,
868+ [ assert , '--injected' , injected , '--vendored' , vendored , '--assets' , path . join ( dist , 'assets' ) ] ,
869+ { encoding : 'utf8' } ,
870+ ) ;
871+ const SHARED = 'Shared text in both specs for the blind-spot fixtures' ;
872+ // Both sort before FRESH and STALE, so the old first-candidate pick chose them.
873+ const UNBUNDLED_FRESH = 'A description only an injected entry the console never imports carries' ;
874+ const UNBUNDLED_STALE = 'A description only a published entry the console never imports carries' ;
875+
876+ // Fresh side: the first injected-only candidate is not in the bundle, a later
877+ // one is. The injection worked, so the check must pass on the one it carries.
878+ const freshInjected = makeSpecPkg ( path . join ( root , 'bs-fresh-injected' ) , [ UNBUNDLED_FRESH , FRESH , SHARED ] ) ;
879+ const freshVendored = makeSpecPkg ( path . join ( root , 'bs-fresh-vendored' ) , [ STALE , SHARED ] ) ;
880+ const freshDist = makeDist ( path . join ( root , 'bs-fresh-dist' ) , `console(${ JSON . stringify ( FRESH ) } )` , undefined ) ;
881+ const fresh = runAssert ( freshInjected , freshVendored , freshDist ) ;
882+ expect ( 'a witness the bundle carries verifies, whatever sorts first' , fresh . status , 0 ) ;
883+ const freshStamp = fs . existsSync ( path . join ( freshDist , STAMP_BASENAME ) ) ? readStamp ( freshDist ) : null ;
884+ expect ( 'the stamp records the witness the bundle carries' , freshStamp ?. packages ?. [ 0 ] ?. freshWitness , FRESH ) ;
885+ expect ( 'and this gate replays that stamp green' , evaluate ( { distDir : freshDist , specDir : freshInjected } ) . code , 0 ) ;
886+
887+ // Stale side: the first published-only candidate is not in the bundle, a
888+ // later one IS. The console carries the published spec, so the check must
889+ // fail — the old single pick read the absent one and passed.
890+ const staleInjected = makeSpecPkg ( path . join ( root , 'bs-stale-injected' ) , [ FRESH , SHARED ] ) ;
891+ const staleVendored = makeSpecPkg ( path . join ( root , 'bs-stale-vendored' ) , [ UNBUNDLED_STALE , STALE , SHARED ] ) ;
892+ const staleDist = makeDist (
893+ path . join ( root , 'bs-stale-dist' ) ,
894+ `console(${ JSON . stringify ( FRESH ) } );console(${ JSON . stringify ( STALE ) } )` ,
895+ undefined ,
896+ ) ;
897+ const stale = runAssert ( staleInjected , staleVendored , staleDist ) ;
898+ expect ( 'any published-only description in the bundle fails the build' , stale . status , 1 ) ;
899+ checked += 1 ;
900+ if ( ! stale . stderr . includes ( 'still carries the PUBLISHED' ) || ! stale . stderr . includes ( STALE ) ) {
901+ failures . push ( 'the stale-side failure must say the published spec is bundled and name the text it found' ) ;
902+ }
903+ expect ( 'a failing build writes no stamp' , fs . existsSync ( path . join ( staleDist , STAMP_BASENAME ) ) , false ) ;
904+
905+ // Neither: the bundle carries no unique text from either spec. Still exit 2 —
906+ // choosing from the bundle must never turn "unverified" into a pass.
907+ const neitherDist = makeDist ( path . join ( root , 'bs-neither-dist' ) , `console(${ JSON . stringify ( SHARED ) } )` , undefined ) ;
908+ const neither = runAssert ( freshInjected , freshVendored , neitherDist ) ;
909+ expect ( 'neither spec in the bundle stays inconclusive' , neither . status , 2 ) ;
910+ checked += 1 ;
911+ if ( ! neither . stderr . includes ( 'Neither spec appears' ) ) {
912+ failures . push ( 'the neither-found verdict must still say that neither spec appears' ) ;
913+ }
914+ }
915+
853916 fs . rmSync ( root , { recursive : true , force : true } ) ;
854917
855918 // ── The floor: every declared battery RAN, and ran its cases (#13489) ───
0 commit comments