fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it - #17616
fix(spec)!: refuse dashboard.widgets[].options.stageOrder on every widget type that does not read it#17616os-bill wants to merge 5 commits into
dashboard.widgets[].options.stageOrder on every widget type that does not read it#17616Conversation
…widget type that reads it WIP checkpoint before the heavy verification run. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…rence page The ADR-0087 protocol-18 semantic entry, the regenerated migration registry, the `gen:schema` -> `gen:docs` projection of the changed `.describe()`, and the `minor` changeset carrying the BREAKING banner and the disposition marker. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
…napshots `check:objectui-pin-citations` refuses a sha a reader cannot find mechanically; `check:api-surface` / `check:export-origins` record the one added export, `checkDashboardWidgetStageOrder`, with nothing removed or narrowed. Two more pins: the door runs the EXPORT (declared once, attached once, by identifier) and `@objectstack/spec/ui` ships that same function object. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 135 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e0b3a3c7513df46f55c90b24cdc71b8ee54f621 && git checkout 9e0b3a3c7513df46f55c90b24cdc71b8ee54f621
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2 47599ccc66d6a19a3dfe39f1d57056ac6b1093c0 && git checkout -B drift-repro c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2 && git merge --no-ff 47599ccc66d6a19a3dfe39f1d57056ac6b1093c0
node scripts/docs-audit/affected-docs.mjs --json c1123cf2ad0c9f4e2e0d7b2c8bc6282e27eeb4f2
|
|
Seat adoption record — adopted VERBATIM. Tier verified from the transcript, ⛔ not self-report: 138 harness-stamped ⭐ The must-fix is a fourth non-coverage the PR names nowhere, and the reviewer found it by probing a mechanism claim instead of reading it. The round said it attached the check by identifier "so a Re-measured by this seat before adopting — ⛔ not taken on the reviewer's word: ⇒ After this lands, the client-side authoring door keeps accepting ⭐ And judgment 1 is the strongest verification of "correctly bounded" this seat has seen tonight. A differential corpus of 768 fixtures (24 types × 16 option shapes × 2 doors) run identically at base and head: base parses 546, head 386, 160 moved — and the 160 are exactly 20 non- ⭐ The refusal's advice was checked for truth, not just for presence. The order said a refusal that sends an author to a key that does not help them is worse than the silence it replaced. The reviewer followed Carrier handlingThe standing rule is that a returned verdict clears both carriers — 「FAIL 同 PASS 剥双载体」 — because the label means a review is pending and one has happened; the owed work rides the handover, ⛔ not a label. ⇒ Cleared on both, one stroke each seconds apart ( ⛔ This seat applied that rule inconsistently earlier tonight and says so rather than leaving two precedents standing: on PR #17567's first verdict (PASS WITH FINDINGS with two must-fixes) it kept the gate hung. The rule as written does not carve out that case, and the cycle is self-correcting anyway — the rework's push moves the head, ⛔ The PR stays draft, not enqueued, no auto-merge; card state and assignee untouched; ⛔ Contract review (
|
… and make the exported check equal the door
Contract review, must-fix. The PR claimed that attaching by identifier means
"a `.shape` mirror re-attaches the rule rather than a copy". Probed, and it is
false as a mechanism: `z.strictObject(DashboardWidgetSchema.shape)` ACCEPTS a
`horizontal-bar` carrying `stageOrder` and holds zero object-level checks, while
`.extend({})` keeps the refusal — a lit control (`type: 'ziggurat'`) is refused
by both, so the mirror carries the fields and it is precisely the check that is
dropped. Identifier attachment only makes re-attachment POSSIBLE.
That matters because objectui's authoring door IS that mirror
(`packages/types/src/zod/complex.zod.ts:627` at the pin) and re-attaches none of
the spec's exported checks, so its client-side door keeps accepting the key on a
`bar`. Named at all three sites that presented the list as complete: the check's
docblock, the changeset, and the migration entry's `acceptanceCriteria`, whose
"refused on its next authoring-path save" overstated the door coverage.
Two more things the rework surfaced rather than assumed:
- Adding the check to `object-refinement-check-exports.test.ts` (the declared
increment — the catalogue's population is "every mirrored spec object that
carries an object-level check", and this schema is measurably mirrored) turned
its parity leg RED: called directly with a raw fixture the export returned
early on an omitted `type`, while the door refuses, because the default fires
before object-level checks. Repaired at the producer — the check defaults
`type` itself — rather than by dropping the fixture. The accept set is
unmoved: `parse({...w})` and `parse({...w, type: undefined})` both yield
`type: 'metric'`, so the coalesce is unreachable through this door.
- zod 4 throws on `.omit()` / `.pick()` / `.partial()` of an object carrying a
refinement, so this change converts those three from working to throwing.
Latent, not live, and named as a fifth non-coverage.
Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Co-authored-by: Claude <noreply@anthropic.com>
…reads `check:objectui-pin-citations` refuses a sha in neither recognised spelling: these are historical measurements, so they take ``.objectui-sha` pin `<sha>``, not "the pinned `.objectui-sha` `<sha>`". Three sites, plus the regenerated registry. Gate now reports 12 asserting and 23 historical citations, exit 0. Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH Co-authored-by: Claude <noreply@anthropic.com>
Part of #17344 — finding 1 only (the ADR-0049 gate). Finding 2 landed in #17474; finding 3 (the locale drop) is objectui's and stays open on the card.
Clause-②: yes
This narrows a published accept set:
dashboard.widgets[].options.stageOrderparses today on every widgettypeand is refused here on every type exceptfunnel.needs:contract-reviewhangs on both carriers; ⛔ nothing lands until an at-tier review returns.What was wrong
optionsis the open renderer-extras bag, so nothing closed overstageOrder. Ahorizontal-barwidget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. Nothing warned, nothing refused, and the chart looked deliberate.Re-measured at this repo's
.objectui-shapin53ded82bf7a494f54e344e19099dbf00854b8694, not inherited from the reporter's published-tarball reading:categoryOrderreads inpackages/plugin-charts/src/AdvancedChartImpl.tsxgrep -o): the prop declaration (247), the destructure (850), and one read —buildCategoryRank(categoryOrder)at 1514if (chartType === 'funnel'), opened at 1473packages/plugin-dashboard/src/DatasetWidget.tsxstageOrderinAdvancedChartImpl.tsx⇒ funnel-only reproduces on this pin.
What it does now
DashboardWidgetSchemacarries an object-level check,checkDashboardWidgetStageOrder, that refusesstageOrderunless the widget'stypeisfunnel.Why object-level, and why not a field refinement.
stageOrderis declared atdashboard.zod.tsinsideDashboardWidgetOptionsSchema; thetypethat decides whether it means anything is that object's sibling one level up onDashboardWidgetSchema. A refinement attached tostageOrdersees the array and nothing else. The idiom is not invented for this: the same file already attachescheckGlobalFilterDateDefaultValuetoGlobalFilterSchemawith.superRefine(…)by identifier, and this follows it — a named function, chained on its own line, exported so it is the rule the door runs rather than a copy that can drift..shapemirror re-attaches the rule rather than a copy". That is false as a mechanism, and the review probed it rather than reading it. Reproduced here:z.strictObject(DashboardWidgetSchema.shape)ACCEPTS thehorizontal-bar+stageOrderwidget and holds zero object-level checks, while.extend({})keeps the refusal; a lit control (type: 'ziggurat') is refused by both, so the mirror does carry the fields and it is precisely the check that is dropped. Attaching by identifier only makes re-attachment possible. The consequence is non-coverage 1 below.The refusal, because the defect was silence. A bare "unrecognized key" would answer silence with a shrug, so the message names the key, the
typethis widget carries, and the onetypethat honours it — plus where ordering lives for every other type:The authored type is interpolated, not hard-coded, and a pin proves it: two different authored types produce two different messages.
Behaviour, both directions, measured
Every leg is
safeParseon an authored widget — ⛔ never a reading of the schema source or of its.describe()prose.type: 'horizontal-bar'+stageOrdercustomissue atoptions.stageOrdertype: 'funnel'+stageOrdertype: 'horizontal-bar', nostageOrder(otheroptionsmembers)type: 'horizontal-bar', nooptionsat allThe "before" row is not a claim about the past: the pin that asserted it —
CONTROL — the key is still UNGATED: a non-funnel widget carrying it parses too, added by #17474 precisely so a future gate would have a red test to flip — is the test this PR flips, and it is in the diff.What the gate does NOT cover
Stated so the change is not read as complete. The first and last arrived from the contract review.
@object-ui/typesbuilds its ownDashboardWidgetSchemafromspecFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()(packages/types/src/zod/complex.zod.ts:627at the pin), and a.shapespread drops every object-level check. Re-measured here rather than taken on the review's word: 0 occurrences of any of the five exported check names inpackages/types/src, against a lit control of 17specFieldsExceptcall sites and the mirror line itself present. So until objectui imports and chainscheckDashboardWidgetStageOrder, its dashboard editor keeps acceptingstageOrderon abarand the author meets this refusal later, at publish. Carrier: objectui#9111.typeoutsideChartTypeSchema. zod treats thatinvalid_valueas aborting and skips object-level checks for the input, sotype: 'ziggurat'+stageOrderreports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then — the two are never seen together. Pinned, so a zod upgrade cannot change it silently.type.typecarries.default('metric')and zod applies defaults before object-level checks, so an omittedtypeis indistinguishable here from an authoredmetric. The verdict is right either way —metricreads the key no more thanhorizontal-bardoes — so that one case carries an extra sentence pointing at the missingtyperather than a wrong one, instead of claiming the author wrotemetric.string | number | boolean, unmatched against the dimension's picklist. Afunnelwith a misspelled stage parses and renders it in the sentinel position..omit()/.pick()/.partial(). zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and.extend()is unaffected and keeps the refusal, which is the spelling the mirrors actually use.Sibling sweep —
stageOrderwas the only oneAsked of the same pin: is any other member of that generic bag read by a single branch? No.
dateGranularityDatasetWidget.tsx:443sortBy:444(→orderat 450)sortOrder:450limit:452stageOrder:1468→ forwarded →AdvancedChartImpl.tsx:1514chartType === 'funnel'Lines 443–455 sit outside every type branch (the only
widgetType ===reads in that span areisTable/isMatrix, which do not enclose them), so the other four act on every widget type. ⛔ Nothing was changed about them.Changeset level
minor, notpatch. An accept-set narrowing is a breaking change; the launch-window convention in theCheck Changesetstep's WHICH LEVEL prose ships breaking changes asminorand carries breaking-ness in the BREAKING banner plus the ADR-0087 disposition instead of in the bump. Both are present: the banner, and anadr-0087: registered dashboard-widget-stage-order-non-funnel-refuseddisposition marker (written as the HTML-comment form the gate reads, in the changeset file) against a new protocol-18 semantic entry. The disposition isregisteredrather thannot-required (no-migration-prescription)because there genuinely is a prescription and the changeset carries its FROM → TO table.Repo census before landing: zero authored widgets carry the key anywhere in the monorepo — 59 occurrences outside changelogs, all schema, tests, generated reference pages, the sdui-parser census and the gate that derives it (LIT control
sortBy= 180; DARK controlstageOrdre= 0).Not in this PR
options.stageOrderis declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-enlocale #17344.objectstack-ai/objectui. The asymmetry favours gating anyway: a narrowing later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author.Contract review rework
PASS WITH FINDINGS, one must-fix (prose, no behaviour change). The must-fix is non-coverage 1 above, now named at all three sites that presented the list as complete — the check's docblock, the changeset, and the migration entry'sacceptanceCriteria, whose "refused on its next authoring-path save" overstated the door coverage. Registry regenerated;check:migration-registryre-run and green.Two things the rework surfaced rather than inherited:
object-refinement-check-exports.test.tspins "every mirrored spec object that carries an object-level check", andDashboardWidgetSchemais measurably mirrored (non-coverage 1). Adding it turned the parity leg RED — called directly with a raw fixture the export returned early on an omittedtype, while the door refuses, because zod appliestype's default before object-level checks. ⛔ Repaired at the producer rather than by dropping the fixture: the check defaultstypeitself, so the export is the rule the door runs for every input, which is the whole premise of exporting it.parse({ …widget })andparse({ …widget, type: undefined })both yieldtype: 'metric', so the coalesce is unreachable through this door. The review's 768-fixture differential corpus was not re-run and did not need to be; the four behavioural rows re-measure identical at the new head.The review's other two cards are not taken here: objectui#9111 (above), and the present-tense wording inside the refusal message — left alone deliberately, because changing it would move a published message string on a change the review graded prose-only, and I am not otherwise editing that line.
验收备注
skills/objectstack-ui/rules/dashboards.md:345andcontent/docs/ui/dashboards.mdx:121are hand-written sites that still documentstageOrderfor apyramidtype, and are now also stale about the gate. They are already carried by Three hand-written sites still documentoptions.stageOrderfor apyramidchart type the schema refuses #17471 (sub-issue of Dashboard widgets:options.stageOrderis declared for every chart type, documented for a chart type that does not exist, and silently dropped in every non-enlocale #17344, with its own draft PR) — not widened into this PR, whose face is the schema.packages/sdui-parser/src/dashboard-widget-options.ts:51describes the key as "funnel/pyramid stage order" in a comment. Same carrier.This branch was produced in session https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH.
Generated by Claude Code