44 * The SDUI manifest resolver and the one decision the three authoring commands
55 * share about it (#20113). The command-level faces — text and `--json` of `os
66 * validate` / `os build` / `os lint`, and their exit statuses — are pinned by
7- * `test/jsx-gate-manifest-notice.test.ts`; this file holds the logic those
8- * faces all read.
7+ * `test/jsx-gate-manifest-notice.e2e.test.ts`, which runs NIGHTLY (it spawns
8+ * the CLI). This file is the per-PR guard, so every rule those faces read is
9+ * pinned HERE too — the package-carried layout included.
910 *
1011 * ⛔ Anchors, not prose: the notice is found by its `rule` id and asserted on
1112 * the DATA it must carry (the page count and every place looked), never on the
@@ -23,17 +24,39 @@ import {
2324 PROJECT_SDUI_MANIFEST_FILE ,
2425 SduiManifestRefusalError ,
2526 countJsxGatePages ,
27+ jsxGateStacks ,
2628 printJsxGateNotices ,
2729 resolveJsxGateManifest ,
2830 resolveSduiManifest ,
2931 type SduiManifestResolution ,
3032} from './sdui-manifest.js' ;
33+ import { artifactPackages , packageBodyAsStack , runPerPackageAuthoringRules } from './artifact-packages.js' ;
34+ import { authoringRuleUnionStack } from './stack-collections.js' ;
3135import { errorCodeFields , isReportedError } from './format.js' ;
3236
3337const MANIFEST = { components : { div : { type : 'div' , inputs : [ { name : 'children' , type : 'slot' } ] } } } ;
3438const HTML_STACK = { pages : [ { name : 'landing' , kind : 'html' , source : '<div>hi</div>' } ] } ;
3539const NO_PAGES_STACK = { objects : [ { name : 'ticket' } ] } ;
3640
41+ /** A `kind: 'html'` page the gate checks against a manifest. */
42+ const html = ( name : string ) => ( { name, label : name , kind : 'html' , source : '<div>hi</div>' } ) ;
43+ /** An ADR-0130 D4 package entry, complete enough for the fold to resolve it. */
44+ const pkg = ( id : string , pages : unknown [ ] ) => ( {
45+ manifest : { id, name : id . replace ( / \W / g, '_' ) , version : '1.0.0' , type : 'app' , pages } ,
46+ } ) ;
47+ /**
48+ * The round-1 layout: the top level carries a `pages` key — so the union fold
49+ * keeps it and folds nothing in — while the html pages live only inside
50+ * `packages[]`, where the per-package pass still hands them to the gate.
51+ */
52+ const PACKAGE_CARRIED : Record < string , Record < string , unknown > > = {
53+ 'top-level `pages: []`' : { pages : [ ] , packages : [ pkg ( 'com.x.site' , [ html ( 'site_landing' ) ] ) ] } ,
54+ 'a top-level kind:full page' : {
55+ pages : [ { name : 'home' , label : 'Home' , kind : 'full' , regions : [ ] } ] ,
56+ packages : [ pkg ( 'com.x.site' , [ html ( 'site_landing' ) ] ) ] ,
57+ } ,
58+ } ;
59+
3760const ABSENT : SduiManifestResolution = {
3861 status : 'absent' ,
3962 lookedAt : [ '/proj/sdui.manifest.json' , CONSOLE_SDUI_MANIFEST_SPECIFIER ] ,
@@ -136,6 +159,59 @@ describe('countJsxGatePages — the pages the JSX gate checks against a manifest
136159 } ) ;
137160} ) ;
138161
162+ describe ( 'countJsxGatePages — every stack the gate is handed, not the union fold alone' , ( ) => {
163+ const EMPTY_MANIFEST = { components : { } } ;
164+
165+ it . each ( Object . entries ( PACKAGE_CARRIED ) ) (
166+ '%s beside package-carried html pages: the per-package pass judges them, the fold alone judges none' ,
167+ ( _label , stack ) => {
168+ // WHY the fold alone undercounts: it keeps the top-level `pages` key, so
169+ // the union run's gate sees no html page at all…
170+ expect ( validateJsxPages ( authoringRuleUnionStack ( stack ) , { manifest : EMPTY_MANIFEST as never } ) ) . toEqual ( [ ] ) ;
171+ // …while the REAL per-package pass hands the package body to the gate —
172+ // with a manifest declaring nothing, the page's `div` is flagged there.
173+ const perPackage = runPerPackageAuthoringRules ( {
174+ command : 'validate' ,
175+ parsed : stack ,
176+ unionFindings : [ ] ,
177+ sduiManifest : EMPTY_MANIFEST ,
178+ } ) . findings . filter ( ( f ) => f . rule . startsWith ( 'jsx-' ) ) ;
179+ expect ( perPackage . length ) . toBeGreaterThan ( 0 ) ;
180+ expect ( perPackage . every ( ( f ) => f . where . includes ( 'site_landing' ) ) ) . toBe ( true ) ;
181+ // So the count follows what is judged: one page.
182+ expect ( countJsxGatePages ( stack ) ) . toBe ( 1 ) ;
183+ } ,
184+ ) ;
185+
186+ it ( 'jsxGateStacks is the fold plus the per-package pass’s OWN enumeration of the bodies' , ( ) => {
187+ const stack = PACKAGE_CARRIED [ 'top-level `pages: []`' ] ;
188+ expect ( jsxGateStacks ( stack ) ) . toEqual ( [
189+ authoringRuleUnionStack ( stack ) ,
190+ ...artifactPackages ( stack ) . map ( ( p ) => packageBodyAsStack ( p . body , stack . packages ) ) ,
191+ ] ) ;
192+ expect ( jsxGateStacks ( HTML_STACK ) ) . toEqual ( [ HTML_STACK ] ) ;
193+ } ) ;
194+
195+ it ( 'no double count where both runs see a page: option B (the fold copies the bodies in)' , ( ) => {
196+ const optionB = { packages : [ pkg ( 'com.x.a' , [ html ( 'a_one' ) ] ) , pkg ( 'com.x.b' , [ html ( 'b_one' ) , html ( 'b_two' ) ] ) ] } ;
197+ expect ( jsxGateStacks ( optionB ) ) . toHaveLength ( 3 ) ;
198+ expect ( countJsxGatePages ( optionB ) ) . toBe ( 3 ) ;
199+ } ) ;
200+
201+ it ( 'no double count where both runs see a page: additive (the top level already IS the union)' , ( ) => {
202+ const additive = {
203+ pages : [ html ( 'a_one' ) , html ( 'b_one' ) ] ,
204+ packages : [ pkg ( 'com.x.a' , [ html ( 'a_one' ) ] ) , pkg ( 'com.x.b' , [ html ( 'b_one' ) ] ) ] ,
205+ } ;
206+ expect ( countJsxGatePages ( additive ) ) . toBe ( 2 ) ;
207+ } ) ;
208+
209+ it ( 'an already-folded stack answers the same as the stack it was folded from' , ( ) => {
210+ const optionB = { packages : [ pkg ( 'com.x.a' , [ html ( 'a_one' ) ] ) ] } ;
211+ expect ( countJsxGatePages ( authoringRuleUnionStack ( optionB ) ) ) . toBe ( countJsxGatePages ( optionB ) ) ;
212+ } ) ;
213+ } ) ;
214+
139215describe ( 'resolveJsxGateManifest — one decision, three commands' , ( ) => {
140216 let errSpy : ReturnType < typeof vi . spyOn > ;
141217 beforeEach ( ( ) => {
@@ -167,6 +243,24 @@ describe('resolveJsxGateManifest — one decision, three commands', () => {
167243 expect ( n . hint ) . toContain ( '/proj/sdui.manifest.json' ) ;
168244 } ) ;
169245
246+ it . each ( Object . entries ( PACKAGE_CARRIED ) ) (
247+ 'absent, %s beside package-carried html pages: the notice, counting the package page' ,
248+ ( _label , stack ) => {
249+ const r = resolveJsxGateManifest ( stack , ABSENT ) ;
250+ expect ( r . sduiManifest ) . toBeUndefined ( ) ;
251+ expect ( r . notices ) . toHaveLength ( 1 ) ;
252+ expect ( r . notices [ 0 ] ) . toMatchObject ( { severity : 'info' , rule : JSX_PARSE_LEVEL_ONLY_RULE } ) ;
253+ expect ( r . notices [ 0 ] . message ) . toMatch ( / ^ 1 / ) ;
254+ } ,
255+ ) ;
256+
257+ it . each ( Object . entries ( PACKAGE_CARRIED ) ) (
258+ 'unusable, %s beside package-carried html pages: refused, not waved through' ,
259+ ( _label , stack ) => {
260+ expect ( ( ) => resolveJsxGateManifest ( stack , UNUSABLE ) ) . toThrow ( SduiManifestRefusalError ) ;
261+ } ,
262+ ) ;
263+
170264 it ( 'absent with nothing to check: silence is the true answer' , ( ) => {
171265 expect ( resolveJsxGateManifest ( NO_PAGES_STACK , ABSENT ) ) . toEqual ( { sduiManifest : undefined , notices : [ ] } ) ;
172266 expect (
0 commit comments