Skip to content

Commit 9fe7448

Browse files
fix(spec,lint): one-line functional-completeness verdicts; os explain RULE_ID carries their reasoning (#22383)
Part of #22161 Clause-②: no Stage 2 of the card, first slice: the rule ids whose message lives in `packages/spec/src/kernel/functional-completeness.ts`. The card stays open for the later slices listed at the end. ## What changes - **One verdict line per finding.** Each of the nine rule ids the ADR-0078 completeness predicate emits now prints a `message` of one verdict sentence, at most 200 characters. The longest was 793. The verdict still names the runtime site that makes it true, because ADR-0078 §6 (the calibration section) keeps that citation in the finding's own message. The `fix` is unchanged. It is already one pastable line, and the registry's boot log prints it, so leaving it alone keeps that door's text as it was. - **The reasoning moves to `RULE_EXPLANATIONS`** (`packages/lint/src/rule-explanations.ts`): nine new entries, one per rule id. `os explain RULE_ID` prints them, and the CLI's `rule:` line now ends with `` — `os explain RULE_ID` for … `` for these ids. No CLI file changes. `explainPointer()` and `os explain` resolve any id the table holds. That was measured through the real printer and `packages/cli/test/explain-rule-id.test.ts`, which iterates every key (below). - **Every fact the old text stated is still reachable.** Each fact sits in the verdict, the `fix:` line or the explanation. For `view/layout-without-binding`, the keys the old per-type body said to declare are on the `fix:` line, which prints directly under the verdict. Their required/optional status and each type's measured renderer path are in the explanation. - **Nothing a rule accepts or refuses changes.** No severity, rule id, `path` or `fix` changes either. - **Tests.** - `functional-completeness.test.ts` (spec): every firing variant (18 fixtures, all nine ids) prints one line of at most 200 characters, with no newline in `message` or `fix`. The calendar/gantt/timeline/map key pins now read the `fix` line. Every other existing message pin still holds: the runtime file or symbol, the refusal screen names, the offending `key` = "value" entries, the cross-reference to `view/row-color-without-colors`, `no manual fire path exists`, `isActive`. - `validate-functional-completeness.test.ts` (lint): every id in `FUNCTIONAL_COMPLETENESS_RULES` has an explanation, and each explanation names what its verdict stopped saying. - `rule-explanations.test.ts` accepts the spec predicate's ids as rule id constants (they live in `@objectstack/spec/kernel`, not the lint barrel). ## Census (re-taken at `6a53564b9`, before the change) Every firing variant of every rule in the file, run through the predicate. Lengths are `message` alone; the printed line adds `where` and `: `. **All nine ids exceed 200 characters, and all nine are changed here.** Stage 1's list counted 8. It measured over the `packages/lint` suite only, where `view/row-color-unresolvable-value` (726) never fires. That id fires only in the spec suite. | rule id | firing variant (fixture) | severity | before | after | |---|---|---|---|---| | `field/summary-without-operations` | summary | error | 319 | 159 | | `field/formula-without-expression` | formula | error | 259 | 176 | | `field/relationship-without-reference` | lookup | error | 239 | 166 | | `field/relationship-without-reference` | master_detail | error | 246 | 173 | | `field/choice-without-options` | select | error | 250 | 170 | | `field/choice-without-options` | radio | error | 249 | 169 | | `field/choice-without-options` | checkboxes | warning | 291 | 175 | | `view/layout-without-binding` | kanban (no block) | warning | 248 | 142 | | `view/layout-without-binding` | calendar (no block) | warning | 636 | 170 | | `view/layout-without-binding` | gantt (no block) | warning | 673 | 155 | | `view/layout-without-binding` | timeline (no block) | warning | 568 | 151 | | `view/layout-without-binding` | map (no block) | warning | 638 | 152 | | `view/layout-without-binding` | tree (no block) | warning | 244 | 138 | | `view/layout-without-binding` | map (block, no coords) | warning | 570 | 189 | | `view/tree-without-parent-field` | tree (no parent) | warning | 592 | 190 | | `view/row-color-without-colors` | grid rowColor field=status, no colors | warning | 793 | 172 | | `view/row-color-unresolvable-value` | grid rowColor 1 hex value | warning | 726 | 194 | | `webhook/without-triggers` | webhook | error | 594 | 182 | ## Doors that print the new text Measured in-process at `b3598b011`: `runAuthoringRules` / `runRuntimeAuthoringRules` from the rebuilt `@objectstack/lint` dist, printed through `packages/cli/src/utils/format.ts`'s real printers. The protocol ordering was read from source. | door | which ids | what changes | |---|---|---| | `os validate`, `os build` (`os compile`, which `os dev` runs per compile), `os lint` | all nine (`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 verify`, `os init` scaffold check | same registry rules (`build`) | same printers | | runtime publish gate, `object` writes (Studio, REST `/meta`, MCP) | `field/*` only (`runtimeTypes: ['object']`; the gate's context holds objects, permissions and books, no views or webhooks) | `summary` and `formula` errors: the 422 issue `message`, and the hatch's refusal log line. `checkboxes` warning: the 2xx `advisories` `message`, and the deduped `[Protocol] authoring advisory` log line. `hint` unchanged | | same gate, `lookup`/`master_detail` without `reference`, `select`/`radio` without `options` | never reached | `saveMetaItem` runs the schema `safeParse` (`protocol.ts` ~`:20704`) before `runAuthoringGate` (~`:20890`), and `field.zod.ts` refuses both shapes with its own message, unchanged here | | registry boot line (`warnFunctionalCompleteness`, `packages/objectql/src/registry.ts`) | `field/*` | unchanged: it prints rule id and `fix`, never `message` | | webhook auto-enqueuer boot warning | `webhook/without-triggers` | unchanged: its own text | Measured printed shape (`os build` text face, rebuilt dist): ```text • object "my_app_ticket" › fields.total: A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) fix: summaryOperations: { object: 'CHILD_OBJECT', field: 'CHILD_FIELD', function: 'sum' } rule: field/summary-without-operations at objects[0].fields.total.summaryOperations — `os explain field/summary-without-operations` for why a summary field needs summaryOperations ⚠ view container [0] › list: A `calendar` view with no `calendar` block shows the "Calendar configuration required" screen on every object (`ObjectCalendar.tsx` — `getCalendarConfig` resolves `null`) fix: calendar: { startDateField: 'DATE_FIELD', titleField: 'TEXT_FIELD' } rule: view/layout-without-binding at views[0].list.calendar — `os explain view/layout-without-binding` for what each view type renders without its block ``` (The `fix:` placeholders are printed in angle brackets; they are spelled in capitals here because GitHub strips angle-bracket fragments from bodies.) ## Before and after, per rule #### `field/summary-without-operations` summary — before 319, after 159: ```text before: A `summary` field with no `summaryOperations` computes nothing: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`), so it reads 0/null everywhere and anything derived from it is stuck at 0 — while every authoring surface reports success. This is the shape ADR-0078 was written for. after: A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``` #### `field/formula-without-expression` formula — before 259, after 176: ```text before: A `formula` field with no `expression` never computes: the engine builds its formula plan only from fields that HAVE one (`engine.ts` — `if (def?.type === 'formula' && def.expression)`), so this field is permanently empty while parsing and publishing succeed. after: A `formula` field with no `expression` is always empty: the engine plans formulas only for fields that have one (`engine.ts` — `if (def?.type === 'formula' && def.expression)`) ``` #### `field/relationship-without-reference` lookup — before 239, after 166: ```text before: A `lookup` field with no `reference` is a relationship to nowhere: `$expand` silently skips it (`engine.ts` — `if (!referenceObject) continue`) and the record picker has no object to search, so the column stores raw ids that never resolve. after: A `lookup` field with no `reference` points nowhere: `$expand` skips it (`engine.ts` — `if (!referenceObject) continue`) and the record picker has no object to search ``` master_detail — before 246, after 173: ```text before: A `master_detail` field with no `reference` is a relationship to nowhere: `$expand` silently skips it (`engine.ts` — `if (!referenceObject) continue`) and the record picker has no object to search, so the column stores raw ids that never resolve. after: A `master_detail` field with no `reference` points nowhere: `$expand` skips it (`engine.ts` — `if (!referenceObject) continue`) and the record picker has no object to search ``` #### `field/choice-without-options` select — before 250, after 170: ```text before: A `select` field with no `options` is a choice with nothing to choose: the form control is empty AND server-side value validation is disabled (`record-validator.ts` skips the check when the allowed list is empty), so any value writes through the API. after: A `select` field with no `options` offers nothing to pick and validates nothing: `record-validator.ts` skips the value check on an empty list, so any value writes through ``` radio — before 249, after 169: ```text before: A `radio` field with no `options` is a choice with nothing to choose: the form control is empty AND server-side value validation is disabled (`record-validator.ts` skips the check when the allowed list is empty), so any value writes through the API. after: A `radio` field with no `options` offers nothing to pick and validates nothing: `record-validator.ts` skips the value check on an empty list, so any value writes through ``` checkboxes — before 291, after 175: ```text before: A `checkboxes` field with no `options` renders zero checkboxes. The validator's multi-value branch tolerates it as free-form (the `multiselect` tags mode), but a checkbox group is almost never meant to be free-form — declare the boxes, or use `multiselect` if free-form tags were the intent. after: A `checkboxes` field with no `options` renders zero checkboxes (`record-validator.ts` accepts it as free-form tags); declare the boxes, or use `multiselect` if tags were meant ``` #### `view/layout-without-binding` kanban (no block) — before 248, after 142: ```text before: A `kanban` view with no `kanban` block is bound to nothing: the renderer falls back to literal default field names, which works only if the object happens to declare them — on any other object the view renders empty while authoring reports success. after: A `kanban` view with no `kanban` block falls back to literal default field names, so it renders empty on any object that does not declare them ``` calendar (no block) — before 636, after 170: ```text before: A `calendar` view with no `calendar` block declares no date axis, and the renderer does not invent one: objectui's `ListView.tsx` calendar branch forwards only the bindings the view DECLARED, so `getCalendarConfig` (objectui `ObjectCalendar.tsx`) resolves `null` and the view renders its "Calendar configuration required" refusal screen instead of records — it parses and publishes clean, then shows no event on any object, not just on one that happens to lack a field. Declare `calendar.startDateField`, the block's one required key; the event title resolves through the ADR-0079 record display-name chain when `titleField` is omitted. after: A `calendar` view with no `calendar` block shows the "Calendar configuration required" screen on every object (`ObjectCalendar.tsx` — `getCalendarConfig` resolves `null`) ``` gantt (no block) — before 673, after 155: ```text before: A `gantt` view with no `gantt` block declares no date axis, and the renderer does not invent one: objectui's `ListView.tsx` gantt branch forwards only the bindings the view DECLARED, so `getGanttConfig` (objectui `ObjectGantt.tsx`) resolves `null` without both dates and the view renders its "Gantt configuration required" refusal screen instead of tasks — it parses and publishes clean, then shows no task on any object, not just on one that happens to lack a field. Declare `gantt.startDateField`, `gantt.endDateField` and `gantt.titleField`, the three keys `GanttConfigSchema` requires; `progressField` and `dependenciesField` are optional and stay unbound when omitted. after: A `gantt` view with no `gantt` block shows the "Gantt configuration required" screen on every object (`ObjectGantt.tsx` — `getGanttConfig` resolves `null`) ``` timeline (no block) — before 568, after 151: ```text before: A `timeline` view with no `timeline` block declares no date axis, and the renderer does not invent one: objectui's `ListView.tsx` timeline branch forwards a start date only when the view declared one, so `ObjectTimeline.tsx` resolves no date field and renders its "Timeline date axis required" refusal instead of records — it parses and publishes clean, then shows no item on any object. Only the title has a renderer default (`name`); the date axis never does. Declare `timeline.startDateField` and `timeline.titleField`, the two keys `TimelineConfigSchema` requires. after: A `timeline` view with no `timeline` block shows the "Timeline date axis required" screen on every object (`ObjectTimeline.tsx` resolves no date field) ``` map (no block) — before 638, after 152: ```text before: A `map` view with no `map` block declares no coordinate binding, and the renderer does not guess one: objectui's `ListView.tsx` map branch forwards only the keys the view declared, and `ObjectMap.tsx` no longer guesses `location` / `latitude` / `longitude` field names, so its `hasCoordinateBinding` gate fails and the view renders its "Map configuration required" refusal instead of markers — it parses and publishes clean, then plots nothing on any object. Declare `map.locationField`, or both `map.latitudeField` and `map.longitudeField`; `ListMapConfigSchema` requires neither form, so this warning is where the requirement is stated. after: A `map` view with no `map` block shows the "Map configuration required" screen on every object (`ObjectMap.tsx` — its `hasCoordinateBinding` gate fails) ``` tree (no block) — before 244, after 138: ```text before: A `tree` view with no `tree` block is bound to nothing: the renderer falls back to literal default field names, which works only if the object happens to declare them — on any other object the view renders empty while authoring reports success. after: A `tree` view with no `tree` block falls back to literal default field names, so it renders empty on any object that does not declare them ``` map (block, no coords) — before 570, after 189: ```text before: A `map` view whose `map` block declares neither `locationField` nor the `latitudeField`/`longitudeField` pair is bound to nothing, and the renderer does not guess: objectui `ObjectMap.tsx` applies its `hasCoordinateBinding` gate to a declared block exactly as to an absent one, so the view renders its "Map configuration required" refusal instead of markers — while the block parses and publishes clean, because `ListMapConfigSchema` requires neither form. Declare `map.locationField`, or both `map.latitudeField` and `map.longitudeField` (half a pair is not a binding). after: A `map` block with neither `map.locationField` nor both `map.latitudeField` and `map.longitudeField` shows the "Map configuration required" screen (`ObjectMap.tsx` — `hasCoordinateBinding`) ``` #### `view/tree-without-parent-field` tree (no parent) — before 592, after 190: ```text before: A `tree` view with no resolvable parent pointer renders FLAT, not empty: `parentField` is undeclared and the bound object declares neither a `tree` field (with no `reference`, or one naming this object) nor a lookup/master_detail back to itself, so the renderer's auto-detection finds nothing (objectui `ObjectTree.tsx` — `detectParentField`) and `buildForest` makes every record a root at depth 0. The result is a complete, correct-looking table with an expand slot that never opens, while authoring reports success. Declare `tree.parentField`, or add a self-referencing field to the object. after: A `tree` view with no resolvable parent pointer renders flat, every record at depth 0: `parentField` is undeclared and `detectParentField` (objectui `ObjectTree.tsx`) finds no self-reference ``` #### `view/row-color-without-colors` grid rowColor field=status, no colors — before 793, after 172: ```text before: A `grid` view whose `rowColor` binds `status` and declares no `colors` map never colours a row: the grid's row-className resolver returns before it reads a record (objectui `useRowColor.ts` — `if (!config?.field || !config.colors) return undefined`), so every row keeps the default background while parsing and publishing report success. An empty `colors: {}` is the same dead shape spelled out — it passes that guard and then matches no value. The map is what does the colouring; the field only says which value to look up. Each value is a colour NAME from the resolver's own vocabulary (`red`, `blue`, `slate`, …) or a complete Tailwind background class (`bg-red-200`) — a hex parses, publishes and silences this very rule while still colouring nothing (`view/row-color-unresolvable-value`). after: A `grid` view whose `rowColor` binds `status` and declares no `colors` map never colours a row (`useRowColor.ts` — `if (!config?.field || !config.colors) return undefined`) ``` #### `view/row-color-unresolvable-value` grid rowColor 1 hex value — before 726, after 194: ```text before: A `grid` view whose `rowColor` binds `status` declares 1 colour value the renderer resolves to nothing: `open` = "#ff0000". objectui `useRowColor.ts` — `colorToClass` — hands a `bg-`-prefixed literal through untouched and otherwise looks the lower-cased, trimmed value up in its own closed vocabulary of colour NAMES, returning `undefined` for everything else; Tailwind v4 has no runtime, so no class can be fabricated from a hex. A map like this CLEARS the `!config.colors` guard, so `view/row-color-without-colors` goes quiet, and every row still keeps its default background while parsing and publishing report success. Write a colour name (`red`, `blue`, `slate`, …) or a complete Tailwind background class (`bg-red-200`). after: A `grid` view's `rowColor` on `status` has 1 colour value the renderer resolves to nothing (`useRowColor.ts` — `colorToClass`), which silences `view/row-color-without-colors`: `open` = "#ff0000" ``` #### `webhook/without-triggers` webhook — before 594, after 182: ```text before: A webhook with no `triggers` never fires on any path. The auto-enqueuer drops it while building its subscription cache (`auto-enqueuer.ts` — `if (triggers.size === 0) … return null`), and there is no manual fire path to reach it either: `webhook.zod.ts` records that the `api` trigger was removed because "no manual fire path exists — the only webhook HTTP surface re-queues already-failed deliveries". The webhook materializes into `sys_webhook`, looks armed in Setup, and delivers nothing. To disable a webhook use `isActive: false`; an empty `triggers` is not an off switch, just a dead one. after: A webhook with no `triggers` never fires: `auto-enqueuer.ts` drops it (`if (triggers.size === 0) … return null`) and no manual fire path exists; to turn it off, use `isActive: false` ``` ## Tests and gates (all local; this branch) - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2`: `Test Files 680 passed (680)`, `Tests 19655 passed | 1 todo (19656)`, run at `7386db03f`. The only later commit, `b3598b011`, adds the changeset. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: `Test Files 128 passed (128)`, `Tests 5863 passed (5863)`, run at `7386db03f`. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 test/explain-rule-id.test.ts`: `12 passed`, against the rebuilt lint dist. `every rule the pointer can name resolves through the command to its own explanation` iterates all 11 keys, including the nine new ones. - `pnpm --filter @objectstack/lint run typecheck` and `pnpm --filter @objectstack/spec run typecheck`: exit 0. `check:test-typecheck` held at 2/6/2 (lint) and 52/246/135 (spec). - Builds: `turbo run build --filter=@objectstack/lint...` (spec, formula, sdui-parser, lint), then `--filter=@objectstack/core...` and `--filter=@objectstack/objectql...` for the dist-reading gates. `git status` stayed clean after each, with no generated artifact moved. The moved strings feed no generated file or docs table: no `.describe()` and no export changed, and `git grep` finds no copy of the old message text outside the predicate and its own tests. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b3598b011` derived 86 commands. The printed artifact-roster block adds 51 more (one duplicate), so 136 ran, each with its exit code captured before any pipe. - 131 exited 0. - `check:dual-build-cjs-loads` and `check:lean-entry-closure` first exited 3 (PREREQUISITE NOT MET, missing dists). After the builds above, both exited 0 on rerun (107 entries / 66 packages; 15 packages for `objectql/core`). - `check-partof-closing-keyword` exited 2 (NOT WIRED, no PR context). It was rerun with `PR_BODY` set to this body: exit 0, "this PR carries no Part-of/closing-keyword contradiction". - `check-closing-target-claim` and `check-single-claim-paths` exited 2 (NOT WIRED: they need `PR_NUMBER` and a token). NOT MEASURED locally; their guard workflows run them on this PR. - `dispatch-gates --ran`: exit 0, "86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED". The two NOT-WIRED roster guards are recorded as NOT-MEASURED rows outside the derived 86 - ESLint, narrowed to the diff: `npx eslint --no-inline-config --format json` over the 5 changed `.ts` files. The JSON report lists 5 files, 0 errors, 0 warnings, and none is ignored. `eslint.config.mjs` never enables type-aware linting (its comment at `:327`), so this diff cannot change the verdict on any untouched file. - Control-character scan of every changed file: no match. ## Next slices left on #22161 The card stays open. These are named here, not built: - 124 more author-time rule ids in `packages/lint` whose fired message exceeds about 200 characters (stage 1's "Second stage" list, with measured lengths). - 19 in `validate-expressions.ts`, `lint-flow-patterns.ts` and `validate-flow-template-paths.ts`, serial behind open PRs. - The action-governance boot-log lines in `packages/objectql/src/action-governance.ts` (`:568` / `:584`). ## Acceptance notes - **ADR-0078 §6 is kept, not reversed.** The ADR says each rule names its runtime skip site in the finding's own message, and its tests pin that. Every verdict here keeps that citation, so no superseding ADR is needed. - **Runtime 422 coverage was read from source, not booted.** The `lookup` / `select` refusal comes from the object schema's parse before the authoring gate. That conclusion is a source reading of `protocol.ts` and `field.zod.ts`. The lint-level gate itself still produces those findings, as measured with `runRuntimeAuthoringRules`. - **The `rule:` line is still long.** With the pointer it runs about 150 to 190 characters for these ids, for example `rule: view/layout-without-binding at views[0].list.calendar — ...`. That is stage 1's printer shape, unchanged here. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e030d43 commit 9fe7448

6 files changed

Lines changed: 375 additions & 102 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/lint": patch
4+
---
5+
6+
fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain <rule-id>` carries their reasoning
7+
8+
Clause-②: no
9+
10+
- **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead.
11+
- **`os explain <rule-id>` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. 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 nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries.
12+
- **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. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch.
13+
- **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate.

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,21 @@ import { dirname, join } from 'node:path';
1010
import { fileURLToPath } from 'node:url';
1111
import { describe, expect, it } from 'vitest';
1212

13+
import { FUNCTIONAL_COMPLETENESS_RULES } from '@objectstack/spec/kernel';
14+
1315
import * as indexBarrel from './index.js';
1416
import { RULE_EXPLANATIONS, explainRule } from './rule-explanations.js';
1517
import { CARRIER_ROOTS, CONSUMER_ROOTS, FIELD_NO_CONSUMERS } from './validate-field-consumers.js';
1618

1719
const HERE = dirname(fileURLToPath(import.meta.url));
1820

19-
/** Every rule id constant the root barrel exports, by value. */
21+
/**
22+
* Every rule id constant the root barrel exports, by value, plus the ids of the
23+
* functional-completeness predicate: those rules live in `@objectstack/spec/kernel`
24+
* and `validate-functional-completeness.ts` forwards their findings unchanged.
25+
*/
2026
function exportedRuleIds(): Set<string> {
21-
const ids = new Set<string>();
27+
const ids = new Set<string>(FUNCTIONAL_COMPLETENESS_RULES);
2228
for (const [name, value] of Object.entries(indexBarrel)) {
2329
if (/^[A-Z][A-Z0-9_]*$/.test(name) && typeof value === 'string') ids.add(value);
2430
}

0 commit comments

Comments
 (0)