Skip to content

Commit 455dcc0

Browse files
fix(cli): the JSX page gate says when it ran at parse level only, and refuses an unusable project manifest (#20164)
Fixes #20113 Clause-②: no ## What this changes `os validate`, `os build` and `os lint` check `kind: 'html'` pages (and the deprecated `kind: 'jsx'`) against an SDUI component manifest when one resolves, and at parse level only when none does. Until now all three did the parse-level fallback in silence and reported success. That is the verifier that "silently degrades … because it reports success" (AGENTS.md, Route & surface ownership, rule 3). One resolver, one decision, one spelling: - `resolveSduiManifest()` (`packages/cli/src/utils/sdui-manifest.ts`) now says why it has no manifest. It returns `resolved`, `absent` (with every place looked, in order) or `unusable` (a project file that exists but cannot be read, is not valid JSON, or has no `components` map). It never throws. - `resolveJsxGateManifest(stack)` is the one decision the three commands share. It runs once per run, and both rule runs (union and per-package) get the same answer. It counts pages over EVERY stack the JSX gate is handed: `jsxGateStacks()` returns the `authoringRuleUnionStack()` fold, then each `packages[]` body read through the per-package pass's own enumeration (`artifactPackages` + `packageBodyAsStack`). Pages are counted distinct by name: - `resolved`: full validation, nothing said. - `absent` with a page to check: parse level, plus ONE notice tagged `sdui/jsx-parse-level-only`. The notice gives the distinct page count, top-level and package-carried alike, and names `CWD/sdui.manifest.json`, then `@objectstack/console/dist/sdui.manifest.json`. The exit status does not change. - `unusable` with a page to check: refused, exit 1 (the decision is below). - No page the gate checks: nothing said and nothing refused, whatever the manifest state. The manifest is read by nothing in that case. - The kind set the count uses is `validateJsxPages`'s own. `sdui-manifest.test.ts` holds the two equal by driving the real rule over eight kinds. It also has a non-vacuity check that both verdicts occur. ### Where the notice goes, per command (Zone 2 item 4, measured) All three commands have a `--json` mode. The notice rides a channel each one already publishes, with a severity value that already exists. No top-level key is added: the pin compares the payload key sets with and without the notice, and they are equal. | command | text face | `--json` | `--strict` | |---|---|---|---| | `os validate` | an info line at the author-time-rules step | an `info` AuthoringFinding-shaped record, appended last to `warningsSoFar()` | not promoted, same as the navigation and permission-set advisories. The notice rides the payload's `warnings` but not the text face's `warnings` list that `--strict` reads | | `os build` | an info line at the author-time-rules step | the same record, appended last to `warningsSoFar()`, so validate/build parity holds | n/a | | `os lint` | listed under Suggestions | a `suggestion` in `issues`, produced by the one `info` to `suggestion` mapping, now a named function that `lintConfig` also uses. `total` and `suggestions` move by one | suggestions never fail; `passed` and `failing` are unchanged | The notice is pushed in the `lint` command, not inside `lintConfig`, because `scoreMetadata` reaches `lintConfig` without a manifest and must not score a notice about the filesystem. ### Zone 2 item 5: a present-but-unusable project manifest is a FAILURE Measured on `examples/app-showcase` at `8d1f7ab7`, with a project `sdui.manifest.json`: | file content | before | after | |---|---|---| | `{ "components": [ oops` (invalid JSON) | exit 0, output byte-identical to "no manifest" (106 lines) | exit 1, file and reason named | | `null` | exit 0, silent (fell through to the dead console leg) | exit 1 | | `{}` / `[]` / `"x"` | exit 1, `Cannot convert undefined or null to object` (bare TypeError from `Object.keys(manifest.components)`) | exit 1, file and reason named | | the repo-root manifest (control) | exit 1, 200 issues (full validation armed) | unchanged | Decision under the four axes: - **Real business need:** 0 of the in-tree examples ship a project manifest, and no customer-facing doc under `content/docs` or `skills/` names the file. Only an author who deliberately placed one is affected, and that author asked for full checking. - **Long-term soundness:** "Prefer failing to falling back". The wrong-shape half already failed, only cryptically. One rule now covers both halves and names the file and the reason. - **Keeping AI from writing wrong code:** an AI that writes a truncated or wrong-shape manifest now gets a located refusal, not a green run that reads as "components and props checked". - **No startup-stage scope creep:** no new gate, flag or key. A silent fallback branch is removed outright, with no transition window. The refusal fires only when there is a `kind: 'html'` page to check. It is reported once on stderr and carried in the `--json` envelope's `error`. Every command exits 1 through its existing catch-all via the structural `reportedToStderr` marker. `os build` exits 1, not the oclif 2. No ADR-0112 `code` is minted. This is a behaviour change on a verify path (exit 0 to exit 1 for invalid JSON, `null` or an unreadable file). The seat ruled to keep it (option A), under AGENTS.md rule 3. `Clause-②: no` is kept from the claim, and the review upheld it: the refused inputs never worked as manifests, the `{}` half already exited 1, and the file appears in no published doc. The closest precedent is the non-array `packages` refusal changeset (`Clause-②: no`, patch). ### Not changed - The console-manifest fallback. #19922 remains open and #20112 remains open. The resolver keeps the same specifier, and a failure on that leg still reads as "not found". The `absent` notice names that location, so it is not silent. What a broken shipped copy should do belongs to the change that makes the leg reachable. - `os init`'s scaffold check (`utils/scaffold-validate.ts`) reads the invoker's directory, not the scaffold's. It keeps its old reading, the manifest or nothing, and neither notices nor refuses. The file changed only because the resolver's return type did. The claim's file surface was amended to name it. ### Round 1 (review FAIL on `08a046af`) The at-tier review found a layout the page count missed. The union fold keeps any collection the top level already carries, `pages: []` included. The per-package pass still hands each `packages[]` body to the JSX gate with the same manifest. So a top-level `pages` key (`[]`, or only non-html pages) beside html pages carried only in `packages[]` counted 0. That layout got no notice, and a malformed manifest passed at exit 0, on all three commands. The count now covers the fold plus each package body, as described above. Other round-1 changes: - The spawn pin moved to the nightly tier by name (`.e2e`). Every rule it pins is also pinned in the unit file, which is the per-PR guard. - The changeset bullets were corrected for package-carried pages and now say "reports one notice" (an info line plus a hint line) instead of "prints one line". - `origin/main` was merged in at `84880f92`. #20166 remains open (the manifest lookup reads the invoker's working directory, not the config's). ## Tests Final head `0fb378cf` (round 1, after the merge of `origin/main` at `84880f92`) unless a row says otherwise. - **Red, before the fix** (`8d1f7ab7`, `examples/app-showcase`, built CLI): `os validate` exit 0, 106 lines, `manifest` / `parse-level` / `jsx` / `sdui` each 0 hits. `os build` exit 0, 180 lines, 0 on all four. `os lint` exit 0, 1035 lines, 0 on `manifest` / `parse-level` / `sdui`; its 2 `jsx` hits are the page name `showcase_command_center_jsx`. - **Round-1 red** (merge commit `f7c21c0d`, built CLI, before the round-1 fix): the fixture was a top-level `pages: []` beside one html page in `packages[]`. It gave 0 notices on the `--json` faces of all three commands, at exit 0. With a malformed manifest, all three exited 0 with empty stderr. A lit control (an undeclared tag with a resolvable manifest) failed with `jsx-forbidden-tag` at `package 'com.example.jxg.site' — page "jxg_landing"`, so the per-package pass does judge that page. - **Green, after:** the same three commands exit 0 and each prints one `sdui/jsx-parse-level-only` line (validate 108 lines, build 182, lint 1037 with 28 suggestions, previously 27). `--json` on all three carries exactly one notice record, with top-level keys unchanged. - `packages/cli/src/utils/sdui-manifest.test.ts` (unit, 35 tests; the per-PR guard) covers: - resolver outcomes, including the directory-where-the-file-should-be case; - the kind-set parity with `validateJsxPages`; - the notice and refusal logic, including the stderr report and the absence of a minted code; - the package-carried layout (top-level `pages: []`, and a top-level `kind: 'full'` page). A behavioural pin shows the real per-package pass judges those pages while the fold alone judges none. `jsxGateStacks()` equals the fold plus the pass's own enumeration. The notice and the refusal fire on that layout. Option B and additive artifacts are not double counted. - `packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts` (renamed in round 1 by seat decision, so it runs in the nightly tier by name like its spawning siblings; spawns `bin/run-dev.js`, the source entry; 30 spawns, 3 at a time; 32 tests, 89.8s with `OS_TEST_TIERS=nightly` at `139a3d3a`): - With no manifest: the notice appears on the text and `--json` faces of all three commands, and exit is 0 under `--strict` on a fixture whose only advisory is the notice. - Controls: a resolvable manifest gives no notice. A lit control shows that manifest arms full validation (`jsx-forbidden-tag` fires with it). No html pages gives no notice. - A malformed manifest gives exit 1 on all three commands, with the file named in the envelope and on stderr, reported once in text mode. A malformed manifest with no html pages gives exit 0 and empty stderr. - Round 1 adds 12 pins for the package-carried layout: a top-level `pages: []`, and a top-level `kind: 'full'` page, each beside an html page inside `packages[]`. They pin the notice counting that page on all three commands, and exit 1 with a malformed manifest on all three. - The pin file and every source file it spawns are byte-identical at `0fb378cf`. - Tier partition: `test/vitest-tiers-partition.test.ts` 22/22 in the queue setting. Under `OS_TEST_TIERS=nightly` the pin is not collected at all, because it is not a nightly-tier file, so that run measures nothing (NOT MEASURED). The nightly collection list printed there includes `test/jsx-gate-manifest-notice.e2e.test.ts`. `node scripts/nightly-tiers.mjs --check`: the CLI owns 72 nightly-tier files, and vitest collects 72 under nightly and 277 under queue. - **Reverse verification** (committed first). The five source files were restored to `8d1f7ab7` with `git restore --source`, working tree only. On-disk proof: `resolveJsxGateManifest` 0 hits in each file, and the base spelling `resolveSduiManifest()` 8 hits. The pin file then showed **11 failed / 9 passed**: every notice and refusal pin red, every control green (the direction declared up front). The restore used `git checkout HEAD --` under a `trap`, and the five blobs were proven hash-equal to `HEAD`, with `git diff HEAD` empty. - **Round-1 reverse verification** (fix committed at `139a3d3a`): `scripts/ablation-replace.mjs` removed the per-package leg from `jsxGateStacks()`, making it the fold alone. On-disk proof: anchor 1 to 0, blob `41c4549f` to `f8d39ab7`. Results: - unit file: **8 failed / 27 passed**; every red is a layout or enumeration pin, and every round-0 pin stays green; - e2e pin with `OS_TEST_TIERS=nightly`: **12 failed / 20 passed**; the 12 are exactly the new layout pins. - The restore was proven: blob equal to `HEAD` (`41c4549f`), `git diff HEAD` empty. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 225 files / 3193 tests passed. This includes the tier-partition pin and `validate-build-gate-parity.test.ts`, whose closed ledger now has `resolveJsxGateManifest` in `SHARED_NON_REGISTRY_GATES` (it judges an input and can refuse) and `printJsxGateNotices` under presentation. The retired `resolveSduiManifest` row was removed. - `pnpm --filter @objectstack/lint exec vitest run src/authoring-rule-wiring.test.ts`: 26/26. This test reads the three command files from disk. - The nightly files whose fixtures carry html pages (`build-json-failure-conversions`, `lint-conversion-notices`, `validate-json-failure-conversions`, run with `OS_TEST_TIERS=nightly`): 3 files / 35 tests passed at `08a046af`. They were not re-run in round 1: their fixtures carry no `packages[]`, so the round-1 count gives them the same answer, and the merge brought no change under `packages/cli`, `packages/lint` or `packages/sdui-parser`. - `pnpm --filter @objectstack/cli run typecheck`: exit 0. Both new test files are in a compiled program: `tsc --listFiles` shows the `src/` test under `tsconfig.json` and the `test/` pin under `tsconfig.test.json`. - `pnpm lint` (the whole tree, `eslint . --no-inline-config`): exit 0 in 103s at `0fb378cf`. No narrowing was needed. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (merge base `84880f92`): 62 families, the same list as round 0, all run at `0fb378cf` with every exit code recorded. `--ran` reconciliation: 62 derived, 62 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. In the fresh worktree, `check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT MET: the same eight packages outside this diff's closure had no `dist/`). Those were built from the turbo cache, and the gate was re-measured at exit 0 at the same head. Also run: the four roster gates whose roster directory holds one of these paths (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`), all exit 0. - **NOT MEASURED locally, declared to CI:** the rest of the `integration` tier, the Dogfood / Build Core / Temporal / Test Core CI jobs, and the workspace type-check lanes. ## Acceptance notes - The manifest lookup reads the invoker's working directory, not the config's directory: #20166 remains open. - `utils/scaffold-validate.ts`'s docblock still says a fresh scaffold resolves "the copy shipped in `@objectstack/console`". That is false while the console leg cannot resolve. It is the same family as #19922 and is left for the change that makes that leg reachable. - The console leg's failure semantics are unchanged on purpose (see "Not changed"). - `content/docs`: no hand-written line becomes false. The validating-metadata matrix row reads "JSX / React page source parses", which stays true. The `--strict` warning list in `deployment/cli.mdx` does not include the notice, and the notice is not gated. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3bd28e2 commit 455dcc0

9 files changed

Lines changed: 1108 additions & 35 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os validate` / `os build` / `os lint` now say when the JSX page gate ran at parse level only, and refuse a project `sdui.manifest.json` they cannot use (#20113)
6+
7+
Clause-②: no
8+
9+
The gate that checks `kind: 'html'` pages (and the deprecated `kind: 'jsx'`) validates components and props only when an SDUI component manifest resolves: the project's `sdui.manifest.json` in the working directory, then the copy inside `@objectstack/console`. With neither, it falls back to parse-level checking (syntax, tag matching, forbidden constructs). All three commands used to do that silently and report success, so "passed" read as "components and props checked" when they were not.
10+
11+
A page "to check" below means one the gate actually judges, wherever it is declared: at the top level of the stack, or inside a `packages[]` entry. The per-package pass judges those even when the top level carries its own `pages` key.
12+
13+
- **No manifest, `kind: 'html'` pages to check: a notice, exit status unchanged.** Each command reports one notice, tagged `sdui/jsx-parse-level-only`. It gives the number of distinct pages checked at parse level only, top-level and package-carried alike, and names every place a manifest was looked for. `os validate` and `os build` print it as an info line plus a hint line; `os lint` lists it under Suggestions. `--json` carries it in the channel each command already publishes. It is an `info` record (the author-time finding shape) at the end of `warnings` on `os validate` / `os build`, and a `suggestion` in `os lint`'s `issues`, which also moves that run's `total` and `suggestions` counts by one. No top-level key is added. `--strict` does not promote it on any command, so a project without its own manifest exits exactly as before.
14+
- **A project `sdui.manifest.json` that exists but cannot be used: refused (exit 1).** This applies when there is a `kind: 'html'` page to check, top-level or package-carried, and the file cannot be read, is not valid JSON, or is not a JSON object with a `components` map. The file and the reason are named on stderr and in the `--json` envelope's `error`. Before, invalid JSON, `null` and an unreadable file were ignored in silence (parse-level checking, exit 0), while `{}` or an array crashed the run with `Cannot convert undefined or null to object` (exit 1). **Fix:** correct the file, or remove it to check those pages at parse level only.
15+
- **Unchanged:** a project with no `kind: 'html'` page to check, at the top level or in any package, says nothing and refuses nothing, whatever its manifest looks like. A resolvable manifest arms full validation exactly as before. `os init`'s scaffold check reads the manifest as it did.

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1919
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
2020
import { buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
2121
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
22-
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
22+
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
2323
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2424
import { attachPackageDocs, collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2525
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
@@ -164,13 +164,22 @@ export default class Compile extends Command {
164164
// `severity: 'warning'` is declared at the producer — this reports, it
165165
// never refuses.
166166
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
167+
// [#20113] The JSX gate's parse-level notice — empty, or ONE `info` record
168+
// saying `kind:'html'` pages were checked at parse level only because no
169+
// SDUI component manifest resolved. A member of `warningsSoFar()`, ⛔ not a
170+
// payload key, for the three reasons the two lists above record: the
171+
// undeclared-key pins refuse a new top-level key by name, `os validate`
172+
// computes the identical record so the residue pin holds, and it reports
173+
// without ever refusing. Appended LAST, in `os validate`'s order.
174+
let jsxGateNotices: ReturnType<typeof resolveJsxGateManifest>['notices'] = [];
167175
const warningsSoFar = () => [
168176
...ruleAdvisories,
169177
...docWarnings,
170178
...unknownKeyWarnings,
171179
...capProviderWarnings,
172180
...navGroupWarnings,
173181
...permissionSetCollisionWarnings,
182+
...jsxGateNotices,
174183
];
175184
// [#18780] ONE rendering of the author-time advisory block, from the
176185
// COMPLETE list — hoisted here for the same reason the lists above are.
@@ -396,10 +405,22 @@ export default class Compile extends Command {
396405
// `packages[]`. The union run is the only one of the two that can see
397406
// a finding spanning packages, which is exactly what an empty input
398407
// silently stops reporting.
408+
//
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
413+
// gate's step, so the parse-level notice shows on the failing paths
414+
// too; it never changes this command's exit status. A project
415+
// manifest that exists but cannot be used is refused instead
416+
// (already reported on stderr; the catch-all exits 1).
417+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
418+
jsxGateNotices = [...jsxGate.notices];
419+
if (!flags.json) printJsxGateNotices(jsxGateNotices);
399420
const findings = runAuthoringRules('build', {
400421
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
401422
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
402-
sduiManifest: resolveSduiManifest(),
423+
sduiManifest: jsxGate.sduiManifest,
403424
// [#16546] Ref strings, not hook indices — survive the union fold and
404425
// the per-package re-slice below unchanged (see `LoweringResult.
405426
// loweredHookRefs`'s header for why an index would not).
@@ -497,7 +518,7 @@ export default class Compile extends Command {
497518
command: 'build',
498519
parsed: result.data as Record<string, unknown>,
499520
unionFindings: findings,
500-
sduiManifest: resolveSduiManifest(),
521+
sduiManifest: jsxGate.sduiManifest,
501522
loweredHookRefs: lowering.loweredHookRefs,
502523
});
503524
const perPackageErrors: Array<{ package: string } & typeof ruleErrors[number]> =

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

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { GLOBAL_ACTION_OBJECT_KEY } from '@objectstack/objectql';
99
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1010
import { computeI18nCoverage, type CoverageIssue } from '../utils/i18n-coverage.js';
1111
import { lintDataModel, runAuthoringRules } from '@objectstack/lint';
12-
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
12+
import { resolveJsxGateManifest } from '../utils/sdui-manifest.js';
1313
import { collectAndLintDocs } from '../utils/collect-docs.js';
1414
import { scoreMetadata } from '../lint/score.js';
1515
import { checkHookBodyLowering } from '../lint/hook-body-lowering.js';
@@ -748,18 +748,28 @@ export function lintConfig(config: any, opts: LintConfigOptions = {}): LintIssue
748748
// ⛔ ONE mapping for both halves. A second copy of this expression is how one
749749
// list comes to render `info` as `suggestion` and the other does not.
750750
for (const f of [...unionFindings, ...perPackageFindings]) {
751-
issues.push({
752-
severity: f.severity === 'info' ? 'suggestion' : f.severity,
753-
rule: f.rule,
754-
message: `${f.where}: ${f.message}`,
755-
path: f.path,
756-
fix: f.hint,
757-
});
751+
issues.push(authoringFindingToLintIssue(f));
758752
}
759753

