Repository navigation
feat(types): form fields declare scale and currencyConfig by spec reference; M3 class (ii) reads 0 (objectui#11070 round 13) - #11972
Conversation
…erence; catalog and guide move to the spec spellings (objectui#11070 round 13)
Round 13 of objectui#11070, the three class (ii) pairs round 12's M3
re-run left, on the seat's answers A, B and C.
A. FormField / FormFieldSchema declare `scale` by reference to
@objectstack/spec FieldSchema.scale, and carry the spec's one
cross-key rule on it: `scale` on a `currency` entry is refused at
`scale` with the spec's own text (pinned equal to the spec's issue).
B. The two currency catalog documents move from `currency` to
`currencyConfig { currencyMode: 'fixed', defaultCurrency }`, and the
form-field faces declare `currencyConfig` by reference to the spec,
which the moved documents need to parse. `currency` stays undeclared.
C. The schema-rendering guide's action:button example and its sentence
name the endpoint `target`, per the spec's action:button row.
Claude-Session: https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw
Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
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
Size Limits
|
Contract reviewServed-tier: PR objectui#11972 (draft, 1 commit ① Derived judgmentsEvery accept-set and public-surface move the diff implies, each judged:
② Semver level
③ Boundary flagsDev flags (report
Reviewer's flags, none verdict-changing:
Nothing else in the diff, the card or the check-runs contradicts the claim, the seat's ACCEPT ( Implemented-by: VERDICT: PASS Generated by Claude Code |
|
Out of the merge queue on a red that is not this PR's: not re-queued, blocker filed as objectui#11978. From the Signature.
Initial judgment: a race in that test, not this diff.
Why it is not re-queued now. This signature is new. The seat's rule allows one re-queue only when three facts all hold: the failing test's import closure is disjoint from the diff, the queue base is green on the same shard, and the first error is a timeout. Here the first error is an assertion, so no re-queue. What happens next.
Generated by Claude Code |
…ls before reading them (objectui#11978) (objectstack-ai#11986) Fixes objectstack-ai#11978 Clause-②: no ## What changed One test, `ObjectHooksPanel.newHookTarget-11820.test.tsx`, "opens with this package's objects first and every other object under the reach warning": every read whose answer the object roster draws now waits for that answer. - The two "This package" checkbox reads (`Invoice (invoice)`, `Invoice line (invoice_line)`) and the two "Other objects" checkbox reads (`User (sys_user)`, `Employee (hr_employee)`) use `await within(…).findByRole(…)` at the default timeout instead of a synchronous `getByRole`. - The "Other objects" group is found with `await screen.findByRole` too: that group is drawn only once the roster answers. - An empty-frontmatter changeset declares no release. The file sits under `src/`, and `check-changeset-presence.mjs` has no carve-out for tests. The component is unchanged: `HookDefaultInspector`'s bare-name interim state is deliberate (objectui#10585). ## Why it was red on timing alone Two reads answer at their own pace. - `ObjectHooksPanel` reads this package's objects in a mount effect and hands them to the editor as `HookTargetScopeContext`. That draws the "This package" group. - `HookDefaultInspector` reads the object roster itself (`useObjectOptions`), after New opens it. Until the roster answers, `pickerOptions` holds only the selected object, labelled by its bare name. So "This package" can be on screen holding one checked checkbox named `invoice` while "Other objects" is not drawn yet. The test awaited only the group, then read the label synchronously. Which read answered first decided the verdict: this is the red that took PR objectstack-ai#11972 out of the merge queue and turned PR objectstack-ai#11973's `Test (shard 6/8)` red. ## Measurements, on this branch **H1: the fix closes the window.** A throwaway, untracked copy of the test (deleted afterwards) delays only the catalog `list('object')` answer; the package-scoped read is not delayed. One copy of the test as on `main` (`989b19082`), one of the test as changed here. | copy | roster delay | runs | result | |---|---|---|---| | as on `main` (the lit control) | 120 ms | 3 | 3 failed, each at the `Invoice (invoice)` read. The dump shows "This package" holding one checked checkbox named `invoice`: the queue's signature. | | as changed here | 120 ms | 3 | 3 passed | | as changed here | 300 ms | 1 | passed | | as changed here | 600 ms | 1 | passed | | as changed here | 1500 ms | 1 | failed at the new `findByRole('checkbox', { name: 'Invoice (invoice)' })`: the wait is bounded by the default 1000 ms timeout. | **H2: the test still tests its claim.** Two ablations of `HookDefaultInspector.tsx` through objectstack's `scripts/ablation-replace.mjs` in wrap mode. Each was restored to the HEAD blob `3bb69262f529` with `git diff HEAD` empty, and a bash trap re-checked the same hash. - `invoice` moved from "This package" to "Other objects" (both `scope.packageObjects.has(o.value)` partition reads replaced; anchor 2 to 0, blob changed). The test went red at the new `await within(own).findByRole('checkbox', { name: 'Invoice (invoice)' })` after its default timeout. - The reach warning removed (the `hook-object-outside-reach` paragraph deleted; anchor 1 to 0). Red at `getByTestId('hook-object-outside-reach')`. - The first attempt at the first ablation was a no-op. Its replacement text contained the anchor, so the anchor count could not drop; the tool refused with exit 1 before running the test, and restored. The reading above is the retake, with a replacement that does not contain the anchor. **H3: the same pattern elsewhere.** Read only; no other test file is touched. Two text scans over the 594 test files under `packages/app-shell/src/views/studio-design/` and `packages/app-shell/src/views/metadata-admin/`. Each has a lit positive control: run on `main`, each hits this card's test; on this branch, neither does. 1. An awaited container (`const X = await …findBy…`), then a synchronous named read inside it: 15 hits in 10 files. None shares the race. The names are static i18n strings drawn in the same commit as their container (buttons, a tab, a placeholder). The one data-derived name, `DocPreview.test.tsx` "keeps a group key no book declares visible", is gated by an interposed `await waitFor(() => expect(picker).toHaveValue('retired_section'))`, which can pass only once the books answered. 2. A synchronous read of a roster-shaped `Label (api_name)` name: 25 hits in 11 files. None shares the race. Most are synchronous renders with no async read at all (static strings such as `Values (measures)`, `role (deprecated)`). `HookDefaultInspector.reach-11820.test.tsx` awaits `findByRole('checkbox', { name: 'Account (crm_account)' })`, the roster's answer, before its synchronous reads. `AccessExplainPanel.test.tsx` waits for the Object field to become a select, which is drawn once the roster answered. `ObjectFieldInspector.usePicklist-10202.test.tsx` awaits one served option before reading its sibling from the same answer. The scans' blind spot: a data-derived name in another form, read through `screen.getBy…` after an unrelated await, is outside both. ## Gates, at `04a3cad2a` - `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/views/studio-design/ packages/app-shell/src/views/metadata-admin/inspectors/` (verify lock): `Test Files 296 passed (296)`, `Tests 2935 passed | 1 skipped (2936)`. - `pnpm exec vitest run --maxWorkers=2 scripts/__tests__/` (verify lock): `Test Files 179 passed | 2 skipped (181)`, `Tests 5452 passed | 2 skipped (5454)`. - `pnpm --filter @object-ui/app-shell type-check` (verify lock; echoes `@object-ui/app-shell@17.7.0 type-check`, which runs `tsc --noEmit && tsc -p tsconfig.test.json`): exit 0, after `turbo run build --filter='@object-ui/app-shell^...'` built the dependency closure (28 of 28 tasks). `tsc -p tsconfig.test.json --listFilesOnly` lists this test file. A first run before the closure was built could not resolve the `@object-ui/*` declarations: NOT MEASURED, not a red. - `node scripts/check-changeset-presence.mjs`: exit 0 (an empty frontmatter, the explicit exemption). `check-changeset-no-major.mjs`, `check-changeset-overwrite.mjs`, `pnpm check:changeset-claims`, `pnpm check:pending-changeset-literals`: exit 0. - `pnpm check:new-line-citations`: `0 new citation(s)`, exit 0. `pnpm check:control-bytes`: exit 0. - `pnpm check:vi-mock-specifiers`, `check:vi-mock-inherit`, `check:vi-mock-override-shape`, `check:test-path-roots`: exit 0. - ESLint, narrowed to the changed file, with an unchanged sibling test as control: 2 files in the JSON result, 0 errors and 0 warnings each. `--print-config` shows 119 rules active on the changed file, so it is in ESLint's population. The config enables no type-aware linting, so this diff cannot move another file's verdict. The repo-wide `pnpm lint` and the full sharded `pnpm test` are CI's. ## Acceptance notes - The sibling test of the same picker, `HookDefaultInspector.reach-11820.test.tsx`, already waits the way this change does: its mount helper awaits the roster-labelled checkbox before any synchronous read. - objectui#11913 waits on this card (`Blocked-by: objectstack-ai#11978`). PR objectstack-ai#11972 re-enters the queue once this lands. Session: `https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw` --- _Generated by [Claude Code](https://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw)_ Co-authored-by: Claude <noreply@anthropic.com>
Part of #11070 (round 13)
Clause-②: yes — widens: the strict authoring face (
StrictAnyComponentSchema) now acceptsformfields[].scaleandfields[].currencyConfig, which it refused withunrecognized_keys; narrows: the tolerant face (safeValidateSchema), which stripped both keys unjudged, now refuses a value the spec'sFieldSchemarefuses for either key, and refusesscaleon acurrencyentry (customatscale, the spec's own refusal) on both faces.Round 13 of objectui#11070, claim
6060489789, sessionhttps://claude.ai/code/session_01DBZ9bntPZ7VKyQNtJeNsgw. This round executes the seat's answers A, B and C (ACCEPT6060086777) for the three class (ii) pairs round 12's M3 re-run left. On this head, M3 class (ii) reads 0 corpus pairs, so the card's executable acceptance holds. The 32 pairs that remain are the class (i) pairs round 12 already listed, unchanged.M3 re-run: base
a835c314, head5f049697The instrument is round 12's, rebuilt as a scratch script and not committed. It copies
scripts/measure-strict-authoring-face.mjsunchanged up to its first corpus run (const catalog = loadCatalog();), except thatREPO_ROOTcomes from the environment. On top of that it adds the JSON half of M2's population: every tracked.jsonoutside the three corpora, 195 files, holding 1 node document. The YAML half holds none: 45 tracked YAML files, 0 with a roottype:, against 41 with a rootname:as the lit control. Each node document is judged byStrictAnyComponentSchemaandAnyComponentSchemafrom the builtpackages/types/dist/zod. Refused keys are read with PR objectui#11069's ownfindUndeclaredKeys, copied from its heada11f73347dwith the types stripped. Each key is attributed to its nearest node by the measure script's schema-guided walk, with node paths kept. zod 4.6.5;@object-ui/typesbuilt at each end.The base reproduces round 12's head reading exactly: 623 node documents, 58 refused, 35 pairs, 54 occurrences, 40 strict-only.
examples/hello-world/schema.json)form·fields[].scale(3),form·fields[].currency(2) andaction:button·properties.endpoint(1, red on both faces).button·action/content/fullWidth,card· five keys,form·submitButton/title/ fourfields[]keys, and so on.UnmirroredDeclaredledger inzod-mirror-parity.test.tsnames none of the 32.A.
fields[].scale, declared by reference, with the spec's one cross-key ruleFormField.scaleisSpecField['scale'], andFormFieldSchema.scaleisstripImportedDefaults(SpecFieldSchema).shape.scale. The readers are thenumberwidget (numberField?.scale), thepercentwidget (throughresolveFieldScale), and theformulaandsummarywidgets (throughformatNumberFieldValue).H2, the cross-key rule, decided by measurement: the face carries it.
@object-ui/types'@objectstack/specis 17.7.0. ItsFieldSchemarefusesscaleoncurrency(codecustom, atscale), forscale: 2andscale: 0alike. It acceptsscaleonnumber,percentand eventext.currencyis the one type it excludes.currencywidget does not readscale. Measured through the realSchemaRendereron a fixed-USD entry: withscaleabsent,0and4, the read-only face draws$1,234.50and the edit face's step is0.01each time. Lit control, anumberentry with the same three:1,234.5/1235/1,234.5000, stepany/1/0.0001.scale(as an undeclared key). A bare.shape.scaledeclaration would therefore have newly accepted exactly the document the spec refuses and nothing reads.type === 'currency' && scale !== undefinedgivescustomatscale. It is keyed as the spec keys it, and its text is the spec's own (CURRENCY_FIELD_SCALE_REFUSAL). The spec exports neither the rule nor its text, so the pin asserts both: the spec still refuses that entry, and objectui's message equals the spec's issue message. A spec release that drops or rewords the rule reds the pin. ⛔ No new local rule: nothing is refused that the spec admits.rows(multiline types),minLength/maxLength(bounded string types) andmultiple. They key on the spec's field-type sets, and this face'stypeis a widget id: the spec'sFieldSchemarefusestype: 'input'itself (invalid_valueattype), while the built-in input branch honoursminLengthon it. The spec's own inline form field (theobject-formrow) also takesrows,minLengthandmaxLengthoffFieldSchema.shapeflat, without the rules. The currency rule keys ontypeonly, so an entry drawn as currency throughwidgetalone is not judged by it.B.
fields[].currencyis runtime-only; the two documents move tocurrencyConfigfields-currency/euro-currency.jsonandusd-currency.jsonnow writecurrencyConfig: { currencyMode: 'fixed', defaultCurrency: 'EUR' | 'USD' }instead ofcurrency.currencystays undeclared, and the strict face still refuses it by name (pinned).One premise was falsified on the way, and the route was completed by the round's own pattern. The ACCEPT's B sends the documents to
currencyConfig, "which the spec declares". The spec declares it, but objectui's form-field face did not. Base probe on the built dist: the strict face refusedfields.0.currencyConfigwithunrecognized_keys, and the tolerant face stripped it. Moving the documents alone would have tradedform·fields[].currencyforform·fields[].currencyConfig, which is still class (ii) becauseresolveFieldCurrencyreads it. SocurrencyConfigis declared on both faces by reference too:SpecField['currencyConfig']andstripImportedDefaults(SpecFieldSchema).shape.currencyConfig. That needs no spec change. It also means B widens the strict face, which the claim's last line did not expect ("B and C correct documents and widen nothing"); the Clause-② line above states it. The imported-defaults boundary keeps the spec config's defaults (dynamic,CNY) out of a parsed document (pinned:currencyConfig: {}stays{}).H3 held, through the real
SchemaRenderer, form renderer and@object-ui/fieldsregistration:currency)currencyConfig, fixed)euro-currency€, step0.01€, step0.01useIdvalues (1080 = 1080 bytes)usd-currency$, step0.01$, step0.01useIdvalues (1066 = 1066 bytes)pl-8paddingThe permanent pin (
examples/schema-catalog/test/fields-scale-currency-config-11070.test.tsx) holds the after column. It has two controls:dynamicmode draws no symbol, andJPYdraws¥with step1. It does not pin the before column, because limb 1's future is a separate question.C. The guide's
action:buttonexample names its endpointtargetIn
content/docs/guide/schema-rendering.md, the "Reference actions in schemas" fence now writes"target": "/api/action"instead ofendpoint. The sentence below it now reads that anapiaction's endpoint is itstarget, with the verb inmethod, and that the spec'saction:buttonrow refusesendpointand namestargetin its place. Its half onapiis unchanged and still true: no action renderer forwardsapi. The core runner'sexecuteAPIresolvesapi || endpoint || target, sotargetalone reaches it. The taught shape is pinned on both faces, andendpointin the bag stays refused on both.Hypotheses
scalesits in the three catalog documents.plugin-timeline/gantt-style-timeline.json, is atimelinenode's own top-levelscale: "month"(the Gantt axis bucket, which the timeline schema declares). It is a different key in a different block, and M3 refuses nothing there.currencysits in the two currency documents.endpointsits in the "Reference actions in schemas" fence and in the sentence "the endpoint key isendpoint".currencyConfig.Tests, reverse verification, ablations
Heavy runs went through the verify lock with
NODE_OPTIONS=--max-old-space-size=4096and--maxWorkers=2. The subjects resolve from source through the root vitest alias, so no leg needs adistrebuild. M3 reads the dist, which was built at each end.Pins.
strict-face-read-keys-11070.test.tsgains the round-13 rows:scaleandcurrencyConfigparse on the strict face, and a misspelled sibling is refused by name;scale; an unknowncurrencyMode; a 4-letterdefaultCurrency; the removedcurrencyConfig.precision);scaleon a currency entry is refused atscalewith the spec's text, with two controls;fields[].currencystays refused by name;action:buttonshape parses, andendpointstays refused;any.form-field-zod-coverage.test.tsadds the two keys to its declared set. The new catalog test is described under B.Predictions were written before any leg (head
5f049697). Every leg ran on committed code. Each restore was proven by blob equal to HEAD and an emptygit diff HEAD.form.tsandform.zod.tsat base under this head's pins. Predicted 17 red (pin 11, coverage 1, catalog 5) and 3tscerrors, all in the pin. ObservedTests 17 failed | 135 passed (152)and 3error TS2344, all in the pin.scalemember deleted through objectstack'sscripts/ablation-replace.mjs(anchor 1 → 0, blob8056f03b→c397dd27). Predicted 11 red and at least 2tscerrors. ObservedTests 11 failed | 141 passed (152).type-checkstopped at its first project with 1 error (form.zod.ts,field.scale). Its test project, run alone under the same mutation, shows 2: that one, andzod-mirror-parity's ledger namingform.zod.ts#FormFieldSchema.currencyConfigmember deleted (blob8056f03b→6febb5ab). Predicted 9 red and at least 1tscerror. ObservedTests 9 failed | 143 passed (152)and 1 (the parity ledger).if (false), blob8056f03b→f05fc4dd). Predicted 1 red and 0tsc. ObservedTests 1 failed | 151 passed (152)and 0.scalemember deleted (blob9c210d73→80d35122). Predicted 0 red and at least 3tscerrors. ObservedTests 152 passed (152)and 4: 2 in the pin, plus 2 in the parity ledgers (one namingform.zod.ts#FormFieldSchema, one namingscale).Union at the final head
5f049697:pnpm exec vitest run --maxWorkers=2 packages/types/ packages/fields/ examples/schema-catalog/ scripts/__tests__/gaveTest Files 824 passed | 3 skipped (827)andTests 21333 passed | 9 skipped (21342).Type-check:
pnpm --filter @object-ui/types type-check(script line@object-ui/types@17.7.0 type-checkechoed; itstsconfig.test.jsonlists both edited pins) andpnpm --filter @object-ui/example-schema-catalog type-check(echoed; its test project lists the new test). Both exit 0, with 0error TS.Gates at
5f049697objectui has no
dispatch-gates, so this set is the brief's list plus what the diff touches, derived by hand from the rootpackage.json. All exit 0:check:skill-examplesandcheck:doc-snippets("784 of 784 block(s) judged, 0 failed"), after building the snippet gate's--build-filterclosure (turbo: "35 successful, 35 total");check:doc-types,check:doc-examples(122 blocks, 87 failing as their ledger rows declare),check:doc-fencesandcheck:doc-example-ids;check:spec-symbols, andcheck:component-surface-parity(report-only; no row names either new key);check-changeset-presence(4 published source files, 1 changeset),check:changeset-claimsandcheck:pending-changeset-literals;check:new-line-citations("0 new citation(s)") andcheck:control-bytes.docs:check-links,check:designer-field-key-parity,check:handler-key-reads,check:test-path-roots,check:prompt-keys,check:action-forward-parity,check:vi-mock-specifiers,changeset:check, andcheck-governed-queue-guard --testover the 9 paths (NOT GOVERNED)..ts/.tsxfiles with the rooteslint.config.jsand--no-inline-config:**/*.{ts,tsx}blocks.--format jsonlists 5 files, with 0 errors and 9 warnings. All 9 areno-explicit-anyinform.ts, and the same 9 read at base through--stdin. The other three existing files read 0/0 at base.parserOptions.projectorprojectService, and noeslint-rules/rule reads the filesystem, so the diff cannot move an untouched file's verdict.NOT MEASURED:
pnpm check(the CLI self-checklint.ymlruns). Reason: it needs the built CLI. It exits non-zero only on unreadable JSON, and both edited JSON documents parse.pnpm lintandpnpm test. Reason: CI-owned.Acceptance notes
currencyConfig, which the claim's file surface allowed (form.ts/form.zod.ts) but its Clause-② note did not expect. The reason is above: the moved documents need it to parse.scale/currencyConfigand onscaleon a currency entry. The changeset states this as BREAKING. No corpus document writes either malformed shape: M3's red-today count moves only by the guide's fence.CurrencyFieldMetadata(the field-metadata type) still declarescurrency.content/docs/fields/currency.mdxteaches it in atsfence, beside the two catalog examples that now writecurrencyConfig. Both are limb 1 ofresolveFieldCurrency, which the ACCEPT keeps as a separate question. Noted, not filed.Generated by Claude Code