feat(types): refuse chartType on a chart series by name, pointing at type - #7737
Conversation
…t `type` `ChartDataSeriesSchema` is a non-strict `z.object`, so an authored series `chartType` — the renderer's INTERNAL spelling of the declared `type`, the first limb of `normalizeSeries`' `str(raw.chartType) ?? str(raw.type)` — was stripped in silence while `safeParse` reported success. It is now declared as a named ALIAS REFUSAL arm (`aliasKeyRefusal()` in `zod/tombstone.zod.ts`, reusing `retirementTombstone`'s `z.never` primitive so `z.toJSONSchema` keeps working) that answers with the spec's own sentence — "Unrecognized key(s) on this chart series: `chartType`. Did you mean `chartType` → `type`?" — and the TS twin carries a `?: never` tombstone. Both-written is refused, not folded. Re-measured at implementation time, series-level, lit controls: docs 0, fixtures 0, designer inputs 0, src literals 0, tests 9 (internal-shape). Limb ablation over 304 files / 5817 tests: deleting the `chartType` limb left all green; deleting the `type` sibling went 2 red. No reader changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract review — VERDICT: PASS
⭐ The review falsified a claim this PR ships, and that is the finding worth readingThe PR argues
So the package already had a precedent for exactly this category — never-declared, renderer-read spelling → named refusal pointing at the canonical spelling — and used The helper itself is still licensed — the reviewer names the real differentiator: the message shape, composing the spec's own What was measured on the two directions I flagged as prioritiesParity ledger (#7731 landed underneath and rewrote how that population is derived). Verified at the merge-base and on a cherry-pick onto current Index signature. Also confirmed
Dev Q1 → B, and it now has a carrier: #7744The authoring face refuses Before landing — the delta I am asking forThe PR is
⛔ Merge Generated by Claude Code |
…efusal Brings the branch current before landing. No conflicts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
…rom the spec's live lead Land-prep for PR #7737, asked for by its contract review (VERDICT: PASS, not withdrawn). Text and one assertion only — no runtime or type behaviour moves. - `tombstone.zod.ts`: the `aliasKeyRefusal` docblock argued that a census of `retirementTombstone(` sites is a census of keys the contract once declared. Re-measured on this merged head, that partition is false: `MenuItemSchema.type` (`overlay.zod.ts:196`, objectui#6523) is a `retirementTombstone` whose own guidance reads "`type` ('separator' or 'label') was an undeclared spelling two renderers used to read and is now a declared refusal, not a strip", and `overlay.ts:458-464` states it again ("a spelling the type never declared"). The docblock now cites that precedent and names the real differentiator — the composed spec sentence: a vocabulary, not a shape. - pin block (e): it compared ours and the spec's message each to a constant and never to each other, so a spec reword of the lead would have left both green while the faces diverged. The expected lead is now derived from the installed spec's live message at assert time, guarded so the cut still contains the remedy. The cut is unchanged — lead + `Did you mean …?`, excluding the spec's trailing clause (91 bytes, byte-equal to ours on 17.2.0). - changeset: names the parents the narrowing propagates through (`ChartSchema.series`, `ReportSectionSchema.chart`, reached via `safeValidateSchema` and `objectui check` / `validate`) and the io-mode nuance — `io:'input'` narrows; `io:'output'` already emitted `additionalProperties: false` on the base. Both measured on this head. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Land-prep verified by the PM — the PASS carries to
|
| file | at review | now |
|---|---|---|
packages/types/src/data-display.ts |
+39−10 | +39−10 |
packages/types/src/zod/data-display.zod.ts |
+33−7 | +33−7 |
packages/types/src/__tests__/chart-series-keys-7546.test.ts |
+25−17 | +25−17 |
content/docs/api/schema-reference.md |
+1−1 | +1−1 |
content/docs/plugins/plugin-charts.mdx |
+2−0 | +2−0 |
packages/types/src/zod/tombstone.zod.ts |
+57−1 | +68−1 |
…/chart-series-chart-type-alias-refusal-7694.test.ts |
+251−0 | +271−0 |
.changeset/7694-chart-series-chart-type-alias-refusal.md |
+78−0 | +93−0 |
Files added since review: none. Files dropped: none. ⭐ The two carriers that hold the actual refusal — data-display.ts and data-display.zod.ts — are byte-identical in count to what was reviewed. The three that moved are exactly the three text/pin items the verdict authorised, and nothing else.
Two disclosures from the implementing seat worth keeping
f7f706f25 + c816881be). It saved them as a patch, reset to the remote tip, and redid everything from its own measurements, reusing nothing. I had measured a proxy (the remote ref) and asserted something wider (no work in progress anywhere) — the same defect I corrected on auto_merge earlier today.
?, the shared cut is 91, not the 92 my verdict carried; the difference is the single space that follows. Recorded in the PR body rather than either number being silently adopted.
Landing checks
| check | reading |
|---|---|
| ① in-seat contract review PASS on record | claude-fable-5-1, nine attack directions, zero confidence gaps |
② needs:contract-review off both carriers |
applied with this comment |
③ check-clause2-carriers --pair 7737 |
run below |
All 32 check runs enumerated individually at 3929d1036 — ⛔ not the aggregate, ⛔ not the check_suite rollup: 29 success, 3 skipped by design, 0 failed, 0 in progress. mergeable_state: clean.
Follow-up carrier from the review — objectui#7744 (the reader still takes chartType first on the authored path while the validator refuses it) — verified open, so it survives Fixes #7694.
Generated by Claude Code
Fixes #7694
Summary
ChartDataSeriesSchema(@object-ui/types/zod) now refuseschartTypeon a chart series by name, pointing attype, and its TS twinChartDataSeriescarries achartType?: nevertombstone. This is the ruled shape — option A of thedomain:uiPM ruling on objectui#7546 and the contract review of PR #7684 (merged) — and it is a narrowing: on the base (a00db9ef9){ name: 'r', chartType: 'line' }parsed green with the key stripped in silence by the non-strict object; it now refuses atseries.N.chartType.The refusal answers with the spec's own sentence, one string on both channels (parse-time issue message and
.describe()metadata):Issue code
invalid_type, path['chartType']. Both-written ({ type: 'bar', chartType: 'line' }) is refused atchartTypealone — not folded, no precedence minted. No reader changed:normalizeSeries'str(raw.chartType) ?? str(raw.type)is untouched.Clause-② — every accept-set / published-surface change in this diff
ChartDataSeriesSchema(@object-ui/types/zod): a series carryingchartType— any value — now refuses atchartType(invalid_type); it parsed green with the key stripped before. Narrowing. Which documents this reaches, re-measured on the merged head: the schema is embedded byChartSchema.series(zod/data-display.zod.ts:622,z.array(ChartDataSeriesSchema)) and, one level further out, byReportSectionSchema.chart(zod/reports.zod.ts:105,ChartSchema.optional()); authors meet it throughsafeValidateSchema()(zod/index.zod.ts:434, which parsesAnyComponentSchema) and through the CLI'sobjectui checkandobjectui validatecommands (packages/cli/src/cli.ts:211and:223). A release reader therefore scans everychartnode'sseries[], and every report section whosechartcarries one.ChartDataSeriesSchema.shapegains the keychartType(az.neverarm, optional, described) — visible to anything reading the mirror's own shape, and toz.toJSONSchema, which now listschartTypeas{ not: {} }carrying the guidance.z.toJSONSchema(ChartDataSeriesSchema)succeeded before (11 properties) and still succeeds (12).io: 'input'the emitted object carries noadditionalPropertiesat all, so the accept set genuinely narrows there (chartTypegoes from unmentioned to a property nothing satisfies); inio: 'output'— the default, and what a barez.toJSONSchema(…)emits — the base already emittedadditionalProperties: false, so the key was outside the accept set before this change, and what that mode gains is the NAMED refusal and its guidance, not a narrower accept set. Property count 11 → 12 in both modes.ChartDataSeries(@object-ui/types): new memberchartType?: never. A fresh literal writing it was already an excess-property error; a widened / non-fresh object carryingchartTypeassigned structurally before and is atscerror now. Narrowing on the TS face.zod/tombstone.zod.tsgainsaliasKeyRefusal()— package-internal like its two siblings, not re-exported fromindex.zod.ts. No published-surface change.content/docs/plugins/plugin-charts.mdxand one clause on theseriesrow ofcontent/docs/api/schema-reference.md, both sayingchartTypeon a series is refused by name andtypeis the spelling. Prose only, no new snippet.Nothing else on the accept surface moved: the object stays non-strict (a truly undeclared key is still stripped — pinned), the six objectui#7546 keys, the
datatombstone and the at-least-one-binding refinement are untouched.Re-measured liveness (my numbers beside the card's)
Series-level
chartTypeon the authoring face, with lit controls in the same query (dataKey/name/type/color). The instrument locates everyseriesarray literal by bracket matching (comment-aware) and counts keys inside those spans, so chart-levelchartType(a declaredChartSchemakey, 22 / 3 / 77 / 133 sites in the same four populations) is never confused with the series-level key.content/docs, READMEs,skills/)examples/,fixtures/,*.json/*.yaml)inputschartregistration'sseriesis onecodeinput (packages/plugin-charts/src/index.tsx:114)packages/*/src,apps/*/src, non-test)AdvancedChartImpl.comboMarkClick.test.tsx:114(2),AdvancedChartImpl.comboFromSeries.test.tsx:59,71,101,116,134(6),ChartRenderer.specSeries.test.tsx:97(1); all internaldataKey-shaped, onChartRenderer's raw pathThe spec's posture, measured live on the installed
@objectstack/spec@17.2.0:ChartSeriesSchema.safeParse({ name: 'r', chartType: 'line' })→unrecognized_keys, keys['chartType'], message beginningUnrecognized key(s) on this chart series: \chartType`. Did you mean `chartType` → `type`?; thetype` control parses green and keeps its value.Limb ablation, re-run (both legs, trap-restored, hashes checked)
Population:
packages/plugin-charts/,packages/plugin-dashboard/,packages/types/,packages/plugin-report/,examples/schema-catalog/plus the eight test files elsewhere that import@object-ui/plugin-charts/normalizeChartSchema— 304 files / 5817 tests (the card's reviewer ran 197 / 2380). Resolution:vitest.config.mtsaliases@object-ui/plugin-chartstopackages/plugin-charts/src, so the mutated SOURCE is what every test resolved; no dist rebuild was on the path. Each leg: anchor counted before (1), mutation confirmed on disk bygrep -cof the old anchor (0) and the new line (1) plusgit diff HEAD --stat(1 file, 1 insertion, 1 deletion) and the mutated blob hash; restore viagit checkout HEAD -- ABSOLUTE_PATHin atrap … EXIT INT TERM, proven bygit hash-objectequal to the HEAD blob (05a66dfb…) and an emptygit diff HEAD --stat.str(raw.chartType) ??:Test Files 304 passed (304) · Tests 5817 passed (5817), vitest exit 0. Green — the key is still not live on any path a test exercises.?? str(raw.type):Test Files 2 failed | 302 passed (304) · Tests 2 failed | 5815 passed (5817), vitest exit 1 —normalizeChartSchema.test.ts › carries stack / yAxis / color on a seriesandChartRenderer.specSeries.test.tsx › honours a spec series[].type override end to end. Red, the same two the card reported. The instrument is lit.Both legs ran on the untouched base tree (before any edit of mine), under
os-verify-lock.sh.The convention matched, and the one thing added
The package carries two named-refusal helpers in⚠️ Corrected after the contract review, and re-measured here. An earlier draft of this paragraph, and of the helper's own docblock, argued that a census of
packages/types/src/zod/tombstone.zod.ts:retirementTombstone(guidance)(line 65 —z.never({ error }).optional().describe(), ADR-0049 retired keys, codeinvalid_type) andhandlerKeyRefusal(key, disposition, label)(line 121 —z.customnever-typed predicate, codecustom, foron*keys). Neither fits an alias by name: a retirement tombstone's guidance is a migration note for the key the contract is withdrawing, while an alias arm's guidance has to carry the canonical spelling; and the handler arm's text is about function values.retirementTombstone(sites is a census of keys the contract once declared. That partition is false on this tree:MenuItemSchema.type(zod/overlay.zod.ts:196, objectui#6523) is aretirementTombstonewhose own guidance reads "type('separator' or 'label') was an undeclared spelling two renderers used to read and is now a declared refusal, not a strip", andoverlay.ts:458-464states it again ("a spelling the type never declared"). A never-declared, renderer-read spelling turned into a named refusal pointing at the canonical key therefore already had a precedent in this package, filed underretirementTombstone. The real differentiator is the message:aliasKeyRefusalcomposes the lead sentence@objectstack/spec'sstrictObject({ aliases })answers with, so one remedy meets the author on both faces — samez.neverprimitive, sameinvalid_typecode, a three-line composer. A vocabulary, not a shape. objectui#7678 tracks this class of self-contradictory retirement rationale; this PR no longer adds a second instance of it. So this PR adds a third sibling,aliasKeyRefusal(alias, canonical, surface, detail), that reusesretirementTombstone's primitive — no new shape — and the same one-string-two-channels discipline.Why that primitive and not
handlerKeyRefusal'sz.custom, measured on the base withtsx:z.toJSONSchemathrows on az.customarm (Custom types cannot be represented in JSON Schema) and represents az.neverarm as{ "description": …, "not": {} }.z.toJSONSchema(ChartDataSeriesSchema)worked before this change and must go on working; the pin test's block (f) holds both halves (the call succeeds and carrieschartType; thez.customcounter-probe throws).Files touched
packages/types/src/zod/data-display.zod.ts— the arm besidetype; import. (uncontended)packages/types/src/data-display.ts—chartType?: neverwith the measurement in its docblock. (uncontended)packages/types/src/zod/tombstone.zod.ts—aliasKeyRefusal()(+ module header line).packages/types/src/__tests__/chart-series-chart-type-alias-refusal-7694.test.ts— new pin (blocks a–f: envelope, shape +z.input, both-written, TS face incl. the non-fresh assignment, spec agreement measured live, JSON-Schema surface + primitive counter-probe).packages/types/src/__tests__/chart-series-keys-7546.test.ts— block (d) restated as the handoff (objectui#7070: not deleted into a vacuum); header prose notes the card that landed..changeset/7694-chart-series-chart-type-alias-refusal.md—@object-ui/types: minor, the narrowing stated in release-reader words (own grade, argued from the accept-set change).content/docs/plugins/plugin-charts.mdx,content/docs/api/schema-reference.md— one sentence / one clause each.Contended files from the brief — none touched:⚠️ The parity ratchet's instrument is
zod-mirror-parity.test.ts(no ledger entry needed: the arm'sz.inputisundefinedand the twin is?: never, so the pair does not drift),zod/index.zod.ts,index.ts.tsc -p packages/types/tsconfig.test.json, notvitest. The ratchet is a type-level assignability assertion, so avitestrun cannot report it in either direction; an earlier draft of this line rested the conclusion on "the parity suite passed unchanged" undervitest, which is the wrong citation for a conclusion that still holds. Thattscinvocation is the one CI's Type Check runs, and it is proven lit on the merged head below.Verification (exit codes captured before any pipe; verdict lines quoted)
Land-prep round — merged head
3929d1036origin/mainmoved seven commits under this branch after the review (a00db9ef9..81a2eb1fb: #7621, #7738, #7686, #7732, #7731, #7730, #7726). Brought current by merge, not rebase — merge commit6bcd58ff2,Merge made by the 'ort' strategy, 0 conflicts;git diff de8fda691..HEADrestricted to this PR's own files is empty, so the merge moved nothing this PR owns.tsc -p packages/types/tsconfig.test.json --noEmit— exit 0.--listFilesreports 580 files and all three edited paths are among them (the pin test,zod/tombstone.zod.ts,data-display.ts), so this instrument really measures the edit rather than excluding it.pnpm --filter @object-ui/types build— exit 0,✓ dist completeness: 1 package(s) complete (124 emitted files verified).pnpm exec eslint .(repo-wide) — exit 0,✖ 11939 problems (0 errors, 11939 warnings); every warning is the pre-existing@typescript-eslint/no-explicit-any.pnpm exec vitest run packages/types/— exit 0,Test Files 119 passed (119) · Tests 2054 passed (2054).check-changeset-presence(5 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)),check-changeset-no-major,check-changeset-fixed,check-changeset-overwrite(1 changeset(s) added, 0 modified, 0 deleted).check:control-bytesexit 0 (6317 tracked text files scanned),check:doc-fencesexit 0, andcheck-governed-queue-guard --testover this PR's 8 paths:✅ NOT GOVERNED — 8 path(s) checked against 5 governed surface(s); none matched.The parity ratchet is lit on the merged head. Control —
tsc -p packages/types/tsconfig.test.json --noEmiton the untouched tree: exit 0. Mutate the TS twinchartType?: never→chartType?: string, with the mutation confirmed on disk before reading anything (old anchorgrep -c1 → 0, injected line 1, blob9aa36e06…≠ the HEAD blob9fb07328…,git diff HEAD --stat= 1 file / 1 insertion / 1 deletion): the same command goes exit 2, and among its 5 errors ispackages/types/src/__tests__/zod-mirror-parity.test.ts(1522,14): error TS2322: Type 'LedgerMismatch' is not assignable to type 'never'., naming the pair"data-display.zod.ts#ChartDataSeriesSchema". Restored inside atrap … EXIT INT TERMagainst an absolute path and proven bygit hash-objectequal to the HEAD blob9fb07328…plus an empty whole-treegit diff HEAD --stat.Pin block (e) was rewritten so it can fail for the right reason. It compared ours and the spec's message each to a constant, never to each other, so a spec reword of the lead would have left both assertions green while the two faces diverged. It now derives the expected lead from the installed spec's live message at assert time, with a guard that the cut still contains the remedy. The cut is unchanged — lead +
Did you mean …?, excluding the spec's trailing clause — and measured on the installed@objectstack/spec@17.2.0that cut is 91 bytes, byte-equal to ours. Two proofs it reddens:UnrecognizedtoUnrecognisedinzod/tombstone.zod.ts; mutation confirmed on disk (old anchor 1 → 0, injected 1, blob71d58158…≠ HEADd431a1f9…). The block goesTests 1 failed | 24 skipped (25), vitest exit 1,AssertionError: expected false to be true. Restored under atrap,git hash-object==d431a1f9…,git diff HEAD --statempty.Implementation round — on
de8fda691, before the mergeUnder
scripts/pm/os-verify-lock.sh(shared box; absolutes are not idle-box figures):packages/types:tsc --noEmitexit 0;tsc -p tsconfig.test.json --listFilesexit 0 and both edited test files listed (2 hits);tsc -p tsconfig.examples.jsonexit 0;eslint .exit 0 (✖ 268 problems (0 errors, 268 warnings)— all pre-existingno-explicit-any, none on touched lines).pnpm exec vitest run --maxWorkers=2 packages/types/—Test Files 116 passed (116) · Tests 1991 passed (1991), exit 0 (includeszod-mirror-parity).pnpm --filter @object-ui/types build—✓ dist completeness: 1 package(s) complete (120 emitted files verified);chartType?: neverpresent indist/data-display.d.ts.data-display.zod.ts+data-display.tsreverted to BASE (helper kept so the instrument loads), pin files run:Tests 15 failed | 37 passed (52), exit 1;tsc -p tsconfig.test.jsonexit 2 with the three predicted errors (TS2339 at thez.inputline and theChartDataSeries['chartType']line, TS2578 unused directive on the non-fresh assignment). Direction observed: red, as predicted; restored, both hashes equal to HEAD blobs,git diff HEAD --statempty.Unlocked gates on the committed tree:
check:control-bytes✅ (6302 files),check:doc-fences✅,check:doc-types✅,check:spec-symbols✅,check:unreferenced-sourcesOK,check:phantom-deps✅,docs:check-links(Links are valid across 17 scan roots),check-changeset-presence✅ (5 source files of 1 released package, 1 changeset declared),check-changeset-no-major✅,check-changeset-fixed✅,check-changeset-overwrite✅ (1 added, 0 modified, 0 deleted).Declared narrowings (CI owns the farm):
check:doc-snippets— NOT MEASURED locally:PRECONDITION NOT MET (exit 2), it needs 26 packages built. Why it cannot turn red on this diff: the docs edits add prose only (no fence), and the only type change ischartType?: neveron a series, whose sole trigger is a doc snippet writing series-levelchartType— the docs census above reads 0 with lit controls.doc-snippet-types.ymlruns it in CI.pnpm --filter '...@object-ui/types' type-check, prefix filter = consumers) — not run in full (15-package build closure). Evidence it cannot break: zero packages outsidepackages/typesimportChartDataSeriesorChartSchemafrom@object-ui/types(grep), and the src-literal census reads 0 series-levelchartTypewith lit controls. CI'stype-checkjob runs the farm.pnpm lint/pnpm testrepo-wide — CI's runs; the package-scopedeslint .andvitest run packages/types/above are the measured halves.Questions for the contract reviewer (boundary flags, not resolved here)
normalizeSeriesstill readschartTypefirst on the internal-shape arrays its producers hand it, while the authoring face now refuses the same key. Fence-correct here (⛔ no reader), but is the limb owed a docblock or a card of its own saying it is an internal-shape read only? Not touched.aliasKeyRefusalas a family member. It is the third helper intombstone.zod.tsand reuses thez.neverprimitive (soz.toJSONSchemakeeps working). If the reviewer would rather the alias arm sharehandlerKeyRefusal'scustomcode, that trades away the JSON-Schema surface — block (f) measures the trade.Did you mean …?remedy and deliberately excluding the spec's trailing clause. The review measured that cut hex-equal across ours / installed 17.2.0 / fetched 17.3.0, with 17.3.0 rewording only the trailing clause — so pinning further would redden this repo on a spec bump for no author-facing gain. (The review states the cut as 92 bytes; measured here through the?it is 91 — the difference is the single space that follows it.)Session:
https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3Generated by Claude Code