From efc9582c39944d07561b1bbb0f68d5874aee11ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 23:49:17 +0000 Subject: [PATCH 1/3] fix(lint): one-line fix lines for the 11 validate-widget-bindings ids (slice 17) (WIP) Each hint of the eleven ids is one remedy line of at most 200 characters; what the lines explained moves into RULE_EXPLANATIONS, behind os explain. table-count-only and widget-legacy-analytics-shape gain an entry. Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/lint/src/rule-explanations.ts | 78 +++++++- .../lint/src/validate-widget-bindings.test.ts | 188 ++++++++++++++++-- packages/lint/src/validate-widget-bindings.ts | 148 +++++++------- 3 files changed, 314 insertions(+), 100 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 9ce42225b07..a86e3406808 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -330,6 +330,28 @@ const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE_EXPLANATION: RuleExplanation = { ], }; +const WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION: RuleExplanation = { + rule: 'widget-legacy-analytics-shape', + covers: 'why the legacy analytics keys are ignored', + paragraphs: [ + 'The ADR-0021 single-form cutover removed the inline analytics shape — `categoryField`, ' + + '`valueField`, `xAxisField`, `yAxisFields`, `aggregate`, `aggregation`, `rowField` and ' + + '`columnField`. Dataset-bound widgets render through DatasetWidget, which reads only the ' + + 'semantic dataset: pivot rows and columns come from `dimensions`, cell values from `values`. ' + + 'These inline keys are a no-op.', + 'This id is the warning case: the widget also has a data source (`dataset`, `object`, or inline ' + + '`data`, top-level or under `options`), so it still renders and the legacy keys are only ignored ' + + 'noise. With no data source the widget renders nothing, and that is the error ' + + '`widget-legacy-analytics-unrenderable`.', + 'On the schema-parsed paths (`compile`, `validate`) the strict widget schema refuses these keys as ' + + 'a parse error before this rule runs; the rule is the steer on the raw-config paths (`lint`, ' + + '`doctor`).', + 'To fix it, delete the legacy keys and select fields by name through the widget\'s dataset. ' + + 'Suppress with `suppressWarnings: [\'widget-legacy-analytics-shape\']` if the keys are ' + + 'intentional.', + ], +}; + const DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { rule: 'dashboard-filter-field-unknown', covers: 'how a dashboard filter reaches every widget', @@ -348,6 +370,12 @@ const DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'a typo to correct, an inherited default is one the widget may opt out of. Not reported: a ' + 'widget that opts out, an object this stack does not define, and an object with no readable ' + 'field map (an ADR-0015 external object, an introspected datasource).', + 'A re-target may be a field of the dataset\'s object or a `relationship[.relationship].field` ' + + 'path. Every hop of such a path must be a relationship field (lookup, master_detail, user or ' + + 'tree; ADR-0021 derives every join from the object graph, so there is no ON clause to write), ' + + 'and its prefix must be declared in the dataset\'s `include` ' + + '(`dashboard-filter-field-not-included` otherwise). The verdict\'s "Did you mean" names the ' + + 'nearest field of the object the miss landed on.', ], }; @@ -384,8 +412,8 @@ const UNPROVISIONED_ANCHOR_CAUSE = * The three ways out of an unprovisioned anchor, which the one-line `fix:` of * the converted anchor ids (`unprovisionedAnchorFix()`) names without the how: * `sort-field-unprovisioned`, `searchable-field-unprovisioned`, - * `react-chart-field-unprovisioned` and the hook, action and flow-node - * write-anchor ids write it out. + * `react-chart-field-unprovisioned`, `dashboard-filter-field-unprovisioned` + * and the hook, action and flow-node write-anchor ids write it out. */ const UNPROVISIONED_ANCHOR_REMEDY = 'To fix it, check the remote table. If it really carries the column, declare it in the object\'s ' + @@ -409,6 +437,7 @@ const DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = { '`filterBindings: { NAME: false }`, or suppress with ' + '`suppressWarnings: [\'dashboard-filter-field-unprovisioned\']` if the remote schema resolves ' + 'the column some other way.', + UNPROVISIONED_ANCHOR_REMEDY, ], }; @@ -424,6 +453,11 @@ const WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'columns — and the verdict names the hop that failed. This rule judges the KEYS; ' + '`filter-token-unknown` judges the values in the same subtree. Not reported: an object this ' + 'stack does not define, or one with no readable field map.', + 'To fix it, filter on a field of the dataset\'s object, or on a `relationship[.relationship].field` ' + + 'path: every hop must be a relationship field (lookup, master_detail, user or tree; ADR-0021 ' + + 'derives every join from the object graph), and the prefix must be declared in the dataset\'s ' + + '`include` (`widget-filter-field-not-included` otherwise). The verdict\'s "Did you mean" names ' + + 'the nearest field of the object the miss landed on.', ], }; @@ -476,6 +510,12 @@ const CHART_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'refused on a dataset-bound widget (ADR-0021). Post-cutover data is keyed by the dataset\'s ' + 'measure NAME, not the base column. Suppress with `suppressWarnings: [\'chart-field-unknown\']` ' + 'if the inert key is intentional.', + 'What replaces each key: the x-axis is the widget\'s first `dimensions` entry; the y-axis ' + + 'bindings come from `values`, one entry per mark; series membership follows `values` (and a ' + + 'second `dimensions` entry to split). A measure named by a refused key that the dataset declares ' + + 'but the widget does not select is plotted by adding it to `values`. For existing sources, ' + + '`os migrate meta --from 17` lists the mechanical edits; `--write` applies the ones it can prove, ' + + 'and you apply the rest by hand.', ], }; @@ -490,7 +530,8 @@ const CHART_MEASURES_MISSING_EXPLANATION: RuleExplanation = { 'tabular widget. A chart with no measures is reported once, here, even when it also selects no ' + 'dimensions: the placeholder returns before the renderer ever tests for dimensions. A chart ' + 'family still needs a dimension to plot against (`chart-dimensions-missing`).', - 'To fix it, select at least one measure of the dataset by name in `values`. Suppress with ' + + 'To fix it, select at least one measure of the dataset by name in `values` (the `fix:` line ' + + 'names up to three the dataset declares). Suppress with ' + '`suppressWarnings: [\'chart-measures-missing\']` while the widget is still being authored.', ], }; @@ -505,8 +546,9 @@ const WIDGET_MEASURES_MISSING_EXPLANATION: RuleExplanation = { '`kpi`, `gauge`, `solid-gauge`, `bullet`) loses the one KPI number the tile exists to show; a ' + 'tabular one (`table`, `pivot`) loses its grid.', 'Such a widget needs no `dimensions`, so measures are the whole fix: select at least one measure ' + - 'of the dataset by name in `values`. Suppress with `suppressWarnings: [\'widget-measures-missing\']` ' + - 'while the widget is still being authored.', + 'of the dataset by name in `values` (the `fix:` line names up to three the dataset declares). ' + + 'Suppress with `suppressWarnings: [\'widget-measures-missing\']` while the widget is still being ' + + 'authored.', ], }; @@ -518,9 +560,27 @@ const CHART_DIMENSIONS_MISSING_EXPLANATION: RuleExplanation = { 'a chart-family widget that selects measures but no dimensions draws a single KPI number instead ' + 'of its declared chart. The number is real, so nothing looks broken — the declared chart family ' + 'is simply gone.', - 'To fix it, plot the chart against a dataset dimension (`dimensions`), or, if a single value IS ' + - 'what the tile should show, declare it as a `metric` or `kpi` widget so the type matches what ' + - 'renders. Suppress with `suppressWarnings: [\'chart-dimensions-missing\']`.', + 'To fix it, plot the chart against a dataset dimension (`dimensions`; the `fix:` line names up to ' + + 'three the dataset declares), or, if a single value IS what the tile should show, declare it as ' + + 'a `metric` or `kpi` widget so the type matches what renders. Suppress with ' + + '`suppressWarnings: [\'chart-dimensions-missing\']`.', + ], +}; + +const TABLE_COUNT_ONLY_EXPLANATION: RuleExplanation = { + rule: 'table-count-only', + covers: 'why a count-only table renders one summary row', + paragraphs: [ + 'A `table` or `pivot` widget bound to a dataset runs an analytics query: rows are the groups of ' + + 'its `dimensions`, cells the aggregates of its `values`. With no dimensions and only count ' + + 'measures (a derived measure is never counted as one), there is nothing to group by, so the ' + + 'widget renders a single summary row — not a per-record list.', + 'A flat record listing is not an analytics dataset. Model it as an object-bound ListView ' + + '(ADR-0017) surfaced through app navigation, and use a `metric` widget on the dashboard if only ' + + 'the count is needed. If a single-row table is intentional, add an explicit dimension or ' + + 'suppress with `suppressWarnings: [\'table-count-only\']`.', + 'Not reported: a widget that selects a dimension, selects no measures (`widget-measures-missing`), ' + + 'or names a measure the dataset does not declare (`widget-measure-unknown`).', ], }; @@ -4009,6 +4069,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [VIEW_ROW_COLOR_UNRESOLVABLE_VALUE_EXPLANATION.rule]: VIEW_ROW_COLOR_UNRESOLVABLE_VALUE_EXPLANATION, [WEBHOOK_WITHOUT_TRIGGERS_EXPLANATION.rule]: WEBHOOK_WITHOUT_TRIGGERS_EXPLANATION, [WIDGET_LEGACY_ANALYTICS_UNRENDERABLE_EXPLANATION.rule]: WIDGET_LEGACY_ANALYTICS_UNRENDERABLE_EXPLANATION, + [WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION.rule]: WIDGET_LEGACY_ANALYTICS_SHAPE_EXPLANATION, [DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION, [DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION, [DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION.rule]: DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION, @@ -4019,6 +4080,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [CHART_MEASURES_MISSING_EXPLANATION.rule]: CHART_MEASURES_MISSING_EXPLANATION, [WIDGET_MEASURES_MISSING_EXPLANATION.rule]: WIDGET_MEASURES_MISSING_EXPLANATION, [CHART_DIMENSIONS_MISSING_EXPLANATION.rule]: CHART_DIMENSIONS_MISSING_EXPLANATION, + [TABLE_COUNT_ONLY_EXPLANATION.rule]: TABLE_COUNT_ONLY_EXPLANATION, [DATASET_INCLUDE_UNKNOWN_EXPLANATION.rule]: DATASET_INCLUDE_UNKNOWN_EXPLANATION, [DATASET_FIELD_UNKNOWN_EXPLANATION.rule]: DATASET_FIELD_UNKNOWN_EXPLANATION, [DATASET_FIELD_NOT_INCLUDED_EXPLANATION.rule]: DATASET_FIELD_NOT_INCLUDED_EXPLANATION, diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index c688b50c492..003a68ccfc4 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -50,9 +50,27 @@ const SHORTENED_RULE_IDS: readonly string[] = [ CHART_DIMENSIONS_MISSING, ]; const firedShortened: Array<{ rule: string; message: string }> = []; +// [#22161] Slice 17: the ids whose `fix:` line this file shortened. Every +// finding of them is recorded too, and the hint block at the foot of the file +// holds each recorded `fix:` line to one line of at most 200 characters. +const HINT_SHORTENED_RULE_IDS: readonly string[] = [ + DASHBOARD_FILTER_FIELD_UNPROVISIONED, + CHART_FIELD_UNKNOWN, + DASHBOARD_FILTER_FIELD_UNKNOWN, + CHART_MEASURES_MISSING, + WIDGET_LEGACY_ANALYTICS_SHAPE, + WIDGET_MEASURES_MISSING, + WIDGET_FILTER_FIELD_UNKNOWN, + WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, + TABLE_COUNT_ONLY, + CHART_DIMENSIONS_MISSING, + DASHBOARD_FILTER_FIELD_NOT_INCLUDED, +]; +const firedHints: Array<{ rule: string; message: string; hint: string }> = []; const validateWidgetBindings: typeof validateWidgetBindingsUnrecorded = (...args) => { const findings = validateWidgetBindingsUnrecorded(...args); for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f); + for (const f of findings) if (HINT_SHORTENED_RULE_IDS.includes(f.rule)) firedHints.push(f); return findings; }; @@ -198,8 +216,12 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { // (pinned at the foot of this file), not the one-line verdict's. expect(findings[0].message).toContain('an authored axis `field` re-points nothing'); 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}']`); + // [#22161] One remedy line plus the nearest selected measure; the + // suppression is the `os explain` entry's. + expect(findings[0].hint).toBe( + 'Delete the key: `chartConfig.yAxis` is refused on a dataset-bound widget; select the measure BY NAME ' + + 'in the widget\'s values. Did you mean "sum_amount"?', + ); }); it('(d) warns on xAxis.field that is not a dataset dimension', () => { @@ -219,8 +241,10 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { // 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}']`); + expect(findings[0].hint).toBe( + 'Delete the key: `chartConfig.xAxis` is refused on a dataset-bound widget, and the x-axis comes from ' + + 'this widget\'s `dimensions`. Did you mean "category"?', + ); }); it('(d) warns on series[].name that resolves to no selected measure', () => { @@ -248,7 +272,7 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { // (ADR-0021; ruling 2026-09-12), so advice about how to use it correctly // would be advice about a document the parse refuses. expect(findings[0].hint).toContain('`chartConfig.series` is refused on a dataset-bound widget'); - expect(findings[0].hint).toContain('Delete the key.'); + expect(findings[0].hint).toMatch(/^Delete the key: /); }); it('(d) a declared-but-unselected measure gets the targeted message', () => { @@ -264,7 +288,10 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { 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'); + expect(findings[0].hint).toBe( + 'Delete the key: `chartConfig.yAxis` is refused on a dataset-bound widget; to plot "ticket_count", add ' + + 'it to the widget\'s values.', + ); }); it('(d) all three refused keys are suppressible per widget (#15463)', () => { @@ -440,8 +467,12 @@ describe('validateWidgetBindings (table-count-only, issue #1719)', () => { expect(warnings[0].path).toBe('dashboards[0].widgets[0]'); expect(warnings[0].message).toContain('report_count'); expect(warnings[0].message).toContain('single summary row'); - expect(warnings[0].hint).toContain('ListView (ADR-0017)'); - expect(warnings[0].hint).toContain(`suppressWarnings: ['${TABLE_COUNT_ONLY}']`); + // [#22161] One remedy line; ADR-0017 and the suppression are the `os + // explain` entry's (pinned at the foot of this file). + expect(warnings[0].hint).toBe( + "List records with an object-bound ListView in app navigation, use a 'metric' widget for the count, " + + 'or add a dimension to group the rows.', + ); }); it('warns for pivot widgets too', () => { @@ -891,7 +922,11 @@ describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8 expect(f.message).toContain('injected column with no storage'); // [#22161] The SQLite constant-false degradation is the `os explain` entry's. expect(f.message).toContain('the widget renders empty'); - expect(f.hint).toContain("columnMap"); + // [#22161] The shared one-line anchor fix. + expect(f.hint).toBe( + "If the remote table carries 'owner_id', declare it in ext_customer's own fields (mapped through " + + 'columnMap); otherwise drop the reference or opt the object out of the injection', + ); expect(f.path).toBe('dashboards[0].widgets[0]'); // The existence rule is UNCHANGED — the name still resolves. expect(unknownOnly(validateWidgetBindings(stack()))).toHaveLength(0); @@ -1076,7 +1111,13 @@ describe('dashboard-filter-field-unknown — gap 1: dotted paths are resolved (# // The verdict travelled: the miss is reported against crm_ACCOUNT, the // object the leaf lives on, not against the dataset's base object. expect(findings[0].message).toContain('crm_account'); - expect(findings[0].hint).toContain('signed_at'); + // [#22161] The nearest field is the verdict's "Did you mean"; the hint is + // the remedy alone, with no field list. + expect(findings[0].message).toContain('Did you mean "signed_at"?'); + expect(findings[0].hint).toBe( + "Point filterBindings: { dateRange: '' } at a path that resolves on `crm_deal`, or opt out with " + + 'filterBindings: { dateRange: false }.', + ); }); it('errors when a hop is not a relationship at all', () => { @@ -1136,7 +1177,11 @@ describe('dashboard-filter-field-not-included — the include clause (#14275)', // The consequence that makes this a p2 rather than the widget-level twin: // the filter reaches every widget on the board. expect(f.message).toContain('the whole board renders empty'); - expect(f.hint).toContain('(none)'); + // [#22161] The remedy alone: the declared include paths are not echoed. + expect(f.hint).toBe( + 'Add "account" to dataset "deal_metrics"\'s include, filter on a field of "crm_deal" itself, or opt out ' + + 'with filterBindings: { dateRange: false }.', + ); }); it('is silent for a bare base column — it needs no join', () => { @@ -1323,9 +1368,14 @@ describe('widget-filter-field-unknown (#14148 limb A)', () => { expect(f.message).toContain('due_daet'); expect(f.message).toContain('duly_task'); expect(f.path).toBe('dashboards[0].widgets[0].filter.due_daet'); - // The "did you mean", and the object's field list, both present. + // The "did you mean" is the verdict's; [#22161] the hint is the remedy + // alone, and the object's field list is no longer echoed. expect(f.message).toContain('Did you mean "due_date"?'); - expect(f.hint).toContain('business_unit'); + expect(f.hint).toBe( + 'Filter on a field of "duly_task" (dataset "duly_workload"), or on a relationship path whose prefix that ' + + "dataset's `include` declares.", + ); + expect(f.hint).not.toContain('business_unit'); }); it('is silent on the clean shape — a real column', () => { @@ -1572,8 +1622,10 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { // renderer returns — not an inferred one. expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.'); expect(findings[0].message).toContain('no chart is drawn at all'); - expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count'); - expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_MEASURES_MISSING}']`); + // [#22161] One remedy line naming the declared measures (at most three). + expect(findings[0].hint).toBe( + 'Select a measure of dataset "expense_line_metrics" BY NAME in `values` (declared: sum_amount, ticket_count).', + ); }); it('(1) an absent `values` key reports the same shape as an empty array', () => { @@ -1593,10 +1645,10 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { // The consequence. [#22161] The pinned `isMetric` expression that produces // it is quoted by `os explain chart-dimensions-missing` (foot of this file). expect(findings[0].message).toContain('draws a single KPI number'); - expect(findings[0].hint).toContain('declared dimensions: category'); + expect(findings[0].hint).toContain('(declared: category)'); // The honest alternative repair: declare the family that actually renders. expect(findings[0].hint).toContain("'metric' or 'kpi'"); - expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_DIMENSIONS_MISSING}']`); + expect(findings[0].hint).not.toContain('suppressWarnings'); }); it('each shape is suppressible per widget', () => { @@ -1626,7 +1678,7 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { })); expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]); // The dimension is still named, so one fix round closes both. - expect(findings[0].hint).toContain(CHART_DIMENSIONS_MISSING); + expect(findings[0].hint).toMatch(/, and a dimension to plot it against\.$/); }); it('a single-value or tabular family with no dimensions is NOT the dimensions finding', () => { @@ -1775,8 +1827,10 @@ describe('widget-measures-missing (#15508)', () => { expect(findings[0].message).toContain('its KPI number is not drawn at all'); // ...and NOT the chart family's consequence, which would be false here. expect(findings[0].message).not.toContain('no chart is drawn'); - expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count'); - expect(findings[0].hint).toContain(`suppressWarnings: ['${WIDGET_MEASURES_MISSING}']`); + expect(findings[0].hint).toBe( + 'Select a measure of dataset "expense_line_metrics" BY NAME in `values` (declared: sum_amount, ' + + "ticket_count); a 'metric' widget needs no `dimensions`.", + ); }); it('warns when a `table` selects no measures, naming the TABLE consequence', () => { @@ -1830,7 +1884,7 @@ describe('widget-measures-missing (#15508)', () => { })); expect(findings).toHaveLength(1); expect(findings[0].rule).toBe(WIDGET_MEASURES_MISSING); - expect(findings[0].hint).not.toContain(CHART_DIMENSIONS_MISSING); + expect(findings[0].hint).not.toContain('a dimension to plot it against'); expect(findings[0].hint).toContain('needs no `dimensions`'); }); @@ -1894,7 +1948,7 @@ describe('widget-measures-missing (#15508)', () => { }], }); expect(rules(findings)).toEqual([WIDGET_MEASURES_MISSING]); - expect(findings[0].hint).toContain('declared measures: user_count'); + expect(findings[0].hint).toContain('(declared: user_count)'); }); }); @@ -2002,3 +2056,93 @@ describe('[#22161] one-line verdicts — the rule ids this file shortened', () = for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); }); }); + +// [#22161] Slice 17: the `fix:` line of the eleven ids above whose hint ran +// past 200 characters. One remedy line each; the field, include-path and full +// measure or dimension lists are no longer echoed (the verdict's "Did you +// mean" names the nearest field, and an empty selection names at most three +// declared names), and the reasoning and the suppression are each id's +// `os explain` entry. +describe('[#22161] one-line fix — the rule ids whose `fix:` line this file shortened', () => { + it('every `fix:` line the cases above fired for those ids is one line of at most 200 characters', () => { + // Coverage control: each id fired, and every hint arm of the multi-arm ids. + expect([...new Set(firedHints.map((f) => f.rule))].sort()).toEqual([...HINT_SHORTENED_RULE_IDS].sort()); + const arms: Array<[string, (f: { rule: string; message: string; hint: string }) => boolean]> = [ + ['filter: dotted', (f) => f.rule === DASHBOARD_FILTER_FIELD_UNKNOWN && f.hint.includes('at a path that resolves')], + ['filter: explicit', (f) => f.rule === DASHBOARD_FILTER_FIELD_UNKNOWN && f.hint.includes('at a field that exists')], + ['filter: inherited', (f) => f.rule === DASHBOARD_FILTER_FIELD_UNKNOWN && f.hint.startsWith('Set filterBindings')], + ['chart: xAxis', (f) => f.rule === CHART_FIELD_UNKNOWN && f.hint.includes('`chartConfig.xAxis`')], + ['chart: yAxis', (f) => f.rule === CHART_FIELD_UNKNOWN && f.hint.includes('`chartConfig.yAxis`')], + ['chart: series', (f) => f.rule === CHART_FIELD_UNKNOWN && f.hint.includes('`chartConfig.series`')], + ['chart: declared, unselected', (f) => f.rule === CHART_FIELD_UNKNOWN && f.hint.includes('to plot "')], + ['measures: no dimensions either', (f) => f.rule === CHART_MEASURES_MISSING && f.hint.includes('a dimension to plot it against')], + ['measures: with dimensions', (f) => f.rule === CHART_MEASURES_MISSING && !f.hint.includes('a dimension to plot it against')], + ]; + for (const [arm, test] of arms) expect(firedHints.some(test), arm).toBe(true); + for (const f of firedHints) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + for (const gone of ['suppressWarnings', 'Object fields:', 'Fields on "', 'Declared include paths', 'declared measures:', 'declared dimensions:']) { + expect(f.hint, `${f.rule}: ${gone}`).not.toContain(gone); + } + } + }); + + it('an empty selection names at most three declared names, then an ellipsis', () => { + const many = ['m_one', 'm_two', 'm_three', 'm_four', 'm_five']; + const findings = validateWidgetBindings({ + datasets: [{ + name: 'wide_metrics', object: 'o', + dimensions: [{ name: 'stage', field: 'stage' }], + measures: many.map((name) => ({ name, aggregate: 'count' })), + }], + dashboards: [{ name: 'd', widgets: [{ id: 'w', type: 'bar', dataset: 'wide_metrics', dimensions: ['stage'], values: [] }] }], + }); + expect(findings.map((f) => f.rule)).toEqual([CHART_MEASURES_MISSING]); + expect(findings[0].hint).toBe('Select a measure of dataset "wide_metrics" BY NAME in `values` (declared: m_one, m_two, m_three, …).'); + }); + + it('a dataset that declares no measures says so instead of listing none', () => { + const findings = validateWidgetBindings({ + datasets: [{ name: 'bare_metrics', object: 'o', dimensions: [{ name: 'stage', field: 'stage' }], measures: [] }], + dashboards: [{ name: 'd', widgets: [{ id: 'w', type: 'kpi', dataset: 'bare_metrics', values: [] }] }], + }); + expect(findings.map((f) => f.rule)).toEqual([WIDGET_MEASURES_MISSING]); + expect(findings[0].hint).toBe( + "Select a measure of dataset \"bare_metrics\" BY NAME in `values` (it declares no measures yet); a 'kpi' widget needs no `dimensions`.", + ); + }); + + it('the two legacy-shape ids name the semantic shape in one line', () => { + const shape = firedHints.find((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE)!; + expect(shape.hint).toMatch(/^Delete the legacy keys? and select fields BY NAME through the widget's dataset: `dataset`, `dimensions`, `values`\.$/); + const dead = firedHints.find((f) => f.rule === WIDGET_LEGACY_ANALYTICS_UNRENDERABLE)!; + expect(dead.hint).toBe("Bind a semantic dataset and select fields BY NAME: `dataset: '', dimensions: [...], values: [...]`."); + }); + + // What each `fix:` line stopped saying, which `os explain RULE_ID` now prints. + const MOVED_FROM_HINT: Record = { + [DASHBOARD_FILTER_FIELD_UNPROVISIONED]: ['`columnMap`', '`ownership: \'none\'`', 'filterBindings: { NAME: false }', "suppressWarnings: ['dashboard-filter-field-unprovisioned']"], + [CHART_FIELD_UNKNOWN]: ['os migrate meta --from 17', 'one entry per mark', 'second `dimensions` entry to split', "suppressWarnings: ['chart-field-unknown']"], + [DASHBOARD_FILTER_FIELD_UNKNOWN]: ['`relationship[.relationship].field`', 'lookup, master_detail, user or tree', '`include`', '"Did you mean"'], + [CHART_MEASURES_MISSING]: ['chart-dimensions-missing', 'up to three', "suppressWarnings: ['chart-measures-missing']"], + [WIDGET_LEGACY_ANALYTICS_SHAPE]: ['DatasetWidget', 'pivot rows and columns come from `dimensions`', 'no-op', "suppressWarnings: ['widget-legacy-analytics-shape']"], + [WIDGET_MEASURES_MISSING]: ['measures are the whole fix', 'up to three', "suppressWarnings: ['widget-measures-missing']"], + [WIDGET_FILTER_FIELD_UNKNOWN]: ['`relationship[.relationship].field`', 'lookup, master_detail, user or tree', '"Did you mean"'], + [WIDGET_LEGACY_ANALYTICS_UNRENDERABLE]: ['DatasetWidget', 'pivot rows and columns come from `dimensions`', 'has no data at all'], + [TABLE_COUNT_ONLY]: ['A flat record listing is not an analytics dataset', 'ListView', 'ADR-0017', "suppressWarnings: ['table-count-only']"], + [CHART_DIMENSIONS_MISSING]: ['`metric` or `kpi`', 'up to three', "suppressWarnings: ['chart-dimensions-missing']"], + [DASHBOARD_FILTER_FIELD_NOT_INCLUDED]: ['declaring `a.b` implicitly includes `a`', 'filterBindings: { NAME: false }'], + }; + + it('covers exactly the ids whose `fix:` line was shortened', () => { + expect(Object.keys(MOVED_FROM_HINT).sort()).toEqual([...HINT_SHORTENED_RULE_IDS].sort()); + }); + + it.each([...HINT_SHORTENED_RULE_IDS])('`os explain %s` carries what its `fix:` line no longer says', (rule) => { + const explanation = explainRule(rule); + expect(explanation, `no \`os explain ${rule}\` entry`).toBeDefined(); + const text = explanation!.paragraphs.join('\n'); + for (const fact of MOVED_FROM_HINT[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + }); +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index c07dd85cd59..d5b1b5debf3 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -16,7 +16,7 @@ import { } from './object-graph.js'; import { indexUnprovisionedAnchors, - unprovisionedAnchorHint, + unprovisionedAnchorFix, unprovisionedAnchorVerdict, } from './system-fields.js'; @@ -588,6 +588,18 @@ function list(names: Iterable): string { return arr.length > 0 ? arr.join(', ') : '(none)'; } +/** + * [#22161] The bounded form of {@link list} for a one-line `fix:`: at most the + * first three declared names, so a dataset with forty measures cannot stretch + * the remedy past one line. An empty selection has no "Did you mean" to point + * at, so the names stay, bounded, rather than dropped. + */ +function declaredFew(names: Iterable, noun: string): string { + const arr = [...names]; + if (arr.length === 0) return `it declares no ${noun} yet`; + return `declared: ${arr.length > 3 ? `${arr.slice(0, 3).join(', ')}, …` : arr.join(', ')}`; +} + // ── dashboard-filter field-existence (#3365) ───────────────────────────────── /** Reserved filter name for the dashboard's built-in date range (#2501). */ @@ -779,11 +791,9 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { w.data !== undefined || optionsData; const keyList = legacyUsed.map((k) => `\`${k}\``).join(', '); const plural = legacyUsed.length > 1; - const datasetHint = - `Bind a semantic dataset and select fields BY NAME: ` + - `\`dataset: '', dimensions: [...], values: [...]\`. ` + - `Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` + - `\`dimensions\`, cell values from \`values\`).`; + // [#22161] One remedy line per id. How DatasetWidget reads a widget + // (pivot rows from `dimensions`, cells from `values`), that the legacy + // keys are ignored, and the suppression are each id's `os explain`. if (!hasDataSource) { push({ severity: 'error', @@ -792,8 +802,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `sets legacy analytics key${plural ? 's' : ''} ${keyList}, removed by the ADR-0021 ` + `cutover, and binds no \`dataset\`, \`object\` or inline \`data\`, so it renders nothing`, hint: - `${datasetHint} The renderer ignores the legacy keys, so without a data ` + - `source this widget has no data at all.`, + `Bind a semantic dataset and select fields BY NAME: ` + + `\`dataset: '', dimensions: [...], values: [...]\`.`, }); } else { push({ @@ -803,8 +813,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `sets legacy analytics key${plural ? 's' : ''} ${keyList} that the ADR-0021 ` + `single-form cutover removed — the dashboard renderer ignores ${plural ? 'them' : 'it'}.`, hint: - `${datasetHint} These inline keys are a no-op. ` + - `Suppress with suppressWarnings: ['${WIDGET_LEGACY_ANALYTICS_SHAPE}'] if intentional.`, + `Delete the legacy key${plural ? 's' : ''} and select fields BY NAME through the ` + + `widget's dataset: \`dataset\`, \`dimensions\`, \`values\`.`, }); } } @@ -906,11 +916,13 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { (eff.explicit ? `binds dashboard filter \`${def.name}\` (via filterBindings), but ` : `inherits dashboard filter \`${def.name}\`, but `) + account.message, + // [#22161] One remedy line: the verdict's "Did you mean" names + // the nearest field, so the hop's field list (`account.detail`) + // is not echoed; which fields traverse and the `include` rule + // for a dotted target are the `os explain` entry's. hint: - `Point filterBindings: { ${def.name}: '' } at a field that resolves on ` + - `\`${datasetObject}\` — or at a \`relationship[.relationship].field\` path whose ` + - `prefix is declared in dataset "${dsName}"'s \`include\` — or opt out with ` + - `filterBindings: { ${def.name}: false }. ${account.detail}`, + `Point filterBindings: { ${def.name}: '' } at a path that resolves on ` + + `\`${datasetObject}\`, or opt out with filterBindings: { ${def.name}: false }.`, }); continue; } @@ -923,13 +935,15 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `has no field \`${field}\`.` : `inherits dashboard filter \`${def.name}(${field})\`, but object ` + `\`${datasetObject}\` (dataset "${dsName}") has no field \`${field}\`.`, + // [#22161] One remedy line plus the nearest field; the object's + // field list is not echoed. hint: eff.explicit ? `Point filterBindings: { ${def.name}: '' } at a field that exists on ` + `\`${datasetObject}\`, or opt out with filterBindings: { ${def.name}: false }.` + - `${suggestName(field, base.names)} Object fields: ${list(base.names)}.` + suggestName(field, base.names) : `Set filterBindings: { ${def.name}: false } on this widget to opt out, or ` + `re-target to an existing field with filterBindings: { ${def.name}: '' }.` + - `${suggestName(field, base.names)} Object fields: ${list(base.names)}.`, + suggestName(field, base.names), }); continue; } @@ -950,11 +964,12 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { message: `${provenance}its relationship prefix "${prefix}" is not in dataset ` + `"${dsName}"'s \`include\`, so no join reaches it and the whole board renders empty`, + // [#22161] One remedy line; that "a.b" implicitly includes "a" + // is the `os explain` entry's, and the declared include paths + // are not echoed (the remedy adds the one that is missing). hint: - `Add "${prefix}" to dataset "${dsName}"'s include (declaring "a.b" implicitly ` + - `includes "a"), filter on a field of "${datasetObject}" itself, or opt out with ` + - `filterBindings: { ${def.name}: false }. Declared include paths: ` + - `${included.size > 0 ? [...included].sort().join(', ') : '(none)'}.`, + `Add "${prefix}" to dataset "${dsName}"'s include, filter on a field of ` + + `"${datasetObject}" itself, or opt out with filterBindings: { ${def.name}: false }.`, }); continue; } @@ -976,11 +991,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { severity: 'warning', rule: DASHBOARD_FILTER_FIELD_UNPROVISIONED, message: `${provenance}${unprovisionedAnchorVerdict(leafObject, leafField)}, so the widget renders empty`, - hint: - `${unprovisionedAnchorHint(leafObject, leafField)} A widget can also opt out ` + - `with filterBindings: { ${def.name}: false }. Suppress with ` + - `suppressWarnings: ['${DASHBOARD_FILTER_FIELD_UNPROVISIONED}'] if the remote schema ` + - `resolves it some other way.`, + // [#22161] The shared one-line anchor fix; how to vouch for or + // opt out of the anchor, the widget's `filterBindings` opt-out + // and the suppression are `os explain`. + hint: unprovisionedAnchorFix(leafObject, leafField), }); } } @@ -1011,10 +1025,12 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { rule: WIDGET_FILTER_FIELD_UNKNOWN, path: at, message: account.message, + // [#22161] One remedy line: the verdict's "Did you mean" names + // the nearest field, so the field list (`account.detail`) is + // not echoed; which fields traverse is the `os explain` entry's. hint: - `Filter on a field that exists on "${filterObject}" (dataset "${dsName}"), ` + - `or on a \`relationship[.relationship].field\` path whose prefix is declared ` + - `in that dataset's \`include\`. ${account.detail}`, + `Filter on a field of "${filterObject}" (dataset "${dsName}"), or on a relationship ` + + `path whose prefix that dataset's \`include\` declares.`, }); return; } @@ -1151,8 +1167,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // 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.`; + // [#22161] Each position's hint is one remedy line — delete the + // refused key — plus the nearest selectable name. How the renderer + // derives each binding, the `os migrate meta` route for existing + // sources and the suppression are `os explain chart-field-unknown`. const xAxis = (chartConfig.xAxis && typeof chartConfig.xAxis === 'object') ? (chartConfig.xAxis as AnyRec) @@ -1168,11 +1186,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `chartConfig.xAxis.field "${xAxis.field}" is not a dimension of dataset "${dsName}" ` + `and is ignored anyway: the x-axis stays bound to this widget's first dimension (${list(dims)})`, hint: - `Delete the key: \`chartConfig.xAxis\` is refused on a dataset-bound widget ` + - `(ADR-0021) and the x-axis binding comes from this widget's \`dimensions\`. ` + - `Run \`os migrate meta --from 17\` to list the mechanical edits for existing ` + - `sources; \`--write\` applies the ones it can prove, and you apply the rest by hand.` + - `${suggestName(xAxis.field, dimensionNames)} ${suppressHint}`, + `Delete the key: \`chartConfig.xAxis\` is refused on a dataset-bound widget, and the ` + + `x-axis comes from this widget's \`dimensions\`.${suggestName(xAxis.field, dimensionNames)}`, }); } @@ -1193,23 +1208,20 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { const consequence = kind === 'series' ? `so it pairs with no series and its presentation lands on nothing` : `and an authored axis \`field\` 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())}`; // Both positions are now REFUSED outright on a dataset-bound widget - // (ADR-0021; ruling 2026-09-12), so the shape sentence says delete - // rather than describing what the key used to carry. - const shapeHint = kind === 'series' - ? `\`chartConfig.series\` is refused on a dataset-bound widget: series membership ` + - `follows \`values\` (and a second \`dimensions\` entry to split). Delete the key.` - : `\`chartConfig.yAxis\` is refused on a dataset-bound widget: the bindings come ` + - `from \`values\`, one entry per mark. Delete the key.`; + // (ADR-0021; ruling 2026-09-12), so the remedy says delete rather + // than describing what the key used to carry. [#22161] One line: + // what to do instead, by whether the dataset declares the name. + const key = kind === 'series' ? 'chartConfig.series' : 'chartConfig.yAxis'; + const instead = declaredButUnselected + ? `; to plot "${field}", add it to the widget's values.` + : `; select the measure BY NAME in the widget's values.` + + suggestName(field, selectedValues.size > 0 ? selectedValues : measures.keys()); push({ severity: 'warning', rule: CHART_FIELD_UNKNOWN, message: `${nameClause}, ${consequence}`, - hint: `${fixHint} ${shapeHint} ${suppressHint}`, + hint: `Delete the key: \`${key}\` is refused on a dataset-bound widget${instead}`, }); }; @@ -1275,15 +1287,12 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { message: `'${w.type}' widget selects no measures (\`values\` is empty), so the renderer ` + `shows the "Pick measures (values) for this dataset widget." placeholder and no chart is drawn at all`, + // [#22161] One remedy line naming at most three declared measures; + // the placeholder short-circuit and the suppression are `os explain`. hint: - `Select at least one measure of dataset "${dsName}" BY NAME — ` + - `values: [''] (declared measures: ${list(measures.keys())}).` + - (dims.length === 0 - ? ` This widget selects no \`dimensions\` either; a chart family needs one ` + - `to plot against (see ${CHART_DIMENSIONS_MISSING}).` - : '') + - ` If the widget is deliberately still being authored, suppress with: ` + - `suppressWarnings: ['${CHART_MEASURES_MISSING}']`, + `Select a measure of dataset "${dsName}" BY NAME in \`values\` ` + + `(${declaredFew(measures.keys(), 'measures')})` + + (dims.length === 0 ? `, and a dimension to plot it against.` : '.'), }); } else if (isNonChartDatasetWidgetType(w.type)) { // The consequence is stated per family, because it differs: the @@ -1299,12 +1308,11 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { message: `'${w.type}' widget selects no measures (\`values\` is empty), so the renderer ` + `shows the "Pick measures (values) for this dataset widget." placeholder and ${drawn}`, + // [#22161] One remedy line naming at most three declared measures; + // which families need no dimensions and the suppression are `os explain`. hint: - `Select at least one measure of dataset "${dsName}" BY NAME — ` + - `values: [''] (declared measures: ${list(measures.keys())}). ` + - `A '${w.type}' widget needs no \`dimensions\`, so measures are the whole ` + - `fix. If the widget is deliberately still being authored, suppress with: ` + - `suppressWarnings: ['${WIDGET_MEASURES_MISSING}']`, + `Select a measure of dataset "${dsName}" BY NAME in \`values\` ` + + `(${declaredFew(measures.keys(), 'measures')}); a '${w.type}' widget needs no \`dimensions\`.`, }); } } else if (dims.length === 0) { @@ -1315,12 +1323,13 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { message: `'${w.type}' widget selects no dimensions, so the renderer draws a single KPI ` + `number instead of a '${w.type}' chart`, + // [#22161] One remedy line naming at most three declared + // dimensions; the renderer's `isMetric` test and the suppression + // are `os explain chart-dimensions-missing`. hint: - `Plot the chart against a dataset dimension — dimensions: [''] ` + - `(declared dimensions: ${list(dimensionNames)}) — or, if a single value ` + - `IS what this tile should show, declare it as a 'metric' or 'kpi' widget ` + - `so the type matches what renders. Suppress with: ` + - `suppressWarnings: ['${CHART_DIMENSIONS_MISSING}']`, + `Plot it against a dimension of dataset "${dsName}" ` + + `(${declaredFew(dimensionNames, 'dimensions')}), or declare it a 'metric' or 'kpi' ` + + `widget if one value is what it should show.`, }); } } @@ -1346,12 +1355,11 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { `a '${w.type}' widget bound to dataset "${dsName}" selects only count ` + `measure(s) (${values.join(', ')}) and no dimensions, so it renders a ` + `single summary row — not a per-record list.`, + // [#22161] One remedy line; why a record listing is not an analytics + // dataset and the suppression are `os explain table-count-only`. hint: - `A flat record listing is not an analytics dataset. Model it as an ` + - `object-bound ListView (ADR-0017) surfaced through app navigation, and ` + - `use a 'metric' widget here if you only need the count. If a single-row ` + - `table is intentional, add an explicit dimension or suppress with: ` + - `suppressWarnings: ['${TABLE_COUNT_ONLY}']`, + `List records with an object-bound ListView in app navigation, use a 'metric' widget ` + + `for the count, or add a dimension to group the rows.`, }); } } From baa3efa0215af096c61d8b4f2aeb1b08029370e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:16:23 +0000 Subject: [PATCH 2/3] chore(changeset): @objectstack/lint patch for slice 17's one-line fix lines (WIP) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../22161-s17-widget-binding-fix-lines.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/22161-s17-widget-binding-fix-lines.md diff --git a/.changeset/22161-s17-widget-binding-fix-lines.md b/.changeset/22161-s17-widget-binding-fix-lines.md new file mode 100644 index 00000000000..ac84041b500 --- /dev/null +++ b/.changeset/22161-s17-widget-binding-fix-lines.md @@ -0,0 +1,24 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): the 11 dashboard widget-binding rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + +Clause-②: no + +- **Shorter fix lines.** Each finding of these 11 rule ids (all in `validate-widget-bindings.ts`) now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - dashboard filters: `dashboard-filter-field-unprovisioned` (561 → 177), `dashboard-filter-field-unknown` (403 → 148) and `dashboard-filter-field-not-included` (218 → 144); + - widget filters: `widget-filter-field-unknown` (322 → 133); + - refused `chartConfig` keys: `chart-field-unknown` (415 → 152); + - empty selections: `chart-measures-missing` (369 → 144), `widget-measures-missing` (330 → 154) and `chart-dimensions-missing` (291 → 161); + - legacy analytics keys: `widget-legacy-analytics-shape` (338 → 113) and `widget-legacy-analytics-unrenderable` (320 → 105); + - tables: `table-count-only` (311 → 136). +- **Lists are no longer echoed.** `dashboard-filter-field-unknown` and `widget-filter-field-unknown` no longer append the object's whole field list, and `dashboard-filter-field-not-included` no longer lists the dataset's declared `include` paths: the verdict's (or, on a bare dashboard-filter field, the fix line's own) "Did you mean" names the nearest field. The three empty-selection ids still name what the dataset declares, because no "Did you mean" can: at most three names, then `…`, or "it declares no measures yet". +- **One line, one remedy.** The `suppressWarnings` clause leaves every warning's fix line, and with it: how DatasetWidget reads a dataset-bound widget; that a dotted filter target must traverse relationship fields and have its prefix in `include`, and that `a.b` includes `a`; how to vouch for or opt out of an unprovisioned anchor (`dashboard-filter-field-unprovisioned` now prints the shared one-line anchor fix the other anchor ids print); what replaces each refused `chartConfig` key, and the `os migrate meta --from 17` route; which widget families need no dimensions; and why a flat record listing is not an analytics dataset (ADR-0017). +- **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Two ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `table-count-only` and `widget-legacy-analytics-shape`. Seven existing entries gain text. +- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), all 11 fire on a `dashboard` write; the `hint` FROM → TO, measured at the door: + - 422 `issues[].hint`: `widget-legacy-analytics-unrenderable` 320 → 105; `dashboard-filter-field-unknown` 196 / 195 / 290 → 148 / 147 / 137 (an inherited bare field, a re-targeted bare field, a dotted path); `dashboard-filter-field-not-included` 213 → 139; `widget-filter-field-unknown` 220 → 126; + - 2xx `advisories[].hint`: `widget-legacy-analytics-shape` 338 → 112; `dashboard-filter-field-unprovisioned` 550 → 173; `chart-field-unknown` 413 / 294 / 324 → 150 / 124 / 127 (an x-axis, a y-axis, a series); `chart-measures-missing` 240 → 96; `widget-measures-missing` 310 → 134; `chart-dimensions-missing` 297 → 156; `table-count-only` 311 → 136. + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the seven advisories shorten there; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. +- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. From 33460e98bd632fe274a136c348ded90bedc415f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:28:02 +0000 Subject: [PATCH 3/3] fix(lint): chart-field-unknown's explanation carries the house os migrate meta sentence (WIP) spec's repo-tier pin judges every os migrate meta prescription in packages/lint/src; the moved sentence is now the house sentence, in one plain-quoted literal. Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- packages/lint/src/rule-explanations.ts | 5 ++--- packages/lint/src/validate-widget-bindings.test.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a86e3406808..0ebc18bbff7 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -513,9 +513,8 @@ const CHART_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { 'What replaces each key: the x-axis is the widget\'s first `dimensions` entry; the y-axis ' + 'bindings come from `values`, one entry per mark; series membership follows `values` (and a ' + 'second `dimensions` entry to split). A measure named by a refused key that the dataset declares ' + - 'but the widget does not select is plotted by adding it to `values`. For existing sources, ' + - '`os migrate meta --from 17` lists the mechanical edits; `--write` applies the ones it can prove, ' + - 'and you apply the rest by hand.', + 'but the widget does not select is plotted by adding it to `values`. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.', ], }; diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 003a68ccfc4..9063fab647f 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -2123,7 +2123,7 @@ describe('[#22161] one-line fix — the rule ids whose `fix:` line this file sho // What each `fix:` line stopped saying, which `os explain RULE_ID` now prints. const MOVED_FROM_HINT: Record = { [DASHBOARD_FILTER_FIELD_UNPROVISIONED]: ['`columnMap`', '`ownership: \'none\'`', 'filterBindings: { NAME: false }', "suppressWarnings: ['dashboard-filter-field-unprovisioned']"], - [CHART_FIELD_UNKNOWN]: ['os migrate meta --from 17', 'one entry per mark', 'second `dimensions` entry to split', "suppressWarnings: ['chart-field-unknown']"], + [CHART_FIELD_UNKNOWN]: ['Run `os migrate meta --from 17` to list the mechanical edits for existing sources', 'one entry per mark', 'second `dimensions` entry to split', "suppressWarnings: ['chart-field-unknown']"], [DASHBOARD_FILTER_FIELD_UNKNOWN]: ['`relationship[.relationship].field`', 'lookup, master_detail, user or tree', '`include`', '"Did you mean"'], [CHART_MEASURES_MISSING]: ['chart-dimensions-missing', 'up to three', "suppressWarnings: ['chart-measures-missing']"], [WIDGET_LEGACY_ANALYTICS_SHAPE]: ['DatasetWidget', 'pivot rows and columns come from `dimensions`', 'no-op', "suppressWarnings: ['widget-legacy-analytics-shape']"],