diff --git a/.changeset/22161-lint-slice-2-one-line.md b/.changeset/22161-lint-slice-2-one-line.md new file mode 100644 index 00000000000..182e135a0a1 --- /dev/null +++ b/.changeset/22161-lint-slice-2-one-line.md @@ -0,0 +1,18 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): 26 more author-time findings print one verdict line, and `os explain ` carries their reasoning + +Clause-②: no + +- **Shorter verdicts.** Each finding of these 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids: + - dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`; + - datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`; + - security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`; + - visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`. + + The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. +- **`os explain ` takes these 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 entries. +- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. +- **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call). diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index a515b043ba5..8e90b745b7a 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -304,6 +304,458 @@ const WEBHOOK_WITHOUT_TRIGGERS_EXPLANATION: RuleExplanation = { ], }; +// ── Dashboard widget bindings (`validate-widget-bindings.ts`) ────────────── +// A widget is bound to a semantic dataset (ADR-0021) and selects its +// dimensions and measures by name; these entries hold what each verdict about +// that binding no longer says. + +const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE_EXPLANATION: RuleExplanation = { + rule: 'widget-legacy-analytics-unrenderable', + covers: 'why the legacy analytics keys render nothing', + paragraphs: [ + 'The ADR-0021 single-form cutover removed the inline analytics shape — `categoryField`, ' + + '`valueField`, `xAxisField`, `yAxisFields`, `aggregate`, `aggregation`, `rowField` and ' + + '`columnField`. The dashboard renderer reads a widget\'s data only through its semantic dataset ' + + '(`dataset`, `dimensions`, `values`, rendered by DatasetWidget), so a legacy key is dead ' + + 'wherever it appears.', + 'This id is the error case: the legacy keys are the widget\'s only data wiring — no `dataset`, ' + + 'no `object`, no inline `data` (top-level or under `options`) — so the widget has no data at all ' + + 'and renders nothing. When a data source is present the widget still renders and the legacy ' + + 'keys are only ignored noise; that is the suppressible warning `widget-legacy-analytics-shape`.', + 'To fix it, bind a semantic dataset and select fields by name: `dataset`, `dimensions` and ' + + '`values` (pivot rows and columns come from `dimensions`, cell values from `values`).', + ], +}; + +const DASHBOARD_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'dashboard-filter-field-unknown', + covers: 'how a dashboard filter reaches every widget', + paragraphs: [ + 'A dashboard-level filter — the built-in `dateRange` (on `created_at` unless `dateRange.field` ' + + 'says otherwise) or a `globalFilters` entry — is ANDed into EVERY bound widget\'s analytics ' + + 'query. The field it lands on in one widget is the effective field: the filter\'s own field, ' + + 'unless the widget re-targets it with `filterBindings` or opts out with ' + + '`filterBindings: { NAME: false }`.', + 'When the effective field is not a column of the widget\'s dataset object, the query addresses a ' + + 'column that does not exist and the widget fails at query time. That is a broken query, not ' + + 'advice, so the rule is an error. A dotted field (`account.signed_at`) is resolved hop by hop on ' + + 'the object graph and the verdict names the hop that failed; a bare name is judged against the ' + + 'object\'s authored and registry-injected columns.', + 'How the widget came to carry the filter decides the fix: an explicit `filterBindings` target is ' + + '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).', + ], +}; + +const DASHBOARD_FILTER_FIELD_NOT_INCLUDED_EXPLANATION: RuleExplanation = { + rule: 'dashboard-filter-field-not-included', + covers: 'why a filter\'s relationship prefix must be included', + paragraphs: [ + 'ADR-0021 joins ONLY the relationship paths a dataset declares in `include`. A dashboard filter ' + + 'lands in the same `runtimeFilter` slot as a widget\'s own filter, and the compiler\'s ' + + '`assertDeclared` never sees a `runtimeFilter`, so there is no runtime door in front of this ' + + 'check: an effective field that resolves on the object graph but whose prefix is not declared ' + + 'compiles to no join, and the column is out of the query\'s reach.', + 'The filter is ANDed into EVERY bound widget\'s analytics query, so the whole board renders ' + + 'empty, not one tile.', + 'To fix it, add the prefix to the dataset\'s `include` (declaring `a.b` implicitly includes `a`), ' + + 'filter on a field of the dataset\'s own object, or opt the widget out with ' + + '`filterBindings: { NAME: false }`.', + ], +}; + +const DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = { + rule: 'dashboard-filter-field-unprovisioned', + covers: 'why a filter on an unprovisioned anchor matches nothing', + paragraphs: [ + 'The registry injects system columns — the ownership and audit anchors such as `owner_id`, ' + + '`created_at` and `created_by` — into objects. On an ADR-0015 external object the remote ' + + 'database owns the schema, so the platform registers these anchors without provisioning a ' + + 'column: the name resolves, but no storage stands behind it.', + 'A dashboard filter whose effective field lands on such an anchor is ANDed into the widget\'s ' + + 'analytics query and can never match a real value: on SQLite it silently degrades to ' + + 'constant-false and the widget renders empty (HTTP 200, zero rows, no error).', + 'Only a leaf that resolved BECAUSE it is injected is judged — an author-declared column of the ' + + 'same name is one the author vouches for — and a dotted path ending on an external object is ' + + 'judged too. The rule is a warning, not an error, because it cannot see the remote table, only ' + + 'that the platform provisions no storage. Opt the widget out with ' + + '`filterBindings: { NAME: false }`, or suppress with ' + + '`suppressWarnings: [\'dashboard-filter-field-unprovisioned\']` if the remote schema resolves ' + + 'the column some other way.', + ], +}; + +const WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'widget-filter-field-unknown', + covers: 'what an unresolved widget filter key does', + paragraphs: [ + 'A widget\'s own `filter` is ANDed into its dataset query as `runtimeFilter`. A key that names no ' + + 'column either widens the scope (the condition is dropped) or empties it, and the widget ' + + 'renders successfully either way: nothing reports the miss.', + 'Each key is resolved on the object graph against the dataset\'s object — a dotted ' + + '`relationship.field` path hop by hop, a bare name against the authored and registry-injected ' + + '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.', + ], +}; + +const WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION: RuleExplanation = { + rule: 'widget-filter-field-not-included', + covers: 'why a filter key\'s relationship prefix must be included', + paragraphs: [ + 'ADR-0021 joins ONLY the paths a dataset declares in `include`, and the compiler\'s ' + + '`assertDeclared` never sees a `runtimeFilter`, so an undeclared prefix has no runtime door in ' + + 'front of it: no join is compiled, the column is out of the query\'s reach, and the widget ' + + 'renders empty.', + 'To fix it, add the prefix to the dataset\'s `include` (declaring `a.b` implicitly includes `a`), ' + + 'or filter on a field of the dataset\'s own object.', + ], +}; + +const WIDGET_SORTBY_UNSELECTED_EXPLANATION: RuleExplanation = { + rule: 'widget-sortby-unselected', + covers: 'why sortBy must name a column the widget selects', + paragraphs: [ + '`options.sortBy` is lowered into the dataset selection\'s `order`, whose key must name a ' + + 'dimension or measure this widget selects — the contract `DashboardWidgetOptionsSchema.sortBy` ' + + 'states. A key that does not is either dropped in favour of the implicit ordering, silently, or ' + + 'refused by the executor (`resolveOrdering` throws `DATASET_INVALID`). Both lose the order the ' + + 'author wrote, and the first loses it in silence.', + 'Ordering is applied to the query RESULT, so it can only name a column that result carries: a ' + + 'name the dataset declares but this widget does not select is not in the result. The fix for ' + + 'that case is a `values` or `dimensions` entry, not a spelling correction.', + 'Judged against the AUTHORED `dimensions` and `values` arrays. An entry there that does not ' + + 'resolve is `widget-dimension-unknown` or `widget-measure-unknown`\'s finding and is not ' + + 'reported twice.', + ], +}; + +const CHART_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'chart-field-unknown', + covers: 'why chartConfig binding keys are ignored', + paragraphs: [ + 'On a dataset-bound widget the dashboard renderer derives every chart binding from the widget\'s ' + + '`dimensions` and `values`. An authored axis `field` — `chartConfig.xAxis.field` or ' + + '`chartConfig.yAxis[].field` — is stripped by `axisPresentation`, so the x-axis stays bound to ' + + 'the widget\'s first dimension and the y-axis bindings stay derived from its values.', + 'For `chartConfig.series` the renderer derives one series per selected measure and matches an ' + + 'authored entry BY NAME, so an entry naming no selected measure pairs with no series and the ' + + 'presentation on it (mark, colour, stack, axis side) lands on nothing.', + 'Each is a silent no-op, not a query that fails, so the rule is a warning. A field that already ' + + 'names an entry of the widget\'s selection is not reported, and a selection entry that does not ' + + 'resolve is `widget-dimension-unknown` or `widget-measure-unknown`\'s error.', + 'To fix it, delete the key: `chartConfig.xAxis`, `chartConfig.yAxis` and `chartConfig.series` are ' + + '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.', + ], +}; + +const CHART_MEASURES_MISSING_EXPLANATION: RuleExplanation = { + rule: 'chart-measures-missing', + covers: 'what a chart with no measures renders', + paragraphs: [ + 'A dataset widget whose `values` list is empty never runs a query: the renderer returns its ' + + 'authoring placeholder, "Pick measures (values) for this dataset widget." (`DatasetWidget.tsx`), ' + + 'before any widget-family branch, so no chart is drawn at all.', + 'This id is the chart family\'s; `widget-measures-missing` is the same shape on a single-value or ' + + '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 ' + + '`suppressWarnings: [\'chart-measures-missing\']` while the widget is still being authored.', + ], +}; + +const WIDGET_MEASURES_MISSING_EXPLANATION: RuleExplanation = { + rule: 'widget-measures-missing', + covers: 'what a KPI or table widget with no measures renders', + paragraphs: [ + 'The same short-circuit as `chart-measures-missing`, on every declared widget type outside the ' + + 'chart family: with `values` empty the renderer returns the authoring placeholder "Pick measures ' + + '(values) for this dataset widget." before any query runs. A single-value widget (`metric`, ' + + '`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.', + ], +}; + +const CHART_DIMENSIONS_MISSING_EXPLANATION: RuleExplanation = { + rule: 'chart-dimensions-missing', + covers: 'why a chart with no dimensions draws one number', + paragraphs: [ + 'The renderer\'s `isMetric` test is `METRIC_TYPES.has(widgetType) || dimensions.length === 0`, so ' + + '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\']`.', + ], +}; + +// ── Dataset references (`validate-dataset-references.ts`) ────────────────── + +const DATASET_INCLUDE_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'dataset-include-unknown', + covers: 'why an include entry must be a relationship', + paragraphs: [ + 'Joins are COMPILED from a dataset\'s `include` (ADR-0021): each entry must be a traversable ' + + 'relationship path — a lookup, master_detail, user or tree field, up to three hops (ADR-0071). An entry that ' + + 'resolves to nothing, or to a field that is not a relationship, produces no join at all, so ' + + 'every dimension or measure written against that prefix addresses nothing.', + 'Resolution is the shared object graph\'s, injected columns included: `owner_id` reads as the ' + + 'lookup it is and joins, `created_at` reads as a datetime and does not. Not reported: a dataset ' + + 'whose base object this stack does not define or cannot read the fields of.', + 'To fix it, declare a relationship that exists on the join chain from the dataset\'s object, or ' + + 'drop the entry. Declaring `a.b` implicitly includes `a`.', + ], +}; + +const DATASET_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'dataset-field-unknown', + covers: 'what an unresolved field path does to the query', + paragraphs: [ + 'A dimension\'s or measure\'s `field` is compiled into the analytics query as written, so a path ' + + 'that names no column addresses one that does not exist: the surface renders successfully with ' + + 'empty or wrong numbers, and nothing reports the miss. A dimension bound to such a column cannot ' + + 'group by anything; a measure bound to one cannot aggregate anything.', + 'Dashboards and reports bind a dataset\'s dimensions and measures by name, and that consumer end ' + + 'is already guarded (`widget-dimension-unknown`, `widget-measure-unknown`), so this was the quiet ' + + 'hole one level down: every binding resolves, the board renders, and the numbers are wrong. The ' + + 'rule is an error for that reason.', + 'Paths are resolved on the shared object graph — a dotted `relationship.field` path hop by hop — ' + + 'and the verdict names the hop that failed. Not reported: an object this stack does not define, ' + + 'an object with no readable field map (an ADR-0015 external object, an introspected ' + + 'datasource), and a registry-injected system column, which is real at runtime.', + ], +}; + +const DATASET_FIELD_NOT_INCLUDED_EXPLANATION: RuleExplanation = { + rule: 'dataset-field-not-included', + covers: 'why a relationship prefix must be in include', + paragraphs: [ + 'ADR-0021 joins ONLY the relationship paths a dataset declares in `include`. A dimension, measure ' + + 'or filter path that resolves on the object graph but whose prefix was never declared compiles ' + + 'to no join, so the column is out of the query\'s reach however real it is.', + 'The path is still compiled into the analytics query as written, so it addresses a column that ' + + 'does not exist: the surface renders successfully with empty or wrong numbers, and nothing ' + + 'reports the miss.', + 'To fix it, add the prefix to `include` (declaring `a.b` implicitly includes `a`), or bind the ' + + 'position to a field on the dataset\'s own object.', + ], +}; + +const DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'dataset-filter-field-unknown', + covers: 'what an unresolved filter key does', + paragraphs: [ + 'A filter KEY — on the dataset\'s scope `filter` or on a measure\'s `filter` — that names no ' + + 'column either widens the scope (the condition is dropped) or empties it (the engine compares a ' + + 'missing column). The path is compiled into the analytics query as written, so the surface ' + + 'renders successfully with empty or wrong numbers, and nothing reports the miss.', + 'All three authored filter shapes are walked — a condition object, `{ field, operator, value }` ' + + 'rules and `[field, op, value]` triples — so a filter written one way is not judged while another ' + + 'is skipped. This rule judges the KEYS; `filter-token-unknown` judges the values in the same ' + + 'position.', + ], +}; + +// ── Security posture (`validate-security-posture.ts`) ────────────────────── + +const SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION: RuleExplanation = { + rule: 'security-controlled-by-parent-ambiguous-relation', + covers: 'how the master relation is chosen', + paragraphs: [ + 'A `controlled_by_parent` object derives every row\'s record-level access from a master record. ' + + 'ADR-0055 resolves the master through three tiers, in order — a required master_detail, then any ' + + 'master_detail, then a required lookup, each of which must also name a `reference` target — and ' + + 'takes the FIRST match in the tier that wins.', + 'When two or more fields tie in the winning tier, which object this one derives its access from ' + + 'is decided by FIELD DECLARATION ORDER. Moving a field up or down the schema reads as a cosmetic ' + + 'edit in review, and it silently moves that security boundary to another object; the runtime ' + + 'reports nothing when it does, because it does not refuse, it picks. Author time is the only ' + + 'place this can surface, so the rule is an error. A tie in a lower tier is masked by a higher ' + + 'tier\'s single winner and is not reported.', + 'To fix it, leave exactly ONE candidate in the winning tier: promote the intended master into a ' + + 'higher tier (make it the object\'s only required master_detail), or demote the others (drop ' + + '`required`, or change the relation type). System objects are judged too: the ambiguity is a ' + + 'property of the document.', + ], +}; + +const SECURITY_CBP_NO_RELATION_EXPLANATION: RuleExplanation = { + rule: 'security-controlled-by-parent-no-relation', + covers: 'why the object is unusable without a master', + paragraphs: [ + 'A `controlled_by_parent` object says its access comes from its master. ADR-0055 resolves the ' + + 'master through a required master_detail, then any master_detail, then a required lookup — each ' + + 'of which must also name a `reference` target — and an object that matches none of the three has ' + + 'nothing to derive access from.', + 'Both runtime halves refuse it: every read is DENIED, and every write is refused with 422 ' + + 'INVALID_METADATA — as a metadata defect rather than a permission denial — so the object is ' + + 'unusable rather than merely locked down. System objects are judged too, because the runtime ' + + 'refusal does not exempt them.', + 'To fix it, add the master relation this object is derived from, a `master_detail` field with a ' + + '`reference` and `required: true`. An object with no master decides its own baseline: ' + + '\'private\', \'public_read\' or \'public_read_write\'.', + ], +}; + +const SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = { + rule: 'security-fls-unknown-field', + covers: 'how the runtime resolves a field-permission key', + paragraphs: [ + 'The runtime resolves a field-permission (FLS) key by stripping its object prefix and looking the ' + + 'remainder up as a column (`PermissionEvaluator.getFieldPermissions`). A remainder no column ' + + 'answers to contributes nothing, so the key matches NOTHING: the masking it declares never ' + + 'enforces, and the field it was meant to cover stays as readable and as editable as the ' + + 'object-level grant leaves it — for every holder of the set. Nothing reports that at runtime.', + 'Unlike an unqualified key (`security-fls-unqualified-key`) this one looks correct in review, and ' + + 'it is exactly what a field rename leaves behind; an empty remainder is what a half-finished edit ' + + 'leaves. A key with more dots is judged on its whole remainder, because FLS keys address columns, ' + + 'never joins.', + 'Not judged: an object this stack does not define, an object with no readable field map, and a ' + + 'registry-injected system column, which is real and addressable. To fix it, point the key at a ' + + 'field the object declares, or delete the entry: an entry that cannot match is not protection, ' + + 'and if the field was renamed the mask has been off since that rename.', + ], +}; + +const SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION: RuleExplanation = { + rule: 'security-master-detail-ungranted', + covers: 'why a detail object needs its own CRUD grant', + paragraphs: [ + 'A master-detail child derives its RECORD-level access from the master (ADR-0055 ' + + '`controlled_by_parent`), but object-level CRUD is a SEPARATE gate that is never derived: a ' + + 'permission set that grants the master and forgets the child denies role-bound non-admin users ' + + '(403) before the parent-derived access is ever consulted — the silent "can\'t submit the ' + + 'subtable" trap.', + 'A child is an object with a master_detail field, or a `controlled_by_parent` object that resolves ' + + 'its master the way the runtime does (a required lookup included). The rule is a warning, and it ' + + 'stays silent when the stack authors no permission sets or a `\'*\'` wildcard grant covers every ' + + 'object; one set granting the child while another forgets it is out of scope.', + 'To fix it, grant the object in at least one permission set that already grants its master — ' + + 'allowRead, allowCreate and allowEdit. A table no role should ever touch is named `sys_*` or set ' + + '`isSystem: true`.', + ], +}; + +const SECURITY_OWD_ALIAS_EXPLANATION: RuleExplanation = { + rule: 'security-owd-alias', + covers: 'which sharing values are retired or misplaced', + paragraphs: [ + 'ADR-0090 D4 retired three OWD spellings: \'read\' (now \'public_read\'), \'read_write\' and ' + + '\'full\' (both now \'public_read_write\'). The runtime fails CLOSED to \'private\' on a value it ' + + 'does not know, so an object meant to be readable or writable org-wide is neither, with no notice ' + + 'on the read path.', + '\'public\' is not an OWD value and never was, so nothing retired it: it is legal on the ' + + 'neighbouring keys \'access\' (its \'default\') and \'publicSharing\' (its \'allowedAudiences\'), ' + + 'just not on \'sharingModel\' or \'externalSharingModel\'. Its fix-it offers \'public_read_write\'.', + 'The parsed doors refuse these values at the schema enum before this rule runs; it reaches the ' + + 'unparsed ones — `os lint` on a raw config, a direct call of the rule.', + ], +}; + +const SECURITY_DELEGATION_MISSING_REASON_EXPLANATION: RuleExplanation = { + rule: 'security-delegation-missing-reason', + covers: 'why a delegation needs a reason', + paragraphs: [ + 'ADR-0091 D3 makes the reason mandatory on every delegation, for the dual audit trail: ' + + '`granted_by` records the writer, `delegated_from` the authority source, and `reason` why. The ' + + 'runtime delegation gate rejects a delegation without one; this rule moves the failure to ' + + 'authoring.', + 'Judged on `sys_user_position` seed rows only: `delegated_from` is not declared on ' + + '`sys_user_permission_set`, whose engine refuses it as an undeclared field.', + ], +}; + +// ── Visibility predicates (`validate-visibility-predicates.ts`) ──────────── +// The console renders an element whose predicate cannot evaluate as if it had +// none (it falls OPEN); every entry below is a way to reach that outcome. + +const VISIBILITY_ROOT_MISLAYERED_EXPLANATION: RuleExplanation = { + rule: 'visibility-root-mislayered', + covers: 'which root each surface binds', + paragraphs: [ + 'ADR-0089 D3 binds a different root on each layer. A runtime view or page surface binds the live ' + + 'record as `record`, plus `current_user` (and page state as `page.VAR` on a page component); a ' + + 'metadata-editing form — a `*.form.ts` module or a schema-bound form view — binds the row under ' + + 'edit as `data`.', + 'A predicate rooted at the other layer\'s namespace never matches, and the element renders ' + + 'unconditionally. The rule is a warning rather than an error because the root is at least a ' + + 'namespace some surface binds.', + ], +}; + +const VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION: RuleExplanation = { + rule: 'visibility-predicate-over-budget', + covers: 'why an oversized predicate never evaluates', + paragraphs: [ + 'The canonical CEL front end enforces the platform\'s parse budgets (`maxAstNodes`, ' + + '`maxListElements` and the rest). A predicate that is valid CEL but overruns one is refused, so it ' + + 'can never evaluate, and the console falls OPEN: the element renders unconditionally and looks ' + + 'exactly like one with no predicate at all. Failing open is the console\'s settled behaviour, so ' + + 'this error is the only signal.', + 'It is a SIZE fault, not a dialect mistake, so re-spelling the predicate will not fix it. Collapse ' + + 'a long `record.f == \'a\' || record.f == \'b\'` chain into one `record.f in [\'a\', \'b\']`, which is ' + + 'far fewer AST nodes (`maxListElements` is 64, so a very large set needs the next option), or ' + + 'precompute the heavy part into a formula or roll-up field and test that one field. The finding ' + + 'does not echo the predicate; its `path` locates it.', + ], +}; + +const VISIBILITY_PREDICATE_SYNTAX_EXPLANATION: RuleExplanation = { + rule: 'visibility-predicate-syntax', + covers: 'why a predicate that does not parse is an error', + paragraphs: [ + 'Visibility predicates are bare CEL. A predicate the canonical front end does not parse can never ' + + 'evaluate, and the console falls OPEN: the element renders unconditionally and looks exactly like ' + + 'one with no predicate at all (failing open is the console\'s settled behaviour). Every other ' + + 'predicate surface already gates a syntax fault (ADR-0032), so this one does too.', + 'Spellings from other languages do not parse: write `==` (not `===`), `!=` (not `!==` or `<>`), ' + + '`&&` (not `and`), `||` (not `or`), `!` (not `not`). A predicate that is flawless CEL but too ' + + 'large is `visibility-predicate-over-budget` instead.', + ], +}; + +const VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION: RuleExplanation = { + rule: 'visibility-predicate-unknown-function', + covers: 'what a call to an unregistered function does', + paragraphs: [ + 'The predicate parses, so nothing else reports it, and it faults the moment it is evaluated. On a ' + + 'view or page surface the console falls OPEN and the element renders unconditionally, exactly ' + + 'like one carrying no predicate at all. On an action surface — evaluated with ' + + '`throwOnError: true` — it falls CLOSED and the action disappears for EVERY user, including one ' + + 'who holds the grant, leaving one deduped `console.warn` as the only signal.', + 'The finding quotes the engine\'s own wording and offers no "did you mean": the nearest name is ' + + 'often an unrelated function. It is a NAME fault, so re-spelling the predicate will not fix it. ' + + 'The callable names advertised for authoring are the `functions` list `introspectScope` returns ' + + '(`CEL_STDLIB_FUNCTIONS`); pick one of those, or precompute the value into a formula field on the ' + + 'object and test that field.', + ], +}; + +const VISIBILITY_BARE_IDENTIFIER_EXPLANATION: RuleExplanation = { + rule: 'visibility-bare-identifier', + covers: 'why every value is read through a namespace', + paragraphs: [ + 'On a visibility surface values are bound under a namespace — `record` and `current_user` on a ' + + 'runtime view or page (plus `page.VAR` on a page component), `data` on a metadata-editing form — ' + + 'and never flattened to top level. A bare identifier, or a path through a namespace no binding ' + + 'provides, resolves to nothing, so the predicate can never evaluate and the console falls OPEN: ' + + 'the element renders unconditionally and looks exactly like one with no predicate at all ' + + '(failing open is the console\'s settled behaviour).', + 'There is no reading of the metadata under which it was going to work, so the rule is an error. ' + + 'One position is exempt: a bare word on the right of `==` or `!=` on a metadata-editing form, ' + + 'which the console parses as a literal (a path there is `predicate-rhs-path-shaped`\'s finding).', + ], +}; + /** * Every rule explanation this package ships, keyed by rule id. `os explain * ` reads this table and nothing else. @@ -320,6 +772,32 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [VIEW_ROW_COLOR_WITHOUT_COLORS_EXPLANATION.rule]: VIEW_ROW_COLOR_WITHOUT_COLORS_EXPLANATION, [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, + [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, + [WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: WIDGET_FILTER_FIELD_UNKNOWN_EXPLANATION, + [WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION.rule]: WIDGET_FILTER_FIELD_NOT_INCLUDED_EXPLANATION, + [WIDGET_SORTBY_UNSELECTED_EXPLANATION.rule]: WIDGET_SORTBY_UNSELECTED_EXPLANATION, + [CHART_FIELD_UNKNOWN_EXPLANATION.rule]: CHART_FIELD_UNKNOWN_EXPLANATION, + [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, + [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, + [DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION.rule]: DATASET_FILTER_FIELD_UNKNOWN_EXPLANATION, + [SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION.rule]: SECURITY_CBP_AMBIGUOUS_RELATION_EXPLANATION, + [SECURITY_CBP_NO_RELATION_EXPLANATION.rule]: SECURITY_CBP_NO_RELATION_EXPLANATION, + [SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION.rule]: SECURITY_FLS_UNKNOWN_FIELD_EXPLANATION, + [SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION.rule]: SECURITY_MASTER_DETAIL_UNGRANTED_EXPLANATION, + [SECURITY_OWD_ALIAS_EXPLANATION.rule]: SECURITY_OWD_ALIAS_EXPLANATION, + [SECURITY_DELEGATION_MISSING_REASON_EXPLANATION.rule]: SECURITY_DELEGATION_MISSING_REASON_EXPLANATION, + [VISIBILITY_ROOT_MISLAYERED_EXPLANATION.rule]: VISIBILITY_ROOT_MISLAYERED_EXPLANATION, + [VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION.rule]: VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION, + [VISIBILITY_PREDICATE_SYNTAX_EXPLANATION.rule]: VISIBILITY_PREDICATE_SYNTAX_EXPLANATION, + [VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION.rule]: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION, + [VISIBILITY_BARE_IDENTIFIER_EXPLANATION.rule]: VISIBILITY_BARE_IDENTIFIER_EXPLANATION, }); /** The explanation for `rule`, or `undefined` when the rule has none. Exact id match. */ diff --git a/packages/lint/src/runtime-gate.test.ts b/packages/lint/src/runtime-gate.test.ts index eea9e7824cb..491183859a4 100644 --- a/packages/lint/src/runtime-gate.test.ts +++ b/packages/lint/src/runtime-gate.test.ts @@ -923,8 +923,10 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)' 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`'); + // The message names the refusal, not a query that never runs. [#22161] + // The one-line verdict says the key is ignored; HOW the renderer strips + // it is `os explain chart-field-unknown`. + expect(f!.message).toContain('is ignored anyway'); 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'); diff --git a/packages/lint/src/system-fields.ts b/packages/lint/src/system-fields.ts index 3a07a37ef35..1aa6e287a8a 100644 --- a/packages/lint/src/system-fields.ts +++ b/packages/lint/src/system-fields.ts @@ -207,6 +207,20 @@ export function unprovisionedAnchorCause(objectName: string, field: string): str ); } +/** + * [#22161] The SHORT form of {@link unprovisionedAnchorCause}, for a rule whose + * finding is one verdict sentence: it names the same column and object and + * says the same thing — an injected anchor with no storage behind it — in one + * clause, and the rule's `os explain` entry (`rule-explanations.ts`) carries + * the long cause (ADR-0015 federation, who owns the schema, why the anchor is + * registered unprovisioned). One wording for every converted rule, so the + * drift the long form's note warns about cannot reopen between them; a rule + * still on the long form converges here when its message is shortened. + */ +export function unprovisionedAnchorVerdict(objectName: string, field: string): string { + return `'${field}' is an injected column with no storage on external object '${objectName}'`; +} + /** * The FIX clause paired with {@link unprovisionedAnchorCause} — the two ways * out, in the order an author should consider them: vouch for the remote column diff --git a/packages/lint/src/validate-dataset-references.test.ts b/packages/lint/src/validate-dataset-references.test.ts index 4d2c864a4ea..2ba9aa10bf3 100644 --- a/packages/lint/src/validate-dataset-references.test.ts +++ b/packages/lint/src/validate-dataset-references.test.ts @@ -2,12 +2,33 @@ import { describe, it, expect } from 'vitest'; import { - validateDatasetReferences, + validateDatasetReferences as validateDatasetReferencesUnrecorded, DATASET_INCLUDE_UNKNOWN, DATASET_FIELD_UNKNOWN, DATASET_FIELD_NOT_INCLUDED, DATASET_FILTER_FIELD_UNKNOWN, } from './validate-dataset-references.js'; +import { explainRule } from './rule-explanations.js'; + +// [#22161] Each finding of the rule ids this file's rule shortened is one +// verdict sentence; the reasoning it used to carry is the id's `os explain` +// entry. Every call below records what it fired, and the last case in this +// file holds each recorded verdict of those ids to one line of at most 200 +// characters — so the pin covers every firing variant this suite exercises, +// not a chosen few. Run the whole file: that case reads what the cases above +// fired. +const SHORTENED_RULE_IDS: readonly string[] = [ + DATASET_INCLUDE_UNKNOWN, + DATASET_FIELD_UNKNOWN, + DATASET_FIELD_NOT_INCLUDED, + DATASET_FILTER_FIELD_UNKNOWN, +]; +const firedShortened: Array<{ rule: string; message: string }> = []; +const validateDatasetReferences: typeof validateDatasetReferencesUnrecorded = (...args) => { + const findings = validateDatasetReferencesUnrecorded(...args); + for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f); + return findings; +}; /** * The object graph every case below resolves against. Deliberately a real @@ -557,3 +578,34 @@ describe('validateDatasetReferences — the shipped dataset shapes', () => { ).toEqual([]); }); }); + +describe('[#22161] one-line verdicts — the rule ids this file shortened', () => { + it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => { + // The coverage control first: each shortened id fired at least once, so + // the shape assertion below cannot pass over an empty record. + expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + for (const f of firedShortened) { + expect(f.message, f.rule).not.toContain('\n'); + expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200); + } + }); + + // What each verdict stopped saying, which `os explain RULE_ID` now prints. + const MOVED: Record = { + [DATASET_INCLUDE_UNKNOWN]: ['Joins are COMPILED', 'ADR-0071', 'addresses nothing'], + [DATASET_FIELD_UNKNOWN]: ['compiled into the analytics query as written', 'empty or wrong numbers', 'nothing reports the miss', 'widget-dimension-unknown'], + [DATASET_FIELD_NOT_INCLUDED]: ['joins ONLY', 'empty or wrong numbers', 'nothing reports the miss'], + [DATASET_FILTER_FIELD_UNKNOWN]: ['widens the scope', 'empty or wrong numbers', 'filter-token-unknown'], + }; + + it('covers exactly the shortened ids', () => { + expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + }); + + it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict 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[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + }); +}); diff --git a/packages/lint/src/validate-dataset-references.ts b/packages/lint/src/validate-dataset-references.ts index a895af0373c..d9a28bebf91 100644 --- a/packages/lint/src/validate-dataset-references.ts +++ b/packages/lint/src/validate-dataset-references.ts @@ -163,11 +163,10 @@ function strName(v: unknown): string | undefined { return typeof v === 'string' && v.length > 0 ? v : undefined; } -/** The shared consequence sentence — why an unresolved path is not merely inert. */ -const SILENT_EMPTY = - 'The path is compiled into the analytics query as written, so it addresses a column ' + - 'that does not exist: the surface renders successfully with empty or wrong numbers, ' + - 'and nothing reports the miss.'; +// [#22161] Each finding is one verdict sentence plus its fix. Why an unresolved +// path is not merely inert — it is compiled into the analytics query as written, +// and the surface renders successfully with empty or wrong numbers — is each +// rule id's long-form explanation (`rule-explanations.ts`, `os explain RULE_ID`). /** * Validate every ADR-0021 dataset's references against the object graph. @@ -232,8 +231,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { message: `include[${ii}] "${entry}" names a` + `${type ? ` \`${type}\`` : 'n ordinary'} field on object "${verdict.object}", ` + - `not a relationship — no join can be derived from it, so every dimension or ` + - `measure written against that prefix addresses nothing.`, + `not a relationship, so no join is derived from it`, hint: prescription, }); return; @@ -246,9 +244,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { rule: DATASET_INCLUDE_UNKNOWN, where, path, - message: - `${account.message} Joins are COMPILED from \`include\` (ADR-0021), so an entry ` + - `that resolves to nothing produces no join at all.`, + message: account.message, hint: `${prescription} ${account.detail}`, }); }); @@ -279,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { rule, where: positionWhere, path, - message: `${account.message} ${SILENT_EMPTY}`, + message: account.message, hint: `${prescription} ${account.detail}`, }); return; @@ -300,10 +296,8 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { where: positionWhere, path, message: - `${subject} "${written}" resolves on the object graph, but its relationship ` + - `prefix "${prefix}" is not declared in this dataset's \`include\` — and ADR-0021 ` + - `joins ONLY declared paths, so no join is compiled and the column is out of the ` + - `query's reach. ${SILENT_EMPTY}`, + `${subject} "${written}" resolves, but its relationship prefix "${prefix}" is not ` + + `declared in this dataset's \`include\`, so no join reaches the column`, hint: `Add "${prefix}" to include (declaring "a.b" implicitly includes "a"), or bind ` + `this position to a field on "${object}" itself. Declared include paths: ` + diff --git a/packages/lint/src/validate-security-posture.test.ts b/packages/lint/src/validate-security-posture.test.ts index 1d49b2c3a5e..9394de924a6 100644 --- a/packages/lint/src/validate-security-posture.test.ts +++ b/packages/lint/src/validate-security-posture.test.ts @@ -15,7 +15,7 @@ import { ObjectPermissionSchema, PermissionSetSchema } from '@objectstack/spec/s import { SECURITY_FLS_UNQUALIFIED_KEY, SECURITY_FLS_UNKNOWN_FIELD, - validateSecurityPosture, + validateSecurityPosture as validateSecurityPostureUnrecorded, validateSecurityRoleWord, SECURITY_OWD_UNSET, SECURITY_OWD_ALIAS, @@ -34,6 +34,28 @@ import { import { lintDataModel } from './data-model-rules.js'; import { explainRule } from './rule-explanations.js'; +// [#22161] Each finding of the rule ids this file's rule shortened is one +// verdict sentence; the reasoning it used to carry is the id's `os explain` +// entry. Every call below records what it fired, and the last case in this +// file holds each recorded verdict of those ids to one line of at most 200 +// characters — so the pin covers every firing variant this suite exercises, +// not a chosen few. Run the whole file: that case reads what the cases above +// fired. +const SHORTENED_RULE_IDS: readonly string[] = [ + SECURITY_CBP_AMBIGUOUS_RELATION, + SECURITY_FLS_UNKNOWN_FIELD, + SECURITY_CBP_NO_RELATION, + SECURITY_MASTER_DETAIL_UNGRANTED, + SECURITY_OWD_ALIAS, + SECURITY_DELEGATION_MISSING_REASON, +]; +const firedShortened: Array<{ rule: string; message: string }> = []; +const validateSecurityPosture: typeof validateSecurityPostureUnrecorded = (...args) => { + const findings = validateSecurityPostureUnrecorded(...args); + for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f); + return findings; +}; + const rulesOf = (stack: Record) => validateSecurityPosture(stack).map((f) => f.rule); @@ -247,8 +269,10 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { // that is in fact an unenforced security control. expect(findings[0].message).toContain("crm_account.description_nope"); expect(findings[0].message).toContain("declares no field 'description_nope'"); - expect(findings[0].message).toMatch(/NEVER ENFORCES/); - expect(findings[0].message).toMatch(/stays as readable and as editable/); + // [#22161] The cost, in the one-line verdict. HOW the runtime resolves the + // key — and that the field stays as readable and editable as the object + // grant leaves it — is `os explain security-fls-unknown-field`. + expect(findings[0].message).toContain('the masking it declares never enforces'); // And the hint must be actionable: the fields that DO exist. expect(findings[0].hint).toContain('description'); }); @@ -376,8 +400,7 @@ describe('validateSecurityPosture (ADR-0090 D7)', () => { expect(findings[0].message).toMatch(/names NO field/); // The consequence half is the same sentence the dangling case carries — an // author must be told what it costs, not merely that the key is malformed. - expect(findings[0].message).toMatch(/NEVER ENFORCES/); - expect(findings[0].message).toMatch(/stays as readable and as editable/); + expect(findings[0].message).toContain('the masking it declares never enforces'); // ⛔ And it must not read as the dangling-field message: there is no field // name to quote, so the wrong branch would render `declares no field ''`. expect(findings[0].message).not.toContain("declares no field"); @@ -728,7 +751,9 @@ describe('validateSecurityPosture · master-detail detail ungranted (framework#2 expect(md).toHaveLength(1); expect(md[0]).toMatchObject({ severity: 'warning', where: 'object "work_order_item"' }); expect(md[0].path).toBe('objects[1].fields.order'); - expect(md[0].message).toContain('controlled_by_parent'); + // [#22161] Why object-level CRUD is never derived from the master + // (ADR-0055 controlled_by_parent) is `os explain security-master-detail-ungranted`. + expect(md[0].message).toContain('no object-level CRUD grant'); expect(md[0].message).toContain('403'); expect(md[0].message).toContain('work_order'); // names the master expect(md[0].hint).toContain('permissions[i].objects.work_order_item'); @@ -1036,25 +1061,25 @@ describe('validateSecurityPosture · controlled_by_parent with an AMBIGUOUS mast where: 'object "crm_contract"', path: 'objects[2].fields', }); - // Every candidate is named — field, type and master — in DECLARATION order, - // which is the order that decides the winner. A message naming only the - // winner would describe the pick, not the ambiguity. - expect(findings[0].message).toContain('"account" (lookup -> "crm_account")'); - expect(findings[0].message).toContain('"contact" (lookup -> "crm_contact")'); - expect(findings[0].message.indexOf('"account" (lookup')).toBeLessThan( - findings[0].message.indexOf('"contact" (lookup'), + // Every candidate is named — field and master, its type in the tier — in + // DECLARATION order, which is the order that decides the winner. A message + // naming only the winner would describe the pick, not the ambiguity. + expect(findings[0].message).toContain('"account" → crm_account'); + expect(findings[0].message).toContain('"contact" → crm_contact'); + expect(findings[0].message.indexOf('"account" →')).toBeLessThan( + findings[0].message.indexOf('"contact" →'), ); // The tier that was actually tested, and the winner it currently resolves. expect(findings[0].message).toContain('required lookup tier'); - expect(findings[0].message).toContain('Today "account" wins'); + expect(findings[0].message).toContain('"account" wins today'); expect(findings[0].hint).toContain('master_detail'); }); it('names the OTHER field as the winner when the same two are declared the other way round', () => { const findings = ambiguousOnly(cbpStack({ contact: REQ_CONTACT_LOOKUP, account: REQ_ACCOUNT_LOOKUP })); expect(findings).toHaveLength(1); - expect(findings[0].message).toContain('Today "contact" wins'); - expect(findings[0].message).toContain('derives from "crm_contact"'); + expect(findings[0].message).toContain('"contact" wins today'); + expect(findings[0].message).toContain('"contact" → crm_contact'); }); it('errors on tier 1: two REQUIRED master_detail fields', () => { @@ -1088,7 +1113,7 @@ describe('validateSecurityPosture · controlled_by_parent with an AMBIGUOUS mast }), ); expect(findings).toHaveLength(1); - expect(findings[0].message).toContain('3 of its fields tie'); + expect(findings[0].message).toContain('3 fields tie'); }); it('reports the array field form too', () => { @@ -1828,3 +1853,36 @@ describe('validateSecurityPosture — every branch is reachable without an undec ).toEqual([]); }); }); + +describe('[#22161] one-line verdicts — the rule ids this file shortened', () => { + it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => { + // The coverage control first: each shortened id fired at least once, so + // the shape assertion below cannot pass over an empty record. + expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + for (const f of firedShortened) { + expect(f.message, f.rule).not.toContain('\n'); + expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200); + } + }); + + // What each verdict stopped saying, which `os explain RULE_ID` now prints. + const MOVED: Record = { + [SECURITY_CBP_AMBIGUOUS_RELATION]: ['ADR-0055', 'FIELD DECLARATION ORDER', 'security boundary', 'it does not refuse, it picks'], + [SECURITY_FLS_UNKNOWN_FIELD]: ['PermissionEvaluator.getFieldPermissions', 'stays as readable and as editable', 'field rename', 'security-fls-unqualified-key'], + [SECURITY_CBP_NO_RELATION]: ['ADR-0055', 'INVALID_METADATA', 'unusable rather than merely locked down'], + [SECURITY_MASTER_DETAIL_UNGRANTED]: ['`controlled_by_parent`', 'SEPARATE gate', "can't submit the", 'wildcard'], + [SECURITY_OWD_ALIAS]: ["'read_write'", "'full'", "'publicSharing'", "'allowedAudiences'", 'fails CLOSED'], + [SECURITY_DELEGATION_MISSING_REASON]: ['ADR-0091 D3', 'dual audit trail', '`granted_by`', '`sys_user_position`'], + }; + + it('covers exactly the shortened ids', () => { + expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + }); + + it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict 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[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + }); +}); diff --git a/packages/lint/src/validate-security-posture.ts b/packages/lint/src/validate-security-posture.ts index f306973b53e..e929e979005 100644 --- a/packages/lint/src/validate-security-posture.ts +++ b/packages/lint/src/validate-security-posture.ts @@ -235,11 +235,12 @@ function owdOf(obj: AnyRec): unknown { * receiver that does not exist. */ function owdAliasProvenance(value: string): string { + // [#22161] One clause. Which values ADR-0090 D4 retired, and where a + // non-OWD value such as 'public' IS legal, is this rule id's `os explain` + // entry (`rule-explanations.ts`). return OWD_RETIRED_ALIAS_FIX[value] ? `is a retired alias (ADR-0090 D4)` - : `is not an OWD value and never was — ADR-0090 D4 retired 'read', 'read_write' and 'full', ` + - `not this. '${value}' is legal on the neighbouring keys 'access' (its 'default') and ` + - `'publicSharing' (its 'allowedAudiences'), just not on this one`; + : `is not an OWD value — '${value}' belongs to 'access' and 'publicSharing', not this key`; } /** @@ -505,8 +506,8 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } where: `object "${objName}"`, path: `${objPath}.sharingModel`, message: - `sharingModel '${owd}' ${owdAliasProvenance(owd)}. The runtime fails CLOSED ` + - `to 'private' on unknown values, so this object is NOT ${owd === 'read' ? 'readable' : 'writable'} org-wide.`, + `sharingModel '${owd}' ${owdAliasProvenance(owd)}; the runtime fails CLOSED to 'private', ` + + `so this object is NOT ${owd === 'read' ? 'readable' : 'writable'} org-wide`, hint: `Replace with the canonical value: sharingModel: '${OWD_ALIAS_FIX[owd]}'.`, }); } else if (typeof owd === 'string' && !(CANONICAL_OWD as readonly string[]).includes(owd)) { @@ -546,13 +547,12 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } rule: SECURITY_CBP_NO_RELATION, where: `object "${objName}"`, path: `${objPath}.sharingModel`, + // [#22161] One verdict sentence; the ADR-0055 precedence it tested and + // why the object is unusable rather than locked down are this id's + // `os explain` entry. message: - `"${objName}" declares sharingModel 'controlled_by_parent' but has no relation the platform ` + - `can derive access from. ADR-0055 resolves the master through a required master_detail, then ` + - `any master_detail, then a required lookup — each of which must also name a reference target — ` + - `and this object matches none of the three. At runtime every read is DENIED and every write is ` + - `refused with 422 INVALID_METADATA, as a metadata defect rather than a permission denial, so the ` + - `object is unusable rather than merely locked down.`, + `declares sharingModel 'controlled_by_parent' but has no master relation to derive access ` + + `from, so every read is denied and every write refused (422 INVALID_METADATA)`, hint: `Add the master relation this object is derived from, e.g. fields.parent: ` + `{ type: 'master_detail', reference: '', required: true }. If the object has no ` + @@ -586,22 +586,18 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } const cbpTier = owd === 'controlled_by_parent' ? cbpMasterCandidates(obj) : undefined; if (cbpTier && cbpTier.candidates.length > 1) { const winner = cbpTier.candidates[0]; - const roster = cbpTier.candidates - .map((cand) => `"${cand.field}" (${cand.type} -> "${cand.master}")`) - .join(', '); + const roster = cbpTier.candidates.map((cand) => `"${cand.field}" → ${cand.master}`).join(', '); findings.push({ severity: 'error', rule: SECURITY_CBP_AMBIGUOUS_RELATION, where: `object "${objName}"`, path: `${objPath}.fields`, + // [#22161] One verdict sentence; why a tie is decided by declaration + // order, and what reordering does to the security boundary, are this + // id's `os explain` entry. message: - `"${objName}" declares sharingModel 'controlled_by_parent' and ${cbpTier.candidates.length} of its ` + - `fields tie for the master relation: ${roster}. ADR-0055 resolves the master through a required ` + - `master_detail, then any master_detail, then a required lookup, and takes the FIRST match in the ` + - `tier that wins — here the ${cbpTier.tier} tier — so which object this one derives its access from ` + - `is decided by FIELD DECLARATION ORDER. Today "${winner.field}" wins and every row's record-level ` + - `access derives from "${winner.master}"; reordering these fields moves that security boundary to ` + - `another object, and the runtime reports nothing when it does.`, + `the controlled_by_parent master is decided by field order: ${cbpTier.candidates.length} fields ` + + `tie in the ${cbpTier.tier} tier (${roster}) and "${winner.field}" wins today`, hint: `Leave exactly ONE candidate in the winning tier, so the master is authored rather than positional. ` + `Either promote the intended master into a higher tier — make it the object's only required ` + @@ -775,20 +771,14 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } const wrote = emptyField ? `field-permission key '${flsKey}' names NO field — everything after the '${flsObject}.' prefix is empty` : `field-permission key '${flsKey}' is object-qualified but "${flsObject}" declares no field '${flsField}'`; - const looksRight = emptyField - ? `A truncated key is what a half-finished edit leaves behind.` - : `Unlike an unqualified key this one looks correct in review, and it is exactly what a field rename leaves behind.`; findings.push({ severity: 'error', rule: SECURITY_FLS_UNKNOWN_FIELD, where: `permission set "${psName}"`, path: `${psPath}.fields["${flsKey}"]`, - message: - `${wrote}. The runtime resolves an FLS key by stripping the '${flsObject}.' prefix and ` + - `looking the remainder up as a column, so this key matches NOTHING: the masking it declares ` + - `NEVER ENFORCES, and the field it was meant to cover stays as readable and as editable as the ` + - `object-level grant leaves it — for every holder of this set. Nothing reports that at runtime. ` + - `${looksRight}`, + // [#22161] One verdict sentence; how the runtime resolves an FLS key, + // and why this shape survives review, are this id's `os explain` entry. + message: `${wrote}, so the key matches nothing and the masking it declares never enforces`, hint: `Point the key at a field "${flsObject}" really declares (${roster}), or delete the entry if the ` + `field is gone — an entry that cannot match is not protection. If the masking is still wanted, ` + @@ -977,12 +967,11 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } rule: SECURITY_MASTER_DETAIL_UNGRANTED, where: `object "${objName}"`, path: `objects[${i}].fields.${md.name}`, + // [#22161] One verdict sentence; why object-level CRUD is never + // derived from the master is this id's `os explain` entry. message: `detail object "${objName}" (${md.type} "${md.name}"${parentText}) has no object-level ` + - `CRUD grant in any permission set. A master-detail child derives its RECORD-level access ` + - `from the master (ADR-0055 controlled_by_parent), but object-level CRUD is a SEPARATE gate ` + - `that is never derived — role-bound non-admin users are denied (403) before the ` + - `parent-derived access is ever consulted (the silent "can't submit the subtable" trap).`, + `CRUD grant in any permission set, so non-admin users are denied it (403)`, hint: `Grant "${objName}" in at least one permission set that already grants its master` + `${md.parent ? ` "${md.parent}"` : ''} — e.g. permissions[i].objects.${objName} = ` + @@ -1063,9 +1052,8 @@ export function validateSecurityPosture(stack: AnyRec, opts?: { nowMs?: number } where, path: `data[${i}].records[${j}].reason`, message: - `delegation row (delegated_from = ${JSON.stringify(delegatedFrom)}) has no reason. ` + - `ADR-0091 D3 requires a mandatory reason on every delegation for the dual audit trail ` + - `(granted_by = writer, delegated_from = authority source, reason = why).`, + `delegation row (delegated_from = ${JSON.stringify(delegatedFrom)}) has no reason, which ` + + `ADR-0091 D3 requires on every delegation`, hint: `Add reason: 'vacation stand-in for 张三, 2026-08-01..15' (free text, required).`, }); } diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index 9d54196afca..b8e1d8031e2 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -6,16 +6,38 @@ import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; import { - validateVisibilityPredicates, + validateVisibilityPredicates as validateVisibilityPredicatesUnrecorded, VISIBILITY_ROOT_MISLAYERED, VISIBILITY_BARE_IDENTIFIER, VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_OVER_BUDGET, VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, } from './validate-visibility-predicates.js'; +import { explainRule } from './rule-explanations.js'; import { AUTHORING_RULES } from './authoring-rules.js'; import { CEL_STDLIB_FUNCTIONS } from '@objectstack/formula'; +// [#22161] Each finding of the rule ids this file's rule shortened is one +// verdict sentence; the reasoning it used to carry is the id's `os explain` +// entry. Every call below records what it fired, and the last case in this +// file holds each recorded verdict of those ids to one line of at most 200 +// characters — so the pin covers every firing variant this suite exercises, +// not a chosen few. Run the whole file: that case reads what the cases above +// fired. +const SHORTENED_RULE_IDS: readonly string[] = [ + VISIBILITY_ROOT_MISLAYERED, + VISIBILITY_PREDICATE_OVER_BUDGET, + VISIBILITY_PREDICATE_SYNTAX, + VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + VISIBILITY_BARE_IDENTIFIER, +]; +const firedShortened: Array<{ rule: string; message: string }> = []; +const validateVisibilityPredicates: typeof validateVisibilityPredicatesUnrecorded = (...args) => { + const findings = validateVisibilityPredicatesUnrecorded(...args); + for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f); + return findings; +}; + describe('validateVisibilityPredicates (ADR-0089 D3b)', () => { it('is clean for canonical `visibleWhen` with a runtime binding root', () => { const stack = { @@ -1041,8 +1063,10 @@ describe('visibility-predicate-syntax (#6253)', () => { // the predicate is echoed so the finding is self-contained. expect(findings[0].message).toContain('Unexpected character: ='); expect(findings[0].message).toContain('country === "USA"'); - // The consequence is stated, because on screen it is invisible. - expect(findings[0].message).toContain("failing open is the console's settled behaviour"); + // The consequence is stated, because on screen it is invisible. [#22161] + // That failing open is the console's settled behaviour is the rule's + // `os explain` entry (pinned at the foot of this file). + expect(findings[0].message).toContain('never evaluates and the element always renders'); }); it('the CEL spelling of the SAME predicate is clean — paired so it cannot pass vacuously', () => { @@ -1487,12 +1511,13 @@ describe('visibility-predicate-over-budget (#7217)', () => { // ⛔ The headline was FALSE: this IS valid CEL. expect(findings[0].message).not.toContain('is not valid CEL'); expect(findings[0].message).toContain('syntactically valid CEL'); - // The front end's own summary is quoted, and the bound is NAMED with the - // platform's value for it — which is what "shrink it to fit" needs. - expect(findings[0].message).toContain('Exceeded maxAstNodes (256)'); + // The bound is NAMED with the platform's value for it — which is what + // "shrink it to fit" needs. [#22161] The front end's summary line repeats + // exactly that, so the one-line verdict quotes it only when the bound + // cannot be named. expect(findings[0].message).toContain('`maxAstNodes` budget (platform limit 256)'); // The consequence is unchanged: it still falls OPEN on screen. - expect(findings[0].message).toContain("failing open is the console's settled behaviour"); + expect(findings[0].message).toContain('never evaluates and the element always renders'); // ⛔ The defect itself: the dialect prescription must not reach this class. expect(findings[0].hint).not.toMatch(/bare CEL/); @@ -1553,10 +1578,13 @@ describe('visibility-predicate-over-budget (#7217)', () => { .toEqual([VISIBILITY_PREDICATE_OVER_BUDGET]); }); - it('elides the echoed predicate — one runaway expression cannot flood the console', () => { + it('does not echo the predicate — one runaway expression cannot flood the console', () => { + // [#22161] An over-budget predicate is long by definition, and the `path` + // locates it, so the one-line verdict quotes none of it (it used to quote + // an elided head). const findings = overBudgetFindings(formStack(OVER_AST_NODES)); - expect(findings[0].message).not.toContain(OVER_AST_NODES); - expect(findings[0].message).toContain('...'); + expect(findings[0].message).not.toContain(OVER_AST_NODES.slice(0, 20)); + expect(findings[0].message).not.toContain('(predicate:'); }); it('a blank predicate is still not a fault of any class', () => { @@ -1712,3 +1740,35 @@ describe('emitted prose names the surface, not a source file (#8042)', () => { expect(source).toContain('when linting a `*.form.ts` metadata-editing form'); }); }); + +describe('[#22161] one-line verdicts — the rule ids this file shortened', () => { + it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => { + // The coverage control first: each shortened id fired at least once, so + // the shape assertion below cannot pass over an empty record. + expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + for (const f of firedShortened) { + expect(f.message, f.rule).not.toContain('\n'); + expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200); + } + }); + + // What each verdict stopped saying, which `os explain RULE_ID` now prints. + const MOVED: Record = { + [VISIBILITY_ROOT_MISLAYERED]: ['ADR-0089', '`*.form.ts`', '`page.VAR`'], + [VISIBILITY_PREDICATE_OVER_BUDGET]: ['falls OPEN', 'settled behaviour', 'SIZE fault', '`maxListElements` is 64'], + [VISIBILITY_PREDICATE_SYNTAX]: ['falls OPEN', 'settled behaviour', 'ADR-0032', '`===`'], + [VISIBILITY_PREDICATE_UNKNOWN_FUNCTION]: ['`throwOnError: true`', 'falls CLOSED', '`console.warn`', 'CEL_STDLIB_FUNCTIONS'], + [VISIBILITY_BARE_IDENTIFIER]: ['never flattened', 'falls OPEN', 'predicate-rhs-path-shaped'], + }; + + it('covers exactly the shortened ids', () => { + expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + }); + + it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict 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[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + }); +}); diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 07eb9233c01..4fa26e43146 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -649,12 +649,24 @@ interface CelBoundsFault { */ type CelRefusal = CelSyntaxFault | CelBoundsFault; -/** The predicate as it appears in a message — whitespace flattened, long sources elided. */ +/** + * The predicate as it appears in a message — whitespace flattened, long sources + * elided. [#22161] Capped so the verdict stays one line: the `path` locates the + * whole predicate, the quote only shows where the fault sits. + */ function quoteSource(source: string): string { const flat = source.replace(/\s+/g, ' ').trim(); - return flat.length > 120 ? `${flat.slice(0, 117)}...` : flat; + return flat.length > 72 ? `${flat.slice(0, 69)}...` : flat; } +/** + * [#22161] What a predicate that can never evaluate does on a view/page surface, + * as the one clause each verdict below ends with. Why the console falls OPEN — + * and that failing open is its settled behaviour — is each rule id's + * `os explain` entry (`rule-explanations.ts`). + */ +const NEVER_EVALUATES = 'so it never evaluates and the element always renders'; + /** * The canonical front end's refusal of `source`, or `null` when it parses. * @@ -971,10 +983,8 @@ const MISLAYER_BY_LAYER: Record< runtime: { forbiddenRoot: 'data', message: - 'visibility predicate is rooted at `data.` — that is the root a ' + - 'metadata-editing form binds (the row under edit), not a runtime ' + - 'surface. A runtime view/page predicate that binds `data.` never matches ' + - 'and the element renders unconditionally (ADR-0089).', + 'visibility predicate is rooted at `data.`, the root a metadata-editing form binds, so on ' + + 'this runtime view/page surface it never matches and the element always renders', hint: 'Runtime record surfaces bind `record` + `current_user` (pages also ' + "expose `page.`). Use e.g. `record.status == 'open'` instead of " + @@ -983,11 +993,8 @@ const MISLAYER_BY_LAYER: Record< metadata: { forbiddenRoot: 'record', message: - 'visibility predicate is rooted at `record.` — that is the root a ' + - 'runtime view/page surface binds (the live record), not the root a ' + - 'metadata-editing form binds. On a metadata-editing form — the row ' + - 'under edit — a `record.`-rooted predicate never matches and the ' + - 'element renders unconditionally (ADR-0089).', + 'visibility predicate is rooted at `record.`, the root a runtime view/page surface binds, so ' + + 'on this metadata-editing form it never matches and the element always renders', hint: 'Metadata-editing forms bind `data` (the row under edit). Use e.g. ' + "`data.type == 'grid'` instead of `record.type == 'grid'`.", @@ -1057,12 +1064,13 @@ function checkElement( rule: VISIBILITY_PREDICATE_OVER_BUDGET, where, path, + // [#22161] The bound names its own limit, so the front end's summary line + // is echoed only when it does not; the predicate is not echoed at all — + // it is over budget by definition, and the `path` locates it. message: - `visibility predicate is syntactically valid CEL but overruns ${bound} ` + - `(${refusal.overrun.summary}) (predicate: \`${quoteSource(source)}\`). The canonical front ` + - `end refuses it, so it can never evaluate, and the console falls OPEN: the element renders ` + - `unconditionally and looks exactly like one with no predicate at all (failing open is the ` + - `console's settled behaviour).`, + `visibility predicate is syntactically valid CEL but overruns ${bound}` + + `${refusal.overrun.limit && refusal.overrun.limitValue !== null ? '' : ` (${refusal.overrun.summary})`}, ` + + NEVER_EVALUATES, hint: `There is no syntax or dialect error to correct here — this is a SIZE fault, not a dialect ` + `mistake, so re-spelling the predicate will not fix it. Make it smaller, or move the work ` + @@ -1082,9 +1090,7 @@ function checkElement( path, message: `visibility predicate is not valid CEL — ${refusal.detail} ` + - `(predicate: \`${quoteSource(source)}\`). A predicate that does not parse can never ` + - `evaluate, and the console falls OPEN: the element renders unconditionally and looks ` + - `exactly like one with no predicate at all (failing open is the console's settled behaviour).`, + `(predicate: \`${quoteSource(source)}\`), ${NEVER_EVALUATES}`, hint: refusal.token ? `\`${refusal.token.wrote}\` is not a CEL operator — CEL spells it ` + `\`${refusal.token.cel}\`. Replace \`${refusal.token.wrote}\` with ` + @@ -1134,14 +1140,12 @@ function checkElement( // #5149 for the fail-open half, objectui#4421 for the fail-closed half — // are in the comment above and in the module note, where the reader who // CAN resolve them is. + // [#22161] One verdict sentence: the name, the engine's own wording, and + // both surfaces' outcome. Why a view/page fails open and an action fails + // closed is this id's `os explain` entry. message: - `visibility predicate calls \`${unknownCall.name}\`, which the platform's CEL environment ` + - `does not register — ${unknownCall.detail} (predicate: \`${quoteSource(source)}\`). The ` + - `predicate parses, so nothing else reports it, and it faults the moment it is evaluated: on ` + - `a view/page surface the console falls OPEN and the element renders unconditionally, exactly ` + - `like one carrying no predicate at all; on an action surface — evaluated with ` + - `\`throwOnError: true\` — it falls CLOSED and the action disappears for EVERY user, ` + - 'including one who holds the grant, leaving one deduped `console.warn` as the only signal.', + `visibility predicate calls unregistered \`${unknownCall.name}\` (${unknownCall.detail}), ` + + `so view/page elements always render and actions hide for everyone`, hint: `\`${unknownCall.name}\` is not a function this platform registers — a NAME fault, not a ` + `dialect mistake, so re-spelling the predicate will not fix it. The callable names ` + @@ -1188,10 +1192,8 @@ function checkElement( where, path, message: - `visibility predicate reads through \`${unbound.name}.\`, a namespace no binding on this surface ` + - `provides — so \`${unbound.name}\` resolves to nothing, the predicate can never evaluate, and the ` + - `console falls OPEN: the element renders unconditionally and looks exactly like one with no ` + - `predicate at all (failing open is the console's settled behaviour).`, + `visibility predicate reads through \`${unbound.name}.\`, a namespace this surface does not ` + + `bind, ${NEVER_EVALUATES}`, hint: `Root the path at a namespace this surface binds — e.g. \`${root}.\` instead of ` + `\`${unbound.name}.\`` + @@ -1208,12 +1210,8 @@ function checkElement( where, path, message: - `visibility predicate references \`${bare}\` as a bare identifier. ` + - `Values are bound under a namespace on this surface — they are never ` + - `flattened to top level — so \`${bare}\` resolves to nothing, the predicate ` + - `can never evaluate, and the console falls OPEN: the element renders ` + - `unconditionally and looks exactly like one with no predicate at all (failing open is the ` + - `console's settled behaviour).`, + `visibility predicate references \`${bare}\` as a bare identifier, which resolves to ` + + `nothing on this surface, ${NEVER_EVALUATES}`, hint: `Write \`${root}.${bare}\` instead of \`${bare}\`` + (layer === 'runtime' diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 736c8536c9b..c688b50c492 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { ChartTypeSchema } from '@objectstack/spec/ui'; import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; import { - validateWidgetBindings, + validateWidgetBindings as validateWidgetBindingsUnrecorded, MARK_MIXING_CHART_TYPES, METRIC_WIDGET_TYPES, TABULAR_WIDGET_TYPES, @@ -27,6 +27,34 @@ import { WIDGET_FILTER_FIELD_NOT_INCLUDED, WIDGET_SORTBY_UNSELECTED, } from './validate-widget-bindings.js'; +import { explainRule } from './rule-explanations.js'; + +// [#22161] Each finding of the rule ids this file's rule shortened is one +// verdict sentence; the reasoning it used to carry is the id's `os explain` +// entry. Every call below records what it fired, and the last case in this +// file holds each recorded verdict of those ids to one line of at most 200 +// characters — so the pin covers every firing variant this suite exercises, +// not a chosen few. Run the whole file: that case reads what the cases above +// fired. +const SHORTENED_RULE_IDS: readonly string[] = [ + WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, + DASHBOARD_FILTER_FIELD_UNKNOWN, + DASHBOARD_FILTER_FIELD_NOT_INCLUDED, + DASHBOARD_FILTER_FIELD_UNPROVISIONED, + WIDGET_FILTER_FIELD_UNKNOWN, + WIDGET_FILTER_FIELD_NOT_INCLUDED, + WIDGET_SORTBY_UNSELECTED, + CHART_FIELD_UNKNOWN, + CHART_MEASURES_MISSING, + WIDGET_MEASURES_MISSING, + CHART_DIMENSIONS_MISSING, +]; +const firedShortened: Array<{ rule: string; message: 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); + return findings; +}; /** The downstream repro from issue #1719 — dataset with a count AND a sum * measure plus a dimension; the widget selects only the count, no dims. */ @@ -164,10 +192,11 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { 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'); + expect(findings[0].message).toContain('is not a measure of dataset "expense_line_metrics"'); // 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'); + // [#22161] That it is a silent no-op is `os explain chart-field-unknown`'s + // (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}']`); @@ -208,8 +237,10 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => { 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'); + // from the axis positions, and the difference is the point. [#22161] The + // BY-NAME mechanism is the `os explain` entry's; the verdict keeps the + // outcome. + expect(findings[0].message).toContain('pairs with no series'); expect(findings[0].message).toContain('lands on nothing'); expect(findings[0].message).not.toContain('will not contain'); // The shape sentence now says DELETE rather than describing what the key @@ -856,9 +887,10 @@ describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8 expect(f.where).toContain('federation_dashboard'); expect(f.where).toContain('total_customers'); expect(f.message).toContain('owner_id'); - expect(f.message).toContain('ext_customer'); - expect(f.message).toContain('external object (ADR-0015)'); - expect(f.message).toContain('constant-false'); + expect(f.message).toContain("external object 'ext_customer'"); + 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"); expect(f.path).toBe('dashboards[0].widgets[0]'); // The existence rule is UNCHANGED — the name still resolves. @@ -950,8 +982,8 @@ describe('validateWidgetBindings (dashboard-filter-field-unprovisioned, issue #8 expect(findings).toHaveLength(1); expect(findings[0].severity).toBe('warning'); // The cause names ext_customer — the LEAF's object — not crm_order. - expect(findings[0].message).toContain('ext_customer'); - expect(findings[0].message).toContain('external object (ADR-0015)'); + expect(findings[0].message).toContain("external object 'ext_customer'"); + expect(findings[0].message).not.toContain('crm_order'); expect(unknownOnly(validateWidgetBindings(federated))).toHaveLength(0); }); }); @@ -1103,7 +1135,7 @@ describe('dashboard-filter-field-not-included — the include clause (#14275)', expect(f.message).toContain('deal_metrics'); // 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('EVERY bound widget'); + expect(f.message).toContain('the whole board renders empty'); expect(f.hint).toContain('(none)'); }); @@ -1558,8 +1590,8 @@ describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { expect(findings[0].severity).toBe('warning'); expect(findings[0].rule).toBe(CHART_DIMENSIONS_MISSING); expect(findings[0].path).toBe('dashboards[0].widgets[0]'); - // The pinned expression, quoted, and the consequence it produces. - expect(findings[0].message).toContain('METRIC_TYPES.has(widgetType) || dimensions.length === 0'); + // 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'); // The honest alternative repair: declare the family that actually renders. @@ -1740,7 +1772,7 @@ describe('widget-measures-missing (#15508)', () => { expect(findings[0].path).toBe('dashboards[0].widgets[0]'); // The PINNED placeholder string, quoted — not an inferred consequence. expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.'); - expect(findings[0].message).toContain('the single KPI number this tile is for is not drawn at all'); + 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'); @@ -1932,3 +1964,41 @@ describe('#15463 acceptance — the refused chartConfig binding keys advise, nev }); } }); + +describe('[#22161] one-line verdicts — the rule ids this file shortened', () => { + it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => { + // The coverage control first: each shortened id fired at least once, so + // the shape assertion below cannot pass over an empty record. + expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + for (const f of firedShortened) { + expect(f.message, f.rule).not.toContain('\n'); + expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200); + } + }); + + // What each verdict stopped saying, which `os explain RULE_ID` now prints. + const MOVED: Record = { + [WIDGET_LEGACY_ANALYTICS_UNRENDERABLE]: ['single-form cutover', 'DatasetWidget', '`xAxisField`', 'widget-legacy-analytics-shape'], + [DASHBOARD_FILTER_FIELD_UNKNOWN]: ['EVERY bound widget', '`created_at`', 'filterBindings: { NAME: false }', 'broken query'], + [DASHBOARD_FILTER_FIELD_NOT_INCLUDED]: ['`assertDeclared`', '`runtimeFilter`', 'EVERY bound widget', 'whole board renders empty'], + [DASHBOARD_FILTER_FIELD_UNPROVISIONED]: ['ADR-0015', 'remote database owns the schema', 'constant-false', 'HTTP 200, zero rows, no error'], + [WIDGET_FILTER_FIELD_UNKNOWN]: ['`runtimeFilter`', 'widens the scope', 'nothing reports the miss', 'filter-token-unknown'], + [WIDGET_FILTER_FIELD_NOT_INCLUDED]: ['`assertDeclared`', '`runtimeFilter`', 'joins ONLY'], + [WIDGET_SORTBY_UNSELECTED]: ['`resolveOrdering`', 'DATASET_INVALID', 'query RESULT', 'DashboardWidgetOptionsSchema.sortBy'], + [CHART_FIELD_UNKNOWN]: ['`axisPresentation`', 'BY NAME', 'silent no-op', 'refused on a dataset-bound widget'], + [CHART_MEASURES_MISSING]: ['DatasetWidget.tsx', 'widget-measures-missing', 'chart-dimensions-missing'], + [WIDGET_MEASURES_MISSING]: ['`solid-gauge`', '`pivot`', 'one KPI number the tile exists to show'], + [CHART_DIMENSIONS_MISSING]: ['METRIC_TYPES.has(widgetType) || dimensions.length === 0', 'declared chart family is simply gone', '`metric` or `kpi`'], + }; + + it('covers exactly the shortened ids', () => { + expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort()); + }); + + it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict 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[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 63571559921..c07dd85cd59 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -16,8 +16,8 @@ import { } from './object-graph.js'; import { indexUnprovisionedAnchors, - unprovisionedAnchorCause, unprovisionedAnchorHint, + unprovisionedAnchorVerdict, } from './system-fields.js'; /** @@ -789,9 +789,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { severity: 'error', rule: WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, message: - `sets legacy analytics key${plural ? 's' : ''} ${keyList} ` + - `(removed by the ADR-0021 single-form cutover) and binds no data source ` + - `(no \`dataset\`, \`object\`, or inline \`data\`) — it renders nothing.`, + `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.`, @@ -893,18 +892,20 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // population, and this PR narrows the accept set rather than // re-wording what already fires. const account = field.includes('.') - ? describeFieldPathVerdict(verdict, field, 'the effective field') + ? describeFieldPathVerdict(verdict, field, 'field') : undefined; if (account) { push({ severity: 'error', rule: DASHBOARD_FILTER_FIELD_UNKNOWN, - // #2501 is the fan-out this sentence describes; the id stays - // in the comment, never in the string an author reads. + // [#22161] One verdict sentence. The account names the field + // path, so the provenance drops it; the #2501 fan-out (the + // filter is ANDed into EVERY bound widget's query) is this + // id's `os explain` entry, never a tracker id in the string. message: - `${provenance}${account.message} The filter is ANDed into EVERY bound ` + - `widget's analytics query, so the query addresses a column that ` + - `does not exist.`, + (eff.explicit + ? `binds dashboard filter \`${def.name}\` (via filterBindings), but ` + : `inherits dashboard filter \`${def.name}\`, but `) + account.message, hint: `Point filterBindings: { ${def.name}: '' } at a field that resolves on ` + `\`${datasetObject}\` — or at a \`relationship[.relationship].field\` path whose ` + @@ -947,11 +948,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // Same #2501 fan-out as above — id in the comment, not the // message: `#NNNN` resolves for nobody downstream of here. message: - `${provenance}its relationship prefix "${prefix}" is not declared in dataset ` + - `"${dsName}"'s \`include\` — and ADR-0021 joins ONLY declared paths, so no join ` + - `is compiled and the column is out of this query's reach. The filter is ANDed ` + - `into EVERY bound widget's analytics query, so the whole board renders ` + - `empty, not one tile.`, + `${provenance}its relationship prefix "${prefix}" is not in dataset ` + + `"${dsName}"'s \`include\`, so no join reaches it and the whole board renders empty`, 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 ` + @@ -977,11 +975,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { push({ severity: 'warning', rule: DASHBOARD_FILTER_FIELD_UNPROVISIONED, - message: - `${provenance}${unprovisionedAnchorCause(leafObject, leafField)}. The filter is ANDed ` + - `into this widget's analytics query, so it can never match a real value — ` + - `on SQLite it silently degrades to constant-false and the widget renders empty ` + - `(HTTP 200, zero rows, no error).`, + 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 ` + @@ -1016,11 +1010,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { severity: 'error', rule: WIDGET_FILTER_FIELD_UNKNOWN, path: at, - message: - `${account.message} The widget's own \`filter\` is ANDed into the ` + - `dataset query as \`runtimeFilter\`, so the condition addresses a column ` + - `that does not exist: the widget renders successfully and empty, and ` + - `nothing reports the miss.`, + message: account.message, hint: `Filter on a field that exists on "${filterObject}" (dataset "${dsName}"), ` + `or on a \`relationship[.relationship].field\` path whose prefix is declared ` + @@ -1042,10 +1032,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { rule: WIDGET_FILTER_FIELD_NOT_INCLUDED, path: at, message: - `filter key "${field}" resolves on the object graph, but its relationship ` + - `prefix "${prefix}" is not declared in dataset "${dsName}"'s \`include\` — ` + - `and ADR-0021 joins ONLY declared paths, so no join is compiled and the ` + - `column is out of this query's reach. The widget renders empty.`, + `filter key "${field}" resolves, but its relationship prefix "${prefix}" is not in ` + + `dataset "${dsName}"'s \`include\`, so no join reaches it and the widget renders empty`, hint: `Add "${prefix}" to dataset "${dsName}"'s include (declaring "a.b" implicitly ` + `includes "a"), or filter on a field of "${filterObject}" itself. Declared ` + @@ -1129,12 +1117,10 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { rule: WIDGET_SORTBY_UNSELECTED, path: `${path}.options.sortBy`, message: declaredButUnselected - ? `options.sortBy "${sortBy}" is declared by dataset "${dsName}" but is not ` + - `selected by this widget (selects: ${list(selected)}), so the query result ` + - `will not contain that column and the authored order cannot be applied.` - : `options.sortBy "${sortBy}" is neither a \`dimensions\` nor a \`values\` entry ` + - `of this widget (selects: ${list(selected)}) — \`sortBy\` must name one this ` + - `widget actually selects, so the authored order cannot be applied.`, + ? `options.sortBy "${sortBy}" is declared by dataset "${dsName}" but is not selected by ` + + `this widget (selects: ${list(selected)}), so the authored order cannot be applied` + : `options.sortBy "${sortBy}" is neither a \`dimensions\` nor a \`values\` entry of ` + + `this widget (selects: ${list(selected)}), so the authored order cannot be applied`, hint: declaredButUnselected ? `Add "${sortBy}" to this widget's ${dimensionNames.has(sortBy) ? 'dimensions' : 'values'}, ` + `or order by something it already selects.` + @@ -1179,12 +1165,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { 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)}) — ` + - `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.`, + `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\`. ` + @@ -1201,18 +1183,16 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { 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); + // [#22161] One verdict sentence: the name clause and a short + // consequence. How the renderer derives series and strips an axis + // `field` is this id's `os explain` entry. 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())})`; + `not selected in the widget's values (${list(values)})` + : `chartConfig.${label} "${field}" is not a measure of dataset "${dsName}"`; 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`; + ? `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 ` + @@ -1228,7 +1208,7 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { push({ severity: 'warning', rule: CHART_FIELD_UNKNOWN, - message: `${nameClause} — ${consequence}. It is a silent no-op, not a query that fails.`, + message: `${nameClause}, ${consequence}`, hint: `${fixHint} ${shapeHint} ${suppressHint}`, }); }; @@ -1293,10 +1273,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { severity: 'warning', rule: CHART_MEASURES_MISSING, message: - `'${w.type}' widget selects no measures (\`values\` is empty), so the ` + - `renderer short-circuits to the authoring placeholder "Pick measures ` + - `(values) for this dataset widget." before any query runs — no chart is ` + - `drawn at all.`, + `'${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`, hint: `Select at least one measure of dataset "${dsName}" BY NAME — ` + `values: [''] (declared measures: ${list(measures.keys())}).` + @@ -1313,15 +1291,14 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { // tabular ones lose the grid. The placeholder is the same one, and // it is quoted from the pin rather than paraphrased. const drawn = METRIC_WIDGET_TYPES.has(w.type as string) - ? 'the single KPI number this tile is for is not drawn at all' + ? 'its KPI number is not drawn at all' : 'no table is rendered at all'; push({ severity: 'warning', rule: WIDGET_MEASURES_MISSING, message: - `'${w.type}' widget selects no measures (\`values\` is empty), so the ` + - `renderer short-circuits to the authoring placeholder "Pick measures ` + - `(values) for this dataset widget." before any query runs — ${drawn}.`, + `'${w.type}' widget selects no measures (\`values\` is empty), so the renderer ` + + `shows the "Pick measures (values) for this dataset widget." placeholder and ${drawn}`, hint: `Select at least one measure of dataset "${dsName}" BY NAME — ` + `values: [''] (declared measures: ${list(measures.keys())}). ` + @@ -1336,11 +1313,8 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { severity: 'warning', rule: CHART_DIMENSIONS_MISSING, message: - `'${w.type}' widget selects no dimensions, so the renderer's ` + - `\`isMetric\` test (\`METRIC_TYPES.has(widgetType) || dimensions.length ` + - `=== 0\`) is true and it draws a single KPI number instead of a ` + - `'${w.type}' chart. The number is real, so nothing looks broken — the ` + - `declared chart family is simply gone.`, + `'${w.type}' widget selects no dimensions, so the renderer draws a single KPI ` + + `number instead of a '${w.type}' chart`, hint: `Plot the chart against a dataset dimension — dimensions: [''] ` + `(declared dimensions: ${list(dimensionNames)}) — or, if a single value ` +