Skip to content

Commit ebbb8f9

Browse files
fix(lint): one-line verdicts for the hook, action and flow record-write rules; os explain RULE_ID carries their reasoning (#22548)
Part of #22161 Clause-②: no Stage 2 of the card, slice 3: the 14 rule ids of the six record-write rules in `packages/lint`, six whole source files. The card stays open for the later slices listed under "Remaining" below. ## What changes - **One verdict line per finding.** Each finding of the 14 ids prints a `message` of one verdict sentence. Every finding the rules' own suites fire is now 196 characters or fewer, and the CLI suite's handler-hook variants 195 or fewer (the longest of each id was 212 to 792 before). The author's values still close each verdict: the field, the object, the `ctx.api` call and the run identity. A hook lowered from an inline `handler` keeps its location suffix " (judged on the metadata body lowered from the inline handler)", and the bound holds with it. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 14 new entries, so `os explain RULE_ID` prints it and the CLI's `rule:` line ends with the pointer for these ids. No CLI file changes: `explainPointer()` and `os explain` resolve any key the table holds (`packages/cli/test/explain-rule-id.test.ts` iterates every key; run below). - **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, or a shared verdict helper. - `.changeset/22161-lint-slice-3-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. ## Shared prose: written once (the dispatch's H2) H2 holds. Three families of sentences were shared across the action, hook and flow surfaces. Each was typed out separately in each rule, or concatenated from one long helper. Each now lives in one place for the verdict and one place for the explanation, and every id that uses it references it: | shared sentence | ids | verdict clause (rule files) | explanation (`rule-explanations.ts`) | |---|---|---|---| | the unprovisioned-anchor cause and write consequence (was `unprovisionedAnchorCause()` plus the 5-sentence `unprovisionedAnchorWriteConsequence()`) | the three `*-write-unprovisioned-anchor` ids | `unprovisionedAnchorWriteVerdict()` in `validate-hook-body-writes.ts`, over slice 2's one-clause `unprovisionedAnchorVerdict()` (`system-fields.ts`, unchanged) | `UNPROVISIONED_ANCHOR_WRITE` (3 paragraphs) | | the declared-field door ("scoped handle on the running engine … INVALID_FIELD / 400, identically on every driver, before any statement is built"), typed twice verbatim plus a flow variant | the three `*-write-unknown-field` ids | `UNDECLARED_FIELD_WRITE_REFUSAL` and `undeclaredApiWriteVerdict()` in `validate-hook-body-writes.ts` | `DECLARED_FIELD_DOOR` | | the parse-failure sentence, typed twice verbatim | `hook-body-source-unparseable`, `action-body-source-unparseable` | `bodyParseFailureVerdict()` in `validate-hook-body-writes.ts` | `BODY_PARSE_FAILURE` (2 paragraphs) | | the static `readonly` strip and the conditional `readonlyWhen` strip | the five readonly ids | `READONLY_WHEN_STRIP_SCOPE`, `READONLY_INSERT_STRIP_OUTCOME` in `validate-readonly-flow-writes.ts` (already the module the other two import `buildReadonlyIndex` from) | `READONLY_STATIC_STRIP`, `READONLY_WHEN_STRIP` (2 paragraphs), plus `FLOW_FIELDS_CALLER_PAYLOAD` and `HOOK_API_CALLER_PAYLOAD` for the two sibling pairs | `unprovisionedAnchorWriteConsequence()` had exactly these three callers and is not exported from the package barrel, so it is replaced, not left dead. `unprovisionedAnchorCause()` keeps its other callers in the files later slices will shorten. The shape, as `os lint`'s printer wrote it in `packages/cli/test/lint-hook-rules-reach-handler-hooks.test.ts` against the rebuilt `@objectstack/lint` dist (`where: message`, recorded by the census preload below): ```text hook "typo" › body: body calls ctx.api.object('crm_case').update(…) writing undeclared field 'is_escalatd', so the write is refused (INVALID_FIELD / 400) (judged on the metadata body lowered from the inline handler) hook "escalate" > body: body's ctx.api.object('crm_case').update(...) writes readonly field 'is_escalated', silently stripped on a non-system trigger hook "hold" > body: body's ctx.api.object('crm_case').update(...) writes readonlyWhen field 'credit_hold', silently stripped where its predicate is TRUE (judged on the metadata body lowered from the inline handler) ``` The other verdict forms, one each, as the lint suite fired them: ```text 'organization_id' is an injected column with no storage on external object 'wh_order', so the body's updateById(…) write can never land create_record writes 'stagee', but object 'deal' declares no such field, so the write is refused (INVALID_FIELD / 400), the record is never created and the step fails the run body assigns ctx.record.amount, but an action's ctx.record is a snapshot the runtime never writes back, so the assignment is discarded while the action returns success L2 body did not parse (line 1, column 15: Expression expected.; 2 syntax errors in total), so writes in its unread part go unchecked writes readonly field 'approval_status' of object 'crm_opportunity', which a runAs:'user' INSERT silently strips, so the row is created WITHOUT this column ``` ## Census (taken first, before any edit, at the base `faf6348508`) Method: a scratch preload (`NODE_OPTIONS=--import`, never committed) patched `Array.prototype.push` to record every finding-shaped object (`rule` + `message`) of the 14 ids, deduped by (rule, message), with its push site, in every vitest worker and every process a test spawns. Lengths are `message` alone; the printed line adds `where` and `: `. Rows from `os lint`'s own printer (`commands/lint.ts`), whose `message` is the printed `where: message` line, are excluded, as in slice 2. - **`packages/lint` suite** at the base: 131 files, 6,010 tests passed. All 14 ids fired, every one over 200. - **`packages/cli` suite**, unit and integration, against the base's lint dist: 376 files, 373 passed, 4,961 tests passed and 35 skipped. Three files failed before any test: `published-subpath-console.pin`, `published-subpath-hook-body.pin` and `dev-standalone-self-heal.integration`. Each refused because `packages/cli` itself had no `dist/` (the census built the cli's dependency closure, not the cli). None of the three calls a lint rule (grep: no `lintConfig`, `runAuthoringRules`, `ctx.api` or rule id), so the census is complete for these ids. The cli suite fires three of the 14, all from `test/lint-hook-rules-reach-handler-hooks.test.ts` with the lowered-handler suffix: `hook-body-write-unknown-field` 527 and `hook-api-update-readonly-when-field` 323 (both longer than the lint suite's), and `hook-api-update-readonly-field` 419 (the lint suite's 464 is longer). - **H1 holds:** every id is over 200 on some variant, so none is dropped. The shortest were the two `*-source-unparseable` ids at 212, carried by the parse diagnostic plus "; 2 syntax errors in total". - **No producer outside the six files:** every one of the 14 messages is built in its own rule file, from `system-fields.ts` (unchanged) and `checked-parse.ts` (unchanged). No id quotes `packages/spec` refusal prose, so none was dropped for that reason. | rule id | file | severity | before: lint suite messages · longest | after: lint suite messages · shortest–longest | cli suite before → after | |---|---|---|---|---|---| | `hook-body-write-unprovisioned-anchor` | `validate-hook-body-writes.ts` | warning | 2 · 792 | 2 · 127–135 | — | | `action-body-write-unprovisioned-anchor` | `validate-action-body-writes.ts` | warning | 2 · 778 | 2 · 124–128 | — | | `flow-node-write-unprovisioned-anchor` | `validate-flow-node-writes.ts` | warning | 1 · 739 | 1 · 122–122 | — | | `hook-body-write-unknown-field` | `validate-hook-body-writes.ts` | warning | 15 · 465 | 15 · 118–195 | 527 → 195 | | `hook-api-update-readonly-field` | `validate-readonly-hook-writes.ts` | error | 14 · 464 | 14 · 119–196 | 419 → 187 | | `action-body-write-unknown-field` | `validate-action-body-writes.ts` | warning | 11 · 433 | 11 · 120–135 | — | | `flow-node-write-unknown-field` | `validate-flow-node-writes.ts` | error | 21 · 421 | 21 · 141–176 | — | | `flow-update-readonly-field` | `validate-readonly-flow-writes.ts` | error | 13 · 410 | 13 · 96–155 | — | | `action-api-update-readonly-when-field` | `validate-readonly-action-writes.ts` | warning | 5 · 396 | 5 · 173–182 | — | | `hook-api-update-readonly-when-field` | `validate-readonly-hook-writes.ts` | warning | 1 · 267 | 1 · 135–135 | 323 → 194 | | `flow-update-readonly-when-field` | `validate-readonly-flow-writes.ts` | warning | 3 · 317 | 3 · 135–146 | — | | `action-record-write-discarded` | `validate-action-body-writes.ts` | warning | 3 · 293 | 3 · 165–167 | — | | `hook-body-source-unparseable` | `validate-hook-body-writes.ts` | warning | 2 · 212 | 2 · 99–132 | — | | `action-body-source-unparseable` | `validate-action-body-writes.ts` | warning | 2 · 212 | 2 · 99–132 | — | The per-id message counts are unchanged, so no two variants of an id collapsed into one sentence. The cli "after" column is the four cli files that call these rules (run below), not the whole suite again. ## Doors that print the new text Read from the registry (`authoring-rules.ts`: the reference-integrity suite entry is `commands: ALL`, `runtimeTypes: ['flow', 'view', 'object', 'dataset', 'report']`), the suite's per-member `runtimeTypes` (`reference-integrity-suite.ts`: the six members carry the frozen `['flow']` default) and the runtime gate (`lint/src/runtime-gate.ts`, `metadata-protocol/src/runtime-authoring-gate.ts`): | door | ids | what changes | |---|---|---| | `os validate`, `os build` (and `os compile`, which `os dev` runs per compile), `os lint`, `os verify`, `os init` scaffold check | all 14 | the text-face verdict line; the `rule:` line gains the `os explain` pointer; `os validate --json` `warnings` and `os build --json` author-time `issues` carry the new `message` | | runtime publish gate, `flow` writes (Studio, REST `/meta`, MCP) | the 4 flow ids | errors (`flow-node-write-unknown-field`, `flow-update-readonly-field`): the 422 issue `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line. Warnings (`flow-node-write-unprovisioned-anchor`, `flow-update-readonly-when-field`): the 2xx `advisories` `message` and the deduped `[Protocol] authoring advisory` log line | | never at the runtime gate | the 10 hook and action ids | a `hook` or `action` write does not dispatch the suite (the body members parse JavaScript, which `runtime-lazy-deps.test.ts` pins off the publish path), and a `flow` snapshot carries no hook or action body | | unchanged | every `hint`; every other rule id | | Every row is named in the changeset. ## Tests (head `77821c2e1d`) - **Each rule's own suite pins the new shape**, as in slice 2. The six test files wrap their rule import and record every finding their cases fire for the shortened ids. A final case holds each recorded verdict to one line of at most 200 characters, behind a coverage control that each shortened id fired at least once. The two hook files also require that a lowered-handler variant (with the suffix) is among the recorded ones, so the bound covers it. A second block pins, per id, that `explainRule(id)` exists and still names what the verdict stopped saying. The measured-refusal pins (`INVALID_FIELD / 400`, "identically on every driver", "before any statement is built", "ordinary CALLER write", what fails) moved from the message to the explanation. The retired driver-split negatives now hold over both the message and the explanation. Every refusal assertion, rule id, severity, `path`, `where` and hint pin is unchanged. - **Lint suite:** `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` gave Test Files 131 passed (131), Tests 6,036 passed (6,036); lock VERDICT command-exit 0. The first run, at `4c78055635`, was red in exactly one case: `rule-explanations.test.ts` refuses a dot in `covers`, and `action-record-write-discarded`'s phrase read "ctx.record". `77821c2e1d` rewords it. - **Lint typecheck:** `pnpm --filter @objectstack/lint run typecheck` gave VERDICT command-exit 0. `check:test-typecheck` OK: 2 files, 6 errors and 2 pinned signatures held. - **cli**, against the rebuilt `@objectstack/lint` dist (marker grep: 1 hit each in `dist/rule-explanations.js` and `.cjs`): `pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/explain-rule-id.test.ts test/commands.test.ts test/lint-hook-rules-reach-handler-hooks.test.ts test/validate-build-gate-parity.test.ts src/flow-node-undeclared-field-write.integration.test.ts test/lint-hook-rules-reach-handler-hooks.e2e.test.ts` gave Test Files 5 passed (5), Tests 107 passed (107). Of the six named paths, `vitest list` selects the first five, four `unit` and one `integration`. The flow-node file pins the runtime refusal the verdict names, not the message. The sixth, the `.e2e` file, selected nothing: it is nightly-tier. `explain-rule-id.test.ts` iterates every `RULE_EXPLANATIONS` key, so all 14 new ids resolve through `os explain` and `explainPointer`. The nightly-tier file then ran on its own after `pnpm turbo run build --filter=@objectstack/cli`: `OS_TEST_TIERS=nightly … vitest run test/lint-hook-rules-reach-handler-hooks.e2e.test.ts` gave 1 file, 10 passed. It spawns `os build` / `os lint`, and every `hook-api-update-readonly-field` verdict it fired is at most 187 characters. - **Spec `repo` project (the dispatch's H3):** `pnpm --filter @objectstack/spec exec vitest run --project repo --maxWorkers=2` gave Test Files 54 passed (54), Tests 915 passed (915); lock VERDICT command-exit 0. It walks `packages/lint/src` as a text corpus. The new explanations carry no `os migrate meta` sentence (grep: 0), no `security-*` rule id constant, and no retired key. - **Nothing else moves.** The six rule files' only non-message hunks are imports, comments and the shared helpers. `unprovisionedAnchorWriteConsequence()`, the one removed function, had exactly the three in-slice callers (grep at the base) and is not on the package barrel. `system-fields.ts` and `checked-parse.ts` are untouched, so no other rule's message can move. The per-id message counts in the census table are equal before and after. - **Ablation** (one-shot, from the committed state `77821c2e1d`, through `scripts/ablation-replace.mjs` wrap mode, trap-armed). The tests import the rule source, so there is no dist leg. `action-record-write-discarded`'s pre-slice message was restored verbatim through the anchor of its new two-line message: anchor x1 to x0, blob `134c72868e97` to `1a67d89f7b73`. `src/validate-action-body-writes.test.ts` then gave Test Files 1 failed (1), Tests 1 failed | 44 passed (45). The one red case was the bound pin, "every verdict the cases above fired for those ids is one line of at most 200 characters" (the restored `ctx.record.stage` verdict, 291 characters). Restored: blob after restore `134c72868e97` == blob at HEAD, `git diff HEAD` empty, `git status --porcelain` empty. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 13 changed `.ts` files (`git diff --name-only faf6348..HEAD`) gave 13 files in the report, 0 errors, 0 warnings, none ignored. `eslint.config.mjs` never enables type-aware linting (its comment at `:327`), so no untouched file's verdict can move. A control-character scan of every changed file found no match, and `check:nul-bytes` exits 0. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on the actual diff (14 paths vs merge base `faf634850`) derived 62 commands, identical to the dispatch's 62. I also ran the 4 artifact-roster gates it flags as rostered in a directory this diff touches: `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing` and `check:filter-alias-parity`. All 66 ran sequentially, each with its own log and an exit code captured before any pipe, at `77821c2e1d`; 64 exited 0. Two exited 3 with PREREQUISITE NOT MET, which is not a measurement. `check:dual-build-cjs-loads` lacked eight packages' `dist/` and exited 0 after building them. `check:type-check-debt`'s re-measure build had a message-less tsup DTS-worker exit in `plugin-approvals` on the shared box, and its re-run exited 0 ("1 ledger entr(ies) re-measured … none above its recorded number"). `--ran` gave `✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED`. ## Remaining for later slices — 80 ids in `packages/lint`, by file Slice 2's list (PR #22448, census at `05c7c3fa3b`; longest fired message, "cli" marks a length only the cli suite reached) minus this slice's six files and 14 ids. The lengths are slice 2's, not re-measured here. - `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-flow-trigger-readiness.ts` (5): `flow-time-relative-descriptor-invalid` 796, `flow-time-relative-descriptor-unroutable` 532, `flow-trigger-unroutable` 518, `flow-api-trigger-secret-missing` 336, `flow-trigger-unknown-event` 222 - `validate-react-page-props.ts` (5): `react-chart-drilldown-invalid` 784, `react-chart-aggregate-invalid` 507, `react-chart-field-unprovisioned` 429, `react-block-needs-record-context` 267, `react-page-source-unparseable` 211 - `validate-rls-predicate-enforceability.ts` (5): `rls-predicate-unparseable` 2056, `rls-predicate-unenforceable` 1679, `rls-predicate-unknown-user-variable` 1544, `rls-predicate-unknown-field` 1399, `rls-predicate-over-budget` 1259 - `validate-sharing-rule-enforceability.ts` (4): `sharing-rule-unlowerable-condition` 1093, `sharing-rule-object-not-shareable` 774, `sharing-rule-object-controlled-by-parent` 660, `sharing-rule-runtime-variable-condition` 492 - `validate-ai-agent-authoring.ts` (3): `default-agent-legacy-alias` 466, `default-agent-outside-roster` 388, `agent-authoring-withdrawn` 352 - `validate-predicate-path-refs.ts` (3): `predicate-rhs-path-shaped` 648, `predicate-path-unrooted` 434, `predicate-path-unresolved` 371 - `validate-searchable-fields.ts` (3): `searchable-field-unprovisioned` 512, `searchable-field-unsearchable` 449, `searchable-field-unknown` 295 - `validate-sortable-fields.ts` (3): `sort-field-unprovisioned` 844, `sort-field-unsortable` 369, `sort-field-unknown` 287 - `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-dataset-measure-aggregates.ts` (2): `measure-aggregate-field-type-refused` 809, `dimension-json-stored-field-refused` 531 - `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-rule-compilability.ts` (2): `validation-rule-json-schema-uncompilable` 517, `validation-rule-regex-uncompilable` 482 - `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-dispatch-contract.ts` (1): `action-dispatch-contract-mismatch` 927 - `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-component-types.ts` (1): `component-type-unknown` 807 - `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-preset-comparands.ts` (1): `filter-preset-comparand` 669 - `validate-retired-permission-residue.ts` (1): `permission-retired-lifecycle-residue` 235 - `validate-rule-schema-formats.ts` (1): `validation-rule-json-schema-unknown-format` 1049 - `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. This slice touched none of them. ## Acceptance notes - **The `fix:` lines are unchanged and several stay long.** The dispatch holds hints as they are. The readonly hook hints run to several hundred characters (the `runAs: 'system'` / own-hook-stamp / `sudo()` remedy), and so does the flow `readonlyWhen` hint. Whether hints get the same one-line budget is the card's call, as slice 2's report already noted. Some explanation paragraphs restate reasoning those long hints also carry (elevation does not waive `readonlyWhen`, `sudo()` is not marshalled into the sandbox). Slice 2's entries overlap their hints the same way. - **Author values and the lowered-handler suffix extend a verdict.** The fixed text of every verdict is short. The suffix is location, not reasoning: it explains why `path` is `hooks[i].handler`, and `packages/cli` pins its wording, so it stays verbatim. Every combination the suites fire is at most 196. A combination no suite fires can pass 200: a hook lowered from a `handler` that INSERTs a long-named readonly field (239 with the fixtures' names). The changeset says a verdict over a long name grows with it. - **`unprovisionedAnchorCause()` keeps five callers** (`validate-flow-template-paths.ts`, `validate-page-field-bindings.ts`, `validate-react-page-props.ts`, `validate-searchable-fields.ts`, `validate-sortable-fields.ts`). They converge on `unprovisionedAnchorVerdict()` when their slices shorten them, as slice 2's docblock states. - **`os lint` prints `where: message`.** For a lowered hook its printed line reaches 215 characters with the `where` prefix. That is the printer's shape, stage 1's, carried as before. - **Process:** the cli census of the whole suite held the shared verify lock for 35m32s, as slice 2's did (34m39s). A later slice whose ids the cli suite does not fire can scope that half of its census to the files that call its rules. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 37c7114 commit ebbb8f9

14 files changed

Lines changed: 845 additions & 173 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 14 more author-time 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 14 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire, and the CLI suite's handler-hook variants, is at most 200 characters; before, the longest of each ran from 212 to 792 characters. The ids:
10+
- hook bodies: `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`;
11+
- action bodies: `action-body-write-unknown-field`, `action-body-write-unprovisioned-anchor`, `action-record-write-discarded`, `action-body-source-unparseable`;
12+
- flow nodes: `flow-node-write-unknown-field`, `flow-node-write-unprovisioned-anchor`;
13+
- readonly writes: `flow-update-readonly-field`, `flow-update-readonly-when-field`, `hook-api-update-readonly-field`, `hook-api-update-readonly-when-field`, `action-api-update-readonly-when-field`.
14+
15+
The three write surfaces now share one wording per question: an undeclared field ends on "so the write is refused (INVALID_FIELD / 400)", an unprovisioned anchor reads "'FIELD' is an injected column with no storage on external object 'OBJECT', so … can never land", an unparseable hook or action body reads "L2 body did not parse (…), so writes in its unread part go unchecked", and a readonly write says it is "silently stripped" (a `readonlyWhen` field "where its predicate is TRUE"). The values the author wrote still close each verdict — the field, the object, the `ctx.api` call, the run identity — so a verdict over a long name grows with it, and a hook lowered from an inline `handler` keeps its " (judged on the metadata body lowered from the inline handler)" suffix. 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.
16+
- **`os explain <rule-id>` takes these 14 ids**, for example `os explain hook-body-write-unknown-field`. It prints the reasoning the verdicts no longer carry: how each write channel reaches the engine's declared-field door and what the refusal takes down with it, why a write to an unprovisioned anchor on an external object passes the validator and never lands, what a partially parsed body leaves unchecked, why an action's `ctx.record` is never written back, and which readonly strip a system context waives and which it does not. The paragraphs the three surfaces share 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 14 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 14 entries.
17+
- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `flow` write carries the four flow ids: `flow-node-write-unknown-field` and `flow-update-readonly-field` are errors, so the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change; `flow-node-write-unprovisioned-anchor` and `flow-update-readonly-when-field` are warnings, so the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line change. Each issue's `hint` is unchanged.
18+
- **Never at the runtime gate:** the ten hook and action ids. A `hook` or `action` write does not dispatch these rules (they parse authored JavaScript, which the publish path never loads), and a `flow` write's snapshot carries no hook or action body for them to read; they speak only on the CLI doors above.

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

Lines changed: 302 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/lint/src/validate-action-body-writes.test.ts‎

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import {
5-
validateActionBodyWrites,
5+
validateActionBodyWrites as validateActionBodyWritesUnrecorded,
66
ACTION_BODY_WRITE_PATTERNS,
77
ACTION_BODY_WRITE_PATTERN_IDS,
88
ACTION_RECORD_WRITE_PATTERNS,
@@ -18,6 +18,30 @@ import {
1818
HOOK_BODY_WRITE_PATTERNS,
1919
IMPLICIT_FIELDS,
2020
} from './validate-hook-body-writes.js';
21+
import { explainRule } from './rule-explanations.js';
22+
23+
// [#22161] Each finding of the rule ids this file's rule shortened is one
24+
// verdict sentence; the reasoning it used to carry is the id's `os explain`
25+
// entry. Every call below records what it fired, and the last case in this
26+
// file holds each recorded verdict of those ids to one line of at most 200
27+
// characters — so the pin covers every firing variant this suite exercises,
28+
// not a chosen few. Run the whole file: that case reads what the cases above
29+
// fired.
30+
const SHORTENED_RULE_IDS: readonly string[] = [
31+
ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR,
32+
ACTION_BODY_WRITE_UNKNOWN_FIELD,
33+
ACTION_RECORD_WRITE_DISCARDED,
34+
ACTION_BODY_SOURCE_UNPARSEABLE,
35+
];
36+
const firedShortened: Array<{ rule: string; message: string }> = [];
37+
const validateActionBodyWrites: typeof validateActionBodyWritesUnrecorded = (...args) => {
38+
const findings = validateActionBodyWritesUnrecorded(...args);
39+
for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f);
40+
return findings;
41+
};
42+
43+
/** The `os explain` text of `rule`, one string. */
44+
const explanationOf = (rule: string): string => explainRule(rule)?.paragraphs.join('\n') ?? '';
2145

2246
// Target objects: array-shaped and map-shaped `fields`, so both authoring
2347
// shapes are resolved.
@@ -163,7 +187,7 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
163187
expect(findings[0].path).toBe('actions[0].body.source');
164188
expect(findings[0].message).toContain('discont_total');
165189
expect(findings[0].message).toContain('crm_deal');
166-
expect(findings[0].message).toContain('INVALID_FIELD / 400, identically on every driver');
190+
expect(findings[0].message).toContain('INVALID_FIELD / 400');
167191
expect(findings[0].hint).toContain("'discount_total'");
168192
});
169193

