From 5ef3aa79b283f7b288de3830b92cd2c4649b055f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 20:59:02 +0000 Subject: [PATCH] fix(lint): chart-field-unknown warns on the chartConfig binding keys the pinned renderer refuses (#15463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule id covers exactly three positions — `chartConfig.xAxis.field`, `chartConfig.yAxis[].field` and `chartConfig.series[].name` — and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so the data failure the messages named ("the query result will not contain it") never happens. `axisPresentation` builds an axis's presentation minus its `field`, structurally rather than by a guard, so the x-axis stays `buildChartSeries`' `xAxisKey` (the widget's `dimensions[0]`) and a y-axis entry keeps only its slot, scale and chrome. `mergeAuthoredSeries` matches an authored entry BY NAME against one derived series per entry of `values`, and an entry matching none is ignored whole — the mark, colour, stack and axis side hung on it land on nothing. That is an ignored key, which is `widget-legacy-analytics-shape`'s class in this same file, reported there at `warning` ("the dashboard renderer ignores them ... a silent no-op"). All three positions drop to `warning`, suppressible per widget, and each message states its own consequence: the axis positions and the series position are refused for different reasons and had been sharing one sentence. The finding is KEPT — unlike the #14436 over-reach this measurement came from, the metadata really is wrong. The tier drop is a behaviour change on the `sys_metadata` publish door. The 2026-08-15 ruling put the rule's whole error set there as one "this board cannot render" class; that set was six ids and is now five. The demoted id still RUNS at the door and still reaches the author, on the advisory channel — pinned as its own case in runtime-gate.test.ts so "demoted" cannot decay into "dropped". `packages/platform-objects` is untouched: the shipped `system_overview` dashboard declares no `chartConfig` at all and stays at zero findings. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk --- ...hart-field-unknown-refused-binding-tier.md | 29 ++++ packages/lint/src/authoring-rules.ts | 11 +- packages/lint/src/runtime-gate.test.ts | 52 +++++-- .../lint/src/validate-widget-bindings.test.ts | 81 ++++++++++- packages/lint/src/validate-widget-bindings.ts | 132 ++++++++++++++---- 5 files changed, 261 insertions(+), 44 deletions(-) create mode 100644 .changeset/chart-field-unknown-refused-binding-tier.md diff --git a/.changeset/chart-field-unknown-refused-binding-tier.md b/.changeset/chart-field-unknown-refused-binding-tier.md new file mode 100644 index 0000000000..f18c3f48ca --- /dev/null +++ b/.changeset/chart-field-unknown-refused-binding-tier.md @@ -0,0 +1,29 @@ +--- +'@objectstack/lint': minor +--- + +`chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens + +The rule id covers exactly three positions, and the `@object-ui` revision this repo pins (`.objectui-sha`) refuses all three as bindings, so none of them can produce the data failure the messages described: + +- `chartConfig.xAxis.field` — `axisPresentation` (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis presentation **minus** its `field`. The x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's `dimensions[0]`; an authored `field` re-points nothing. +- `chartConfig.yAxis[].field` — the same call, per entry. The entry keeps its slot (the count is what turns on a secondary axis) and its scale and chrome; only the binding is dropped. +- `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored entry with the derived series whose `dataKey` it equals, one per entry of `values`. An entry naming no derived series is ignored whole, so the presentation hung on it — the mark, the colour, the stack, the axis side — lands on nothing. + +The renderer pins this by name in `DatasetWidget.chartConfig.test.tsx` ("ignores an authored axis `field` and keeps the derived axis binding", "ignores an authored series and keeps one derived series per measure"). + +So the old message — "the query result will not contain it" — named a query failure that never happens, and `error` blocked a build and a Studio publish for a key that changes nothing at runtime. That is the class `widget-legacy-analytics-shape` reports at `warning` in the same file ("the dashboard renderer ignores them … a silent no-op"), and this id now carries the same tier, the same suppressibility (`suppressWarnings: ['chart-field-unknown']` per widget) and the same kind of sentence. Each message states its own consequence, because the axis positions and the series position are refused for different reasons. + +The finding is **kept**, not deleted: unlike the `chart-config-missing` over-reach this measurement came from, the metadata really is wrong — the author wrote a binding and believes it is in force. + +## Migration + +**A publish that used to be refused now succeeds.** Ruled 2026-08-15, `validateWidgetBindings` put its whole error set on the `sys_metadata` publish door (Studio / REST `/meta` / MCP) as one "this board cannot render" reference-integrity class. That class was six ids and is now five — `chart-field-unknown` has left it. A dashboard write whose only reference-integrity problem is a refused `chartConfig` binding key is no longer a 422 `INVALID_METADATA`; it publishes, and the finding rides the non-blocking `advisories` channel on the 2xx response instead. The other five (`widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`) are unchanged. + +Same direction on the CLI: `os validate` / `os build` / `os lint` report the finding at `warning`, so a stack that used to fail the build over one of these keys now exits 0 with an advisory. If you were relying on the build to stop on it, add the key to your own gate, or fix the binding — the fix has not changed: + +- point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only); +- point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only); +- name a selected measure in `series[].name`, remembering that post-cutover (ADR-0021) result rows are keyed by the dataset's measure **name** (`sum_amount`), not the base column (`amount`). + +A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index e2796765e3..7944703d0f 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -547,8 +547,8 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // hold a forward reference; publishing refuses it with the key path named. // The snapshot carries `datasets` for exactly this rule (`RuntimeStackContext` // — without it every legitimate board reads as dangling, the 3-phantom - // measurement). `surfaces` is per-RULE, so this flip puts all SIX of the - // rule's error ids on the publish gate, not just `widget-dataset-unknown` — + // measurement). `surfaces` is per-RULE, so this flip puts the rule's error + // ids on the publish gate, not just `widget-dataset-unknown` — // ruled 2026-08-15: they are one coherent "this board cannot render" // reference-integrity class, and the ~6× wider accept-set narrowing was // accepted knowingly rather than splitting the dataset limb into its own @@ -556,6 +556,13 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ // filter (registry machinery that weakens "delete a rule from the table and // enforcement stops in the same commit"). Warning-tier ids ride along on the // advisory channel and never block (#4463 P1). + // + // [#15463] That accepted class was SIX ids and is now FIVE: + // `chart-field-unknown` dropped to `warning`, because the pinned `@object-ui` + // renderer refuses its three `chartConfig` binding keys outright — the board + // renders identically with or without them, so the id describes an ignored + // key rather than a binding the analytics service cannot satisfy. It still + // runs at the door and still reaches the author, on the advisory channel. { name: 'validateWidgetBindings', tier: 'gating', diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index 5f3e4815b4..c8633f9cb6 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -697,9 +697,14 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)', // a runtime-gated type, so the gate returned empty before building a snapshot. // Ruled 2026-08-12 (option B): a DRAFT may hold a forward reference; // publishing refuses with the key path named. Ruled 2026-08-15: `surfaces` is -// per-RULE, so the flip puts all SIX of the rule's error ids on the door as -// one "this board cannot render" reference-integrity class — pinned below as -// reachable, not merely declared. +// per-RULE, so the flip puts the rule's error ids on the door as one "this +// board cannot render" reference-integrity class — pinned below as reachable, +// not merely declared. That class was SIX ids until #15463 demoted +// `chart-field-unknown` to `warning` (the pinned renderer refuses those three +// `chartConfig` binding keys outright, so the board renders correctly either +// way); it is FIVE now, and the demoted id is pinned on the advisory channel +// immediately after, so leaving the accept-set is a measured departure rather +// than an absence. // ───────────────────────────────────────────────────────────────────── /** A resolution universe with one real object and one real dataset. */ @@ -824,13 +829,19 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)' expect(dangling.errors).toHaveLength(1); }); - it('ALL SIX error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => { + it('ALL FIVE error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => { // `surfaces` is per-rule, so the flip enforces the rule's whole error set, - // not just `widget-dataset-unknown`. The ruling accepted the six as one - // coherent "this board cannot render" class — this test makes that + // not just `widget-dataset-unknown`. The ruling accepted the class as one + // coherent "this board cannot render" set — this test makes that // acceptance REACHABLE rather than declared, and its exact error sets keep // the boundary honest: a case gaining or losing an id is a scope change // that must go through this line rather than around it. + // + // [#15463] It went through this line: the set was SIX until + // `chart-field-unknown` dropped to `warning`. Its case moves to the + // advisory test below rather than being deleted — an id that leaves the + // accept-set must still be shown to REACH the door, or "no longer gating" + // and "no longer running" become indistinguishable here. const cases: Array<[string, unknown, string[]]> = [ [ 'dangling dataset', @@ -847,11 +858,6 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)' dashboard([cleanWidget({ values: ['no_such_measure'], chartConfig: { xAxis: { field: 'status' } } })]), ['widget-measure-unknown'], ], - [ - 'chartConfig field off the selection', - dashboard([cleanWidget({ chartConfig: { xAxis: { field: 'not_a_dim' } } })]), - ['chart-field-unknown'], - ], [ 'legacy analytics shape as the only (dead) data wiring', dashboard([{ id: 'w9', type: 'bar', categoryField: 'status' }]), @@ -876,15 +882,35 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)' for (const e of errors) seen.add(e.rule); } - // Non-vacuous, and the ruling's whole accept-set: exactly the six. + // Non-vacuous, and the ruling's whole accept-set as #15463 left it: + // exactly the five. `chart-field-unknown` is deliberately absent. expect([...seen].sort()).toEqual([ - 'chart-field-unknown', 'dashboard-filter-field-unknown', 'widget-dataset-unknown', 'widget-dimension-unknown', 'widget-legacy-analytics-unrenderable', 'widget-measure-unknown', ]); + expect(seen.has('chart-field-unknown')).toBe(false); + }); + + it('the demoted `chart-field-unknown` still RUNS at the door — on the advisory channel (#15463)', () => { + // The other half of the accept-set narrowing: the exact body that used to + // 422 now publishes, and the finding reaches the author on the 2xx response + // instead. Pinned as one case so "demoted" cannot decay into "dropped". + const board = dashboard([cleanWidget({ chartConfig: { xAxis: { field: 'not_a_dim' } } })]); + const result = gateDashboard(board); + expect(result.errors, JSON.stringify(result.errors)).toEqual([]); + const f = result.advisories.find((a) => a.rule === 'chart-field-unknown'); + expect(f, 'the advisory must still reach the author').toBeDefined(); + expect(f!.severity).toBe('warning'); + expect(f!.path).toBe('dashboards[0].widgets[0]'); + expect(f!.message).toMatch(/not_a_dim/); + // The message names the refusal, not a query that never runs. + expect(f!.message).toContain('ignores an authored axis `field`'); + expect(f!.message).not.toContain('will not contain'); + // And the rule genuinely ran, rather than the door skipping the type. + expect(result.rulesRun).toContain('validateWidgetBindings'); }); it('warning-tier ids ride the ADVISORY channel and never block (#4463 P1)', () => { diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index b4c59292c0..4db4ca5b80 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -142,7 +142,13 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { expect(findings[0].hint).toContain('Did you mean "sum_amount"?'); }); - it('(d) errors on the issue repro: yAxis.field naming the stale base column', () => { + // [#15463] The three positions below are the WHOLE of what `chart-field-unknown` + // covers, and all three are WARNING tier: the pinned `@object-ui` revision + // (`.objectui-sha`) refuses every one of them as a binding, so the authored key + // is ignored rather than mis-queried. The `error`-tier assertions these four + // cases used to carry are inverted below — the tier itself is the finding this + // card changed, so a silent flip back must red here rather than anywhere else. + it('(d) warns on the issue repro: yAxis.field naming the stale base column', () => { const findings = validateWidgetBindings(chartStack({ chartConfig: { type: 'bar', @@ -151,15 +157,21 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { }, })); expect(findings).toHaveLength(1); - expect(findings[0].severity).toBe('error'); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].severity).not.toBe('error'); expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN); expect(findings[0].where).toContain('spend_by_category'); expect(findings[0].message).toContain('chartConfig.yAxis[0].field "amount"'); expect(findings[0].message).toContain('declared measures: sum_amount, ticket_count'); + // The TRUE consequence: the axis `field` is stripped, not queried and missed. + expect(findings[0].message).toContain('ignores an authored axis `field`'); + expect(findings[0].message).toContain('silent no-op'); + expect(findings[0].message).not.toContain('will not contain'); expect(findings[0].hint).toContain('Did you mean "sum_amount"?'); + expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_FIELD_UNKNOWN}']`); }); - it('(d) errors on xAxis.field that is not a dataset dimension', () => { + it('(d) warns on xAxis.field that is not a dataset dimension', () => { const findings = validateWidgetBindings(chartStack({ chartConfig: { type: 'bar', @@ -168,12 +180,19 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { }, })); expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].severity).not.toBe('error'); expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN); expect(findings[0].message).toContain('chartConfig.xAxis.field "categories"'); + // The derived binding the renderer keeps is NAMED, so the author can see + // what the chart is actually plotting. + expect(findings[0].message).toContain('x-axis stays bound to this widget\'s first dimension (category)'); + expect(findings[0].message).not.toContain('will not contain'); expect(findings[0].hint).toContain('Did you mean "category"?'); + expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_FIELD_UNKNOWN}']`); }); - it('(d) errors on series[].name that resolves to no selected measure', () => { + it('(d) warns on series[].name that resolves to no selected measure', () => { const findings = validateWidgetBindings(chartStack({ chartConfig: { type: 'bar', @@ -181,8 +200,17 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { }, })); expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].severity).not.toBe('error'); expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN); expect(findings[0].message).toContain('chartConfig.series[0].name "value"'); + // A series entry is matched BY NAME, so an unmatched entry is dropped + // WHOLE — its presentation lands on nothing. That is a different sentence + // from the axis positions, and the difference is the point. + expect(findings[0].message).toContain('matches an authored entry BY NAME'); + expect(findings[0].message).toContain('lands on nothing'); + expect(findings[0].message).not.toContain('will not contain'); + expect(findings[0].hint).toContain('`series[].name` selects WHICH derived series'); }); it('(d) a declared-but-unselected measure gets the targeted message', () => { @@ -194,11 +222,35 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { }, })); expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN); expect(findings[0].message).toContain('not selected in the widget\'s values'); + expect(findings[0].message).not.toContain('will not contain'); expect(findings[0].hint).toContain('Add "ticket_count" to the widget\'s values'); }); + it('(d) all three refused keys are suppressible per widget (#15463)', () => { + // Suppressibility is what the tier BUYS, and it is per widget rather than + // global — pinned on one widget carrying all three positions at once so a + // half-applied suppression (one position still shouting) reds here. + const allThree = { + type: 'bar', + xAxis: { field: 'categories' }, + yAxis: [{ field: 'amount' }], + series: [{ name: 'value' }], + }; + const loud = validateWidgetBindings(chartStack({ chartConfig: allThree })); + expect(loud.map((f) => f.rule)).toEqual([ + CHART_FIELD_UNKNOWN, CHART_FIELD_UNKNOWN, CHART_FIELD_UNKNOWN, + ]); + expect(loud.every((f) => f.severity === 'warning')).toBe(true); + const quiet = validateWidgetBindings(chartStack({ + chartConfig: allThree, + suppressWarnings: [CHART_FIELD_UNKNOWN], + })); + expect(quiet).toEqual([]); + }); + it('(d) warns when a `combo` widget has no chartConfig at all', () => { const findings = validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined })); expect(findings).toHaveLength(1); @@ -1673,3 +1725,24 @@ describe('#15462 acceptance — both ids are advisory on `validate` and `build`' }); } }); + +/** + * [#15463] The same end-to-end tier pin for `chart-field-unknown`, which USED to + * gate. It is the sharper of the two: this id did not arrive as a warning, it + * was demoted, and the demotion is visible on the publish door as well as on the + * CLI (`runtime-gate.test.ts` re-pins the door's accept-set). Nothing else in + * this file would notice a later flip back. + */ +describe('#15463 acceptance — the refused chartConfig binding keys advise, never gate', () => { + const refusedKey = chartStack({ + chartConfig: { type: 'bar', xAxis: { field: 'categories' }, yAxis: [{ field: 'sum_amount' }] }, + }); + + for (const command of ['validate', 'build'] as const) { + it(`chart-field-unknown advises (never gates) \`${command}\``, () => { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: refusedKey })); + expect(errors.map((f) => f.rule)).not.toContain(CHART_FIELD_UNKNOWN); + expect(advisories.map((f) => f.rule)).toContain(CHART_FIELD_UNKNOWN); + }); + } +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 8987e498f7..f1e5e6c78b 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -37,13 +37,6 @@ import { * name on the bound dataset. * - `widget-measure-unknown` — a `values[]` entry is not a measure name on * the bound dataset. - * - `chart-field-unknown` — a `chartConfig` binding names a field the query - * result will not contain: `xAxis.field` must be one of the widget's - * dimensions (or a dataset dimension), and each `yAxis[].field` / - * `series[].name` must be one of the widget's selected measures - * (`values`). Post-cutover (ADR-0021) the result rows are keyed by - * measure NAME (e.g. `sum_amount`), not the base column (`amount`) — a - * stale base-column reference renders the axis but an empty series. * - `widget-legacy-analytics-unrenderable` (#1878/#1894) — a widget uses the * removed pre-ADR-0021 inline-analytics shape (`categoryField`/`rowField`/…) * as its ONLY data wiring: no `dataset`, no `object`, no inline `data`. The @@ -77,6 +70,17 @@ import { * * Advisory rules — severity `warning`, build stays green: * + * - `chart-field-unknown` (#1721; tier ruled on #15463) — a `chartConfig` + * BINDING key names something the widget's selection does not carry: + * `xAxis.field` is not one of the widget's dimensions (or a dataset + * dimension), or a `yAxis[].field` / `series[].name` is not one of its + * selected measures (`values`). Post-cutover (ADR-0021) the result rows are + * keyed by measure NAME (e.g. `sum_amount`), not the base column (`amount`), + * so a stale base-column reference is the usual way to write one. The pinned + * renderer REFUSES all three keys as bindings — see "The three refused + * binding keys" below — so the authored key changes nothing that renders: it + * is a silent no-op, exactly `widget-legacy-analytics-shape`'s class. Kept as + * a finding because the author wrote a binding and believes it is in force. * - `chart-config-missing` — a `combo` widget has no `chartConfig`, so no * series carries a mark and the combination chart draws as one uniform * family. Narrowed to `combo` because `chartConfig` carries NO binding: @@ -244,7 +248,7 @@ import { * * So for every chart family except one, a missing `chartConfig` costs the * widget nothing at all, and ADDING one could not have repaired a widget whose - * selection is empty either — `chart-field-unknown` above refuses a + * selection is empty either — `chart-field-unknown` above reports a * `yAxis[].field` that names anything the widget did not select, so * `chartConfig` can never supply a measure the `values` array is missing. * @@ -303,6 +307,51 @@ import { * state an author passes THROUGH, and the family's errors are reserved for * bindings the analytics service cannot satisfy. * + * ### The three refused binding keys (#15463) + * + * `chart-field-unknown` shipped at `error` with a message that named a QUERY + * failure — *"the query result will not contain it"*. Read at the same PINNED + * `@object-ui` revision (`.objectui-sha`), that consequence never happens, + * because the renderer never reads these keys as bindings at all. The rule id + * covers exactly three positions, and all three are refused: + * + * - `chartConfig.xAxis.field` — `axisPresentation` + * (`@object-ui/core` `src/utils/chart-presentation.ts`) builds the axis's + * presentation MINUS its `field`, and that dropping is structural, not a + * guard: the x-axis key is `buildChartSeries`' `xAxisKey`, i.e. the widget's + * `dimensions[0]`. An authored `field` re-points nothing. + * - `chartConfig.yAxis[].field` — the same `axisPresentation` call, per entry. + * The entry keeps its SLOT (the count is what turns on a secondary axis) and + * its scale/chrome; only the binding is dropped. + * - `chartConfig.series[].name` — `mergeAuthoredSeries` pairs an authored + * entry with the derived binding whose `dataKey` it EQUALS, one series per + * entry of `values`. A name matching no derived series is *"**ignored** — + * membership belongs to the dataset, so an author cannot add, remove or + * re-point a series from the chart config"*. So the presentation the author + * hung on that entry — the mark, the colour, the stack, the axis side — + * lands on nothing. + * + * The renderer pins all three by name in + * `packages/plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.test.tsx` + * (*"ignores an authored axis `field` and keeps the derived axis binding"*, + * *"ignores an authored series and keeps one derived series per measure"*). + * + * So the failure is not a broken page, it is an ignored key — which is the + * class `widget-legacy-analytics-shape` above reports at WARNING tier in this + * same file (*"the dashboard renderer ignores them … a silent no-op"*). All + * three positions therefore drop from `error` to `warning`, suppressible per + * widget, and each message states what actually happens instead of a query that + * never runs. The finding is KEPT rather than deleted: unlike #14436's + * over-reach the metadata really is wrong — the author wrote a binding and + * believes it is in force. + * + * The tier drop is a behaviour change on the `sys_metadata` publish door: the + * 2026-08-15 ruling put all SIX of this rule's error ids on that door as one + * "this board cannot render" class, and this id leaves that set, so a publish + * carrying only a refused `chartConfig` binding key now SUCCEEDS with the + * finding on the advisory channel. The remaining five are unchanged; the + * accept-set is re-pinned in `runtime-gate.test.ts`. + * * Warnings can be deliberately suppressed per widget via * `suppressWarnings: ['']`; errors cannot — they describe a * binding the analytics service cannot satisfy. @@ -1042,6 +1091,14 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // measures; resolve every chartConfig field against that shape. const selectedValues = new Set(values.filter((v) => measures.has(v))); + // [#15463] All three positions below are WARNING tier. The pinned + // renderer refuses every one of them as a binding (see "The three + // refused binding keys" in the module docblock), so the authored key + // is ignored rather than mis-queried — `widget-legacy-analytics-shape`'s + // class, and it carries that class's tier and suppressibility. + const suppressHint = + `Suppress with suppressWarnings: ['${CHART_FIELD_UNKNOWN}'] if the inert key is intentional.`; + const xAxis = (chartConfig.xAxis && typeof chartConfig.xAxis === 'object') ? (chartConfig.xAxis as AnyRec) : undefined; @@ -1050,43 +1107,68 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { if (xAxis && typeof xAxis.field === 'string' && !dimensionNames.has(xAxis.field) && !dims.includes(xAxis.field)) { push({ - severity: 'error', + severity: 'warning', rule: CHART_FIELD_UNKNOWN, message: `chartConfig.xAxis.field "${xAxis.field}" does not resolve to a ` + - `dimension of dataset "${dsName}" (declared dimensions: ${list(dimensionNames)}).`, - hint: `Point xAxis.field at a dataset dimension name.${suggestName(xAxis.field, dimensionNames)}`, + `dimension of dataset "${dsName}" (declared dimensions: ${list(dimensionNames)}) — ` + + `and the dashboard renderer ignores an authored axis \`field\` in any case: ` + + `\`axisPresentation\` strips it, so the x-axis stays bound to this widget's ` + + `first dimension (${list(dims)}). The binding is a silent no-op, not a query ` + + `that fails.`, + hint: + `Point xAxis.field at a dataset dimension name, or drop the key — \`xAxis\` ` + + `carries presentation only (title, format, gridlines) and the axis binding ` + + `comes from this widget's \`dimensions\`.` + + `${suggestName(xAxis.field, dimensionNames)} ${suppressHint}`, }); } - const measureField = (label: string, field: string): void => { + // [#15463] The two measure-side positions are refused for DIFFERENT + // reasons — an axis `field` is stripped and the derived binding stands, + // while a `series[].name` is the MATCH KEY and an unmatched entry is + // dropped whole — so the consequence sentence is per position. + const measureField = (label: string, field: string, kind: 'axis' | 'series'): void => { if (values.includes(field)) return; // resolvable, or already errored via rule (c) const declaredButUnselected = measures.has(field); + const nameClause = declaredButUnselected + ? `chartConfig.${label} "${field}" is a measure of dataset "${dsName}" ` + + `but is not selected in the widget's values (${list(values)})` + : `chartConfig.${label} "${field}" does not resolve to a measure of ` + + `dataset "${dsName}" (declared measures: ${list(measures.keys())})`; + const consequence = kind === 'series' + ? `the dashboard renderer derives one series per selected measure and matches an ` + + `authored entry BY NAME, so this entry pairs with no series and the presentation ` + + `on it (mark, colour, stack, axis side) lands on nothing` + : `the dashboard renderer ignores an authored axis \`field\` — \`axisPresentation\` ` + + `strips it — so the y-axis bindings stay derived from this widget's values ` + + `(${list(values)}) and the key re-points nothing`; + const fixHint = declaredButUnselected + ? `Add "${field}" to the widget's values, or bind the chart to a selected measure.` + : `Post-cutover data is keyed by the dataset's measure NAME, not the ` + + `base column.${suggestName(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`; + const shapeHint = kind === 'series' + ? `\`series[].name\` selects WHICH derived series the presentation lands on; it ` + + `cannot add, remove or re-point one.` + : `\`yAxis[]\` carries presentation only (title, min/max, position); the bindings ` + + `come from \`values\`.`; push({ - severity: 'error', + severity: 'warning', rule: CHART_FIELD_UNKNOWN, - message: declaredButUnselected - ? `chartConfig.${label} "${field}" is a measure of dataset "${dsName}" ` + - `but is not selected in the widget's values (${list(values)}), so the ` + - `query result will not contain it.` - : `chartConfig.${label} "${field}" does not resolve to a measure of ` + - `dataset "${dsName}" (declared measures: ${list(measures.keys())}).`, - hint: declaredButUnselected - ? `Add "${field}" to the widget's values, or bind the chart to a selected measure.` - : `Post-cutover data is keyed by the dataset's measure NAME, not the ` + - `base column.${suggestName(field, selectedValues.size > 0 ? selectedValues : measures.keys())}`, + message: `${nameClause} — ${consequence}. It is a silent no-op, not a query that fails.`, + hint: `${fixHint} ${shapeHint} ${suppressHint}`, }); }; const yAxes = Array.isArray(chartConfig.yAxis) ? (chartConfig.yAxis as AnyRec[]) : []; for (let k = 0; k < yAxes.length; k++) { const field = yAxes[k]?.field; - if (typeof field === 'string') measureField(`yAxis[${k}].field`, field); + if (typeof field === 'string') measureField(`yAxis[${k}].field`, field, 'axis'); } const series = Array.isArray(chartConfig.series) ? (chartConfig.series as AnyRec[]) : []; for (let k = 0; k < series.length; k++) { const name = series[k]?.name; - if (typeof name === 'string') measureField(`series[${k}].name`, name); + if (typeof name === 'string') measureField(`series[${k}].name`, name, 'series'); } } else if (isMarkMixing) { push({