Skip to content

Commit c01b3a6

Browse files
claude[bot]claude
andauthored
fix(lint): chart-field-unknown warns on the chartConfig binding keys the pinned renderer refuses (#15463) (#15571)
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. Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba426b0 commit c01b3a6

5 files changed

Lines changed: 261 additions & 44 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`chart-field-unknown` drops to `warning` on the three `chartConfig` binding keys the pinned renderer refuses, and says what actually happens
6+
7+
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:
8+
9+
- `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.
10+
- `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.
11+
- `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.
12+
13+
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").
14+
15+
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.
16+
17+
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.
18+
19+
## Migration
20+
21+
**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.
22+
23+
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:
24+
25+
- point `xAxis.field` at a dimension the widget selects (or drop the key — `xAxis` carries presentation only);
26+
- point `yAxis[].field` at a selected measure (or drop it — `yAxis[]` carries presentation only);
27+
- 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`).
28+
29+
A deliberately inert key can be silenced per widget with `suppressWarnings: ['chart-field-unknown']`.

packages/lint/src/authoring-rules.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,15 +547,22 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
547547
// hold a forward reference; publishing refuses it with the key path named.
548548
// The snapshot carries `datasets` for exactly this rule (`RuntimeStackContext`
549549
// — without it every legitimate board reads as dangling, the 3-phantom
550-
// measurement). `surfaces` is per-RULE, so this flip puts all SIX of the
551-
// rule's error ids on the publish gate, not just `widget-dataset-unknown` —
550+
// measurement). `surfaces` is per-RULE, so this flip puts the rule's error
551+
// ids on the publish gate, not just `widget-dataset-unknown` —
552552
// ruled 2026-08-15: they are one coherent "this board cannot render"
553553
// reference-integrity class, and the ~6× wider accept-set narrowing was
554554
// accepted knowingly rather than splitting the dataset limb into its own
555555
// rule (traversal-duplication drift) or adding a per-finding-id surface
556556
// filter (registry machinery that weakens "delete a rule from the table and
557557
// enforcement stops in the same commit"). Warning-tier ids ride along on the
558558
// advisory channel and never block (#4463 P1).
559+
//
560+
// [#15463] That accepted class was SIX ids and is now FIVE:
561+
// `chart-field-unknown` dropped to `warning`, because the pinned `@object-ui`
562+
// renderer refuses its three `chartConfig` binding keys outright — the board
563+
// renders identically with or without them, so the id describes an ignored
564+
// key rather than a binding the analytics service cannot satisfy. It still
565+
// runs at the door and still reaches the author, on the advisory channel.
559566
{
560567
name: 'validateWidgetBindings',
561568
tier: 'gating',

packages/lint/src/runtime-gate.test.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -697,9 +697,14 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)',
697697
// a runtime-gated type, so the gate returned empty before building a snapshot.
698698
// Ruled 2026-08-12 (option B): a DRAFT may hold a forward reference;
699699
// publishing refuses with the key path named. Ruled 2026-08-15: `surfaces` is
700-
// per-RULE, so the flip puts all SIX of the rule's error ids on the door as
701-
// one "this board cannot render" reference-integrity class — pinned below as
702-
// reachable, not merely declared.
700+
// per-RULE, so the flip puts the rule's error ids on the door as one "this
701+
// board cannot render" reference-integrity class — pinned below as reachable,
702+
// not merely declared. That class was SIX ids until #15463 demoted
703+
// `chart-field-unknown` to `warning` (the pinned renderer refuses those three
704+
// `chartConfig` binding keys outright, so the board renders correctly either
705+
// way); it is FIVE now, and the demoted id is pinned on the advisory channel
706+
// immediately after, so leaving the accept-set is a measured departure rather
707+
// than an absence.
703708
// ─────────────────────────────────────────────────────────────────────
704709

705710
/** 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)'
824829
expect(dangling.errors).toHaveLength(1);
825830
});
826831

827-
it('ALL SIX error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
832+
it('ALL FIVE error ids are reachable at the door — the class the 2026-08-15 ruling accepted', () => {
828833
// `surfaces` is per-rule, so the flip enforces the rule's whole error set,
829-
// not just `widget-dataset-unknown`. The ruling accepted the six as one
830-
// coherent "this board cannot render" class — this test makes that
834+
// not just `widget-dataset-unknown`. The ruling accepted the class as one
835+
// coherent "this board cannot render" set — this test makes that
831836
// acceptance REACHABLE rather than declared, and its exact error sets keep
832837
// the boundary honest: a case gaining or losing an id is a scope change
833838
// that must go through this line rather than around it.
839+
//
840+
// [#15463] It went through this line: the set was SIX until
841+
// `chart-field-unknown` dropped to `warning`. Its case moves to the
842+
// advisory test below rather than being deleted — an id that leaves the
843+
// accept-set must still be shown to REACH the door, or "no longer gating"
844+
// and "no longer running" become indistinguishable here.
834845
const cases: Array<[string, unknown, string[]]> = [
835846
[
836847
'dangling dataset',
@@ -847,11 +858,6 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
847858
dashboard([cleanWidget({ values: ['no_such_measure'], chartConfig: { xAxis: { field: 'status' } } })]),
848859
['widget-measure-unknown'],
849860
],
850-
[
851-
'chartConfig field off the selection',
852-
dashboard([cleanWidget({ chartConfig: { xAxis: { field: 'not_a_dim' } } })]),
853-
['chart-field-unknown'],
854-
],
855861
[
856862
'legacy analytics shape as the only (dead) data wiring',
857863
dashboard([{ id: 'w9', type: 'bar', categoryField: 'status' }]),
@@ -876,15 +882,35 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
876882
for (const e of errors) seen.add(e.rule);
877883
}
878884

879-
// Non-vacuous, and the ruling's whole accept-set: exactly the six.
885+
// Non-vacuous, and the ruling's whole accept-set as #15463 left it:
886+
// exactly the five. `chart-field-unknown` is deliberately absent.
880887
expect([...seen].sort()).toEqual([
881-
'chart-field-unknown',
882888
'dashboard-filter-field-unknown',
883889
'widget-dataset-unknown',
884890
'widget-dimension-unknown',
885891
'widget-legacy-analytics-unrenderable',
886892
'widget-measure-unknown',
887893
]);
894+
expect(seen.has('chart-field-unknown')).toBe(false);
895+
});
896+
897+
it('the demoted `chart-field-unknown` still RUNS at the door — on the advisory channel (#15463)', () => {
898+
// The other half of the accept-set narrowing: the exact body that used to
899+
// 422 now publishes, and the finding reaches the author on the 2xx response
900+
// instead. Pinned as one case so "demoted" cannot decay into "dropped".
901+
const board = dashboard([cleanWidget({ chartConfig: { xAxis: { field: 'not_a_dim' } } })]);
902+
const result = gateDashboard(board);
903+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
904+
const f = result.advisories.find((a) => a.rule === 'chart-field-unknown');
905+
expect(f, 'the advisory must still reach the author').toBeDefined();
906+
expect(f!.severity).toBe('warning');
907+
expect(f!.path).toBe('dashboards[0].widgets[0]');
908+
expect(f!.message).toMatch(/not_a_dim/);
909+
// The message names the refusal, not a query that never runs.
910+
expect(f!.message).toContain('ignores an authored axis `field`');
911+
expect(f!.message).not.toContain('will not contain');
912+
// And the rule genuinely ran, rather than the door skipping the type.
913+
expect(result.rulesRun).toContain('validateWidgetBindings');
888914
});
889915

890916
it('warning-tier ids ride the ADVISORY channel and never block (#4463 P1)', () => {

packages/lint/src/validate-widget-bindings.test.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,13 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
142142
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
143143
});
144144

145-
it('(d) errors on the issue repro: yAxis.field naming the stale base column', () => {
145+
// [#15463] The three positions below are the WHOLE of what `chart-field-unknown`
146+
// covers, and all three are WARNING tier: the pinned `@object-ui` revision
147+
// (`.objectui-sha`) refuses every one of them as a binding, so the authored key
148+
// is ignored rather than mis-queried. The `error`-tier assertions these four
149+
// cases used to carry are inverted below — the tier itself is the finding this
150+
// card changed, so a silent flip back must red here rather than anywhere else.
151+
it('(d) warns on the issue repro: yAxis.field naming the stale base column', () => {
146152
const findings = validateWidgetBindings(chartStack({
147153
chartConfig: {
148154
type: 'bar',
@@ -151,15 +157,21 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
151157
},
152158
}));
153159
expect(findings).toHaveLength(1);
154-
expect(findings[0].severity).toBe('error');
160+
expect(findings[0].severity).toBe('warning');
161+
expect(findings[0].severity).not.toBe('error');
155162
expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN);
156163
expect(findings[0].where).toContain('spend_by_category');
157164
expect(findings[0].message).toContain('chartConfig.yAxis[0].field "amount"');
158165
expect(findings[0].message).toContain('declared measures: sum_amount, ticket_count');
166+
// The TRUE consequence: the axis `field` is stripped, not queried and missed.
167+
expect(findings[0].message).toContain('ignores an authored axis `field`');
168+
expect(findings[0].message).toContain('silent no-op');
169+
expect(findings[0].message).not.toContain('will not contain');
159170
expect(findings[0].hint).toContain('Did you mean "sum_amount"?');
171+
expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_FIELD_UNKNOWN}']`);
160172
});
161173

162-
it('(d) errors on xAxis.field that is not a dataset dimension', () => {
174+
it('(d) warns on xAxis.field that is not a dataset dimension', () => {
163175
const findings = validateWidgetBindings(chartStack({
164176
chartConfig: {
165177
type: 'bar',
@@ -168,21 +180,37 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
168180
},
169181
}));
170182
expect(findings).toHaveLength(1);
183+
expect(findings[0].severity).toBe('warning');
184+
expect(findings[0].severity).not.toBe('error');
171185
expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN);
172186
expect(findings[0].message).toContain('chartConfig.xAxis.field "categories"');
187+
// The derived binding the renderer keeps is NAMED, so the author can see
188+
// what the chart is actually plotting.
189+
expect(findings[0].message).toContain('x-axis stays bound to this widget\'s first dimension (category)');
190+
expect(findings[0].message).not.toContain('will not contain');
173191
expect(findings[0].hint).toContain('Did you mean "category"?');
192+
expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_FIELD_UNKNOWN}']`);
174193
});
175194

176-
it('(d) errors on series[].name that resolves to no selected measure', () => {
195+
it('(d) warns on series[].name that resolves to no selected measure', () => {
177196
const findings = validateWidgetBindings(chartStack({
178197
chartConfig: {
179198
type: 'bar',
180199
series: [{ name: 'value' }],
181200
},
182201
}));
183202
expect(findings).toHaveLength(1);
203+
expect(findings[0].severity).toBe('warning');
204+
expect(findings[0].severity).not.toBe('error');
184205
expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN);
185206
expect(findings[0].message).toContain('chartConfig.series[0].name "value"');
207+
// A series entry is matched BY NAME, so an unmatched entry is dropped
208+
// WHOLE — its presentation lands on nothing. That is a different sentence
209+
// from the axis positions, and the difference is the point.
210+
expect(findings[0].message).toContain('matches an authored entry BY NAME');
211+
expect(findings[0].message).toContain('lands on nothing');
212+
expect(findings[0].message).not.toContain('will not contain');
213+
expect(findings[0].hint).toContain('`series[].name` selects WHICH derived series');
186214
});
187215

188216
it('(d) a declared-but-unselected measure gets the targeted message', () => {
@@ -194,11 +222,35 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
194222
},
195223
}));
196224
expect(findings).toHaveLength(1);
225+
expect(findings[0].severity).toBe('warning');
197226
expect(findings[0].rule).toBe(CHART_FIELD_UNKNOWN);
198227
expect(findings[0].message).toContain('not selected in the widget\'s values');
228+
expect(findings[0].message).not.toContain('will not contain');
199229
expect(findings[0].hint).toContain('Add "ticket_count" to the widget\'s values');
200230
});
201231

232+
it('(d) all three refused keys are suppressible per widget (#15463)', () => {
233+
// Suppressibility is what the tier BUYS, and it is per widget rather than
234+
// global — pinned on one widget carrying all three positions at once so a
235+
// half-applied suppression (one position still shouting) reds here.
236+
const allThree = {
237+
type: 'bar',
238+
xAxis: { field: 'categories' },
239+
yAxis: [{ field: 'amount' }],
240+
series: [{ name: 'value' }],
241+
};
242+
const loud = validateWidgetBindings(chartStack({ chartConfig: allThree }));
243+
expect(loud.map((f) => f.rule)).toEqual([
244+
CHART_FIELD_UNKNOWN, CHART_FIELD_UNKNOWN, CHART_FIELD_UNKNOWN,
245+
]);
246+
expect(loud.every((f) => f.severity === 'warning')).toBe(true);
247+
const quiet = validateWidgetBindings(chartStack({
248+
chartConfig: allThree,
249+
suppressWarnings: [CHART_FIELD_UNKNOWN],
250+
}));
251+
expect(quiet).toEqual([]);
252+
});
253+
202254
it('(d) warns when a `combo` widget has no chartConfig at all', () => {
203255
const findings = validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined }));
204256
expect(findings).toHaveLength(1);
@@ -1673,3 +1725,24 @@ describe('#15462 acceptance — both ids are advisory on `validate` and `build`'
16731725
});
16741726
}
16751727
});
1728+
1729+
/**
1730+
* [#15463] The same end-to-end tier pin for `chart-field-unknown`, which USED to
1731+
* gate. It is the sharper of the two: this id did not arrive as a warning, it
1732+
* was demoted, and the demotion is visible on the publish door as well as on the
1733+
* CLI (`runtime-gate.test.ts` re-pins the door's accept-set). Nothing else in
1734+
* this file would notice a later flip back.
1735+
*/
1736+
describe('#15463 acceptance — the refused chartConfig binding keys advise, never gate', () => {
1737+
const refusedKey = chartStack({
1738+
chartConfig: { type: 'bar', xAxis: { field: 'categories' }, yAxis: [{ field: 'sum_amount' }] },
1739+
});
1740+
1741+
for (const command of ['validate', 'build'] as const) {
1742+
it(`chart-field-unknown advises (never gates) \`${command}\``, () => {
1743+
const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: refusedKey }));
1744+
expect(errors.map((f) => f.rule)).not.toContain(CHART_FIELD_UNKNOWN);
1745+
expect(advisories.map((f) => f.rule)).toContain(CHART_FIELD_UNKNOWN);
1746+
});
1747+
}
1748+
});

0 commit comments

Comments
 (0)