Skip to content

test(components): re-express the two element:* spec-parity contrast probes pin-aware for GA's strict flip (#4910) - #4920

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-4910-strict-mode-parity-probes
Aug 17, 2026
Merged

test(components): re-express the two element:* spec-parity contrast probes pin-aware for GA's strict flip (#4910)#4920
os-steve merged 1 commit into
mainfrom
claude/issue-4910-strict-mode-parity-probes

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #4910

Implements the adjudicated option A — assert the rejection, expressed PIN-AWARE. Both
probes now read the installed spec's refusal mode behaviourally and assert the same
verdict under either mode, never branching off a version string. Option B (dropping the
probe) is not taken. This inherits the disposition the maintainer accepted on #4648's open
question 2, landed as PR #4671's adapted plugin-detail gates.

The premise, re-verified on this branch

Both halves of the card reproduce exactly.

rc.6 (native pin, 17.0.0-rc.6)
  text_input     success = true   keys = ["inputType","required","disabled"]
  record_picker  success = true   keys = ["object"]

GA overlay (17.0.0)
  text_input     success = false  code = unrecognized_keys  keys = ["notASpecKey"]
  record_picker  success = false  code = unrecognized_keys  keys = ["notASpecKey"]

And the two named assertions are the only things that were red, at exactly the two lines
the card names — record-picker…:101 and text-input…:83:

FAIL packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts:101:32
FAIL packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts:83:32
     expect(undeclared.success).toBe(true);   AssertionError: expected false to be true
Test Files  2 failed (2)   Tests  2 failed | 13 passed (15)

What each probe became

The contrast probe parses a payload carrying a key no spec declares, then branches on a
module-level behavioural reading:

const specRefusesUnknownTopLevelKeys = !ElementTextInputPropsSchema.safeParse({
  __objectui_4910_probe__: true,
} as never).success;
  • strict arm (GA) — asserts the refusal as an envelope: unrecognized_keys among
    the issue codes, and the refused keys naming notASpecKey. A bare "it failed" is
    deliberately not enough — it would be satisfied just as well by a rejection of the
    declared key, which is the half that has to stay valid, so the arm also asserts
    defaultValue / filter / object are not among the refused keys.
  • strip arm (rc.6) — asserts the key is absent from data on a green parse: the
    silent drop the comments currently pin, kept verbatim as behaviour.

Two changes beyond a straight port, both load-bearing:

  1. The undeclared key is now carried alongside the declared one ({ defaultValue: 'acme', notASpecKey: 1 }, { ...withFilter({status:'open'}), notASpecKey: 1 }) rather
    than alone. That is what makes either arm attributable to notASpecKey instead of to
    the declared key having gone bad — and the green parse asserted immediately above is
    what proves the base fixture is valid.
  2. The load-bearing comments in both files now state the two-mode contract instead of
    the falsified strip-mode premise, as the card requires. The header comment on each
    probe records why it is behavioural rather than version-sniffed, and cites the sibling
    recordHighlightsInputs.spec-parity.test.ts that took this shape first.

Neither arm is vacuous, and neither reads a pin it does not observe.

Ruled widening scan

The strict flip is schema-wide, so the scan was run in two halves — static across
spellings, then empirically under the GA overlay, which is the half that cannot miss a
spelling nobody thought of.

Static. Four spellings over packages apps examples scripts:

grep -rniE "strip-mode|strip mode|\.strip\(\)|stripped it|strips it|silently strip" --include=*.ts --include=*.tsx --include=*.md
grep -rniE "notASpecKey|not_a_spec_key|undeclaredKey|unknownKey|bogusKey|extraKey|notAKey|nonSpecKey|fakeKey" --include=*.ts --include=*.tsx
grep -rn "unrecognized_keys" --include=*.ts --include=*.tsx
grep -rnE --include=*.ts --include=*.tsx "safeParse\(.*\bas (never|any|unknown)"   # plus a multiline -Pz variant

The last one is the high-signal spelling: a payload carrying a key the props type does not
declare needs a type escape to be written at all. Its only hits repo-wide are the two
files this PR changes — which doubles as the positive control that the scan reaches them.
The one other identifier hit, clientValidation.optOuts.test.ts's
notAKeyAnySchemaDeclares, is already an assert-the-refusal probe (bogus.ok is asserted
false) on a metadata schema, not a strip-mode premise.

Empirical. Every test file importing @objectstack/spec220 files — run under
the GA overlay. Before this branch:

Test Files  3 failed | 217 passed (220)      Tests  3 failed | 3396 passed | 1 skipped (3400)
  packages/components/src/__tests__/record-picker-inputs-spec-parity.test.ts   <- this card
  packages/components/src/__tests__/text-input-inputs-spec-parity.test.ts      <- this card
  packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts <- NOT this card

At this branch's HEAD:

Test Files  1 failed | 219 passed (220)
  packages/fields/src/widgets/__tests__/FilterConditionField.operators.test.ts

Hit list for this card: exactly the two named files. No third file needed adapting.

The remaining failure is a different GA change — GA's FieldOperatorsSchema adds $like /
$ilike, which the builder cannot author (expected [ '$like', '$ilike' ] to deeply equal []). Not the strip flip, no undeclared-key parse involved. It is already filed as
#4911, which is not addressed here and remains open.

Verification — both pins, at 846ec5dd8 (working tree clean)

GA readings use PR #4660's npm-pack overlay, re-derived: npm pack @objectstack/spec@17.0.0 unpacked over the pnpm store directory every one of the 34
@objectstack/spec symlinks already resolves to, so all 34 arm at once and none is
repointed or restored wrongly. That placement is also the peer-link repair PR #4901's
report records: ai and zod live one level up inside the same store entry, so they are
mirrored by construction rather than by hand. rc.6 restored afterwards, and the installed
version plus the raw refusal mode were printed before every run.

reading rc.6 GA overlay
the two files 15 passed (2 files) 15 passed (2 files)
@object-ui/components full suite 145 files / 1311 tests passed
every @objectstack/spec-importing test (220 files) 219 passed / 1 failed (#4911)
type-check@object-ui/components PASS PASS
eslint --quiet, 2 changed files PASS (0 errors)
check:control-bytes PASS (4398 files)
check-changeset-presence / -fixed / -no-major PASS
check:self-import PASS

The dependency closure was built first (pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build) before either type-check.

Reverse verification — direction predicted first, one ablation per arm

Predicted before running, and the point of using two: each ablation must redden on
exactly one pin, which is what proves each arm actually judges under its own pin and is
genuinely dormant under the other. Run from the committed state.

Ablation A — break only the strict arm (refused must contain a key that is not
there), in text-input. Predicted GA red, rc.6 green.

Ablation B — break only the strip arm (the surviving filter value), in
record-picker. Predicted rc.6 red, GA green.

Observed, exactly and symmetrically:

GA:    × text-input … publishes `defaultValue`, which the renderer has read all along
       AssertionError: expected [ 'notASpecKey' ] to include '__ablation_A_no_such_key__'
       Test Files  1 failed | 1 passed (2)      <- record-picker green: strip arm dormant

rc.6:  × record-picker … publishes `filter`, which the renderer has read all along
       AssertionError: expected { status: 'open' } to deeply equal { status: '__ablation_B__' }
       Test Files  1 failed | 1 passed (2)      <- text-input green: strict arm dormant

So: the strict arm judges under GA and the strip arm judges under rc.6, each dormant on
the other pin.
Ablation A's message is also the direct positive control that the strict
arm truly receives the named key — the refused set really is [ 'notASpecKey' ].

Both restored with git checkout from the branch; git status clean, i.e.
byte-identical, and re-run green on both pins.

Changeset

An empty-frontmatter changeset — the repo's tests-only exemption, per precedent PRs
#4641 / #4666 / #4671. No runtime source, no registration and no gate was touched; only the
two test files. The gate confirms it rather than my reading of it:

✅  2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
    Every one of them has an EMPTY frontmatter — declared as releasing nothing, which
    is the explicit exemption and a complete answer to this gate.

No skip-changeset label is applied or created — that label does not exist in this repo
(#4912).

Scope

Three files: the two parity tests and the changeset. No production code, no gate weakening,
no content/docs/releases/. In-flight siblings untouched.

One finding filed unassigned rather than fixed here: #4918 — two plugin-detail
parity tests still narrate rc.6 strip mode as present-tense fact in prose only. No
assertion depends on it, so both files are green under the GA overlay and no gate can catch
it; it falls outside this card's claimed surface, which is bounded to tests that assert a
green safeParse with an undeclared key.


Generated by Claude Code

… pin-aware (#4910)

@objectstack/spec 17.0.0 GA flipped the element:* props schemas from strip mode
to strict (objectstack#4001 batch A). Both probes asserted the strip-mode half
as their control; they now probe the installed spec's refusal mode behaviourally
and assert the same verdict under either mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbRmJD3iPhXjKr6vhd4Qkv
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-DzXFL076.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.37KB 111.05KB
core (index.js) 4.06KB 1.61KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 157.05KB 43.28KB
fields (index.js) 231.73KB 57.60KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.86KB 10.83KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 127.85KB 32.73KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.81KB 59.97KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 120.43KB 29.04KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.59KB 53.01KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.23KB 26.97KB
plugin-map (index.js) 17.91KB 5.72KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.97KB 11.33KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.53KB 9.41KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@os-steve
os-steve marked this pull request as ready for review August 17, 2026 05:38
@os-steve
os-steve added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 69dbea2 Aug 17, 2026
21 checks passed
@os-steve
os-steve deleted the claude/issue-4910-strict-mode-parity-probes branch August 17, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec 17.0.0 GA made the element props schemas strict — two spec-parity tests still encode rc.6 strip-mode as their control

2 participants