Skip to content

Commit 4bee182

Browse files
os-zhuangclaude
andauthored
fix(cli): every author-time rule that can gate runs on all three commands (#4409) (#4445)
* fix(cli): every author-time rule that can gate runs on all three commands (#4409) `os validate`, `os build` and `os lint` each hand-wired their own subset of the author-time rules. Nothing connected the three lists, so "which rules run here?" was answerable only by diffing three 800-line files by eye — and the answer drifted every time a rule landed. The audit found 23 of 26 rules running on some strict subset, nine of them able to emit `error`. The worst direction was the least obvious: `os build` — the command that PUBLISHES — was the weakest gate of the three. A flow whose expression approver does not parse (`approval-expression-invalid`) built and published green; only `os lint` stopped it, and CI usually runs the other two. This is the same failure mode's fifth appearance (#3583, #3782, #4384/#4394, #4402). Each earlier repair removed an instance and left the MODE: a rule's command coverage was whatever its author remembered to type, and forgetting was silent. #4402's guard could not catch the rest — it filtered on the current member names of one suite, so a rule hand-wired into two commands from outside that suite passed it without a word. Replace remembering with a table: - `packages/cli/src/lint/authoring-rules.ts` declares all 26 rules as data — tier (gating/advisory), which stack tier they read (pre-parse vs parsed), which commands run them, and a written reason for the one narrowing. All three commands consume it through `runAuthoringRules()`; the three command files shrink by ~1000 lines between them. - `authoring-rule-wiring.test.ts` upgrades the guard from a name list to a ratchet: a gating rule on fewer than three commands fails, a narrowed rule with no reason fails, a command that calls a rule directly fails, and an `advisory` claim is checked against the rule's own source so a gate cannot wear an advisory label to buy partial coverage. Remaining direct calls are listed with reasons in `DIRECT_CALL_RATCHET` / `LINT_IMPORT_RATCHET`. - `authoring-rule-command-parity.test.ts` proves the verdict, not just the wiring: one case per previously-blind gating rule, plus the issue's own repro driven through the real CLI — exit 1 on all three commands where it was 1/0/0 before. Two things fall out of one report per run rather than exiting at the first failing gate: an author with three unrelated problems sees all three, and `--strict` now covers every advisory instead of the roughly half that happened to be printed inline. Also closes the same hole one gate over: `collectAndLintDocs` failed `os build` and never ran on `os validate`, invisible because the parity guard keyed on the `lint*`/`validate*` naming convention and that gate is called `collect*`. The guard now names each shared non-registry gate explicitly. Cost is not what argued against this: the heavy deps (typescript, sucrase) are already lazy, and the heaviest rule of the set has run on all three commands as a suite member since #4340 without anyone noticing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs * chore(changeset): author-time rule command-coverage registry (#4409) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs * docs: name the ADR-0085 pointers by field, not by the reserved word (#4409) The role-word ratchet (ADR-0090 D3) counts occurrences per file, and two doc lines describing `validateSemanticRoles` reintroduced the banned word for a non-permission concept. Name what the rule actually checks instead: `stageField` / `highlightFields` pointers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sz61GE1CWCSnGW4qShSEXs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 20bc357 commit 4bee182

12 files changed

Lines changed: 1623 additions & 1319 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
fix(cli): every author-time rule that can gate runs on all three commands (#4409)
6+
7+
`os validate`, `os build` and `os lint` each hand-wired their own subset of the
8+
author-time rules. Nothing connected the three lists, so "which rules run here?"
9+
was answerable only by diffing three 800-line files by eye — and the answer
10+
drifted every time a rule landed. The audit found 23 of 26 rules running on some
11+
strict subset, nine of them able to emit `error`.
12+
13+
The worst direction was the least obvious. `os build` — the command that
14+
PUBLISHES — was the weakest gate of the three: a flow whose expression approver
15+
does not parse (`approval-expression-invalid`) built and published green, and
16+
only `os lint` stopped it, while CI usually runs the other two. `os lint`
17+
disagreed in *both* directions at once, running one gating rule neither other
18+
command ran and missing six that both of them ran, which is worse than no
19+
pre-flight — the remaining options are re-verifying everything or learning to
20+
distrust the signal.
21+
22+
This is the same failure mode's fifth appearance (#3583, #3782, #4384/#4394,
23+
#4402). Each earlier repair removed an instance and left the MODE: a rule's
24+
command coverage was whatever its author remembered to type, and forgetting was
25+
silent. #4402's guard could not catch the rest — it filtered on the current
26+
member names of one suite, so a rule hand-wired into two commands from outside
27+
that suite passed it without a word. A name list only guards the names on it.
28+
29+
**The registry.** `AUTHORING_RULES` declares all 26 rules as data: tier
30+
(`gating`/`advisory`), which stack tier they read (pre-parse `normalized` vs
31+
`parsed`), which commands run them, and a written reason for the one narrowing.
32+
All three commands consume it through `runAuthoringRules()`, so adding a rule is
33+
a one-line edit that reaches every command at once. The three command files
34+
shrink by ~1000 lines between them.
35+
36+
**The ratchet.** The wiring guard is no longer a name list: a `gating` rule on
37+
fewer than three commands fails, a narrowed rule with no reason fails, a command
38+
that calls or imports a registry rule directly fails, and an `advisory` claim is
39+
checked against the rule's own source — so a gate cannot wear an advisory label
40+
to buy itself partial coverage. That last check is the one #3760 needed, having
41+
promoted a `lintFlowPatterns` rule from advisory to gating with nothing anywhere
42+
asking whether its coverage should follow. Remaining direct calls are listed
43+
with reasons, and a stale entry fails too, so the ratchet cannot rot into a
44+
permanent permission slip.
45+
46+
**The verdict, not just the wiring.** A separate test plants one defect per
47+
previously-blind gating rule and asserts all three commands gate on it, plus the
48+
issue's own repro driven end-to-end through the real CLI: exit 1 on all three
49+
where it was 1/0/0.
50+
51+
Two behaviour changes fall out of reporting every failing rule in one run
52+
instead of exiting at the first failing gate: an author with three unrelated
53+
problems now sees all three in one pass, and `--strict` covers every advisory
54+
rather than the roughly half that happened to be printed inline.
55+
56+
Also closes the same hole one gate over: `collectAndLintDocs` failed `os build`
57+
and never ran on `os validate`, invisible because the parity guard keyed on the
58+
`lint*`/`validate*` naming convention and that gate is called `collect*`. The
59+
guard now names each shared non-registry gate explicitly instead of
60+
pattern-matching for them.
61+
62+
Cost is not what argued against any of this. The heavy dependencies
63+
(`typescript` ~9 MB, `sucrase`) are already lazy and load only when a stack
64+
carries the metadata that needs them, and the heaviest rule of the set has run
65+
on all three commands as a reference-integrity suite member since #4340 without
66+
anyone noticing. The one narrowed rule, `lintUniqueDeclarations`, is scoped
67+
because `os lint` already reports it through `lintDataModel` — coverage
68+
recorded, not coverage missing.

content/docs/deployment/cli.mdx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,14 @@ os validate path/to/config # Validate specific file
435435
`dataset` / `dimensions` / `values` resolves to a declared dataset/field, so
436436
a dangling binding fails here instead of rendering an empty chart.
437437

438+
…and every other author-time rule the three commands share — view shape,
439+
name/action/filter references, page sources, approval approvers, security
440+
posture, the autonumber and view-reference lints. All of them come from one
441+
registry, so the list is the same on `os build` and `os lint`; see
442+
[The one gate, three entry points](/docs/deployment/validating-metadata#the-one-gate-three-entry-points)
443+
for the full matrix. Every failing rule is reported in a single run rather than
444+
stopping at the first, so one pass shows the whole hole.
445+
438446
**Options:**
439447
- `--strict` — Treat warnings as errors (exit code 1)
440448
- `--json` — Output results as JSON
@@ -444,13 +452,18 @@ os validate path/to/config # Validate specific file
444452
- Missing `manifest.namespace` (required for multi-app hosting)
445453
- No objects defined
446454
- No apps or plugins defined
455+
- Every advisory the rule registry raised (dangling `stageField` /
456+
`highlightFields` pointers, replay-unsafe seeds, ambiguous flow status,
457+
deprecated visibility aliases, …)
447458

448459
<Callout type="tip">
449-
`os validate` and `os build` share one validator, so a config that passes
450-
`os validate` will not fail the build on schema/predicate/binding grounds. In a
451-
scaffolded project these are wired as `npm run validate` and `npm run build`;
452-
your `AGENTS.md` tells coding agents to run `npm run validate` after editing
453-
metadata. See [Validating metadata](/docs/deployment/validating-metadata).
460+
`os validate`, `os build` and `os lint` share one rule registry, so a config that
461+
passes any of them will not fail another on schema/predicate/binding grounds — a
462+
CLI test fails the build if a rule that can gate runs on fewer than all three
463+
(#4409). In a scaffolded project these are wired as `npm run validate` and
464+
`npm run build`; your `AGENTS.md` tells coding agents to run `npm run validate`
465+
after editing metadata. See
466+
[Validating metadata](/docs/deployment/validating-metadata).
454467
</Callout>
455468

456469
#### `os info`
@@ -819,21 +832,32 @@ os create example my-app # Create examples/my-app
819832

820833
| Command | Description |
821834
|---------|-------------|
822-
| `os lint [config]` | Check metadata for style and convention issues (beyond `validate`'s hard gates) |
835+
| `os lint [config]` | Every author-time gate `validate`/`build` run, plus style and convention checks |
823836
| `os test [files]` | Run Quality Protocol test scenarios against a running server |
824837
| `os doctor` | Check development environment health |
825838

826839
#### `os lint`
827840

828-
Style and convention checks on top of `os validate` — naming, labels, translation coverage — with a 0-100 quality score:
841+
The cheapest of the three author-time commands. It runs the same rule registry
842+
`os validate` and `os build` run — so anything that can fail a build fails here
843+
too — and adds its own style rubric: naming, labels, namespace prefixes,
844+
data-model conventions, translation coverage, with a 0-100 quality score.
829845

830846
```bash
831-
os lint # Style / convention checks
847+
os lint # Author-time rules + style / convention checks
832848
os lint --score # Append a 0-100 metadata quality score (letter-graded)
833849
os lint --fix # Show what would be fixed (dry-run)
834850
os lint --json # JSON output for CI
835851
```
836852

853+
It does not replace `os validate`: `os lint` never parses the stack against the
854+
Zod schema (a schema error is `os validate`'s verdict to give), and it emits no
855+
artifact. What it does guarantee is the direction that matters for a pre-flight
856+
— a green `os lint` is not followed by a red `os build`. That was not true
857+
before #4409: `os lint` ran one gating rule neither other command ran and missed
858+
six that both of them ran, so it disagreed with the build in **both**
859+
directions.
860+
837861
#### `os test`
838862

839863
Runs Quality Protocol test scenarios (JSON-based BDD) against a running ObjectStack server.

content/docs/deployment/validating-metadata.mdx

Lines changed: 69 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -318,51 +318,67 @@ Skipped, to keep false positives at zero: the same set as §8 — non-static
318318
values, `{...spread}` usages, relationship paths, system fields, and objects
319319
another package defines.
320320

321-
## The one gate, two entry points
322-
323-
`os validate` and `os build` (alias of `os compile`) run the **same** validator:
324-
325-
| | `os validate` | `os build` |
326-
|---|---|---|
327-
| Protocol schema (Zod) |||
328-
| CEL / predicate validation |||
329-
| Widget-binding integrity |||
330-
| Dashboard action/route references (ADR-0049) |||
331-
| Object & action name references (#3583) |||
332-
| Page-component field bindings (#3583) |||
333-
| React page block field bindings — §10 (#4340) |||
334-
| Chart bindings outside dashboards (#3583) |||
335-
| Navigation vs. granted access (ADR-0090 D6) |||
336-
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) |||
337-
| Autonumber `{field}` interpolation |||
338-
| View references — form targets, view-key collisions (#2554) |||
339-
| Flow authoring anti-patterns (#1874) |||
340-
| Liveness author-warnings |||
341-
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) |||
342-
| Emits `dist/objectstack.json` |||
343-
344-
So `os validate` is the fast inner-loop check (no artifact); `os build` is what
345-
you run when you need the deployable artifact. A config that passes `os validate`
346-
will not fail `os build` on schema/predicate/binding grounds — a test in the CLI
347-
asserts that every gate `os build` runs is also run by `os validate`, so the two
348-
cannot drift apart again (#3782). Both entry points
349-
also check SDUI styling (ADR-0065), and `os validate` additionally runs a set of
350-
view- and page-SHAPE checks — list-view navigation modes (ADR-0053), view
351-
container shape, and whether a JSX/React page source parses at all
352-
(ADR-0080/0081) — that catch UI metadata which would otherwise be silently
353-
dropped.
354-
355-
The field bindings INSIDE a react page source are a different matter: they are
356-
reference-integrity, so they run wherever the suite runs. `os lint` gets them
357-
too — it shares the same `REFERENCE_INTEGRITY_RULES` list, which is why the
358-
table's reference rows are the ones a cheap pre-flight can rely on. That was not
359-
always true: the react-page prop gate was hand-wired into `os validate` alone
360-
until #4340's follow-up, so `os lint` and `os build` accepted a page whose every
361-
field binding was stale — the same divergence #4394 closed for readonly flow
362-
writes. A CLI test now asserts no command reaches for a suite member directly
363-
(#4384).
364-
365-
A clean run walks each gate and reports timing:
321+
## The one gate, three entry points
322+
323+
`os validate`, `os build` (alias of `os compile`) and `os lint` run the **same**
324+
author-time rules, from one table — `AUTHORING_RULES` in
325+
`packages/cli/src/lint/authoring-rules.ts`:
326+
327+
| | `os validate` | `os build` | `os lint` |
328+
|---|---|---|---|
329+
| Protocol schema (Zod) ||||
330+
| CEL / predicate validation (ADR-0032) ||||
331+
| List-view navigation modes (ADR-0053) ||||
332+
| View container shape ||||
333+
| Widget-binding integrity (ADR-0021) ||||
334+
| Dashboard action/route references (ADR-0049) ||||
335+
| Filter placeholder resolvability (#3574) ||||
336+
| Object & action name references (#3583) ||||
337+
| Page-component field bindings (#3583) ||||
338+
| React page block field bindings — §10 (#4340) ||||
339+
| Chart bindings outside dashboards (#3583) ||||
340+
| Navigation vs. granted access (ADR-0090 D6) ||||
341+
| SDUI scoped styling (ADR-0065) ||||
342+
| JSX / React page source parses (ADR-0080/0081) ||||
343+
| Approval-node approvers (ADR-0090 D3) ||||
344+
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) ||||
345+
| Organization-axis red lines (ADR-0105 D6) ||||
346+
| Autonumber `{field}` interpolation ||||
347+
| View references — form targets, view-key collisions (#2554) ||||
348+
| Flow authoring anti-patterns (#1874) ||||
349+
| Advisory: flow trigger wiring, record titles, semantic field pointers (ADR-0085), seed replay/state safety, capability references, liveness, visibility aliases ||||
350+
| Package docs — flatness, prefixes, links (ADR-0046) ||||
351+
| Undeclared authoring keys — every metadata collection (#3786) and the stack's own top-level keys (#4167) ||||
352+
| Naming, labels, data-model conventions, i18n coverage ||||
353+
| Emits `dist/objectstack.json` ||||
354+
355+
So `os validate` is the fast inner-loop check (no artifact), `os build` is what
356+
you run when you need the deployable artifact, and `os lint` adds its own style
357+
rubric on top. **Any rule that can fail a build runs on all three**, so a green
358+
`os lint` means the build's gates are green too, and a stack cannot be published
359+
through the one command that happens to skip a check.
360+
361+
Two rows are deliberately not universal, and both are one-directional (neither
362+
lets a stack through a gate another command enforces): the Zod parse and the
363+
undeclared-key diff need the pre-parse tier and the schema, which only the two
364+
commands that parse actually have; and `os lint`'s own rubric — snake_case
365+
names, missing labels, data-model conventions — is a lint verdict, not a publish
366+
gate. `os build` has never rejected a camelCase object name.
367+
368+
That invariant is enforced, not merely documented. Each rule declares its command
369+
coverage as data, and a CLI test fails if a rule that can emit `error` runs on
370+
fewer than all three, if a narrowed rule carries no written reason, or if any
371+
command reaches for a rule directly instead of going through the registry.
372+
373+
The enforcement exists because the contract drifted four separate times, and the
374+
last audit (#4409) found 23 of 26 rules running on some strict subset of the
375+
three — nine of them able to fail a build. The worst direction was the least
376+
obvious: **`os build` was the weakest of the three gates**, so it emitted an
377+
artifact for stacks the other two refuse. A flow whose expression approver did
378+
not parse built and published green; only `os lint` stopped it, and CI usually
379+
runs the other two.
380+
381+
A clean run walks the registry and reports timing:
366382

367383
```
368384
◆ Validate
@@ -371,19 +387,9 @@ A clean run walks each gate and reports timing:
371387
Config: /path/to/support-desk/objectstack.config.ts
372388
Load time: 21ms
373389
→ Validating against ObjectStack Protocol...
374-
→ Validating expressions (ADR-0032)...
375-
→ Checking list-view navigation modes (ADR-0053)...
376-
→ Checking view container shape...
377-
→ Checking dashboard widget bindings (ADR-0021)...
378-
→ Checking dashboard action references (ADR-0049)...
379-
→ Checking SDUI styling (ADR-0065)...
380-
→ Checking JSX-source pages (ADR-0080)...
381-
→ Checking React-source pages (ADR-0081)...
382-
→ Checking source-page styling (ADR-0065)...
383-
→ Checking capability references (ADR-0066)...
384-
→ Checking flow trigger wiring...
385-
→ Running authoring lints (#3782)...
386-
→ Checking security posture (ADR-0090 D7)...
390+
→ Running author-time rules (26)...
391+
→ Checking capability providers (#3366)...
392+
→ Checking package docs (ADR-0046)...
387393
388394
✓ Validation passed (64ms)
389395
@@ -397,9 +403,11 @@ see [the gate in action](/docs/getting-started/build-with-claude-code#4-the-gate
397403
for the bare-reference example verbatim.
398404

399405
<Callout type="info">
400-
`os lint` is a **separate** pass — style and convention checks (snake_case
401-
naming, required labels, namespace prefixes, data-model patterns). Run it too,
402-
but it does not replace `os validate`, and `os validate` does not replace it.
406+
`os lint` runs every gate above **plus** its own style rubric (snake_case
407+
naming, required labels, namespace prefixes, data-model patterns, translation
408+
coverage). It does not replace `os validate` — it never parses against the Zod
409+
schema, so a schema error is `os validate`'s verdict to give — but a rule that
410+
can fail the build fails `os lint` too.
403411
</Callout>
404412

405413
## The workflow

0 commit comments

Comments
 (0)