760754
return issues;
761755
}
762756

757+
/**
758+
* The ONE mapping from an author-time finding to an `os lint` issue — `info`
759+
* grades as `suggestion`, the location leads the message, the hint is the
760+
* fix. Read by both halves of `lintConfig`'s rule run and by the command's
761+
* JSX-gate notice (#20113), so the three cannot render one severity two ways.
762+
*/
763+
function authoringFindingToLintIssue(f: ReturnType<typeof runAuthoringRules>[number]): LintIssue {
764+
return {
765+
severity: f.severity === 'info' ? 'suggestion' : f.severity,
766+
rule: f.rule,
767+
message: `${f.where}: ${f.message}`,
768+
path: f.path,
769+
fix: f.hint,
770+
};
771+
}
772+
763773
// ─── Command ────────────────────────────────────────────────────────
764774

765775
export default class Lint extends Command {
@@ -947,7 +957,20 @@ export default class Lint extends Command {
947957
printWarning(formatConversionNotice(n));
948958
}
949959
}
950-
const issues = lintConfig(normalized, { sduiManifest: resolveSduiManifest() });
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
963+
// `kind:'html'` pages to check and no manifest, the JSX gate runs at
964+
// parse level and the run SAYS so: one `info` finding, graded
965+
// `suggestion` by the one mapping, so it rides `issues` and the counts
966+
// and never fails a run — `--strict` promotes warnings, not suggestions.
967+
// Pushed HERE rather than inside `lintConfig`, because `scoreMetadata`
968+
// reaches that function without a manifest and must not score a notice
969+
// about the filesystem. A project manifest that exists but cannot be
970+
// used is refused instead (already reported on stderr; exit 1).
971+
const jsxGate = resolveJsxGateManifest(normalized as Record<string, unknown>);
972+
const issues = lintConfig(normalized, { sduiManifest: jsxGate.sduiManifest });
973+
issues.push(...jsxGate.notices.map(authoringFindingToLintIssue));
951974

