Skip to content

Commit 33ae4bf

Browse files
fix(lint): one-line verdicts for the widget, dataset, security-posture and visibility rules; os explain RULE_ID carries their reasoning (#22448)
Part of #22161 Clause-②: no Stage 2 of the card, slice 2: 26 `packages/lint` rule ids, four 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 26 ids prints a `message` of one verdict sentence. Every finding the rules' own suites fire is now 197 characters or fewer (the longest of each id was 205 to 697 before). The values the author wrote still close the verdict: field paths, the candidate roster, the selection list, and the predicate excerpt, now capped at 72 characters. `visibility-predicate-over-budget` no longer echoes the predicate at all; its `path` locates it. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`), 26 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. Every fired message of every other rule id is byte-identical, measured below. - **One shared clause added:** `unprovisionedAnchorVerdict()` in `system-fields.ts` is the one-clause form of `unprovisionedAnchorCause()`, for a rule whose finding is one sentence (here `dashboard-filter-field-unprovisioned`). The eight rule files still on the long clause converge on it when their slices shorten them. - `.changeset/22161-lint-slice-2-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. The shape, through the CLI's real printers against the rebuilt `@objectstack/lint` dist (a scratch stack, `runAuthoringRules('build', …)`): ```text • dataset "deal_metrics" › dimension "region": dimension field "account.region" resolves, but its relationship prefix "account" is not declared in this dataset's `include`, so no join reaches the column fix: Add "account" to include (declaring "a.b" implicitly includes "a"), or bind this position to a field on "crm_deal" itself. Declared include paths: (none). rule: dataset-field-not-included at datasets[0].dimensions[1].field — `os explain dataset-field-not-included` for why a relationship prefix must be in include ⚠ dashboard "sales_board" › widget "deals_by_stage": 'bar' widget selects no dimensions, so the renderer draws a single KPI number instead of a 'bar' chart fix: Plot the chart against a dataset dimension — dimensions: ['NAME'] (declared dimensions: stage, region) — or, if a single value IS what this tile should show, declare it as a 'metric' or 'kpi' widget so the type matches what renders. Suppress with: suppressWarnings: ['chart-dimensions-missing'] rule: chart-dimensions-missing at dashboards[0].widgets[0] — `os explain chart-dimensions-missing` for why a chart with no dimensions draws one number ``` (The `fix:` line prints the rule's unchanged `hint`; its placeholder is spelled `NAME` here only because this page's sanitizer eats angle-bracket tokens.) ## Census (taken first, before any edit) Method: a scratch preload (`NODE_OPTIONS=--import`, never committed) recorded every finding-shaped object (`rule` + `message`) pushed into an array or produced by `map`/`flatMap`, deduped by (rule, message), with its push site, in every vitest worker and every process a test spawns. - **`packages/lint` suite** at `05c7c3fa3b` (the base): 128 files, 5,894 tests passed; 241 rule ids fired, 147 over 200 characters. - **`packages/cli` suite**, unit and integration, against the base's lint dist: 372 files, 4,961 passed / 2 skipped. It fires 75 ids. Rows from `os lint`'s own printer (`commands/lint.ts`), whose `message` is the printed `where: message` line, are excluded. It adds one over-200 lint id the lint suite never fires that long (`relationship/delete-behavior` 201), longer variants of five others (marked below), and nine over-200 ids owned by `packages/cli`. - Not measured: the `metadata-protocol`, `spec` and example suites (the functional-completeness ids were measured over the spec suite by slice 1). The census counts what some test fires; a variant no test fires is not in it, and `chart-config-missing`, `react-prop-deprecated`, `relationship/association-inline-edit` and `rollup/missing-summary` fired in neither suite. - Lengths are `message` alone; the printed line adds `where` and `: `. **Author-time ids over about 200 characters, after slice 1: 146 in `packages/lint`** (26 this slice, 26 in fenced files, 94 later), plus 9 in `packages/cli` and the action-governance boot-log lines in `packages/objectql/src/action-governance.ts` (named by stage 1, its own later slice). `lint-startup-registry-verdict.ts`'s two ids (779, 731) belong to the repo gate `check:startup-registry-verdict`, not to an author, and are not counted. ### This slice — 26 ids, before → after (every distinct message each id fires in the `packages/lint` suite) | rule id | file | severity | before: messages · longest | after: messages · shortest–longest | |---|---|---|---|---| | `dashboard-filter-field-unprovisioned` | `validate-widget-bindings.ts` | warning | 3 · 509 | 3 · 163–192 | | `chart-field-unknown` | `validate-widget-bindings.ts` | warning | 5 · 407 | 5 · 134–185 | | `dashboard-filter-field-not-included` | `validate-widget-bindings.ts` | error | 1 · 370 | 1 · 197–197 | | `widget-filter-field-unknown` | `validate-widget-bindings.ts` | error | 5 · 364 | 5 · 62–156 | | `dashboard-filter-field-unknown` | `validate-widget-bindings.ts` | error | 11 · 333 | 11 · 119–189 | | `chart-dimensions-missing` | `validate-widget-bindings.ts` | warning | 13 · 306 | 13 · 102–124 | | `widget-filter-field-not-included` | `validate-widget-bindings.ts` | error | 1 · 282 | 1 · 169–169 | | `widget-measures-missing` | `validate-widget-bindings.ts` | warning | 7 · 255 | 7 · 175–188 | | `widget-sortby-unselected` | `validate-widget-bindings.ts` | error | 2 · 242 | 2 · 177–191 | | `chart-measures-missing` | `validate-widget-bindings.ts` | warning | 13 · 224 | 13 · 170–181 | | `widget-legacy-analytics-unrenderable` | `validate-widget-bindings.ts` | error | 3 · 205 | 3 · 148–176 | | `security-controlled-by-parent-ambiguous-relation` | `validate-security-posture.ts` | error | 8 · 697 | 6 · 159–194 | | `security-fls-unknown-field` | `validate-security-posture.ts` | error | 7 · 593 | 7 · 173–197 | | `security-controlled-by-parent-no-relation` | `validate-security-posture.ts` | error | 7 · 526 | 2 · 165–165 | | `security-master-detail-ungranted` | `validate-security-posture.ts` | warning | 10 · 449 | 10 · 134–178 | | `security-owd-alias` | `validate-security-posture.ts` | error | 9 · 354 | 9 · 61–186 | | `security-delegation-missing-reason` | `validate-security-posture.ts` | error | 3 · 210 | 3 · 100–100 | | `visibility-predicate-unknown-function` | `validate-visibility-predicates.ts` | error | 9 · 655 | 9 · 156–190 | | `visibility-predicate-over-budget` | `validate-visibility-predicates.ts` | error | 6 · 507 | 4 · 156–163 | | `visibility-bare-identifier` | `validate-visibility-predicates.ts` | error | 23 · 411 | 23 · 135–169 | | `visibility-predicate-syntax` | `validate-visibility-predicates.ts` | error | 16 · 341 | 16 · 140–175 | | `visibility-root-mislayered` | `validate-visibility-predicates.ts` | warning | 4 · 304 | 4 · 167–169 | | `dataset-field-not-included` | `validate-dataset-references.ts` | error | 1 · 446 | 1 · 152–152 | | `dataset-field-unknown` | `validate-dataset-references.ts` | error | 13 · 296 | 13 · 59–100 | | `dataset-filter-field-unknown` | `validate-dataset-references.ts` | error | 5 · 294 | 5 · 55–98 | | `dataset-include-unknown` | `validate-dataset-references.ts` | error | 6 · 260 | 6 · 77–152 | The message counts fall for three ids because the old messages repeated the object's name (the two `controlled_by_parent` ids) or echoed the predicate (`visibility-predicate-over-budget`), which the finding's `where` and `path` already carry. ### Fenced off by the claim — 26 ids (open PRs and the serial flow-template queue) - `lint-flow-patterns.ts` (15): `flow-multi-write-unfiltered` 656, `flow-decision-mode-invalid` 528, `flow-loop-body-uncontained` 522, `flow-try-catch-without-catch` 520, `flow-approval-revise-target-not-service-owned` 366, `flow-decision-unconditional-branch` 342, `flow-error-label-not-fault` 315, `flow-inert-node-condition` 286, `flow-runas-unscoped` 284, `flow-branch-label-unmatched` 272, `flow-decision-inclusive-overlap` 250, `flow-default-edge-with-condition` 239, `flow-multiple-default-edges` 211, `flow-time-relative-antipattern` 208, `flow-date-equality-filter` 208 - `validate-flow-template-paths.ts` (3): `flow-template-field-unprovisioned` 440, `flow-template-lookup-traversal` 349, `flow-template-unknown-field` 258 - `validate-component-props.ts` (2): `component-props-invalid` 994, `component-props-unknown-key` 844 - `validate-page-field-bindings.ts` (2): `page-field-unprovisioned` 484, `page-section-group-unknown` 214 - `validate-expressions.ts` (1): `expression-invalid` 2077 - `validate-filter-tokens.ts` (1): `filter-token-unknown` 386 - `validate-form-layout.ts` (1): `form-section-group-unknown` 214 - `validate-print-page-blocks.ts` (1): `print-page-block-unprintable` 368 ### Remaining for later slices — 94 ids in `packages/lint`, by file (longest fired message; "cli" marks a length only the cli suite reached) - `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-action-body-writes.ts` (4): `action-body-write-unprovisioned-anchor` 778, `action-body-write-unknown-field` 433, `action-record-write-discarded` 293, `action-body-source-unparseable` 212 - `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-hook-body-writes.ts` (3): `hook-body-write-unprovisioned-anchor` 792, `hook-body-write-unknown-field` 527 cli, `hook-body-source-unparseable` 212 - `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-flow-node-writes.ts` (2): `flow-node-write-unprovisioned-anchor` 739, `flow-node-write-unknown-field` 421 - `validate-list-view-field-refs.ts` (2): `list-view-field-dotted` 445, `list-view-field-unknown` 355 - `validate-readonly-flow-writes.ts` (2): `flow-update-readonly-field` 410, `flow-update-readonly-when-field` 317 - `validate-readonly-hook-writes.ts` (2): `hook-api-update-readonly-field` 464, `hook-api-update-readonly-when-field` 323 cli - `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-readonly-action-writes.ts` (1): `action-api-update-readonly-when-field` 396 - `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 ### Outside `packages/lint` — 9 ids owned by `packages/cli` (fired by the cli suite) - `packages/cli/src/lint/hook-body-lowering.ts` (4): `hook-body/not-lowerable` 661, `hook-body/unparseable` 469, `hook-body/bundled-fallback` 334, `hook-body/extraction-failed` 331 - `packages/cli/src/utils/collect-docs.ts` (4): `docs/uncollected-directory` 558, `docs/duplicate-name` 330, `docs/nav-target` 299, `docs/frontmatter-tags` 285 - `packages/cli/src/utils/picklist-references.ts` (1): `picklist-reference-unverified` 245 ## Picking the slice Whole files, fenced files excluded, most over-200 ids first, at most 30: | file | over-200 ids | taken? | |---|---|---| | `validate-widget-bindings.ts` | 11 | yes | | `validate-security-posture.ts` | 6 | yes | | `data-model-rules.ts` | 6 (5 in the lint suite; the cli suite fires `relationship/delete-behavior` at 201) | no: `relationship/master-detail-required` and `rollup/non-numeric-aggregand` have no exported rule id constant, and a `RULE_EXPLANATIONS` key must be one (`rule-explanations.test.ts`). Exporting two new constants adds public surface, which is not `Clause-②: no`. | | `validate-visibility-predicates.ts` | 5 | yes | | `validate-flow-trigger-readiness.ts` | 5 | no: `flow-time-relative-descriptor-invalid` (796) ends with the time-relative trigger schema's own refusal prose, which lives in `packages/spec`, so it needs a spec edit | | `validate-react-page-props.ts` | 5 | no: `react-chart-drilldown-invalid` (784) and `react-chart-aggregate-invalid` (507) are the chart schemas' own refusal prose (`packages/spec/src/ui/chart.zod.ts`) | | `validate-rls-predicate-enforceability.ts` | 5 | no: each verdict quotes the whole predicate and composes a per-clause consequence from builders the five ids share (1,259 to 2,056 characters). That security-critical rework is a slice of its own. | | `validate-dataset-references.ts` | 4 | yes: the dataset half of the widget family (the same object-graph account and the same "compiled into the analytics query" consequence) | | `validate-action-body-writes.ts`, `validate-sharing-rule-enforceability.ts` | 4 each | no: the first shares its unprovisioned-anchor and unknown-field wording with `validate-hook-body-writes.ts` and `validate-flow-node-writes.ts`, a nine-id family to shorten together. The second shares the RLS consequence prose. | So the slice is 11 + 6 + 5 + 4 = **26 ids**. ## Doors that print the new text Read from the registry entries (`authoring-rules.ts`, `reference-integrity-suite.ts`) and the runtime gate (`metadata-protocol/src/runtime-authoring-gate.ts`, `lint/src/runtime-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 26 (every entry is `commands: ALL`) | 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` | | `os doctor` dashboard widget check | the 11 widget ids | prints `where: message`, no `rule:` line (see Acceptance notes) | | runtime publish gate, `dashboard` writes (Studio, REST `/meta`, MCP) | the 11 widget ids | errors: the 422 issue `message` and the `OS_ALLOW_UNLINTED_METADATA_WRITES` refusal log line. Warnings (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`): the 2xx `advisories` `message` and the deduped `[Protocol] authoring advisory` log line | | same gate, `dataset` writes | the 4 dataset ids (all errors) | the 422 issue `message`, the hatch log line | | same gate, `view` writes | the 5 visibility ids | four errors (the 422, the hatch log); `visibility-root-mislayered` is a warning (the 2xx `advisories`, the advisory log) | | same gate, `object` / `permission` / `seed` writes | `security-controlled-by-parent-*` (object), `security-fls-unknown-field` (permission), `security-master-detail-ungranted` (object or permission, a warning), `security-delegation-missing-reason` (seed) | as above, by severity | | never at the runtime gate | `security-owd-alias` | the object schema's closed OWD enums refuse those values at parse, with their own message, before the gate runs | | unchanged | every `hint`; every other rule id | | Every row is named in the changeset. ## Tests (round 0, head `7564f71b04`) - **Each rule's own suite pins the new shape.** The four test files record every finding their cases fire for the shortened ids, through a wrapper around the imported rule. A final case then holds each recorded verdict to one line of at most 200 characters, behind a coverage control that each shortened id fired at least once. So the pin covers every firing variant the suite exercises, not a chosen few, which is the gap slice 1's landing recorded. A second block pins, per id, that `explainRule(id)` exists and still names what the verdict stopped saying. Existing message pins now read the verdict's substance, the path, names and outcome, never the full prose; every refusal assertion is unchanged. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2` → Test Files 128 passed (128), Tests 5,928 passed; lock VERDICT command-exit 0. - `pnpm --filter @objectstack/lint run typecheck` → VERDICT command-exit 0. `check:test-typecheck` OK: 2 files, 6 errors and 2 pinned signatures held. - cli, after `pnpm --filter @objectstack/lint build`: `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 test/explain-rule-id.test.ts test/commands.test.ts` → 2 files, 62 passed. `explain-rule-id.test.ts` iterates every `RULE_EXPLANATIONS` key, so all 26 new ids resolve through `os explain` and through `explainPointer`. The two sets stay disjoint from the schema names. The rebuilt dist carries the new entries (marker grep: 1 hit each in `dist/rule-explanations.js` and `.cjs`). - Sibling suites that fire these ids, run although the public surface is byte-identical: - `metadata-protocol`, three runtime-gate files (`runtime-authoring-gate.dataset-writes`, `protocol-publish-drafts-closure`, `protocol.dashboard-dataset-publish-gate`) → 3 files, 21 passed; - `examples/app-showcase`, three files (`nav-and-detail-grants`, `dashboard-filter-vocabulary`, `my-work-visibility`) → 3 files, 14 passed, after building the showcase's dependency closure. - **Ablations**, one-shot and on committed state through `scripts/ablation-replace.mjs` (wrap mode, trap-armed). The tests import the rule source directly, so no dist leg applies. - (a) `chart-dimensions-missing`'s verdict lengthened to 240 characters (blob `c07dd85c` → `449306c6`) → `validate-widget-bindings.test.ts` 1 failed | 161 passed, the one-line case. - (b) `BY NAME` removed from the `chart-field-unknown` explanation (blob `da3574bc` → `7288a22f`) → 1 failed | 161 passed, that id's explanation case. - Both restored: blob == HEAD, `git diff HEAD` empty, `git status --porcelain` empty. - **Nothing else moved:** the census preload re-run over the whole lint suite at the head shows every fired message of every id outside the 26 byte-identical to the base. - **ESLint, narrowed:** `npx eslint --no-inline-config --format json` over the 11 changed `.ts` files → 11 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. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 commands from the 11 changed paths (merge base `05c7c3fa3`). All 62 ran sequentially, each with its own log and an exit code captured before any pipe; 61 exited 0 on the first run. `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET, because eight packages outside this tree's builds had no `dist/`; after building them it exited 0. `--ran` → `✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED`. A control-character scan of every changed file: no match. ## Patch round 1 (head `6258d37185`) - CI on `7564f71b04` went red in `@objectstack/spec`'s `repo` project: `retired-key-migrate-sentence.test.ts` walks `packages/lint/src` and judged the new `chart-field-unknown` explanation's sentence "`os migrate meta --from 17` lists the mechanical edits for existing sources" neither house-form nor MIXED (REWORK `6078055875` on #22161). - `77a246b85d` drops that sentence from the explanation; the house sentence still prints on the rule's xAxis fix line in `validate-widget-bindings.ts`. The rule's MOVED-fact pin now names "refused on a dataset-bound widget". - Spec `repo` project 54 files / 915 passed; with the old sentence restored, exactly the three `retired-key-migrate-sentence.test.ts` cases go red (3 failed / 912 passed). Lint suite 129 / 5,938. - `origin/main` merged twice (`2e5eb5aed3`, `6258d37185`); the net diff is unchanged in scope (12 files, +903 / −197). - Report `6079174263` on #22161; seat ACCEPT `6079204385`. ## Acceptance notes - **`os doctor` prints the widget ids without a `rule:` line.** Its dashboard check prints `where: message`, with the `hint` only under `--verbose`. It never named the rule id, and the `os explain` pointer stage 1 put on the other printers never reached it, so a doctor reader now sees the short verdict and no pointer. `os validate` on the same project prints the `rule:` line with the pointer. That is a `domain:cli` printer change, outside this slice's files, and is recorded rather than built. - **The `rule:` line with its pointer runs up to about 190 characters** for the longer ids (`security-controlled-by-parent-ambiguous-relation` plus its `covers`). This is stage 1's printer shape, carried as before. - **Author values extend a verdict.** The fixed text of every verdict is short; the field path, the candidate roster, the selection list and the predicate excerpt (capped at 72) are what an author needs, so they stay, and a long one lengthens the line. The changeset says so. - **`unprovisionedAnchorCause()` keeps its other callers.** Only `dashboard-filter-field-unprovisioned` moves to the new one-clause `unprovisionedAnchorVerdict()`; the eight other rule files converge when their slices shorten them, which the helper's docblock states. --- _Generated by [Claude Code](https://claude.ai/code/session_01VZqqwTj2wsihZEbfT6yyYN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c7a3234 commit 33ae4bf

12 files changed

Lines changed: 903 additions & 197 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): 26 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 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids:
10+
- dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`;
11+
- datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`;
12+
- security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`;
13+
- visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`.
14+
15+
The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. 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 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. 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 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 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`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged.
18+
- **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call).

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

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

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -923,8 +923,10 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
923923
expect(f!.severity).toBe('warning');
924924
expect(f!.path).toBe('dashboards[0].widgets[0]');
925925
expect(f!.message).toMatch(/not_a_dim/);
926-
// The message names the refusal, not a query that never runs.
927-
expect(f!.message).toContain('ignores an authored axis `field`');
926+
// The message names the refusal, not a query that never runs. [#22161]
927+
// The one-line verdict says the key is ignored; HOW the renderer strips
928+
// it is `os explain chart-field-unknown`.
929+
expect(f!.message).toContain('is ignored anyway');
928930
expect(f!.message).not.toContain('will not contain');
929931
// And the rule genuinely ran, rather than the door skipping the type.
930932
expect(result.rulesRun).toContain('validateWidgetBindings');

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,20 @@ export function unprovisionedAnchorCause(objectName: string, field: string): str
207207
);
208208
}
209209

210+
/**
211+
* [#22161] The SHORT form of {@link unprovisionedAnchorCause}, for a rule whose
212+
* finding is one verdict sentence: it names the same column and object and
213+
* says the same thing — an injected anchor with no storage behind it — in one
214+
* clause, and the rule's `os explain` entry (`rule-explanations.ts`) carries
215+
* the long cause (ADR-0015 federation, who owns the schema, why the anchor is
216+
* registered unprovisioned). One wording for every converted rule, so the
217+
* drift the long form's note warns about cannot reopen between them; a rule
218+
* still on the long form converges here when its message is shortened.
219+
*/
220+
export function unprovisionedAnchorVerdict(objectName: string, field: string): string {
221+
return `'${field}' is an injected column with no storage on external object '${objectName}'`;
222+
}
223+
210224
/**
211225
* The FIX clause paired with {@link unprovisionedAnchorCause} — the two ways
212226
* out, in the order an author should consider them: vouch for the remote column

‎packages/lint/src/validate-dataset-references.test.ts‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,33 @@
22

33
import { describe, it, expect } from 'vitest';
44
import {
5-
validateDatasetReferences,
5+
validateDatasetReferences as validateDatasetReferencesUnrecorded,
66
DATASET_INCLUDE_UNKNOWN,
77
DATASET_FIELD_UNKNOWN,
88
DATASET_FIELD_NOT_INCLUDED,
99
DATASET_FILTER_FIELD_UNKNOWN,
1010
} from './validate-dataset-references.js';
11+
import { explainRule } from './rule-explanations.js';
12+
13+
// [#22161] Each finding of the rule ids this file's rule shortened is one
14+
// verdict sentence; the reasoning it used to carry is the id's `os explain`
15+
// entry. Every call below records what it fired, and the last case in this
16+
// file holds each recorded verdict of those ids to one line of at most 200
17+
// characters — so the pin covers every firing variant this suite exercises,
18+
// not a chosen few. Run the whole file: that case reads what the cases above
19+
// fired.
20+
const SHORTENED_RULE_IDS: readonly string[] = [
21+
DATASET_INCLUDE_UNKNOWN,
22+
DATASET_FIELD_UNKNOWN,
23+
DATASET_FIELD_NOT_INCLUDED,
24+
DATASET_FILTER_FIELD_UNKNOWN,
25+
];
26+
const firedShortened: Array<{ rule: string; message: string }> = [];
27+
const validateDatasetReferences: typeof validateDatasetReferencesUnrecorded = (...args) => {
28+
const findings = validateDatasetReferencesUnrecorded(...args);
29+
for (const f of findings) if (SHORTENED_RULE_IDS.includes(f.rule)) firedShortened.push(f);
30+
return findings;
31+
};
1132

1233
/**
1334
* The object graph every case below resolves against. Deliberately a real
@@ -557,3 +578,34 @@ describe('validateDatasetReferences — the shipped dataset shapes', () => {
557578
).toEqual([]);
558579
});
559580
});
581+
582+
describe('[#22161] one-line verdicts — the rule ids this file shortened', () => {
583+
it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => {
584+
// The coverage control first: each shortened id fired at least once, so
585+
// the shape assertion below cannot pass over an empty record.
586+
expect([...new Set(firedShortened.map((f) => f.rule))].sort()).toEqual([...SHORTENED_RULE_IDS].sort());
587+
for (const f of firedShortened) {
588+
expect(f.message, f.rule).not.toContain('\n');
589+
expect(f.message.length, `${f.rule}: ${f.message}`).toBeLessThanOrEqual(200);
590+
}
591+
});
592+
593+
// What each verdict stopped saying, which `os explain RULE_ID` now prints.
594+
const MOVED: Record<string, readonly string[]> = {
595+
[DATASET_INCLUDE_UNKNOWN]: ['Joins are COMPILED', 'ADR-0071', 'addresses nothing'],
596+
[DATASET_FIELD_UNKNOWN]: ['compiled into the analytics query as written', 'empty or wrong numbers', 'nothing reports the miss', 'widget-dimension-unknown'],
597+
[DATASET_FIELD_NOT_INCLUDED]: ['joins ONLY', 'empty or wrong numbers', 'nothing reports the miss'],
598+
[DATASET_FILTER_FIELD_UNKNOWN]: ['widens the scope', 'empty or wrong numbers', 'filter-token-unknown'],
599+
};
600+
601+
it('covers exactly the shortened ids', () => {
602+
expect(Object.keys(MOVED).sort()).toEqual([...SHORTENED_RULE_IDS].sort());
603+
});
604+
605+
it.each([...SHORTENED_RULE_IDS])('`os explain %s` carries what its verdict no longer says', (rule) => {
606+
const explanation = explainRule(rule);
607+
expect(explanation, `no \`os explain ${rule}\` entry`).toBeDefined();
608+
const text = explanation!.paragraphs.join('\n');
609+
for (const fact of MOVED[rule]) expect(text, `${rule} explanation names ${fact}`).toContain(fact);
610+
});
611+
});

