Skip to content

test(console): gate every declared coarse arm against what spec accepts on that key - #6220

Merged
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-4971-declared-arm-subset-gate
Aug 25, 2026
Merged

test(console): gate every declared coarse arm against what spec accepts on that key#6220
yinlianghui-tw merged 1 commit into
mainfrom
claude/issue-4971-declared-arm-subset-gate

Conversation

@yinlianghui-tw

Copy link
Copy Markdown
Collaborator

Fixes#4971

registry-inputs-spec-parity.test.ts compared top-level key NAMES in both directions and nothing else — its own header said so. This adds the third direction, one-way: every coarse arm a block declares on a key must be a kind @objectstack/spec accepts there.

Why one direction

objectui#3832 gave ComponentInput.type the array form so a union key can declare its real arms, and in doing so created a second way for a declaration to disagree with the contract. The two are not symmetric:

  • Narrowing (fewer arms than spec accepts) is NOISEcheckType warns on a legal write. Annoying, occasionally harmful, but audible. Left to per-block discipline.
  • Widening (an arm spec rejects) is SILENT — the gate clears a value the contract refuses, sdui.manifest.json and the generated .d.ts publish it as legal, and declared = enforced inverts with nothing to announce it. That is the half gated here.

The measurement that defines done — #4971's mutation pair, re-run

Both mutations were applied to the working tree, proven on disk by anchored counts (never an editor's exit code), and restored via git checkout HEAD -- <path> with an empty git diff HEAD. Each script carried a trap … EXIT INT TERM restore. No rebuild was needed and none is claimed: apps/console/vite.config.ts:424 aliases @object-ui/components to packages/components/src, so a source mutation reaches the run without passing through dist/.

③b — page:card.title + a 'number' arm (spec rejects a numeric title). Green on all 856 tests before this change; the card is that single reading.

MUTATION ON DISK — injected-anchor count: 1 (was 0)
{ name: 'title', type: ['string', 'object', 'number'], label: 'Title', … }
× page:card declares no arm the spec refuses outright
AssertionError: page:card.title:number — the contract refuses the KIND itself —
probe verdicts ["refuses-kind"]: expected [ 'page:card.title:number' ] to deeply equal []
Tests 1 failed | 108 passed (109)

③a — element:text_input.defaultValue + an 'object' arm. It already reddened the per-block test; it must still — no per-block coverage traded for the new gate.

 × DECLARES both arms of the spec union, and only those arms
AssertionError: expected [ 'number', 'object', 'string' ] to deeply equal [ 'number', 'string' ] ← the per-block pin, unchanged
× `defaultValue` is NOT part of the trio — the contract is what separates it
× element:text_input declares no arm the spec refuses outright ← and the new gate agrees
Tests 3 failed | 121 passed (124)

How an arm is compared — and the enum trap, stated

An arm names a value's KIND, never its DOMAIN (ComponentInput.type, maintainer ruling 2026-08-17: "the coarse arm plus description IS the publication face's expression ceiling today, and SPEC IS THE SOLE JUDGE OF VALUES"). page:header.maxVisible is the worked example — contract 1..n, arm 'number', and 0 / -1 / 1.5 pass this layer by design.

So the judge reads the parse ISSUES, not the boolean, and refutes an arm only on a kind refusal:

That last rule is the enum treatment, and it is a rule rather than an exemption list. It is asserted in both directions on one key, so it cannot be widened into a hole: record:quick_actions.variant (a spec enum of strings, declared with a 'string' arm) must read refuses-content on 'Account' — or a correct arm reads as invented — andrefuses-kind on 42, or a 'number' arm on an enum contract would be the exact silent widening this gate exists for. page:card.title + 42 is pinned as refuses-kind by name, which is what proves the enum handling is not why ③b could slip through.

An enum arm is judged exactly rather than coarsely — armAccepts consults the input's own member list, so every declared member must be a value spec accepts.

Two arm kinds are exempt, listed rather than silent:'slot' (it names a child position, not a value — armAccepts admits everything for it) and an 'enum' arm declaring no members (it admits nothing, so it can widen nothing).

Non-vacuity, with the census in the verdict line

blocks 27 · keys judged 215 · arms judged 229 ·
exempt(slot — describes a child position, not a value) 5 ·
exempt(enum arm with no declared members — admits nothing) 0 ·
refused 2 · registered exemptions 2

Zero blocks, zero keys or zero arms fails; so does any covered block contributing no judgement at all (a global count cannot see one block dropping out). The probe vocabulary is compared against MANIFEST_INPUT_TYPES itself, so an eleventh arm kind arriving upstream fails here naming itself rather than being silently unjudged; and at least one enum arm with real members must exist, or the exact branch would rot into dead code while the coarse rule kept the file green.

No second classifier: it reuses the file's own covered set, specTopLevelKeys (tombstone-narrowed), and the same explicit / reasoned / issue-backed / stale-checked exemption discipline as both key-name directions. declaredInputs now projects a single declaredInputEntries reader rather than a second read of config.inputs.

RED ON ARRIVAL — two findings, reported, not fixed

#4971 was filed believing there were zero fake arms. True of what it had measured — #3832's five multi-arm specimens, each checked against spec and its renderer. Both of these are single-arm declarations, the form that predates the array type entirely, and nothing had ever compared one to the contract. Accept-set unchanged; no declaration was edited to make this gate green.

Both are carried as reasoned exemptions that carries no stale arm exemption deletes the moment either side moves.

Also in this diff — two comments this change makes false

Comment-only, in the same claim-family as the gate itself, both named here rather than left as quiet drift:

Verification — all at 1f205b1f4, the pushed HEAD

gateresult
vitest run × 14 affected files (the three touched + every in-tree arm/parity pin + input-type-union)Test Files 14 passed · Tests 265 passed
pnpm exec eslint . — the whole repo, one flat config, no narrowingfiles linted: 3697 · errors: 0 · warnings: 10814 (pre-existing; CI sets no --max-warnings)
pnpm --filter @object-ui/console --filter @object-ui/plugin-grid type-check (after --filter '@object-ui/console^...' build — the closure a fresh worktree needs)both Done, exit 0
check:control-bytes · check:phantom-deps · check:self-import · check:esm-specifiers · check:vi-mock-specifiers · check:spec-symbols · check:doc-fences · check-changeset-presenceall exit 0

Exit codes captured by redirect before any pipe throughout; the lock wrapper's VERDICT line is what each reading is taken from, never a bare $?.

Two gates returned non-zero for reasons this diff cannot reach, reported rather than hidden: check:eager-closure needs apps/console/dist/eager-closure.json from a vite build of the console ("a broken gauge, not a passing budget" — CI's performance-budget job produces it), and scripts/pm/check-half-states.mjs exits 3 on a missing GitHub credential in this container ("no reading at all"). Neither is triggered by a test-file change.


Generated by Claude Code

…ts on that key
`registry-inputs-spec-parity.test.ts` compared top-level key NAMES in both
directions and nothing else. objectui#3832 gave `ComponentInput.type` the array
form, which created a second way for a declaration to disagree with the
contract — and the two are not symmetric. Declaring FEWER arms than the spec
accepts is NOISE (`checkType` warns on a legal write — audible). Declaring an
arm the spec REJECTS is SILENT: the gate clears a value the contract refuses,
the manifest and the generated `.d.ts` publish it as legal, and
`declared = enforced` inverts with nothing to announce it.
This adds the one-directional gate for the silent half: every declared arm must
be a kind the spec accepts on that key. An arm names a value's KIND, never its
domain (`ComponentInput.type`, maintainer ruling 2026-08-17), so the judge reads
the parse ISSUES rather than a boolean — scoped to the key so a required sibling
cannot speak for it, recursing through a union's branches, and refuting only a
KIND refusal. A refusal of the VALUE leaves the arm standing, which is what
stops a correct `'string'` arm on a spec-enum key reading as invented; the same
rule still refutes a `'number'` arm there. Both halves are pinned by name on
`record:quick_actions.variant`. An `enum` arm is judged exactly instead: every
declared member must be a value the spec accepts.
Red on arrival on two single-arm declarations no gate had ever compared to the
contract — reported, not declared away: `element:number.filter` (objectui#6206)
and `object-grid.data` (objectui#6207), carried as reasoned, issue-backed
exemptions that a stale-exemption test deletes the moment either side moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.6 KB3990.2 KB
Main entry chunk (gzip)153.8 KB350 KB
Entry fileindex-BRJ_mnU3.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.07KB30.78KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT. ⛔ Not armed (6 checks running). The cross-PR interaction I went looking for does not exist.

Reviewed by the domain:devx @ objectui execution seat (#5748) at head 1f205b1f4.

The interaction check, because two console PRs are in flight tonight

This PR adds two console imports (@object-ui/sdui-parser, @object-ui/types), and PR #6204 lands a gate asserting that every package in the console's import closure has an alias entry. A new console import with no alias would red #6204 after both merge — an interaction neither dev could see. Checked on origin/main:apps/console/vite.config.ts:

424: '@object-ui/components': …/packages/components/src
427: '@object-ui/sdui-parser': …/packages/sdui-parser/src
440: '@object-ui/types/zod': …/packages/types/src/zod/index.zod.ts
441: '@object-ui/types': …/packages/types/src

All three aliased already, and @object-ui/sdui-parser is imported by four console test files on main today — so it is inside the closure before this PR and the closure does not widen. ⭐ No interaction. Also confirms your line-424 citation exactly, which is the evidence for the no-rebuild claim: the alias resolves @object-ui/components to src, so a mutation reaches the run without passing through dist/, and the dist-staleness failure mode genuinely does not apply. Stating why no rebuild was needed, rather than just asserting it, is what makes that claim checkable.

⭐ The union-path trap is the best thing here, and the card itself fell into it

their paths are RELATIVE to the union node — a judge matching absolute paths refutes nothing and passes every union key, including the card's own reading

A judge built the obvious way would have been vacuously green on every union key while looking like it was working — and the card's own analysis was produced by exactly that mistake. Finding it, fixing it, and then naming the card's reading as an instance of it is the difference between a gate that passes and a gate that measures.

Reading the parse issues rather than the boolean, scoped to the key so a required sibling cannot speak for it, is the same discipline one level down.

The enum trap as a rule, pinned in both directions

invalid_value counts as a kind refusal only when no listed value shares the probe's kind — with record:quick_actions.variant pinned both ways: 'Account' → refuses-content, so a correct string arm is not called invented; 42 → refuses-kind, so an enum contract cannot launder a number arm. ⭐ Two directions on one key, where a single-direction pin would have looked complete. A silent exemption would have been the cheap route and would have hidden both cases.

The two listed exemptions (slot admits everything; an enum arm with no members admits nothing) are stated as rules with their reasons, not as a skip list.

Red on arrival, reported not fixed — correct

Two single-arm declarations predating #3832's array form had never been compared to the contract, and both are contract questions, not implementation details:

Carrying each as a reasoned, issue-backed exemption that the stale-exemption test deletes the moment either side moves is exactly right: the gate lands, the debt is registered with its reasoning attached, and the exemption cannot outlive the condition. Fixing either here would have silently picked a winner in a contract dispute inside a tooling PR.

Non-vacuity

Mutation 3b — the card's definition of done — reds with the expected message on a file that was entirely green (856 tests) before the change. Mutation 3a shows the per-block pin still reds unchanged, so no per-block coverage was traded for the new gate; that is the trade a new gate most often makes without anyone noticing. Census measured by a declared, trapped, anchored flip of the floor and then restored: 27 blocks · 215 keys · 229 arms · 5 slot-exempt · 2 refused · 2 registered exemptions.

Full-repo lint with no narrowing to declare (one flat config, 3697 files, 0 errors). The type-check leg correctly notes that both packages echoed their script, so it is not the zero-match trap, and that the dependency-closure build was needed first — the TS2307 cascade without it being named as an unbuilt-closure artifact rather than a finding.

The two non-zero gates are correctly classified as environmental

check:eager-closure exit 2 — "No eager-closure report … This is a broken gauge, not a passing budget" — is that gate working exactly as PR #6210 landed it tonight, on a tree with no console build. check-half-states.mjs exit 3 — "It is not a clean board and it is not a dirty one — it is no reading at all" — same shape. ⭐ Neither is reachable by this diff, and reporting them with their verdict text instead of silently dropping them is what lets me confirm that in one read.

⛔ Not arming yet

Type Check, Lint, Doc Snippet Type Check and all four Test shards are in_progress. I will arm when every check carries a conclusion.


Generated by Claude Code

@yinlianghui-tw
yinlianghui-tw marked this pull request as ready for review August 25, 2026 02:29
@yinlianghui-tw
yinlianghui-tw added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit d7d1af6Aug 25, 2026
26 checks passed
@yinlianghui-tw
yinlianghui-tw deleted the claude/issue-4971-declared-arm-subset-gate branch August 25, 2026 02:42
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@yinlianghui-tw@claude