Skip to content

Commit fdcc85b

Browse files
committed
lint: complete the hook-rule reach ledger — enumerate the intakes, pin the scaffold door
The reach annotation landed naming three `os *` commands. The doors are the call sites of `runAuthoringRules`, and there are more of them than there are commands: `os build` enters twice (the union run and the per-package run) and `runScaffoldAuthoringRules` — which `os init` / `dev` drive over a rendered template — is a fourth intake that lowers before it parses and has therefore reached this family all along. A ledger that omits a reached door mis-states coverage in the same direction the card is about. Both rule headers now enumerate every measured intake, each recorded with the body-authored control that makes its verdict readable, and the scaffold door gains a pin with that control beside it. The pin asserts `schemaError` is null before reading the verdict, so a stack that stops parsing cannot read as "no finding". No rule logic changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
1 parent 1182fdc commit fdcc85b

4 files changed

Lines changed: 86 additions & 19 deletions

File tree

.changeset/lint-hook-rules-reach-handler-authored-hooks.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `ho
1414
- Nothing about what `objectstack build` accepts changes, and `objectstack validate` — which parses without lowering — is unchanged and still does not see handler-authored hooks; both are recorded in the rules' headers.
1515
- The lint input is never mutated: rules that read the live function value (`hook-body/not-lowerable` and its siblings) keep seeing it, and a handler the extractor refuses has no body on any command, so no rule guesses about a body that was not produced.
1616

17-
`@objectstack/lint` carries only the header ledger recording which commands reach each hook rule; its behaviour is unchanged.
17+
Measured on this repository's own four example apps (`examples/app-crm`, `app-showcase`, `app-todo`, `app-multi-package`), before and after: **121 findings before, 121 after — row for row identical, and zero at `error` on both sides.** No config that passes today starts failing. Two of the six hooks in that corpus are `handler`-authored and were invisible to this family before; their bodies write nothing the family objects to, which is why the delta is zero rather than the family being unreached. The reach itself is pinned separately, with a body-authored control beside every leg.
18+
19+
`@objectstack/lint` carries only the header ledger recording which *intakes* reach each hook rule — the call sites of `runAuthoringRules`, which are more numerous than the three commands (the scaffold validator is a fourth, and it has always reached this family). Its behaviour is unchanged.

packages/cli/test/lint-hook-rules-reach-handler-hooks.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import { describe, expect, it } from 'vitest';
2121
import { normalizeStackInput } from '@objectstack/spec';
2222
import { lintConfig } from '../src/commands/lint';
23+
import { runScaffoldAuthoringRules } from '../src/utils/scaffold-validate';
2324

2425
const READONLY_RULE = 'hook-api-update-readonly-field';
2526
const READONLY_WHEN_RULE = 'hook-api-update-readonly-when-field';
@@ -199,3 +200,43 @@ describe('#16095 — the lowering is a VIEW for the registry, not a rewrite of t
199200
expect(issues.filter((i) => i.rule === READONLY_RULE)).toEqual([]);
200201
});
201202
});
203+
204+
/**
205+
* The FOURTH door, and the reason it is pinned here rather than left to the
206+
* three `os *` legs in the e2e sibling.
207+
*
208+
* `runScaffoldAuthoringRules` is a separate entry into the same registry —
209+
* `os init` / `dev` drive it over a freshly rendered template — and it is NOT
210+
* one of the three commands. It reaches the family, and always did, because it
211+
* lowers before it parses exactly as `compile.ts` does; nothing in this card
212+
* changed it. It is pinned because an unpinned reached door is the failure the
213+
* ledger in the two rule headers exists to prevent: a reachability claim
214+
* measured through one entry point is a claim about that entry point, not
215+
* about the rule. The control below is what makes the RED leg readable — if
216+
* the scaffold path ever stops lowering, the handler leg goes silent while the
217+
* body leg keeps firing, and that asymmetry is the signal.
218+
*/
219+
describe('#16095 — door: `runScaffoldAuthoringRules` (reached, and not one of the three commands)', () => {
220+
const scaffoldStack = (hook: Hook) => ({ objects: OBJECTS, hooks: [hook] });
221+
const familyOf = (hook: Hook) => {
222+
const report = runScaffoldAuthoringRules(scaffoldStack(hook));
223+
// A schema failure returns empty lists, which would read exactly like "no
224+
// finding" — assert the stack actually parsed before reading the verdict.
225+
expect(report.schemaError).toBeNull();
226+
return [...report.errors, ...report.advisories].filter((f) => f.rule === READONLY_RULE);
227+
};
228+
229+
it('INTAKE — the handler-authored hook IS judged here (it lowers before it parses)', () => {
230+
expect(
231+
familyOf(
232+
handlerHook('escalate', async (ctx: any) => {
233+
await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true });
234+
}),
235+
),
236+
).toHaveLength(1);
237+
});
238+
239+
it('CONTROL — the identical statement as an explicit `body` fires the same finding', () => {
240+
expect(familyOf(bodyHook('escalate_body', WRITE_READONLY_SOURCE))).toHaveLength(1);
241+
});
242+
});

packages/lint/src/validate-hook-body-writes.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -719,15 +719,27 @@ export function extractHookBodyWriteSet(source: string): ExtractedHookBodyWriteS
719719
* is a property of the DOOR — what each caller hands this function — not of
720720
* the rule. Measured (`packages/cli/test/lint-hook-rules-reach-handler-hooks*`):
721721
*
722-
* `os build` lowers every inline handler to a metadata body before its
723-
* parse (`lowerCallables`) — REACHED, always was.
724-
* `os lint` hands the registry's `parsed` tier that same lowered view —
725-
* REACHED since #16095.
726-
* `os validate` parses the normalized stack without lowering — NOT reached;
727-
* the body-authored control fires there. Changing that
728-
* changes what `os validate` refuses and is its own decision.
729-
* direct call judges exactly the stack it is given — NOT reached unless
730-
* the caller lowers first.
722+
* Every leg below was measured with the body-authored control beside it, so a
723+
* silent leg is a reading about that door and never about this rule. The doors
724+
* are the call sites of `runAuthoringRules`, enumerated — not the three `os *`
725+
* commands, which are fewer than the doors:
726+
*
727+
* `os build` union `compile.ts` lowers every inline handler to a
728+
* metadata body BEFORE its parse (`lowerCallables`)
729+
* and judges the parsed result — REACHED, always was.
730+
* `os build` per-package same lowered `result.data`, re-entered one package
731+
* manifest at a time — REACHED, always was.
732+
* `os lint` hands the registry's `parsed` tier that same
733+
* lowered view — REACHED since #16095.
734+
* scaffold validate `runScaffoldAuthoringRules` (`os init` / `dev` over
735+
* a rendered template) lowers before it parses too —
736+
* REACHED, always was, and pinned since #16095.
737+
* `os validate` parses the normalized stack WITHOUT lowering — NOT
738+
* reached; the body-authored control fires there.
739+
* Changing that changes what `os validate` refuses
740+
* and is its own decision, not this card's.
741+
* direct call judges exactly the stack it is given — NOT reached
742+
* unless the caller lowers first; measured both ways.
731743
*
732744
* A handler the extractor refuses (forbidden token, free identifier,
733745
* unparseable) is left with no `body` on every door, so this rule stays silent

packages/lint/src/validate-readonly-hook-writes.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -284,15 +284,27 @@ function isRec(v: unknown): v is AnyRec {
284284
* is a property of the DOOR — what each caller hands this function — not of
285285
* the rule. Measured (`packages/cli/test/lint-hook-rules-reach-handler-hooks*`):
286286
*
287-
* `os build` lowers every inline handler to a metadata body before its
288-
* parse (`lowerCallables`) — REACHED, always was.
289-
* `os lint` hands the registry's `parsed` tier that same lowered view —
290-
* REACHED since #16095.
291-
* `os validate` parses the normalized stack without lowering — NOT reached;
292-
* the body-authored control fires there. Changing that
293-
* changes what `os validate` refuses and is its own decision.
294-
* direct call judges exactly the stack it is given — NOT reached unless
295-
* the caller lowers first.
287+
* Every leg below was measured with the body-authored control beside it, so a
288+
* silent leg is a reading about that door and never about this rule. The doors
289+
* are the call sites of `runAuthoringRules`, enumerated — not the three `os *`
290+
* commands, which are fewer than the doors:
291+
*
292+
* `os build` union `compile.ts` lowers every inline handler to a
293+
* metadata body BEFORE its parse (`lowerCallables`)
294+
* and judges the parsed result — REACHED, always was.
295+
* `os build` per-package same lowered `result.data`, re-entered one package
296+
* manifest at a time — REACHED, always was.
297+
* `os lint` hands the registry's `parsed` tier that same
298+
* lowered view — REACHED since #16095.
299+
* scaffold validate `runScaffoldAuthoringRules` (`os init` / `dev` over
300+
* a rendered template) lowers before it parses too —
301+
* REACHED, always was, and pinned since #16095.
302+
* `os validate` parses the normalized stack WITHOUT lowering — NOT
303+
* reached; the body-authored control fires there.
304+
* Changing that changes what `os validate` refuses
305+
* and is its own decision, not this card's.
306+
* direct call judges exactly the stack it is given — NOT reached
307+
* unless the caller lowers first; measured both ways.
296308
*
297309
* A handler the extractor refuses (forbidden token, free identifier,
298310
* unparseable) is left with no `body` on every door, so this rule stays silent

0 commit comments

Comments
 (0)