Skip to content

Commit 63d5db2

Browse files
fix(lint): one-line fix lines for 17 more author-time rule ids; os explain RULE_ID carries what they explained (#23097)
Part of #22161 Clause-②: no Stage 2 of the card, slice 14: the over-long `fix:` lines, continued in slices 11 to 13's shape. Each finding's `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of 17 more `packages/lint` rule ids, in nine rule files, is now one remedy line of at most 200 characters, and what the lines used to explain is each id's `RULE_EXPLANATIONS` entry behind `os explain RULE_ID`. The card stays open: 58 author-time ids still carry a hint over 200 characters (the last section). ## What changes - **One remedy line per finding.** The 17 ids, every hint the lint suite fires, before → after (count of distinct hints · length range): | rule id | file | lint suite, before → after | runtime door | |---|---|---|---| | `searchable-field-unknown` | `validate-searchable-fields.ts` | 16 · 238–448 → 10 · 47–129 | `view` / `object` write, 422 | | `searchable-field-unprovisioned` | same | 2 · 361–362 → 2 · 174–175 | `view` / `object` write, 2xx advisory | | `searchable-field-unsearchable` | same | 11 · 143–328 → 10 · 69–139 | `view` / `object` write, 422 | | `flow-filter-token-unknown` | `validate-flow-filter-tokens.ts` | 5 · 100–427 → 5 · 91–182 | `flow` write, 422 | | `action-no-placement` | `validate-action-locations.ts` | 1 · 424 → 1 · 171 | CLI only | | `mapping-target-field-unknown` | `validate-mapping-target-fields.ts` | 5 · 189–405 → 3 · 61–189 | CLI only | | `unconsumed-widget-option` | `validate-dashboard-widget-options.ts` | 1 · 396 → 1 · 135 | CLI only | | `sort-field-unprovisioned` | `validate-sortable-fields.ts` | 5 · 371–378 → 5 · 184–191 | `view` write, 2xx advisory | | `sort-field-unsortable` | same | 5 · 312–332 → 5 · 134–154 | `view` write, 422 | | `sort-field-unknown` | same | 8 · 88–301 → 8 · 47–142 | `view` write, 422 | | `visibility-predicate-unknown-function` | `validate-visibility-predicates.ts` | 7 · 360–375 → 1 · 165 | `view` write, 422 | | `visibility-bare-identifier` | same | 21 · 101–221 → 21 · 37–102 | `view` write, 422 | | `visibility-predicate-syntax` | same | 8 · 97–214 → 8 · 97–113 | `view` write, 422 | | `nav-object-ungranted` | `validate-nav-access.ts` | 3 · 362–370 → 3 · 110–118 | CLI only | | `hook-body-write-unprovisioned-anchor` | `validate-hook-body-writes.ts` | 2 · 358–365 → 2 · 171–178 | CLI only | | `hook-body-write-unknown-field` | same | 11 · 216–275 → 11 · 51–110 | CLI only | | `hook-body-source-unparseable` | same | 1 · 254 → 1 · 105 | CLI only | A count that falls is a line that stopped interpolating something: the unknown-function line no longer repeats the function's name (the verdict names it), and the searchable / mapping lines no longer list the object's fields. - **The reasoning moves, the remedy stays.** - The three search ids: why a stale or inadmissible entry is a `400 INVALID_FIELD` on list search (the `$searchFields` echo), that a view narrows the object's set and never widens it (ADR-0061), that a lookup column stores only an id, and that declaring the object's own set chooses it explicitly are the explanations'. - The three sort ids: that both runtime doors refuse a formula sort with `400 INVALID_SORT` is the explanation's; the remedy lines are unchanged in substance. - The three unprovisioned-anchor ids (`searchable-field-unprovisioned`, `sort-field-unprovisioned`, `hook-body-write-unprovisioned-anchor`): the line names the three ways out (declare the column, drop the reference, opt out of the injection); how (`columnMap`, `ownership: 'none'`, `systemFields: { audit: false }`) is one shared paragraph, `UNPROVISIONED_ANCHOR_REMEDY`, in each of the three entries. - `flow-filter-token-unknown`: the line names the value functions (as the run-time refusal does) and the placeholder alternative; the two vocabularies in full are the explanation's (already held to the grammar's constants by the rule's test). - The three visibility ids: the other-language spellings CEL does not parse, why re-spelling cannot fix an unregistered name, and which namespaces each surface binds are the explanations'. The unknown-function line still says NAME fault and still offers no suggestion (the ruling's refinement 2 and the cases pinning it are untouched). - `nav-object-ungranted`: why `requiredPermissions` / `visible` do not clear it, and when a grant from another installed package makes it moot, are the explanation's. - The hook-body ids: which write shapes are checked (`HOOK_BODY_WRITE_PATTERNS`) and that the warning never blocks a build are a new paragraph of the unknown-field entry; what a partially parsed body leaves unchecked was already the unparseable entry's. - **A list the verdict already covers is not echoed.** `searchable-field-unknown`, `sort-field-unknown` and `mapping-target-field-unknown` listed every field of the object; the verdict's "Did you mean" names the near miss. `mapping-target-field-unknown` keeps the compound fields and their parts on every line: the ruling on #20149 (item 3) is that every import-mapping refusal lists the legal parts, and the import door's own refusal does. - **Two ids gain an explanation entry**, because their reasoning had no entry to move into: `action-no-placement` ("where an action can be placed"; the location list is held to the spec's `ACTION_LOCATIONS` by the rule's test) and `unconsumed-widget-option` ("which widget options a renderer reads"). Their `rule:` line now ends with the `os explain` pointer. `unconsumed-widget-option` is `@objectstack/sdui-parser`'s diagnostic code, which `validate-dashboard-widget-options.ts` forwards unchanged, so the table's id test (`rule-explanations.test.ts`) and the CLI's (`explain-rule-id.test.ts`) count it as a lint-produced id the way they already count the functional-completeness ids: lint's test imports `UNCONSUMED_WIDGET_OPTION`, and the CLI's (which does not depend on sdui-parser) fires it from `validateDashboardWidgetOptions`. No name is added to the barrel. - **Two shared one-line forms, beside the long ones (a file-surface amendment, below).** Four of the 17 hints were not written in their rule files: `searchable-field-unprovisioned`, `sort-field-unprovisioned` and `hook-body-write-unprovisioned-anchor` print `unprovisionedAnchorHint()` (`system-fields.ts`), and `hook-body-source-unparseable` prints `PARSE_FAILURE_HINT` (`checked-parse.ts`). Each gains a short sibling, `unprovisionedAnchorFix()` and `PARSE_FAILURE_FIX`, in the pattern slice 2 set with `unprovisionedAnchorVerdict()`: the converted ids use the short form, and the long form keeps its other callers (`dashboard-filter-field-unprovisioned`, `react-chart-field-unprovisioned`, `flow-template-field-unprovisioned`, `page-field-unprovisioned`, the action-body and flow-node anchor ids; `action-body-source-unparseable`, `react-page-source-unparseable`, `startup-source-unparseable`) byte-identical until their slices converge them. Neither new name is on a published entry: `index.ts` re-exports `checked-parse.ts` by name and `system-fields.ts` not at all. - **Nothing else moves.** Every `message`, rule id, severity and `path`, and what each rule accepts or refuses, is unchanged: every hunk in the eleven source files is a `hint` expression, a comment, the two additive short forms, or an import swapped to them (`listNames` drops out of `validate-mapping-target-fields.ts` with the roster). No condition, branch, skip or dedupe moved. - `.changeset/22161-lint-slice-14-one-line-fix.md`: `@objectstack/lint` `patch`, `Clause-②: no`, with the runtime `hint` FROM → TO for every id that fires at the runtime gate. ### File surface: the claim's, plus two declared amendments The claim (`6127894761`) names the nine rule files, `rule-explanations.ts`, a text-only test rider in `packages/cli` / `packages/metadata-protocol` and the changeset. The dispatch's instruction for a producer outside that list is "fix it at the producer and say where and why". Where it went beyond the list: - `system-fields.ts` and `checked-parse.ts`: the additive short forms above. Editing the long forms instead would have changed the hints of nine ids outside this slice, two of them in files this slice may not touch (`validate-flow-template-paths.ts`, which open PR #23062 edits, and `validate-widget-bindings.ts`, left for a whole-file slice); inlining a copy in each rule file would have forked one remedy into three spellings, which is the drift both modules exist to prevent. No open PR touches either file. - Test riders: `validate-react-page-props.test.ts` (one text pin on the shared searchable core's `fix:` line, which now reads `'Fix the name, or add "email" to crm_account.fields'`), `rule-explanations.test.ts` (the forwarded id above) and `packages/cli/test/explain-rule-id.test.ts` (the same id, fired from its producer; not text-only, one statement). `packages/metadata-protocol` needed nothing. ## The runtime wire Measured at the door with `runRuntimeAuthoringRules` on the base dist and on the rebuilt one (a scratch probe, 32 writes across `view`, `object`, `flow`, `hook`, `action`, `dashboard`, `app`, `mapping` and `page`): - a `view` write: `sort-field-unknown` (422 `issues[].hint`, a name / a dotted path 114 / 261 → 47 / 135), `sort-field-unsortable` (422, 318 → 140), `sort-field-unprovisioned` (2xx `advisories[].hint`, 363 → 176), `searchable-field-unknown` (422, 281 / 430 → 48 / 129), `searchable-field-unsearchable` (422, a formula / outside the declared set / a lookup / a json column 222 / 142 / 323 / 157 → 110 / 68 / 135 / 64), `searchable-field-unprovisioned` (2xx advisory, 361 → 174), `visibility-predicate-syntax` (422, a recognised spelling / none 106 / 214 → 106 / 107), `visibility-predicate-unknown-function` (422, 371 → 165) and `visibility-bare-identifier` (422, a bare name / an unbound namespace 160 / 215 → 41 / 96); - an `object` write: `searchable-field-unknown` (422, the object's own set / a built-in list view 266 / 261 → 55 / 50), `searchable-field-unsearchable` (422, 222 → 110) and `searchable-field-unprovisioned` (2xx advisory, 361 → 174); - a `flow` write: `flow-filter-token-unknown` (422, `{TOMORROW()}` / `{ROUND(x)}` / `{Math.round(x)}` / `{TODAY() - 45 - 10}` 336 / 358 / 427 / 100 → 91 / 113 / 182 / 100); - no write reaches `action-no-placement`, `mapping-target-field-unknown`, `unconsumed-widget-option`, `nav-object-ungranted` or the three `hook-body-*` ids: the first three are registered with no `runtimeTypes`, and the other four are reference-suite members on the frozen `flow` default whose collections a flow snapshot does not carry. A sort declared on an object write's own `listViews` does not reach either (`validateSortableFields` runs for `flow` and `view` only); only its search half does. Readers of `hint`, read-only against objectui `main` at `5dcb101c` (fetched into a ref of its own, `git grep` for `.hint` reads and destructured `hint`, ref deleted after): Studio's `saveAdvisoryToast.ts` `formatFinding` renders `[rule] where — message hint` for every 2xx advisory, so the two `*-unprovisioned` toasts shorten; `DraftChangesPanel.tsx` reads `hint` only for its in-browser security-posture pass (none of these ids). No objectui source reads a 422 issue's `hint`. REST (`PUT /api/v1/meta`) forwards `issues` / `advisories` as JSON (`toIssue` copies `hint` verbatim); `packages/mcp` registers no metadata write tool, so an MCP or AI author reaches the gate through REST. **No reader in either repository matches on `hint` text.** `check:watch-hint-literal` guards something else of the same name and ran in the gate set (below). ## Census (taken first, before any edit, at the base `c5b43b606`) Method: the earlier slices' scratch preload (`NODE_OPTIONS=--import`, never committed) patching `Array.prototype.push` to record every finding-shaped object, deduped by (rule, hint) per process, with its push site, appended per new key. Lengths are the `hint` alone. - **`packages/lint` suite**: the 17 ids re-measured at the base equal the claim's lengths exactly (448, 362, 328, 427, 424, 405, 396, 378, 332, 301, 375, 221, 214, 370, 365, 275, 254). Base: 239 ids fire, 97 over 200 = 21 fenced + 3 repo-gate + 73 author-time. After, at the final head `bea1fb15f`: 240 ids fire, 82 over 200 = 21 fenced + 3 repo-gate + 58 author-time, and every hint of the 17 is at most 200 (the table). The 58 are the base's 73 less these 17, plus two ids `main` brought over 200 while this slice was open (`page-section-count-not-multi-value` 228 and `page-field-unknown` 206, `validate-page-field-bindings.ts`, #23080); `lint-flow-patterns.ts`'s two grew with #23062. - **Runtime publish gate**, the whole `@objectstack/metadata-protocol` suite (which reads `@objectstack/lint` from source), in a base worktree and at the branch: `searchable-field-unknown` 253 → 51 and `sort-field-unknown` 101 → 55 at the door; `action-no-placement` 424 → 171 fires only inside `protocol.runtime-authoring-gate.test.ts`'s comparison against `runAuthoringRules('build', …)`, the CLI tier, never at the door. - **`packages/cli` unit tier** (283 files, after): `action-no-placement` (171) and `hook-body-write-unknown-field` (87; the removed caveat was 165 characters, so 252 before, derived) fire. Not run before the edit: the slot was spent on the base `os validate` and metadata-protocol runs (see Tests). - **`os validate`** (the built CLI) on `app-crm`, `app-todo`, `app-showcase`, `app-multi-package`, exit 0 each, base worktree and branch: none of the 17 fires; each prints the same 9 / 7 / 70 / 3 `rule:` lines, and the output is byte-identical once the worktree path and the millisecond timings (`Load time`, `Validation passed (…ms)`) are normalised — those are the only differing lines. ## Tests Every heavy run went through `scripts/pm/os-verify-lock.sh` (slots `issue-22161-s14`, `issue-22161-s14-base`); its VERDICT lines are in the report. - **Each rule's own suite pins the new shape.** A new last block per test file holds every recorded `fix:` line of its converted ids to one line of at most 200 characters behind a coverage control naming each arm (both unknown arms and the four unsearchable arms of the search rule; both unknown arms, the formula and the anchor of the sort rule; the date, plain, suggestion and method-call arms of the flow filter line; the mapping line with and without compound parts and the collision; the syntax line with and without a recognised spelling, the unknown function, both bare-identifier arms on both layers; the hook unknown-field line with and without a suggestion), plus a check per id that `explainRule(id)` holds what the line stopped saying. Two files gained a recorder (`validate-action-locations.test.ts`, `validate-dashboard-widget-options.test.ts`), and four recorders now keep `hint` beside `message`. Pins that read the old hint text moved to the explanation, text-only (the searchable `400 INVALID_FIELD` pins, the sortable `400 INVALID_SORT` pin, the mapping part-list pin's wording). New pins hold an explanation's spelled-out list to its source: the action locations to `ACTION_LOCATIONS`, and the value functions on the flow filter line to `FLOW_TEMPLATE_VALUE_FUNCTIONS`. - **Lint suite** at `bea1fb15f`: Test Files 137 passed (137), Tests 6,614 passed (6,614); VERDICT command-exit 0 (6,569 at the base). - **Lint build + typecheck** at `bea1fb15f`: `pnpm turbo run build --filter=@objectstack/lint...` (4 tasks; lint's build script runs `check-dts-emitted`) and `pnpm --filter @objectstack/lint run typecheck` (`tsc --noEmit` + `check:test-typecheck` OK, 2 files / 6 errors / 2 pinned signatures held), VERDICT command-exit 0 each. The rebuilt dist carries the new text (`If the remote table carries '` and `until the source parses` 1 hit each in `index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`; `where an action can be placed` in `index` and `rule-explanations`; the old `Flow filter values resolve in two vocabularies` 0). - **The built entries' export closure is unchanged** (Clause-② check): the runtime export keys and the declaration exports of `index`, `runtime` and `rule-explanations`, read from the base dist and from the rebuilt one, are identical — 370 / 552, 6 / 11 and 2 / 3 names, 0 added and 0 removed; at the final head against current `main` (`968acc895`, which adds one name of its own) 371 / 553, 6 / 11 and 2 / 3, again 0 added and 0 removed. - **CLI unit tier** on the branch before the merge: Test Files 1 failed, 282 passed (283), Tests 1 failed, 4,269 passed — the one failure was `explain-rule-id.test.ts` counting the new `unconsumed-widget-option` entry as a CLI-produced id, fixed by the rider above; that file at `bea1fb15f`: Test Files 1 passed (1), Tests 14 passed (14), VERDICT command-exit 0. - **metadata-protocol suite**, base worktree and branch: Test Files 227 passed, 3 skipped (230), Tests 28,192 passed, 19 skipped, VERDICT command-exit 0 both. - **Ablation** (one-shot, from the committed head `bea1fb15f`, through `scripts/ablation-replace.mjs` under the lock, with a shell `trap` restoring `HEAD` by absolute path; the rule suite imports the rule source, so there is no dist leg): `nav-object-ungranted`'s pre-slice 370-character hint restored as one literal. Predicted before the run: exactly the new bound pin in `validate-nav-access.test.ts` goes red, every other case stays green. Observed: `src/validate-nav-access.test.ts` Tests 1 failed, 20 passed (21), exactly that pin, printing the old line. The mutation landed (anchor x1 → x0, blob `8a92bc4eeb61` → `098d3d76f9fe`) and was restored (blob `8a92bc4eeb61` == HEAD, `git diff HEAD` empty, `git status --porcelain` empty). Run at `51e2ab6b3`; the rule file and its test have the same blobs at the final head. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 23 changed `.ts` files of `packages/lint` (and the CLI test, 24 files at the final head): 0 errors, 0 warnings. The population is `eslint.config.mjs`'s `**/*.{ts,…}` block plus its `packages/**` blocks; the config never enables type-aware linting (no `parserOptions.project`), so no untouched file's verdict can move. A control-character scan of every changed file found no match. - **Gates:** one merge of `origin/main` through `scripts/pm/os-regen-merge.sh` (`3c7949b66` → `51e2ab6b3`, onto `0aef78881`; no conflict, and the branch edits no generated artifact; main brought `packages/spec`, `packages/cli` and `packages/metadata-protocol` changes and no `packages/lint` file). A second merge followed when #23062 (the open PR the claim named) landed: `bea1fb15f`, onto `968acc895`, no conflict; it touches `packages/lint` but none of this slice's files. `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths at the final head `bea1fb15f` (25 paths vs the merge base `968acc895`) derived 63 commands; 62 ran, each exit code captured before any pipe, all 0 (longest: `check:query-options-erasure` 305s, `check:slot-lookup` 163s, `check-comment-mask-corpus` 123s). `check:dual-build-cjs-loads` first refused (exit 3, PREREQUISITE NOT MET: six packages had no dist); after building those six with their closure under the lock (56 tasks, VERDICT command-exit 0) it ran, exit 0 (107 entry points across 66 packages). NOT MEASURED, by the dispatch's order: `check:type-check-debt`. `--ran`: `Run reconciliation — 63 derived, 62 run, 1 NOT-MEASURED, 0 UNRUN.` `origin/main` moved two commits after the second merge (`051134140`, `6662e3f59`: the REST openapi document and `AppPlugin`), touching no file of this slice; not merged again. The artifact-roster and PR-context families are CI's. ## Acceptance notes - **A long name can still lengthen a line.** The bound holds on every variant the suites, the runtime probe and the four example apps fire; the names in a hint (the object and field of the search, sort and anchor lines, the nav entry's object) are the author's. The closest measured case is `sort-field-unprovisioned` at 191 (`showcase_ext_customer` / `created_at`). A hook with several target objects joins them into the anchor line's object name, as before. - **The mapping line keeps the parts list.** An object with two compound fields prints both fields' parts, about 80 characters each; no suite, probe or example app fires that shape. The ruling on #20149 (item 3) puts the parts on every refusal, and this slice does not re-rule it. - **The field rosters now print nowhere.** A near miss gets the verdict's "Did you mean"; a far miss gets none, where it used to get every field of the object. Slices 11 to 13 made the same call for their lists. - **Two `rule:` lines gain the `os explain` pointer** (`action-no-placement`, `unconsumed-widget-option`), through the new entries. That is the printer's own rule for an id the table holds, not a printer change. - **The long forms stay for their other callers.** `unprovisionedAnchorHint()` keeps six ids outside this slice and `PARSE_FAILURE_HINT` three (of those nine, two are fenced ids, one is a repo-gate id and six are on the remaining list); each converges on the short form when its slice comes, as `unprovisionedAnchorVerdict()` did for the verdicts. ## Remaining for this card **58 author-time `packages/lint` ids still carry a hint over 200 characters** in the final lint census (at `bea1fb15f`: 82 over 200 = 58 + 21 fenced + 3 repo-gate), by file, longest first: - `lint-flow-patterns.ts` (2): `flow-double-brace-interpolation` 1413, `flow-bare-dollar-reference` 1234 - `data-model-rules.ts` (6): `unique/legacy-organization-composite` 578, `unique/unscoped-declared-index` 380, `object/missing-name-field` 327, `rollup/non-numeric-aggregand` 282, `unique/double-declaration` 279, `field/select-missing-options` 207 - `validate-widget-bindings.ts` (11): `dashboard-filter-field-unprovisioned` 561, `chart-field-unknown` 415, `dashboard-filter-field-unknown` 403, `chart-measures-missing` 369, `widget-legacy-analytics-shape` 338, `widget-measures-missing` 330, `widget-filter-field-unknown` 322, `widget-legacy-analytics-unrenderable` 320, `table-count-only` 311, `chart-dimensions-missing` 291, `dashboard-filter-field-not-included` 218 - `validate-ai-agent-authoring.ts` (2): `agent-authoring-withdrawn` 363, `default-agent-outside-roster` 237 - `validate-react-page-props.ts` (4): `react-chart-field-unprovisioned` 362, `react-prop-missing-required` 277, `react-page-source-unparseable` 254, `react-block-needs-record-context` 228 - `validate-translatable-sections.ts` (1): `translation-section-name-missing` 359 - `validate-action-body-writes.ts` (4): `action-body-write-unprovisioned-anchor` 358, `action-body-write-unknown-field` 323, `action-record-write-discarded` 309, `action-body-source-unparseable` 254 - `validate-flow-node-writes.ts` (2): `flow-node-write-unprovisioned-anchor` 358, `flow-node-write-unknown-field` 289 - `validate-managed-api-methods.ts` (1): `object/managed-api-method-unaffordable` 357 - `validate-nav-object-servability.ts` (1): `nav-object-unservable` 348 - `validate-rule-compilability.ts` (2): `validation-rule-json-schema-uncompilable` 341, `validation-rule-regex-uncompilable` 300 - `validate-dataset-references.ts` (2): `dataset-include-unknown` 336, `dataset-field-unknown` 206 - `validate-translation-references.ts` (1): `translation-target-unknown` 317 - `validate-approval-approvers.ts` (3): `approval-approver-not-membership-tier` 313, `approval-expression-no-empty-policy` 294, `approval-expression-invalid` 292 - `validate-seed-replay-safety.ts` (1): `seed-insert-mode-duplicates-on-replay` 309 - `validate-chart-bindings.ts` (1): `chart-measure-unknown` 285 - `validate-object-field-refs.ts` (1): `object-field-ref-unknown` 284 - `validate-capability-references.ts` (1): `capability-reference-unknown` 273 - `validate-record-title.ts` (2): `title-format-retired` 267, `title-unresolvable` 237 - `validate-list-view-field-refs.ts` (2): `list-view-field-unknown` 258, `list-view-field-dotted` 233 - `validate-view-containers.ts` (1): `view-container-shape` 256 - `validate-seed-state-machine.ts` (1): `seed-value-outside-state-machine` 251 - `validate-ai-surface-affinity.ts` (1): `ai-skill-surface-mismatch` 235 - `validate-page-source-styling.ts` (1): `page-source-className-tailwind` 231 - `lint-liveness-properties.ts` (1): `liveness-planned-property` 230 - `validate-page-field-bindings.ts` (2): `page-section-count-not-multi-value` 228, `page-field-unknown` 206 - `validate-list-view-mode.ts` (1): `list-view-filters-in-views-mode` 219 Not in the 58: the 26 fenced ids as PR #22448 lists them (21 fire a hint over 200), the 3 repo-gate ids (`startup-open-vocabulary-verdict`, `startup-source-unparseable`, `startup-verdict-assertive-wording`), and the `packages/cli` ids on #22841. --- _Generated by [Claude Code](https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2d21b9c commit 63d5db2

25 files changed

Lines changed: 574 additions & 118 deletions
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
- **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:
10+
- list-view search: `searchable-field-unknown` (448 → 129), `searchable-field-unprovisioned` (362 → 175) and `searchable-field-unsearchable` (328 → 139);
11+
- list-view sort: `sort-field-unprovisioned` (378 → 191), `sort-field-unsortable` (332 → 154) and `sort-field-unknown` (301 → 142);
12+
- visibility predicates: `visibility-predicate-unknown-function` (375 → 165), `visibility-bare-identifier` (221 → 102) and `visibility-predicate-syntax` (214 → 113);
13+
- flow filters: `flow-filter-token-unknown` (427 → 182);
14+
- actions: `action-no-placement` (424 → 171);
15+
- import mappings: `mapping-target-field-unknown` (405 → 189);
16+
- dashboards: `unconsumed-widget-option` (396 → 135);
17+
- app navigation: `nav-object-ungranted` (370 → 118);
18+
- hook bodies: `hook-body-write-unprovisioned-anchor` (365 → 178), `hook-body-write-unknown-field` (275 → 110) and `hook-body-source-unparseable` (254 → 105).
19+
- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a stale or inadmissible `searchableFields` entry is a `400 INVALID_FIELD` on list search, that a view narrows the object's searchable set and never widens it, and that a lookup column stores only an id; that both runtime doors refuse a formula sort with `400 INVALID_SORT`; how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); the two vocabularies a flow filter token resolves in; the other-language operator spellings CEL does not parse, why re-spelling cannot fix an unregistered function, and which namespaces each surface binds; why gating a nav entry does not clear a missing grant; which hook-body write shapes are checked; what a partially parsed body leaves unchecked.
20+
- **A list the verdict already covers is not echoed.** `searchable-field-unknown`, `sort-field-unknown` and `mapping-target-field-unknown` no longer list every field of the object: the verdict's "Did you mean" names the near miss. `mapping-target-field-unknown` still lists the object's compound fields and their parts, as every import-mapping refusal does.
21+
- **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain RULE_ID` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `action-no-placement` ("where an action can be placed") and `unconsumed-widget-option` ("which widget options a renderer reads"). Five entries gain text. The action locations the new entry spells out are held to the spec's `ACTION_LOCATIONS` by the rule's test.
22+
- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 10 of the 17 fire; the `hint` FROM → TO, measured at the door:
23+
- a `view` write: `sort-field-unknown` (422 `issues[].hint`, a name / a dotted path 114 / 261 → 47 / 135), `sort-field-unsortable` (422, 318 → 140), `sort-field-unprovisioned` (2xx `advisories[].hint`, 363 → 176), `searchable-field-unknown` (422, a name / a dotted path 281 / 430 → 48 / 129), `searchable-field-unsearchable` (422, a formula / outside the declared set / a lookup / a json column 222 / 142 / 323 / 157 → 110 / 68 / 135 / 64), `searchable-field-unprovisioned` (2xx advisory, 361 → 174), `visibility-predicate-syntax` (422, a recognised spelling / none 106 / 214 → 106 / 107), `visibility-predicate-unknown-function` (422, 371 → 165) and `visibility-bare-identifier` (422, a bare name / an unbound namespace 160 / 215 → 41 / 96);
24+
- an `object` write: `searchable-field-unknown` (422, the object's own set / a built-in list view 266 / 261 → 55 / 50), `searchable-field-unsearchable` (422, 222 → 110) and `searchable-field-unprovisioned` (2xx advisory, 361 → 174);
25+
- a `flow` write: `flow-filter-token-unknown` (422, `{TOMORROW()}` / `{ROUND(x)}` / `{Math.round(x)}` / `{TODAY() - 45 - 10}` 336 / 358 / 427 / 100 → 91 / 113 / 182 / 100).
26+
27+
Studio's save-advisory toast prints an advisory's `hint` after its message, so the two `*-unprovisioned` toasts shorten; 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.
28+
- **Never at the runtime gate:** `action-no-placement`, `mapping-target-field-unknown`, `unconsumed-widget-option`, `nav-object-ungranted` and the three `hook-body-*` ids run on the CLI only.
29+
- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses.

‎packages/cli/test/explain-rule-id.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,17 @@ function cliFindings(): Array<{ rule: string; message: string; fix?: string }> {
343343

344344
describe('the rule ids packages/cli produces — one verdict line, one fix, and an explanation (#22841)', () => {
345345
it('fires each from its producer, and they are exactly the explained ids no lint rule produces', () => {
346-
const lintIds = new Set<string>([...exportedRuleIds(), ...FUNCTIONAL_COMPLETENESS_RULES]);
346+
// `unconsumed-widget-option` is `@objectstack/sdui-parser`'s diagnostic code,
347+
// which lint's `validateDashboardWidgetOptions` forwards unchanged — a lint
348+
// rule produces it with no constant on lint's barrel, so it is fired from
349+
// that producer here (this package does not depend on sdui-parser).
350+
const forwarded = lint
351+
.validateDashboardWidgetOptions({
352+
dashboards: [{ name: 'd', widgets: [{ id: 'w', type: 'metric', dataset: 'ds', options: { unread: true } }] }],
353+
})
354+
.map((f) => f.rule);
355+
expect(forwarded).toEqual(['unconsumed-widget-option']);
356+
const lintIds = new Set<string>([...exportedRuleIds(), ...FUNCTIONAL_COMPLETENESS_RULES, ...forwarded]);
347357
const explainedElsewhere = Object.keys(RULE_EXPLANATIONS).filter((id) => !lintIds.has(id)).sort();
348358
const fired = [...new Set(cliFindings().map((f) => f.rule))].sort();
349359
expect(fired).toEqual([

‎packages/lint/src/checked-parse.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,14 @@ export const PARSE_FAILURE_HINT =
171171
'The source is parsed (never executed) so it can be checked. A source with syntax errors is only ' +
172172
'partially recovered, so the checks that follow may have skipped real problems — fix the syntax ' +
173173
'error and re-run to get a verdict that covers the whole source.';
174+
175+
/**
176+
* [#22161] The ONE-LINE form of {@link PARSE_FAILURE_HINT}, for a rule whose
177+
* `fix:` line is one remedy: the same instruction, and the rule's `os explain`
178+
* entry (`rule-explanations.ts`) carries what a partially recovered tree means
179+
* for the checks. A caller still on the long hint converges here when its
180+
* `fix:` line is shortened. Not on the root barrel: no caller outside this
181+
* package needs it.
182+
*/
183+
export const PARSE_FAILURE_FIX =
184+
'Fix the syntax error and re-run: until the source parses, the checks cover only what the parser recovered';

‎packages/lint/src/rule-explanations.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
1010
import { fileURLToPath } from 'node:url';
1111
import { describe, expect, it } from 'vitest';
1212

13+
import { UNCONSUMED_WIDGET_OPTION } from '@objectstack/sdui-parser';
1314
import { FUNCTIONAL_COMPLETENESS_RULES } from '@objectstack/spec/kernel';
1415

1516
import * as indexBarrel from './index.js';
@@ -22,9 +23,11 @@ const HERE = dirname(fileURLToPath(import.meta.url));
2223
* Every rule id constant the root barrel exports, by value, plus the ids of the
2324
* functional-completeness predicate: those rules live in `@objectstack/spec/kernel`
2425
* and `validate-functional-completeness.ts` forwards their findings unchanged.
26+
* The same holds for `@objectstack/sdui-parser`'s widget-option check, whose
27+
* code `validate-dashboard-widget-options.ts` forwards unchanged.
2528
*/
2629
function exportedRuleIds(): Set<string> {
27-
const ids = new Set<string>(FUNCTIONAL_COMPLETENESS_RULES);
30+
const ids = new Set<string>([...FUNCTIONAL_COMPLETENESS_RULES, UNCONSUMED_WIDGET_OPTION]);
2831
for (const [name, value] of Object.entries(indexBarrel)) {
2932
if (/^[A-Z][A-Z0-9_]*$/.test(name) && typeof value === 'string') ids.add(value);
3033
}

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,19 @@ const UNPROVISIONED_ANCHOR_CAUSE =
380380
'database owns the schema, so the platform registers these anchors without provisioning a ' +
381381
'column: the name resolves, but no storage stands behind it.';
382382

383+
/**
384+
* The three ways out of an unprovisioned anchor, which the one-line `fix:` of
385+
* the converted anchor ids (`unprovisionedAnchorFix()`) names without the how:
386+
* `sort-field-unprovisioned`, `searchable-field-unprovisioned` and
387+
* `hook-body-write-unprovisioned-anchor` write it out.
388+
*/
389+
const UNPROVISIONED_ANCHOR_REMEDY =
390+
'To fix it, check the remote table. If it really carries the column, declare it in the object\'s ' +
391+
'own `fields`, mapped to the remote column through the external binding\'s `columnMap`, so the name ' +
392+
'resolves to a column you vouch for. If it does not, drop the reference, or opt the object out of ' +
393+
'the injection: `ownership: \'none\'` for the ownership anchors, `systemFields: { audit: false }` ' +
394+
'for the audit family.';
395+
383396
const DASHBOARD_FILTER_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = {
384397
rule: 'dashboard-filter-field-unprovisioned',
385398
covers: 'why a filter on an unprovisioned anchor matches nothing',
@@ -894,6 +907,10 @@ const HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION: RuleExplanation = {
894907
'hook\'s input, a target another package declares — because a false positive costs an advisory ' +
895908
'rule more than a miss. The rule stays a warning: it reads the body through a parser, never by ' +
896909
'running it.',
910+
'Only the literal write shapes in `HOOK_BODY_WRITE_PATTERNS` are checked: a computed key, a spread ' +
911+
'and an aliased `ctx.input` are not, so a clean run does not prove every write lands. The warning ' +
912+
'never blocks a build. The `fix:` line suggests the nearest declared or system column when one is ' +
913+
'close.',
897914
],
898915
};
899916

@@ -906,6 +923,7 @@ const HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION: RuleExplanation = {
906923
'target objects, and a multi-target hook is reported only when the anchor is unprovisioned on ' +
907924
'every one of them: the body may branch per object, so an anchor that is real on one target is a ' +
908925
'legitimate write there. A `ctx.api.object(NAME)` write is judged against the object it names.',
926+
UNPROVISIONED_ANCHOR_REMEDY,
909927
],
910928
};
911929

@@ -2015,6 +2033,7 @@ const SORT_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = {
20152033
'of the same name is one the author vouches for, and a dotted sort name is refused by the ' +
20162034
'ingress gate on its own. The rule is a warning because it cannot see the remote table, only ' +
20172035
'that the platform provisions no storage.',
2036+
UNPROVISIONED_ANCHOR_REMEDY,
20182037
],
20192038
};
20202039

@@ -2046,9 +2065,13 @@ const SEARCHABLE_FIELD_UNSEARCHABLE_EXPLANATION: RuleExplanation = {
20462065
'them, otherwise the AUTO-DEFAULT set — its text-like columns (text / email / phone / url / ' +
20472066
'autonumber / textarea / markdown / select), never a system/audit column or a hidden field. A ' +
20482067
'`lookup` or `master_detail` column stores only the referenced record\'s id, so it is never a ' +
2049-
'keyword target. The verdict quotes at most three names of a declared set.',
2068+
'keyword target: to search by the related record\'s title, mirror it onto a stored text field ' +
2069+
'and declare that. Declaring `searchableFields` on the object chooses its set explicitly, and a ' +
2070+
'view then narrows that set, never widens it (ADR-0061). The verdict quotes at most three names ' +
2071+
'of a declared set.',
20502072
VIRTUAL_FIELD_STORAGE,
2051-
'A formula entry is refused on EVERY surface, the object\'s own set included: measured, it ' +
2073+
'A formula entry is refused on EVERY surface, the object\'s own set included — the REST ingress ' +
2074+
'answers `400 INVALID_FIELD`, the same answer a stale entry gets: measured, it ' +
20522075
'matched 0 rows on driver-memory, and 0 rows with no error on driver-sql. Otherwise the ' +
20532076
'object\'s own `searchableFields` is existence-only: its declared branch scans any stored ' +
20542077
'column, so a json or lookup column declared there is a narrow choice the engine executes, not ' +
@@ -2071,6 +2094,7 @@ const SEARCHABLE_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = {
20712094
'Only a name that resolved BECAUSE it is injected is judged: an author-declared column of the ' +
20722095
'same name is one the author vouches for. The rule is a warning because it cannot see the ' +
20732096
'remote table, only that the platform provisions no storage.',
2097+
UNPROVISIONED_ANCHOR_REMEDY,
20742098
],
20752099
};
20762100

@@ -3602,6 +3626,57 @@ const SEMANTIC_ROLE_FIELD_UNPROVISIONED_EXPLANATION: RuleExplanation = {
36023626
],
36033627
};
36043628

3629+
// ── Action placement (`validate-action-locations.ts`) ───────────────────────
3630+
3631+
const ACTION_NO_PLACEMENT_EXPLANATION: RuleExplanation = {
3632+
rule: 'action-no-placement',
3633+
covers: 'where an action can be placed',
3634+
paragraphs: [
3635+
'An action renders at a location only if it DECLARES that location: every console renderer reads ' +
3636+
'the one predicate. An action that omits `locations`, and that no view places by name, parses, ' +
3637+
'publishes and is listed in Setup, and no user can ever click it — the ADR-0078 shape of a ' +
3638+
'declaration that renders nothing.',
3639+
// Held equal to `ACTION_LOCATIONS` (`@objectstack/spec/ui`) by the rule's test.
3640+
'The locations an action can declare: `list_toolbar`, `list_item`, `record_header`, `record_more`, ' +
3641+
'`record_related`, `record_section`. A list view places an action by NAME instead: naming it in ' +
3642+
'`bulkActions` or `bulkActionDefs` puts it on the selection bar, which the view drives, never ' +
3643+
'`locations` (an action that only makes sense over a selection has no single-record location). ' +
3644+
'A name in `rowActions` counts as placement too.',
3645+
'`locations: []` is the documented headless shape and is never reported: the action stays callable ' +
3646+
'over REST, MCP and AI and keeps its capability gate, params contract and audit trail while ' +
3647+
'claiming no UI surface. ADR-0110 D3 refuses an undeclared handler, so a headless declaration is ' +
3648+
'the legal way to expose such an action; only a MISSING key is unstated placement.',
3649+
'A warning, not an error: a view in another installed package can place the action by name. The ' +
3650+
'rule asks only whether anyone placed the action; whether a declared location is one a renderer ' +
3651+
'serves, and whether a name resolves (`action-name-undefined`), are other rules\' questions.',
3652+
],
3653+
};
3654+
3655+
// ── Dashboard widget options (`validate-dashboard-widget-options.ts`) ───────
3656+
// The id is `@objectstack/sdui-parser`'s diagnostic code: the rule forwards that
3657+
// package's check unchanged, and the SDUI save gate emits the same code.
3658+
3659+
const UNCONSUMED_WIDGET_OPTION_EXPLANATION: RuleExplanation = {
3660+
rule: 'unconsumed-widget-option',
3661+
covers: 'which widget options a renderer reads',
3662+
paragraphs: [
3663+
'A widget\'s `options` bag is declared open (the spec\'s `DashboardWidgetOptionsSchema` passes ' +
3664+
'extra keys through), so any key parses, validates and publishes, including one no renderer ' +
3665+
'reads. Every spec-legal widget renders through the dataset-bound path, whose read set is the ' +
3666+
'warning\'s list, so a key outside it styles nothing and the widget renders as if it were absent. ' +
3667+
'Open extras stay open; they stop being silent. The rule is a warning, at this door as at the ' +
3668+
'SDUI save gate, which runs the same check under the same code.',
3669+
'Presentation has declared homes instead: a number\'s face is the dataset measure\'s `format` and ' +
3670+
'`currency`, a tile\'s accent is the widget\'s `colorVariant`, and a chart\'s look is the ' +
3671+
'widget\'s `chartConfig`. `format` and `thresholds` in `options` are the keys most often ' +
3672+
'believed to work; both draw this warning.',
3673+
'Not reported: a widget with no `dataset` (a legacy inline form), one in the legacy `component` ' +
3674+
'format, an expression-valued `options`, and a widget carrying ' +
3675+
'`suppressWarnings: [\'unconsumed-widget-option\']`, which is how a key with a genuine consumer ' +
3676+
'outside the renderer says so in metadata.',
3677+
],
3678+
};
3679+
36053680
// ── Rule ids `@objectstack/cli` produces ────────────────────────────────────
36063681
// The CLI's own checks, which `os explain` reads from this one table. The CLI
36073682
// depends on this package, so their id constants cannot be imported here:
@@ -3922,6 +3997,8 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
39223997
[SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION.rule]: SEED_INSERT_MODE_DUPLICATES_ON_REPLAY_EXPLANATION,
39233998
[SEED_VALUE_OUTSIDE_STATE_MACHINE_EXPLANATION.rule]: SEED_VALUE_OUTSIDE_STATE_MACHINE_EXPLANATION,
39243999
[SEMANTIC_ROLE_FIELD_UNPROVISIONED_EXPLANATION.rule]: SEMANTIC_ROLE_FIELD_UNPROVISIONED_EXPLANATION,
4000+
[ACTION_NO_PLACEMENT_EXPLANATION.rule]: ACTION_NO_PLACEMENT_EXPLANATION,
4001+
[UNCONSUMED_WIDGET_OPTION_EXPLANATION.rule]: UNCONSUMED_WIDGET_OPTION_EXPLANATION,
39254002
[HOOK_BODY_NOT_LOWERABLE_EXPLANATION.rule]: HOOK_BODY_NOT_LOWERABLE_EXPLANATION,
39264003
[HOOK_BODY_BUNDLED_FALLBACK_EXPLANATION.rule]: HOOK_BODY_BUNDLED_FALLBACK_EXPLANATION,
39274004
[HOOK_BODY_UNPARSEABLE_EXPLANATION.rule]: HOOK_BODY_UNPARSEABLE_EXPLANATION,

‎packages/lint/src/system-fields.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,3 +235,21 @@ export function unprovisionedAnchorHint(objectName: string, field: string): stri
235235
`the audit family).`
236236
);
237237
}
238+
239+
/**
240+
* [#22161] The ONE-LINE form of {@link unprovisionedAnchorHint}, for a rule
241+
* whose `fix:` line is one remedy: the same three ways out — vouch for the
242+
* remote column by declaring it, drop the reference, opt the object out of the
243+
* injection — in one clause each, and the rule's `os explain` entry
244+
* (`rule-explanations.ts`) carries how (`columnMap`, `ownership: 'none'`,
245+
* `systemFields: { audit: false }`). The pairing of
246+
* {@link unprovisionedAnchorVerdict} with the long cause, repeated for the fix:
247+
* a rule still on the long hint converges here when its `fix:` line is
248+
* shortened, so the converted rules cannot drift apart.
249+
*/
250+
export function unprovisionedAnchorFix(objectName: string, field: string): string {
251+
return (
252+
`If the remote table carries '${field}', declare it in ${objectName}'s own fields (mapped ` +
253+
`through columnMap); otherwise drop the reference or opt the object out of the injection`
254+
);
255+
}

0 commit comments

Comments
 (0)