@@ -176,24 +200,30 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
176200
// The old text promised a driver-level error on SQL and a persisted stray
177201
// key on schemaless — neither happens on this path, and has not since
178202
// #8682/#8738 put the declared-field door ahead of any statement.
203+
//
204+
// [#22161] The verdict names the refusal; the rest of the measured account
205+
// is `os explain action-body-write-unknown-field`, so it is pinned there.
179206
it('states the measured refusal — INVALID_FIELD / 400 on every driver — and no driver split', () => {
180207
const [finding] = validateActionBodyWrites(
181208
stackWith("await ctx.api.object('crm_deal').update({ discont_total: 0 });"),
182209
);
210+
const explanation = explanationOf(ACTION_BODY_WRITE_UNKNOWN_FIELD);
183211

184212
expect(finding.message).toContain('INVALID_FIELD / 400');
185-
expect(finding.message).toContain('identically on every driver');
186-
expect(finding.message).toContain('before any statement is built');
213+
expect(explanation).toContain('identically on every driver');
214+
expect(explanation).toContain('before any statement is built');
187215
// The reason the door — not a driver — is what answers.
188-
expect(finding.message).toContain('ordinary CALLER write');
189-
// The action-side blast radius, the one word that differs from the hook
190-
// sibling's sentence. Pinned so a future sweep cannot flatten the two.
191-
expect(finding.message).toContain('fails the action');
192-
193-
expect(finding.message).not.toMatch(/driver-level error/);
194-
expect(finding.message).not.toMatch(/schemaless/);
195-
expect(finding.message).not.toMatch(/is persisted/);
196-
expect(finding.message).not.toMatch(/write-path validator skips/);
216+
expect(explanation).toContain('ordinary CALLER write');
217+
// The action-side blast radius, the one sentence that differs from the
218+
// hook sibling's explanation. Pinned so a future sweep cannot flatten the two.
219+
expect(explanation).toContain('fails the action');
220+
221+
for (const text of [finding.message, explanation]) {
222+
expect(text).not.toMatch(/driver-level error/);
223+
expect(text).not.toMatch(/schemaless/);
224+
expect(text).not.toMatch(/is persisted/);
225+
expect(text).not.toMatch(/write-path validator skips/);
226+
}
197227
});
198228

199229
it('checks insert/update payloads (argument 0) and updateById at argument 1', () => {
@@ -203,7 +233,7 @@ describe('validateActionBodyWrites — ctx.api writes', () => {
203233
"await ctx.api.object('crm_deal').updateById(ctx.recordId, { stag: 'won' });",
204234
),
205235
);
206-
expect(findings.map((f) => f.message.match(/writing '(\w+)'/)?.[1])).toEqual(['emial', 'stag']);
236+
expect(findings.map((f) => f.message.match(/writing undeclared field '(\w+)'/)?.[1])).toEqual(['emial', 'stag']);
207237
expect(findings[0].message).toContain("ctx.api.object('crm_contact').insert");
208238
expect(findings[1].message).toContain('updateById');
209239
});
@@ -313,7 +343,10 @@ describe('validateActionBodyWrites — discarded ctx.record writes (#4345)', ()
313343
expect(findings[0].where).toBe('action "close_deal" › body');
314344
expect(findings[0].path).toBe('actions[0].body.source');
315345
expect(findings[0].message).toContain('ctx.record.stage');
316-
expect(findings[0].message).toContain("The snapshot stays read-only by design: an action's write channel is ctx.api.");
346+
// [#22161] Why the snapshot is not a write surface is the id's `os explain` text.
347+
expect(explanationOf(ACTION_RECORD_WRITE_DISCARDED)).toContain(
348+
"The snapshot stays read-only by design: an action's write channel is `ctx.api`.",
349+
);
317350
expect(findings[0].hint).toContain('updateById');
318351
});
319352

@@ -528,7 +561,7 @@ describe('[#8663] validateActionBodyWrites — unprovisioned anchor writes', ()
528561
expect(findings[0].where).toBe('action "stamp_owner" › body');
529562
expect(findings[0].path).toBe('actions[0].body.source');
530563
expect(findings[0].message).toContain("'owner_id'");
531-
expect(findings[0].message).toContain('external object (ADR-0015)');
564+
expect(findings[0].message).toContain("external object 'wh_order'");
532565
expect(findings[0].message).toContain('can never land');
533566
});
534567

@@ -592,3 +625,34 @@ describe('an unparseable action body is reported, not scored clean (#10653)', ()
592625
}
593626
});
594627
});
628+
629+
describe('[#22161] one-line verdicts — the rule ids this file shortened', () => {
630+
it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => {
631+
// The coverage control first: each shortened id fired at least once, so
632+
// the shape assertion below cannot pass over an empty record.
633+
expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort());
634+
for (const f of firedShortened) {
635+
expect(f.message, f.rule).not.toContain('\n');
636+
expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200);
637+
}
638+
});
639+
640+
// What each verdict stopped saying, which `os explain RULE_ID` now prints.
641+
const MOVED: Record<string, readonly string[]> = {
642+
[ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR]: ['external object', 'ADR-0015', 'PAST the write-path validator', 'no such column', 'schemaless remote'],
643+
[ACTION_BODY_WRITE_UNKNOWN_FIELD]: ['scoped handle on the running engine', 'ordinary CALLER write', 'before any statement is built', 'fails the action'],
644+
[ACTION_RECORD_WRITE_DISCARDED]: ['whether or not NAME is a declared field', 'read-only by design', 'provably dead'],
645+
[ACTION_BODY_SOURCE_UNPARSEABLE]: ['partially recovered', 'judged by no rule', 'action-api-update-readonly-when-field'],
646+
};
647+
648+
it('covers exactly the shortened ids', () => {
649+
expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort());
650+
});
651+
652+
it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict no longer says', (rule) => {
653+
const explanation = explainRule(rule);
654+
expect(explanation, `no \`os explain ${rule}\` entry`).toBeDefined();
655+
const text = explanation!.paragraphs.join('\n');
656+
for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact);
657+
});
658+
});