‎packages/lint/src/validate-dataset-references.ts‎

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,10 @@ function strName(v: unknown): string | undefined {
163163
return typeof v === 'string' && v.length > 0 ? v : undefined;
164164
}
165165

166-
/** The shared consequence sentence — why an unresolved path is not merely inert. */
167-
const SILENT_EMPTY =
168-
'The path is compiled into the analytics query as written, so it addresses a column ' +
169-
'that does not exist: the surface renders successfully with empty or wrong numbers, ' +
170-
'and nothing reports the miss.';
166+
// [#22161] Each finding is one verdict sentence plus its fix. Why an unresolved
167+
// path is not merely inert — it is compiled into the analytics query as written,
168+
// and the surface renders successfully with empty or wrong numbers — is each
169+
// rule id's long-form explanation (`rule-explanations.ts`, `os explain RULE_ID`).
171170

172171
/**
173172
* Validate every ADR-0021 dataset's references against the object graph.
@@ -232,8 +231,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
232231
message:
233232
`include[${ii}] "${entry}" names a` +
234233
`${type ? ` \`${type}\`` : 'n ordinary'} field on object "${verdict.object}", ` +
235-
`not a relationship — no join can be derived from it, so every dimension or ` +
236-
`measure written against that prefix addresses nothing.`,
234+
`not a relationship, so no join is derived from it`,
237235
hint: prescription,
238236
});
239237
return;
@@ -246,9 +244,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
246244
rule: DATASET_INCLUDE_UNKNOWN,
247245
where,
248246
path,
249-
message:
250-
`${account.message} Joins are COMPILED from \`include\` (ADR-0021), so an entry ` +
251-
`that resolves to nothing produces no join at all.`,
247+
message: account.message,
252248
hint: `${prescription} ${account.detail}`,
253249
});
254250
});
@@ -279,7 +275,7 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
279275
rule,
280276
where: positionWhere,
281277
path,
282-
message: `${account.message} ${SILENT_EMPTY}`,
278+
message: account.message,
283279
hint: `${prescription} ${account.detail}`,
284280
});
285281
return;
@@ -300,10 +296,8 @@ export function validateDatasetReferences(stack: AnyRec): DatasetRefFinding[] {
300296
where: positionWhere,
301297
path,
302298
message:
303-
`${subject} "${written}" resolves on the object graph, but its relationship ` +
304-
`prefix "${prefix}" is not declared in this dataset's \`include\` — and ADR-0021 ` +
305-
`joins ONLY declared paths, so no join is compiled and the column is out of the ` +
306-
`query's reach. ${SILENT_EMPTY}`,
299+
`${subject} "${written}" resolves, but its relationship prefix "${prefix}" is not ` +
300+
`declared in this dataset's \`include\`, so no join reaches the column`,
307301
hint:
308302
`Add "${prefix}" to include (declaring "a.b" implicitly includes "a"), or bind ` +
309303
`this position to a field on "${object}" itself. Declared include paths: ` +

0 commit comments

Comments
 (0)