952975
// ── Package docs (ADR-0046) ── collected src/docs/*.md + inline docs:
953976
// flatness, namespace-prefixed names, MDX/image ban, link resolution.

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { authoringRuleUnionStack } from '../utils/stack-collections.js';
1919
// `os compile` — ⛔ the loop is not re-written here; see that module's header.
2020
import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js';
2121
import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint';
22-
import { resolveSduiManifest } from '../utils/sdui-manifest.js';
22+
import { resolveJsxGateManifest, printJsxGateNotices } from '../utils/sdui-manifest.js';
2323
import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js';
2424
import { collectAndLintDocs, type DocIssue } from '../utils/collect-docs.js';
2525
import {
@@ -151,6 +151,14 @@ export default class Validate extends Command {
151151
// reason the line above it gives: the #11727 residue pin asserts that
152152
// nothing rides in build's `warnings` that validate does not also report.
153153
let permissionSetCollisionWarnings: PermissionSetNameCollisionDiagnostic[] = [];
154+
// [#20113] The JSX gate's parse-level notice: empty, or ONE `info` record
155+
// saying `kind:'html'` pages were checked at parse level only because no
156+
// SDUI component manifest resolved, and where one was looked for. Same
157+
// class as the two members above it — reports, never refuses, and is NOT
158+
// in the `warnings` list `--strict` reads (see step 3), so the exit status
159+
// of a project without a manifest is unchanged on both faces. `os compile`
160+
// computes the identical record, so the residue pin keeps holding.
161+
let jsxGateNotices: ReturnType<typeof resolveJsxGateManifest>['notices'] = [];
154162
const warningsSoFar = () => [
155163
...ruleAdvisories,
156164
...docWarnings,
@@ -167,6 +175,8 @@ export default class Validate extends Command {
167175
...navGroupWarnings,
168176
// [#18024] APPENDED for the same reason, one member later.
169177
...permissionSetCollisionWarnings,
178+
// [#20113] APPENDED for the same reason, one member later again.
179+
...jsxGateNotices,
170180
];
171181
// [#12125] The ADR-0087 D2 conversion notices, hoisted for the SAME reason
172182
// and under the SAME ruling as the five lists above — one field over. The
@@ -346,10 +356,28 @@ export default class Validate extends Command {
346356
// stats below nor the `--json` payload. A stack that still carries
347357
// its collections is returned by identity, so every single-package
348358
// project is unaffected by construction.
359+
//
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.
365+
// With `kind:'html'` pages to check and no manifest, the gate runs at
366+
// parse level and SAYS so — here, at its step, so the line shows on
367+
// the failing paths below too, and in `warningsSoFar()` for `--json`.
368+
// It stays out of the `warnings` list `--strict` reads, exactly like
369+
// the navigation and permission-set advisories: a project with no
370+
// manifest of its own has no remedy but to author one, so promoting
371+
// this to a failure under `--strict` would break every such project.
372+
// A project manifest that exists but cannot be used is REFUSED
373+
// instead (thrown, already reported on stderr; the catch-all exits 1).
374+
const jsxGate = resolveJsxGateManifest(result.data as Record<string, unknown>);
375+
jsxGateNotices = [...jsxGate.notices];
376+
if (!flags.json) printJsxGateNotices(jsxGateNotices);
349377
const findings = runAuthoringRules('validate', {
350378
normalized: authoringRuleUnionStack(normalized as Record<string, unknown>),
351379
parsed: authoringRuleUnionStack(result.data as Record<string, unknown>),
352-
sduiManifest: resolveSduiManifest(),
380+
sduiManifest: jsxGate.sduiManifest,
353381
// [#16546] Same ref set `os build` / `os lint` compute — keeps this
354382
// door's hook write-set findings at the same `path` as the other two.
355383
loweredHookRefs: lowering.loweredHookRefs,
@@ -433,7 +461,7 @@ export default class Validate extends Command {
433461
command: 'validate',
434462
parsed: result.data as Record<string, unknown>,
435463
unionFindings: findings,
436-
sduiManifest: resolveSduiManifest(),
464+
sduiManifest: jsxGate.sduiManifest,
437465
// [#16546] The same ref set the union run above was handed, so a
438466
// per-package hook write-set finding reports at the same `path` the
439467
// other two doors report it at.
@@ -723,6 +751,13 @@ export default class Validate extends Command {
723751
// same divergence one collection narrower. `protocolVersionGap` stays
724752
// out on both faces; it is never gated by `--strict` (see below).
725753
//
754+
// [#20113] The difference runs the other way too, and that half is
755+
// deliberate: the navigation-contribution and permission-set
756+
// advisories (#14553, #18024) and the JSX gate's parse-level notice
757+
// ride the payload's `warnings` but not this list, so none of them
758+
// is gated by `--strict` on either face — each has its own text
759+
// block rather than a `⚠` line in the one below.
760+
//
726761
// `valid: true` beside a 1 is not a contradiction, it is the text
727762
// face verbatim: that path prints "Validation passed" and THEN fails
728763
// for strict. The stack IS schema-valid; `--strict` is what promotes

‎packages/cli/src/utils/scaffold-validate.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,17 @@ export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport {
8484
};
8585
}
8686

87+
// [#20113] The resolver now says WHY it has no manifest. This door keeps its
88+
// old reading — the manifest, or nothing — and neither notices nor refuses:
89+
// it reads the INVOKER's directory, not the scaffold's (see the note on
90+
// `validateScaffold` below), so a verdict about that file would be a verdict
91+
// about somewhere else. The three authoring commands the scaffold meets next
92+
// make that call through `resolveJsxGateManifest`, in the project itself.
93+
const sdui = resolveSduiManifest();
8794
const findings = runAuthoringRules(SCAFFOLD_RULE_COMMAND, {
8895
normalized: normalized as Record<string, unknown>,
8996
parsed: result.data as Record<string, unknown>,
90-
sduiManifest: resolveSduiManifest(),
97+
sduiManifest: sdui.status === 'resolved' ? sdui.manifest : undefined,
9198
// [#16546] Same ref set the other three doors compute — keeps this
9299
// door's hook write-set findings at the same `path` as they are.
93100
loweredHookRefs: lowering.loweredHookRefs,

0 commit comments

Comments
 (0)