fix(cli): os validate lowers inline handlers before its parse, so the hook write-set family judges handler-authored hooks there too - #16727
Conversation
…he hook write-set family judges handler-authored hooks there too
Every rule in the `hook-body-*` / `hook-api-update-readonly-*` family opens
on `body.language === 'js'`. `os validate` parsed the normalized stack
without lowering, so a hook authored as `handler: async (ctx) => { … }`
carried no body there and the family returned before reading anything:
`os validate` passed (exit 0, no finding) a stack `os build` refuses with
`hook-api-update-readonly-field`. The body-authored control fired on every
door, so the silence was the door, not the rule.
`validate.ts` now runs the same `lowerCallables` call `compile.ts` runs at
its step 2b — after the two pre-parse unknown-key lints, which keep reading
`normalized`, and before the parse, which reads `lowering.lowered` — and
hands the registry `parsed: result.data` as before. `lowerCallables` never
mutates its input, so the `normalized` tier, the stats and the structural
advisories are unchanged; the text face prints no new step.
The e2e pin's `os validate` leg flips from a measured "not lowered" reading
to a red-first intake leg, with the body-authored control unchanged beside
it and a negative control proving a handler-authored hook the family has
nothing to say about still passes. `lowerCallables` moves from the parity
pin's BUILD_ONLY_GATES to SHARED_NON_REGISTRY_GATES; the intake ledgers in
the two rule modules and the hook-bodies doc record the door as reached.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…arse (cli minor, lint minor) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 2 package(s): 17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5cec5325fcfd4ac612ea55ca8afca518a4c405de && git checkout 5cec5325fcfd4ac612ea55ca8afca518a4c405de
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37 c9727c1ce1b718aa9a2a8d793b85e892ce15aa9c && git checkout -B drift-repro 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37 && git merge --no-ff c9727c1ce1b718aa9a2a8d793b85e892ce15aa9c
node scripts/docs-audit/affected-docs.mjs --json 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37
|
…ts an inline action `target` callable, as `os build` always did The contract review on #16544 found the change is not a pure narrowing. `ActionSchema.target` is a string and `normalizeStackInput` never touches function values, so before this branch a plain-object config with `actions: [{ name, label, target: async (ctx) => { … } }]` was refused by `os validate` at the parse (`invalid_type` at `actions.0.target`, exit 1) while `os build`, which lowers before it parses, accepted it. The same `lowerCallables` pass now lowers it here too, so `os validate` accepts it — an accepted-set relaxation on a published command, measured through the real CLI on both sides (BASE validate exit 1 with two `invalid_type` issues; HEAD validate exit 0; build exit 0 on both) and now declared in the changeset, in the `validate.ts` comment, and pinned beside the hook legs in the e2e file with a build-parity leg next to it. Also: the stale twin sentence in `build-json-undeclared-key-parity.e2e.test.ts` ("validate lowers no handlers") is corrected, and the `validate.ts` comment now says what "mirrors `compile.ts`" is exact about (lower-before-parse) and what protects the key lints' input on both doors (non-mutation, not order). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
…ions` array entry, which the same pass names `anon_fn`
The re-review enumerated every callable slot `lowerCallables` rewrites and
found one more accepted-set relaxation on `os validate`: the `functions`
ARRAY form requires `name`, and `normalizeStackInput` never touches
`functions`, so `functions: [{ handler: async (ctx) => { … } }]` failed the
`functions` union at the un-lowered parse (exit 1) while `lowerBody` names
it `anon_fn` before `os build`'s parse. Measured through the real CLI on
both sides: BASE validate exit 1 `invalid_union@functions`; HEAD validate
exit 0 `valid=true`; build exit 0. Declared in the changeset's widening
bullet and the `validate.ts` comment, pinned as a third INTAKE leg in the
fourth `describe`. No code change; the `functions` map forms and
`hooks[*].handler` are not limbs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
Fixes #16544
Clause-②: yes
Every rule in the
hook-body-*/hook-api-update-readonly-*family opens onbody.language === 'js'. A hook authored ashandler: async (ctx) => { … }carries nobody, andos validateparsed the normalized stack without lowering — so on that door the whole family returned before reading anything, and a stackos buildrefuses withhook-api-update-readonly-field(exit 1) passedos validatewith exit 0 and no finding. The identical statement authored as an explicitbody: { language: 'js', source }was refused byos validateall along: the silence was the door, not the rule (#3782 / #4409 class, hook surface).validate.tsnow runs the samelowerCallablescallcompile.tsruns at its step 2b — not a copy — after the two pre-parse unknown-key lints (which keep readingnormalized) and before the schema parse (which readslowering.lowered), and hands the registryparsed: result.dataas before. Nothing about whatos buildaccepts changes; #13838 is untouched.This is not a pure narrowing. The contract review (card comment
5578119212) found a second limb going the other way, which round 1 had neither declared nor measured: the same pass lowers an inline actiontargetcallable, so a plain-object configos validateused to refuse at the parse is now accepted — parity withos build, which accepted it all along. It is measured through the real CLI below, declared in the changeset, and pinned beside the hook legs. TheClause-②: yesabove stands on that limb, on the reviewer's ground rather than triage's:Premise check against
origin/main(6ba0db4e0)os linthalf, lint: everyhook-body-*/hook-api-update-readonly-*rule is unreachable for a hook authored as ahandlerfunction — the whole family is gated onbody.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095) is onmainat0cde37d7— the PM's re-check holds on this tree. ItslintConfigwiring is reused as the reference for theparsed-tier semantics; no second lowering wiring was built.lowerCallablesnever mutates its input —packages/cli/src/utils/lower-callables.tsshallow-clones the top level and every slot it touches and returns a NEW object. So the triage's hard position (after the unknown-key lints, before the parse) is satisfiable with the same call and no helper extraction: the two key lints see byte-for-byte what they saw before, on every door.runAuthoringRules(command, { normalized, parsed?, sduiManifest? })(packages/lint/src/authoring-rules.ts),parsed: result.dataalready in place invalidate.ts;os buildand the scaffold door hand it the same lowered-then-parsed value.packages/cli/test/validate-build-gate-parity.test.tslistedlowerCallablesunderBUILD_ONLY_GATESwith the reason "there is nothing to lower when nothing is emitted". The family'sbody.language === 'js'opener is what there was to lower for. It moves toSHARED_NON_REGISTRY_GATES, so the pin now holds both doors to the call.os buildalready refuses; zero blast radius on the ten corpus files) was re-measured, and holds for the hook axis — table below. It said nothing about the action-targetaxis, which the review found and this round measures; the ten corpus files carry no inline actiontarget, so the 10/10 zero-delta was true and uninformative there, and the round-1 negative control was a hook and structurally could not see it. A control only controls for the axis it varies.Red-first, through the real CLI (the card's own control fixture)
packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts— theos validateleg flips from the #16095 "measured, NOT lowered" reading (exit 0, no finding) to a red intake leg. The file's own comment asked for exactly this: "the intake row becomes the control row — update the ledger in the file header, do not delete the pin."validate.ts(blobee42edd1b)7d82e8156)ctx.apiAssertionError: exit 0 — expected +0 to be 1hook-api-update-readonly-fieldbodyos buildrefusedos lint×2,os build×2 (unchanged by this card)Red leg:
1 failed, 6 passed (7), vitest exit 1. Green leg:7 passed (7),VERDICT command-exit 0. The BASE copy was written into the working tree only (git restore --source=BASE, never the index), proven landed by blob hash (ee42edd1b= BASE blob; anchorssafeParse(normalized)=1,lowerCallables(=0), restored bygit checkout HEAD -- PATHunder anEXIT INT TERMtrap, and restoration proven by blob hash (7d82e8156= HEAD blob) and an emptygit diff HEAD.The widening limb — measured red-first through the real CLI, then pinned
ActionSchema.targetisz.string().optional()(packages/spec/src/ui/action.zod.ts:1003) andnormalizeStackInputnever touches function values, so nothing lowered an inline actiontargetbeforeos validate's parse. Fixture: a plain-object config withactions: [{ name: 'ping_global', label: 'Ping', target: async (ctx) => { … } }]and the same onobjects[0].actions[0]— both slotslowerActionCallablehandles. Driven throughbin/run-dev.jswith the built closure, BASEvalidate.tswritten into the tree only (blobee42edd1b, anchorssafeParse(normalized)=1/lowerCallables(=0) and restored bygit checkout HEADunder anEXIT INT TERMtrap:validate.tsos validate --jsonvalid=false errors=2:invalid_type@objects.0.actions.0.target,invalid_type@actions.0.target(Zod: "Invalid input: expected string, received function")valid=true errors=0os build --jsonvalidate.ts: build does not run it; it lowered before its parse all along)valid=true errors=0⇒ Branch (b) of the rework: BASE refuses, HEAD accepts. An accepted-set relaxation on a published command, now named in the changeset ("Widening (actions)"), in the
validate.tscomment ("NOT A PURE NARROWING"), and pinned in the e2e file as a fourthdescribebeside the negative control — an INTAKE leg (validateexit 0,valid: true, noinvalid_type) and a PARITY leg (buildexit 0). Red-first on the same BASE/HEAD pair as the hook legs: on BASE the e2e file readsTests 2 failed | 7 passed (9)— exactly the two INTAKE legs (hook:expected +0 to be 1; action:expected 1 to be +0, with the twoinvalid_typeissues in the assertion output) — and on HEADTests 9 passed (9). ⛔ No code change was made for this: the parity is the intent; what was missing was the declaration and the pin.Blast radius — the ruling's ground, re-measured
os validate --jsonover all tenobjectstack.config.tscorpus files at6ba0db4e0, BASEvalidate.tsvs HEAD, comparing exit code, error text and the full rule-id list: identical on 10 of 10, zero lines differ.examples/app-crmexamples/app-multi-packageexamples/app-todopackages/plugins/plugin-authpackages/plugins/plugin-securitypackages/services/service-i18npackages/drivers/driver-memoryObjectStackManifest, not a stack: refused at the schema parse (top-levelunrecognized_keys), which is AFTER the lowering point — same issue both sidespackages/plugins/plugin-hono-serverexamples/app-showcasepackages/create-objectstack/…/blanknode_modules), BEFORE the lowering point — same both sidesSo eight of ten exercised the new pass (six through the registry, two through the parse) and none moved; the two that did not are environment limits, not readings about the door. This table is a reading about the hook axis only: none of the ten carries an inline action
target, so it cannot see the widening limb — that limb is pinned on its own fixture above. Outside this tree the affected population is the one #16095 measured (39 of 39 reference-CRM hooks are handler-authored), and every stack this narrowing refuses is oneos buildrefuses today with the same finding.Evidence
pnpm --filter "@objectstack/cli^..." build,VERDICT command-exit 0) — the spawned CLI resolves@objectstack/spec/@objectstack/lintthroughexportstodist/.pnpm --filter @objectstack/cli typecheck→ exit 0;pnpm --filter @objectstack/lint typecheck→ exit 0 (each captured from its own redirect).packages/cliunit tier:pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2→Test Files 3 failed | 181 passed (184),Tests 2470 passed | 6 expected fail | 40 skipped (2516)— zero failing tests; the three file-level failures were twoPREREQUISITE NOT MET(the twopublished-subpath-*.pintests refuse whenpackages/cli/distis absent — their own remedy ispnpm --filter @objectstack/cli build) and one 10 s hook timeout insrc/commands/datasource/envelope-unwrap.test.tson a shared box running the gate sweep alongside. Afterpnpm --filter @objectstack/cli build(exit 0, gated by&&) the three re-ran:Test Files 3 passed (3),Tests 40 passed (40).OS_TEST_TIERS=nightly … --project integration, 7/7); the rest of the integration and nightly tiers are declared to CI.node scripts/pm/dispatch-gates.mjs --commands, reconciled with--ran): 86 derived (77 from the code diff, 9 more once the changeset existed), 86 run, 86 measured green, 0 NOT-MEASURED, 0 UNRUN (dispatch-gates --ran: "86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED"). Six first stopped at their ownPREREQUISITE NOT METand were cleared by building what they named, never routed around:check:i18n/check:i18n-walk-parity(the workspace CLI,pnpm --filter @objectstack/cli build),check:i18n-coverage(its printed 14-package closure — thenOK (13 config(s), 621 baselined untranslated string(s), none new), i.e. the real CLI over 13 configs with thisvalidate.ts),check:dual-build-cjs-loads(same closure; 104 entry points across 67 packages load),check:skill-examples(@objectstack/client-reactbuilt; 258 prose examples type-check), andcheck:type-check-debt, which first raced a concurrent build ofpackages/client(TS7016 on a half-emitteddist) and then OOMed its whole-workspacetscat the 4096 MB heap I cap heavy commands to — re-run once at 6144 MB under the verify lock:command-exit 0, coverage OK 76/80.pnpm check:nul-bytes→ exit 0 (owed on any edit).f974fd2769): red leg on BASEvalidate.tsTests 2 failed | 7 passed (9)(the two INTAKE legs, nothing else), restoration proven by blob hash2d9f18a1f= HEAD blob and emptygit diff HEAD; green legTests 9 passed (9); the other touched integration filetest/build-json-undeclared-key-parity.e2e.test.ts(comment-only change)Tests 6 passed (6);pnpm --filter @objectstack/cli typecheckexit 0. Gate families derived from the four patch-round paths: 58 (node scripts/pm/dispatch-gates.mjs --commandsover the four changed paths atf974fd2769; all 58 are members of round 1's 86) — 58 run, 58 green, 0 NOT-MEASURED, 0 UNRUN. One first stopped at its ownPREREQUISITE NOT MET(check:dual-build-cjs-loads, namingstudio/embedder-openai/knowledge-ragflow/organizations/service-cluster-redisdists) and passed aftercheck:type-check-debt's internal tree build (run atNODE_OPTIONS=--max-old-space-size=6144under the verify lock as in round 1: coverage OK 76/80);check:i18n-coverageran green on its rebuilt 14-package closure (OK (13 config(s), 621 baselined untranslated string(s), none new)). The 28 families of round 1 not re-derived here derive from the five files this round did not touch.464c70d96(the code-diff gate sweep ran on the working tree of5b4f49bd6with the changeset already on disk — byte-identical content to464c70d96; everything derived from the changeset, every re-run, the parity and unit readings, and the reconciliation ran at464c70d96).Deviations from the dispatch's declared file surface
The claim named
packages/cli/src/commands/validate.tsplus the e2e test. Five more files move, each because this change makes its text false or its pin stale — none changes behaviour:packages/cli/test/validate-build-gate-parity.test.ts—lowerCallablesfromBUILD_ONLY_GATEStoSHARED_NON_REGISTRY_GATES(the pin whose reason was measured false).packages/lint/src/validate-readonly-hook-writes.ts,packages/lint/src/validate-hook-body-writes.ts— the lint: everyhook-body-*/hook-api-update-readonly-*rule is unreachable for a hook authored as ahandlerfunction — the whole family is gated onbody.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095 intake ledgers:os validatefrom "NOT reached" to "REACHED sinceos validateparses the normalized stack WITHOUT lowering inline handlers, so the hook write-set family (hook-api-update-readonly-fieldand siblings) cannot see a handler-authored hook there —os validatepasses a stackos buildrefuses #16544". Comment-only.content/docs/automation/hook-bodies.mdx— the one sentence that saidobjectstack validateparses without lowering.packages/cli/src/commands/compile.ts— one comment clause ("that command lowers no handlers") in thebodyExtractionWarningspayload note; its conclusion (the key stays build's) is unchanged and still true.packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts— the twin of that clause ("validate lowers no handlers"), missed in round 1 and named by the review; corrected in the patch round, conclusion unchanged.Holder measurement before widening (round 1, seven files; the twin file was added in the patch round on the review's instruction): zero holders on all seven files across 13 of 13 open PRs (every
/pulls/N/filesread 200), positive control fired on both expected PRs (#16380 →packages/adapters/hono/src/index.ts, #16573 →AGENTS.md).@objectstack/lintis gradedminoralthough only comments underpackages/lint/src/**move: this PR declaresClause-②: yes, andcheck-changeset-no-major's level axis refusespatchon any package whosepackages/*/src/**the PR moves — the same reading #16564 landed under.Deliberately not done
os buildfail by default on the accidental hook-body-lowering class? — deferred until the new lint rule has produced a real population number #13838 (os buildacceptance) — untouched, as the card marks it.targetfrom the lowering would re-open the build/validate disagreement one slot over.bodyExtractionWarningsare not surfaced byos validate— publishing them would add a key to its--jsonpayload, which is its own contract decision;compile.tsrecords why the key is build's alone. A handler the extractor refuses has no body on any door and is reported byos lint'shook-body/*rules.os validatetranscripts verbatim; the pass is a view for the parse, not a verdict of its own, so the text face is byte-for-byte what it was.os validaterefuses — pre-existing onorigin/main, unrelated to hooks; noted in the report, not filed (a plugin manifest is not a stack, and no PR or seat is known to be heading for those files).Changeset
.changeset/validate-lowers-inline-handlers-before-parse.md—@objectstack/cli: minor,@objectstack/lint: minor.验收备注
os validatedoor now refuses a handler-authored readonly write throughctx.api— exactly the setos build(always) andos lint(since lint: everyhook-body-*/hook-api-update-readonly-*rule is unreachable for a hook authored as ahandlerfunction — the whole family is gated onbody.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095) refuse.--strictsemantics are unchanged: the warning-severity members of the family now report on inline handlers underos validatetoo, and fail a run only with--strict.packages/drivers/driver-memory/objectstack.config.tsandpackages/plugins/plugin-hono-server/objectstack.config.tsareObjectStackManifestplugin manifests under a stack-shaped filename;os validaterefuses both with top-levelunrecognized_keysonorigin/mainbefore and after this PR. 承接者:无.Generated by Claude Code