Skip to content

fix(cli): the JSX page gate says when it ran at parse level only, and refuses an unusable project manifest - #20164

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20113-jsx-gate-parse-level-notice
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-20113-jsx-gate-parse-level-notice

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

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

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


Generated by Claude Code

… 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>
@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 24 documentable anchor(s).

33 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 369bcbeda7bb6c2cbc8c3c3892435351506c960c.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 51 pages)
  • 8 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 369bcbeda7bb6c2cbc8c3c3892435351506c960c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6354c6fd4225f6329ea5537328fd292ad9ba4554 — the merge of head 0fb378cfdb022b1fbdcc19ed91df3c359c2b852a into base 369bcbeda7bb6c2cbc8c3c3892435351506c960c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 369bcbeda7bb6c2cbc8c3c3892435351506c960c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 08a046af36360d77aa3a0e24e38cf75217ce11c3

① Derived judgments

  • "No top-level key is added" to any --json output — validate/build spread jsxGateNotices into the existing warningsSoFar() (head packages/cli/src/commands/validate.ts lines 183–192, success payload line 720, catch-all line 828; compile.ts lines 45–53, payload line 997 and catch-all); lint pushes jsxGate.notices.map(authoringFindingToLintIssue) into issues (lint.ts lines 970–972). Pin "the payloads grow no new top-level key" compares key sets with and without the notice. Correct.
  • "--strict does not promote it on any command" — validate's strict gate reads the local warnings: string[] built at validate.ts lines 668–700 (exit set at line 772 and line 801), which never receives jsxGateNotices; lint's failing = errors + (strict ? warnings : 0) (lint.ts lines 1013–1017) and the notice is graded suggestion; build has no --strict. Pins: noManifest under --strict exits 0 on validate and lint. Correct.
  • Existing channel, existing severity — AuthoringSeverity = 'error' | 'warning' | 'info' (packages/lint/src/authoring-rules.ts line 178); ruleAdvisories already carries AuthoringFinding records in the payload's warnings (validate.ts line 140) and validate-approval-approvers.ts / validate-security-posture.ts already emit info; lint's suggestion is the pre-existing info to suggestion mapping (lint.ts line 765). Correct.
  • Exit codes unchanged in the missing-manifest case — the absent branch of resolveJsxGateManifest (packages/cli/src/utils/sdui-manifest.ts lines 214–258) returns notices only, never throws; pins exit 0 on all three text and json faces. Correct.
  • Refusal fires only when there is a kind:'html' page to check — if (pages === 0) return at line 220 precedes the unusable branch at line 222; pin noPagesMalformed exits 0 with empty stderr. Correct as a one-direction statement. The converse the changeset asserts ("kind:'html' pages present: a notice" / "refused when the project has a kind:'html' page to check") is WRONG on one layout — see ③, union-fold gap.
  • No ADR-0112 code minted — SduiManifestRefusalError (lines 184–196) carries only reportedToStderr and hints; errorCodeFields reads exactly code and httpStatus (format.ts lines 278–286); unit pin asserts errorCodeFields(e) equals {}. Correct.
  • Kind set parity — validateJsxPages (packages/lint/src/validate-jsx-pages.ts lines 43–58) checks kind === 'html' || 'jsx' over collectionEntries(stack.pages) and refuses empty or non-string source before any manifest read; countJsxGatePages (lines 162–175) uses the same two kinds, the same array/map handling with non-record skip as collection-entries.ts, and excludes empty source. 'jsx' is a deprecated alias (page.zod.ts lines 839–843). The driven parity over eight kinds with a non-vacuity check holds. Correct.
  • os build exits 1, not 2 — compile.ts catch-all takes this.exit(1) on isReportedError before this.error(). Correct.
  • Before-table (invalid JSON / null / unreadable silent at exit 0; {} / array crash at exit 1) — base resolveSduiManifest: if (parsed) return parsed lets null fall through, catch {} swallows read and JSON errors; authoring-rules.ts line 1040 hands the manifest to the rule when truthy, so {} reaches compile()'s Object.keys(manifest.components). Correct.
  • "os init's scaffold check reads the manifest as it did" — scaffold-validate.ts lines 93–97 map resolved to the manifest, else undefined; a wrong-shape file previously reached the rule as a truthy object. No scaffold under packages/cli/src declares a kind: 'html' or 'jsx' page (grep), so the manifest is never dereferenced on that door and the reading is observationally unchanged. Correct with that caveat.
  • "then the copy inside @objectstack/console" — packages/console/package.json exports publishes only ./package.json, so the specifier cannot resolve on any install ([finding] the CLI's @objectstack/console manifest fallback can never resolve: resolveSduiManifest() asks for @objectstack/console/dist/sdui.manifest.json, but the console's exports map publishes only ./package.json #19922). The changeset says only that it is looked for, and the notice names it. Not false; the hint names a dead location until [finding] the CLI's @objectstack/console manifest fallback can never resolve: resolveSduiManifest() asks for @objectstack/console/dist/sdui.manifest.json, but the console's exports map publishes only ./package.json #19922 lands.
  • Published docs — sdui.manifest appears nowhere in content/docs/** or skills/**; deployment/cli.mdx lines 667–675 list the --strict warning set (the notice is not gated, so still true); cli.mdx lines 1551–1560 say suggestions stay advisory (true); validating-metadata.mdx line 435 "JSX / React page source parses" stays true; no os validate --json warnings shape is documented anywhere. No page becomes false and none needed to change. Correct.
  • Changeset wording nit — "Each command prints one line": printJsxGateNotices (lines 260–265) prints the info line plus a dim hint line; lint prints via printIssue. One record, two lines. Not a claim of substance.

② Semver level

  • Ruling: Clause-②: no with patch is right. By the repo's own reader (scripts/pm/clause2-line.mjs lines 69–95) no (narrowing) is for a diff that narrows a PUBLISHED accept set. The narrowed population — a present sdui.manifest.json that is invalid JSON, null, unreadable, or without a components map — was never a member of a published set: the file is named in no content/docs or skills page; ADR-0080 §3–§4 define the manifest as the serialized registry carrying components, and its status line records manifest validation as not wired; half the class ({}, array, string) already exited 1 via a bare TypeError, so no coherent accept set existed to narrow; and the exit 0 was a catch {} fall-through, exactly what AGENTS.md rule 3 orders removed ("prefer failing to falling back"). Precedent: .changeset/15293-non-array-packages-refusal.md (@objectstack/runtime: patch, Clause-②: no) refused a schema-malformed value on the same terms. Contrast: the ADR-0080 type= refusal (packages/sdui-parser/CHANGELOG.md, "This narrows what the html tier accepts") narrowed the documented page-source grammar and was ruled a narrowing by the maintainer; this PR narrows no documented grammar. The changeset carries the consumer fix line ("correct the file, or remove it"). No BREAKING banner and no ADR-0087 marker are owed.

③ Boundary flags

  • Refuse vs notice for an unusable project manifest — Answer: refuse (option A), as shipped. AGENTS.md rule 3; the author placed the file and the remedy is named; option B keeps a green over a file the author wrote (the card's defect one layer down); option C refuses a file nothing reads. Reach is bounded: 0 in-tree examples carry the file and no doc names it.
  • scaffold-validate.ts edited outside the claimed surface — Answer: acceptable; mechanically forced by the resolver's return type, behaviour preserved (resolved to manifest, else undefined), and unobservable on os init since no scaffold ships an html page. Declared in the PR body; no change required.
  • New pin file in the integration tier (~53 s, 18 spawns) — Answer: rename it jsx-gate-manifest-notice.e2e.test.ts. Every sibling pin that spawns bin/run-dev.js is .e2e (build-json-failure-conversions.e2e.test.ts, lint-conversion-notices.e2e.test.ts, validate-json-failure-conversions.e2e.test.ts), and the maintainer direction recorded in packages/cli/vitest.config.ts lines 596–611 moved that class out of the per-PR and queue runs by name; the logic is already held by the unit file. If the seat wants the exit-code pins in the queue, keep only the malformed and noPagesMalformed spawns there.
  • FOUND, union-fold gap (the required change) — resolveJsxGateManifest counts pages over authoringRuleUnionStack(...) alone, and that fold returns the stack BY IDENTITY whenever the top level carries any pages key, [] included (packages/cli/src/utils/stack-collections.ts line 362). The per-package pass then judges each packages[].manifest body as its own stack with the same sduiManifest (packages/cli/src/utils/artifact-packages.ts lines 300–304), and AssembledPackageBodySchema admits pages there (packages/spec/src/stack.zod.ts line 1017 pages: 'concat'; ArtifactPackageSchema lines 1482–1490). So a stack with a top-level pages key ([], or a kind: 'full' page) plus html pages inside packages[]: count is 0, no notice, and the per-package pass parse-checks those pages in silence — the card's defect; with an unusable manifest the early return at sdui-manifest.ts line 220 bypasses the refusal and the run exits 0. Both changeset bullets state the opposite for that layout. Same on all three commands (lintConfig runs the same per-package pass with opts.sduiManifest).
  • CI at head — 32 check runs: 20 success, 3 skipped, 9 in progress (Test Core 1–6, Temporal Conformance, Lint & Repo Gates, Type Check · workspace), 0 failure at read time. Not green yet.
  • Minor — the absent notice always names the console specifier, which cannot resolve on any install while @objectstack/console exports only ./package.json; harmless, sends a reader to a dead location until [finding] the CLI's @objectstack/console manifest fallback can never resolve: resolveSduiManifest() asks for @objectstack/console/dist/sdui.manifest.json, but the console's exports map publishes only ./package.json #19922. Noted, not required.
  • Minor — the pin's noticesIn reads warnings first and issues only when warnings is absent; a command carrying both would be half-read. Not reachable today.

Implemented-by: claude/issue-20113-jsx-gate-parse-level-notice
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: FAIL

  • Count the JSX-gate pages over every stack the gate judges — the union fold PLUS each packages[].manifest body the per-package pass hands validateJsxPages (de-duplicated when the fold already folded pages) — so the notice and the unusable refusal fire whenever any html/jsx page will be parse-checked; carry the corrected count in the notice message; add a pin with a top-level pages key ([] and a kind: 'full' page) beside package-carried html pages that asserts the notice on all three commands and exit 1 with a malformed manifest; then re-read the two changeset bullets against it.

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>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 27, 2026
…ml pages; one notice, two printed lines

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0fb378cfdb022b1fbdcc19ed91df3c359c2b852a

Delta of: 5852657026 (FAIL on 08a046af)

① Derived judgments

  • Required change, enumeration — jsxGateStacks (packages/cli/src/utils/sdui-manifest.ts lines 169–174) returns [authoringRuleUnionStack(stack), ...artifactPackages(stack).map(pkg => packageBodyAsStack(pkg.body, stack.packages))], which is byte-for-byte the pass's own walk in runPerPackageAuthoringRules (artifact-packages.ts lines 291–300: artifactPackages(run.parsed) then packageBodyAsStack(pkg.body, run.parsed.packages)); no second walk of packages[]. Correct.
  • Same bodies on all three doors — validate hands result.data to both the resolver (validate.ts line 374) and the pass (line 462); compile the same (compile.ts lines 417, 519); the union run's parsed tier, which is the tier validateJsxPages reads (packages/lint/src/authoring-rules.ts lines 1032–1040, input: 'parsed'), is authoringRuleUnionStack(result.data) (validate line 379, compile line 422), the fold leg of jsxGateStacks. Lint hands the resolver normalized (lint.ts line 971) while lintConfig hands its pass parsed: lowered (line 738) = lowerCallables(authoringRuleUnionStack(normalized)); lowerCallables maps packages[] one-to-one (lower-callables.ts lines 315–318) and lowerBody rewrites only hooks, objects[*].actions, actions and functions, never pages, so the body set and every page's name/kind/source are identical. Correct.
  • Notice and refusal on the named layout — each command resolves before its rule runs; on a top-level pages key beside package-carried html pages the fold keeps the key by identity (stack-collections.ts line 362), the per-package leg still yields the body, count is at least 1, so absent returns the notice and unusable throws (sdui-manifest.ts lines 271–285). Unit pins both layouts (sdui-manifest.test.ts lines 52–58, 165–184, 246–262); e2e pins each of the three commands (jsx-gate-manifest-notice.e2e.test.ts lines 356–377). Corrected count carried in the message (line 297) and the refusal hint (line 277). Correct.
  • No double counting — checkedPageKeys keys by non-empty page.name, else the map key, else the record (lines 201–202); countJsxGatePages unions keys across every stack in one Set (lines 216–222). Option B: the fold spreads the same page objects with the same names (collectFrom), one key each; additive: the top-level union carries the bodies' names, one key each; both pinned (unit lines 195–207, expecting 3 and 2). Map-shaped pages: the fold's record branch (stack-collections.ts lines 372–378) preserves map keys and collectionEntries injects the map key as name, so the count keys and the gate's page "NAME" agree. Unnamed array pages are unreachable through a parsed stack (PageSchema.name is SnakeCaseIdentifierSchema, required, page.zod.ts line 764; stack.zod.ts line 379 is z.array(PageSchema)); on lint's unparsed input they fall to record identity, which the fold preserves. Correct.
  • Two packages shipping the same page name — counted once while the gate judges both (docblock lines 212–214 says so). Only the integer in the notice is affected; any html page yields at least one key, so the notice and the refusal still fire. Under-count on a name collision, not a silent degradation; noted, not required.
  • New unit pins fail without the round-1 fix — with the per-package leg removed the fold alone reds exactly: the two PACKAGE_CARRIED count pins (0 not 1), the jsxGateStacks equality pin, the option-B length-3 pin, the two absent-notice pins and the two unusable-refusal pins = 8, while additive and already-folded stay green; 35 tests in the file (8+11+6+9+1). E2e: 2 layouts × 3 commands × 2 = 12 of 32 (PLAN = 18 + 12 = 30 spawns). Both match the PR body's "8 failed / 27 passed" and "12 failed / 20 passed". Consistent with the diff.
  • Delta scope — git diff 08a046af 0fb378cf on the PR's own paths touches only the changeset, the three commands (argument plus comments), sdui-manifest.ts, its unit file and the renamed e2e file; artifact-packages.ts, stack-collections.ts, scaffold-validate.ts and the parity ledger are unchanged from the earlier head. Correct.

② Semver level

Unchanged by the delta: Clause-②: no, patch stands. The round changed which stacks are counted, not what is narrowed: the refused population is still a present project sdui.manifest.json that is unreadable, invalid JSON, or without a components map, and the round-0 changeset already claimed that refusal for "a page to check"; on the package-carried layout the before-state was the same class (invalid JSON silently parse-level at exit 0; {} a bare TypeError at exit 1 in the per-package pass). No new published key, severity, or flag.

③ Boundary flags

  • Anything new wrong in the round — No. The command delta is the resolver argument (result.data / normalized in place of the fold) plus comments; the warningsSoFar() spread, --strict reads, catch-alls and payload builders are untouched. The notice record shape is unchanged (unit line 239); the e2e key-set pin (lines 285–291) and the lint passed/failing/suggestions/total pin (line 295) stand. Exit codes move only on the layout the count now covers. The earlier noticesIn nit is fixed: it reads warnings and issues together (e2e lines 94–102).
  • Changeset prose against head — line 11 (a page to check is top-level or package-carried; the per-package pass judges those even with a top-level pages key): true per runPerPackageAuthoringRules. Line 13: "one notice" (notices has length 1, lines 290–303), "distinct pages" (Set size), "info line plus a hint line" (printJsxGateNotices lines 312–317), "os lint lists it under Suggestions" (lint.ts lines 1102–1104), info to suggestion by the one mapping (lines 763–770), --strict not promoting: all true. Line 14 refusal scope "top-level or package-carried": true. Line 15 "Unchanged": count 0 returns silence before the manifest is consulted (line 272); scaffold-validate.ts unchanged. All correct.
  • Tier move — .e2e.test.ts is the nightly selector by name (packages/cli/vitest.config.ts lines 596–610); rename detected as one file (+99/−4). Every resolver rule the e2e reads is unit-pinned, the new layout included. Pinned only nightly now: (a) os lint's wiring itself — that lint.ts calls resolveJsxGateManifest and pushes the notice into issues as a suggestion; the parity ledger's call-site scan covers only compile.ts/validate.ts (validate-build-gate-parity.test.ts line 306 PARITY_COMMANDS, line 751), and packages/lint/src/authoring-rule-wiring.test.ts does not scan for the resolver; (b) exit statuses at the command level: 0 with the notice on every face and under --strict, 1 through each catch-all on refusal (os build 1 not 2), the --json envelope error naming the file; (c) no new top-level key, and lint's total/suggestions/failing counts; (d) the notice appended last to warningsSoFar(); (e) the CLI-level lit control (a project manifest in cwd arms full validation) and the "reported once, not on stdout" text-face pin. These faces were spawn-only before the move too; the seat ruled the move (5852660731) with that trade-off named by the earlier review. Acceptable; the residue is named here.
  • CI on 0fb378cf — 41 check runs: 36 success, 5 skipped (Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in progress. Governed Surface Queue Guard success; the 9-file list touches no governed surface.
  • Minor, carried from round 0 — the absent notice still names the console specifier that cannot resolve today; noted by the dev, not required.

Implemented-by: claude/issue-20113-jsx-gate-parse-level-notice
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 05:35
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 455dcc0 Sep 27, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20113-jsx-gate-parse-level-notice branch September 27, 2026 05:56
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants