Skip to content

Commit 762db99

Browse files
fix(lint): one-line verdicts for the sort, search, form-predicate, bulk-dispatch, component-type and preset-comparand rules; os explain RULE_ID carries their reasoning (#22717)
Part of #22161 Clause-②: no Stage 2 of the card, slice 6: the 12 rule ids of six whole `packages/lint` source files — the list-view sort and search rules, the metadata-form predicate-path rule, the bulk-dispatch contract rule, the component-type rule and the preset-comparand rule. The card stays open for the later slices listed under "Remaining for later slices" below. ## What changes - **One verdict line per finding.** Each finding of the 12 ids prints a `message` of one verdict sentence. Every finding the rules' own suites fire is now 193 characters or fewer, and the runtime publish gate's suites (the whole `@objectstack/metadata-protocol` suite) 137 or fewer; the longest of each id was 287 to 927 before. - **The verdict no longer repeats what `where` names:** the view that wires an action (`action-dispatch-contract-mismatch`'s `where` is `view "task" · list · bulkActions`). This follows slice 4's accepted design call. - **Two shared clauses, one wording each:** - the unprovisioned-anchor ids (`sort-field-unprovisioned`, `searchable-field-unprovisioned`) print the same one-clause cause the converted anchor rules already print, `unprovisionedAnchorVerdict()` from `system-fields.ts` (slice 2's short form); - the two virtual-entry ids (`sort-field-unsortable`, the virtual arm of `searchable-field-unsearchable`) state the storage fact through one new clause, `virtualFieldClause()`, exported from `validate-searchable-fields.ts` (not on the barrel; the sort file already imports from it). - **A quoted text is quoted to its verdict, never rewritten:** - a retired component type (`user:profile`, `element:filter`, `element:form`, `ai:chat_window`) is quoted to the HEAD of its `RETIRED_PAGE_COMPONENT_TYPES` prescription — the clause before its first dash, else its first sentence (`retiredTypeHead()`, exported, not on the barrel) — and the verdict says the parse refuses the node by name and that `os validate` prints the whole prescription. The rule still authors no second copy: the test holds that the head is a prefix of the map's text, where it used to hold byte equality. - `filter-preset-comparand` opens with the first sentence of the refusal the schema door shares (`bareDateRangePresetComparandMessage`, cut at its first period: `"last_30_days" is a dashboard date-range PRESET name, not a filter value`), then the operator and the preset's `{date-macro}` window (`presetComparandVerdict()`, exported, not on the barrel). The window is read from `DATE_RANGE_PRESET_MACRO_WINDOWS` in the refusal's own spelling, and a test holds the two equal for all 13 presets; another holds every preset under the longest operator spelling (`greater_than_or_equal`) to the 200 bound. - `searchable-field-unsearchable` quotes at most three names of an object's declared set, then `(and N more)` (`declaredRoster()`); the `fix:` line still names `OBJECT.searchableFields`. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 12 new entries, so `os explain RULE_ID` prints it and the CLI's `rule:` line ends with the pointer for these ids. No CLI source changes: `explainPointer()` and `os explain` resolve any key the table holds (`packages/cli/test/explain-rule-id.test.ts` iterates every key; run below); `node bin/run-dev.js explain sort-field-unknown` prints the entry. The 75 entries already in the table are byte-equal at the head; the one existing constant this slice reuses, `UNPROVISIONED_ANCHOR_CAUSE`, changes only its `//` doc comment (it now names the two new ids it serves). - **Nothing else moves.** Rule ids, severities, `path`, `hint` (the `fix:` line) and what each rule accepts or refuses are unchanged. No condition, branch, dedupe key or skip moved; every hunk in the six rule files is a `message` expression, a comment, an import, a verdict helper, or the removal of a value that only fed the old message. The helper and plumbing hunks, each message-only: - `validate-action-dispatch-contract.ts`: `check()` loses its `viewLabel` parameter, which fed only the message (the two call sites drop the argument; `where` still carries the label); `CONTRACT_PROSE` (record of strings) becomes `DELIVERS` (record of strings) and `MISFIRE`'s two strings are shortened. `collectDeclaredContracts`, the `declared === wired` early return and both skips are untouched. - `validate-searchable-fields.ts`: the auto-default arm's `why` for a system/audit column loses its trailing clause (`'a system/audit column, which the auto-default set never includes'` → `'a system/audit column'`); its three-way choice and the `continue` on an unreadable type are unchanged. The `SEARCHABLE_TEXTUAL_TYPES` / `SEARCHABLE_ENUM_TYPES` imports, which fed only the old message, go (the explanation writes the list out; the rule's test holds it equal to the two constants). - `validate-sortable-fields.ts` and `validate-searchable-fields.ts`: `unprovisionedAnchorCause` → `unprovisionedAnchorVerdict` (string → string). - `validate-predicate-path-refs.ts`: a `FAILS_OPEN` constant, the consequence clause both resolution verdicts end with. - No helper changed shape (no string → boolean); no truth table is involved. - `.changeset/22161-lint-slice-6-one-line.md`: `@objectstack/lint` `patch`, naming every door that prints the new text (below). `Clause-②: no` follows the contract review on slice 1 (its section ②): `RULE_EXPLANATIONS` entries are data in an existing export. - **One docs transcript follows the text:** `content/docs/data-modeling/schema-design.mdx` quoted `searchable-field-unknown`'s old message as what `os validate` prints for a dotted entry; its two message lines now read the new verdict (the quoted `hint:` is unchanged). This file is outside the claim's file surface; see "Acceptance notes". - **Rider:** one text-only assertion in `packages/lint/src/validate-react-page-props.test.ts` (the react page's ListView `searchableFields` prop runs the shared searchable core); no `packages/cli/test` or `packages/metadata-protocol` file asserts these ids' message text (the consumers pin rule ids and `path`), so neither is touched. The verdict forms, the longest of each id as the suites fired them (census below): ```text list-view sort orders by "nope", which is not a field on object "showcase_ext_customer", so every load of this view answers 400 INVALID_SORT. Did you mean "name"? list-view sort orders by "expected_revenue" on object "crm_opportunity", a virtual 'formula' field: its value is computed on read and never stored, so there is no column to ORDER BY list-view sort: 'organization_id' is an injected column with no storage on external object 'showcase_ext_customer', so its ORDER BY is dropped and every fetch returns rows in an arbitrary order list-view searchableFields entry "email" is not a field on object "crm_account", so the engine drops it from the search. Did you mean "billing_email"? list-view searchableFields entry "account_id" is of type 'lookup', which 'search' cannot scan, so the auto-default search set excludes it and list search answers 400 INVALID_FIELD list-view searchableFields entry "status" is outside the declared searchableFields (subject, case_number, description), so list search answers 400 INVALID_FIELD searchableFields: 'owner_id' is an injected column with no storage on external object 'ext_customer', so 'search' never matches it and the searchable set is narrower than it declares predicate references `data.__no_such_key__`, but `__no_such_key__` is not a key of `data`, so the predicate never evaluates and the console fails open: the element always renders predicate names `type` bare, but it is a key of the edited schema with its `data.` root dropped, so the predicate never evaluates and the console fails open: the element always renders predicate compares against `record.active` on the RIGHT of `==`, which this form reads as the literal string "record.active", so the result never depends on that path's value predicate compares against the unquoted word `active` on the RIGHT of `==`, which reads as a literal today but is outside the declared subset and breaks under the real CEL evaluator action "recalc_selection" declares execution: 'aggregate', wired as execution: 'perRecord' (one call per row: `recordId`, no `_selectedIds`), so its body takes its single-record branch `record:approval_decison` is not a declared type of the reserved `record:` namespace, so the page draws a placeholder in its place. Did you mean 'record:approval_decision'? `element:filter` was removed in @objectstack/spec 17 (ADR-0049), so the parse refuses this node by name (`os validate` prints the prescription) "last_quarter" is a dashboard date-range PRESET name, not a filter value as a bare "nin" comparand; its window is { $between: ['{last_quarter_start}', '{last_quarter_end}'] } ``` ## Shared prose: written once (the dispatch's mechanism assumption 2) | shared sentence | ids | verdict clause (rule files) | explanation (`rule-explanations.ts`) | |---|---|---|---| | what an unprovisioned anchor is | `sort-field-unprovisioned`, `searchable-field-unprovisioned` (and slices 2 and 5's two anchor ids) | `unprovisionedAnchorVerdict()` (`system-fields.ts`, existing) | `UNPROVISIONED_ANCHOR_CAUSE` (existing, text byte-equal) | | a formula field has no stored column | `sort-field-unsortable`, `searchable-field-unsearchable` (virtual arm) | `virtualFieldClause()` (new) | `VIRTUAL_FIELD_STORAGE` | | what the list-view field rules do not judge | the three sort ids, `searchable-field-unknown` | — | `LIST_VIEW_FIELD_SKIPS` | | a list view's narrowing becomes the `$searchFields` override | `searchable-field-unknown`, `searchable-field-unsearchable` (`searchable-field-unprovisioned` states its own consequence) | — | `SEARCH_FIELDS_ECHO` | | a dead predicate fails open | `predicate-path-unresolved`, `predicate-path-unrooted` | `FAILS_OPEN` | `PREDICATE_FAIL_OPEN` | | which form predicates are judged, against which schema | `predicate-path-unresolved`, `predicate-path-unrooted` | — | `PREDICATE_FORM_SCOPE` | | a preset name's refusal | `filter-preset-comparand` and the schema door in `@objectstack/spec` | `presetComparandVerdict()` cuts the shared refusal's first sentence | — (the explanation states the rest once) | | a retired type's prescription | `component-type-unknown` and the parse door | `retiredTypeHead()` cuts the map's entry | — (the explanation names where the whole prescription prints, and restates none; a test holds that) | The explanation module imports nothing, so the facts it writes out are held to their sources by the rules' tests: the auto-default type list to `SEARCHABLE_TEXTUAL_TYPES` + `SEARCHABLE_ENUM_TYPES`, the virtual-type claim (`formula` alone) to `SEARCH_VIRTUAL_TYPES`. ## Census (taken first, before any edit, at the base `61bea24207`) Method: slice 4's scratch preload (`NODE_OPTIONS=--import`, never committed), which patches `Array.prototype.push` to record every finding-shaped object (`rule` + `message`) of the 12 ids, deduped by (rule, message), with its push site, in every vitest worker. Lengths are `message` alone; the printed line adds `where` and `: `. Rows from `os lint`'s own printer (`commands/lint.ts`) are excluded. - **`packages/lint` suite** at the base: 135 files, 6,264 passed (the CLI closure build put the `dist/` its dist-reading tests wait for in place). **All 12 ids fired, every one over 200**, from 25 push sites; the longest of each matches the claim's figures exactly. - **`packages/cli` unit tier**, the whole project (279 files) against the base's lint dist: 277 files and 4,097 tests passed; the two that did not load (`published-subpath-console.pin`, `published-subpath-hook-body.pin`) need `packages/cli/dist`, which was not built then (they refuse before running, naming the build). **It fires none of the 12 ids.** Positive control, same preload: `field-no-consumers` added to the recorded set is recorded (64 characters). The integration tier is declared to CI: no file in it names these ids, and its fixture shapes (`visibleWhen` on object fields, a `record.*` scope) are not ones these rules judge. - **Runtime publish gate:** the whole `@objectstack/metadata-protocol` suite (224 files, 221 run, 28,047 passed), which reads `@objectstack/lint` from its built `dist/`. The before run read a lint `dist/` built from the base, on a clean tree (the closure build above, before the first edit); the after run read the dist rebuilt at the slice's commit (marker: `so the engine drops it from the search` 1 hit each in `dist/index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`; the old `The declaration is stale: searching it can never match` 0). It fires two ids, from a flattened list overlay's `sort` (`runtime-authoring-gate.public-form-intake.test.ts`) and an object's `searchableFields` (`protocol-publish-drafts-object-field-refs.test.ts`); both suites pin the rule id and `path`, never the text. - Not measured: the example apps (outside the dispatch's list; slice 5 measured them for its own ids). | rule id | lint suite before: messages · longest | lint suite after: messages · range | runtime-gate suite before → after | |---|---|---|---| | `sort-field-unknown` | 8 · 287 | 8 · 129–162 | 262 → 137 | | `sort-field-unsortable` | 6 · 369 | 6 · 161–181 | — | | `sort-field-unprovisioned` | 6 · 844 | 6 · 183–193 | — | | `searchable-field-unknown` | 14 · 295 | 14 · 108–150 | 256 → 111 | | `searchable-field-unsearchable` | 12 · 449 | 12 · 137–179 | — | | `searchable-field-unprovisioned` | 3 · 512 | 3 · 171–182 | — | | `predicate-path-unresolved` | 6 · 371 | 6 · 156–178 | — | | `predicate-path-unrooted` | 1 · 434 | 1 · 184 | — | | `predicate-rhs-path-shaped` | 12 · 648 | 12 · 160–181 | — | | `action-dispatch-contract-mismatch` | 5 · 927 | 3 · 174–184 | — | | `component-type-unknown` | 16 · 807 | 16 · 120–172 | — | | `filter-preset-comparand` | 43 · 669 | 43 · 136–174 | — | All 25 push sites fire before and after. One lint test reads the package's `dist/` and fires `filter-preset-comparand` through it; the after run was taken on the rebuilt dist, so it reports the new text too (a first after run on the base dist recorded that one old message, and was discarded). `action-dispatch-contract-mismatch` loses two distinct messages because the old message echoed the view label, which is the finding's `where`. ## Doors that print the new text Read from the registry (`authoring-rules.ts`: every entry is `commands: ALL`; `validatePresetComparands` is `CLI_AND_RUNTIME` with `runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow', 'report']`, `validatePredicatePathRefs` with `['view']`, `validateComponentTypes` is `CLI_ONLY`; the reference-integrity suite, `CLI_AND_RUNTIME`, dispatches `validateSearchableFields` on `['flow', 'view', 'object']`, `validateSortableFields` on `['flow', 'view']` and `validateActionDispatchContract` on the default `['flow']`) and the CLI's callers (`commands/validate.ts`, `compile.ts`, `lint.ts`, `verify.ts`, `utils/scaffold-validate.ts`, which `init.ts` and `generate.ts` run): | door | ids | what changes | |---|---|---| | `os validate`, `os build` (and `os compile`, which `os dev` runs per compile), `os lint`, `os verify`, the `os init` / `os generate` scaffold check | all 12 | the text-face verdict line; the `rule:` line gains the `os explain` pointer; `os validate --json` `errors` and `os build --json` author-time `issues` carry the new `message` | | runtime publish gate (Studio, REST `/meta`, MCP) | the three `searchable-field-*` ids on a `view`, `object` or `flow` write; the three `sort-field-*` ids on a `view` or `flow` write; the three `predicate-*` ids on a `view` write; `filter-preset-comparand` on a `dashboard`, `view`, `object`, `page`, `flow` or `report` write | the 422 issue `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line | | never at the runtime gate | `component-type-unknown` (CLI only); `action-dispatch-contract-mismatch` (it runs there only on a `flow` write, whose snapshot carries no actions, so its declaration map is empty and it returns before judging) | | | unchanged | every `hint`; every other rule id | | Every row is named in the changeset. ## Tests The rule suites import the rule source; the CLI and runtime-gate suites read the rebuilt `@objectstack/lint` dist. - **Each rule's own suite pins the new shape**, as in slices 2–5. All six test files wrap their rule import (and `checkSortDeclaration` / `checkSearchableFieldList`, the exported cores) and record every finding their cases fire; a final case in each holds every recorded verdict of its ids to one line of at most 200 characters, behind a coverage control (each id fired; for `component-type-unknown`, the retired arm fired for every retired type). A second block per file pins, per id, that `explainRule(id)` exists and still names what the verdict stopped saying, and holds the explanation's written-out lists to their spec sources. New exact pins: the sort virtual verdict, the two predicate resolution verdicts (one consequence clause), both dispatch directions, the namespace verdict, the preset verdict for an injected `created_at`; `retiredTypeHead` has its own cases (dash, first sentence, never rewrites). Every refusal assertion, rule id, severity, `path`, `where` and hint pin is unchanged. The measured-consequence pins that read the old text (ADR-0015 in the anchor messages, the skill-parity full-message pins of the searchable verdicts, the preset refusal's full text) moved to the new text or to the explanation. - **Lint suite:** `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` at `8ad8e8bdf4` (lint source byte-identical to the head `cd82744e3f`, which changes only the changeset): Test Files 135 passed (135), Tests 6,295 passed (6,295); lock VERDICT command-exit 0 (31 new cases). - **Lint build + typecheck:** `pnpm --filter @objectstack/lint build && pnpm --filter @objectstack/lint run typecheck`: VERDICT command-exit 0; `check:test-typecheck` OK, 2 files, 6 errors and 2 pinned signatures held (the test layer compiles under `tsconfig.test.json`, so the six test files are covered). - **CLI**, against the rebuilt lint dist: `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/explain-rule-id.test.ts test/commands.test.ts test/validate-build-gate-parity.test.ts test/migrate-meta-engine-guidance.test.ts`: Test Files 4 passed (4), Tests 91 passed (91). `explain-rule-id.test.ts` iterates every `RULE_EXPLANATIONS` key, so all 12 new ids resolve through `os explain`, `explainPointer` and the listing. `migrate-meta-engine-guidance.test.ts` is the tracker-id refusal (mechanism assumption 4). - **Runtime gate:** the whole `@objectstack/metadata-protocol` suite, before and after (census above): Test Files 221 passed | 3 skipped (224), Tests 28,047 passed | 19 skipped, both runs; lock VERDICT command-exit 0. - **Spec `repo` project (mechanism assumption 3):** `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2` (= `test:repo`): Test Files 55 passed (55), Tests 971 passed (971); lock VERDICT command-exit 0, at `8ad8e8bdf4`. Two of its tests walk `packages/lint/src` as a corpus: `src/shared/retired-key-migrate-sentence.test.ts` (the `os migrate meta` sentence scanner; the new text carries no such sentence, grep 0) and `src/identity/position-delegatable-enforcer.pin.test.ts` (exported `security-*` rule id constants; this slice declares none). The other `lint/src` mentions under `packages/spec/src` name `validate-component-props.test.ts` in path lists or are comments. - **Tracker ids (mechanism assumption 4):** no `#` plus digits in any printed message (0 in the census's 142 after messages) or explanation (`rule-explanations.test.ts` refuses one in every paragraph); the citations stay in `//` comments and test names. - **Ablation** (one-shot, from the committed state `8ad8e8bdf4`, through `scripts/ablation-replace.mjs` wrap mode under the verify lock, plus a shell `trap` restoring `HEAD`). The rule suites import the rule source, so there is no dist leg. `sort-field-unknown`'s pre-slice message was restored verbatim (the mutated block checked equal to the base file's) through the anchor of its new message: anchor x1 to x0, blob `ace03ff2b4d3` to `8373465e586c`. Predicted before the run: exactly the bound pin goes red, since the old text still carries every fragment the other cases pin (`Did you mean "amount"?`, `400 INVALID_SORT`). Observed: `src/validate-sortable-fields.test.ts` gave Test Files 1 failed (1), Tests 1 failed | 45 passed (46); the one red case was "every verdict the cases above fired is one line of at most 200 characters" (269 characters). Restored: blob after restore `ace03ff2b4d3` == blob at HEAD, `git diff HEAD` empty, `git status --porcelain` empty. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 14 changed `.ts` files gave 14 files in the report, 0 errors, 0 warnings. `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, no typed rules), so no untouched file's verdict can move. A control-character scan of every changed file found no match. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths, on the head `cd82744e3f` (merge base `61bea24207`; the derived set is identical to the one at `8ad8e8bdf4`), derived 90 commands. I ran 88 of them sequentially, each with its own log and its exit code captured before any pipe: all 88 exited 0. Two are NOT MEASURED, as the dispatch directs: `pnpm check:dual-build-cjs-loads` (it needs the whole-workspace build), and `pnpm check:type-check-debt`, which is `check-type-check-coverage.mjs --re-measure` in `package.json` (it builds every package); its non-re-measuring half is the same command as `check:type-check-coverage`, which ran and exited 0. `--ran` gave `Run reconciliation — 90 derived, 88 run, 0 NOT-MEASURED, 2 UNRUN` (those two). I also ran the 51-family "Artifact rosters" block the same run prints, 47 of them (one, `check:engine-double-contract`, is also a derived command): all exited 0 (14 of the 51 are checker-health `--self-test` rows, which grade a checker and not this diff; four keep their roster in a directory this diff touches — `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity` — all green). The three PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) need this PR's number and body, so they run against this PR after it opens; their readings are in the report on the card. On a first pass at `8ad8e8bdf4` (the same 135 commands, all 0 at the second pass), `check:skill-examples` and `check:published-readme-exports` refused with `PREREQUISITE NOT MET` (exit 3, nothing measured: seven packages' `dist/*.d.ts` absent); I built exactly those seven (`client-react`, `cli`, `organizations`, `service-knowledge`, `knowledge-memory`, `knowledge-ragflow`, `embedder-openai`; lock VERDICT 0, 48s) and both exited 0. The long ones on the shared box: `check:query-options-erasure` 267s, `check:slot-lookup` 135s, `check-comment-mask-corpus` 103s. ## Remaining for later slices — 44 ids in `packages/lint`, by file PR #22700's list minus this slice's six files and 12 ids. The lengths are slice 2's (PR #22448, census at `05c7c3fa3b`; "cli" marks a length only the cli suite reached), not re-measured here. `validate-approval-approvers.ts` is PR #22703's (another seat), in flight. - `data-model-rules.ts` (6): `unique/legacy-organization-composite` 480 cli, `relationship/master-detail-required` 462, `unique/unscoped-declared-index` 427, `unique/double-declaration` 402 cli, `rollup/non-numeric-aggregand` 364, `relationship/delete-behavior` 201 cli - `validate-ai-agent-authoring.ts` (3): `default-agent-legacy-alias` 466, `default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352 - `lint-view-refs.ts` (2): `view-ref-nav-view-missing` 437, `view-key-collision` 278 cli - `validate-approval-approvers.ts` (2): `approval-approvers-may-resolve-empty` 451, `approval-approver-not-membership-tier` 272 - `validate-chart-bindings.ts` (2): `chart-measure-unknown` 442, `chart-axis-not-selected` 327 - `validate-dashboard-action-refs.ts` (2): `dashboard-action-route-unresolved` 242, `dashboard-action-target-undefined` 239 - `validate-empty-combinators.ts` (2): `filter-empty-combinator` 352, `filter-empty-node` 226 - `validate-list-view-field-refs.ts` (2): `list-view-field-dotted` 445, `list-view-field-unknown` 355 - `validate-translation-references.ts` (2): `translation-target-unknown` 345, `translation-option-key-unknown` 230 - `lint-flow-credential-literals.ts` (1): `flow-credential-literal` 390 - `validate-action-name-refs.ts` (1): `action-name-undefined` 409 - `validate-ai-surface-affinity.ts` (1): `ai-skill-surface-mismatch` 281 - `validate-ai-tool-references.ts` (1): `ai-skill-tool-unresolved` 441 - `validate-capability-references.ts` (1): `capability-reference-unknown` 219 - `validate-flow-filter-tokens.ts` (1): `flow-filter-token-unknown` 278 - `validate-managed-api-methods.ts` (1): `object/managed-api-method-unaffordable` 412 - `validate-mapping-target-fields.ts` (1): `mapping-target-field-unknown` 466 - `validate-nav-access.ts` (1): `nav-object-ungranted` 353 - `validate-nav-object-servability.ts` (1): `nav-object-unservable` 528 - `validate-nav-target-refs.ts` (1): `nav-target-unresolved` 426 - `validate-object-field-refs.ts` (1): `object-field-ref-unknown` 320 - `validate-object-references.ts` (1): `object-reference-unregistered-platform` 324 - `validate-org-axis-red-lines.ts` (1): `org-axis-cross-org-bu-grant` 365 - `validate-page-visualization-bindings.ts` (1): `page/visualization-without-binding` 629 - `validate-retired-permission-residue.ts` (1): `permission-retired-lifecycle-residue` 235 - `validate-seed-replay-safety.ts` (1): `seed-insert-mode-duplicates-on-replay` 222 - `validate-seed-state-machine.ts` (1): `seed-value-outside-state-machine` 320 - `validate-semantic-roles.ts` (1): `semantic-role-field-unprovisioned` 291 - `validate-translatable-sections.ts` (1): `translation-section-name-missing` 553 - `validate-view-containers.ts` (1): `view-container-shape` 290 The 26 ids slice 2 fenced, the 9 ids owned by `packages/cli`, and the `action-governance.ts` boot-log lines stay as PR #22448's body lists them, and `react-prop-deprecated` as PR #22700's notes it. This slice touched none of them. ## Acceptance notes - **One more `fix:` line now points at text the verdict quotes only in part:** `component-type-unknown`'s retired arm ends its hint "Apply the prescription above", and the verdict now quotes the prescription's head and says `os validate` prints the whole of it (`os validate` and `os build` refuse the same name at the parse with the whole prescription; `os lint`, which does not parse, prints the head). This joins the card's open hint call next to the three slice 5 named (`react-chart-drilldown-invalid`, `react-chart-aggregate-invalid`, `flow-time-relative-descriptor-invalid`) and `sharing-rule-runtime-variable-condition`'s length. `filter-preset-comparand`'s hint ("write the {date-macro} window the message names") stays true: the verdict names the window. - **The schema door still prints the whole preset refusal.** `bareDateRangePresetComparandMessage` (`packages/spec/src/data/date-range-presets.ts`) is shared by the schema door and this rule, and its docblock says so ("one condition keeps one wording"). The rule now opens with that refusal's own first sentence and names the same window, and the rest is its `os explain` entry; the schema door, outside this file surface, keeps the long text. Converging it on the same verdict belongs to a slice that takes `@objectstack/spec`'s own refusals (as slice 1 did for the functional-completeness ones). - **Two files outside the claim's file surface, both text-only:** `content/docs/data-modeling/schema-design.mdx` quoted the old `searchable-field-unknown` message as `os validate` output, which this change makes false, so its two message lines are updated; `packages/lint/src/validate-react-page-props.test.ts` asserted the old `searchable-field-unprovisioned` text through the react ListView surface (one assertion). No other doc, skill or test quotes the 12 ids' text (grep over `content/docs`, `skills` and every package). The searchable suite's skill-parity block said `skills/objectstack-ui` quotes the two verdicts verbatim; it names them by rule id only (`rules/list-views.md`), so that comment is corrected and the skill needs no edit. - **A long name can still lengthen a line.** The 200 bound holds on every variant the suites fire, and on every preset under every operator spelling the rule reports (pinned); the object, field, action and component names in a verdict are the author's. - **Process:** the base CLI closure build held the shared lock 6m26s; the base `packages/cli` unit-tier census 9m23s; each `@objectstack/metadata-protocol` census 5m24s / 5m57s; the spec `repo` project 15m38s. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fd2768a commit 762db99

16 files changed

Lines changed: 975 additions & 176 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): the list-view sort and search, form-predicate path, bulk-dispatch, component-type and preset-comparand findings print one verdict line, and `os explain <rule-id>` carries their reasoning
6+
7+
Clause-②: no
8+
9+
- **Shorter verdicts.** Each finding of these 12 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 193 characters, and the runtime publish gate's suites at most 137; before, the longest of each ran from 287 to 927 characters. The ids:
10+
- list-view `sort` (`objects[].listViews`, `views[]` lists, list overlays and ViewItem records): `sort-field-unknown`, `sort-field-unsortable`, `sort-field-unprovisioned`;
11+
- `searchableFields` (the object's own set, list views, and a react page's `<ListView searchableFields>`): `searchable-field-unknown`, `searchable-field-unsearchable`, `searchable-field-unprovisioned`;
12+
- metadata-form `visibleWhen` predicates (`views[]` forms bound to a schema): `predicate-path-unresolved`, `predicate-path-unrooted`, `predicate-rhs-path-shaped`;
13+
- list-view bulk wiring: `action-dispatch-contract-mismatch`;
14+
- page component types (`pages[]`): `component-type-unknown`;
15+
- filter comparands: `filter-preset-comparand`.
16+
17+
A verdict no longer repeats what the finding's `where` already names: the view that wires an action (`action-dispatch-contract-mismatch`). The unprovisioned-anchor ids print the same one-clause cause the other converted anchor rules print (`'owner_id' is an injected column with no storage on external object 'x'`), and the two virtual-entry ids (`sort-field-unsortable`, `searchable-field-unsearchable`) state the storage fact in one shared wording. `searchable-field-unsearchable` quotes at most three names of an object's declared set, then `(and N more)`. A retired component type (`user:profile`, `element:filter`, `element:form`, `ai:chat_window`) is quoted to the head of its prescription in `RETIRED_PAGE_COMPONENT_TYPES` (`` `element:filter` was removed in @objectstack/spec 17 (ADR-0049) ``), and the verdict says the parse refuses the node by name; the whole prescription is the parse door's refusal of the same name, which `os validate` and `os build` print. `filter-preset-comparand` opens with the first sentence of the refusal the schema door shares (`"last_30_days" is a dashboard date-range PRESET name, not a filter value`), then names the operator and the preset's `{date-macro}` window. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead.
18+
- **`os explain <rule-id>` takes these 12 ids**, for example `os explain sort-field-unknown`. It prints the reasoning the verdicts no longer carry: why an unknown sort field breaks a view's first fetch and every load after it, and which list-view surfaces the sort and search rules walk and skip; what a `formula` field's lack of storage does to an ORDER BY and to a search; what an unprovisioned anchor is and what sorting or searching one measured; how a stale `searchableFields` entry narrows a search or falls through to the auto-default set, and how a list view's narrowing reaches the runtime as the `$searchFields` override; how a metadata-form predicate path is resolved against the edited schema, why a dead predicate fails open, and why the right side of `==` / `!=` is a literal; how the two bulk wirings call an action and why nothing refuses a mismatch at run time; which component types the vocabulary closes and where a retired type's prescription lives; and where a date-range preset name is understood and what each layer does with a bare one. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain <rule-id>` for … ``. The no-argument listing and its `--json` `rules` array list the 12 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 12 entries.
19+
- **Where the new text prints.** On the CLI, all 12 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` and `os generate` run print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change for: the three `searchable-field-*` ids on a `view`, `object` or `flow` write; the three `sort-field-*` ids on a `view` or `flow` write; the three `predicate-*` ids on a `view` write; and `filter-preset-comparand` on a `dashboard`, `view`, `object`, `page`, `flow` or `report` write. Each issue's `hint` is unchanged.
20+
- **Never at the runtime gate:** `component-type-unknown`, which runs on the CLI doors only, and `action-dispatch-contract-mismatch`, whose rule runs at that door only for a `flow` write, whose snapshot carries no actions to judge, so both speak only on the CLI doors above.

‎content/docs/data-modeling/schema-design.mdx‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,8 @@ send you to the same fix, so either message is greppable back to this section.
167167
`os validate` reports `searchable-field-unknown`:
168168

169169
```text
170-
searchableFields entry "project_id.name" is not a field on object "task". The
171-
declaration is stale: searching it can never match, and the engine silently
172-
drops it — leaving a narrower search than declared, or the auto-default set once
173-
every entry is dropped.
170+
searchableFields entry "project_id.name" is not a field on object "task", so the
171+
engine drops it from the search.
174172
175173
hint: 'search' scans this object's own columns, so a related record's column
176174
cannot be a search target — expand the relation and search the related object,

0 commit comments

Comments
 (0)