diff --git a/.changeset/20313-use-grouping-live.md b/.changeset/20313-use-grouping-live.md new file mode 100644 index 00000000000..9bb1dcd37cb --- /dev/null +++ b/.changeset/20313-use-grouping-live.md @@ -0,0 +1,18 @@ +--- +'@objectstack/spec': patch +--- + +The `field` liveness ledger grades `useGrouping` `live`, and the key's docblock stops describing a grouping heuristic the renderer does not use + +Clause-②: no + +A number field's authored `useGrouping` is honoured by the console this release builds against: +an authored `true` or `false` decides whether the value renders with thousands separators, and an +absent key keeps the renderer's interim rule. The `liveness/field.json` row therefore moves from +`planned` to `live`, citing the objectui reader and the sites that carry the key to the number +cell, and `liveness/state-counts/field.md` is regenerated to match. The `FieldSchema.useGrouping` +docblock in `src/data/field.zod.ts` said the interim rule looked at a field's `min` / `max` +bounds, and that the renderer half had not landed; both are corrected: the rule reads only a +declared `scale: 0` (ungrouped) against any other `scale` or none (grouped), and the renderer half +reads an authored value first. Text and ledger only: the schema, its `.describe()` string, every +export and all runtime behaviour are unchanged. diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index cc28dbda19c..ec2da038770 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -194,8 +194,12 @@ "note": "CAVEAT — grid formatting only; DDL never sizes." }, "useGrouping": { - "status": "planned", - "note": "[#7768] Declared 2026-08-11 — Option A of the card's fork (narrowest shape with measured pull, maintainer veto window open). Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. The runtime consumer is objectui#4033 (in flight at declaration time): its interim heuristic (`scale === 0 && no currency` ⇒ ungrouped) is explicitly documented at its own definition as \"overridden by the spec presentation hint when it lands\" — i.e. the read side is designed to pick this key up, not a speculative future phase. Not `authorWarn`'d: unlike `externalSharingModel`'s scheduled-phase precedent, an author who sets this today loses nothing and is not misled — the value becomes effective the moment the objectui consumer lands, no re-authoring needed. Re-verify to `live` once #4033 (or its successor) actually reads the key." + "status": "live", + "verifiedAt": "2026-09-30", + "evidenceScope": "cross-repo", + "evidence": "objectui: packages/core/src/utils/number-display.ts#shouldGroupDisplayNumber makes the one grouping decision, and an authored boolean `useGrouping` answers it first, in either direction, so only an ABSENT key reaches the interim heuristic, currency ⇒ grouped, else a declared `scale: 0` ⇒ ungrouped; objectui: packages/core/src/utils/number-display.ts#formatDisplayNumber takes the field's `useGrouping`, asks that decision and spells the answer for `Intl.NumberFormat`, an ungrouped answer as `useGrouping: false` and an authored `true` as `useGrouping: true`, the 1:1 mapping this key declares; objectui: packages/fields/src/index.tsx#NumberCellRenderer reads `field.useGrouping`, booleans only, and hands it to formatDisplayNumber — all three read at objectui @db11afd4967c, which is this repo's `.objectui-sha` pin, so the reader is the one inside the console this repo builds against rather than on objectui main", + "producer": "objectui: packages/plugin-grid/src/ObjectGrid.tsx#generateColumns copies the object definition's `useGrouping` onto the column's field metadata beside `scale` on each of its three column paths; objectui: packages/plugin-detail/src/fieldEnrichment.ts#ENRICHED_FIELD_METADATA_KEYS lists it among the object-metadata keys copied onto a detail-view field; objectui: packages/plugin-detail/src/RecordDetailPanel.tsx#buildRecordDetailFields, packages/plugin-detail/src/RelatedList.tsx#effectiveColumns and packages/plugin-list/src/ObjectGallery.tsx#ObjectGallery relay it beside `scale` the same way — seven hand-copy sites, read at objectui @db11afd4967c, the pin. They are the producer half because the cell reads the field metadata these sites build, never the object definition: a site that dropped the key would leave an authored `false` year reading `2,026` on that surface", + "note": "[#7768] Declared 2026-08-11 as Option A of that card's fork, the narrowest shape with measured pull. Maps 1:1 to `Intl.NumberFormat`'s `useGrouping`. FLIPPED `planned` → `live` 2026-09-30, on the condition this row's earlier note set, that the objectui consumer actually read the key: the renderer half landed as objectui ae582b70a, and objectui's compare `ae582b70a...db11afd4967c` answers ahead 21, behind 0, so it is inside the console this repo pins — measured at the pin, not on objectui main. An authored boolean decides in either direction. An ABSENT key is still the renderer's call: the interim heuristic objectui#4033 introduced stays for exactly that case, and it reads the declared `scale` alone, never `min` or `max` — a declared `scale: 0` with no currency is a discrete integer, a year or an ordinal, and renders ungrouped, while any other number keeps grouping. Never `authorWarn`'d, so the flip drops no warning, and an author who set the key while it was `planned` re-authors nothing. CAVEAT — display only, and only on the surfaces whose field metadata carries the key to the cell: the seven relay sites `producer` names." }, "reference": { "status": "live", diff --git a/packages/spec/liveness/state-counts/field.md b/packages/spec/liveness/state-counts/field.md index 83feef47783..0df888579da 100644 --- a/packages/spec/liveness/state-counts/field.md +++ b/packages/spec/liveness/state-counts/field.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `field` | 91 | 0 | 0 | 1 | 2 | 94 | +| `field` | 92 | 0 | 0 | 1 | 1 | 94 | diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index 949898cfbd8..cfb65e438ec 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -73,6 +73,19 @@ function setChildEvidence(root: string, type: string, prop: string, child: strin writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); } +/** Rewrite one property's `status` in a copied ledger. */ +function setStatus(root: string, type: string, prop: string, status: string): void { + const file = path.join(root, `${type}.json`); + const ledger = JSON.parse(readFileSync(file, 'utf8')); + ledger.props[prop].status = status; + writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); +} + +/** One property's row, read back from a copied ledger. */ +function readRow(root: string, type: string, prop: string): Record { + return JSON.parse(readFileSync(path.join(root, `${type}.json`), 'utf8')).props[prop]; +} + /** * The population label the gate renders from `EVIDENCE_SCANNED_STATUSES` * (#13041). Mirrored here once rather than inlined at each assertion, and the @@ -388,13 +401,23 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { }); it('FAILS when a `planned` entry cites a repo-local file that is gone', () => { - // `field.useGrouping` is `planned` and carries no evidence today, so the - // pointer this writes is the only thing that can fail — and the rot is the - // plainest kind, the one the existence check has caught for `live` entries - // since #5623. + // The row is MADE `planned` in the copy rather than found that way. This + // case used to rely on `field.useGrouping` being `planned` in the shipped + // ledger, and when that row went `live` the case kept passing while it drove + // a `live` row — the one leg it exists for went unexercised, silently. The + // row's shipped `producer` and `evidenceScope` cite only objectui-attributed + // paths and trip nothing on a `planned` row, and `moveCount` keeps the copy's + // count shard in step with the flip, so the pointer written below is still + // the run's only cause — and the rot is the plainest kind, the one the + // existence check has caught for `live` entries since #5623. const root = path.join(tmp, 'planned-missing-file'); cpSync(LEDGERS, root, { recursive: true }); + const shipped = String(readRow(root, 'field', 'useGrouping').status); + setStatus(root, 'field', 'useGrouping', 'planned'); + if (shipped !== 'planned') moveCount(root, 'field', shipped, 'planned'); setEvidence(root, 'field', 'useGrouping', `${ROTTED} (rotted by the self-test)`); + // The control: the row this run judges IS `planned` in the copy. + expect(readRow(root, 'field', 'useGrouping').status).toBe('planned'); const { status, output } = runGate(root); expect(status, output).toBe(1); @@ -904,21 +927,16 @@ describe('check:liveness — an unrecognized ledger `status` (#13083)', () => { }); afterAll(() => rmSync(tmp, { recursive: true, force: true })); - /** Rewrite one property's `status` in a copied ledger. */ - function setStatus(root: string, type: string, prop: string, status: string): void { - const file = path.join(root, `${type}.json`); - const ledger = JSON.parse(readFileSync(file, 'utf8')); - ledger.props[prop].status = status; - writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); - } - /** A copy of the real ledger root with `field.useGrouping` misspelled. */ function typodRoot(name: string): string { const root = path.join(tmp, name); cpSync(LEDGERS, root, { recursive: true }); - // `field.useGrouping` is `planned` and carries no evidence, so the misspelling - // is the only thing in the copy that can move a verdict — no evidence-scan - // finding can be confused for it. + // The misspelling is the only thing in the copy that can move a verdict — no + // evidence-scan finding can be confused for it. The gate scans `evidence` + // only for a status in `EVIDENCE_SCANNED_STATUSES`, which `planed` is not, + // and it resolves `producer` at any status, but every path this row's + // `producer` cites is objectui-attributed, so none of them is resolved + // against this checkout. setStatus(root, 'field', 'useGrouping', 'planed'); return root; } diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 1db44884d9c..c802ff7629f 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -841,8 +841,9 @@ describe('FieldSchema', () => { expect(prop).toBeDefined(); expect(prop.type).toBe('boolean'); // Unlike `autonumberFormat`, this key carries no JSON-Schema `default` - // annotation — there is no renderer-agnostic grouping behavior to declare - // until the objectui half (#4033) retires the interim heuristic. + // annotation — there is no renderer-agnostic grouping behavior to declare: + // an absent key is the renderer's call, and the renderer half (objectui's + // `shouldGroupDisplayNumber`) keeps the interim heuristic for it. expect(prop.default).toBeUndefined(); expect(js.required ?? []).not.toContain('useGrouping'); }); diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index ff1f6499e26..657a3433672 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -1277,10 +1277,10 @@ export const FieldSchema = lazySchema(() => { * Presentation hint (#7768): whether a `number` field renders with digit * grouping (`Intl.NumberFormat`'s `useGrouping`, e.g. `2,026` vs `2026`). * `scale` was the ONLY presentation-adjacent property `number` had, and it - * governs decimal places, not grouping — console renderers construct + * governs decimal places, not grouping — console renderers constructed * `Intl.NumberFormat` with grouping unconditionally ON, so an * ordinal/identifier integer stored as `Field.number({ scale: 0, min: 1900 - * })` (a year) renders `2,026` everywhere it is shown. Downstream apps hit + * })` (a year) rendered `2,026` everywhere it was shown. Downstream apps hit * this three times (hotcrm-heimao#35/#40/#59) and each time converted the * field to `Field.text` to escape the comma — trading away numeric * semantics (range validation, sort-as-number, arithmetic) for a display @@ -1289,9 +1289,10 @@ export const FieldSchema = lazySchema(() => { * Three-valued, and the absent case is deliberately NOT "grouping off": * - **absent** (default state) — the author has not judged whether this * number reads as a quantity or an identifier; the RENDERER decides. - * Today that is an interim heuristic (objectui#4033, e.g. `scale: 0` - * + no upper bound reads as a plain count and keeps grouping, a small - * bounded integer range reads as ordinal-shaped and drops it); + * Today that is an interim heuristic (objectui#4033: a declared + * `scale: 0` reads as a discrete integer, a year or an ordinal, and + * drops grouping, while any other `scale`, or none, keeps it — no + * bound is read); * eventually the locale's own default. Neither contract lives here — * this key only carries the author's EXPLICIT override when they have * one, exactly like `min`/`max`/`scale` carry constraints without @@ -1308,8 +1309,9 @@ export const FieldSchema = lazySchema(() => { * number renderers are expected to pass it straight through. No default is * declared here on purpose — unlike `autonumberFormat`'s JSON-Schema * `default` annotation, there is no single grouping behavior every - * `number` field should present until the renderer half of this contract - * (objectui#4033) lands and retires the interim heuristic. + * `number` field should present: the renderer half of this contract has + * landed (objectui's `shouldGroupDisplayNumber` reads an authored value + * first) and keeps the interim heuristic for an absent key. */ useGrouping: z.boolean().optional().describe('Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`\'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on.'),