Repository navigation
Commit 455dcc0
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
File tree
- .changeset
- packages/cli
- src
- commands
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
167 | 175 | | |
168 | 176 | | |
169 | 177 | | |
170 | 178 | | |
171 | 179 | | |
172 | 180 | | |
173 | 181 | | |
| 182 | + | |
174 | 183 | | |
175 | 184 | | |
176 | 185 | | |
| |||
396 | 405 | | |
397 | 406 | | |
398 | 407 | | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
399 | 420 | | |
400 | 421 | | |
401 | 422 | | |
402 | | - | |
| 423 | + | |
403 | 424 | | |
404 | 425 | | |
405 | 426 | | |
| |||
497 | 518 | | |
498 | 519 | | |
499 | 520 | | |
500 | | - | |
| 521 | + | |
501 | 522 | | |
502 | 523 | | |
503 | 524 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
| |||
748 | 748 | | |
749 | 749 | | |
750 | 750 | | |
751 | | - | |
752 | | - | |
753 | | - | |
754 | | - | |
755 | | - | |
756 | | - | |
757 | | - | |
| 751 | + | |
758 | 752 | | |
759 | 753 | | |
760 | 754 | | |
761 | 755 | | |
762 | 756 | | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
763 | 773 | | |
764 | 774 | | |
765 | 775 | | |
| |||
947 | 957 | | |
948 | 958 | | |
949 | 959 | | |
950 | | - | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
| 970 | + | |
| 971 | + | |
| 972 | + | |
| 973 | + | |
951 | 974 | | |
952 | 975 | | |
953 | 976 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
19 | 19 | | |
20 | 20 | | |
21 | 21 | | |
22 | | - | |
| 22 | + | |
23 | 23 | | |
24 | 24 | | |
25 | 25 | | |
| |||
151 | 151 | | |
152 | 152 | | |
153 | 153 | | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
154 | 162 | | |
155 | 163 | | |
156 | 164 | | |
| |||
167 | 175 | | |
168 | 176 | | |
169 | 177 | | |
| 178 | + | |
| 179 | + | |
170 | 180 | | |
171 | 181 | | |
172 | 182 | | |
| |||
346 | 356 | | |
347 | 357 | | |
348 | 358 | | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
349 | 377 | | |
350 | 378 | | |
351 | 379 | | |
352 | | - | |
| 380 | + | |
353 | 381 | | |
354 | 382 | | |
355 | 383 | | |
| |||
433 | 461 | | |
434 | 462 | | |
435 | 463 | | |
436 | | - | |
| 464 | + | |
437 | 465 | | |
438 | 466 | | |
439 | 467 | | |
| |||
723 | 751 | | |
724 | 752 | | |
725 | 753 | | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
726 | 761 | | |
727 | 762 | | |
728 | 763 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
84 | 84 | | |
85 | 85 | | |
86 | 86 | | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
87 | 94 | | |
88 | 95 | | |
89 | 96 | | |
90 | | - | |
| 97 | + | |
91 | 98 | | |
92 | 99 | | |
93 | 100 | | |
| |||
0 commit comments