Skip to content

fix(cli): os validate lowers inline handlers before its parse, so the hook write-set family judges handler-authored hooks there too - #16727

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-16544-validate-lower-callables
Sep 8, 2026
Merged

fix(cli): os validate lowers inline handlers before its parse, so the hook write-set family judges handler-authored hooks there too#16727
os-project-manager merged 4 commits into
mainfrom
claude/issue-16544-validate-lower-callables

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16544

Clause-②: yes

Every rule in the hook-body-* / hook-api-update-readonly-* family opens on body.language === 'js'. A hook authored as handler: async (ctx) => { … } carries no body, and os validate parsed the normalized stack without lowering — so on that door the whole family returned before reading anything, and a stack os build refuses with hook-api-update-readonly-field (exit 1) passed os validate with exit 0 and no finding. The identical statement authored as an explicit body: { language: 'js', source } was refused by os validate all along: the silence was the door, not the rule (#3782 / #4409 class, hook surface).

validate.ts now runs the same lowerCallables call compile.ts runs at its step 2b — not a copy — after the two pre-parse unknown-key lints (which keep reading normalized) and before the schema parse (which reads lowering.lowered), and hands the registry parsed: result.data as before. Nothing about what os build accepts 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 action target callable, so a plain-object config os validate used to refuse at the parse is now accepted — parity with os build, which accepted it all along. It is measured through the real CLI below, declared in the changeset, and pinned beside the hook legs. The Clause-②: yes above stands on that limb, on the reviewer's ground rather than triage's:

Refusing only what a sibling command already refuses is no; this diff is yes because the same pre-parse lowering also makes os validate ACCEPT inline action target callables its un-lowered parse refused, and any growth of a published command's accepted set is yes even when it is parity with a sibling — the sibling's acceptance is evidence of intent, not a declaration on this command's face.

Premise check against origin/main (6ba0db4e0)

  • lint: reach handler-authored hooks with the hook write-set rules, and record which intakes reach them #16564 (the os lint half, lint: every hook-body-* / hook-api-update-readonly-* rule is unreachable for a hook authored as a handler function — the whole family is gated on body.language === 'js', and the reference CRM has 39 hooks and 0 bodies #16095) is on main at 0cde37d7 — the PM's re-check holds on this tree. Its lintConfig wiring is reused as the reference for the parsed-tier semantics; no second lowering wiring was built.
  • lowerCallables never mutates its inputpackages/cli/src/utils/lower-callables.ts shallow-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.
  • Registry hand-off shape verified: runAuthoringRules(command, { normalized, parsed?, sduiManifest? }) (packages/lint/src/authoring-rules.ts), parsed: result.data already in place in validate.ts; os build and the scaffold door hand it the same lowered-then-parsed value.
  • A pin contradicted the card and was measured false, not routed around: packages/cli/test/validate-build-gate-parity.test.ts listed lowerCallables under BUILD_ONLY_GATES with the reason "there is nothing to lower when nothing is emitted". The family's body.language === 'js' opener is what there was to lower for. It moves to SHARED_NON_REGISTRY_GATES, so the pin now holds both doors to the call.
  • The triage ruling's ground (the narrowing refuses only stacks os build already 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-target axis, which the review found and this round measures; the ten corpus files carry no inline action target, 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 — the os validate leg 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."

leg BASE validate.ts (blob ee42edd1b) HEAD (blob 7d82e8156)
INTAKE — handler-authored readonly write via ctx.api red: AssertionError: exit 0 — expected +0 to be 1 green: exit 1, hook-api-update-readonly-field
CONTROL — same statement as explicit body green: exit 1, same rule green: exit 1, same rule — refused throughout
NEGATIVE CONTROL (new) — handler-authored, writes a declared writable field green: exit 0, no finding green: exit 0, no finding — the door refuses only what os build refused
os lint ×2, os build ×2 (unchanged by this card) green green

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; anchors safeParse(normalized)=1, lowerCallables(=0), restored by git checkout HEAD -- PATH under an EXIT INT TERM trap, and restoration proven by blob hash (7d82e8156 = HEAD blob) and an empty git diff HEAD.

The widening limb — measured red-first through the real CLI, then pinned

ActionSchema.target is z.string().optional() (packages/spec/src/ui/action.zod.ts:1003) and normalizeStackInput never touches function values, so nothing lowered an inline action target before os validate's parse. Fixture: a plain-object config with actions: [{ name: 'ping_global', label: 'Ping', target: async (ctx) => { … } }] and the same on objects[0].actions[0] — both slots lowerActionCallable handles. Driven through bin/run-dev.js with the built closure, BASE validate.ts written into the tree only (blob ee42edd1b, anchors safeParse(normalized)=1 / lowerCallables(=0) and restored by git checkout HEAD under an EXIT INT TERM trap:

door BASE validate.ts HEAD
os validate --json exit 1valid=false errors=2: invalid_type@objects.0.actions.0.target, invalid_type@actions.0.target (Zod: "Invalid input: expected string, received function") exit 0valid=true errors=0
os build --json exit 0 (not re-measured on BASE validate.ts: build does not run it; it lowered before its parse all along) exit 0 — 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.ts comment ("NOT A PURE NARROWING"), and pinned in the e2e file as a fourth describe beside the negative control — an INTAKE leg (validate exit 0, valid: true, no invalid_type) and a PARITY leg (build exit 0). Red-first on the same BASE/HEAD pair as the hook legs: on BASE the e2e file reads Tests 2 failed | 7 passed (9) — exactly the two INTAKE legs (hook: expected +0 to be 1; action: expected 1 to be +0, with the two invalid_type issues in the assertion output) — and on HEAD Tests 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 --json over all ten objectstack.config.ts corpus files at 6ba0db4e0, BASE validate.ts vs HEAD, comparing exit code, error text and the full rule-id list: identical on 10 of 10, zero lines differ.

corpus file exit (both) reading
examples/app-crm 0 reaches the registry; 10 advisories, identical
examples/app-multi-package 0 reaches the registry; 3 advisories, identical
examples/app-todo 0 reaches the registry; 7 advisories, identical
packages/plugins/plugin-auth 0 reaches the registry; 265 advisories, identical
packages/plugins/plugin-security 0 reaches the registry; 51 advisories, identical
packages/services/service-i18n 0 reaches the registry; 2 advisories, identical
packages/drivers/driver-memory 1 a plugin ObjectStackManifest, not a stack: refused at the schema parse (top-level unrecognized_keys), which is AFTER the lowering point — same issue both sides
packages/plugins/plugin-hono-server 1 same as above
examples/app-showcase 1 fails at load in the measuring environment (unbuilt example dependency), BEFORE the lowering point — same both sides
packages/create-objectstack/…/blank 1 fails at load (template dir has no node_modules), BEFORE the lowering point — same both sides

So 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 one os build refuses today with the same finding.

Evidence

  • Dependency closure built first (pnpm --filter "@objectstack/cli^..." build, VERDICT command-exit 0) — the spawned CLI resolves @objectstack/spec / @objectstack/lint through exports to dist/.
  • pnpm --filter @objectstack/cli typecheck → exit 0; pnpm --filter @objectstack/lint typecheck → exit 0 (each captured from its own redirect).
  • packages/cli unit tier: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2Test Files 3 failed | 181 passed (184), Tests 2470 passed | 6 expected fail | 40 skipped (2516) — zero failing tests; the three file-level failures were two PREREQUISITE NOT MET (the two published-subpath-*.pin tests refuse when packages/cli/dist is absent — their own remedy is pnpm --filter @objectstack/cli build) and one 10 s hook timeout in src/commands/datasource/envelope-unwrap.test.ts on a shared box running the gate sweep alongside. After pnpm --filter @objectstack/cli build (exit 0, gated by &&) the three re-ran: Test Files 3 passed (3), Tests 40 passed (40).
  • Integration tier: only the touched e2e file was run locally (OS_TEST_TIERS=nightly … --project integration, 7/7); the rest of the integration and nightly tiers are declared to CI.
  • Derived gate families (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 own PREREQUISITE NOT MET and 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 — then OK (13 config(s), 621 baselined untranslated string(s), none new), i.e. the real CLI over 13 configs with this validate.ts), check:dual-build-cjs-loads (same closure; 104 entry points across 67 packages load), check:skill-examples (@objectstack/client-react built; 258 prose examples type-check), and check:type-check-debt, which first raced a concurrent build of packages/client (TS7016 on a half-emitted dist) and then OOMed its whole-workspace tsc at 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).
  • Patch round (head f974fd2769): red leg on BASE validate.ts Tests 2 failed | 7 passed (9) (the two INTAKE legs, nothing else), restoration proven by blob hash 2d9f18a1f = HEAD blob and empty git diff HEAD; green leg Tests 9 passed (9); the other touched integration file test/build-json-undeclared-key-parity.e2e.test.ts (comment-only change) Tests 6 passed (6); pnpm --filter @objectstack/cli typecheck exit 0. Gate families derived from the four patch-round paths: 58 (node scripts/pm/dispatch-gates.mjs --commands over the four changed paths at f974fd2769; all 58 are members of round 1's 86) — 58 run, 58 green, 0 NOT-MEASURED, 0 UNRUN. One first stopped at its own PREREQUISITE NOT MET (check:dual-build-cjs-loads, naming studio / embedder-openai / knowledge-ragflow / organizations / service-cluster-redis dists) and passed after check:type-check-debt's internal tree build (run at NODE_OPTIONS=--max-old-space-size=6144 under the verify lock as in round 1: coverage OK 76/80); check:i18n-coverage ran 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.
  • Readings above were taken at 464c70d96 (the code-diff gate sweep ran on the working tree of 5b4f49bd6 with the changeset already on disk — byte-identical content to 464c70d96; everything derived from the changeset, every re-run, the parity and unit readings, and the reconciliation ran at 464c70d96).

Deviations from the dispatch's declared file surface

The claim named packages/cli/src/commands/validate.ts plus the e2e test. Five more files move, each because this change makes its text false or its pin stale — none changes behaviour:

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/files read 200), positive control fired on both expected PRs (#16380packages/adapters/hono/src/index.ts, #16573AGENTS.md).

@objectstack/lint is graded minor although only comments under packages/lint/src/** move: this PR declares Clause-②: yes, and check-changeset-no-major's level axis refuses patch on any package whose packages/*/src/** the PR moves — the same reading #16564 landed under.

Deliberately not done

  • Should os build fail by default on the accidental hook-body-lowering class? — deferred until the new lint rule has produced a real population number #13838 (os build acceptance) — untouched, as the card marks it.
  • The widening limb was not narrowed away — the review asked for measurement and declaration, ⛔ not a code change; excluding action target from the lowering would re-open the build/validate disagreement one slot over.
  • bodyExtractionWarnings are not surfaced by os validate — publishing them would add a key to its --json payload, which is its own contract decision; compile.ts records why the key is build's alone. A handler the extractor refuses has no body on any door and is reported by os lint's hook-body/* rules.
  • No new step line on the text face — three docs pages carry os validate transcripts 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.
  • The two plugin-manifest corpus files os validate refuses — pre-existing on origin/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.

验收备注


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/lint, touching 1 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/lint/src/validate-hook-body-writes.ts, packages/lint/src/validate-readonly-hook-writes.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/validate-hook-body-writes.ts, packages/lint/src/validate-readonly-hook-writes.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: os validate (command, 45 pages)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 25 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37packageMentionDocs.

Which tree this was computed on

This run read content/docs from 5cec5325fcfd4ac612ea55ca8afca518a4c405de — the merge of head c9727c1ce1b718aa9a2a8d793b85e892ce15aa9c into base 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7c12e475e0d17f66b648e9e6d6c22bce42bd5a37 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@os-project-manager
os-project-manager marked this pull request as ready for review September 8, 2026 03:41
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 6b7d709 Sep 8, 2026
39 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-16544-validate-lower-callables branch September 8, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants