Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/chart-field-unknown-refused-binding-tier.md
Original file line number Diff line number Diff line change
@@ -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']`.
11 changes: 9 additions & 2 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,15 +547,22 @@ 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
// rule (traversal-duplication drift) or adding a per-finding-id surface
// 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',
Expand Down
52 changes: 39 additions & 13 deletions packages/lint/src/runtime-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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',
Expand All @@ -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' }]),
Expand All @@ -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)', () => {
Expand Down
81 changes: 77 additions & 4 deletions packages/lint/src/validate-widget-bindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -168,21 +180,37 @@ 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',
series: [{ name: 'value' }],
},
}));
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', () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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);
});
}
});
Loading
Loading