Skip to content

Commit 139a3d3

Browse files
committed
fix(cli): count JSX-gate pages over every stack the gate judges, not the union fold alone
The union fold keeps any top-level `pages` key, `[]` included, while the per-package pass still hands each packages[] body to the JSX gate. Counting the fold alone read 0 for html pages carried only in packages[] beside a top-level `pages` key: no notice, and an unusable manifest passed at exit 0. jsxGateStacks() now enumerates the fold plus the per-package pass's own bodies (artifactPackages + packageBodyAsStack); the count is distinct by page name. The spawn pin moves to the nightly tier by name (.e2e); every rule, the new layout included, is pinned in the unit file. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7c21c0 commit 139a3d3

6 files changed

Lines changed: 278 additions & 32 deletions

File tree

‎packages/cli/src/commands/compile.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,14 +406,15 @@ export default class Compile extends Command {
406406
// a finding spanning packages, which is exactly what an empty input
407407
// silently stops reporting.
408408
//
409-
// [#20113] The SDUI manifest is resolved ONCE, over the stack the JSX
410-
// gate judges, and both rule runs are handed the same answer — see
411-
// `validate.ts` step 3 for the three outcomes. Printed here, at the
409+
// [#20113] The SDUI manifest is resolved ONCE, and both rule runs are
410+
// handed the same answer; its pages are counted over the union fold
411+
// AND each package body the pass below judges — see `validate.ts`
412+
// step 3 for the three outcomes. Printed here, at the
412413
// gate's step, so the parse-level notice shows on the failing paths
413414
// too; it never changes this command's exit status. A project
414415
// manifest that exists but cannot be used is refused instead
415416
// (already reported on stderr; the catch-all exits 1).
416-
const jsxGate = resolveJsxGateManifest(authoringRuleUnionStack(result.data as Record<string, unknown>));
417+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
417418
jsxGateNotices = [...jsxGate.notices];
418419
if (!flags.json) printJsxGateNotices(jsxGateNotices);
419420
const findings = runAuthoringRules('build', {

‎packages/cli/src/commands/lint.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -957,8 +957,9 @@ export default class Lint extends Command {
957957
printWarning(formatConversionNotice(n));
958958
}
959959
}
960-
// [#20113] The SDUI manifest, resolved once over the stack `lintConfig`
961-
// folds to — see `validate.ts` step 3 for the three outcomes. With
960+
// [#20113] The SDUI manifest, resolved once; its pages are counted over
961+
// the fold `lintConfig` judges AND each package body its per-package
962+
// pass judges — see `validate.ts` step 3 for the three outcomes. With
962963
// `kind:'html'` pages to check and no manifest, the JSX gate runs at
963964
// parse level and the run SAYS so: one `info` finding, graded
964965
// `suggestion` by the one mapping, so it rides `issues` and the counts
@@ -967,7 +968,7 @@ export default class Lint extends Command {
967968
// reaches that function without a manifest and must not score a notice
968969
// about the filesystem. A project manifest that exists but cannot be
969970
// used is refused instead (already reported on stderr; exit 1).
970-
const jsxGate = resolveJsxGateManifest(authoringRuleUnionStack(normalized as Record<string, unknown>));
971+
const jsxGate = resolveJsxGateManifest(normalized as Record<string, unknown>);
971972
const issues = lintConfig(normalized, { sduiManifest: jsxGate.sduiManifest });
972973
issues.push(...jsxGate.notices.map(authoringFindingToLintIssue));
973974

‎packages/cli/src/commands/validate.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,11 @@ export default class Validate extends Command {
357357
// its collections is returned by identity, so every single-package
358358
// project is unaffected by construction.
359359
//
360-
// [#20113] The SDUI manifest is resolved ONCE, over the stack the JSX
361-
// gate judges, and both rule runs below are handed the same answer.
360+
// [#20113] The SDUI manifest is resolved ONCE, and both rule runs below
361+
// are handed the same answer. Its pages are counted over EVERY stack
362+
// the JSX gate is handed — the union fold AND each package body the
363+
// per-package pass judges — so html pages carried only in `packages[]`
364+
// count beside a top-level `pages` key the fold keeps.
362365
// With `kind:'html'` pages to check and no manifest, the gate runs at
363366
// parse level and SAYS so — here, at its step, so the line shows on
364367
// the failing paths below too, and in `warningsSoFar()` for `--json`.
@@ -368,7 +371,7 @@ export default class Validate extends Command {
368371
// this to a failure under `--strict` would break every such project.
369372
// A project manifest that exists but cannot be used is REFUSED
370373
// instead (thrown, already reported on stderr; the catch-all exits 1).
371-
const jsxGate = resolveJsxGateManifest(authoringRuleUnionStack(result.data as Record<string, unknown>));
374+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
372375
jsxGateNotices = [...jsxGate.notices];
373376
if (!flags.json) printJsxGateNotices(jsxGateNotices);
374377
const findings = runAuthoringRules('validate', {

‎packages/cli/src/utils/sdui-manifest.test.ts‎

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
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';
3135
import { errorCodeFields, isReportedError } from './format.js';
3236

3337
const MANIFEST = { components: { div: { type: 'div', inputs: [{ name: 'children', type: 'slot' }] } } };
3438
const HTML_STACK = { pages: [{ name: 'landing', kind: 'html', source: '<div>hi</div>' }] };
3539
const 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+
3760
const 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+
139215
describe('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(

‎packages/cli/src/utils/sdui-manifest.ts‎

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,18 @@
3636
* TypeError while `{ oops` passed it silently — one rule now
3737
* covers both, with the file and the reason named.
3838
*
39-
* Both the notice and the refusal fire only when the stack has a page the JSX
39+
* Both the notice and the refusal fire only when the run has a page the JSX
4040
* gate actually checks. With none, the manifest is read by nothing, so a
4141
* missing or broken one degrades nothing and silence is the true answer.
4242
*
43+
* "The run" is EVERY stack the gate is handed, not the union fold alone
44+
* ({@link jsxGateStacks}). The fold keeps any collection the top level already
45+
* carries, `pages: []` included, while the per-package pass still hands each
46+
* `packages[]` body to the gate with the same manifest. Counting the fold alone
47+
* read 0 for a top-level `pages` key beside package-carried html pages, so that
48+
* layout got no notice and a broken manifest passed at exit 0: the silent
49+
* degradation this module exists to end, one layout over (#20113 round 1).
50+
*
4351
* ⛔ The console leg's FAILURE semantics are deliberately unchanged: the
4452
* specifier below resolves to nothing today (the console's `exports` map does
4553
* not publish that subpath), and whatever makes it reachable owns what a broken
@@ -52,7 +60,9 @@ import { createRequire } from 'node:module';
5260
import { join } from 'node:path';
5361
import chalk from 'chalk';
5462
import type { AuthoringFinding } from '@objectstack/lint';
63+
import { artifactPackages, packageBodyAsStack } from './artifact-packages.js';
5564
import { printErrorToStderr, printInfo } from './format.js';
65+
import { authoringRuleUnionStack } from './stack-collections.js';
5666

5767
/** The file the project provides, looked for in the working directory. */
5868
export const PROJECT_SDUI_MANIFEST_FILE = 'sdui.manifest.json';
@@ -146,30 +156,69 @@ export function resolveSduiManifest(cwd: string = process.cwd()): SduiManifestRe
146156
}
147157

148158
/**
149-
* How many pages the JSX gate checks AGAINST a manifest: `kind: 'html'` (and
150-
* its deprecated alias `'jsx'`) with a non-empty `source`, over the collection
151-
* authored either as an array or as a name-keyed map.
159+
* Every stack the JSX gate is handed in one run of `os validate` / `os build` /
160+
* `os lint`, from the stack the command parsed: the union run's
161+
* `authoringRuleUnionStack()` fold, then each `packages[]` body as the
162+
* per-package pass judges it — read through that pass's OWN enumeration
163+
* (`artifactPackages` + `packageBodyAsStack`), ⛔ never a second walk of
164+
* `packages[]`, so the two cannot disagree about which bodies are judged.
165+
*
166+
* Handed an already-folded stack it answers the same: the fold keeps
167+
* `packages`, and re-folding a folded stack returns it by identity.
168+
*/
169+
export function jsxGateStacks(stack: AnyRec): AnyRec[] {
170+
return [
171+
authoringRuleUnionStack(stack),
172+
...artifactPackages(stack).map((pkg) => packageBodyAsStack(pkg.body, stack.packages)),
173+
];
174+
}
175+
176+
/**
177+
* The pages one stack's JSX gate checks AGAINST a manifest: `kind: 'html'`
178+
* (and its deprecated alias `'jsx'`) with a non-empty `source`, over the
179+
* collection authored either as an array or as a name-keyed map — each keyed
180+
* by the name the gate reports it under (`page "NAME"`; a map entry's key when
181+
* the entry carries no `name`), or by the record itself when it has neither.
152182
*
153183
* The kind set is `validateJsxPages`'s own (`packages/lint/src/
154184
* validate-jsx-pages.ts`), and the empty-source pages are left out because the
155185
* gate refuses those with `jsx-page-empty-source` before a manifest is ever
156186
* consulted. `sdui-manifest.test.ts` holds the two equal by driving the real
157187
* rule, so a kind the gate starts or stops checking reds there.
158-
*
159-
* Hand it the stack the gate judges — the `authoringRuleUnionStack()` fold —
160-
* so a page that lives only in `packages[]` is counted.
161188
*/
162-
export function countJsxGatePages(stack: AnyRec): number {
189+
function checkedPageKeys(stack: AnyRec): unknown[] {
163190
const pages = stack.pages;
164-
const entries: unknown[] = Array.isArray(pages) ? pages : isRecord(pages) ? Object.values(pages) : [];
165-
let count = 0;
166-
for (const page of entries) {
191+
const entries: Array<[string | undefined, unknown]> = Array.isArray(pages)
192+
? pages.map((page): [undefined, unknown] => [undefined, page])
193+
: isRecord(pages)
194+
? Object.entries(pages)
195+
: [];
196+
const keys: unknown[] = [];
197+
for (const [mapKey, page] of entries) {
167198
if (!isRecord(page)) continue;
168199
if (page.kind !== 'html' && page.kind !== 'jsx') continue;
169200
if (typeof page.source !== 'string' || page.source.trim() === '') continue;
170-
count++;
201+
const name = typeof page.name === 'string' && page.name !== '' ? page.name : mapKey;
202+
keys.push(name === undefined ? page : `page:${name}`);
203+
}
204+
return keys;
205+
}
206+
207+
/**
208+
* How many DISTINCT pages the JSX gate checks against a manifest across every
209+
* stack it is handed ({@link jsxGateStacks}). Distinct by page name, because a
210+
* page reaches the gate twice whenever both runs see it: the fold copies an
211+
* absent `pages` in from the bodies, and an additive artifact's top-level
212+
* `pages` already IS the union of them. ⚠️ So two packages each shipping a
213+
* page under the SAME name count once — a name collision, not a layout this
214+
* count is the place to judge.
215+
*/
216+
export function countJsxGatePages(stack: AnyRec): number {
217+
const seen = new Set<unknown>();
218+
for (const judged of jsxGateStacks(stack)) {
219+
for (const key of checkedPageKeys(judged)) seen.add(key);
171220
}
172-
return count;
221+
return seen.size;
173222
}
174223

175224
/**
@@ -207,9 +256,12 @@ export interface JsxGateManifest {
207256

208257
/**
209258
* The one decision the three authoring commands share about the manifest,
210-
* made once per run over the stack the JSX gate judges. Throws
211-
* {@link SduiManifestRefusalError} for an `unusable` project manifest when
212-
* there is a page to check; see the header for the three outcomes.
259+
* made once per run. `stack` is the stack the command parsed; the pages
260+
* counted are those of every stack the gate is handed ({@link jsxGateStacks}),
261+
* so a page carried only inside `packages[]` counts whatever the top level
262+
* holds. Throws {@link SduiManifestRefusalError} for an `unusable` project
263+
* manifest when there is a page to check; see the header for the three
264+
* outcomes.
213265
*/
214266
export function resolveJsxGateManifest(
215267
stack: AnyRec,

0 commit comments

Comments
 (0)