diff --git a/.changeset/react-chart-aggregate-parse-gate.md b/.changeset/react-chart-aggregate-parse-gate.md new file mode 100644 index 0000000000..ed58ad6be8 --- /dev/null +++ b/.changeset/react-chart-aggregate-parse-gate.md @@ -0,0 +1,64 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): the react-page publish gate PARSES `ChartAggregateSchema` instead of re-deriving it (#5020) + +`` is judged at publish time by +`validate-react-page-props`. That gate used to RE-DERIVE the aggregate's +declaration: a local `CHART_FUNCTIONS` copy of the function vocabulary and a +hand-written twin of the schema's count/field refinement. Two implementations of +one contract, each free to drift — and, because unknown-key handling is a +property of a **parse** rather than of a list of `if`s, a gate with no +unknown-key check at all. The rule now calls `ChartAggregateSchema.safeParse()` +on a statically resolvable literal, exactly as #5022 did for +`ChartDrillDownSchema` beside it, and both hand-derived copies are deleted: +`@objectstack/spec` is the single source of the vocabulary and the refinement +again. + +**Newly reported (all `error`, all previously silent).** These are shapes the +schema, the published react-blocks type and objectui's renderer already agreed +were wrong; the old gate simply could not see them: + +| authored | before | after | +|---|---|---| +| `aggregate={{ field: 'total', groupBy: 'status' }}` (no `function`) | accepted | `aggregate.function: Invalid option: expected one of "count"\|"sum"\|"avg"\|"min"\|"max" (nothing is set there)` | +| `aggregate={{ field: 42, function: 'sum', groupBy: 'status' }}` | accepted | `aggregate.field: Invalid input: expected string, received number` | +| `aggregate={{ function: 'count', groupBy: 42 }}` | accepted | `aggregate.groupBy: Invalid input (received 42) — no accepted form matched: (1) … (2) …` | +| `aggregate="count"` / `aggregate={[]}` | accepted | `aggregate must be a configuration object, not string.` | + +**Re-worded, same verdict.** Two messages now arrive from the schema rather than +from this rule's own copy. If you match on lint output, update the text: + +- FROM `aggregate.function "median" is not an aggregation this chart can run.` + (hint: `Use one of: count, sum, avg, min, max.`) + TO `aggregate.function: Invalid option: expected one of "count"|"sum"|"avg"|"min"|"max" (received "median")` + — the vocabulary is the enum's own, and the author's value is echoed back from + the input (the one part zod does not put in the message). +- FROM `aggregate.function "sum" has no "field" to aggregate.` + TO `aggregate.field: aggregate.function "sum" needs a "field" to aggregate (only "count" may omit it).` + — verbatim from the schema's refinement. + +The rule id (`react-chart-aggregate-invalid`) and the severity are unchanged for +both. + +**`aggregate.groupBy` missing is a NEW `warning`, deliberately not an error.** +It is the one violation the platform does not agree with itself about: +`ChartAggregateSchema` and the published react-blocks type both declare `groupBy` +**required**, while objectui's `ObjectChart` honours its absence +(`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own +`chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it +would break a working authoring shape to enforce a declaration the platform does +not keep, so the finding explains the situation and does not fail +`os lint`/`validate`/`compile`. Whether the schema loosens or the renderer +tightens is decided on #5583. + +**What this does NOT fix yet.** `ChartAggregateSchema` and `ChartGroupBySchema`'s +object arm are still STRIP-posture, so the parse this gate now runs **drops** an +unknown key rather than reporting it: `groupby` for `groupBy` and +`dateGranularty` for `dateGranularity` still degrade a chart to one ungrouped +point with the build green. Wiring the parse is the precondition for closing +that, not the closing — `.strict()` is a property of a parse, and until now there +was no parse to make strict. The spec-side tightening is **#5583**; the tolerance +is pinned by name in this rule's tests so a wired gate cannot be mistaken for a +closed one (#4583). diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md index adf9aeed31..39c1f21bc7 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts.md @@ -29,11 +29,11 @@ Remaining strip sites by class: | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 11 | +| authorable — the ruling's forced scope | 13 | | unresolved — needs a per-schema verdict | 33 | | wire / open — out of forced scope | 106 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 31 | +| no gate — carrier live, no parse | 29 | ## Posture, per triaged directory @@ -169,11 +169,11 @@ over it is here. | Bucket | Sites | |---|---| -| authorable — the ruling's forced scope | 2 | +| authorable — the ruling's forced scope | 4 | | unresolved — needs a per-schema verdict | 0 | | wire / open — out of forced scope | 2 | | no door — no carrier, ADR-0049 territory | 14 | -| no gate — carrier live, no parse | 31 | +| no gate — carrier live, no parse | 29 | ### `data/` — open diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.md b/docs/audits/2026-07-unknown-key-strictness-ledger.md index 8d371ed495..11e17d3de5 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.md @@ -633,7 +633,7 @@ sites left to be a verdict about. | `dashboard.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DashboardWidgetSchema` has been strict since the ADR-0021 cutover; 批 14 closed the two NESTED holes inside it (`compareTo`'s object arm, `layout`), the same strict-shell-over-strip-children silhouette 批 13 found on `page.components[]`. `DashboardWidgetOptionsSchema` stays `passthrough` **deliberately** (renderer escape hatch) and the `responsive` tombstone (#4876) is untouched. ⚠️ **The `compareTo` union caveat this row carried is RESOLVED, and it is the one entry in this table whose limit was dissolved rather than worked around.** 批 14 recorded that `compareTo` was a UNION, so its curated prescription was produced but never delivered — `zodIssuesToFields` maps only top-level issues and a failed union collapses to a bare `Invalid input` (#5014) — with the rejection itself unaffected. **#5011 removed the union**: the slot converged onto the analytics executor's own contract, `{ kind, dimension? }`, a plain strict object whose message IS top-level. The reason was not the message, it was worse — all three declared arms were broken on the ADR-0021 dataset path (the two strings silently dropped by the renderer, `{ offset }` throwing `compareTo requires a timeDimension "undefined"`), while all three worked on the legacy inline path: same key, two fates, the failing one blessed. The union-free shape is the design benefit, pinned in `dashboard-compareto.test.ts` so it cannot silently return. **#5014 still binds every OTHER curated message this campaign has put inside a union arm** — this row is one slot's correction, not the finding's retraction. ⚠️ **#5010 retired four more widget keys and moved this row's posture by nothing, which is the point.** The `#4956` drill gave `DashboardWidgetSchema`'s 22 widget-level keys their first per-key verdicts and found six dead; `actionUrl`/`actionType`/`actionIcon` (a per-widget action BUTTON no renderer in either repo has ever drawn — all 14 `actionUrl` reads in `DashboardRenderer` are scoped to `header.actions[]`) and `aria` (ARIA attributes that never reached the DOM — the dashboard-level `aria` the #3896 sweep removed, one level down) are now `retiredKey` tombstones beside `responsive`. **Strip sites remain 0 and the strictness verdict is untouched**, because a retirement is ADR-0049 work and this ratchet is not: closing a door makes a *dropped* key loud, it cannot make a *declared* one live — the same boundary `theme.zod.ts` records two rows up, met here from the other side. The removal also settled a second-order cost the strictness campaign could never have reached: `packages/lint`'s dashboard action-ref rule enforced ERROR-severity reference integrity on `widgets[].actionUrl`, its docblock calling the key "the per-widget button" and claiming to mirror a runtime dispatch that does not exist, so an author could FAIL A BUILD because a control that cannot render pointed at an action that also did not — an enforcement gate sustaining the very false affordance ADR-0049 wrote it to delete. That widget branch is gone, pinned. ⚠️ **`colorVariant`, the fifth dead key, is deliberately NOT retired here and this row must not be read as closing it**: the rewrite target the #4956 triage assumed (`options.colorVariant`) measured dead too — `options` only reaches a renderer through `componentSchema` on the INLINE path, and `dataset` is required on this schema, so every spec-authorable widget is dataset-bound and renders through `DatasetWidget`, which has no colour affordance at all. Moving the key there would relocate 16 authored sites from one dead slot to another and mint a second inert key. Returned for adjudication; `chartConfig`'s dashboard-face inertness (11 of 12 keys, #5175) is the same shape on the neighbouring slot | | `widget.zod.ts` | ~~authorable (p)~~ **no door** | **no authoring door (measured, #4001 批 16)** — the `(p)` resolved NEGATIVE for the whole file, the second such run after 批 13's five. Three independent measurements on 2026-08-04: (1) nothing under `packages/spec/src` imports this module except the `ui/index.ts` barrel, so no schema anywhere declares a carrier key for a widget shape — `field.widget` is a `z.string()` naming a registered *component* and has never referenced `WidgetManifest`; (2) a BFS over the in-memory Zod graph from all 24 metadata-type roots plus `defineStack` (4 766 nodes) reaches none of the six shapes, while `PageSchema` / `ObjectListViewSchema` resolve in the same run, a fresh `z.object` and a deliberate look-alike both resolve unreachable, and a synthetic carrier flips all six to reachable; (3) zero `.parse()` / `.safeParse()` in `objectstack`, `objectui` or `cloud` outside this file's own tests — objectui re-exports the inferred TYPES only and under different names (`RuntimeWidgetManifest` / `FieldWidgetComponentProps`, #4115 / #3161), and a `cloud` code search returns 0 for every symbol against a working index (`"@objectstack/spec"` → 345). ADR-0049 enforce-or-remove is **#5055**. ⚠️ **The campaign's own BFS said REACHABLE on the first run** — a false positive in the derived-clone bridge, filed as **#5056**: zod's `.describe()` returns a clone that SHARES the original `_zod.def`, so `WidgetManifestSchema.name` / `.label` (a described `SnakeCaseIdentifierSchema` / `I18nLabelSchema`) are def-identical to the same leaves on live schemas, and a bridge firing on ANY one shared property links two unrelated shapes. 2 shared keys of 20. The error is one-directional — it can only manufacture a door, i.e. it can only make a batch tighten something dead. Corrected to whole-shape overlap in `ui/door-reachability.testkit.ts` and pinned in `widget.test.ts` | | `page.zod.ts` | authorable | partially strict (ADR-0089) | -| `chart.zod.ts` | **mixed — 6 authorable, 2 no gate** | **5 strict as of #4001 批 15**, a sixth added at **#5022**; 2 deliberately left open. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which at the time was not a key this protocol declared anywhere** — it was an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately — and **closed at #5022**, which is the entry worth reading twice, because the fix is not the one the file's own prose implied. The gap was real (a live renderer capability with no declaration), but the two carriers that prose pointed at both measured DEAD on the dashboard metadata path: `widget.chartConfig.drillDown` is read by nothing (`DashboardRenderer` never looks at `chartConfig`; `DatasetWidget` forwards exactly one key out of it, `showLegend`), and `widget.options.drillDown` is read only inside `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach — `dataset` is required, so `datasetBound` is always true and that component schema is discarded unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and reads no drill config at all, which the platform's own docs had already said (`content/docs/ui/dashboards.mdx`: *there is no per-widget drill configuration in the dataset form*) while this ledger row pointed authors at the `options` bag. So `drillDown` was declared as `ChartDrillDownSchema` at the ONE surface measured to read it — the react tier's `` prop, published through `react-blocks.ts`'s interaction overlay rather than through `ChartConfigSchema`, precisely so the dashboard surface does not inherit an inert key. The shape is the honest six (`enabled`/`filter`/`title`/`target`/`columns`/`maxRows`); objectui's wider renderer-side `DrillDownConfig` (`mode`/`report`/`view`/`sort`, and a `navigate` target) was NOT copied — a chart reads none of them and two are read by no widget at all (objectui#3354) — and each absent key is a `guidance` entry saying so rather than a rename. Two second-order findings came out of the same measurement and are filed, not fixed here: **#5175** (`chartConfig` delivers 1 of its 12 keys on the dashboard path, and `liveness/dashboard.json` records evidence that overstates it) and **objectui#3354**. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | +| `chart.zod.ts` | **mixed — 8 authorable** (~~2 no gate~~ **gate wired at #5020**) | **5 strict as of #4001 批 15**, a sixth added at **#5022**; 2 still open, but no longer `no gate` — see the #5020 note at the end of this cell. `ChartConfigSchema` / `ChartAxis` / `ChartSeries` / `ChartAnnotation` / `ChartInteraction` are `root-graph`-reachable from the `dashboard` and `report` metadata roots (`DashboardWidget.chartConfig`, `ReportChartSchema`), so they are judged on the stored-metadata path and are now closed. **`ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are NOT**, and this is the batch's real finding. They are not 批 13's no-door case — their carrier is LIVE: `aggregate` is a real authorable prop on the react tier's `` (ADR-0081), published in the generated react-blocks contract, and objectui's `ObjectChart` reads `schema.aggregate` to run the query. What is missing is the PARSE: neither schema is reachable from any metadata-type root or from `ObjectStackSchema` (both `UNREACHABLE` in the run where the five above come back `root-graph`), nothing in the three repos calls `.parse()` on them outside this file's unit tests, and the gate that DOES judge an authored `aggregate` — the react-page publish lint — re-derives the rules by hand (`CHART_FUNCTIONS`, the count/field requirement, the result-column naming) and never checks unknown keys. `react-blocks.ts` publishes the prop as a hand-written TYPE STRING; the Zod schema beside it is not what the contract is generated from. So `groupby` / `dateGranularty` are silently dropped today and would go on being silently dropped after a `strictObject` here — `.strict()` is a property of a parse. A fourth class, **`no gate`**: carrier live, parse absent. Distinct from `no door` (批 13), where the carrier itself does not exist. The contract-first fix is to make the publish gate PARSE the schema instead of re-deriving it — a `packages/lint` change, filed rather than smuggled into a spec strictness batch. Recorded in three places (schema-adjacent comment, test pin incl. a standing BFS assertion that goes red the day a carrier key appears, this row). ✅ **That fix landed at #5020, and this row's `no gate` verdict is spent — the two sites are now `authorable`** (the second half of the `Class` cell above; the strip row further down carries the same flip). The publish gate calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and `CHART_FUNCTIONS` plus the hand-written count/field twin are DELETED, so the vocabulary and the refinement are single-source again. Read the flip precisely, because it is the class's first worked example and the distinction is the whole value of having added `no gate`: what changed is the PARSE, not the posture. Both schemas are still STRIP, so `groupby` / `dateGranularty` are still dropped silently — wiring the parse is the *precondition* for closing them, not the closing, and the closing is **#5583** (a sub-issue of the campaign, where the two `chart.test.ts` STRIP pins invert). #5020 also pinned today's tolerance out loud in `validate-react-page-props.test.ts` so a wired gate cannot be mistaken for a closed door — the #4583 shape, guarded from the other side. One severity note that belongs in this ledger because it is a *declared ≠ enforced* judgement, not a lint detail: an absent `groupBy` reports at **`warning`**, alone among the graded violations, because the schema and the published react-blocks type declare it required while objectui's renderer honours its absence (`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own `chartAggregateCategoryKey` documents the ungrouped single-row result. Gating it would enforce a declaration the platform does not itself keep; which of the two moves is #5583's product question. ⚠️ One correction shipped with the tightening: the `clickAction` migration text #3752 wrote into this file prescribed **`drillDown`, which at the time was not a key this protocol declared anywhere** — it was an untyped `(schema as any).drillDown` read inside objectui's `ObjectChart`. Promoting that sentence into a strict rejection would have handed an author the platform's authority for a key the same gate then rejects: finding 7, third occurrence, this time caught before shipping. The prose and the tombstone now name `onSegmentClick` / `ReportSchema.drilldown` / the widget's `options` bag, all of which exist. Filed separately — and **closed at #5022**, which is the entry worth reading twice, because the fix is not the one the file's own prose implied. The gap was real (a live renderer capability with no declaration), but the two carriers that prose pointed at both measured DEAD on the dashboard metadata path: `widget.chartConfig.drillDown` is read by nothing (`DashboardRenderer` never looks at `chartConfig`; `DatasetWidget` forwards exactly one key out of it, `showLegend`), and `widget.options.drillDown` is read only inside `DashboardRenderer`'s legacy `isObjectProvider` branch, which a spec-legal v17 widget cannot reach — `dataset` is required, so `datasetBound` is always true and that component schema is discarded unrendered. An ADR-0021 dataset-bound widget drills through the semantic layer and reads no drill config at all, which the platform's own docs had already said (`content/docs/ui/dashboards.mdx`: *there is no per-widget drill configuration in the dataset form*) while this ledger row pointed authors at the `options` bag. So `drillDown` was declared as `ChartDrillDownSchema` at the ONE surface measured to read it — the react tier's `` prop, published through `react-blocks.ts`'s interaction overlay rather than through `ChartConfigSchema`, precisely so the dashboard surface does not inherit an inert key. The shape is the honest six (`enabled`/`filter`/`title`/`target`/`columns`/`maxRows`); objectui's wider renderer-side `DrillDownConfig` (`mode`/`report`/`view`/`sort`, and a `navigate` target) was NOT copied — a chart reads none of them and two are read by no widget at all (objectui#3354) — and each absent key is a `guidance` entry saying so rather than a rename. Two second-order findings came out of the same measurement and are filed, not fixed here: **#5175** (`chartConfig` delivers 1 of its 12 keys on the dashboard path, and `liveness/dashboard.json` records evidence that overstates it) and **objectui#3354**. **`chart` 6 → 7 at the re-measurement** — no schema changed: `ChartAggregateSchema` is written `z\n .object({`, and the old counter's `z\.object\(` could not match across the line break | | `i18n.zod.ts` | **split** | **`i18n` SPLITS across two classes (measured, #4001 批 16)** and is the file this table's standing warning was about. The warning said "label shapes are wide-open records by design"; measurement says something more useful. `AriaPropsSchema` is a **real door and is closed** — carried as `aria:` on ~30 live shapes under six metadata-type roots (`ListViewSchema`, `PageSchema`, `PageComponentSchema`, `DashboardWidgetSchema`, `ChartConfigSchema`, `ActionSchema`, 20 SDUI component defs) and directly BFS-reachable. It was stripping in the wild: through the `view` root, `aria: { label: 'Accounts', describedBy: 'x' }` parsed CLEAN and returned `aria: {}`, so the accessible name existed in the source file and nowhere else. The other five (`I18nObject`, `PluralRule`, `NumberFormat`, `DateFormat`, `LocaleConfig`) are **no door** — no carrier, unreachable, zero parse in all three repos; ADR-0049 is #5055. Note `NumberFormat` / `DateFormat` DO have a carrier (`LocaleConfig.numberFormat` / `.dateFormat`) but the carrier is itself doorless, so the subtree is `no door`, not `no gate`. And the warning's own subject — the wide-open **record** level — was never one of the six sites: `I18nObject.params` is a `z.record` interpolation bag whose key space is whatever the message template names, so openness there is the contract and there was nothing to close. Pinned in `i18n.zod.ts`'s header, in `i18n.test.ts`, and here | | `responsive.zod.ts` | authorable | **strict as of #4001 批 13** — all four sites (`ResponsiveConfig`, `ResponsiveStyles`, and the two per-breakpoint maps). This is the one file of batch 13's six whose `(p)` resolved POSITIVE, and it resolved on the graph rather than on the file's face: `page.components[].responsive` / `.responsiveStyles` put both shapes inside the `page` metadata-type root (`dashboard.widgets[].responsive` was the second carrier until #4876 retired it, same day). What the closure bought is the batch's whole argument in one parse — **`PageComponentSchema` has been `.strict()` since ADR-0089 D3a and that never reached these blocks**, so `{ type:'element:text', responsiveStyles: { lg: {…} }, responsive: { colums: {…}, hideOn: [] } }` parsed CLEAN and returned `responsiveStyles: {}, responsive: {}` — every styling and layout instruction the author wrote, gone, reported valid. A strict shell over strip-mode children is a closed surface's silhouette, not a closed surface. The curation is the file's real hazard rather than typos: it carries TWO breakpoint vocabularies sixteen lines apart on the same component (`responsiveStyles`' `large`/`medium`/`small`/`xsmall`, ADR-0065, against `responsive`'s Tailwind `xs`…`2xl`), so the aliases run BOTH ways between them and are anchored to the named sibling, not to edit distance — batch 12's method, and the only thing that can answer `lg` → `large`. Two entries had to be measured rather than reasoned: `{ columns: { large: 4, lg: 3 } }` used to keep HALF the map (the node laid out, at the wrong width, on breakpoints the author never named — worse than a total loss, which is at least visible); and `hideOn` → `hiddenOn` needed a hand-written alias because the distance fallback provably cannot reach it — it lowercases the input but not the candidates, so a capital in a declared key costs an extra edit against a budget of 2, and the all-lowercase `hiddenon` resolves while the correctly-cased `hideOn` does not. That asymmetry is general to camelCase keys, i.e. to most of the spec, and is filed as **#4990**. `StyleMapSchema` stays deliberately OPEN (its key space is every CSS property; objectui's `declarations()` emits whatever it is handed) — recorded in the schema JSDoc, in a test pin, and in this row | | `dataset.zod.ts` | authorable | **strict as of #4001 批 14 — 0 strip sites remain.** `DatasetSchema` was strict from the ADR-0021 cutover while the two shapes carrying the actual semantic contract — `DatasetDimension`, `DatasetMeasure` (+ `.derived`) — were not. Curated against the sibling this module's own header names, `data/analytics.zod.ts`'s Cube layer: a Cube metric's `type` IS its aggregation, so `{ name: 'revenue', type: 'sum', field: 'amount' }` parsed clean and computed a `count`; `sql` gets guidance rather than an alias, because aiming `SUM(amount)` at `field` is finding 7's trap | @@ -846,7 +846,7 @@ next person to open that file will look. | `component.zod.ts` | **no gate** | ⛔ **not strictness work** — measured at 批 17 as having no parse at all: BFS-unreachable from every metadata root (all 52 targets, controls green in the same run), zero production `.parse()` sites in the three repos, and an unknown key inside `components[].properties` demonstrably survives the live `definePage()` door. The carrier (`PageComponentSchema.properties`) is live but is `z.record(z.string(), z.unknown())` — ADR-0089 D3a strictness does not recurse into it. Closing these 29 sites would gate nothing (#4583). Blocked on wiring the parse at the carrier — **#5068**. See the triage row for the full measurement | | `view.zod.ts` | mixed · 1 authorable, 2 wire | **15 of 20 closed at #4001 批 18**, a sixteenth (`UserFiltersSchema`) at **#5073** once its protocol blocker was adjudicated, a seventeenth — `ViewFilterRuleSchema`, closed by an EARLIER wave — reopened at **#5114**, and then the file's last authoring debt cleared at **#5074**, which closed `ViewItemSchema` (×2 arms), `ListView.sort` AND `ViewFilterRuleSchema` in one structural change. **The strip count went 5 → 3, and the arithmetic is the finding, not the number: FOUR sites closed and TWO were ADDED** — the two arms of the new `ViewItemWireSchema`, which are strip BY DESIGN. That is why this row's Class cell is now a split (`1 authorable, 2 wire`) rather than a smaller `authorable` count: the wire contract that used to live on "the member nobody closed" now has a name, and this map measures posture, not intent. Closed: `ViewDataSchema`'s four provider arms, `UserFilterField.options`, `GanttQuickFilter.options`, `GanttConfig.tooltipFields`, `ListView.conditionalFormatting` / `.emptyState`, `FormFieldBase.keyField`, `FormView.subforms`, and `submitBehavior`'s four arms. Reachability was measured, not assumed: a BFS from all 24 metadata-type roots plus `ObjectStackSchema` resolves every one `root-graph`, with `ViewSchema`/`FormViewSchema`/`ViewItemSchema`/`PageSchema` as positive controls and 批 13's no-door shapes UNREACHABLE **in the same run** — and the instrument had to be fixed first: `lazySchema` returns a Proxy, but a carrier writes `X.optional()`, which RESOLVES it, so the closure holds the real instance and comparing the Proxy alone false-negatived `ViewDataSchema` (caught by cross-checking its two literal carrier keys, not by trusting the reading). ⚠️ **Re-checked against #5056**: every 批 18 target is `root-graph` by **identity**, so **none** of the fifteen rests on the `derived-clone` bridge that 批 16 found can mark a dead shape reachable. The one `derived-clone` verdict in the run is `ListViewSchema` — a positive CONTROL, not a target, and independently identity-reachable via `ObjectListViewSchema`. Every closed shape also has a literal carrier key in this file and a named parse door (`defineView` / `defineViewItem` / the `view` metadata-type schema / objectui's `GanttConfigSchema.safeParse` at `plugin-gantt/src/ObjectGantt.tsx:408`) — the strong-evidence class #5056 leaves standing. ⚠️ **`ListView.sort` was closed, REVERTED, and closed again at #5074 — the round trip is the file's most useful finding.** It carried `direction → order`, the #4721 alias for the identical tuple (`{field, direction:'desc'}` parsed to `{field, order:'asc'}` — a silently REVERSED sort). The full suite then failed one case: `view-metadata-schema.test.ts` pins `sort: [{ id, field, order }]` as the exact body a console column-sort PUT persists, and objectui stamps that `id` per row (`components/src/custom/sort-builder.tsx:68`/`:94`, `crypto.randomUUID()`). **The mechanism governs every nested block in this file and is the opposite of what the union's own comment implies: `.strip()` does NOT recurse.** `ViewMetadataSchema` rescues Studio's round-trip keys by making its flattened members `.strip()`, but that re-opens the TOP level only — a nested block closed inside `ListViewSchema` is still reached through that member, so a console-stamped key inside it becomes a 422 regardless. `id` was deliberately NOT declared to silence it: it is a React list key, and declaring it would put a UI artifact on the authorable surface and tell an AI author to emit one. **#5074 supplied the missing half and the shape is now CLOSED**: the write door removes the declared decoration vocabulary (`VIEW_CONSOLE_ROW_DECORATIONS` / `stripViewConsoleDecorations`, the mirror of `stripReadDecorations`) BEFORE the union runs, so the opening is recursive-effective where a member-level `.strip()` can never be, and the authoring surface never grew the key. The `direction → order` alias came back with it. Curation on what DID close is anchored to named siblings: an option `count` gets a wrong-layer pointer to `showCount` because objectui COMPUTES it per render; and a bare `name` on the `object` data source is deliberately NOT aliased — it is a real key on the view ITEM, so a rename would be finding 7 again. `submitBehavior` became a `discriminatedUnion` on the `kind` literal it already required: as a plain union of four strict members the rejection is an `invalid_union` whose prescription #5014 measured the renderers flattening away. ⚠️ **`GanttConfigSchema` / `TreeConfigSchema` are `strictObject(…).passthrough()`** — open at the parent by design, and this ledger's own counter used to read them as `strict`, because `postureOf` returned early on the `strictObject` idiom instead of walking the chain. **Fixed at #5072**: the idiom now seeds the initial posture and the chain always runs, so the two read `passthrough` and the directory's strict count drops by 2. The strip count was never affected — neither posture is strip — so this row's numbers do not move. **`UserFiltersSchema` is CLOSED as of #5073, and it is the one site in this file whose blocker was never a strictness question.** Closing it would have 422'd `allowAddTab` — a key objectui's renderer reads (`plugin-list/src/UserFilters.tsx:182`/`:742`) and the spec never declared; because `saveMetaItem` validates but persists the ORIGINAL body, the stripped key still reached the renderer, so the capability WORKED and closing would have removed it rather than making a silent failure loud. 批 18 stopped and filed rather than guessing, and the maintainer adjudicated **promote, then close, in one PR** (2026-08-04): `allowAddTab` is now DECLARED here, so the capability is discoverable from the contract (JSON Schema / Studio SchemaForm / an AI author) instead of living in one React file, and the shape closes behind it with no intermediate state. The rejected option was `SANCTIONED_LOCAL` in objectui, which would have made spec and objectui two sources of truth for one contract — the fork #2231's derive-by-reference exists to prevent (PD#12) — and would have taught authors to delete a working key with a rejection that was itself "correct" (finding 7). Two details the close is worth remembering for. **(a)** The promotion is scoped to what the renderer really does: the add-tab button objectui renders carries no click handler, so `allowAddTab` declares that the affordance RENDERS and deliberately says nothing about creating presets — a `.describe()` promising more would be PD#10's advertise-what-you-don't-deliver, and the renderer gap is filed as **#5236**. **(b)** The 批 6e reliance question resolved exactly as predicted — `ObjectUserFiltersSchema` is `.omit()`ed off this base and `.omit()` inherits posture, so the pin flipped from "drops" to "rejects", which is wanted (the CLI lint `validate-list-view-mode.ts` was already reporting these) — but inheriting the posture also inherits the base's ERROR MAP, whose `knownKeys` were read from the base shape and therefore still listed the omitted keys. Measured on the flip: `tab` was answered *"Did you mean `tab` → `tabs`?"*, steering the author at the one key that surface refuses — finding 7 produced by the fix for finding 7. So the object variant now carries its own map built over the OMITTED shape (the shape still derived by `.omit()`, so #2231 holds), with `guidance` pointing all three page-only keys at `listViews`. **⚠️ #5074 — the authoring/wire SPLIT, and the row's headline.** `ViewItemSchema` wore two contracts: the authoring gate (`defineViewItem`, objectui's view-create form, which validates `createBuildBody`'s output against the real spec schema) and member 1 of `ViewMetadataSchema`, the union `saveMetaItem` validates every persisted `view` body against. The wire role was measured, not inferred — objectui's pin control PUTs `{...storedItem, isPinned}` (`ObjectView.tsx:882` → `data-objectstack/src/index.ts:2801`); a stored ViewItem record carries `viewKind` AND `config`, so the merged body lands on member 1 (the flattened members are excluded by their `config: z.undefined()` guard) and closing the one schema would have 422'd pinning a saved view. The maintainer ruled **split** (2026-08-04), and the two-axis reasoning is worth keeping: `defineViewItem({name, object, viewKind, confg: {…}})` — one letter — used to strip the typo and hand back a ViewItem with **no view configuration at all**, parsed clean, which is #1535's `workflows: [...]` replayed on the file's densest authoring surface. `ViewItemSchema` is now `strictObject` on both arms; `ViewItemWireSchema` is the `.strip()` wire variant, built from the SAME `viewItemArmShape()` (derive-by-reference, #2231 — a `discriminatedUnion` cannot be `.extend()`ed, so sharing the shape factory is what keeps one contract from becoming two transcriptions), and `isPinned`/`sortOrder` are DECLARED on it — an explicit home, instead of surviving because nobody closed the member. **The scope addendum's hard requirement was recursive-effective openness, and that is the part a posture flip could not deliver.** `.strip()` re-opens a member's TOP level only, so the two console-decorated NESTED blocks (`ListView.sort[].id`, `ViewFilterRule.id`) were still reached at full strictness through it. The route taken is the addendum's second sanctioned one: a declared decoration vocabulary stripped before validation, at the wire door, reaching every carrier at every depth — including ones added later, which a hand-maintained parallel wire tree would not. It is deliberately NOT a second schema tree (PD#12's fork) and deliberately NOT a declared `id` (批 18 Q1's two-axis rejection: a React list key on the authoring surface teaches AI authors to emit UUIDs). Two landmines were named in the ruling and both are pinned in `view-authoring-wire-split.test.ts` §5: `z.toJSONSchema()` must still emit a four-member `anyOf` (the `/api/v1/meta/types/view` endpoint feeds Studio's SchemaForm from it — it does; a pipe converts to its output side, asserted in BOTH io directions), and the `lazySchema` Proxy's ADR-0089 D3a crash (`Cannot set properties of undefined (setting 'ref')`) must not recur under a pipe-rooted lazy schema — it does not, and each new schema is converted directly rather than only through its parent. **One real hazard the change surfaced, fixed in the same PR:** a `z.preprocess` at a registered root put TWO gate walkers into the exact blind spot #4488 had already found and fixed in `check-liveness.mts` — `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` both unwrapped a pipe via `def.in`, which for a preprocess is the TRANSFORM, so each reported `view` as *not key-bearing* and silently stopped covering it. Caught by their own coverage assertions (`lintables.length >= 1`, `root schema is not key-bearing`), which is precisely what those assertions exist for; both now prefer whichever side is not the transform. **A gate going quiet is worse than a gate failing** — and the pattern will recur on the next preprocess-rooted registration, so it is recorded here rather than only in the diff. **Still open, one site, measured:** `FormFieldBaseSchema` — a module-private BASE whose sole consumer already applies `.strict()` plus the ADR-0089 `strictVisibilityError` map; the door is closed, the ledger counts the base. The two remaining strip sites beyond it are `ViewItemWireSchema`'s arms, which are `wire` by design and are not debt. `ViewFilterRuleSchema` — **the same wire contamination, one block over, and it was already LIVE on `main`** (#5114): closed by an earlier wave, while objectui's filter builder stamps `id: crypto.randomUUID()` on every row it writes (`components/src/custom/filter-builder.tsx:228`, re-stamped on read-back at `plugin-view/src/config/view-config-utils.ts:146`/`:160`), and `saveMetaItem` persists the AUTHORED body verbatim — so saving a filter from the console 422'd, on all three paths including the flattened overlay that is the body actually PUT. Reopened as a p1 hotfix; `id` deliberately NOT declared, for the reason given for `sort` above. **That reopen was explicitly PROVISIONAL — "pending #5074" — and #5074 retired it rather than leaving it standing: the shape is CLOSED again, by the same decoration strip that closed `sort`, so the authoring gate rejects `id` by name while the console's own three paths still parse.** Its pin file now asserts the split per door, and the direction is the INVERTED one worth flagging to the next reader: probes 1/3 and 2/3 were GREEN before #5074 and are RED after (that IS the close), while 3/3 — the body the console actually PUTs — is green on BOTH sides and must stay so; a file that only asserted "the console body parses" would have passed unchanged through a change that quietly declared `id` as authorable. Two details worth keeping: the overlay path's rejection surfaces as `invalid_union` / *"Invalid input"* — the #5014 flattening, so the key that caused it is not in the message the author sees, which is why this sat on `main` unnoticed; and the reopening was verified in BOTH directions (re-close it and 7 assertions in `view-filter-rule-wire-id.test.ts` go red, while that file's two mechanism CONTROLS — top-level aux key rides, nested `emptyState` still rejects — stay green either way, which is what makes them controls). #5074's scope addendum named this site; the gate it was waiting on — a wire opening that REACHES a nested block — landed with it. Each verdict is recorded in three places (schema JSDoc + `view-strictness-batch18.test.ts` / `view-filter-rule-wire-id.test.ts` + this row) | | `widget.zod.ts` | **no door** | ⛔ **not strictness work** — the whole file measured unreachable from every authoring root (#4001 批 16), with no carrier key and zero parse in all three repos. ADR-0049 triage is **#5055**. See the triage row above, including why the campaign's own BFS said otherwise first (**#5056**) | -| `chart.zod.ts` | **no gate** | `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two are NOT unfinished work — their carrier (``) is live but nothing parses them, so closing them would gate nothing (#4583). Blocked on wiring the react-page publish gate to parse the schema instead of re-deriving it — see the triage row. **#5022 added an eighth site to this file, and it is the one worth copying**: `ChartDrillDownSchema` arrived with its gate already wired — `packages/lint/src/validate-react-page-props.ts` PARSES it against a static `drillDown={{…}}` literal instead of re-deriving the rules the way `CHART_FUNCTIONS` does for `aggregate` beside it. That is exactly the fix this row is blocked on, demonstrated on one key; the two sites here are unchanged because their prop is `aggregate`, not `drillDown` | +| `chart.zod.ts` | **authorable** | **was `no gate` until #5020** (the cell carries one verdict on purpose — it is the machine-readable input to the generated subtotal, so the history lives here in the evidence). `ChartAggregateSchema` + `ChartGroupBySchema`'s object arm. Config / axis / series / annotation / interaction closed at 批 15; these two were held OUT of the ratchet as `no gate` — carrier live, no parse — because closing them would have gated nothing (#4583). **#5020 wired the parse, so the hold is over and these two are ordinary strictness work again.** `packages/lint/src/validate-react-page-props.ts` now calls `ChartAggregateSchema.safeParse()` on a static `aggregate={{…}}` literal, and the hand-derived `CHART_FUNCTIONS` list + count/field refinement twin are deleted. That is the path **#5022 demonstrated on one key** and this row was blocked on: `ChartDrillDownSchema` arrived with its gate already wired, parsing instead of re-deriving, while `aggregate` beside it did the opposite. ⚠️ **The flip is `no gate` → `authorable`, NOT → closed.** Both sites still STRIP: the parse the gate runs drops `groupby` / `dateGranularty` rather than reporting them, so the ADR-0078 failure mode survives until the posture changes. Converting the two object arms to `strictObject` is **#5583** (Blocked-by resolved; a sub-issue of #4001), which is also where the two `chart.test.ts` "still STRIPS — deliberate" pins invert and where the one product question lands — `groupBy` is declared REQUIRED here and in the published react-blocks type while the renderer honours its absence, so #5020's gate reports that single case at `warning` instead of gating a shape the platform delivers | | `i18n.zod.ts` | **split** · 5 no door | **批 16 closed the one real door**: `AriaPropsSchema` (`strictObject`, carried as `aria:` on ~30 shapes under six metadata-type roots — it was returning `aria: {}` for a legacy-spelled block). The 5 left are `I18nObject` / `PluralRule` / `NumberFormat` / `DateFormat` / `LocaleConfig`, all **no door** (#5055) — ⛔ **do not close them**. This row shrinks without disappearing, the third such in the ledger after `flow` (批 11) and `etl` (批 12): the reverse pin fires on ZERO, so a row parked at a deliberate floor looks exactly like a row nobody finished, and only the `Class` column separates them | | `app.zod.ts` | verify | **批 19 ran the check and it came back NEGATIVE — no posture change, and the row's `Class` is held at `verify` deliberately (see below).** `BaseNavItemSchema`. The instruction here was to confirm the members' strictness was not already covering it before touching; it is, and the premise this row carried was wrong twice. (1) **The members do not `.extend()` the base — they spread `...BaseNavItemSchema.shape`.** That is a different mechanism, and the difference is the whole of finding 16: `.extend()` clones INHERIT the base's posture (which is how closing two `view` authoring schemas silently closed the Studio round-trip overlay), while a `...shape` spread copies the per-key schemas into a FRESH `z.object` whose posture is its own. Measured in both directions rather than read off the source, because *"closing the base closes the members"* and *"closing the base is a no-op"* are opposite claims: `strictBase.extend({…})` rejects an unknown key, `z.object({...strictBase.shape})` accepts it, `z.object({...openBase.shape}).strict()` rejects it. (2) **All nine branches already apply their own `.strict()`** with the curated `navItemUnknownKeyError` — asserted per branch through the real door (`AppSchema.navigation`, a `discriminatedUnion` on `type`), with a positive control (every base-contributed key, incl. `requiresService` which no branch declares itself, is ACCEPTED) and a negative control (an undeclared key is REJECTED) in the same run. The base is also module-private and has zero `.parse()` anywhere, so `.strict()` here would be a property of a parse that does not exist. Closing it is therefore a guaranteed no-op, and #4583 is explicit that a no-op closure is not neutral. ⚠️ **The open question is the VOCABULARY, not the measurement** — which is why the `Class` cell was not changed, since it is machine-read and a guess here would be published as a confident subtotal. The two-axis table above resolves carrier-absent + parse-absent to `no door`, whose prescribed follow-up is ADR-0049 retirement — and that prescription is *destructive* here: the vocabulary is fully ALIVE and fully GATED at nine consumers, so retiring the base would delete nine branches' shared keys. `no gate` is wrong for the mirror reason (the gate exists, at the members). `authorable` is the `FormFieldBaseSchema` precedent one row over in `view.zod.ts` — but that base really is `.extend()`ed, so closing it WOULD change behaviour, and calling this one `authorable` invites exactly the later sweep that "finishes the job" on a shape nothing parses. None of the eight enumerated verdicts is honest for a shape that is neither a door nor dead, and adding a ninth changes a machine-read contract — so the decision is the maintainer's (**#5249**). Recorded in three places (the `BaseNavItemSchema` JSDoc + `app-strictness-batch19.test.ts` + this row); the pin includes a guard that fails if any branch ever stops rejecting unknown keys, which is the one change that would make this verdict need re-taking | @@ -988,10 +988,17 @@ remains open is overwhelmingly work for OTHER issues: every export in them was in-family and unreachable. The question is always asked per SCHEMA; the file is only the answer's unit when every schema in it answers the same way. -- **`no gate`** — `chart.zod.ts`'s remaining pair from 批 15, plus **all of +- **`no gate`** — ~~`chart.zod.ts`'s remaining pair from 批 15~~ **left this + class at #5020**, which wired the react-page publish gate to parse + `ChartAggregateSchema` instead of re-deriving it; the pair is `authorable` + again and its strictness half is #5583. Still here: **all of `component.zod.ts` from 批 17** (#5068). That single row is the campaign's largest reclassification and the reason this subtotal fell by 29 without one - site being closed. + site being closed. Note what #5020 makes visible about the class as a whole — + leaving it is a TWO-step move, and only the first step is the carrier's own + issue: wire the parse (the `no gate` cure), then close the posture (ordinary + ratchet work, on its own issue). A single PR doing both would land a strict + rejection nobody had yet seen a gate produce. Read the difference before acting on either: they imply OPPOSITE follow-ups (`no door` → ADR-0049 enforce-or-remove; `no gate` → wire the parse at the diff --git a/packages/lint/src/validate-react-page-props.test.ts b/packages/lint/src/validate-react-page-props.test.ts index 940c63a8fa..9ebc1029a6 100644 --- a/packages/lint/src/validate-react-page-props.test.ts +++ b/packages/lint/src/validate-react-page-props.test.ts @@ -14,6 +14,10 @@ import { SEARCHABLE_FIELD_UNSEARCHABLE, } from './validate-searchable-fields.js'; import { PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js'; +// The gate PARSES `ChartAggregateSchema` since #5020, so the function +// vocabulary lives in the schema and nowhere in the rule. Imported here to pin +// the test table against it — see the `aggregate` block near the bottom. +import { ChartAggregateFunctionSchema } from '@objectstack/spec/ui'; const page = (source: string) => ({ pages: [{ name: 'p', kind: 'react', source }] }); @@ -107,6 +111,9 @@ describe('validateReactPageProps — bindings (#3701)', () => { const f = validateReactPageProps( chartPage(chart(`objectName="invoice" aggregate={{ field: 'total', function: 'median', groupBy: 'status' }}`)), ); + // Since #5020 the vocabulary in this message comes from the schema's enum + // rather than a local copy, and the author's own value is echoed back from + // the input — the one part of the hand-rolled message zod does not produce. expect(f.some((x) => x.rule === REACT_CHART_AGGREGATE_INVALID && /median/.test(x.message))).toBe(true); }); @@ -114,7 +121,11 @@ describe('validateReactPageProps — bindings (#3701)', () => { const f = validateReactPageProps( chartPage(chart(`objectName="invoice" aggregate={{ function: 'sum', groupBy: 'status' }}`)), ); - expect(f.some((x) => x.rule === REACT_CHART_AGGREGATE_INVALID && /no "field"/.test(x.message))).toBe(true); + // The schema's OWN refinement message now reaches the author verbatim + // (#5020) — this rule no longer keeps a second copy of the rule to phrase. + expect( + f.some((x) => x.rule === REACT_CHART_AGGREGATE_INVALID && /needs a "field" to aggregate/.test(x.message)), + ).toBe(true); }); it('accepts a fieldless count', () => { @@ -778,9 +789,11 @@ describe('validateReactPageProps — resolve per child obj // // This is the half that makes `ChartDrillDownSchema` more than a type. The // schema is `.strict()`, but `.strict()` is a property of a PARSE — before -// this gate nothing on the react surface called one, which is exactly the -// `no gate` verdict the strictness ledger records for `aggregate` two props -// over. The rule parses instead of re-deriving, so the surface name, the +// this gate nothing on the react surface called one, which was exactly the +// `no gate` verdict the strictness ledger recorded for `aggregate` two props +// over until #5020 wired that parse too (its own block below; the difference +// that remains is posture — `aggregate`'s schema still STRIPS, so its +// unknown-key half waits on #5583). The rule parses instead of re-deriving, so the surface name, the // near-key guidance and the `target` union all arrive without being restated // here — which is why #5435's widening needed no edit to the rule itself. // ───────────────────────────────────────────────────────────────────────── @@ -878,3 +891,245 @@ describe('validateReactPageProps — (#5022)', () => { expect(f.some((x) => x.rule === REACT_CHART_DRILLDOWN_INVALID)).toBe(true); }); }); + +// ───────────────────────────────────────────────────────────────────────── +// — the declared shape, PARSED (#5020) +// +// The other half of what #5022 did for `drillDown`. Until this issue the gate +// re-derived `ChartAggregateSchema`: a local `CHART_FUNCTIONS` copy of the +// function vocabulary and a hand-written twin of the count/field refinement, +// each free to drift from the schema, and — since unknown-key handling is a +// property of a PARSE — no unknown-key check at all. +// +// Read the three groups below as one statement, because any one alone would be +// misleading: +// +// 1. VOCABULARY, exhaustively green through the gate, with the enum pinned to +// the schema's own so the table cannot silently under-cover it. This is +// what replaces the deleted constant: the vocabulary is no longer restated +// in the rule, so the test is where "all five functions are accepted" is +// asserted. +// 2. SEVERITY GRADING (#5020 R2) — every violation the schema, the published +// react-blocks type and objectui's renderer agree on gates at `error`; +// an absent `groupBy` is a `warning`, because the renderer honours the +// absence and the ledger's rule is "declare before you gate". +// 3. THE GAP, pinned open. Both schemas are STRIP-posture, so wiring the +// parse did NOT close the unknown-key hole `groupby` walks through — +// #5583 is the spec-side half. Asserting the tolerance out loud is the +// only thing that stops this gate reading as a closed door (#4583). +// ───────────────────────────────────────────────────────────────────────── + +describe('validateReactPageProps — PARSED (#5020)', () => { + const agg = (literal: string, objectName = 'invoice') => + chartPage(chart(`objectName="${objectName}" aggregate={${literal}}`)); + const aggFindings = (literal: string) => + validateReactPageProps(agg(literal)).filter((x) => x.rule === REACT_CHART_AGGREGATE_INVALID); + + // The vocabulary UNDER TEST. Restating it *here* is the point — the rule no + // longer does, and the pin below fails the day the schema's enum and this + // list disagree, so the table can never quietly cover less than the contract. + const FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; + const GRANULARITIES = ['day', 'week', 'month', 'quarter', 'year'] as const; + + it('covers exactly the function vocabulary the schema declares', () => { + expect([...ChartAggregateFunctionSchema.options]).toEqual([...FUNCTIONS]); + }); + + // Every function against every accepted `groupBy` form: the bare-field-name + // arm, the structured arm, and the structured arm at each date granularity. + // `count` is the one function that may omit `field`, so it is exercised both + // ways; the rest carry one. + const GREEN: Array<[string, string]> = []; + for (const fn of FUNCTIONS) { + const field = fn === 'count' ? '' : `field: 'total', `; + GREEN.push([`${fn} / bare groupBy`, `{ ${field}function: '${fn}', groupBy: 'status' }`]); + GREEN.push([`${fn} / structured groupBy`, `{ ${field}function: '${fn}', groupBy: { field: 'closed_at' } }`]); + for (const g of GRANULARITIES) { + GREEN.push([ + `${fn} / groupBy bucketed by ${g}`, + `{ ${field}function: '${fn}', groupBy: { field: 'closed_at', dateGranularity: '${g}' } }`, + ]); + } + } + GREEN.push(['count WITH an explicit field', `{ field: 'total', function: 'count', groupBy: 'status' }`]); + GREEN.push([ + 'groupBy alias (the projected category column)', + `{ function: 'count', groupBy: { field: 'closed_at', alias: 'closed_at', dateGranularity: 'month' } }`, + ]); + + it.each(GREEN)('accepts %s', (_label, literal) => { + expect(aggFindings(literal)).toEqual([]); + }); + + // ── R2: what gates at `error` ────────────────────────────────────────── + + it('gates a missing function — required in the schema, in the published type and in the renderer', () => { + // Previously invisible: the hand-rolled check only ran `if (fn)`, so an + // aggregate with no function at all passed the publish gate silently. + const hit = aggFindings(`{ field: 'total', groupBy: 'status' }`); + expect(hit.length).toBeGreaterThan(0); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('aggregate.function'); + expect(hit[0].message, 'the vocabulary comes from the enum, not from a local list').toContain('"count"'); + expect(hit[0].message, 'and absence is named as absence').toContain('nothing is set there'); + }); + + it('gates a function outside the enum and echoes the value back', () => { + const hit = aggFindings(`{ field: 'total', function: 'median', groupBy: 'status' }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('received "median"'); + }); + + it('gates a non-string field — a shape the old check silently ignored', () => { + const hit = aggFindings(`{ field: 42, function: 'sum', groupBy: 'status' }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('aggregate.field'); + expect(hit[0].message).toContain('expected string'); + }); + + it("gates sum/avg/min/max with no field, in the SCHEMA's own words", () => { + for (const fn of ['sum', 'avg', 'min', 'max']) { + const hit = aggFindings(`{ function: '${fn}', groupBy: 'status' }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain(`aggregate.function "${fn}" needs a "field" to aggregate`); + } + }); + + it('gates an aggregate that is not an object at all', () => { + for (const literal of ['true', `'count'`, '[]']) { + const hit = aggFindings(literal); + expect(hit.length, `aggregate={${literal}} must be reported`).toBeGreaterThan(0); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('configuration object'); + } + }); + + // ── R2: the one violation that only WARNS ────────────────────────────── + + it('WARNS on an absent groupBy and does not block — the renderer honours it', () => { + // `ChartAggregateSchema` and `react-blocks.ts` both declare `groupBy` + // required, but ObjectChart falls back to `xAxisKey` + // (`schema.aggregate?.groupBy || schema.xAxisKey`) and this protocol's own + // `chartAggregateCategoryKey` documents the ungrouped single-row result. + // Gating it would break a working authoring shape to enforce a declaration + // the platform does not keep; #5583 decides which of the two moves. + const hit = aggFindings(`{ function: 'count' }`); + expect(hit.length).toBe(1); + expect(hit[0].severity).toBe('warning'); + expect(hit[0].message).toContain('aggregate.groupBy is not set'); + expect(hit[0].hint).toContain('5583'); + expect( + validateReactPageProps(agg(`{ function: 'count' }`)).filter((x) => x.severity === 'error'), + 'nothing about this aggregate may gate the build', + ).toEqual([]); + }); + + it('still gates the REST of an aggregate whose groupBy is merely absent', () => { + // The severity split is per issue, not per aggregate: a bad `function` next + // to an absent `groupBy` gates, and both findings reach the author. + const hit = aggFindings(`{ function: 'median' }`); + expect(hit.filter((x) => x.severity === 'error').length, 'the function still gates').toBe(1); + expect(hit.filter((x) => x.severity === 'warning').length, 'the absence still warns').toBe(1); + }); + + it('gates a groupBy that is PRESENT and wrong — absence is the only tolerance', () => { + const hit = aggFindings(`{ function: 'count', groupBy: 42 }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('aggregate.groupBy'); + expect(hit[0].message).toContain('received 42'); + }); + + // ── The zod-4 union collapse, unpacked ───────────────────────────────── + + it("unpacks the union's arms so an author gets named messages, not just 'Invalid input'", () => { + // `groupBy` is `ChartGroupBySchema`, a union — and zod 4 reports a failed + // union as ONE `invalid_union` whose own message is the bare string + // "Invalid input", with the arm messages reachable only through + // `issue.errors`. Passing that through verbatim would say nothing an author + // can act on, which is the class of diagnostic this gate exists to replace. + const hit = aggFindings(`{ function: 'count', groupBy: { dateGranularity: 'day' } }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('no accepted form matched'); + expect(hit[0].message, "the structured arm's own complaint").toContain('field'); + expect(hit[0].message, 'the bare-field-name arm reports too').toContain('expected string'); + expect(hit[0].message, 'the collapsed message must not be the whole report').not.toBe( + 'aggregate.groupBy: Invalid input', + ); + }); + + it('unpacks a nested enum rejection with its vocabulary intact', () => { + const hit = aggFindings(`{ function: 'count', groupBy: { field: 'closed_at', dateGranularity: 'fortnight' } }`); + expect(hit[0].severity).toBe('error'); + expect(hit[0].message).toContain('dateGranularity'); + expect(hit[0].message).toContain('"quarter"'); + }); + + // ── The gap this PR does NOT close, pinned open (#5583) ───────────────── + + it('⚠️ STILL ACCEPTS an unknown key inside aggregate — the parse STRIPS it (#5583)', () => { + // NOT the desired end state. `ChartAggregateSchema` is a STRIP-posture + // `z.object()`, so `groupby` is silently dropped BY THE PARSE and this gate + // has nothing to report — the #4001 failure mode this issue set out to + // close survives one layer down. Wiring the parse was the precondition + // (`.strict()` is a property of a parse, and there was none); #5583 is the + // spec-side tightening, after which this assertion INVERTS and the finding + // arrives carrying the schema's named surface and rename suggestion. + // + // Asserted rather than left implicit so nobody reads this gate as a closed + // door: a rule that looks like it rejects `groupby` and does not is worse + // than one that visibly does not (#4583). + expect(aggFindings(`{ function: 'count', groupBy: 'status', groupby: 'status' }`)).toEqual([]); + + // …and the tolerance is pinned on a PARSED aggregate, not on a rule that + // happens to look at nothing. Without this half the assertion above would + // stay green for the wrong reason — an empty finding list proves nothing on + // its own (the #5046 trap). Same unknown key, plus one violation only the + // parse can report: exactly one finding comes back, and it is not about + // `groupby`. + const alsoBad = aggFindings(`{ function: 'kount', groupBy: 'status', groupby: 'status' }`); + expect(alsoBad.length, 'the parse ran').toBe(1); + expect(alsoBad[0].message).toContain('aggregate.function'); + // Only the schema's own rejection is worded this way — the deleted + // hand-rolled check said "is not an aggregation this chart can run" — so + // this is what makes the tolerance above a statement about a PARSED + // aggregate rather than one the rule never looked at. + expect(alsoBad[0].message, 'and the finding came from the schema').toContain('expected one of'); + // What #5583 adds, and what today's STRIP posture cannot produce. + expect(alsoBad[0].message, 'the stripped key is invisible to the gate — today').not.toContain( + 'Unrecognized key', + ); + }); + + it("⚠️ STILL ACCEPTS an unknown key inside a structured groupBy (#5583)", () => { + // The same hole one level deeper: `dateGranularty` is dropped by + // `ChartGroupBySchema`'s object arm, so the dates are never bucketed and + // the trend line is one flat segment — silently. + expect( + aggFindings(`{ function: 'count', groupBy: { field: 'closed_at', dateGranularty: 'month' } }`), + ).toEqual([]); + + // The same parse-proof as above, so this tolerance is not green merely + // because nothing reads the structured arm: plant the typo NEXT TO a value + // the arm does judge, and the arm's own rejection comes back while the + // unknown key stays invisible. + const alsoBad = aggFindings( + `{ function: 'count', groupBy: { field: 'closed_at', dateGranularty: 'month', dateGranularity: 'fortnight' } }`, + ); + expect(alsoBad.length, 'the structured arm was parsed').toBe(1); + expect(alsoBad[0].message).toContain('dateGranularity'); + // The typo DOES appear in this message — inside the echo of the author's own + // object — but only as data, never as a rejection. `Unrecognized key` is the + // sentence #5583 makes possible and today's STRIP posture cannot produce, so + // that is what this pin watches for. + expect(alsoBad[0].message, 'the stripped near-key is not REJECTED — today').not.toContain('Unrecognized key'); + }); + + // ── Unresolvable is not wrong (ADR-0072 D1) ──────────────────────────── + + it('says nothing about an aggregate assembled from variables', () => { + const f = validateReactPageProps( + chartPage(`function Page(){ const g = useG(); return ; }`), + ); + expect(f.filter((x) => x.rule === REACT_CHART_AGGREGATE_INVALID)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-react-page-props.ts b/packages/lint/src/validate-react-page-props.ts index 8cae99ffd9..3508542a48 100644 --- a/packages/lint/src/validate-react-page-props.ts +++ b/packages/lint/src/validate-react-page-props.ts @@ -39,6 +39,7 @@ import { REACT_BLOCKS, RECORD_CONTEXT_BLOCK_TAGS, REACT_RECORD_BLOCK_ALTERNATIVES, + ChartAggregateSchema, ChartDrillDownSchema, chartAggregateResultKeys, isRecordContextBlockType, @@ -298,7 +299,189 @@ function checkChartDrillDown( } } -const CHART_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const; +/** + * `` against `ChartAggregateSchema` (#5020). + * + * The second half of what #5022 started one prop over. This rule used to + * RE-DERIVE the aggregate's declaration: a local `CHART_FUNCTIONS` copy of the + * function vocabulary and a hand-written twin of the schema's count/field + * refinement. Two implementations of one contract that could drift apart + * independently, and — because unknown-key handling is a property of a PARSE, + * not of a list of `if`s — a gate that never checked for unknown keys at all. + * `groupby` for `groupBy` sailed straight through it, the aggregate degraded to + * ungrouped, and the chart drew axes and a single point with `build`/`validate` + * fully green (#4001's original failure mode, on the react tier). Parsing makes + * the schema the single source: the vocabulary, the refinement message and every + * key's type arrive from `packages/spec` with nothing restated here. + * + * ## What this does NOT yet close, and why the pin test says so out loud + * + * ⚠️ `ChartAggregateSchema` is still a STRIP-posture `z.object()` (and + * `ChartGroupBySchema`'s object arm with it), so an unknown key is *silently + * dropped by the parse* rather than reported. Wiring the parse is a + * precondition for closing that, not the closing itself: `.strict()` is a + * property of a parse, and until this commit there was no parse to make strict. + * The spec-side tightening is **#5583**, and + * `validate-react-page-props.test.ts` pins today's tolerance explicitly so this + * gate cannot be mistaken for one that already rejects `groupby` — a gate that + * READS like it closes a hole while leaving it open is the #4583 shape this + * campaign keeps paying for. + * + * ## Severity is not uniform, and the split is measured + * + * Everything the schema, the published react-blocks type and objectui's + * renderer agree on gates at `error` (declared = enforced): `function` present + * and in the enum, `field` a string, `aggregate` an object, and a non-`count` + * function carrying a `field`. **An absent `groupBy` is a `warning`**, alone + * among them: the schema and `react-blocks.ts` both declare it required, but the + * renderer HONOURS its absence (`ObjectChart.tsx`: `schema.aggregate?.groupBy || + * schema.xAxisKey`) and this protocol's own `chartAggregateCategoryKey` documents + * the ungrouped single-row result. Gating on it would break a working authoring + * shape to enforce a declaration the platform does not itself keep; whether the + * schema loosens or the renderer tightens is the product question on #5583. + */ +function checkChartAggregate( + raw: unknown, + push: (severity: ReactPropSeverity, rule: string, message: string, hint: string) => void, +): void { + if (raw === undefined || raw === NOT_STATIC) return; + if (!isRec(raw)) { + push( + 'error', + REACT_CHART_AGGREGATE_INVALID, + `aggregate must be a configuration object, not ${Array.isArray(raw) ? 'an array' : typeof raw}.`, + 'Write aggregate={{ function: "count", groupBy: "" }} — or bind data={…} instead to chart precomputed rows.', + ); + return; + } + + // Absence is judged on the INPUT, not on the issue shape: zod reports a + // missing `groupBy` as an `invalid_union` indistinguishable at a glance from a + // wrongly-typed one, and the two get different severities here. + const groupByAbsent = raw.groupBy === undefined; + if (groupByAbsent) { + push( + 'warning', + REACT_CHART_AGGREGATE_INVALID, + 'aggregate.groupBy is not set, so the aggregate returns ONE ungrouped row and the chart plots a single point.', + 'Add aggregate.groupBy (a field name, or { field, dateGranularity } to bucket dates) to give the chart a category axis. ' + + 'Deliberate single-value charts are tolerated at warning level for now: ChartAggregateSchema declares groupBy required while ObjectChart honours its absence by falling back to xAxisKey — objectstack#5583 decides which of the two moves.', + ); + } + + const parsed = ChartAggregateSchema.safeParse(raw); + if (parsed.success) return; + for (const issue of parsed.error.issues) { + // Already reported above, at warning severity and with the renderer's + // fallback explained — the raw union rejection would only repeat it as an + // error and re-gate what this rule deliberately does not gate. + if (groupByAbsent && issue.path[0] === 'groupBy') continue; + const at = issue.path.length ? `aggregate.${issue.path.join('.')}` : 'aggregate'; + push( + 'error', + REACT_CHART_AGGREGATE_INVALID, + `${at}: ${describeIssue(issue, raw)}`, + 'The aggregate is declared by ChartAggregateSchema (@objectstack/spec/ui) — the rejection above carries the fix.', + ); + } +} + +/** + * One rejected value's issue, rendered so an author can act on it. + * + * Two things zod 4 does not do for us, both measured against this schema rather + * than assumed: + * + * 1. **Union arms collapse.** A union's arm failures never reach + * `error.issues` — the whole union is reported as ONE `invalid_union` whose + * own `message` is the bare string `"Invalid input"`, with the named arm + * messages tucked inside `issue.errors` (one array per arm). Reporting it + * verbatim would tell an author only that *something* about `groupBy` is + * wrong, which is precisely the class of unhelpful diagnostic this gate + * exists to replace. `aggregate.groupBy` is a union + * (`ChartGroupBySchema` — bare field name or `{ field, dateGranularity?, + * alias? }`), so this is the common path, and it matters more after #5583: + * an `unrecognized_keys` raised inside the object arm collapses exactly the + * same way, so the unpacking is what will carry the strict rejection's + * named surface + rename suggestion to the author. + * 2. **The offending value is dropped.** `Invalid option: expected one of + * "count"|"sum"|…` never echoes what was actually written, and the + * hand-rolled check it replaces did (`aggregate.function "median" is not an + * aggregation…`). It is recovered from the INPUT by path — generic, and no + * contract knowledge restated here to do it. + */ +function describeIssue(issue: LintZodIssue, root: unknown, depth = 0): string { + const value = depth === 0 ? valueAtPath(root, issue.path) : undefined; + // Suppressed in the two cases where it would only repeat what the message + // already says: a `custom` refinement names the missing key itself, and zod's + // `invalid_type` text ends in `received ` of its own accord. What is + // left is where the value genuinely is missing from the diagnostic — the enum + // rejections and the collapsed `invalid_union` (whose message is just + // "Invalid input"). + const seen = + depth > 0 || issue.code === 'custom' || issue.message.includes('received ') + ? '' + : value === undefined + ? ' (nothing is set there)' + : ` (received ${preview(value)})`; + + // Deliberately NOT `Array.isArray(issue.errors)`: that narrows a + // `ReadonlyArray<…>` to `any[]` and silently drops the element type, which is + // the TS7006 trap AGENTS.md names — the arms below would then be `any`. + const armIssues = issue.code === 'invalid_union' ? issue.errors : undefined; + if (!armIssues || armIssues.length === 0) { + return `${issue.message}${seen}`; + } + + const arms = armIssues + .map((arm) => + arm + .map((inner) => { + const where = inner.path.length ? `${inner.path.join('.')} — ` : ''; + return `${where}${describeIssue(inner, root, depth + 1)}`; + }) + .join('; '), + ) + .filter((text) => text.length > 0); + if (arms.length === 0) return `${issue.message}${seen}`; + return ( + `${issue.message}${seen} — no accepted form matched: ` + + arms.map((text, i) => `(${i + 1}) ${text}`).join(' ') + ); +} + +/** + * The subset of a zod issue this file reads. Declared structurally rather than + * imported as `z.core.$ZodIssue` so `packages/lint` keeps its single spec + * dependency and does not take a direct zod one for two field reads. + */ +interface LintZodIssue { + readonly code: string; + readonly message: string; + readonly path: ReadonlyArray; + /** Present on `invalid_union` only: the arms' own issues, one array each. */ + readonly errors?: ReadonlyArray>; +} + +const valueAtPath = (root: unknown, path: ReadonlyArray): unknown => { + let cur: unknown = root; + for (const key of path) { + if (!isRec(cur) && !Array.isArray(cur)) return undefined; + cur = (cur as Record)[key]; + } + return cur; +}; + +/** The author's own value, short enough to sit inside a diagnostic. */ +const preview = (value: unknown): string => { + let text: string; + try { + text = JSON.stringify(value) ?? String(value); + } catch { + text = String(value); + } + return text.length > 80 ? `${text.slice(0, 77)}…` : text; +}; const isRec = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); @@ -328,15 +511,14 @@ function checkObjectChart( // independent of how the chart is bound: an inline `data={…}` chart drills // just as an aggregate-bound one does. // - // This is the one rule in this file that does not restate its schema's - // vocabulary in local constants. `CHART_FUNCTIONS` above is the alternative, - // and the strictness ledger's `chart.zod.ts` row already names it as the - // weakness: a gate that re-derives the rules cannot inherit the schema's - // unknown-key handling, so `groupby` sails through it. Parsing inherits all - // of it for free — the surface name, the near-key guidance, the `target` - // union — which is why #5022 declared the shape as Zod rather than as - // another list here. #5435 is the dividend: widening `target` to admit - // `'navigate'` moved this gate with it, with nothing to edit in this file. + // This was the FIRST rule in this file to parse its schema instead of + // restating the vocabulary in local constants; `aggregate` next door was the + // counter-example the strictness ledger's `chart.zod.ts` row named — a gate + // that re-derives the rules cannot inherit the schema's unknown-key handling, + // so `groupby` sailed through it — and #5020 converted it the same way. + // Parsing inherits all of it for free: the surface name, the near-key + // guidance, the `target` union. #5435 is the dividend — widening `target` to + // admit `'navigate'` moved this gate with it, with nothing to edit here. checkChartDrillDown(values.get('drillDown'), push); // Inline `data` wins over the aggregate query: the columns then come from @@ -344,31 +526,23 @@ function checkObjectChart( if (values.has('data')) return; const aggregate = values.get('aggregate'); + + // 1. The aggregate declaration itself — checked by PARSING the schema, not by + // re-deriving it (#5020). Runs before the early returns below so a + // non-object `aggregate` is reported rather than silently skipped. + checkChartAggregate(aggregate, push); + if (aggregate === undefined || aggregate === NOT_STATIC) return; if (!isRec(aggregate)) return; + // The reads below are NOT a second judgement of the declaration: they feed the + // two questions only this rule can answer — whether the names exist on the + // bound object, and whether the axes name the columns the aggregate returns. const fn = strOf(aggregate.function); const field = strOf(aggregate.field); const groupBy = aggregate.groupBy; const groupByField = strOf(groupBy) ?? (isRec(groupBy) ? strOf(groupBy.field) : undefined); - // 1. The aggregate declaration itself. - if (fn && !(CHART_FUNCTIONS as readonly string[]).includes(fn)) { - push( - 'error', - REACT_CHART_AGGREGATE_INVALID, - `aggregate.function "${fn}" is not an aggregation this chart can run.`, - `Use one of: ${CHART_FUNCTIONS.join(', ')}.`, - ); - } else if (fn && fn !== 'count' && !field) { - push( - 'error', - REACT_CHART_AGGREGATE_INVALID, - `aggregate.function "${fn}" has no "field" to aggregate.`, - 'Add aggregate.field, or use function "count" (the only one that may omit it).', - ); - } - // 2. `field` / `groupBy` are RAW field names on the bound object. const objectName = strOf(values.get('objectName')); const known = objectName ? objectFields.get(objectName) : undefined; diff --git a/packages/spec/src/ui/chart.test.ts b/packages/spec/src/ui/chart.test.ts index 29311e8a1c..85bf1abbc0 100644 --- a/packages/spec/src/ui/chart.test.ts +++ b/packages/spec/src/ui/chart.test.ts @@ -377,6 +377,12 @@ describe('Chart ARIA Integration', () => { // by a later sweep "finishing the file" with a `strictObject` that gates // nothing (#4583). The same verdict is recorded in `chart.zod.ts`'s header and // in the ui/ row of `docs/audits/2026-07-unknown-key-strictness-ledger.md`. +// +// UPDATE (#5020): the open half's verdict moved from `no gate` to `authorable` +// — the react-page publish lint now PARSES `ChartAggregateSchema` instead of +// re-deriving it, so a `strictObject` here would no longer gate nothing. The +// posture itself is unchanged, so every assertion below stands as written; the +// conversion is #5583, and that is the change that inverts the two STRIP pins. // ============================================================================ describe('#4001 批 15 — the five closed chart sites', () => { const reject = (schema: { safeParse: (v: unknown) => { success: boolean; error?: { issues: unknown } } }, value: unknown): string => { @@ -521,11 +527,20 @@ describe('#4001 批 15 — the five closed chart sites', () => { describe('#4001 批 15 — the two chart sites deliberately LEFT OPEN (measured, not skipped)', () => { // `ChartAggregateSchema` and `ChartGroupBySchema`'s object arm have a LIVE // carrier — the react tier's `` prop, which - // objectui's ObjectChart reads to run the query — but no PARSE: they are - // unreachable from all 24 metadata-type roots and from `ObjectStackSchema`, - // and the react-page publish lint re-derives their rules by hand instead of - // parsing them. `.strict()` is a property of a parse, so closing them would - // gate nothing while making the real gap harder to see. + // objectui's ObjectChart reads to run the query — and, as of **#5020**, a + // PARSE: the react-page publish lint calls `ChartAggregateSchema.safeParse()` + // instead of re-deriving the vocabulary and the count/field refinement by + // hand. That retires the 批 15 `no gate` verdict; both sites are now ordinary + // `authorable` ones. + // + // The two pins below are therefore UNCHANGED and must stay GREEN: the posture + // did not move, only the parse did. `.strict()` is a property of a parse, and + // now that one exists, converting these two is a behaviour change with a gate + // to observe it — **#5583**, where these two assertions INVERT (the stripped + // key becomes a named rejection). Until then they record what the wired gate + // still cannot see, which is the difference between a gate and a closed door + // (#4583). The companion pins live in `packages/lint`'s + // `validate-react-page-props.test.ts`. it('ChartAggregateSchema still STRIPS an undeclared key — deliberate', () => { const parsed = ChartAggregateSchema.parse({ function: 'count', groupBy: 'status', groupby: 'status' }) as Record; expect(parsed.groupby, 'if this is no longer stripped, re-read the header in chart.zod.ts').toBeUndefined(); diff --git a/packages/spec/src/ui/chart.zod.ts b/packages/spec/src/ui/chart.zod.ts index 06dc9936ed..23516cbdbe 100644 --- a/packages/spec/src/ui/chart.zod.ts +++ b/packages/spec/src/ui/chart.zod.ts @@ -44,9 +44,20 @@ import { strictObject } from '../shared/strict-object'; // dimension/measure NAMES and adds no key of its own, so the inherited key // set is exactly right and no `extraKeys` entry is needed. // -// LEFT OPEN, DELIBERATELY (`ChartAggregateSchema`, `ChartGroupBySchema`) — -// see the block above those two schemas. Short version: their carrier is the -// REACT tier's `` prop, and nothing parses them. +// STILL OPEN (`ChartAggregateSchema`, `ChartGroupBySchema`) — but no longer for +// the reason 批 15 recorded. Their carrier is the REACT tier's +// `` prop, which had NO parse behind it; #5020 wired +// one (`packages/lint`'s react-page publish gate now calls +// `ChartAggregateSchema.safeParse()` instead of re-deriving the vocabulary and +// the count/field refinement by hand), so the `no gate` verdict is spent and +// these two are ordinary `authorable` sites. What remains is the posture: both +// are still STRIP, so an unknown key is dropped by that parse rather than +// reported, and `groupby` / `dateGranularty` still degrade a chart silently. +// Closing them is now a behaviour change with a gate to observe it — **#5583**, +// which also carries the one product question this pair raises (is an ungrouped +// single-value chart a supported shape? the renderer honours it, `groupBy` is +// declared required, and #5020's gate reports the absence at `warning` until +// that is answered). // --------------------------------------------------------------------------- /** @@ -622,10 +633,45 @@ export const ChartConfigSchema = lazySchema(() => strictObject( */ // --------------------------------------------------------------------------- -// THE TWO SITES BELOW ARE DELIBERATELY NOT CLOSED (#4001 批 15) — and this is -// a measured verdict, not the batch running out of file. +// THE TWO SITES BELOW ARE STILL OPEN — and as of #5020 the reason has CHANGED. +// Read this header as two layers: what 批 15 measured (still accurate as +// history), and what moved since (the parse exists now; the posture does not). // -// `ChartAggregateSchema` and `ChartGroupBySchema`'s object arm are the only +// ## What moved (#5020) +// +// Point 3 below said "nothing parses these". That is no longer true. The +// react-page publish gate — `packages/lint/src/validate-react-page-props.ts` — +// now calls `ChartAggregateSchema.safeParse()` on a static +// `aggregate={{…}}` literal, exactly as #5022 did for `ChartDrillDownSchema` +// beside it, and the hand-derived `CHART_FUNCTIONS` list plus the hand-written +// twin of the count/field refinement are DELETED: this file is the single source +// of both again. So the ledger's `no gate` verdict is spent, and these two rows +// are now ordinary `authorable` sites. +// +// ## What did NOT move, and why it is a separate issue +// +// Both are still STRIP-posture, so the parse the gate now runs still DROPS an +// unknown key instead of reporting it — `groupby` for `groupBy`, +// `dateGranularty` for `dateGranularity` — and a chart still degrades to a +// single ungrouped point with `build`/`validate` green. Converting them to +// `strictObject` is now a real behaviour change with a gate that observes it, +// which is the whole point of doing it in this order, and it is **#5583**. +// `validate-react-page-props.test.ts` pins today's tolerance out loud so the +// wired gate cannot be mistaken for a closed one (#4583); those pins invert +// when #5583 lands, as do the two "still STRIPS — deliberate" pins in +// `chart.test.ts`. +// +// ⚠️ #5583 also carries the one product question this pair raises, which is NOT +// a strictness question: `groupBy` is declared REQUIRED here and in the +// published react-blocks type, while objectui's `ObjectChart` honours its +// absence (`schema.aggregate?.groupBy || schema.xAxisKey`) and +// `chartAggregateCategoryKey` in `./chart-aggregate.ts` documents the ungrouped +// single-row result. Until that is answered, #5020's gate reports the absence at +// `warning` rather than gating a shape the platform itself delivers. +// +// ## What 批 15 measured (the history, unchanged) +// +// `ChartAggregateSchema` and `ChartGroupBySchema`'s object arm were the only // two of this file's seven object sites the 批 15 door measurement could not // find a PARSE for: // @@ -657,12 +703,19 @@ export const ChartConfigSchema = lazySchema(() => strictObject( // `groupby` / `fn` / `dateGranularty` are silently dropped today and would go // on being silently dropped after a `strictObject` here. // -// The contract-first fix is to make the react-page publish gate PARSE this +// The contract-first fix was to make the react-page publish gate PARSE this // schema instead of re-deriving it — a change in `packages/lint`, not a -// strictness change in the spec. Filed rather than smuggled in here. +// strictness change in the spec. Filed rather than smuggled in here, and +// **DONE at #5020**, which is why the two paragraphs at the top of this header +// now supersede this one. // -// DO NOT convert these two to `strictObject` before that is decided: it would -// read as load-bearing, and it would make the real gap harder to see. +// The batch's standing instruction — "do not convert these two to +// `strictObject` before the parse exists, it would read as load-bearing while +// gating nothing" — is therefore SATISFIED, not repealed. The conversion is now +// the right next step and has its own issue (#5583). Anyone reaching this +// paragraph from a strictness sweep should go there rather than closing these +// two in passing: the sweep would also have to invert four pins and answer the +// `groupBy` product question above. // --------------------------------------------------------------------------- /**