From 54ff0bafd08fae9a0793407ef94a7d2b2c5c3e6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:53:27 +0000 Subject: [PATCH 1/3] fix(lint): one-line fix lines for 17 more author-time rule ids (slice 16) (WIP) Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/lint/src/rule-explanations.ts | 144 +++++++++++++++++- .../lint/src/validate-ai-surface-affinity.ts | 7 +- .../lint/src/validate-approval-approvers.ts | 34 +++-- packages/lint/src/validate-chart-bindings.ts | 24 +-- .../lint/src/validate-dataset-references.ts | 17 ++- .../lint/src/validate-list-view-field-refs.ts | 19 ++- .../lint/src/validate-object-field-refs.ts | 13 +- .../lint/src/validate-page-source-styling.ts | 7 +- packages/lint/src/validate-record-title.ts | 17 +-- .../lint/src/validate-seed-replay-safety.ts | 12 +- .../lint/src/validate-seed-state-machine.ts | 7 +- .../src/validate-translation-references.ts | 96 ++++-------- packages/lint/src/validate-view-containers.ts | 8 +- 13 files changed, 251 insertions(+), 154 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index e8b47936983..054e5bd37c6 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -533,12 +533,14 @@ const DATASET_INCLUDE_UNKNOWN_EXPLANATION: RuleExplanation = { '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.', + 'every dimension or measure written against that prefix addresses nothing. ADR-0021 derives ' + + 'every join from the object graph, so an entry names a relationship field and never an ON clause.', '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`.', + 'drop the entry. Declaring `a.b` implicitly includes `a`. The verdict\'s "Did you mean" names the ' + + 'nearest field of the object the entry was resolved on.', ], }; @@ -555,7 +557,8 @@ const DATASET_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { '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, ' + + 'and the verdict names the hop that failed and, when one is close, the nearest declared field ' + + '("Did you mean"). 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.', ], @@ -2400,6 +2403,89 @@ const APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER_EXPLANATION: RuleExplanation = { ], }; +const APPROVAL_EXPRESSION_INVALID_EXPLANATION: RuleExplanation = { + rule: 'approval-expression-invalid', + covers: 'what an expression approver may read', + paragraphs: [ + // The three roots are held to the spec's `APPROVER_EXPRESSION_ROOTS` by the rule's test. + 'An `expression` approver resolves its people from runtime data: its `value` is bare CEL, with ' + + 'no `{…}` template braces, evaluated at node entry over three roots. `current.*` is the ' + + 'record\'s live state at node entry; `trigger.*` is the submit-time snapshot; `vars.*` holds the ' + + 'flow\'s variables, an upstream node\'s output included (for example ' + + '`vars.get_reviewers.record.owner_id` or `vars.approval_lead.picked_departments`). The result — ' + + 'a user id, a comma-separated list or an array, or ids re-expanded per `resolveAs` — is the ' + + 'approver slate.', + 'The runtime rejects an expression that is empty, does not parse, or reads any other root, and ' + + 'fails the node at entry: the CEL environment would resolve an unknown root as `dyn`, so it ' + + 'would evaluate to null and leave a silently empty slate, and the pre-check fails the node ' + + 'loudly instead. So each is an error here, on the CLI and at the runtime publish gate alike.', + '`record` and `previous` are deliberately not bound at an approval node. Everywhere else on the ' + + 'platform (flow conditions, hooks) `record` means the record at event time, and reusing it here ' + + 'would silently alias one of two times. Write `current.FIELD` for the live value at node entry ' + + 'or `trigger.FIELD` for the submit-time snapshot; `vars.previous` carries the pre-update row.', + '`resolveAs` means something only on an `expression` approver: it says how the resolved values ' + + 'become people (`user`, the default, or `department`, `position` or `team`, expanded through the ' + + 'same lookups those approver types use). On any other type it does nothing, so it is reported ' + + 'as dead config at `info`: remove it, or make the approver an expression.', + ], +}; + +const APPROVAL_EXPRESSION_NO_EMPTY_POLICY_EXPLANATION: RuleExplanation = { + rule: 'approval-expression-no-empty-policy', + covers: 'what each empty-slate policy does', + paragraphs: [ + 'A node with an `expression` approver resolves people from runtime data, so an empty result is ' + + 'far likelier than for a static approver type: a field nobody has written yet mid-flow, or an ' + + 'upstream output that came back empty. The rule asks the node to SAY what an empty slate should ' + + 'do rather than inherit the default silently.', + // The four policies are held to the spec's `onEmptyApprovers` enum by the rule's test. + 'The policies, in `onEmptyApprovers`: `admin_rescue`, the default — the request opens on the ' + + 'organization\'s administrators, and only a privileged admin can act; `fail` — the node fails ' + + '(its fault edge, or the run), for a slate that can only be empty through a configuration bug; ' + + '`auto_approve` — the request is skipped and the run continues down the `approve` edge with ' + + '`output.autoApproved = true`, which waves the record through; `fallback` — the request opens on ' + + 'the people a sibling `fallbackApprovers` list names instead, and a fallback that itself resolves ' + + 'to nobody degrades to `admin_rescue`.', + 'Advisory (`info`): declaring the default explicitly silences it, and an `info` finding never ' + + 'blocks a build or a publish.', + ], +}; + +// ── Record titles (`validate-record-title.ts`) ────────────────────────────── + +const TITLE_FORMAT_RETIRED_EXPLANATION: RuleExplanation = { + rule: 'title-format-retired', + covers: 'why titleFormat is retired, and what replaces it', + paragraphs: [ + 'ADR-0079 makes a record\'s title a structural invariant: every object resolves one primary ' + + 'title from a real field, the one `nameField` points at (`displayNameField` is its deprecated ' + + 'alias), or one derived from the object\'s fields. `titleFormat` is a render-only template: the ' + + 'server can neither return the title it draws nor query by it, the shared title predicate ' + + '(`objectTitleCompleteness`) never reads it, and an explicit `nameField` takes precedence over it.', + 'For a single-field title, set `nameField` to that field. For a composite title, add a formula ' + + 'field with `returnType: \'text\'` and point `nameField` at it: a formula whose result is text is ' + + 'title-eligible. Then drop `titleFormat`, which this warning reports for as long as it is declared.', + 'A warning, not an error: the schema still parses the key, so existing metadata keeps loading.', + ], +}; + +const TITLE_UNRESOLVABLE_EXPLANATION: RuleExplanation = { + rule: 'title-unresolvable', + covers: 'how a record title resolves, and its fallback', + paragraphs: [ + // The eligible types are held to the spec's `TITLE_ELIGIBLE_TYPES` by the rule's test. + 'A record\'s title resolves from the object\'s `nameField` pointer (or the deprecated ' + + '`displayNameField`), else by derivation from a title-eligible field: a `text`, `textarea`, ' + + '`email`, `url`, `markdown`, `html` or `richtext` field, or a `formula` whose `returnType` is ' + + '`text`, a field named like a title (`name`, `title`, `subject`, …) first. This finding means ' + + 'neither exists: no pointer and nothing to derive one from (`objectTitleCompleteness` answers ' + + '`none`), so records have no meaningful name.', + 'A warning, not an error: the runtime still auto-provisions a primary and falls back to a stable ' + + '`Record #ID` floor, so a green build never ships a title-less object. An explicit title is far ' + + 'more useful to every surface that names the record.', + ], +}; + // ── Data-model rules (`data-model-rules.ts`) ─────────────────────────────── // The three ADR-0120 uniqueness ids run on `os validate` and `os build` as // registry entries and on `os lint` through `lintDataModel`; the other three @@ -2676,6 +2762,12 @@ const CHART_MEASURE_UNKNOWN_EXPLANATION: RuleExplanation = { 'chart\'s `series[]` is replaced wholesale by one derived entry per selected measure; and a ' + 'page chart\'s `yAxis[].field` keeps its slot (the count turns on a secondary axis) and its ' + 'scale, while the plotted columns come from `values`.', + 'So at a presentation position the fix is the name or nothing: `series[].name` selects WHICH ' + + 'derived series a label and its presentation land on and cannot add, remove or re-point one ' + + '(membership belongs to the dataset selection), and `yAxis[]` carries presentation only — title, ' + + 'min/max, position, gridlines. None of these surfaces carries a `suppressWarnings` key, so ' + + 'correcting the name or dropping the entry is the only way to clear the advisory. The fix line ' + + 'names the nearest declared measure ("Did you mean") rather than listing the dataset\'s measures.', CHART_BINDING_SURFACES, ], }; @@ -2840,7 +2932,7 @@ const LIST_VIEW_FIELD_UNKNOWN_EXPLANATION: RuleExplanation = { '`userFilters.tabs[].filter`, and every binding inside a `kanban`, `calendar`, `gantt`, ' + '`timeline`, `gallery`, `map` or `tree` block. Nothing resolves the name at render time: the ' + 'view renders successfully with a blank, empty or wrong result, and nothing else reports the ' + - 'miss.', + 'miss. The verdict\'s "Did you mean" names the nearest field the object declares.', 'An error where the miss changes what the view returns or collapses what it lays out — for ' + 'example, a filter key, user filter or gantt quick filter matches no record; a column is sent as the ' + 'projection, where the REST ingress answers `400 INVALID_FIELD`; a `kanban.groupByField` ' + @@ -2879,6 +2971,10 @@ const LIST_VIEW_FIELD_DOTTED_EXPLANATION: RuleExplanation = { '(`assertFilterIsMaterializable` and the REST ingress, `400 INVALID_FIELD`) rather than ' + 'answered with an empty list. A structured or JSON head, an array (`multiple: true`) head, a ' + 'file head and an unreadable head are served by the door, so they are not reported.', + 'The ways out are the doors\' own. At a projection position, name the whole column and read ' + + 'into its value in the client, or read the related record with `expand`; at a filter position, ' + + 'filter on a column of the bound object itself. At either, denormalise the value onto the bound ' + + 'object — a stored field, written when the source changes — and name that.', 'Not judged, because each is read client-side out of the fetched row, where a dotted path ' + 'walks an embedded record: `gantt.quickFilters[].field`, `gantt.tooltipFields[]` and every ' + 'renderer binding. `sort` keeps its own rule. Always an error: every judged position is ' + @@ -2906,6 +3002,8 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = { 'long after the field, view, section or navigation entry it was written for was deleted. The ' + 'forward half of the same parity, a declared surface with no translation, already fails, so ' + 'the two halves gate alike.', + 'The verdict names the nearest declared name when one is close ("Did you mean"), and the fix ' + + 'line names the remedy only, without listing what is declared; what each key may name follows.', 'Under `objects.OBJECT` (an object this stack, or a sibling package of the same artifact, ' + 'defines): `fields.FIELD` a declared field (a system column, `_id`, `name` and `space` are ' + 'never reported); `_views.VIEW` a view\'s `name`, not its label; `_sections.KEY` a ' + @@ -3127,6 +3225,8 @@ const OBJECT_FIELD_REF_UNKNOWN_EXPLANATION: RuleExplanation = { 'object and misses under `ownership: \'none\'`), and the positions other rules own: ' + '`searchableFields` (`searchable-field-unknown`), list views (the list-view rules) and the ' + 'scalar role pointers.', + 'The verdict names the nearest field of the addressed object when one is close ("Did you mean"), ' + + 'and the fix line names the position\'s remedy only, without listing that object\'s fields.', ], }; @@ -3326,6 +3426,28 @@ const VIEW_CONTAINER_SHAPE_EXPLANATION: RuleExplanation = { ], }; +// ── Source-tier page styling (`validate-page-source-styling.ts`) ──────────── + +const PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION: RuleExplanation = { + rule: 'page-source-className-tailwind', + covers: 'why Tailwind classes in page source produce no CSS', + paragraphs: [ + 'A `kind: \'html\'`, `\'react\'` or `\'jsx\'` page\'s `source` is runtime metadata, and the ' + + 'Console\'s Tailwind build scans only the renderer\'s own source, never an authored page string. ' + + 'So a Tailwind `className` in page source produces no CSS (ADR-0065): it styles the page only by ' + + 'coincidence, when the class happens to be one the renderer already ships.', + 'On a react page, style with an inline `style={{…}}` object that uses the theme\'s tokens as ' + + '`hsl(var(--token))` — `color: \'hsl(var(--foreground))\'`, `background: \'hsl(var(--card))\'` — ' + + 'so the page follows the theme. Render a drawer or a modal through ' + + '`ObjectForm` with `formType="drawer"` or `formType="modal"` rather than a hand-rolled overlay.', + 'On an html page, lay out with the components\' structured props (`flex` with `direction` and ' + + '`gap`, `grid` with `columns`) and add CSS through a JSON style object, ' + + '`style={{"color":"hsl(var(--foreground))"}}`.', + 'A warning: the page still renders, without the styling the classes asked for. The verdict counts ' + + 'the `className` attributes in the source.', + ], +}; + // ── Interface page visualizations (`validate-page-visualization-bindings.ts`) ── const PAGE_VISUALIZATION_WITHOUT_BINDING_EXPLANATION: RuleExplanation = { @@ -3424,6 +3546,9 @@ const AI_SKILL_SURFACE_MISMATCH_EXPLANATION: RuleExplanation = { 'that resolves to no declared skill is skipped, not reported: kernel skills (`schema_reader`, ' + 'the `ask` and `build` bundles) are registered at run time and invisible to a static read, so ' + 'resolving names against `stack.skills` alone would flag every one of them.', + 'To fix it, move the skill to an agent of its own surface, or change the skill\'s `surface` to the ' + + 'agent\'s. Declare `surface: \'both\'` only for a genuinely shared, read-only capability, since ' + + 'a `both` skill attaches to an agent of either surface.', ], }; @@ -3608,7 +3733,8 @@ const SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION: RuleExplanation = { 'then six, then nine).', // The modes are held to the rule by its test, each one silent. 'The fix line names the idempotent modes, `ignore` (skip rows that already exist) and `upsert` ' + - '(create or update), each matching on the dataset\'s `externalId`: a single natural-key field, ' + + '(create or update), each matching on the dataset\'s `externalId`: a single natural-key field ' + + '(`externalId: \'code\'`), ' + 'or a composite list of fields for a join or junction table with no single natural key ' + '(`[\'team\', \'project\']`).', 'A warning, not a refusal: an insert seed loads and works on a fresh database, and the defect ' + @@ -3630,7 +3756,8 @@ const SEED_VALUE_OUTSIDE_STATE_MACHINE_EXPLANATION: RuleExplanation = { 'So a seeded value need not be an initial state, but it must be a state the machine DECLARES: ' + 'the union of its `initialStates`, the keys of its `transitions` map and every transition ' + 'target. The verdict names the first three declared states in alphabetical order and counts ' + - 'the rest.', + 'the rest. The exemption lets a seed be born mid-lifecycle; it is not a licence to write an ' + + 'unknown state.', 'Not judged: an absent or empty value, a value that is not a plain string (an unresolved ' + 'expression envelope, a number), and a `state_machine` rule that declares no state at all. A ' + 'warning: a curated value the machine does not know is suspicious, not necessarily wrong.', @@ -3989,6 +4116,10 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [FILTER_PRESET_COMPARAND_EXPLANATION.rule]: FILTER_PRESET_COMPARAND_EXPLANATION, [APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION.rule]: APPROVAL_APPROVERS_MAY_RESOLVE_EMPTY_EXPLANATION, [APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER_EXPLANATION.rule]: APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER_EXPLANATION, + [APPROVAL_EXPRESSION_INVALID_EXPLANATION.rule]: APPROVAL_EXPRESSION_INVALID_EXPLANATION, + [APPROVAL_EXPRESSION_NO_EMPTY_POLICY_EXPLANATION.rule]: APPROVAL_EXPRESSION_NO_EMPTY_POLICY_EXPLANATION, + [TITLE_FORMAT_RETIRED_EXPLANATION.rule]: TITLE_FORMAT_RETIRED_EXPLANATION, + [TITLE_UNRESOLVABLE_EXPLANATION.rule]: TITLE_UNRESOLVABLE_EXPLANATION, [UNIQUE_LEGACY_ORGANIZATION_COMPOSITE_EXPLANATION.rule]: UNIQUE_LEGACY_ORGANIZATION_COMPOSITE_EXPLANATION, [UNIQUE_UNSCOPED_DECLARED_INDEX_EXPLANATION.rule]: UNIQUE_UNSCOPED_DECLARED_INDEX_EXPLANATION, [UNIQUE_DOUBLE_DECLARATION_EXPLANATION.rule]: UNIQUE_DOUBLE_DECLARATION_EXPLANATION, @@ -4021,6 +4152,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [ACTION_NAME_UNDEFINED_EXPLANATION.rule]: ACTION_NAME_UNDEFINED_EXPLANATION, [MAPPING_TARGET_FIELD_UNKNOWN_EXPLANATION.rule]: MAPPING_TARGET_FIELD_UNKNOWN_EXPLANATION, [VIEW_CONTAINER_SHAPE_EXPLANATION.rule]: VIEW_CONTAINER_SHAPE_EXPLANATION, + [PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION.rule]: PAGE_SOURCE_CLASSNAME_TAILWIND_EXPLANATION, [PAGE_VISUALIZATION_WITHOUT_BINDING_EXPLANATION.rule]: PAGE_VISUALIZATION_WITHOUT_BINDING_EXPLANATION, [FLOW_CREDENTIAL_LITERAL_EXPLANATION.rule]: FLOW_CREDENTIAL_LITERAL_EXPLANATION, [AI_SKILL_SURFACE_MISMATCH_EXPLANATION.rule]: AI_SKILL_SURFACE_MISMATCH_EXPLANATION, diff --git a/packages/lint/src/validate-ai-surface-affinity.ts b/packages/lint/src/validate-ai-surface-affinity.ts index 7cf8cfc357a..ceb033199ef 100644 --- a/packages/lint/src/validate-ai-surface-affinity.ts +++ b/packages/lint/src/validate-ai-surface-affinity.ts @@ -104,10 +104,11 @@ export function validateAiSurfaceAffinity(stack: AnyRec): AiSurfaceAffinityFindi `Agent "${agentName}" (surface '${agentSurface}') references skill "${ref}" ` + `(surface '${skillSurface}'), so the runtime refuses the binding and chatting with ` + `this agent fails at request time`, + // [#22161] One remedy line; when `surface: 'both'` is the right + // answer (a genuinely shared, read-only capability) is the explanation's. hint: - `A skill may only attach to an agent whose surface it matches. Move "${ref}" to a ` + - `'${skillSurface}'-surface agent, change its \`surface\` to '${agentSurface}', or — ` + - `only if it is a genuinely shared, read-only capability — declare \`surface: 'both'\`.`, + `Move "${ref}" to a '${skillSurface}'-surface agent, change its \`surface\` to ` + + `'${agentSurface}', or declare \`surface: 'both'\` for a shared, read-only skill`, }); } } diff --git a/packages/lint/src/validate-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index fe077083510..68804815028 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -386,10 +386,9 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin where, path: `${path}.value`, message: `expression approver has an empty expression — the node fails at entry.`, - hint: - `Write a CEL expression over current.* (the record's live state at node entry), ` + - `trigger.* (the submit-time snapshot) or vars.* (flow variables), ` + - `e.g. current.approvers_dynamic or vars.approval_lead.picked_departments.`, + // [#22161] One remedy line; what each root holds is + // `os explain approval-expression-invalid`. + hint: `Write a CEL expression over current.*, trigger.* or vars.*, e.g. current.approvers_dynamic`, }); } else { const parsed = collectCelRootIdentifiers(source); @@ -416,11 +415,12 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin message: `expression approver references \`${illegal.join('`, `')}\` — only current.*, ` + `trigger.* and vars.* are available, and the node fails at entry on any other root.`, + // [#22161] One remedy line per arm; why `record` and + // `previous` are unbound at an approval node is + // `os explain approval-expression-invalid`. hint: wantsRecord - ? `\`record\`/\`previous\` are not bound here (on this platform \`record\` always means ` + - `"the record at event time", which is ambiguous at an approval node). Write ` + - `current. for the live value at node entry, trigger. for the ` + - `submit-time snapshot (vars.previous carries the pre-update row).` + ? `Write current. for the live value at node entry or trigger. for the ` + + `submit-time snapshot; vars.previous carries the pre-update row` : `Did you mean current. (live record), trigger. (submit snapshot), ` + `or vars. (flow variable)?`, }); @@ -456,11 +456,12 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin message: `approver { type: '${type}', value: '${value}' } names no org-membership tier ` + `(${MEMBERSHIP_TIER_LIST}), so it matches nobody and the request stalls`, + // [#22161] One remedy line: the verdict already lists the tiers, + // and why a business role is always a position (the closed + // ADR-0108 vocabulary, `sys_user_position`) is the explanation's. hint: - `If '${value}' is an org position, author { type: 'position', value: '${value}' } ` + - `(resolved via sys_user_position, ADR-0090 D3). Keep type 'org_membership_level' ` + - `only for membership tiers (${MEMBERSHIP_TIER_LIST}) — the vocabulary is closed ` + - `(ADR-0108), so a business role is always a position.`, + `If '${value}' is an org position, author { type: 'position', value: '${value}' }; keep ` + + `'org_membership_level' for the tiers the verdict lists`, }); } else if (type in DEPRECATED_APPROVER_TYPES) { const fix = canonicalApproverType(type); @@ -617,11 +618,12 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin `this node resolves approvers from an expression but declares no onEmptyApprovers — ` + `an empty result falls back to the default ('admin_rescue': request opens, only a ` + `privileged admin can act).`, + // [#22161] One remedy line naming the closed vocabulary (held to the + // spec's enum by the rule's test); what each policy does is + // `os explain approval-expression-no-empty-policy`. hint: - `Declare the empty-slate policy explicitly: onEmptyApprovers: 'admin_rescue' (hold for ` + - `admin takeover), 'fail' (fail the node — config bug), 'auto_approve' (wave through, ` + - `output.autoApproved = true), or 'fallback' with a sibling fallbackApprovers list (open ` + - `the request on those people instead).`, + `Declare onEmptyApprovers on the node: 'admin_rescue', 'fail', 'auto_approve', or 'fallback' ` + + `with a fallbackApprovers list`, }); } diff --git a/packages/lint/src/validate-chart-bindings.ts b/packages/lint/src/validate-chart-bindings.ts index c712f13ce11..039a9f9ec93 100644 --- a/packages/lint/src/validate-chart-bindings.ts +++ b/packages/lint/src/validate-chart-bindings.ts @@ -336,19 +336,6 @@ const UNKNOWN_CONSEQUENCE: Record, string> = { + 'it re-points nothing', }; -/** Presentation position → the shape sentence in the hint. */ -const SHAPE_HINT: Record, string> = { - 'report-series': - '`series[].name` selects WHICH derived series the label and per-series presentation ' - + 'land on; it cannot add, remove or re-point one.', - 'page-series': - '`series[].name` cannot add, remove or re-point a series on a dataset-bound chart — ' - + 'membership belongs to the dataset selection.', - 'page-axis': - '`yAxis[]` carries presentation only (title, min/max, position, gridlines); the ' - + 'plotted columns come from `values`.', -}; - /** * Position → the consequence of naming a DECLARED measure outside the * selection, as the verdict's closing clause. [#22161] Which selection each @@ -452,12 +439,15 @@ export function validateChartBindings(stack: AnyRec): ChartBindingFinding[] { ? `, so this series comes back empty (result rows are keyed by measure name, ` + `not the base field)` : `: ${UNKNOWN_CONSEQUENCE[position]}`), + // [#22161] One remedy line plus the nearest declared measure; the + // dataset's measure list is not echoed, and what each presentation + // position carries (and that no `suppressWarnings` key silences it) + // is `os explain chart-measure-unknown`. hint: - `Dataset measures: ${list(ds.measures)}.${suggestName(name, ds.measures)} ` + (position === 'query' - ? `Declare the measure on the dataset, or bind an existing one.` - : `${SHAPE_HINT[position]} Correct the name or drop the entry — this ` + - `surface carries no \`suppressWarnings\` key to silence the advisory with.`), + ? `Bind a measure that dataset "${dsName}" declares, or declare "${name}" on it.` + : `Correct the name to a measure that dataset "${dsName}" declares, or drop the entry.`) + + suggestName(name, ds.measures), }); return; } diff --git a/packages/lint/src/validate-dataset-references.ts b/packages/lint/src/validate-dataset-references.ts index d9a28bebf91..132ade39a8b 100644 --- a/packages/lint/src/validate-dataset-references.ts +++ b/packages/lint/src/validate-dataset-references.ts @@ -206,10 +206,13 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { const verdict = resolveFieldPath(graph, object, entry); if (isUnjudgeable(verdict) || !verdict) return; + // [#22161] One remedy line on every arm: the verdict's "Did you mean" + // names the nearest field, so the object's field list and the + // traversable types (`account.detail`) are not echoed; the three-hop + // limit, which types join, and that "a.b" includes "a" are + // `os explain dataset-include-unknown`. const prescription = - `Declare a relationship (lookup / master_detail) that exists on the join chain from ` + - `"${object}", or drop the entry. ADR-0071 allows up to 3 hops and declaring "a.b" ` + - `implicitly includes "a".`; + `Name a relationship field on the join chain from "${object}" (up to 3 hops), or drop the entry`; if (verdict.kind === 'ok') { // The entry resolves to a real field — but `include` joins, so the @@ -245,7 +248,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { where, path, message: account.message, - hint: `${prescription} ${account.detail}`, + hint: prescription, }); }); @@ -276,7 +279,11 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] { where: positionWhere, path, message: account.message, - hint: `${prescription} ${account.detail}`, + // [#22161] `dataset-field-unknown` prints its remedy alone: the + // verdict's "Did you mean" names the nearest field, so the object's + // field list (`account.detail`) is not echoed. The filter-key id + // keeps the list until its own slice. + hint: rule === DATASET_FIELD_UNKNOWN ? prescription : `${prescription} ${account.detail}`, }); return; } diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index 5cbdb5da869..8cf64884683 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -565,9 +565,10 @@ function describeDottedRefusal( meta: { type?: string; multiple?: boolean } | undefined, subject: string, ): { message: string; hint: string } | undefined { - const denormalise = - `Denormalise the value onto "${object}" (a stored field, written when the source changes) ` + - 'and name that.'; + // [#22161] One remedy line per door, still in the doors' words ("denormalise + // the value onto … and name that"); what a denormalised field is (stored, + // written when the source changes) is `os explain list-view-field-dotted`. + const denormalise = `denormalise the value onto "${object}" and name that`; if (axis === 'projection') { // No classification: `assertProjectionHasNoDottedPaths` filters on @@ -577,8 +578,8 @@ function describeDottedRefusal( `${subject} "${written}" is a dotted path, so the query projection refuses it ` + `(\`assertProjectionHasNoDottedPaths\`) and the view's first fetch answers 400 INVALID_FIELD`, hint: - `Name a whole column of "${object}" — "${head}" — and read into its value in the client, ` + - `or read the related record with \`expand\`. ${denormalise}`, + `Name the whole column "${head}" and read into it in the client, read the related record ` + + `with \`expand\`, or ${denormalise}`, }; } @@ -596,7 +597,7 @@ function describeDottedRefusal( message: `${subject} "${written}" is a dotted path whose head "${head}" is a ${type} field on ` + `"${object}" (${because}), so no backend serves the path (400 INVALID_FIELD)`, - hint: `Filter on a column of "${object}" itself. ${denormalise}`, + hint: `Filter on a column of "${object}" itself, or ${denormalise}`, }; } @@ -682,8 +683,10 @@ export function validateListViewFieldRefs(stack: AnyRec): ListViewFieldRefFindin message: account.message + (dotted ? ` (the head of "${written}": a list view compiles no joins)` : ''), - hint: - `Name a field that exists on "${object}", or drop the entry. ${account.detail}`, + // [#22161] The remedy alone: the verdict's "Did you mean" names the + // nearest field, so the object's field list (`account.detail`) is not + // echoed. + hint: `Name a field that exists on "${object}", or drop the entry`, }); }; diff --git a/packages/lint/src/validate-object-field-refs.ts b/packages/lint/src/validate-object-field-refs.ts index 376e49d816b..ed751582636 100644 --- a/packages/lint/src/validate-object-field-refs.ts +++ b/packages/lint/src/validate-object-field-refs.ts @@ -286,7 +286,7 @@ interface ListPosition { * `lint` path, never an accepted spelling. */ retiredAlias?: string; - /** The prescription half of the hint. */ + /** The hint: this position's remedy, one line (the verdict carries the "Did you mean"). */ prescription: string; } @@ -363,7 +363,7 @@ interface FieldNameSlot { verbatim: boolean; /** Read this slot's name out of one entry, or `undefined` when it holds none. */ read: (entry: unknown) => SlotRead | undefined; - /** The prescription half of the hint. */ + /** The hint: this position's remedy, one line (the verdict carries the "Did you mean"). */ prescription: string; } @@ -470,7 +470,7 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] /** * Resolve one name and push the finding when it misses. `true` = reported. * Every position funnels through here, so the message shape — the verdict's - * account; the prescription, then the addressed object's field list — is one + * account, and the position's prescription as the hint — is one * shape across the family. */ const judge = (at: { @@ -492,9 +492,12 @@ export function validateObjectFieldRefs(stack: AnyRec): ObjectFieldRefFinding[] where: at.where, path: at.path, // [#22161] The shared field-path sentence alone; what the miss costs at - // each position is `os explain object-field-ref-unknown`. + // each position is `os explain object-field-ref-unknown`. The hint is + // the position's remedy alone: the verdict's "Did you mean" names the + // nearest field, so the addressed object's field list + // (`account.detail`) is not echoed. message: account.message, - hint: `${at.prescription} ${account.detail}`, + hint: at.prescription, }); return true; }; diff --git a/packages/lint/src/validate-page-source-styling.ts b/packages/lint/src/validate-page-source-styling.ts index 9664499c90a..320c19185ac 100644 --- a/packages/lint/src/validate-page-source-styling.ts +++ b/packages/lint/src/validate-page-source-styling.ts @@ -54,10 +54,13 @@ export function validatePageSourceStyling(stack: AnyRec): SourceStyleFinding[] { where: `page "${name}"`, path: `${pagePath}.source`, message: `${count} \`className\` attribute${count > 1 ? 's' : ''} in ${String(kind)}-source page — Tailwind utilities in page source silently produce no CSS (the build never scans authored metadata; ADR-0065).`, + // [#22161] One remedy line per kind; why authored Tailwind produces no + // CSS, the theme tokens and the overlay advice are + // `os explain page-source-className-tailwind`. hint: kind === 'react' - ? "Style with inline style={{}} using hsl(var(--token)) theme colors (e.g. color:'hsl(var(--foreground))', background:'hsl(var(--card))'); render drawer/modal via instead of hand-rolled overlays." - : "Lay out with the components' structured props (, ) and add CSS via a JSON style object style={{\"color\":\"hsl(var(--foreground))\"}}; do not use Tailwind className.", + ? "Replace className with inline style={{}} using hsl(var(--token)) theme colors, e.g. style={{ color: 'hsl(var(--foreground))' }}" + : "Replace className with the components' structured props (, ) and a JSON style object, e.g. style={{\"color\":\"hsl(var(--foreground))\"}}", }); } return findings; diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts index 9e9ab505a28..c16ccd5538f 100644 --- a/packages/lint/src/validate-record-title.ts +++ b/packages/lint/src/validate-record-title.ts @@ -80,12 +80,11 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { message: `${objName}: titleFormat is retired (ADR-0079) — migrate to nameField ` + `(single field) or a formula field designated nameField`, + // [#22161] One remedy line; why the template is retired (render-only, + // neither returned nor queryable) is `os explain title-format-retired`. hint: - `titleFormat is a render-only template the server cannot return or ` + - `query, and an explicit nameField now takes precedence. For a ` + - `single-field title set nameField: ''. For a composite title, ` + - `add a formula field (returnType: 'text') and designate it via ` + - `nameField.`, + `Set nameField: '' for a single-field title, or add a formula field (returnType: 'text') ` + + `and designate it via nameField; then drop titleFormat`, }); } @@ -102,11 +101,11 @@ export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { message: `${objName}: no resolvable record title — records will have no ` + `meaningful name (no nameField and no title-eligible field to derive one)`, + // [#22161] One remedy line; the runtime's fallback title, and why this + // is only a warning, are `os explain title-unresolvable`. hint: - `Set nameField to a text/email field (or a formula field with ` + - `returnType: 'text'), or add a text field named "name"/"title". The ` + - `runtime auto-provisions a primary and falls back to "Record #", ` + - `but an explicit title is far more useful.`, + `Set nameField to a text/email field (or a formula field with returnType: 'text'), or add a ` + + `text field named "name"/"title"`, }); } } diff --git a/packages/lint/src/validate-seed-replay-safety.ts b/packages/lint/src/validate-seed-replay-safety.ts index 2e2c47f4e71..0316dc2e283 100644 --- a/packages/lint/src/validate-seed-replay-safety.ts +++ b/packages/lint/src/validate-seed-replay-safety.ts @@ -63,16 +63,16 @@ export function validateSeedReplaySafety(stack: AnyRec): SeedReplaySafetyFinding rule: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY, where, path: `data[${i}].mode`, - // [#22161] One verdict sentence; why seeds are replayed and what the - // idempotent modes match on are `os explain seed-insert-mode-duplicates-on-replay`. + // [#22161] One verdict sentence and one remedy line; why seeds are + // replayed, what each idempotent mode does and the composite + // `externalId` of a junction table are + // `os explain seed-insert-mode-duplicates-on-replay`. message: "`mode: 'insert'` re-inserts every record on each replay boot (dev-server restart, " + 'package re-publish) with no existing-row check, so every restart duplicates its rows', hint: - "Use `mode: 'ignore'` (skip rows that already exist) or `'upsert'` (create-or-update), " + - "and declare an `externalId` to match on: a single natural-key field (e.g. `externalId: 'code'`), " + - 'or a COMPOSITE list of fields for a join / junction table with no single natural key ' + - "(e.g. `externalId: ['team', 'project']`).", + "Use `mode: 'ignore'` or `'upsert'` with an `externalId` to match on (a natural-key field, or a " + + 'list of fields for a junction table)', }); }); diff --git a/packages/lint/src/validate-seed-state-machine.ts b/packages/lint/src/validate-seed-state-machine.ts index bf7e487d361..22a7a4c7984 100644 --- a/packages/lint/src/validate-seed-state-machine.ts +++ b/packages/lint/src/validate-seed-state-machine.ts @@ -157,10 +157,11 @@ export function validateSeedStateMachine(stack: AnyRec): SeedStateMachineFinding `seeds '${rule.field}=${value}', which the '${objectName}' state machine does not declare ` + `(known states: ${atMostThree([...rule.states].sort())}), and seed writes skip that rule, ` + 'so a typo lands silently', + // [#22161] One remedy line; that the seed exemption allows a + // mid-lifecycle state but never an undeclared one is the explanation's. hint: - `If '${value}' is a real state, add it to the state machine (as an initial state or a ` + - `transition endpoint). If it is a typo, correct it to a declared state. The exemption lets ` + - 'a seed be born mid-lifecycle; it is not a licence to write an unknown state.', + `Correct '${value}' to a declared state, or, if it is a real state, add it to the state machine ` + + 'as an initial state or a transition endpoint', }); } }); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 7a36e526aaf..339f26436d8 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -1461,9 +1461,11 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind const universe = buildUniverse(stack); // [#22161] Every `translation-target-unknown` message below is one verdict - // sentence (plus the nearest declared name, when there is one). Why the - // resolver never reads an orphan key, why it gates, and what each leg may - // name are `os explain translation-target-unknown`. + // sentence (plus the nearest declared name, when there is one), and every + // hint one remedy line: the declared names are not listed, since the + // verdict's "Did you mean" already names the nearest. Why the resolver never + // reads an orphan key, why it gates, and what each leg may name are + // `os explain translation-target-unknown`. const orphan = (where: string, path: string, message: string, hint: string) => { findings.push({ severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY, @@ -1521,8 +1523,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `defines, so nothing reads this subtree and every label in it renders untranslated.` + suggest(objectName, universe.objects.keys()), `Rename the key to the object it was written for, drop it, or ignore this if the ` + - `object is contributed by another installed package.` + - (universe.objects.size > 0 ? ` Defined objects: ${listNames(universe.objects.keys())}.` : ''), + `object is contributed by another installed package.`, ); continue; } @@ -1539,8 +1540,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to field "${fieldName}", which object "${objectName}" ` + `does not declare, so its label renders untranslated in this locale.` + suggest(fieldName, facts.fields.keys()), - `Point the key at a declared field, or drop it if the field was removed or renamed.` + - (facts.fields.size > 0 ? ` Declared fields: ${listNames(facts.fields.keys())}.` : ''), + `Point the key at a declared field, or drop it if the field was removed or renamed.`, ); continue; } @@ -1564,8 +1564,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to view "${viewName}", which no view of object ` + `"${objectName}" declares, so the view tab keeps its source-locale label.` + suggest(viewName, facts.views), - `Match the key to the view's \`name\` (not its label), or drop it.` + - (facts.views.size > 0 ? ` Declared views: ${listNames(facts.views)}.` : ''), + `Match the key to the view's \`name\` (not its label), or drop it.`, ); } @@ -1579,10 +1578,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `"${objectName}" declares, so the section heading stays in the source locale.` + suggest(sectionName, facts.sections), `Sections are translatable only through a STABLE NAME: give the group/section a ` + - `\`key\`/\`name\` and use it here, or drop the translation.` + - (facts.sections.size > 0 - ? ` Declared sections: ${listNames(facts.sections)}.` - : ` Object "${objectName}" declares no named section at all.`), + `\`key\`/\`name\` and use it here, or drop the translation.`, ); } @@ -1604,12 +1600,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `object "${objectName}" declares, so the tab keeps its source-locale label.` + suggest(tabName, facts.tabs), `Match the key to the tab's \`name\` (not its label), or drop it if the preset was ` + - `renamed or removed.` + - (facts.tabs.size > 0 - ? ` Declared tabs: ${listNames(facts.tabs)}.` - : ` No page over "${objectName}" declares a filter-preset tab bar at all. Note ` + - `only a page's \`interfaceConfig.userFilters.tabs\` is resolvable — a list ` + - `view's own \`tabs\` has no renderer, so nothing reads a key written for one.`), + `renamed or removed.`, ); } @@ -1629,10 +1620,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `"${objectName}" does not declare, so its refusal \`message\` stays in the source ` + `locale.` + suggest(ruleName, facts.validations), `Match the key to the rule's \`name\`, or drop it if the rule was renamed or ` + - `removed.` + - (facts.validations.size > 0 - ? ` Declared rules: ${listNames(facts.validations)}.` - : ` Object "${objectName}" declares no named validation rules.`), + `removed.`, ); } @@ -1650,8 +1638,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `neither declares nor has bound from \`stack.actions\`, so the button keeps its ` + `source-locale label.` + suggest(actionName, facts.actions.keys()), `Match the key to a defined action name, move it under the object that owns the ` + - `action, or drop it.` + - (facts.actions.size > 0 ? ` Actions on this object: ${listNames(facts.actions.keys())}.` : ''), + `action, or drop it.`, ); continue; } @@ -1682,10 +1669,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind suggest(actionName, universe.globalActions.keys()), owner ? `Move these keys under \`objects.${owner}._actions.${actionName}\`.` - : `Match the key to an object-less action's name, or drop it.` + - (universe.globalActions.size > 0 - ? ` Object-less actions: ${listNames(universe.globalActions.keys())}.` - : ''), + : `Match the key to an object-less action's name, or drop it.`, ); continue; } @@ -1709,13 +1693,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to app "${appName}", which this stack neither defines nor ` + `contributes into, so the app launcher shows the source-locale label.` + suggest(appName, universe.apps.keys()), - `Match the key to an app's \`name\`, or drop it.` + - // Every resolvable name, declared or only contributed into - // (#18442) — a remedy list that omitted the contribution targets - // would omit half the keys this rule accepts. - (universe.apps.size > 0 - ? ` Apps this stack defines or contributes into: ${listNames(universe.apps.keys())}.` - : ''), + `Match the key to an app's \`name\`, or drop it.`, ); continue; } @@ -1740,12 +1718,8 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind suggest(navId, navIds), contributedOnly ? `Match the key to a contributed item's \`id\`, or move it to the package that ` + - `owns app "${appName}".` + - (navIds.size > 0 - ? ` Contributed navigation ids: ${listNames(navIds)}.` - : ` This stack contributes no identified item into "${appName}" at all.`) - : `Match the key to the navigation item's \`id\`, or drop it.` + - (navIds.size > 0 ? ` Declared navigation ids: ${listNames(navIds)}.` : ''), + `owns app "${appName}".` + : `Match the key to the navigation item's \`id\`, or drop it.`, ); } } @@ -1761,8 +1735,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to dashboard "${dashName}", which this stack does not ` + `define, so the dashboard title stays in the source locale.` + suggest(dashName, universe.dashboards.keys()), - `Match the key to a dashboard's \`name\`, or drop it.` + - (universe.dashboards.size > 0 ? ` Defined dashboards: ${listNames(universe.dashboards.keys())}.` : ''), + `Match the key to a dashboard's \`name\`, or drop it.`, ); continue; } @@ -1775,8 +1748,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to widget "${widgetId}", which dashboard "${dashName}" ` + `does not declare, so the widget title stays in the source locale.` + suggest(widgetId, dash.widgets), - `Match the key to the widget's \`id\`, or drop it.` + - (dash.widgets.size > 0 ? ` Declared widget ids: ${listNames(dash.widgets)}.` : ''), + `Match the key to the widget's \`id\`, or drop it.`, ); } for (const actionKey of Object.keys(asRecord(rawDash.actions))) { @@ -1787,8 +1759,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to header action "${actionKey}", which dashboard ` + `"${dashName}" does not declare, so the button keeps its source-locale label.` + suggest(actionKey, dash.actions), - `Header-action translations are keyed by the action's \`actionUrl\`, not its label.` + - (dash.actions.size > 0 ? ` Declared header actions: ${listNames(dash.actions)}.` : ''), + `Header-action translations are keyed by the action's \`actionUrl\`, not its label.`, ); } } @@ -1804,8 +1775,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to flow "${flowName}", which this stack does not define, ` + `so the wizard renders its source-locale label and headings.` + suggest(flowName, universe.flows.keys()), - `Match the key to a flow's \`name\` (the machine name, not its label), or drop it.` + - (universe.flows.size > 0 ? ` Defined flows: ${listNames(universe.flows.keys())}.` : ''), + `Match the key to a flow's \`name\` (the machine name, not its label), or drop it.`, ); continue; } @@ -1838,10 +1808,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `declares no screen node for, so the wizard step keeps its source-locale heading.` + suggest(nodeId, flow.screens.keys()), `Screen translations are keyed by the node's \`id\` (the client's ` + - `\`ScreenSpec.nodeId\`), not its \`label\`.` + - (flow.screens.size > 0 - ? ` Declared screen node ids: ${listNames(flow.screens.keys())}.` - : ` Flow "${flowName}" declares no \`type: 'screen'\` node at all.`), + `\`ScreenSpec.nodeId\`), not its \`label\`.`, ); continue; } @@ -1885,10 +1852,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind suggest(fieldName, screen.fields), objectForm ? `Move this copy under \`objects.${objectForm}.fields.${fieldName}\`, or drop it.` - : `Match the key to a \`config.fields[].name\` on that screen, or drop it.` + - (screen.fields.size > 0 - ? ` Declared screen field names: ${listNames(screen.fields)}.` - : ` Screen "${nodeId}" declares no \`config.fields\` at all.`), + : `Match the key to a \`config.fields[].name\` on that screen, or drop it.`, ); } } @@ -1910,10 +1874,7 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `declare, so the refusal keeps its source-locale message.` + suggest(nodeId, flow.refusals), `Refusal translations are keyed by the \`id\` of an \`end\` node declaring ` + - `\`outcome: 'refused'\`.` + - (flow.refusals.size > 0 - ? ` Declared refusing end node ids: ${listNames(flow.refusals)}.` - : ` Flow "${flowName}" declares no refusing \`end\` node at all.`), + `\`outcome: 'refused'\`.`, ); } } @@ -2078,9 +2039,7 @@ function checkActionParams(findings: TranslationRefFinding[], ctx: ActionEntryCo `Translations are keyed to parameter "${paramName}", which ${ctx.subject} does not ` + `declare, so its label and help text render untranslated in the action dialog.` + suggest(paramName, declared), - hint: - `Match the key to a declared param \`name\`, or drop it.` + - (declared.size > 0 ? ` Declared params: ${listNames(declared)}.` : ''), + hint: `Match the key to a declared param \`name\`, or drop it.`, }); } } @@ -2259,8 +2218,7 @@ function checkActionOutcomeMessages(findings: TranslationRefFinding[], ctx: Acti `\`outcomeMessages\` at all, so nothing reads this map and this copy is never shown.`), hint: `Match the key to an outcome the action's \`outcomeMessages\` declares, declare the ` + - `outcome there first, or drop it.` + - (declared.size > 0 ? ` Declared outcomes: ${listNames(declared)}.` : ''), + `outcome there first, or drop it.`, }); } } @@ -2338,9 +2296,7 @@ function checkActionResultDialogFields(findings: TranslationRefFinding[], ctx: A : `Translations are keyed to result field "${fieldPath}", but ${ctx.subject}'s ` + `\`resultDialog\` declares no \`fields\` (it renders the whole response as JSON), ` + `so nothing reads this label.`, - hint: - `Match the key to a declared field's literal \`path\` (dots included), or drop it.` + - (declared.size > 0 ? ` Declared paths: ${listNames(declared)}.` : ''), + hint: `Match the key to a declared field's literal \`path\` (dots included), or drop it.`, }); } } diff --git a/packages/lint/src/validate-view-containers.ts b/packages/lint/src/validate-view-containers.ts index b040974dfdd..2d26eabefba 100644 --- a/packages/lint/src/validate-view-containers.ts +++ b/packages/lint/src/validate-view-containers.ts @@ -132,10 +132,10 @@ export function validateViewContainers(stack: Record): ViewCont message: 'A ViewItem record (`viewKind`) is not a view container: `views:` carries containers only, ' + 'so the stack schema, this rule and the registration loop all refuse it.', - hint: 'Wrap it in a defineView container: defineView({ list: { type, data, columns, ... }, ' - + 'listViews: { ... } }) — or author the standalone view through the metadata door ' - + '(Studio / `PUT /api/v1/meta/view`). Machine-assembled manifests carry it under ' - + '`viewItems:`.', + // [#22161] One remedy line; that machine-assembled manifests carry a + // ViewItem under `viewItems:` is the explanation's. + hint: 'Wrap it in a defineView container (defineView({ list: { type, data, columns, ... }, ' + + 'listViews: { ... } })), or author it through the metadata door (Studio / `PUT /api/v1/meta/view`)', }); continue; } From 0a66efe5596eceef74fff018791769335420a794 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:27:59 +0000 Subject: [PATCH 2/3] fix(lint): slice 16 test pins follow the one-line fix lines (WIP) Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/lint/src/rule-explanations.ts | 3 +- .../src/validate-ai-surface-affinity.test.ts | 29 ++- .../src/validate-approval-approvers.test.ts | 125 ++++++++++ .../lint/src/validate-approval-approvers.ts | 2 +- .../lint/src/validate-chart-bindings.test.ts | 58 ++++- .../src/validate-dataset-references.test.ts | 62 ++++- .../src/validate-list-view-field-refs.test.ts | 61 ++++- .../src/validate-object-field-refs.test.ts | 46 +++- .../src/validate-page-source-styling.test.ts | 45 ++++ .../lint/src/validate-record-title.test.ts | 77 ++++++- .../src/validate-seed-replay-safety.test.ts | 35 ++- .../src/validate-seed-state-machine.test.ts | 33 ++- .../validate-translation-references.test.ts | 217 ++++++++++++------ .../src/validate-translation-references.ts | 15 +- .../lint/src/validate-view-containers.test.ts | 29 ++- 15 files changed, 735 insertions(+), 102 deletions(-) diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 054e5bd37c6..30e279a4b14 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3006,7 +3006,8 @@ const TRANSLATION_TARGET_UNKNOWN_EXPLANATION: RuleExplanation = { 'line names the remedy only, without listing what is declared; what each key may name follows.', 'Under `objects.OBJECT` (an object this stack, or a sibling package of the same artifact, ' + 'defines): `fields.FIELD` a declared field (a system column, `_id`, `name` and `space` are ' + - 'never reported); `_views.VIEW` a view\'s `name`, not its label; `_sections.KEY` a ' + + 'never reported); `_views.VIEW` a view\'s `name`, not its label — the runtime keys a nameless ' + + 'default `list` `default`, never `list`; `_sections.KEY` a ' + '`fieldGroups[].key`, a named form-view section or a named `record:details` section — a ' + 'section is translatable only through a stable name; `_tabs.TAB` a filter-preset tab that a ' + 'page over the object declares in `interfaceConfig.userFilters.tabs` (a list view\'s own ' + diff --git a/packages/lint/src/validate-ai-surface-affinity.test.ts b/packages/lint/src/validate-ai-surface-affinity.test.ts index 5cd448c1e6a..fe1c40d38c1 100644 --- a/packages/lint/src/validate-ai-surface-affinity.test.ts +++ b/packages/lint/src/validate-ai-surface-affinity.test.ts @@ -13,7 +13,7 @@ import { explainRule } from './rule-explanations.js'; // verdict to one line of at most 200 characters — every firing variant this // suite exercises, not a chosen few. Run the whole file: those cases read what // the cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateAiSurfaceAffinity: typeof validateAiSurfaceAffinityUnrecorded = (...args) => { const findings = validateAiSurfaceAffinityUnrecorded(...args); fired.push(...findings); @@ -196,3 +196,30 @@ describe('[#22161] one-line verdicts — ai-skill-surface-mismatch', () => { } }); }); + +// [#22161] Slice 16: the `fix:` line names the three ways out in one line; when +// `surface: 'both'` is right is the explanation's. +describe('[#22161] one-line fix — ai-skill-surface-mismatch', () => { + it('reads as one remedy, a long skill name included, at most 200 characters', () => { + const ref = 'regional_enterprise_pipeline_forecast_reviewer'; + const [f] = validateAiSurfaceAffinity({ + agents: [{ name: 'sales_assistant', surface: 'ask', skills: [ref] }], + skills: [{ name: ref, surface: 'build' }], + }); + expect(f!.hint).toBe( + `Move "${ref}" to a 'build'-surface agent, change its \`surface\` to 'ask', or declare \`surface: 'both'\` ` + + 'for a shared, read-only skill', + ); + const converted = fired.filter((x) => x.rule === AI_SKILL_SURFACE_MISMATCH); + expect(converted.length).toBeGreaterThan(1); + for (const x of converted) { + expect(x.hint, x.rule).not.toContain('\n'); + expect(x.hint.length, `${x.rule}: ${x.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('`os explain` carries what the `fix:` line no longer says', () => { + const text = explainRule(AI_SKILL_SURFACE_MISMATCH)!.paragraphs.join('\n'); + expect(text).toContain("Declare `surface: 'both'` only for a genuinely shared, read-only capability"); + }); +}); diff --git a/packages/lint/src/validate-approval-approvers.test.ts b/packages/lint/src/validate-approval-approvers.test.ts index 0ec05484e99..7c0cc026f64 100644 --- a/packages/lint/src/validate-approval-approvers.test.ts +++ b/packages/lint/src/validate-approval-approvers.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { BUILTIN_MEMBERSHIP_ROLES } from '@objectstack/spec'; +import { APPROVER_EXPRESSION_ROOTS, ApprovalNodeConfigSchema } from '@objectstack/spec/automation'; import { validateApprovalApprovers as validateApprovalApproversUnrecorded, APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER, @@ -856,3 +857,127 @@ describe('[#22161] one-line fixes — approval-approvers-may-resolve-empty, agai expect(text).toContain("only an organization with no administrator leaves the request on an empty"); }); }); + +// [#22161] Slice 16: the `fix:` lines of the other three `approval-*` ids. Each +// names its remedy in one line; what each root holds, why `record` is unbound, +// what each empty-slate policy does and why a business role is a position are +// the explanations'. The bound reads every finding the cases above fired. +describe('[#22161] one-line fixes — the expression and membership-tier ids', () => { + const SLICE_16 = [ + APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER, + APPROVAL_EXPRESSION_INVALID, + APPROVAL_EXPRESSION_NO_EMPTY_POLICY, + ]; + const stackWithConfig = (config: Record): Record => ({ + flows: [{ + name: 'expense_approval', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'step1', type: 'approval', config }, + ], + edges: [], + }], + }); + + it('every `fix:` line of the three ids the cases above fired is one line of at most 200 characters', () => { + // A long position name, so the bound is not read on short names alone. + validateApprovalApprovers(stackWithApprovers([ + { type: 'org_membership_level', value: 'regional_vice_president_of_enterprise_sales' }, + ])); + const converted = fired.filter((f) => SLICE_16.includes(f.rule)); + // Coverage control: every arm of the three ids fired. + expect(converted.some((f) => f.rule === APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER)).toBe(true); + expect(converted.some((f) => f.rule === APPROVAL_EXPRESSION_NO_EMPTY_POLICY)).toBe(true); + const invalid = converted.filter((f) => f.rule === APPROVAL_EXPRESSION_INVALID); + for (const arm of ['empty expression', 'does not parse', 'references `record`', 'references `approvers_dynamic`', 'resolveAs has no effect']) { + expect(invalid.some((f) => f.message.includes(arm)), arm).toBe(true); + } + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('each arm reads as one remedy', () => { + const [tier] = validateApprovalApprovers(stackWithApprovers([{ type: 'org_membership_level', value: 'sales_manager' }])); + expect(tier.hint).toBe( + "If it is an org position, author { type: 'position', value: 'sales_manager' }; keep " + + "'org_membership_level' for the tiers the verdict lists", + ); + const [empty] = validateApprovalApprovers(stackWithConfig({ + approvers: [{ type: 'expression', value: '' }], + onEmptyApprovers: 'fail', + })); + expect(empty.hint).toBe('Write a CEL expression over current.*, trigger.* or vars.*, e.g. current.approvers_dynamic'); + const [record] = validateApprovalApprovers(stackWithConfig({ + approvers: [{ type: 'expression', value: 'record.owner_id' }], + onEmptyApprovers: 'fail', + })); + expect(record.hint).toBe( + 'Write current. for the live value at node entry or trigger. for the submit-time ' + + 'snapshot; vars.previous carries the pre-update row', + ); + const [nudge] = validateApprovalApprovers(stackWithConfig({ + approvers: [{ type: 'expression', value: 'vars.picked' }], + })); + expect(nudge.hint).toBe( + "Declare onEmptyApprovers on the node: 'admin_rescue', 'fail', 'auto_approve', or 'fallback' " + + 'with a fallbackApprovers list', + ); + }); + + it("the nudge's policies and the explanation's are the spec's `onEmptyApprovers` enum", () => { + // The explanation module imports nothing, so it spells the list out; this + // holds both it and the fix line to the schema. + const policies = ( + ApprovalNodeConfigSchema.shape.onEmptyApprovers.unwrap() as unknown as { options: readonly string[] } + ).options; + expect([...policies].sort()).toEqual(['admin_rescue', 'auto_approve', 'fail', 'fallback']); + const [nudge] = validateApprovalApprovers(stackWithConfig({ + approvers: [{ type: 'expression', value: 'vars.picked' }], + })); + const text = explainRule(APPROVAL_EXPRESSION_NO_EMPTY_POLICY)!.paragraphs.join('\n'); + for (const policy of policies) { + expect(nudge.hint, policy).toContain(`'${policy}'`); + expect(text, policy).toContain(`\`${policy}\``); + } + }); + + it("the explanation's expression roots are the spec's `APPROVER_EXPRESSION_ROOTS`", () => { + const text = explainRule(APPROVAL_EXPRESSION_INVALID)!.paragraphs.join('\n'); + expect(APPROVER_EXPRESSION_ROOTS.length).toBe(3); + for (const root of APPROVER_EXPRESSION_ROOTS) expect(text, root).toContain(`\`${root}.*\``); + }); + + it('`os explain` carries what the three `fix:` lines no longer say', () => { + const facts: Record = { + [APPROVAL_EXPRESSION_INVALID]: [ + "record's live state at node entry", + 'the submit-time snapshot', + 'no `{…}` template braces', + 'record at event time', + '`vars.previous` carries the pre-update row', + 'fails the node at entry', + '`resolveAs` means something only on an `expression` approver', + ], + [APPROVAL_EXPRESSION_NO_EMPTY_POLICY]: [ + "the organization's administrators", + 'its fault edge', + '`output.autoApproved = true`', + '`fallbackApprovers`', + 'degrades to `admin_rescue`', + ], + [APPROVAL_APPROVER_NOT_MEMBERSHIP_TIER]: [ + 'A business role is always a position', + '`sys_user_position`', + 'ADR-0108', + ], + }; + for (const [rule, list] of Object.entries(facts)) { + const entry = explainRule(rule); + expect(entry, `no \`os explain ${rule}\` entry`).toBeDefined(); + const text = entry!.paragraphs.join('\n'); + for (const fact of list) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/validate-approval-approvers.ts b/packages/lint/src/validate-approval-approvers.ts index 68804815028..9ea908ba601 100644 --- a/packages/lint/src/validate-approval-approvers.ts +++ b/packages/lint/src/validate-approval-approvers.ts @@ -460,7 +460,7 @@ export function validateApprovalApprovers(stack: AnyRec): ApprovalApproverFindin // and why a business role is always a position (the closed // ADR-0108 vocabulary, `sys_user_position`) is the explanation's. hint: - `If '${value}' is an org position, author { type: 'position', value: '${value}' }; keep ` + + `If it is an org position, author { type: 'position', value: '${value}' }; keep ` + `'org_membership_level' for the tiers the verdict lists`, }); } else if (type in DEPRECATED_APPROVER_TYPES) { diff --git a/packages/lint/src/validate-chart-bindings.test.ts b/packages/lint/src/validate-chart-bindings.test.ts index 4ca39c9f831..9b23c50db64 100644 --- a/packages/lint/src/validate-chart-bindings.test.ts +++ b/packages/lint/src/validate-chart-bindings.test.ts @@ -16,7 +16,7 @@ import { explainRule } from './rule-explanations.js'; // verdict of those ids to one line of at most 200 characters — every position // and surface this suite exercises, not a chosen few. Run the whole file: those // cases read what the cases above fired. -const fired: Array<{ rule: string; message: string; path: string }> = []; +const fired: Array<{ rule: string; message: string; path: string; hint: string }> = []; const validateChartBindings: typeof validateChartBindingsUnrecorded = (...args) => { const findings = validateChartBindingsUnrecorded(...args); fired.push(...findings); @@ -58,7 +58,9 @@ describe('validateChartBindings — report charts', () => { expect(findings[0].rule).toBe(CHART_MEASURE_UNKNOWN); expect(findings[0].path).toBe('reports[0].chart.yAxis'); expect(findings[0].message).toContain('estimate_hours'); - expect(findings[0].hint).toContain('est_hours'); + // [#22161] The remedy alone: the dataset's measure list is no longer + // appended, and `estimate_hours` is no near miss of a declared measure. + expect(findings[0].hint).toBe('Bind a measure that dataset "task_metrics" declares, or declare "estimate_hours" on it.'); }); // #14577 — this rule used to carry a private Levenshtein-only `suggest`, @@ -547,8 +549,11 @@ describe('validateChartBindings — binding vs presentation positions (#15575)', expect(findings[0].message).toContain('lands on nothing'); expect(findings[0].message).not.toContain('comes back empty'); // No surface here carries `suppressWarnings` (dashboard widgets only), and - // the hint says so rather than advertising a key that does not exist. - expect(findings[0].hint).toContain('no `suppressWarnings` key'); + // nothing says otherwise: [#22161] the hint names the remedy alone, and the + // explanation says no key silences the advisory. + expect(findings[0].hint).toBe('Correct the name to a measure that dataset "task_metrics" declares, or drop the entry.'); + expect(findings[0].hint).not.toContain('suppressWarnings'); + expect(explainRule(CHART_MEASURE_UNKNOWN)!.paragraphs.join('\n')).toContain('carries a `suppressWarnings` key'); }); it('one report chart reports BOTH tiers — the query position gates, the presentation position advises', () => { @@ -1160,3 +1165,48 @@ describe('[#22161] one-line verdicts — chart-measure-unknown, chart-axis-not-s expect(last(CHART_AXIS_NOT_SELECTED)).toBe(last(CHART_MEASURE_UNKNOWN)); }); }); + +// [#22161] Slice 16: the `fix:` line of chart-measure-unknown. One remedy line +// per position kind plus the nearest declared measure; the dataset's measure +// list is no longer appended, and what each presentation position carries is +// the explanation's. +describe('[#22161] one-line fix — chart-measure-unknown', () => { + it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === CHART_MEASURE_UNKNOWN); + // Coverage control: a query position and every presentation position, a + // near miss and none. + for (const marker of ['comes back empty', 'DISPLAY-NAME override', 'REPLACES the authored array', 'axis PRESENTATION']) { + expect(converted.some((f) => f.message.includes(marker)), marker).toBe(true); + } + expect(converted.some((f) => f.hint.includes('Did you mean'))).toBe(true); + expect(converted.some((f) => !f.hint.includes('Did you mean'))).toBe(true); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + expect(f.hint, f.rule).not.toContain('Dataset measures:'); + } + }); + + it('keeps the nearest declared measure after the remedy', () => { + const [f] = validateChartBindings({ + datasets: [{ name: 'sales_metrics', object: 'o', dimensions: [{ name: 'stage', field: 'stage' }], measures: [{ name: 'sum_amount', aggregate: 'sum', field: 'amount' }] }], + reports: [{ name: 'r', dataset: 'sales_metrics', values: ['sum_amount'], chart: { type: 'bar', xAxis: 'stage', yAxis: 'amount' } }], + }); + expect(f!.hint).toBe( + 'Bind a measure that dataset "sales_metrics" declares, or declare "amount" on it. Did you mean "sum_amount"?', + ); + }); + + it('`os explain` carries what the presentation `fix:` line no longer says', () => { + const text = explainRule(CHART_MEASURE_UNKNOWN)!.paragraphs.join('\n'); + for (const fact of [ + 'cannot add, remove or re-point one', + 'membership belongs to the dataset selection', + 'title, min/max, position, gridlines', + 'carries a `suppressWarnings` key', + '"Did you mean"', + ]) { + expect(text, fact).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/validate-dataset-references.test.ts b/packages/lint/src/validate-dataset-references.test.ts index 2ba9aa10bf3..79e46c58163 100644 --- a/packages/lint/src/validate-dataset-references.test.ts +++ b/packages/lint/src/validate-dataset-references.test.ts @@ -23,7 +23,7 @@ const SHORTENED_RULE_IDS: readonly string[] = [ DATASET_FIELD_NOT_INCLUDED, DATASET_FILTER_FIELD_UNKNOWN, ]; -const firedShortened: Array<{ rule: string; message: string }> = []; +const firedShortened: Array<{ rule: string; message: string; hint: 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); @@ -609,3 +609,63 @@ describe('[#22161] one-line verdicts — the rule ids this file shortened', () = for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact); }); }); + +// [#22161] Slice 16: the `fix:` lines of the include and field ids. Each is the +// remedy alone: the verdict's "Did you mean" names the nearest field, so the +// object's field list and the traversable types are no longer appended; the +// three-hop limit and what joins are the explanation's. +describe('[#22161] one-line fixes — dataset-include-unknown and dataset-field-unknown', () => { + const SLICE_16 = [DATASET_INCLUDE_UNKNOWN, DATASET_FIELD_UNKNOWN]; + + it('every `fix:` line of the two ids the cases above fired is one line of at most 200 characters', () => { + const converted = firedShortened.filter((f) => SLICE_16.includes(f.rule)); + // Coverage control: every arm fired — an include entry that resolves to an + // ordinary field, one that names nothing, one whose hop is not a + // relationship; a dimension, a measure and a joined field path. + const include = converted.filter((f) => f.rule === DATASET_INCLUDE_UNKNOWN); + expect(include.some((f) => f.message.includes('not a relationship, so no join'))).toBe(true); + expect(include.some((f) => f.message.includes('Did you mean "duty"?'))).toBe(true); + expect(include.some((f) => f.message.includes('nothing to join through'))).toBe(true); + const field = converted.filter((f) => f.rule === DATASET_FIELD_UNKNOWN); + expect(field.some((f) => f.message.startsWith('dimension field'))).toBe(true); + expect(field.some((f) => f.message.startsWith('measure field'))).toBe(true); + expect(field.some((f) => f.message.includes('object "duly_duty"'))).toBe(true); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + // The object's field list is the verdict's "Did you mean", not the fix line's. + expect(f.hint, f.rule).not.toContain('Fields on'); + } + }); + + it('each reads as one remedy', () => { + const [include] = validateDatasetReferences(stackWith({ include: ['dutee'], dimensions: [], measures: [] })); + expect(include.hint).toBe( + 'Name a relationship field on the join chain from "duly_task" (up to 3 hops), or drop the entry', + ); + const [dimension] = validateDatasetReferences( + stackWith({ dimensions: [{ name: 'period', field: 'period_kee' }], measures: [] }), + ); + expect(dimension.hint).toBe( + 'Bind the dimension to a field on "duly_task", or to a `relationship[.relationship].field` path ' + + 'whose prefix is declared in `include`.', + ); + }); + + it('`os explain` carries what the two `fix:` lines no longer say', () => { + const facts: Record = { + [DATASET_INCLUDE_UNKNOWN]: [ + 'up to three hops (ADR-0071)', + 'a lookup, master_detail, user or tree field', + 'Declaring `a.b` implicitly includes `a`', + 'never an ON clause', + '"Did you mean"', + ], + [DATASET_FIELD_UNKNOWN]: ['the hop that failed', '"Did you mean"'], + }; + for (const [rule, list] of Object.entries(facts)) { + const text = explainRule(rule)!.paragraphs.join('\n'); + for (const fact of list) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/validate-list-view-field-refs.test.ts b/packages/lint/src/validate-list-view-field-refs.test.ts index d36aa6e99d2..87cc2167fea 100644 --- a/packages/lint/src/validate-list-view-field-refs.test.ts +++ b/packages/lint/src/validate-list-view-field-refs.test.ts @@ -29,7 +29,7 @@ import { explainRule } from './rule-explanations.js'; // verdict to one line of at most 200 characters — every firing variant this // suite exercises, not a chosen few. Run the whole file: those cases read what // the cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateListViewFieldRefs: typeof validateListViewFieldRefsUnrecorded = (...args) => { const findings = validateListViewFieldRefsUnrecorded(...args); fired.push(...findings); @@ -218,7 +218,8 @@ describe('#14107 — the card\'s five measured positions', () => { expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); expect(findings[0].severity).toBe('error'); expect(findings[0].message).toContain('is not a field on object "duly_task"'); - expect(findings[0].hint).toContain('Fields on "duly_task"'); + // [#22161] The remedy alone; the verdict's "Did you mean" replaced the field list. + expect(findings[0].hint).toBe('Name a field that exists on "duly_task", or drop the entry'); }); } }); @@ -506,7 +507,8 @@ describe('#18835 — the four keys with no position row', () => { expect(findings[0].rule).toBe(LIST_VIEW_FIELD_UNKNOWN); expect(findings[0].severity).toBe(severity); expect(findings[0].message).toContain('is not a field on object "duly_task"'); - expect(findings[0].hint).toContain('Fields on "duly_task"'); + // [#22161] The remedy alone; the verdict's "Did you mean" replaced the field list. + expect(findings[0].hint).toBe('Name a field that exists on "duly_task", or drop the entry'); }); // The dark half, per key: a real field is silent, so the rows report the @@ -1112,3 +1114,56 @@ describe('[#22161] one-line verdicts — list-view-field-unknown, list-view-fiel for (const name of names) expect(text, name).toContain(`\`${name}\``); }); }); + +// [#22161] Slice 16: the `fix:` lines. The unknown id prints its remedy alone +// (the verdict's "Did you mean" names the nearest field, so the object's field +// list is no longer appended); the dotted id prints one remedy line per door, +// in the doors' own words, and what a denormalised field is moves to the +// explanation. +describe('[#22161] one-line fixes — list-view-field-unknown, list-view-field-dotted', () => { + const CONVERTED = [LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED]; + const filterOn = (field: string): AnyRec => ({ filter: [{ field, operator: 'equals', value: 'x' }] }); + const onlyHint = (patch: AnyRec) => { + const findings = validateListViewFieldRefs(stackWith(mutate(patch))); + expect(findings).toHaveLength(1); + return findings[0]!.hint; + }; + + it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => CONVERTED.includes(f.rule)); + // Coverage control: the unknown id plain and on a dotted head; the dotted + // id at the projection door and at the filter door. + const unknown = converted.filter((f) => f.rule === LIST_VIEW_FIELD_UNKNOWN); + expect(unknown.some((f) => f.message.includes('(the head of "'))).toBe(true); + expect(unknown.some((f) => !f.message.includes('(the head of "'))).toBe(true); + const dotted = converted.filter((f) => f.rule === LIST_VIEW_FIELD_DOTTED); + expect(dotted.some((f) => f.message.includes('the query projection refuses it'))).toBe(true); + expect(dotted.some((f) => f.message.includes('so no backend serves the path'))).toBe(true); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + expect(f.hint, f.rule).not.toContain('Fields on'); + } + }); + + it('reads as one remedy per arm', () => { + expect(onlyHint({ kanban: { groupByField: 'statuss' } })).toBe( + 'Name a field that exists on "duly_task", or drop the entry', + ); + expect(onlyHint({ columns: [{ field: 'owner.name' }] })).toBe( + 'Name the whole column "owner" and read into it in the client, read the related record with ' + + '`expand`, or denormalise the value onto "duly_task" and name that', + ); + expect(onlyHint(filterOn('owner.name'))).toBe( + 'Filter on a column of "duly_task" itself, or denormalise the value onto "duly_task" and name that', + ); + }); + + it('`os explain` carries what the `fix:` lines no longer say', () => { + expect(explainRule(LIST_VIEW_FIELD_UNKNOWN)!.paragraphs.join('\n')).toContain('"Did you mean"'); + const dotted = explainRule(LIST_VIEW_FIELD_DOTTED)!.paragraphs.join('\n'); + for (const fact of ['a stored field, written when the source changes', 'read the related record with `expand`', 'filter on a column of the bound object itself']) { + expect(dotted, fact).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/validate-object-field-refs.test.ts b/packages/lint/src/validate-object-field-refs.test.ts index 24067226fc8..cd386c8486b 100644 --- a/packages/lint/src/validate-object-field-refs.test.ts +++ b/packages/lint/src/validate-object-field-refs.test.ts @@ -32,7 +32,7 @@ const stackOf = (over: Record = {}) => ({ objects: [obj(over)] // line of at most 200 characters — every firing variant this suite exercises, // not a chosen few. Run the whole file: those cases read what the cases above // fired. -const fired: Array<{ rule: string; message: string; path: string }> = []; +const fired: Array<{ rule: string; message: string; path: string; hint: string }> = []; const validateObjectFieldRefs: typeof validateObjectFieldRefsUnrecorded = (...args) => { const findings = validateObjectFieldRefsUnrecorded(...args); fired.push(...findings); @@ -55,8 +55,13 @@ describe('validateObjectFieldRefs — highlightFields', () => { // resolved against — a finding that names neither is unactionable. expect(findings[0]!.message).toContain('field_10'); expect(findings[0]!.message).toContain('proj_task'); - // …and the fields that DO exist, so the fix is one read away. - expect(findings[0]!.hint).toContain('health_score'); + // [#22161] The fix line is the position's remedy alone: a near miss is the + // verdict's "Did you mean" (`field_10` has none), and the object's field + // list is no longer appended. + expect(findings[0]!.hint).toBe( + 'Fix the field name, or drop the entry. `highlightFields` is ordered — the first entry wins where only ' + + 'one field fits (ADR-0085).', + ); }); it('passes a list whose every entry names a real field', () => { @@ -400,8 +405,9 @@ describe('validateObjectFieldRefs — relatedListColumns (addresses the OWNING o }); expect(findings[0]!.message).toContain('"totl" is not a field on object "crm_invoice"'); expect(findings[0]!.message).toContain('Did you mean "total"?'); - // The prescription: the addressed object's field list. - expect(findings[0]!.hint).toContain('Fields on "crm_invoice": account, issued_on, name, region, total.'); + // The prescription alone; [#22161] the addressed object's field list is the verdict's "Did you mean". + expect(findings[0]!.hint).not.toContain('Fields on'); + expect(findings[0]!.hint).toContain('a related-list column is a field of the object that declares this relationship'); }); it('passes columns that are fields of the owning object (the child whose rows the list shows)', () => { @@ -438,7 +444,7 @@ describe('validateObjectFieldRefs — lookupColumns (addresses the REFERENCED ob }); expect(findings[0]!.message).toContain('"industy" is not a field on object "crm_account"'); expect(findings[0]!.message).toContain('Did you mean "industry"?'); - expect(findings[0]!.hint).toContain('Fields on "crm_account": industry, name, region, status.'); + expect(findings[0]!.hint).toContain('a picker column is a field of the referenced object'); }); it('REFUSES a misspelt `field` in the object arm, at `.field`', () => { @@ -509,7 +515,7 @@ describe('validateObjectFieldRefs — lookupFilters[].field (addresses the REFER path: 'objects[0].fields.account.lookupFilters[0].field', }); expect(findings[0]!.message).toContain('"statsu" is not a field on object "crm_account"'); - expect(findings[0]!.hint).toContain('Fields on "crm_account":'); + expect(findings[0]!.hint).toContain('a picker filter runs on the referenced object'); }); it('passes a filter over a field of the referenced object', () => { @@ -621,7 +627,7 @@ describe('validateObjectFieldRefs — indexes[].fields (verbatim physical column expect(findings[0]!.message).toContain('"totl" is not a field on object "crm_invoice"'); expect(findings[0]!.message).toContain('Did you mean "total"?'); expect(explained()).toContain('`unique` index is then unenforced'); - expect(findings[0]!.hint).toContain('Fields on "crm_invoice":'); + expect(findings[0]!.hint).toContain('An index column is a field of this object'); }); it('passes authored columns and the columns the platform injects', () => { @@ -758,3 +764,27 @@ describe('[#22161] one-line verdicts — object-field-ref-unknown', () => { } }); }); + +// [#22161] Slice 16: the `fix:` line is the position's prescription alone — the +// verdict's "Did you mean" names the nearest field, so the addressed object's +// field list is no longer appended. +describe('[#22161] one-line fix — object-field-ref-unknown', () => { + it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === OBJECT_FIELD_REF_UNKNOWN); + // Coverage control: every position fired (the verdict block above names + // them), so the bound reads each prescription. + for (const position of ['.highlightFields[', '.publicSharing.redactFields[', '.indexes[', '.relatedListColumns[', '.lookupColumns[', '.lookupFilters[', '.dependsOn[']) { + expect(converted.some((f) => f.path.includes(position)), position).toBe(true); + } + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + expect(f.hint, f.rule).not.toContain('Fields on'); + } + }); + + it('`os explain` says where the field list went', () => { + expect(explained()).toContain('"Did you mean"'); + expect(explained()).toContain("without listing that object's fields"); + }); +}); diff --git a/packages/lint/src/validate-page-source-styling.test.ts b/packages/lint/src/validate-page-source-styling.test.ts index a5f3bacffb2..1c45a4d75ef 100644 --- a/packages/lint/src/validate-page-source-styling.test.ts +++ b/packages/lint/src/validate-page-source-styling.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; import { validatePageSourceStyling, PAGE_SOURCE_CLASSNAME } from './validate-page-source-styling.js'; +import { explainRule } from './rule-explanations.js'; const page = (kind: string, source: string) => ({ pages: [{ name: 'p', kind, source }] }); @@ -38,3 +39,47 @@ describe('validatePageSourceStyling (ADR-0065 guardrail)', () => { expect(f.length).toBe(1); }); }); + +// [#22161] Slice 16: the `fix:` line names the replacement for `className` in +// one line per kind; why authored Tailwind produces no CSS, the theme tokens and +// the overlay advice are `os explain page-source-className-tailwind`, an entry +// this slice adds. +describe('[#22161] one-line fix — page-source-className-tailwind', () => { + const NAME = 'regional_enterprise_account_overview_dashboard_page'; + const fire = (kind: string, source: string) => + validatePageSourceStyling({ pages: [{ name: NAME, kind, source }] }); + + it('each kind reads as one remedy line of at most 200 characters', () => { + const [react] = fire('react', 'function Page(){ return
; }'); + const [html] = fire('html', ''); + const [jsx] = fire('jsx', ''); + expect(react!.hint).toBe( + "Replace className with inline style={{}} using hsl(var(--token)) theme colors, e.g. style={{ color: 'hsl(var(--foreground))' }}", + ); + expect(html!.hint).toBe( + "Replace className with the components' structured props (, ) and a JSON " + + 'style object, e.g. style={{"color":"hsl(var(--foreground))"}}', + ); + expect(jsx!.hint).toBe(html!.hint); + for (const f of [react!, html!, jsx!]) { + expect(f.hint).not.toContain('\n'); + expect(f.hint.length, f.hint).toBeLessThanOrEqual(200); + } + }); + + it('`os explain` carries what the `fix:` lines no longer say', () => { + const entry = explainRule(PAGE_SOURCE_CLASSNAME); + expect(entry, 'no `os explain page-source-className-tailwind` entry').toBeDefined(); + const text = entry!.paragraphs.join('\n'); + for (const fact of [ + 'never an authored page string', + 'ADR-0065', + "`background: 'hsl(var(--card))'`", + '`formType="drawer"`', + 'hand-rolled overlay', + 'JSON style object', + ]) { + expect(text, fact).toContain(fact); + } + }); +}); diff --git a/packages/lint/src/validate-record-title.test.ts b/packages/lint/src/validate-record-title.test.ts index c13184a79a1..97de3d503c8 100644 --- a/packages/lint/src/validate-record-title.test.ts +++ b/packages/lint/src/validate-record-title.test.ts @@ -1,9 +1,21 @@ import { describe, it, expect } from 'vitest'; +import { TITLE_ELIGIBLE_TYPES } from '@objectstack/spec/data'; import { - validateRecordTitle, + validateRecordTitle as validateRecordTitleUnrecorded, TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE, } from './validate-record-title.js'; +import { explainRule } from './rule-explanations.js'; + +// [#22161] Every call below records what it fired, and the last cases hold +// each recorded `fix:` line to one line of at most 200 characters — the pin +// covers every variant this suite exercises. Run the whole file. +const fired: Array<{ rule: string; message: string; hint: string }> = []; +const validateRecordTitle: typeof validateRecordTitleUnrecorded = (...args) => { + const findings = validateRecordTitleUnrecorded(...args); + fired.push(...findings); + return findings; +}; /** One-object stack; `fields` is a name-keyed map (the parsed-config shape). */ function objStack(obj: Record) { @@ -96,3 +108,66 @@ describe('validateRecordTitle (record-title contract, ADR-0079)', () => { expect(validateRecordTitle({ objects: [] })).toHaveLength(0); }); }); + +// [#22161] Slice 16: each `fix:` line names its remedy in one line; why +// `titleFormat` is retired, how a title resolves and the runtime's fallback are +// `os explain RULE_ID`, which both ids gain with this slice. +describe('[#22161] one-line fixes — title-format-retired and title-unresolvable', () => { + it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { + // A long object name, so the bound is not read on a short one alone. + validateRecordTitle({ + objects: [{ name: 'regional_enterprise_account_hierarchy_snapshot', titleFormat: '{{record.a}}', fields: {} }], + }); + const converted = fired.filter((f) => f.rule === TITLE_FORMAT_RETIRED || f.rule === TITLE_UNRESOLVABLE); + // Coverage control: both ids fired. + expect(new Set(converted.map((f) => f.rule))).toEqual(new Set([TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE])); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('each reads as one remedy', () => { + const findings = validateRecordTitle(objStack({ titleFormat: '{{record.amount}}', fields: { amount: { type: 'currency' } } })); + const byRule = new Map(findings.map((f) => [f.rule, f.hint])); + expect(byRule.get(TITLE_FORMAT_RETIRED)).toBe( + "Set nameField: '' for a single-field title, or add a formula field (returnType: 'text') and " + + 'designate it via nameField; then drop titleFormat', + ); + expect(byRule.get(TITLE_UNRESOLVABLE)).toBe( + "Set nameField to a text/email field (or a formula field with returnType: 'text'), or add a text " + + 'field named "name"/"title"', + ); + }); + + it('`os explain` carries what the two `fix:` lines no longer say', () => { + const facts: Record = { + [TITLE_FORMAT_RETIRED]: [ + 'render-only template', + 'neither return the title it draws nor query by it', + 'an explicit `nameField` takes precedence', + 'ADR-0079', + 'the schema still parses the key', + ], + [TITLE_UNRESOLVABLE]: [ + 'auto-provisions a primary', + '`Record #ID` floor', + 'An explicit title is far more useful', + '`objectTitleCompleteness`', + ], + }; + for (const [rule, list] of Object.entries(facts)) { + const entry = explainRule(rule); + expect(entry, `no \`os explain ${rule}\` entry`).toBeDefined(); + const text = entry!.paragraphs.join('\n'); + for (const fact of list) expect(text, `${rule} explanation names ${fact}`).toContain(fact); + } + }); + + it("the unresolvable explanation's eligible types are the spec's `TITLE_ELIGIBLE_TYPES`", () => { + // The explanation module imports nothing, so it spells the list out. + const text = explainRule(TITLE_UNRESOLVABLE)!.paragraphs.join('\n'); + expect(TITLE_ELIGIBLE_TYPES.size).toBeGreaterThan(0); + for (const type of TITLE_ELIGIBLE_TYPES) expect(text, type).toContain(`\`${type}\``); + }); +}); diff --git a/packages/lint/src/validate-seed-replay-safety.test.ts b/packages/lint/src/validate-seed-replay-safety.test.ts index 18cb886c580..6d2a9f55be5 100644 --- a/packages/lint/src/validate-seed-replay-safety.test.ts +++ b/packages/lint/src/validate-seed-replay-safety.test.ts @@ -11,7 +11,7 @@ import { explainRule } from './rule-explanations.js'; // recorded verdict to one line of at most 200 characters — every firing // variant this suite exercises, not a chosen few. Run the whole file: those // cases read what the cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateSeedReplaySafety: typeof validateSeedReplaySafetyUnrecorded = (...args) => { const findings = validateSeedReplaySafetyUnrecorded(...args); fired.push(...findings); @@ -51,11 +51,12 @@ describe('validateSeedReplaySafety (framework#3434 replay-unsafe seed gate)', () }); expect(findings[0].where).toContain('showcase_project_membership'); expect(findings[0].message).toContain('replay'); - // Hint steers to the idempotent modes and the composite externalId the fix added. + // Hint steers to the idempotent modes and the composite externalId the fix + // added; [#22161] the composite example itself is the explanation's. expect(findings[0].hint).toContain('ignore'); expect(findings[0].hint).toContain('upsert'); expect(findings[0].hint).toContain('externalId'); - expect(findings[0].hint).toContain("['team', 'project']"); + expect(findings[0].hint).toContain('a list of fields for a junction table'); }); it('flags each insert seed and reports its index in the path; leaves safe seeds alone', () => { @@ -122,3 +123,31 @@ describe('[#22161] one-line verdicts — seed-insert-mode-duplicates-on-replay', } }); }); + +// [#22161] Slice 16: the `fix:` line names the two idempotent modes and the +// match key in one line; what each mode does and the examples are the +// explanation's (pinned above, and here for what moved). +describe('[#22161] one-line fix — seed-insert-mode-duplicates-on-replay', () => { + it('reads as one remedy', () => { + const [f] = validateSeedReplaySafety({ data: [{ object: 'z', mode: 'insert', records: [] }] }); + expect(f!.hint).toBe( + "Use `mode: 'ignore'` or `'upsert'` with an `externalId` to match on (a natural-key field, or a list of " + + 'fields for a junction table)', + ); + }); + + it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === SEED_INSERT_MODE_DUPLICATES_ON_REPLAY); + expect(converted.length).toBeGreaterThan(1); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('`os explain` carries the examples the `fix:` line dropped', () => { + const text = explainRule(SEED_INSERT_MODE_DUPLICATES_ON_REPLAY)!.paragraphs.join('\n'); + expect(text).toContain("(`externalId: 'code'`)"); + expect(text).toContain("(`['team', 'project']`)"); + }); +}); diff --git a/packages/lint/src/validate-seed-state-machine.test.ts b/packages/lint/src/validate-seed-state-machine.test.ts index 38d22306fdf..3ae39869a43 100644 --- a/packages/lint/src/validate-seed-state-machine.test.ts +++ b/packages/lint/src/validate-seed-state-machine.test.ts @@ -13,7 +13,7 @@ import { explainRule } from './rule-explanations.js'; // recorded verdict to one line of at most 200 characters — every firing // variant this suite exercises, not a chosen few. Run the whole file: those // cases read what the cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateSeedStateMachine: typeof validateSeedStateMachineUnrecorded = (...args) => { const findings = validateSeedStateMachineUnrecorded(...args); fired.push(...findings); @@ -195,3 +195,34 @@ describe('[#22161] one-line verdicts — seed-value-outside-state-machine', () = } }); }); + +// [#22161] Slice 16: the `fix:` line names both ways out in one line; that the +// seed exemption allows a mid-lifecycle state but never an undeclared one is +// the explanation's. +describe('[#22161] one-line fix — seed-value-outside-state-machine', () => { + it('reads as one remedy', () => { + const stack = { + objects: [{ name: 'ticket', validations: [{ type: 'state_machine', field: 'stage', initialStates: ['open'], transitions: { open: ['closed'] } }] }], + data: [{ object: 'ticket', records: [{ name: 'r', stage: 'clsoed' }] }], + }; + const [f] = validateSeedStateMachine(stack); + expect(f!.hint).toBe( + "Correct 'clsoed' to a declared state, or, if it is a real state, add it to the state machine as an initial " + + 'state or a transition endpoint', + ); + }); + + it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === SEED_VALUE_OUTSIDE_STATE_MACHINE); + expect(converted.length).toBeGreaterThan(1); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('`os explain` carries what the `fix:` line no longer says', () => { + const text = explainRule(SEED_VALUE_OUTSIDE_STATE_MACHINE)!.paragraphs.join('\n'); + expect(text).toContain('it is not a licence to write an unknown state'); + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index b03f5136da4..14452ef505c 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -28,7 +28,7 @@ import { SnapshotContact, SnapshotContactViews } from './showcase-shape.fixtures // and the last cases in this file hold each recorded verdict to one line of at // most 200 characters — every firing variant this suite exercises, not a // chosen few. Run the whole file: those cases read what the cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateTranslationReferences: typeof validateTranslationReferencesUnrecorded = (...args) => { const findings = validateTranslationReferencesUnrecorded(...args); fired.push(...findings); @@ -116,7 +116,8 @@ describe('validateTranslationReferences — orphan keys', () => { expect(findings[0].severity).toBe('error'); expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_lead.fields.assigned_to'); - expect(findings[0].hint).toContain('Declared fields: name, source, status.'); + // [#22161] One remedy line: the verdict's "Did you mean" replaced the declared-field list. + expect(findings[0].hint).toBe('Point the key at a declared field, or drop it if the field was removed or renamed.'); }); it('accepts every key that resolves — fields, options, views, actions, sections', () => { @@ -194,7 +195,7 @@ describe('validateTranslationReferences — orphan keys', () => { ); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._actions.convert_lead.params.assignee'); - expect(findings[0].hint).toContain('Declared params: owner.'); + expect(findings[0].hint).toBe('Match the key to a declared param `name`, or drop it.'); }); }); @@ -790,9 +791,9 @@ describe('validateTranslationReferences — contributed navigation (#18203)', () path: 'translations[0]["zh-CN"].apps.crm_enterprise.navigation.nav_deleted_surface', }); expect(findings[0].message).toContain('which app "crm_enterprise" does not declare'); - // The contributed id joins the population the hint enumerates, so the - // remedy an author is handed lists what they may actually key to. - expect(findings[0].hint).toContain('nav_case'); + // The contributed id joins the population that resolves: `nav_case` drew + // no finding beside the orphan. [#22161] The fix line no longer lists it. + expect(findings[0].hint).toBe("Match the key to the navigation item's `id`, or drop it."); }); /** @@ -931,10 +932,9 @@ describe('validateTranslationReferences — objectExtensions-injected surfaces ( severity: 'error', path: 'translations[0]["zh-CN"].objects.crm_lead.fields.zzz_gone', }); - // The injected name joins the population the remedy enumerates — which is - // also the evidence the fold reached this run at all, rather than the leg - // having gone quiet. - expect(findings[0].hint).toContain('Declared fields: name, sla_tier.'); + // [#22161] The fix line no longer enumerates the population; that the + // injected name resolves is the next case's silent half. + expect(findings[0].hint).toBe('Point the key at a declared field, or drop it if the field was removed or renamed.'); }); it('separates the two directions on ONE bundle — the injected key is silent, the typo is not', () => { @@ -961,7 +961,7 @@ describe('validateTranslationReferences — objectExtensions-injected surfaces ( severity: 'error', path: 'translations[0]["zh-CN"].objects.crm_lead._validations.zzz_rule', }); - expect(findings[0].hint).toContain('Declared rules: sla_required.'); + expect(findings[0].hint).toBe("Match the key to the rule's `name`, or drop it if the rule was renamed or removed."); }); /** @@ -1206,7 +1206,9 @@ describe('validateTranslationReferences — an app a package contributes into wi path: 'translations[0]["zh-CN"].apps.crm_enterprise.navigation.nav_deleted', }); expect(findings[0].message).toContain('which this stack contributes into'); - expect(findings[0].hint).toContain('Contributed navigation ids: nav_case.'); + expect(findings[0].hint).toBe( + "Match the key to a contributed item's `id`, or move it to the package that owns app \"crm_enterprise\".", + ); }); /** ⭐ The other control: the widening is per contributed app, not per key. */ @@ -1219,7 +1221,7 @@ describe('validateTranslationReferences — an app a package contributes into wi severity: 'error', path: 'translations[0]["zh-CN"].apps.zzz_not_an_app', }); - expect(findings[0].hint).toContain('Apps this stack defines or contributes into: crm_enterprise.'); + expect(findings[0].hint).toBe("Match the key to an app's `name`, or drop it."); }); /** @@ -1236,7 +1238,10 @@ describe('validateTranslationReferences — an app a package contributes into wi const findings = validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }])); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('translations[0]["zh-CN"].apps.ops_console.navigation.nav_case'); - expect(findings[0].hint).toContain('Contributed navigation ids: nav_ops.'); + // [#22161] The fix line no longer lists the contributed ids; `ops_console`'s + // own id resolves under it. + const own = { ...body, translations: [{ 'zh-CN': { apps: { ops_console: { navigation: { nav_ops: { label: '运维' } } } } } }] }; + expect(validateTranslationReferences(asPerPackageLeg(own, [{ manifest: own }]))).toEqual([]); }); it('resolves the app even when no contributed item carries an id, and says so', () => { @@ -1248,7 +1253,11 @@ describe('validateTranslationReferences — an app a package contributes into wi const findings = validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }])); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('translations[0]["zh-CN"].apps.crm_enterprise.navigation.nav_case'); - expect(findings[0].hint).toContain('contributes no identified item into "crm_enterprise" at all.'); + // [#22161] The verdict says so; the fix line names the remedy alone. + expect(findings[0].message).toContain('which this stack contributes into without declaring'); + expect(findings[0].hint).toBe( + "Match the key to a contributed item's `id`, or move it to the package that owns app \"crm_enterprise\".", + ); }); /** @@ -1263,7 +1272,7 @@ describe('validateTranslationReferences — an app a package contributes into wi }); expect(findings).toHaveLength(1); expect(findings[0].message).toContain('which app "crm_enterprise" does not declare'); - expect(findings[0].hint).toContain('Declared navigation ids: group_service.'); + expect(findings[0].hint).toBe("Match the key to the navigation item's `id`, or drop it."); }); }); @@ -1383,10 +1392,12 @@ describe('validateTranslationReferences — an object a SIBLING package of the a severity: 'error', path: 'translations[0]["zh-CN"].objects.zzz_not_an_object', }); - // The remedy enumerates what the ARTIFACT provides, not this package's own - // objects alone — the same reading `validateObjectReferences` pins for the - // same widening, and the evidence the fold reached this run at all. - expect(findings[0].hint).toContain('Defined objects: crm_account, crm_order.'); + // [#22161] The fix line no longer enumerates what the ARTIFACT provides; + // that the fold reached this run is the sibling-key cases' silence. + expect(findings[0].hint).toBe( + 'Rename the key to the object it was written for, drop it, or ignore this if the object is ' + + 'contributed by another installed package.', + ); }); /** ⭐ The second false-negative control, one rung down: the subtree stays judged. */ @@ -1398,7 +1409,7 @@ describe('validateTranslationReferences — an object a SIBLING package of the a severity: 'error', path: 'translations[0]["zh-CN"].objects.crm_account.fields.zzz_gone', }); - expect(findings[0].hint).toContain('Declared fields: industry, name.'); + expect(findings[0].hint).toBe('Point the key at a declared field, or drop it if the field was removed or renamed.'); }); it('separates the two directions on ONE bundle — the sibling key is silent, the typo is not', () => { @@ -1583,18 +1594,18 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o severity: 'error', path: 'translations[0]["zh-CN"].dashboards.zzz_no_dashboard', }); - // The remedy enumerates what the ARTIFACT provides — the evidence the - // fold reached this run at all, and not a vacuous hint assertion. - expect(findings[0].hint).toContain('Defined dashboards: crm_overview.'); + // [#22161] The fix line no longer enumerates what the ARTIFACT provides; + // that the fold reached this run is the accepting case above. + expect(findings[0].hint).toBe("Match the key to a dashboard's `name`, or drop it."); }); it('NON-DEGENERACY — the sub-rung stays judged against the SIBLING declaration', () => { const widget = perPackageLeg({ dashboards: { crm_overview: { widgets: { zzz_gone: 'x' } } } }); expect(onlyPath(widget)).toEqual(['translations[0]["zh-CN"].dashboards.crm_overview.widgets.zzz_gone']); - expect(widget[0].hint).toContain('Declared widget ids: pipeline.'); + expect(widget[0].hint).toBe("Match the key to the widget's `id`, or drop it."); const action = perPackageLeg({ dashboards: { crm_overview: { actions: { '/zzz/gone': 'x' } } } }); expect(onlyPath(action)).toEqual(['translations[0]["zh-CN"].dashboards.crm_overview.actions./zzz/gone']); - expect(action[0].hint).toContain('Declared header actions: /orders/new.'); + expect(action[0].hint).toBe("Header-action translations are keyed by the action's `actionUrl`, not its label."); }); /** @@ -1640,20 +1651,20 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o it('NON-DEGENERACY — a flow NO package of the artifact declares still errors', () => { const findings = perPackageLeg({ flows: { zzz_no_flow: {} } }); expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].flows.zzz_no_flow']); - expect(findings[0].hint).toContain('Defined flows: lead_conversion.'); + expect(findings[0].hint).toBe("Match the key to a flow's `name` (the machine name, not its label), or drop it."); }); it('NON-DEGENERACY — both sub-rungs stay judged against the SIBLING declaration', () => { const screen = perPackageLeg({ flows: { lead_conversion: { screens: { zzz_gone: {} } } } }); expect(onlyPath(screen)).toEqual(['translations[0]["zh-CN"].flows.lead_conversion.screens.zzz_gone']); - expect(screen[0].hint).toContain('Declared screen node ids: qualify.'); + expect(screen[0].hint).toBe("Screen translations are keyed by the node's `id` (the client's `ScreenSpec.nodeId`), not its `label`."); const field = perPackageLeg({ flows: { lead_conversion: { screens: { qualify: { fields: { zzz_gone: 'x' } } } } }, }); expect(onlyPath(field)).toEqual([ 'translations[0]["zh-CN"].flows.lead_conversion.screens.qualify.fields.zzz_gone', ]); - expect(field[0].hint).toContain('Declared screen field names: amount.'); + expect(field[0].hint).toBe('Match the key to a `config.fields[].name` on that screen, or drop it.'); }); }); @@ -1677,7 +1688,7 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o it('NON-DEGENERACY — an action NO package of the artifact declares still errors', () => { const findings = perPackageLeg({ globalActions: { zzz_no_action: { label: 'x' } } }); expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].globalActions.zzz_no_action']); - expect(findings[0].hint).toContain('Object-less actions: export_all.'); + expect(findings[0].hint).toBe("Match the key to an object-less action's name, or drop it."); }); /** @@ -1742,10 +1753,12 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o it('NON-DEGENERACY — a view and a tab neither package declares still error', () => { const view = perPackageLeg({ objects: { crm_order: { _views: { zzz_gone: { label: 'x' } } } } }); expect(onlyPath(view)).toEqual(['translations[0]["zh-CN"].objects.crm_order._views.zzz_gone']); - expect(view[0].hint).toContain('Declared views: board.'); + expect(view[0].hint).toBe( + "Match the key to the view's `name` (not its label; a nameless default `list` is `default`), or drop it.", + ); const tab = perPackageLeg({ objects: { crm_order: { _tabs: { zzz_gone: { label: 'x' } } } } }); expect(onlyPath(tab)).toEqual(['translations[0]["zh-CN"].objects.crm_order._tabs.zzz_gone']); - expect(tab[0].hint).toContain('Declared tabs: mine.'); + expect(tab[0].hint).toBe("Match the key to the tab's `name` (not its label), or drop it if the preset was renamed or removed."); }); /** @@ -1825,7 +1838,7 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o it('NON-DEGENERACY — an app NO package of the artifact declares or is contributed into still errors', () => { const findings = perPackageLeg({ apps: { zzz_no_app: { navigation: { a: 'x' } } } }); expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].apps.zzz_no_app']); - expect(findings[0].hint).toContain('Apps this stack defines or contributes into: crm_app.'); + expect(findings[0].hint).toBe("Match the key to an app's `name`, or drop it."); }); /** @@ -1837,7 +1850,7 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o it('NON-DEGENERACY — a nav id nothing declares still errors, with the DECLARED-app diagnosis', () => { const findings = perPackageLeg({ apps: { crm_app: { navigation: { zzz_gone: 'x' } } } }); expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].apps.crm_app.navigation.zzz_gone']); - expect(findings[0].hint).toContain('Declared navigation ids: leads.'); + expect(findings[0].hint).toBe("Match the key to the navigation item's `id`, or drop it."); expect(findings[0].message).toContain('does not declare'); expect(findings[0].message).not.toContain('contributes into'); }); @@ -1953,20 +1966,14 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { const bundle = (flows: unknown) => ({ ...flowStack, translations: [{ 'zh-CN': { flows } }] }); - /** - * The universe the collector actually reached, read back out of the rule's - * OWN hint — the tail `listNames()` prints. - * - * This is the assertion the leg exists for. A universe collector that - * silently reaches nothing reports nothing, which is indistinguishable from - * a leg that looked and found no orphans; here the collected names come back - * through the production code path, so a collector that reached nothing - * prints an empty tail and fails. - */ - const enumeratedUniverse = (hint: string, lead: string): string[] => { - const matched = new RegExp(`${lead}: ([^.]*)\\.`).exec(hint); - return matched ? matched[1].split(', ').filter(Boolean) : []; - }; + // The universe the collector actually reached, read back through the + // production code path: the verdict's "Did you mean" can only offer a name + // the collector reached, and the all-resolve case below can only stay + // silent if it reached every name it keys. This is the assertion the leg + // exists for — a collector that silently reaches nothing reports nothing, + // indistinguishable from a leg that looked and found no orphans. [#22161] + // The fix line's declared-name list, which used to carry this read-back, is + // gone; the verdict's suggestion carries it. it('reports nothing when the flow, the screen node and the field all resolve', () => { const findings = validateTranslationReferences( @@ -1988,7 +1995,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { expect(findings).toEqual([]); }); - it('fires on an orphan at level 1 — the flow name — and enumerates a non-empty flow universe', () => { + it('fires on an orphan at level 1 — the flow name — and reads back a non-empty flow universe', () => { const findings = validateTranslationReferences( bundle({ lead_conversions: { label: 'x', screens: { details: { title: 'y' } } } }), ); @@ -1997,11 +2004,10 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversions'); expect(findings[0].message).toContain('Did you mean "lead_conversion"?'); - // ⭐ the collector reached a real flow, so the zero above is a reading. - expect(enumeratedUniverse(findings[0].hint, 'Defined flows')).toEqual(['lead_conversion']); + // ⭐ the collector reached a real flow (the suggestion above), so the zero above is a reading. }); - it('fires on an orphan at level 2 — the screen node id — and enumerates a non-empty screen universe', () => { + it('fires on an orphan at level 2 — the screen node id — and reads back a non-empty screen universe', () => { const findings = validateTranslationReferences( bundle({ lead_conversion: { label: '线索转换', screens: { detail: { title: 'y' } } } }), ); @@ -2010,10 +2016,9 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.lead_conversion.screens.detail'); expect(findings[0].message).toContain('Did you mean "details"?'); expect(findings[0].hint).toContain('ScreenSpec.nodeId'); - expect(enumeratedUniverse(findings[0].hint, 'Declared screen node ids')).toEqual(['details']); }); - it('fires on an orphan at level 3 — the screen field name — and enumerates a non-empty field universe', () => { + it('fires on an orphan at level 3 — the screen field name — and reads back a non-empty field universe', () => { const findings = validateTranslationReferences( bundle({ lead_conversion: { @@ -2027,10 +2032,6 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { 'translations[0]["zh-CN"].flows.lead_conversion.screens.details.fields.opportunity', ); expect(findings[0].message).toContain('Did you mean "opportunity_name"?'); - expect(enumeratedUniverse(findings[0].hint, 'Declared screen field names')).toEqual([ - 'close_date', - 'opportunity_name', - ]); }); it('diagnoses a key on a real node of the wrong type as such, not as a missing node', () => { @@ -2087,7 +2088,7 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { nestedBundle({ nested_screen: { fields: { confirme: { label: '已确认' } } } }), ); expect(findings).toHaveLength(1); - expect(enumeratedUniverse(findings[0].hint, 'Declared screen field names')).toEqual(['confirmed']); + expect(findings[0].message).toContain('Did you mean "confirmed"?'); }); it('redirects a field key on an object-form screen to the objects group', () => { @@ -2182,7 +2183,7 @@ describe('validateTranslationReferences — flow refusals (#22450)', () => { 'Translations are keyed to refusal "done", which flow "quote_generation" declares as an `end` node ' + "that completes, not one declaring `outcome: 'refused'`, so nothing resolves this key.", ); - expect(findings[0].hint).toContain('Declared refusing end node ids: refuse_held.'); + expect(findings[0].hint).toBe("Refusal translations are keyed by the `id` of an `end` node declaring `outcome: 'refused'`."); }); it('refuses a refusal key over an UNKNOWN node, with the nearest refusing node offered', () => { @@ -2462,7 +2463,10 @@ describe('validateTranslationReferences — the canonical view-record shape', () }); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.hot_leads'); - expect(findings[0].hint).toContain('all_leads'); + // [#22161] The fix line no longer lists the declared views (`all_leads`); the case above keys it silently. + expect(findings[0].hint).toBe( + "Match the key to the view's `name` (not its label; a nameless default `list` is `default`), or drop it.", + ); }); // ── #6038 / #5164 leg 2: the default list's key is the RUNTIME's ───────── @@ -2816,7 +2820,11 @@ describe('validateTranslationReferences — section anchors', () => { translations: [{ en: { objects: { crm_lead: { label: 'Lead', _sections: { deal_info: { label: 'Deal' } } } } } }], }); expect(findings).toHaveLength(1); - expect(findings[0].hint).toContain('declares no named section at all'); + // [#22161] The remedy says why: a section is translatable only through a stable name. + expect(findings[0].hint).toBe( + 'Sections are translatable only through a STABLE NAME: give the group/section a `key`/`name` and ' + + 'use it here, or drop the translation.', + ); }); // #5415. `collectViewRecord` walked `['listViews', 'formViews']` and the @@ -2929,7 +2937,7 @@ describe('validateTranslationReferences — filter-preset tabs (#13835)', () => expect(findings[0].severity).toBe('error'); expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); expect(findings[0].path).toBe('translations[0]["zh-CN"].objects.crm_lead._tabs.overdue'); - expect(findings[0].hint).toContain('Declared tabs: mine, urgent.'); + expect(findings[0].hint).toBe("Match the key to the tab's `name` (not its label), or drop it if the preset was renamed or removed."); }); it('accepts a `_tabs` key naming a declared preset', () => { @@ -2993,11 +3001,12 @@ describe('validateTranslationReferences — filter-preset tabs (#13835)', () => expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._tabs.urgent'); }); - it('de-duplicates one preset name authored on several pages over the same object', () => { + it('reads one preset name authored on several pages over the same object as one tab', () => { // `walkObjectTabs` de-duplicates through an index so it emits each key // once; the universe is a Set, which is the same fact with nothing to - // choose between two authorings. Pinned because a per-page fact set would - // still ACCEPT this bundle — the observable difference is the hint. + // choose between two authorings. [#22161] The de-duplication was visible + // only in the declared-tab list the fix line used to print; what stays + // observable is that both pages' presets resolve and the ghost still fires. const findings = validateTranslationReferences({ objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], pages: [ @@ -3012,17 +3021,18 @@ describe('validateTranslationReferences — filter-preset tabs (#13835)', () => interfaceConfig: { userFilters: { tabs: [{ name: 'urgent', filter: [] }, { name: 'mine', filter: [] }] } }, }, ], - translations: [{ en: { objects: { crm_lead: { _tabs: { ghost: { label: 'Ghost' } } } } } }], + translations: [{ en: { objects: { crm_lead: { _tabs: { ghost: { label: 'Ghost' }, urgent: { label: 'Urgent' }, mine: { label: 'Mine' } } } } } }], }); expect(findings).toHaveLength(1); - expect(findings[0].hint).toContain('Declared tabs: mine, urgent.'); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._tabs.ghost'); }); it('says so when the object declares no preset bar at all, and names the dead carrier', () => { // `ListViewSchema.tabs` is `ViewTabSchema`'s other carrier and has no // renderer in either repo, so a `_tabs` key written for one resolves // nowhere. The hint has to say that, or an author reads the finding as a - // bug in the rule and "fixes" it by moving the tab to a list view. + // bug in the rule and "fixes" it by moving the tab to a list view. [#22161] + // It says it as its one remedy line: declare the tab where it resolves. const findings = validateTranslationReferences({ objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], views: [ @@ -3036,8 +3046,10 @@ describe('validateTranslationReferences — filter-preset tabs (#13835)', () => }); expect(findings).toHaveLength(1); expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._tabs.urgent'); - expect(findings[0].hint).toContain('declares a filter-preset tab bar at all'); - expect(findings[0].hint).toContain("list view's own `tabs` has no renderer"); + expect(findings[0].hint).toBe( + "Declare the tab in a page's `interfaceConfig.userFilters.tabs` (a list view's own `tabs` has no " + + 'renderer), or drop the key.', + ); }); it('reads the preset bar on a source-authored page, which the component walk skips', () => { @@ -3170,10 +3182,11 @@ describe('validateTranslationReferences — the showcase contact surface (#5415) 'translations[0]["zh-CN"].objects.showcase_contact._sections.contract', 'translations[0]["zh-CN"].objects.showcase_contact._sections.who_is_this', ]); - // The hint now enumerates the anchors the object really has, instead of - // claiming it "declares no named section at all". + // The real anchors resolve (the case above); the typo is offered its + // nearest. [#22161] The fix line no longer enumerates the anchors. + expect(findings[0].message).toContain('Did you mean "contact"?'); for (const finding of findings) { - expect(finding.hint).toContain('Declared sections: contact, notes, status, work'); + expect(finding.hint).not.toContain('Declared sections'); expect(finding.hint).not.toContain('declares no named section at all'); } }, 60_000); @@ -3874,3 +3887,59 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation for (const key of FLOW_TERMINAL_MESSAGE_KEYS) expect(text, key).toContain(`\`.${key}\``); }); }); + +// [#22161] Slice 16: the `fix:` line. Each `translation-target-unknown` hint is +// one remedy line: the declared names it used to list are the verdict's "Did +// you mean" (the nearest one), and what each leg may name is the explanation's. +// The bound reads every finding the cases above fired. +describe('[#22161] one-line fixes — translation-target-unknown', () => { + /** One fragment per hint arm, each unique to its arm. */ + const HINT_ARMS = [ + 'Rename the key to the object it was written for', + 'Point the key at a declared field', + "Match the key to the view's `name`", + 'Sections are translatable only through a STABLE NAME', + "Match the key to the tab's `name`", + "Declare the tab in a page's `interfaceConfig.userFilters.tabs`", + "Match the key to the rule's `name`", + 'Match the key to a defined action name', + 'Move these keys under `objects.', + "Match the key to an object-less action's name", + "Match the key to an app's `name`", + "Match the key to a contributed item's `id`", + "Match the key to the navigation item's `id`", + "Match the key to a dashboard's `name`", + "Match the key to the widget's `id`", + 'Header-action translations are keyed by', + "Match the key to a flow's `name`", + 'on the flow, or drop the key', + "Screen translations are keyed by the node's `id`", + 'Declare `config.description` on the screen', + 'Move this copy under `objects.', + 'Match the key to a `config.fields[].name`', + 'Refusal translations are keyed by the `id`', + 'Match the key to a declared param `name`', + "Match the key to an outcome the action's `outcomeMessages` declares", + 'Move the `resultDialog` translations under the action', + "Match the key to a declared field's literal `path`", + ]; + + it('every `fix:` line the cases above fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === TRANSLATION_TARGET_UNKNOWN); + // Coverage control: every hint arm fired at least once. + for (const arm of HINT_ARMS) expect(converted.some((f) => f.hint.includes(arm)), arm).toBe(true); + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + // The declared names are the verdict's "Did you mean", never a list on the fix line. + expect(f.hint, f.rule).not.toMatch(/(Declared|Defined|Contributed) [a-z ]+: /); + } + }); + + it('`os explain` says where the declared names went', () => { + const text = explainRule(TRANSLATION_TARGET_UNKNOWN)!.paragraphs.join('\n'); + expect(text).toContain('"Did you mean"'); + expect(text).toContain('without listing what is declared'); + expect(text).toContain("a list view's own `tabs` has no renderer"); + }); +}); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 339f26436d8..b7fa5022b51 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -1564,7 +1564,10 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to view "${viewName}", which no view of object ` + `"${objectName}" declares, so the view tab keeps its source-locale label.` + suggest(viewName, facts.views), - `Match the key to the view's \`name\` (not its label), or drop it.`, + // [#22161] The one fact the declared-view list used to carry for + // the commonest stale key (`list`): a nameless default list's key. + `Match the key to the view's \`name\` (not its label; a nameless default \`list\` is ` + + `\`default\`), or drop it.`, ); } @@ -1599,8 +1602,14 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind `Translations are keyed to filter-preset tab "${tabName}", which no page over ` + `object "${objectName}" declares, so the tab keeps its source-locale label.` + suggest(tabName, facts.tabs), - `Match the key to the tab's \`name\` (not its label), or drop it if the preset was ` + - `renamed or removed.`, + // [#22161] With no preset bar over the object, the one remedy names + // the carrier that resolves — never a list view's own `tabs`, which + // has no renderer, so an author does not "fix" it by moving there. + facts.tabs.size > 0 + ? `Match the key to the tab's \`name\` (not its label), or drop it if the preset was ` + + `renamed or removed.` + : `Declare the tab in a page's \`interfaceConfig.userFilters.tabs\` (a list view's own ` + + `\`tabs\` has no renderer), or drop the key.`, ); } diff --git a/packages/lint/src/validate-view-containers.test.ts b/packages/lint/src/validate-view-containers.test.ts index eec4f5e45bb..31347ad4128 100644 --- a/packages/lint/src/validate-view-containers.test.ts +++ b/packages/lint/src/validate-view-containers.test.ts @@ -12,7 +12,7 @@ import { explainRule } from './rule-explanations.js'; // one line of at most 200 characters — every firing variant this suite // exercises, not a chosen few. Run the whole file: those cases read what the // cases above fired. -const fired: Array<{ rule: string; message: string }> = []; +const fired: Array<{ rule: string; message: string; hint: string }> = []; const validateViewContainers: typeof validateViewContainersUnrecorded = (...args) => { const findings = validateViewContainersUnrecorded(...args); fired.push(...findings); @@ -194,3 +194,30 @@ describe('[#22161] one-line verdicts — view-container-shape', () => { } }); }); + +// [#22161] Slice 16: the `fix:` lines. The ViewItem arm names its two ways out +// in one line; that machine-assembled manifests carry it under `viewItems:` is +// the explanation's. +describe('[#22161] one-line fixes — view-container-shape', () => { + it('every `fix:` line the cases in this file fired is one line of at most 200 characters', () => { + const converted = fired.filter((f) => f.rule === VIEW_CONTAINER_SHAPE); + // Coverage control: all four arms fired. + for (const arm of ['A ViewItem record', '`viewItems` is', 'Flat list-view object', 'View container defines no views']) { + expect(converted.some((f) => f.message.startsWith(arm)), arm).toBe(true); + } + for (const f of converted) { + expect(f.hint, f.rule).not.toContain('\n'); + expect(f.hint.length, `${f.rule}: ${f.hint}`).toBeLessThanOrEqual(200); + } + }); + + it('the ViewItem arm reads as one remedy', () => { + const [f] = validateViewContainers({ + views: [{ name: 'task.pipeline', object: 'task', viewKind: 'list', config: { type: 'kanban', columns: ['title'] } }], + }); + expect(f!.hint).toBe( + 'Wrap it in a defineView container (defineView({ list: { type, data, columns, ... }, listViews: { ... } })), ' + + 'or author it through the metadata door (Studio / `PUT /api/v1/meta/view`)', + ); + }); +}); From 515b093fd7ef16cb665d46d96642727d265c5f29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 04:54:30 +0000 Subject: [PATCH 3/3] chore(changeset): @objectstack/lint patch for the slice 16 one-line fix lines (WIP) Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../22161-lint-slice-16-one-line-fix.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/22161-lint-slice-16-one-line-fix.md diff --git a/.changeset/22161-lint-slice-16-one-line-fix.md b/.changeset/22161-lint-slice-16-one-line-fix.md new file mode 100644 index 00000000000..7c1c3c57de9 --- /dev/null +++ b/.changeset/22161-lint-slice-16-one-line-fix.md @@ -0,0 +1,33 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): 17 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain RULE_ID` carries what those lines used to explain + +Clause-②: no + +- **Shorter fix lines.** Each finding of these 17 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (the lengths here are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - approval nodes: `approval-approver-not-membership-tier` (313 → 168), `approval-expression-no-empty-policy` (294 → 121) and `approval-expression-invalid` (292 → 144); + - datasets: `dataset-include-unknown` (336 → 97) and `dataset-field-unknown` (206 → 139); + - record titles: `title-format-retired` (267 → 148) and `title-unresolvable` (237 → 122); + - list views: `list-view-field-unknown` (258 → 62) and `list-view-field-dotted` (233 → 158); + - translations: `translation-target-unknown` (317 → 164); + - seeds: `seed-insert-mode-duplicates-on-replay` (309 → 131) and `seed-value-outside-state-machine` (251 → 139); + - charts: `chart-measure-unknown` (285 → 115); + - object field-name lists: `object-field-ref-unknown` (284 → 186); + - stack views: `view-container-shape` (256 → 187); + - agents: `ai-skill-surface-mismatch` (235 → 171); + - source-tier pages: `page-source-className-tailwind` (231 → 167). +- **Lists are no longer echoed.** `dataset-include-unknown`, `dataset-field-unknown`, `list-view-field-unknown` and `object-field-ref-unknown` no longer append the object's whole field list, `chart-measure-unknown` no longer lists the dataset's measures, and `translation-target-unknown` no longer lists the declared fields, views, sections, tabs, rules, actions, apps, navigation ids, dashboards, widgets, header actions, flows, screens, screen fields, refusing end nodes, params, outcomes or result paths: the verdict's "Did you mean" (on `chart-measure-unknown`, the fix line's own) names the nearest one. Two facts a list used to carry stay on the line as one bounded clause: a stale `_views` key is told that a nameless default `list` is keyed `default`, and a `_tabs` key over an object with no preset bar is told to declare the tab in a page's `interfaceConfig.userFilters.tabs`, because a list view's own `tabs` has no renderer. `dataset-filter-field-unknown`, outside this change, keeps its field list. +- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: what an expression approver's three roots hold, why `record` and `previous` are unbound at an approval node, and what `resolveAs` does; what each `onEmptyApprovers` policy does; that a business role is always a position; that a dataset join is derived from the object graph, up to three hops, with `a.b` including `a`; why `titleFormat` is retired and how a record title resolves, with its fallback; what a denormalised field is; which presentation positions a chart measure lands on, and that none carries a `suppressWarnings` key; the composite `externalId` of a junction table; that the seed exemption never licenses an undeclared state; when a skill may declare `surface: 'both'`; and why a Tailwind class in page source produces no CSS, with the theme-token and overlay advice. +- **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Five ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `approval-expression-invalid`, `approval-expression-no-empty-policy`, `title-format-retired`, `title-unresolvable` and `page-source-className-tailwind`. Ten existing entries gain text. The enumerations an entry spells out are held to their sources by the rules' tests: the empty-slate policies to the spec's `onEmptyApprovers` enum, the expression roots to `APPROVER_EXPRESSION_ROOTS`, and the title-eligible types to `TITLE_ELIGIBLE_TYPES`. +- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 9 of the 17 fire; the `hint` FROM → TO, measured at the door: + - a `flow` write with an approval node: `approval-approver-not-membership-tier` (2xx `advisories[].hint`, 313 → 138), `approval-expression-invalid` (422 `issues[].hint`: an empty expression 216 → 90, a `record` root 292 → 144; a parse failure 129, a bare root 110 and the `resolveAs` advisory 102 unchanged) and `approval-expression-no-empty-policy` (2xx advisory, 294 → 121); + - a `dataset` write: `dataset-include-unknown` (422, an entry naming nothing / an ordinary field 239 / 187 → 93 / 93) and `dataset-field-unknown` (422, a dimension / a measure 184 / 182 → 132 / 130); + - an `object` write: `object-field-ref-unknown` (422, 180 → 128; the same text is the `fix` of the publish receipt's `object_field_ref_unknown` runtime probe issue), and on its `listViews`, `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, 231 → 157); + - a `view` write: `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, a projection / a filter 231 / 143 → 157 / 95); + - a `report` write: `chart-measure-unknown` (422 at the query position, 117 → 106; 2xx advisory at a presentation position, 301 → 113). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the three approval advisories and the chart presentation advisory shorten there; Studio renders no 422 issue's `hint`. No Studio, REST or MCP reader in this repository or in objectui matches on `hint` text; a tool that did should match on `rule` and `path` instead. +- **Never at the runtime gate:** `title-format-retired`, `title-unresolvable`, `translation-target-unknown`, both seed ids, `view-container-shape`, `ai-skill-surface-mismatch` and `page-source-className-tailwind` run on the CLI only. +- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses.