Skip to content

Commit 61bea24

Browse files
fix(lint): one-line verdicts for the flow-trigger, react-page, dataset-measure and validation-rule compile rules; os explain RULE_ID carries their reasoning (#22700)
Part of #22161 Clause-②: no Stage 2 of the card, slice 5: the 15 rule ids of five whole `packages/lint` source files — the flow-trigger readiness rules, the react-page prop rules, the dataset measure / dimension rule and the two validation-rule compile rules. 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 15 ids prints a `message` of one verdict sentence. Every finding the rules' own suites fire is now 194 characters or fewer, and the runtime publish gate's suites (the whole `@objectstack/metadata-protocol` suite) 166 or fewer; the longest of each id was 211 to 1,049 before. - **The verdict no longer repeats what `where` and `path` name.** The validation rule and its object (`where`: `object 'account' · validation 'support_shape'`), the JSON Pointer of an unregistered `format` (`path`: `…schema#/properties/email/format`), the measure or dimension name (`where`: `dataset "x" › measure "y"`), and the source of an uncompilable `regex` (V8 echoes it as `/SOURCE/: `; the unchanged `fix:` line still quotes it). This follows slice 4's accepted design call. - **A quoted refusal is quoted to its verdict**, with the rest counted: - `TimeRelativeTriggerSchema`'s refusal of a descriptor: ONE issue, an unrecognized key first (its rename usually explains the others — a misspelled `field` is also the missing `dateField`), then `(and N more)`; the trigger's bind-time warn still prints the whole list. - ajv's metaschema refusal (`schema is invalid: …`): its first violation, which names the offending keyword, then `(and N more)`. - a `@objectstack/spec` unknown-key refusal (`Unrecognized key(s) on SURFACE: KEY.`): the key and the schema's rename (`Did you mean …?`), without the wrong-layer prescription bullet and the history sentence the producer appends after them. The producer orders its refusal "which key → how to fix it → why it used to be silent" so a one-line reader can stop early; the cut is `schemaRefusalHead()`, exported from `validate-flow-trigger-readiness.ts` and imported by `validate-react-page-props.ts` (not on the barrel). - an `ObjectChart` `aggregate.groupBy` union that matches no form: the forms whose complaint is about the value's CONTENT, dropping one whose only complaint is that the whole value is another type (the bare-field-name form's "expected string, received object", for an object), unless every form says only that (`unionRefusal()`; each arm's issue is still rendered by `describeIssue`). - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 15 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). The 60 entries already in the table are byte-equal at the head (measured: each entry's JSON, base vs head). - **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 five 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: - `declarerOf(verdict, baseObject, owner)` → `declarerOf(verdict, baseObject)`: string → string; a joined leaf reads `joined object "X"` (it read `object "X" (reached through this cube's join chain)`). - `jsonStoredDimensionFinding` loses `selector`; `refusedMeasureFinding` loses `accepted`; `MemberSite` loses `name` (the `where` still carries it). The `ACCEPTED_TYPES_BY_AGGREGATE.get(aggregate)` lookup stays at both call sites and still gates skip 5 (`if (!accepted) continue` / `return`), unchanged. - `validateRuleSchemaFormats`' pending entries and `validateRuleCompilability`'s walk destructuring drop `label` / `objectName`, which only fed the message. - The time-relative `problems` join is replaced by `descriptorRefusal(issues)`; the `fallback` ternary that picks the consequence is unchanged (its schedule arm's words drop "plain"). - No helper changed shape (no string → boolean); no truth table is involved. - `.changeset/22161-lint-slice-5-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. - **No rider:** no `packages/cli/test` or `packages/metadata-protocol` file asserts these ids' message text (grep: the consumers pin rule ids and `path`, never the message), so neither is touched. The verdict forms, one each, as the suites fired them (census below): ```text config.timeRelative never binds, so the flow never runs: Unrecognized key(s) on this flow start node's `config.timeRelative` descriptor: `field`. Did you mean `field` → `dateField`? (and 2 more) has config.timeRelative = "daily" (a string), not a descriptor object, so the sweep is never installed and the flow fires through its `config.schedule` cadence, dropping the descriptor has config.timeRelative = "daily" (a string), not a descriptor object, so the sweep is never installed and with no other trigger declared the flow binds to nothing and never fires declares type: 'record_change' but its start node's triggerType is 'onCreate', which the engine routes to no trigger, so the flow is demoted to a manual one and never fires binds the inbound api trigger (type: 'api' and start-node triggerType: 'api') but its start node declares no config.secret, so the engine refuses to register the flow triggerType is an array (["record-after-create","record-after-delete"]), but a start node takes one trigger event, so the flow binds to nothing and never fires drillDown: Unrecognized key(s) on this chart drill-down block: `maxrows`. Did you mean `maxrows` → `maxRows`? aggregate.groupBy matches no accepted form: Unrecognized key(s) on this chart groupBy: `dateGranularty`. Did you mean `dateGranularty` → `dateGranularity`? aggregate.groupBy "owner_id": 'owner_id' is an injected column with no storage on external object 'ext_customer', so the chart groups everything into one empty bucket TAG renders "record:related_list", which reads its record from a record page's context; a kind:'react' page mounts none, so the block renders empty kind:'react' source did not parse (line 1, column 11: Octal literals are not allowed. Use the syntax '0o755'.), so the component-contract checks may have missed problems in it aggregate "count_distinct" over field "account.region" (`json` on joined object "fx_branch") is refused by the aggregate × field-type table: the number would depend on the SQL dialect aggregate "count_distinct" over field "measured" (`select` with `multiple: true` on object "crm_opportunity") is refused: a list stored as JSON, which no two backends compare alike groups by field "grouped" (`composite` with `multiple: true` on object "crm_opportunity"), a structured-JSON value, so every query grouping by it is refused (400 INVALID_FIELD) ajv cannot compile `schema` (schema is invalid: data/type must be equal to one of the allowed values (and 2 more)), so the write path skips the rule and it enforces nothing on any record `regex` does not compile (Unterminated character class), so the write path skips the rule and it enforces nothing on any record `format: 'emial'` is not a registered format, so ajv drops the keyword and the rule enforces it on no record: every write is ACCEPTED ``` (`TAG` stands for the block's JSX tag, `RecordRelatedList` in angle brackets, which this page's sanitizer would eat.) ## Shared prose: written once (the dispatch's mechanism assumption 2) | shared sentence | ids | verdict clause (rule files) | explanation (`rule-explanations.ts`) | |---|---|---|---| | the never-fire outcome | `flow-trigger-unknown-event` (both arms), `flow-trigger-unroutable` (both arms), `flow-time-relative-descriptor-unroutable` (no-fallback arm) | `NEVER_FIRES` | — (each entry says what reports it, which differs) | | the engine's trigger routing chain | `flow-time-relative-descriptor-unroutable`, `flow-trigger-unroutable` | — | `FLOW_TRIGGER_ROUTING` | | the record-trigger token grammar | `flow-trigger-unknown-event`, `flow-trigger-unroutable` | — | `RECORD_TRIGGER_GRAMMAR` | | the time-relative routing predicate (the partition) | both `flow-time-relative-descriptor-*` | — | `TIME_RELATIVE_PARTITION` | | where the never-fire family gates | the four `validateFlowTriggerReadiness` ids | — | `FLOW_GATE_REACH` | | a forwarded spec refusal, cut to its verdict | `flow-time-relative-descriptor-invalid`, `react-chart-drilldown-invalid`, `react-chart-aggregate-invalid` | `schemaRefusalHead()` | `CHART_PROP_REFUSAL` (the two chart ids; the descriptor entry states its own) | | the write path's fail-open skip | `validation-rule-regex-uncompilable`, `validation-rule-json-schema-uncompilable` | `SKIPPED_ON_EVERY_WRITE` | `VALIDATION_RULE_FAIL_OPEN` | | the runtime's ajv environment | `validation-rule-json-schema-uncompilable`, `validation-rule-json-schema-unknown-format` | — | `RUNTIME_AJV_ENVIRONMENT` | | what a JSON-stored column is | `measure-aggregate-field-type-refused`, `dimension-json-stored-field-refused` | `declaredAs()` / `declarerOf()` (existing) | `JSON_STORED_COLUMN` | | the member walk, the cube leg and the skips | the same two | — | `ANALYTICS_MEMBER_WALK` | | what an unprovisioned anchor is | `react-chart-field-unprovisioned` and slice 2's `dashboard-filter-field-unprovisioned` | `unprovisionedAnchorVerdict()` (`system-fields.ts`, slice 2's short form) | `UNPROVISIONED_ANCHOR_CAUSE`, extracted verbatim from the dashboard entry (its text is byte-equal, measured) | The explanation module imports nothing, so the lists it writes out are held to their sources by the rules' tests: the aggregate × field-type rows to `AGGREGATE_FIELD_TYPE_COMPATIBILITY`, the JSON-stored types to `STRUCTURED_JSON_TYPES` / `MULTI_OPTION_TYPES` / `MULTI_CAPABLE_TYPES`, and each wrong-layer key an explanation names (`schedule`, `runAs`; `drilldown`, `mode`, `report`, `view`, `sort`; five aggregate and two `groupBy` keys) to the schema still answering it with a prescription. ## Census (taken first, before any edit, at the base `cb3bb9333f`) 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 files' 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,231 passed (the CLI closure build put the `dist/` its five dist-reading tests wait for in place). All 15 ids fired, every one over 200, from 19 push sites. The files' other ids were measured too and are all 197 or under (`react-chart-axis-unknown` 197, `flow-trigger-unknown-object` 156, `flow-draft-status-ambiguous` 161, `react-chart-field-unknown` 142, …); `react-prop-deprecated` fired in no suite, as slice 2 recorded. - **`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. **It fires none of the 15 ids.** Positive control, same preload: `test/validate-per-package-authoring-seam.test.ts` with `field-no-consumers` added to the recorded set records it. The integration tier is declared to CI (no file in it names these ids, and a grep for their fixtures' shapes finds none). - **The example corpus**, `os validate` (the source CLI) on `app-crm`, `app-todo`, `app-showcase` and `app-multi-package`: all exit 0 and **none of the 15 ids fires**; control: `flow-draft-status-ambiguous`, an id of `validate-flow-trigger-readiness.ts`, is recorded. - **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 reading was taken on a lint `dist/` built from the base text of the six slice sources (proved in `dist/`: the old marker in all four entry bundles, the new one in none), then the sources were restored blob-equal to HEAD and rebuilt (preflight: old marker absent, new present). It fires three ids. | rule id | lint suite before: messages · longest | lint suite after: messages · range | runtime-gate suite before → after | |---|---|---|---| | `flow-time-relative-descriptor-invalid` | 6 · 796 | 6 · 103–194 | — | | `flow-time-relative-descriptor-unroutable` | 11 · 532 | 11 · 169–184 | — | | `flow-trigger-unroutable` | 10 · 518 | 10 · 164–183 | — | | `flow-api-trigger-secret-missing` | 10 · 336 | 10 · 131–166 | 336 → 166 | | `flow-trigger-unknown-event` | 9 · 222 | 9 · 126–159 | — | | `react-chart-drilldown-invalid` | 5 · 784 | 5 · 54–109 | — | | `react-chart-aggregate-invalid` | 18 · 507 | 17 · 53–155 | — | | `react-chart-field-unprovisioned` | 1 · 429 | 1 · 166 | — | | `react-block-needs-record-context` | 6 · 267 | 6 · 148–163 | — | | `react-page-source-unparseable` | 2 · 211 | 2 · 133–175 | — | | `measure-aggregate-field-type-refused` | 382 · 809 | 364 · 155–183 | 504 → 160 | | `dimension-json-stored-field-refused` | 58 · 531 | 58 · 137–176 | — | | `validation-rule-json-schema-uncompilable` | 11 · 517 | 5 · 140–186 | 389 → 148 | | `validation-rule-regex-uncompilable` | 11 · 482 | 5 · 117–136 | — | | `validation-rule-json-schema-unknown-format` | 49 · 1,049 | 12 · 131–140 | — | All 19 push sites fire before and after. The message counts fall where the old message echoed what `where` or `path` carries: the validation rule's label and object, the format's pointer, the measure's name; `react-chart-aggregate-invalid` loses one because two refusals differed only in the echoed `(received …)` value after the dropped history sentence. ## Doors that print the new text Read from the registry (`authoring-rules.ts`: every entry is `commands: ALL`; `validateFlowTriggerReadiness` and `validateFlowApiTriggerSecret` are `CLI_AND_RUNTIME` with `runtimeTypes: ['flow']`, `validateDatasetMeasureAggregates` with `['dataset']`, `validateRuleCompilability` and `validateRuleSchemaFormats` with `['object']`; `validateReactPageProps` is a reference-integrity suite member with 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 15 | 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), `flow` writes | the 5 `flow-*` ids, all errors | the 422 issue `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line | | the same gate, `dataset` writes | the 2 dataset ids (a dataset write carries no `analyticsCubes`, so the cube leg speaks on the CLI only) | the same | | the same gate, `object` writes | the 3 `validation-rule-*` ids | the same | | never at the runtime gate | the 5 `react-*` ids | their rule runs there only on a flow write, whose snapshot carries no pages | | 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 (marker: `never binds, so the flow never runs` 1 hit each in `dist/index.js`, `index.cjs`, `runtime.js`, `runtime.cjs`; `reached through this` 0; `flow-time-relative-descriptor-invalid` 2 hits each in `dist/rule-explanations.js` and `.cjs`). - **Each rule's own suite pins the new shape**, as in slices 2–4. All five test files wrap their rule import 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). A second block per file pins, per id, that `explainRule(id)` exists and still names what the verdict stopped saying; the explanation lists are held to their spec sources (above). `schemaRefusalHead` has its own cases (rename kept, bullet and history dropped, one sentence quoted whole). The receiver meta-test in `validate-rule-compilability.test.ts` excuses the four new verdict-helper strings as plumbing. Every refusal assertion, rule id, severity, `path`, `where` and hint pin is unchanged; the measured-consequence pins (the audit and the banner count, zero diagnostics at any layer, the HMAC secret, the accepted-type rows, the analytics door, ADR-0015) moved from the message to the explanation. - **Lint suite:** `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` at `1a40fc23e9` (lint source byte-identical to the head): Test Files 135 passed (135), Tests 6,264 passed (6,264); lock VERDICT command-exit 0 (33 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. - **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 15 new ids resolve through `os explain` and `explainPointer`; `node bin/run-dev.js explain flow-trigger-unroutable` prints the entry. `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` gave Test Files 55 passed (55), Tests 971 passed (971); lock VERDICT command-exit 0, at `858cc21400` (the head). 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` or are comments. - **Tracker ids (mechanism assumption 4):** no `#` plus digits in any printed message or explanation (`rule-explanations.test.ts` refuses one in every paragraph); the citations stay in `//` comments. - **Ablation** (one-shot, from the committed state `858cc21400`, 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. `react-block-needs-record-context`'s pre-slice message was restored verbatim (the replacement text checked equal to the base file's) through the anchor of its new message: anchor x1 to x0, replacement x0 to x1, blob `9f1728087ba8` to `218b907d4de9`. Predicted before the run: exactly the bound pin goes red, since the old text still carries every fragment the other cases pin (`renders empty`, `record:activity`, `record:highlights`). Observed: `src/validate-react-page-props.test.ts` gave Test Files 1 failed (1), Tests 1 failed | 170 passed (171); the one red case was "every verdict the cases above fired is one line of at most 200 characters" (the restored `RecordDetails` verdict, 258 characters). Restored: blob after restore `9f1728087ba8` == blob at HEAD, `git diff HEAD` empty, `git status --porcelain` empty. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 11 changed `.ts` files gave 11 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 `858cc21400` (merge base `cb3bb9333f`), derived 62 commands. I ran 61 of them sequentially, each with its own log and its exit code captured before any pipe: all 61 exited 0. The 62nd, `pnpm check:dual-build-cjs-loads`, is NOT MEASURED, reason: the dispatch forbids the whole-workspace build it needs. `--ran` gave `Run reconciliation — 62 derived, 61 run, 0 NOT-MEASURED, 1 UNRUN` (the one above). I also ran the 52-family "Artifact rosters" block the same run prints, 49 of them at `858cc21400`: all exited 0 (14 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. `check:type-check-debt` ran without `--re-measure`. The long ones on the shared box: `check:type-check-debt` 431s, `check:query-options-erasure` 275s. ## Remaining for later slices — 56 ids in `packages/lint`, by file PR #22670's list minus this slice's five files and 15 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. - `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 - `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-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-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-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. `react-prop-deprecated` (`validate-react-page-props.ts`, about 225 characters by its template) fires in no suite and was outside the claim's 15 ids, so it is unchanged here; it belongs to whichever slice next takes that file's leftovers. ## Acceptance notes - **The `fix:` lines are unchanged, and two of them now point at text the verdict no longer carries in full.** `react-chart-drilldown-invalid`'s and `react-chart-aggregate-invalid`'s hint ends "the rejection above carries the fix", and `flow-time-relative-descriptor-invalid`'s says "satisfies each message above … an unrecognized key names the declared key it was probably meant to be". The rename survives in the verdict; a wrong-layer prescription (`schedule` / `runAs` on a descriptor, `drilldown` / `mode` / `report` / `view` / `sort` on a drill block, five aggregate and two `groupBy` keys) and the descriptor's other issues do not — the explanations name those keys, and the trigger's bind-time warn still prints the whole list. Whether hints get the one-line budget, and whether a forwarded prescription moves into the `fix:` line, is the card's own later call (slices 2–4's notes; slice 4's `sharing-rule-runtime-variable-condition` hint is 2,280 characters). - **A forwarded refusal can still lengthen the line.** The 200 bound holds on every variant the suites fire, but the quoted parts are the author's and the producer's: a long key name, a long `format` name, a long field path or a long engine reason lengthens the verdict. `flow-time-relative-descriptor-invalid`'s fixture sits at 194 because the spec's surface name ("this flow start node's `config.timeRelative` descriptor") is 55 characters of it. - **`schemaRefusalHead()` reads the producer's sentence shape** (`strictUnknownKeyError` in `@objectstack/spec`: the key sentence, then an optional `Did you mean …?`, then bullets and history). It cuts and never rewrites — the test holds that the head is a prefix of the schema's own sentence — and a refusal that does not have that shape is quoted to its first sentence or whole. `validate-component-props.ts` (`component-props-invalid`, 994, fenced in slice 2) forwards the same producer through `describeIssue`; its slice can lift `schemaRefusalHead` / `unionRefusal` into `zod-issue-format.ts` beside it, which this slice's file surface did not include. - **Process:** the CLI closure build held the shared lock 6m52s; the base `packages/cli` unit-tier census 9m42s; the showcase closure build (for the example corpus) 6m18s; each `@objectstack/metadata-protocol` census about 5m40s (the before leg 6m06s with its two lint builds); the spec `repo` project 15m02s. --- _Generated by [Claude Code](https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8830c2 commit 61bea24

12 files changed

Lines changed: 1194 additions & 226 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): the flow-trigger, react-page, dataset-measure and validation-rule 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 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites and the runtime publish gate's suites fire is at most 194 characters; before, the longest of each ran from 211 to 1,049 characters. The ids:
10+
- flow triggers (`flows[].nodes[]` start node): `flow-time-relative-descriptor-invalid`, `flow-time-relative-descriptor-unroutable`, `flow-trigger-unroutable`, `flow-api-trigger-secret-missing`, `flow-trigger-unknown-event`;
11+
- react pages (`pages[].source`): `react-chart-drilldown-invalid`, `react-chart-aggregate-invalid`, `react-chart-field-unprovisioned`, `react-block-needs-record-context`, `react-page-source-unparseable`;
12+
- dataset and cube members: `measure-aggregate-field-type-refused`, `dimension-json-stored-field-refused`;
13+
- object validation rules: `validation-rule-json-schema-uncompilable`, `validation-rule-regex-uncompilable`, `validation-rule-json-schema-unknown-format`.
14+
15+
A verdict no longer repeats what the finding's `where` and `path` already name: the validation rule and its object, the JSON Pointer of a `format`, the measure or dimension name, the source of an uncompilable `regex` (the `fix:` line still quotes it). A refusal the verdict quotes is quoted to its verdict. `TimeRelativeTriggerSchema`'s refusal of a descriptor is quoted for ONE issue (an unrecognized key first) and the rest are counted, `(and N more)`; ajv's metaschema refusal is quoted to its first violation, with the rest counted; a `@objectstack/spec` unknown-key refusal is quoted to the key and the schema's rename (`Did you mean …?`), without the wrong-layer prescription and the history sentence that follow it; and when no form of an `<ObjectChart aggregate>` `groupBy` union matches, the verdict keeps the forms whose complaint is about the value's content. A measure or dimension over a column its join chain reaches names a `joined object` (it read "(reached through this dataset's join chain)"). 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 15 ids**, for example `os explain flow-trigger-unroutable`. It prints the reasoning the verdicts no longer carry: the engine's trigger routing chain, the record-trigger token grammar and the time-relative routing predicate, what nothing reports at run time, and where the publish gate refuses the flow; the inbound api secret (ADR-0041) and why the engine refuses to register a flow without one; the write path's fail-open skip of a rule whose `regex` or `schema` does not compile, the runtime's ajv environment, and what ajv does with an unregistered `format` name; each aggregate's accepted field types (the `AGGREGATE_FIELD_TYPE_COMPATIBILITY` rows, written out), what a JSON-stored column is, and why analytics refuses to group by one; how a forwarded `<ObjectChart drillDown>` or `aggregate` refusal is quoted and which keys the schema answers with a prescription instead of a rename (`schedule` and `runAs` on a time-relative descriptor, `drilldown`, `mode`, `report`, `view` and `sort` on a drill block, five aggregate and two `groupBy` keys); what an unprovisioned anchor is; why a `record:*` block renders empty on a react page; and why an unparseable react source is a warning. 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 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries.
17+
- **Where the new text prints.** On the CLI, all 15 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): a `flow` write carries the five flow ids (all errors, refusing an active flow's publish), a `dataset` write the two dataset ids, and an `object` write the three validation-rule ids — the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged.
18+
- **Never at the runtime gate:** the five `react-*` ids. Their rule runs at that door only for a flow write, whose snapshot carries no pages, so they speak only on the CLI doors above.

0 commit comments

Comments
 (0)