Repository navigation
fix(cli): the JSX page gate says when it ran at parse level only, and refuses an unusable project manifest - #20164
Conversation
… refuses an unusable project manifest resolveSduiManifest() now answers why it has no manifest (resolved / absent / unusable); resolveJsxGateManifest() turns that into one decision the three authoring commands share: a parse-level notice (exit status unchanged) when kind:'html' pages have no manifest, a refusal when a project manifest exists but cannot be used. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…ifest refusal on all three commands Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…el notice and manifest refusal Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…test Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6354c6fd4225f6329ea5537328fd292ad9ba4554 && git checkout 6354c6fd4225f6329ea5537328fd292ad9ba4554
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 369bcbeda7bb6c2cbc8c3c3892435351506c960c 0fb378cfdb022b1fbdcc19ed91df3c359c2b852a && git checkout -B drift-repro 369bcbeda7bb6c2cbc8c3c3892435351506c960c && git merge --no-ff 0fb378cfdb022b1fbdcc19ed91df3c359c2b852a
node scripts/docs-audit/affected-docs.mjs --json 369bcbeda7bb6c2cbc8c3c3892435351506c960c
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: FAIL
Generated by Claude Code |
…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>
…ml pages; one notice, two printed lines Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta of: ① Derived judgments
② Semver levelUnchanged by the delta: ③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the prior review, and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS Generated by Claude Code |
…t with ObjectSchema.create (objectstack-ai#20195) Fixes objectstack-ai#19722 Clause-②: no Ruling `5644350230` (director seat, decision batch objectstack-ai#122 item 1, maintainer 「同意」 2026-09-12), item 1: 「`packages/cli/src/commands/init.ts` `TEMPLATES` emit the factory shape; `content/docs/deployment/cli.mdx:1323` describes it.」 Item 3: 「the changeset states how a user converts theirs (one mechanical rewrite: wrap the literal).」 This is the `domain:cli` half; the spec/scripts half landed as PR objectstack-ai#19720 (`42339e2f`). objectstack-ai#17418 remains open (it carries `Blocked-by` on this card and is the spec lane's to move). objectstack-ai#19098 remains open (the other `generate.ts` card, serial behind this one). ## What changed Both doors that write a `*.object.ts` now emit the one authorised shape, `ObjectSchema.create({ … })`: | door | before | after | |:--|:--|:--| | `os init -t app` / `-t plugin` (`TEMPLATES[…].srcFiles`) | `import * as Data …` + `const myAppItem: Data.ServiceObject = { … };` | `import { ObjectSchema } …` + `const myAppItem = ObjectSchema.create({ … });` | | `os generate object` (`GENERATORS.object`) | `const orderLine: Data.ServiceObject = { … };` | `const orderLine = ObjectSchema.create({ … });` | - The emitted shape is exactly what the changeset's user rewrite produces from the old one (wrap the literal, drop the annotation, import the factory), so a scaffold and a converted file look the same. - `ObjectSchema` is a **value** import: `import type` is erased at compile time and the module would throw on first evaluation. - The binding stays the file's **default export**. Both barrels (`os init`'s `src/objects/index.ts` and the line `os generate` appends for all seven generators) re-export `default`, so no barrel spelling moves and no user barrel needs touching. - The authored OWD comment block is unchanged byte for byte in all three emitters (only the closing `};` became `});`); `init-template-comments-self-contained.test.ts` is green. - `generate.ts`'s docblock states that the init/generate parity now covers the declaration shape as well as the `sharingModel` value, and names the pin that holds it. ## Premise check (on `origin/main`, sites located by symbol) - `TEMPLATES` (both object-bearing entries) and `GENERATORS.object.generate` emitted the annotated literal: confirmed. - `create-objectstack`'s bundled `blank/src/objects/note.object.ts` is already `export const Note = ObjectSchema.create({ … })`: confirmed. - `scripts/sync-scaffold-emission-policy.mjs` syncs the pnpm/TypeScript ranges only and reads no declaration shape; `pnpm check:scaffold-emission-policy` was run (read-only `--check`) and is green. - The ruling's `cli.mdx:1323` anchor has drifted with later edits. The page's only description of the scaffolded object shape was the `os generate` "What it does" line (it named `Data.ServiceObject`); that line now describes the factory (and names `defineSkill({ … })` for `skill`, the one non-object type that is not a typed literal), and the `os init` section gains a short paragraph naming the shape and the one mechanical rewrite for older projects. ## Measured: does the objectstack-ai#19720 gate reach a scaffold? Before and after Built `@objectstack/cli` at the base and at this branch, ran `os init my-app -t app` and `os init my-plugin -t plugin` (`--no-install`, under `packages/cli/node_modules` so `@objectstack/spec` is found by the upward walk), then `os g object my_app_order_line` in each, then the project's own gates. The repo gate was driven through its exported `sweep()` over a tree holding the four scaffolded object files (plus the driver file it reads its text family from). | reading | before (base `3bd28e2b`) | after (this branch) | |:--|:--|:--| | `os validate` / `os compile` / `tsc --noEmit`, init only | exit 0 / 0 / 0 (both templates) | exit 0 / 0 / 0 | | same, after `os g object` | exit 0 / 0 / 0 | exit 0 / 0 / 0 | | `check-keyed-text-bounds` `sweep()` over the 4 scaffolded files | 0 objects parsed, **4 shape violations** (`… is declared as a plain object literal — use ObjectSchema.create`) | **4 objects parsed, 0 shape violations**, 0 refusals | | compiled `dist/objectstack.json` | sha256 `3981f1ab…` (app), `e6d2c61d…` (plugin) | **byte-identical** (`cmp` equal) | So the platform's own shape gate refused every scaffold before this change, but only as a repo script: a user project carries no `scripts/`, and `os validate` / `os compile` never judged the shape. After it, the gate parses all four. The compiled artifact is byte-identical, which is the measured basis for `Clause-②: no` (no published payload changes). ## Pins - **New**: `packages/cli/test/scaffold-object-declaration-shape.test.ts` reads every emitter's bytes with the TypeScript parser (roster derived from `TEMPLATES` and `GENERATOR_SCAFFOLD_TARGETS`) and asserts: value import of `ObjectSchema` from `@objectstack/spec/data`; exactly one top-level declaration, initialised by `ObjectSchema.create({…})`, with no annotation; the default export is that binding; and **one signature across `os init` and `os generate object`**, which is the parity the docblock claims. Two controls prove the reader can refuse each half (the pre-ruling annotated literal; a type-only factory import). - **Repointed** (they asserted the refused spelling, per the `domain:services` pointer `5788276757`): `generate-emission-parses.test.ts` (`:148` and the `class` discriminator, which asserted `const class:`), `generate-refuses-unparseable-name.test.ts:255`, and the worked examples in `emitted-source-parses.ts`, `generate-emission-parses.test.ts` and the `generate.ts` refusal comment. Docblock-only: `scaffold-emission-typechecks.test.ts` (why the pin still stands after the annotation is gone) and `generate-refuses-name-outside-charset.test.ts` (`const class:` → `const class =`). - Unchanged and still covering it: `scaffold-emission-typechecks.test.ts` (tsc over every emitted scaffold), `generate-scaffold-validates.test.ts` and `init-scaffold-authoring-rules.test.ts` (runtime loads, which now execute the factory), `init.test.ts` (its assertions are name and barrel, not shape). ### Ablation (the new pin can fail) Committed first, then `node scripts/ablation-replace.mjs` swapped the `os generate object` emitter's `import { ObjectSchema }` for `import type { ObjectSchema }` and ran the pin: **2 failed / 5 passed**. The failures were `'os generate object order_line'` (`is not value-imported … (type-only)`) and `one signature across every door` (the generate door's signature diverged). Restore proven by the tool: blob `03b8006959dc` == HEAD and `git diff HEAD` empty. The direction observed was red, as expected. ## Verification (head `3082b024`, after merging `origin/main` `836aad2a`; round 1 at `468000c4` below) `main` moved under this branch with PR objectstack-ai#20164 (same package), so the suite was re-run after the merge: - `@objectstack/cli` unit tier, `vitest run --project unit --maxWorkers=2 --shard=N/4` × 4: **226 files / 3199 tests passed** (949 + 780 + 723 + 747). - `@objectstack/cli` integration tier, run locally because the diff touches two integration-tier files: `generate-refuses-unparseable-name` + `generate-refuses-name-outside-charset`, **2 files / 25 tests passed**. The rest of the integration tier is declared to CI. - `pnpm --filter @objectstack/cli typecheck` (tsc + `check:test-typecheck`): exit 0; the new test file is in the test program (`tsc -p tsconfig.test.json --listFilesOnly` counts it). - `pnpm lint` (full, `eslint . --no-inline-config`): exit 0. - Gates derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (94): all 94 exit 0; `--ran` verdict: `94 derived famil(ies) accounted for — 94 run, 0 NOT-MEASURED`. - Before the merge (head `20526f3d`): unit tier 225 files / 3163 tests passed, the same two integration files 25/25, typecheck exit 0. - Round 1 (head `468000c4`: `origin/main` `d7c02413` merged as `e5499d52`, then the one-sentence `cli.mdx` correction naming `defineSkill` for `skill`): the 41 docs-scoped gates (`dispatch-gates --commands content/docs/deployment/cli.mdx`) all exit 0, `--ran` 41 of 41 accounted for, 0 NOT-MEASURED; `pnpm lint` exit 0; `node scripts/check-issue-citations.mjs` answered `no issue citations added against d7c0241 (3 file(s) read)`. The cli test tiers were not re-run locally on this head; CI runs them. ## Acceptance notes - `scripts/check-keyed-text-bounds.mjs`'s refusal text says 「the `os init` shape imports only `* as Data`」. After this change that describes the shape older `os init` releases emitted, not the current one; it is still the right advice for a converted file. `scripts/**` is read-only for this lane. Carrier: the spec lane when it next touches that gate (for example when objectstack-ai#17418 is unblocked). Noted, not filed. - Reported to the seat, not addressed here: in an `os init` project, `os g object order_line` writes `name: 'order_line'`, and the project's own `os validate` then refuses it (`Object 'order_line' is missing the package namespace prefix`). Measured at the base; this PR does not change it. - Local tooling observation: `pnpm check:type-check-debt` (`--re-measure`) runs a whole-workspace `turbo run build` before tsc. A local timeout that kills it mid-build leaves some packages' `dist/` without declarations, and `check:dual-build-cjs-loads` then flags them. Rebuilding the two packages cleared it; CI builds fresh. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20113
Clause-②: no
What this changes
os validate,os buildandos lintcheckkind: 'html'pages (and the deprecatedkind: '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 returnsresolved,absent(with every place looked, in order) orunusable(a project file that exists but cannot be read, is not valid JSON, or has nocomponentsmap). 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 theauthoringRuleUnionStack()fold, then eachpackages[]body read through the per-package pass's own enumeration (artifactPackages+packageBodyAsStack). Pages are counted distinct by name:resolved: full validation, nothing said.absentwith a page to check: parse level, plus ONE notice taggedsdui/jsx-parse-level-only. The notice gives the distinct page count, top-level and package-carried alike, and namesCWD/sdui.manifest.json, then@objectstack/console/dist/sdui.manifest.json. The exit status does not change.unusablewith a page to check: refused, exit 1 (the decision is below).validateJsxPages's own.sdui-manifest.test.tsholds 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
--jsonmode. 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.--json--strictos validateinfoAuthoringFinding-shaped record, appended last towarningsSoFar()warningsbut not the text face'swarningslist that--strictreadsos buildwarningsSoFar(), so validate/build parity holdsos lintsuggestioninissues, produced by the oneinfotosuggestionmapping, now a named function thatlintConfigalso uses.totalandsuggestionsmove by onepassedandfailingare unchangedThe notice is pushed in the
lintcommand, not insidelintConfig, becausescoreMetadatareacheslintConfigwithout 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-showcaseat8d1f7ab7, with a projectsdui.manifest.json:{ "components": [ oops(invalid JSON)null{}/[]/"x"Cannot convert undefined or null to object(bare TypeError fromObject.keys(manifest.components))Decision under the four axes:
content/docsorskills/names the file. Only an author who deliberately placed one is affected, and that author asked for full checking.The refusal fires only when there is a
kind: 'html'page to check. It is reported once on stderr and carried in the--jsonenvelope'serror. Every command exits 1 through its existing catch-all via the structuralreportedToStderrmarker.os buildexits 1, not the oclif 2. No ADR-0112codeis minted. This is a behaviour change on a verify path (exit 0 to exit 1 for invalid JSON,nullor an unreadable file). The seat ruled to keep it (option A), under AGENTS.md rule 3.Clause-②: nois 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-arraypackagesrefusal changeset (Clause-②: no, patch).Not changed
@objectstack/consolemanifest fallback can never resolve:resolveSduiManifest()asks for@objectstack/console/dist/sdui.manifest.json, but the console'sexportsmap publishes only./package.json#19922 remains open and [Decision] may a kind:'html' page author intrinsic HTML tags (div, a)? The console manifest says no, 3 of 3 shipped html pages say yes, and #19922's fallback cannot go live until one side changes #20112 remains open. The resolver keeps the same specifier, and a failure on that leg still reads as "not found". Theabsentnotice 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 eachpackages[]body to the JSX gate with the same manifest. So a top-levelpageskey ([], or only non-html pages) beside html pages carried only inpackages[]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:.e2e). Every rule it pins is also pinned in the unit file, which is the per-PR guard.origin/mainwas merged in at84880f92.#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 oforigin/mainat84880f92) unless a row says otherwise.8d1f7ab7,examples/app-showcase, built CLI):os validateexit 0, 106 lines,manifest/parse-level/jsx/sduieach 0 hits.os buildexit 0, 180 lines, 0 on all four.os lintexit 0, 1035 lines, 0 onmanifest/parse-level/sdui; its 2jsxhits are the page nameshowcase_command_center_jsx.f7c21c0d, built CLI, before the round-1 fix): the fixture was a top-levelpages: []beside one html page inpackages[]. It gave 0 notices on the--jsonfaces 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 withjsx-forbidden-tagatpackage 'com.example.jxg.site' — page "jxg_landing", so the per-package pass does judge that page.sdui/jsx-parse-level-onlyline (validate 108 lines, build 182, lint 1037 with 28 suggestions, previously 27).--jsonon 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:validateJsxPages;pages: [], and a top-levelkind: '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; spawnsbin/run-dev.js, the source entry; 30 spawns, 3 at a time; 32 tests, 89.8s withOS_TEST_TIERS=nightlyat139a3d3a):--jsonfaces of all three commands, and exit is 0 under--stricton a fixture whose only advisory is the notice.jsx-forbidden-tagfires with it). No html pages gives no notice.pages: [], and a top-levelkind: 'full'page, each beside an html page insidepackages[]. They pin the notice counting that page on all three commands, and exit 1 with a malformed manifest on all three.0fb378cf.test/vitest-tiers-partition.test.ts22/22 in the queue setting. UnderOS_TEST_TIERS=nightlythe 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 includestest/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.8d1f7ab7withgit restore --source, working tree only. On-disk proof:resolveJsxGateManifest0 hits in each file, and the base spellingresolveSduiManifest()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 usedgit checkout HEAD --under atrap, and the five blobs were proven hash-equal toHEAD, withgit diff HEADempty.139a3d3a):scripts/ablation-replace.mjsremoved the per-package leg fromjsxGateStacks(), making it the fold alone. On-disk proof: anchor 1 to 0, blob41c4549ftof8d39ab7. Results:OS_TEST_TIERS=nightly: 12 failed / 20 passed; the 12 are exactly the new layout pins.HEAD(41c4549f),git diff HEADempty.pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 225 files / 3193 tests passed. This includes the tier-partition pin andvalidate-build-gate-parity.test.ts, whose closed ledger now hasresolveJsxGateManifestinSHARED_NON_REGISTRY_GATES(it judges an input and can refuse) andprintJsxGateNoticesunder presentation. The retiredresolveSduiManifestrow 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.build-json-failure-conversions,lint-conversion-notices,validate-json-failure-conversions, run withOS_TEST_TIERS=nightly): 3 files / 35 tests passed at08a046af. They were not re-run in round 1: their fixtures carry nopackages[], so the round-1 count gives them the same answer, and the merge brought no change underpackages/cli,packages/lintorpackages/sdui-parser.pnpm --filter @objectstack/cli run typecheck: exit 0. Both new test files are in a compiled program:tsc --listFilesshows thesrc/test undertsconfig.jsonand thetest/pin undertsconfig.test.json.pnpm lint(the whole tree,eslint . --no-inline-config): exit 0 in 103s at0fb378cf. No narrowing was needed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(merge base84880f92): 62 families, the same list as round 0, all run at0fb378cfwith every exit code recorded.--ranreconciliation: 62 derived, 62 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. In the fresh worktree,check:dual-build-cjs-loadsfirst answered exit 3 (PREREQUISITE NOT MET: the same eight packages outside this diff's closure had nodist/). 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.integrationtier, the Dogfood / Build Core / Temporal / Test Core CI jobs, and the workspace type-check lanes.Acceptance notes
os validate path/to/objectstack.config.tsfrom elsewhere never reads that project's manifest #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 [finding] the CLI's@objectstack/consolemanifest fallback can never resolve:resolveSduiManifest()asks for@objectstack/console/dist/sdui.manifest.json, but the console'sexportsmap publishes only./package.json#19922 and is left for the change that makes that leg reachable.content/docs: no hand-written line becomes false. The validating-metadata matrix row reads "JSX / React page source parses", which stays true. The--strictwarning list indeployment/cli.mdxdoes not include the notice, and the notice is not gated.Generated by Claude Code