Skip to content

Commit 3476211

Browse files
committed
fix(cli,lint): run validateFormLayout, and close the rule registry from the other side (#4449)
`validateFormLayout` was implemented, unit-tested, exported and given published rule ids — and no command ever called it. A whole-repo search found the implementation, the barrel export line and its own unit test, and nothing else: the rule ran on zero stacks for as long as it existed. Two changes: * register it in `AUTHORING_RULES` as `advisory` on all three commands. It walks structured metadata only (no lazy dependency), so `os validate`, `os build` and `os lint` pay nothing measurable for it. * add the reverse closure to the wiring guard. Every invariant #4409 shipped starts FROM a registry and looks at the commands, which cannot see a rule that never entered a registry — the same blind spot as #4402's name list, one layer up. The guard now subtracts both registries from the `validate*` / `lint*` symbols on `@objectstack/lint`'s public barrel; the difference must be empty or ledgered with a reason in `UNWIRED_RULE_LEDGER`, which ships empty because today's difference was exactly this one rule. The new tests fail without the registry entry: the closure reports `validateFormLayout` as unwired, and the liveness test asserts the entry's own `run` adapter returns both findings for a stack that earns them — membership alone is not evidence a rule produces output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017gEHJN2NFpS9VMeURvakgD
1 parent 2b64e0e commit 3476211

3 files changed

Lines changed: 147 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/cli": minor
3+
---
4+
5+
Wire `validateFormLayout` into the authoring-rule registry, and close the
6+
registry from the other direction (#4449).
7+
8+
`validateFormLayout` was implemented, unit-tested, exported from
9+
`@objectstack/lint` and given published rule ids (`form-field-unknown`,
10+
`absolute-colspan-discouraged`) — and **no command ever called it**. It ran on
11+
zero stacks for as long as it existed, so a form section referencing a field
12+
that is not on the bound object, or pinning an absolute `colSpan` under a
13+
per-surface derived column count, produced no output anywhere. It is now an
14+
`advisory` entry in `AUTHORING_RULES`, so `os validate`, `os build` and
15+
`os lint` all run it. It is a pure structured-metadata walk with no lazy
16+
dependency, so all three commands pay nothing measurable.
17+
18+
The wiring guard (#4409) could not have found this. Every one of its invariants
19+
starts FROM a registry and looks at the commands, which is blind by construction
20+
to a rule that never entered a registry — the same shape as #4402's name list
21+
guarding only the names on it, one layer up. The guard now also runs the reverse
22+
subtraction: every `validate*` / `lint*` symbol on `@objectstack/lint`'s public
23+
barrel, minus `AUTHORING_RULES``REFERENCE_INTEGRITY_RULES`, must be empty or
24+
carry a written reason in `UNWIRED_RULE_LEDGER`. The ledger ships empty: today's
25+
difference was exactly this one rule.

packages/cli/src/commands/authoring-rule-wiring.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,36 @@ const REGISTRY_NAMES = new Set<string>([
120120
...REFERENCE_INTEGRITY_RULES.map((r) => r.name),
121121
]);
122122

123+
/**
124+
* Rules `@objectstack/lint` EXPORTS but that no authoring command runs, each
125+
* with the reason it is legitimately unwired (#4449).
126+
*
127+
* Empty, and that is the healthy state. An entry here is a written claim that
128+
* the rule has a consumer OTHER than the three commands (a Studio panel, an MCP
129+
* authoring surface) — not a parking space for one nobody got round to wiring.
130+
* Under ADR-0049 enforce-or-remove, a rule with no consumer at all is deleted,
131+
* not ledgered.
132+
*/
133+
const UNWIRED_RULE_LEDGER: Readonly<Record<string, string>> = {};
134+
135+
/**
136+
* Every `validate*` / `lint*` symbol the lint package's public barrel exports —
137+
* read from source for the same reason the call-site scans are: vitest inlines
138+
* imports, so the module namespace object cannot tell an exported RULE from an
139+
* exported helper the way the naming convention can.
140+
*/
141+
function exportedLintRules(): string[] {
142+
const source = readFileSync(join(repoRoot, 'packages/lint/src/index.ts'), 'utf8');
143+
const names = new Set<string>();
144+
for (const m of source.matchAll(/export\s+(?:type\s+)?\{([^}]*)\}/g)) {
145+
for (const raw of m[1].split(',')) {
146+
const name = raw.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()?.trim();
147+
if (name && /^(?:validate|lint)[A-Z]/.test(name)) names.add(name);
148+
}
149+
}
150+
return [...names].sort();
151+
}
152+
123153
/** Every `lintFoo(`/`validateFoo(` call site in a source file. */
124154
function ruleCallsIn(source: string): string[] {
125155
return [...new Set(source.match(/\b(?:lint|validate)[A-Z]\w*(?=\s*\()/g) ?? [])];
@@ -285,6 +315,76 @@ describe('authoring-rule registry wiring (#4409)', () => {
285315
).toEqual([]);
286316
});
287317

318+
// ── The other direction: a rule wired NOWHERE (#4449) ────────────────
319+
320+
/**
321+
* The invariants above all start FROM a registry and look at the commands.
322+
* That view is blind by construction to a rule that never entered a registry:
323+
* `validateFormLayout` was implemented, unit-tested, exported and given four
324+
* published rule ids, and ran on zero stacks for as long as it existed. The
325+
* closure is the reverse subtraction — exported rules MINUS both registries —
326+
* which is the shape #4402 (a name list guards only the names on it) and
327+
* #4409 (a registry guards only what entered it) each missed one layer down.
328+
*/
329+
it('every rule @objectstack/lint exports is wired into a registry', () => {
330+
const unwired = exportedLintRules()
331+
.filter((name) => !REGISTRY_NAMES.has(name))
332+
.filter((name) => !(name in UNWIRED_RULE_LEDGER));
333+
334+
expect(
335+
unwired,
336+
`@objectstack/lint exports ${unwired.length} rule(s) that no authoring command runs: ` +
337+
`${unwired.join(', ')}.\n` +
338+
`A rule on the public export surface reads — to a human and to an AI author alike — as a ` +
339+
`check the platform performs. Either register it in AUTHORING_RULES ` +
340+
`(packages/cli/src/lint/authoring-rules.ts) so all three commands run it, or add it to ` +
341+
`UNWIRED_RULE_LEDGER in this file WITH the real consumer that justifies it — or delete it ` +
342+
`under ADR-0049 enforce-or-remove. Advertising it while running it nowhere is the one option ` +
343+
`that is not available (Prime Directive #10).`,
344+
).toEqual([]);
345+
});
346+
347+
it('the form-layout rule really runs, and really finds something', () => {
348+
// The wiring assertion above proves membership. This proves the entry is
349+
// live end to end: the rule reaches all three commands AND its `run`
350+
// adapter returns the finding a broken stack earns. Both halves matter —
351+
// #4449 is precisely a rule that existed, passed its own unit tests, and
352+
// produced no output on any real stack.
353+
for (const command of AUTHORING_COMMANDS) {
354+
expect(
355+
authoringRulesFor(command).map((r) => r.name),
356+
`os ${command} must run validateFormLayout`,
357+
).toContain('validateFormLayout');
358+
}
359+
360+
const entry = AUTHORING_RULES.find((r) => r.name === 'validateFormLayout')!;
361+
const findings = entry.run(
362+
{
363+
objects: [{ name: 'widget', fields: { title: { type: 'text' } } }],
364+
views: [
365+
{
366+
name: 'widget_form',
367+
data: { object: 'widget' },
368+
sections: [{ fields: [{ field: 'no_such_field', colSpan: 2 }] }],
369+
},
370+
],
371+
},
372+
{},
373+
);
374+
expect(findings.map((f) => f.rule).sort()).toEqual([
375+
'absolute-colspan-discouraged',
376+
'form-field-unknown',
377+
]);
378+
});
379+
380+
it('every ledger entry is still an exported rule', () => {
381+
// Same anti-rot discipline as the two ratchets: an entry naming a rule that
382+
// no longer exists silently widens the allowance for the next one.
383+
const exported = new Set(exportedLintRules());
384+
const stale = Object.keys(UNWIRED_RULE_LEDGER).filter((n) => !exported.has(n));
385+
expect(stale, `UNWIRED_RULE_LEDGER entries no longer exported: ${stale.join(', ')}`).toEqual([]);
386+
});
387+
288388
// ── Guards the guard ─────────────────────────────────────────────────
289389

290390
it('the registry is non-empty and still holds the rules that motivated it', () => {
@@ -314,6 +414,12 @@ describe('authoring-rule registry wiring (#4409)', () => {
314414
expect(emitsError("severity: 'error',")).toBe(true);
315415
expect(emitsError("severity: 'error' | 'warning';")).toBe(false);
316416
expect(emitsError("if (f.severity === 'error') return;")).toBe(false);
417+
// The export scan feeding the unwired-rule closure: if it stops matching,
418+
// that set difference is empty for the wrong reason.
419+
const exported = exportedLintRules();
420+
expect(exported.length).toBeGreaterThan(20);
421+
expect(exported).toContain('validateReferenceIntegrity');
422+
expect(exported).toContain('validateFormLayout');
317423
});
318424

319425
it('every ratchet entry is still load-bearing', () => {

packages/cli/src/lint/authoring-rules.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ import {
8989
validateApprovalApprovers,
9090
validateRecordTitle,
9191
validateSemanticRoles,
92+
validateFormLayout,
9293
validateSeedReplaySafety,
9394
validateSeedStateMachine,
9495
validateVisibilityPredicates,
@@ -386,6 +387,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
386387
source: 'packages/lint/src/validate-semantic-roles.ts',
387388
run: (stack) => validateSemanticRoles(stack),
388389
},
390+
// #2578 / #4449 — a form section's field reference that resolves to nothing
391+
// (silently not rendered) and an absolute `colSpan` under a per-surface
392+
// derived column count. Advisory: the renderer skips the unknown field and
393+
// clamps the span, so nothing is broken — but each is almost certainly an
394+
// authoring mistake, and until #4449 this rule ran on no command at all.
395+
// Pure structured-metadata walk (no lazy dependency), so wiring it to all
396+
// three costs nothing measurable.
397+
{
398+
name: 'validateFormLayout',
399+
tier: 'advisory',
400+
input: 'parsed',
401+
commands: ALL,
402+
source: 'packages/lint/src/validate-form-layout.ts',
403+
run: (stack) => validateFormLayout(stack),
404+
},
389405
// ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no
390406
// `locations` and that no view places by name renders on no surface at all.
391407
// objectui#3142 made that measurable: four renderers used to show an

0 commit comments

Comments
 (0)