‎packages/lint/src/validate-action-body-writes.ts‎

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,16 @@
8383

8484
import { findClosestMatches, formatSuggestion } from '@objectstack/spec/shared';
8585

86-
import { describeParseFailure, PARSE_FAILURE_HINT } from './checked-parse.js';
87-
import {
88-
indexUnprovisionedAnchors,
89-
unprovisionedAnchorCause,
90-
unprovisionedAnchorHint,
91-
} from './system-fields.js';
86+
import { PARSE_FAILURE_HINT } from './checked-parse.js';
87+
import { indexUnprovisionedAnchors, unprovisionedAnchorHint } from './system-fields.js';
9288
import {
89+
bodyParseFailureVerdict,
9390
extractHookBodyWriteSet,
9491
indexObjectFields,
9592
judgeableFieldsOf,
9693
IMPLICIT_FIELDS,
97-
unprovisionedAnchorWriteConsequence,
94+
undeclaredApiWriteVerdict,
95+
unprovisionedAnchorWriteVerdict,
9896
HOOK_BODY_WRITE_PATTERNS,
9997
type BodyWritePatternExclusion,
10098
type HookBodyWritePattern,
@@ -341,9 +339,8 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
341339
rule: ACTION_BODY_SOURCE_UNPARSEABLE,
342340
where,
343341
path: site.path,
344-
message:
345-
`L2 body did not parse (${describeParseFailure(parseFailure)}), so its write set was read from a ` +
346-
`partially recovered tree — an undeclared field write in the unread part is not reported.`,
342+
// [#22161] The hook twin's sentence, from the one function both use.
343+
message: bodyParseFailureVerdict(parseFailure),
347344
hint: PARSE_FAILURE_HINT,
348345
});
349346
}
@@ -365,11 +362,13 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
365362
rule: ACTION_RECORD_WRITE_DISCARDED,
366363
where,
367364
path: site.path,
365+
// [#22161] One verdict sentence; that the snapshot is read-only by
366+
// design whether or not the field is declared, and why only a
367+
// provably dead write is reported, is
368+
// `os explain action-record-write-discarded`.
368369
message:
369-
`body assigns ctx.record.${w.field}, but an action's ctx.record is a plain snapshot the runtime ` +
370-
`never writes back — the action returns success and the assignment is discarded, whether or not ` +
371-
`'${w.field}' is a declared field. The snapshot stays read-only by design: an action's ` +
372-
`write channel is ctx.api.`,
370+
`body assigns ctx.record.${w.field}, but an action's ctx.record is a snapshot the runtime never ` +
371+
`writes back, so the assignment is discarded while the action returns success`,
373372
hint:
374373
`To persist it, write through the API: ctx.api.object('<object>').updateById(ctx.recordId, ` +
375374
`{ ${w.field}: … }). Reported only because ctx.record is never passed anywhere in this body — ` +
@@ -408,9 +407,7 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
408407
rule: ACTION_BODY_WRITE_UNPROVISIONED_ANCHOR,
409408
where,
410409
path: site.path,
411-
message:
412-
`body calls ctx.api.object('${w.object}').${w.method ?? 'update'}(…) writing '${w.field}', and ` +
413-
`${unprovisionedAnchorCause(w.object, w.field)} — ${unprovisionedAnchorWriteConsequence()}`,
410+
message: unprovisionedAnchorWriteVerdict(w.object, w.field, `the body's ${w.method ?? 'update'}(…) write`),
414411
hint: unprovisionedAnchorHint(w.object, w.field),
415412
});
416413
continue;
@@ -422,16 +419,12 @@ export function validateActionBodyWrites(stack: AnyRec): ActionBodyWriteFinding[
422419
rule: ACTION_BODY_WRITE_UNKNOWN_FIELD,
423420
where,
424421
path: site.path,
425-
message:
426-
`body calls ctx.api.object('${w.object}').${w.method ?? 'update'}(…) writing '${w.field}', but ` +
427-
// [#13858] Same door, same measurement as the hook sibling — ctx.api
428-
// is a ScopedContext over the running engine, so this payload is
429-
// CALLER-supplied and #8682/#8738 refuse it before any driver.
430-
`object '${w.object}' declares no such field. ctx.api is a scoped handle on the running ` +
431-
`engine, so the payload arrives as an ordinary CALLER write and the declared-field door ` +
432-
`REFUSES it at run time — INVALID_FIELD / 400, identically on every driver, before ` +
433-
`any statement is built. The write lands nothing, and the refusal escapes the body and ` +
434-
`fails the action.`,
422+
// [#13858] Same door, same measurement as the hook sibling — ctx.api
423+
// is a ScopedContext over the running engine, so this payload is
424+
// CALLER-supplied and #8682/#8738 refuse it before any driver.
425+
// [#22161] The hook sibling's verdict, from the one function both use;
426+
// the reasoning is `os explain action-body-write-unknown-field`.
427+
message: undeclaredApiWriteVerdict(w.object, w.method ?? 'update', w.field),
435428
hint: fixHint(w.field, [...known]),
436429
});
437430
}

0 commit comments

Comments
 (0)