Repository navigation
Commit 9fe7448
fix(spec,lint): one-line functional-completeness verdicts;
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>os explain RULE_ID carries their reasoning (#22383)1 parent e030d43 commit 9fe7448
6 files changed
Lines changed: 375 additions & 102 deletions
File tree
- .changeset
- packages
- lint/src
- spec/src/kernel
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
| 14 | + | |
13 | 15 | | |
14 | 16 | | |
15 | 17 | | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
19 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
20 | 26 | | |
21 | | - | |
| 27 | + | |
22 | 28 | | |
23 | 29 | | |
24 | 30 | | |
| |||
0 commit comments