Skip to content

Commit 878e02f

Browse files
committed
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
1 parent 04f1182 commit 878e02f

11 files changed

Lines changed: 1554 additions & 1319 deletions

content/docs/deployment/cli.mdx

Lines changed: 31 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,17 @@ 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 semantic-role pointers,
456+
replay-unsafe seeds, ambiguous flow status, deprecated visibility aliases, …)
447457

448458
<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).
459+
`os validate`, `os build` and `os lint` share one rule registry, so a config that
460+
passes any of them will not fail another on schema/predicate/binding grounds — a
461+
CLI test fails the build if a rule that can gate runs on fewer than all three
462+
(#4409). In a scaffolded project these are wired as `npm run validate` and
463+
`npm run build`; your `AGENTS.md` tells coding agents to run `npm run validate`
464+
after editing metadata. See
465+
[Validating metadata](/docs/deployment/validating-metadata).
454466
</Callout>
455467

456468
#### `os info`
@@ -819,21 +831,32 @@ os create example my-app # Create examples/my-app
819831

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

826838
#### `os lint`
827839

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

830845
```bash
831-
os lint # Style / convention checks
846+
os lint # Author-time rules + style / convention checks
832847
os lint --score # Append a 0-100 metadata quality score (letter-graded)
833848
os lint --fix # Show what would be fixed (dry-run)
834849
os lint --json # JSON output for CI
835850
```
836851

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

839862
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 roles, 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)