Repository navigation
Commit 6d36017
Fixes #22837
Clause-②: yes (narrowing)
## What changes
`objectstack build` lowers a hook handler's statements into
`body.source`, and the runtime runs a body as `(async (ctx) => { SOURCE
})`, so `ctx` is the only name a body is given. The lowering dropped the
handler's parameter list. A handler named `async (hookCtx) => …` or
`async ({ input }) => …` lowered at exit 0 and threw `ReferenceError` on
every run.
- **The rebind.** `planHookParameterRebind` in
`packages/cli/src/utils/extract-hook-body.ts`. On the hook face the
handler's one parameter is re-emitted as the body's first statement:
`var hookCtx = ctx;`, `var { input, previous } = ctx;`. The parameter is
read off the TypeScript parse that `detectFreeIdentifiers` already
performs, never off the regex peel, so the free-identifier verdict and
the rebind agree on what the parameter binds. Nothing is emitted for a
parameter named `ctx`, or for no parameter. Those lower
byte-identically: pinned by requiring the hook face's output to equal
the unchanged action face's over six shapes.
- **The residue (A), refused as `forbidden-token`, naming the
parameter.** Covered: more than one parameter, a default value, a rest
parameter. Also covered: a pattern that binds the name `ctx`, and a body
that declares `ctx` (or the parameter's own name) with `let` / `const` /
`class` / `function` where the prologue would read it. Last, a
destructured `log` / `crypto` / `title` / `api` taken apart further than
a plain name, which the capability inference cannot follow. The handler
is bundled, as `.sudo(` is.
- **Capability inference follows the rebound names.**
`CAPABILITY_PATTERNS` spells `ctx.log.` and `ctx.crypto.randomUUID`.
`reboundCapabilities` re-spells each with the receiver renamed, and
nothing else, for an identifier parameter, an object-rest element, and a
destructured `log` / `crypto` / `title`. A renamed handler infers
exactly what the same handler named `ctx` infers; seven pairs are
pinned.
- **The enumeration pin that closes the family.** It lives in
`packages/cli/test/lowered-body-face-enumeration.test.ts`. It derives
the in-process face from `HookContextSchema`'s source
(`packages/spec/src/data/hook.zod.ts`, one level into each `z.object`,
tombstones excluded). It derives the body face from
`buildSandboxContext`'s source
(`packages/runtime/src/sandbox/body-runner.ts`, one level into an object
literal or a passthrough). Every member absent from the body must be
refused, which is measured behaviourally through `extractHookBody`, or
recorded in `DELIBERATE_EXCEPTIONS` with its reason. Stale exceptions
red too. Both reads are declared in
`scripts/cross-package-test-inputs.mjs` and `turbo.json`. Neither
package is edited.
- **What the pin found.** It found six absent members. Two were already
refused (`dispatch.scope`, `submitted`). Three were unrefused, and this
PR now refuses them as the family's own rule prescribes (the VM lacks
them): `ctx.provenance`, `ctx.ql`, `ctx.transaction`. They are hook face
only, and `ctx.api.transaction(fn)` is exempt. One is a recorded
exception: `id`, which none of the five hook-context assembly sites in
objectql's `engine.ts` sets, so it is absent on both faces.
- **Parameter-list destructuring now meets the member refusals.** `async
({ input, submitted }) => …` and `async ({ dispatch: { scope } }) => …`
used to lower, because the scan read a body that had lost its parameter
list. The scan now reads the rebound source.
- **The face split.** `LoweringFace` is `hook | action`. An action body
runs as `(async (input, ctx) => …)`, a different binding list.
`lowerCallables` and `os lint`'s `hook-body-lowering` rule pass the face
of the slot, so an action target lowers exactly as before. The published
`extractHookBody` keeps its signature and is the hook face.
- **`parseFunction`'s span check** (`detect-free-identifiers.ts`, an
in-place fix: see Deviations). A method shorthand holding exactly one
nested function returned the NESTED function as the handler. Measured on
the base: the nested arrow's parameter was read as the handler's, a
method local it closed over was refused as free, and a module-scope
helper outside it lowered unreported. The parse now accepts only the
node that spans the whole source.
- **The door, the docs, the changeset.** In
`lowered-body-door.dogfood.test.ts`, `lbd_param` moves from documenting
the `500` to proving the rebind, with a destructured cell beside it and
the residue (`lbd_two`) bundled like `lbd_stash`.
`content/docs/automation/hook-bodies.mdx` states the rebind and the new
refusals. The changeset is BREAKING (narrowing), with before and now per
door.
## Premise, re-measured on `origin/main` `1eff3224d` before any code
- `peelToBlockBody` keeps the block and drops the header. Through
esbuild (`bundle-require`, as `loadConfig` loads a config), `async
(hookCtx: HookCtx) => { hookCtx.input.note = 'a' }` became `body.source`
= `hookCtx.input.note = "a";` with nothing binding `hookCtx`.
- `quickjs-runner.ts`'s L2 wrapper is `(async (ctx) => { SOURCE })` for
hook and job origins, and `(async (input, ctx) => …)` for actions.
- `lbd_param` asserted `SandboxError` / `500`.
## Mechanism assumptions (order §2), measured
1. Holds, all three.
2. These spellings reach `peelToBlockBody` after esbuild: `async (x) =>
{}`. Bare `async x =>` is printed with parentheses, and a TypeScript
annotation is stripped. Also `async function (x)`, a named function, a
method shorthand, a destructuring and a nested pattern, and `async () =>
{}`. All rebind or refuse as above. The regex peel itself mis-peels some
bodies. That is pre-existing and unchanged here; see Acceptance notes.
3. The pin derives, can fail, and the cross-package reads are declared
(`check:cross-package-test-inputs` green). Ablations are below.
4. The in-process face is `HookContextSchema` (the declared contract).
The producer side is cross-checked by hand: all five `HookContext`
literals in objectql's `engine.ts` set `provenance`, `transaction` and
`ql`, and none sets `id`.
## Measured at the doors
| door | base `1eff3224d` | this PR |
|---|---|---|
| `bootStack(config, { artifact })`, `lbd_param` (`hookCtx`) |
`SandboxError: … ReferenceError: 'hookCtx' is not defined`, REST `500`,
nothing stored | both boots store `named:alpha`; REST `201` on both, the
same stored note |
| same, `lbd_destructure` (`({ input })`) | (new cell) under the
ablation below: `ReferenceError: 'input' is not defined`, `500` | both
boots store the same row, `201` |
| same, `lbd_two` (`(ctx, suffix)`) | lowered | bundled, no body;
in-process stores `two:alpha` |
| `objectstack build`, a two-parameter hook | lowered at exit 0 |
bundled, exit 0, with a warning naming its two parameters, `ctx` and
`extra`; a renamed hook in the same app ships `var hookCtx = ctx;` |
| `objectstack build --strict-body`, same | exit 0 | exit 1, naming
`hook 'two_params'` (measured on a scratch app built through
`bin/run-dev.js`) |
| `os lint`, same | silent | `hook-body/bundled-fallback` warning at
`hooks[i].handler` |
| `os lint`, an `(input, ctx)` action target | silent | silent (action
face) |
**The example corpus**, lowered with base and with this head
(`lowerCallables` over `loadConfig` of each
`examples/*/objectstack.config.ts`): `app-crm`, `app-multi-package` and
`app-showcase` are byte-identical. `app-todo`'s `task_logic` moves from
lowered to bundled, refused for `ctx.ql` (it reads the kernel logger off
`ctx.ql`). Its lowered body was already a silent no-op: it reads
`ctx.input.data`, which the body face does not carry, so `if (!data)
return;` returned before the completion stamp. Bundled, it runs
in-process again wherever the runtime module is served.
## Tests
- `pnpm --filter @objectstack/cli exec vitest run --project unit
--maxWorkers=2`: 283 files, 4231 tests passed. This was the full `unit`
tier, run on `e006f1a65`. The only delta to `a27cba278` renames a
private option and deletes two comment lines, and the targeted re-run
below covers it. The `integration` tier is declared to CI: the diff
touches no spawn entry or driver boot path.
- Targeted re-run on `a27cba278`: eight cli files, 179 tests passed (the
rebind, enumeration, extractor, lowering, free-identifier, lint rule,
refusal-kind and published-subpath pins).
`lowered-body-door.dogfood.test.ts`: 13 passed.
- `pnpm --filter @objectstack/cli typecheck` (tsc plus the test layer):
green; `check:test-typecheck` holds its ledger unchanged.
## Ablations (committed first, mutated through
`scripts/ablation-replace.mjs`, each restored to HEAD's blob with an
empty `git diff HEAD`)
Each subject resolves from source (relative imports to
`packages/cli/src`, and the pin reads the two source files directly), so
no `dist/` is in the path.
1. `rx: SUBMITTED_RX,` was replaced with a never-matching `rx`. The
enumeration pin went red on exactly `['submitted']`.
2. A fake member `ablationOnlyMember` was added to `HookContextSchema`'s
source (`packages/spec`, mutated for the run and restored, never
committed). The pin went red on exactly `['ablationOnlyMember']`. A
first attempt used an anchor its own replacement contained, and the tool
refused it before the test ran (anchor count 1 to 1). That run is void
and was redone with a disjoint anchor.
3. The rebind was disabled (`const rebind = NO_REBIND;`). The door went
red on 6 of 13 tests, with the card's failure exactly: `ReferenceError:
'hookCtx' is not defined`, `expected 500 to be 201`, `ReferenceError:
'input' is not defined`, and `lbd_two ships no body`.
## Gates
All on `a27cba278`. `node scripts/pm/dispatch-gates.mjs --commands`,
re-derived from this diff with no paths, gives 113 families: every
family derived at dispatch except `pnpm lint` (below), plus the ones the
diff adds, such as `check:adr-0087-registration`,
`check:changeset-no-major` and `check:empty-changeset`. All 113 exited
0. Reconciled with `--ran`: "113 derived famil(ies) accounted for — 113
run, 0 NOT-MEASURED (a DERIVED zero — all 113 recorded an exit code and
none of them is 3)".
- Two gates first answered `PREREQUISITE NOT MET` (exit 3) because eight
packages had never been built in this worktree: `check:skill-examples`
(`client-react`) and `check:dual-build-cjs-loads`. I built those
packages (57 of 57 turbo tasks were cache hits) and re-ran both: exit 0.
- `check:adr-0087-registration` reads one declared-breaking changeset:
`[BREAKING+bang+clause-②-narrowing] not-required
(no-migration-prescription)`.
- `check:cross-package-test-inputs` reads: "30 package(s) read outside
themselves, all declared".
- `pnpm --filter @objectstack/cli typecheck` and `pnpm --filter
@objectstack/dogfood typecheck` exit 0 on `a27cba278`. `--listFilesOnly`
shows each new or edited test inside a program: the two `src/` tests in
`tsconfig.json`, the two `test/` files in `tsconfig.test.json`, and the
door in dogfood's.
**Lint.** I ran a proven narrowing rather than `pnpm lint`, with three
pieces of evidence. First, the population: eslint's own config over this
branch's 14 files, of which `--format json` reports 11 linted and 3 with
no matching configuration (the changeset, the `.mdx`, `turbo.json`).
Second, the result: 0 errors and 0 warnings. Third, invariance:
`eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project` anywhere, as its own header states), so no
verdict on an untouched file can move with this diff. The repo-wide
`pnpm lint` run belongs to CI.
## Deviations from the order and the ruling text
- **`var`, not the ruling's literal `const`.** A parameter is a mutable,
function-scoped binding. `const` would TypeError on a handler that
reassigns its parameter, which runs fine in-process (pinned in "lets the
body reassign its parameter"). A `let` or `const` would also SyntaxError
against a body's own `var` of the same name. `var` is the one spelling
with a parameter's semantics.
- **Three member refusals the card did not name** (`provenance`, `ql`,
`transaction`). They are the enumeration pin's classification under the
ruling's either/or: none of the three has a reason to be a deliberate
exception.
- **`parseFunction`'s span check is an in-place fix** in
`detect-free-identifiers.ts`, under the bounded exemption. It is the
same family; the rebind depends on it, because without it a method
shorthand with one inner arrow gets that arrow's parameter rebound; the
fix is mechanical (one span comparison); no other claim holds the file;
and the gates are the same.
- **Files beyond the claim's named surface:**
`detect-free-identifiers.ts` (+ test), `lint/hook-body-lowering.ts` (+
test), `hook-body.ts` (docblock only),
`scripts/cross-package-test-inputs.mjs`, `turbo.json`. Each is required
by the face split, the span fix or the pin's declared reads. No
`packages/runtime` or `packages/spec` file is edited.
- **A measured widening, for the seat's Clause-② call.** The span fix
makes one shape lower that was refused: a method shorthand with exactly
one nested function closing over a method local. `--strict-body` and `os
lint` therefore accept that shape where they refused it. The line above
is the claim's `no (narrowing)`, copied as ordered; by
`scripts/pm/clause2-line.mjs`'s arms the honest reading may be `yes
(narrowing)`.
- **Overlap with #22849 (#22839)** on
`packages/cli/src/utils/lower-callables.ts`. Both PRs add a parameter on
the same four lines: `tryExtractBody`'s signature, the hook call site,
`lowerActionCallable`'s `tryExtract` type, and the action call site.
That PR adds `ref`, this one adds `face`. The resolution is mechanical
(`tryExtractBody(fn, originLabel, ref, face)`). Nothing here touches the
counting or `bodylessCallables` code.
## Acceptance notes
- **Out of scope, reported for the seat: the regex peel mis-peels some
bodies, at exit 0.** Measured through a real `objectstack build` of a
scratch app on this branch; the peel is unchanged from base. Two cases
ship a `body.source` that is not valid JS (`SyntaxError` under `new
Function`). The first is a `}` inside a string, template or regex
literal: `ctx.input.note = 'a}b'` ships as `ctx.input.note = "a`. The
second is a function or method form with a statement-level parenthesised
inner arrow, which ships as `return (a + "!"; …});`. The build's only
signal is `hook-body-source-unparseable`, which tells the author to fix
a syntax error their handler does not have. A third case, a `{` inside a
string, is refused as `unparseable` (bundled, the safe direction). This
is the same family. The TypeScript node this PR already reads holds the
right body span.
- The action face has the same parameter question: a target written
`(params, context)` loses both names. No example or package ships an
inline action target, the in-process action calling convention was not
measured here, and the action face's lowering is unchanged by this PR.
- `os lint`'s write-set extractor keys on the literal `ctx.input.FIELD`.
A rebound body writes `hookCtx.input.FIELD`, which it does not follow,
exactly as it does not follow an in-body `const c = ctx`. This is a
coverage gap, not a regression.
- The `ctx.input` envelope split (`input.data` exists in-process and not
in the body) is a documented, deliberately undecided divergence on
`HookContextSchema.input`. The pin treats `input` as opaque.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01B5CHJNXuuqzChM4w6hkTN4)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55382dc commit 6d36017
14 files changed
Lines changed: 1373 additions & 104 deletions
File tree
- .changeset
- content/docs/automation
- packages
- cli
- src
- lint
- utils
- test
- qa/dogfood/test
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
172 | 173 | | |
173 | | - | |
| 174 | + | |
174 | 175 | | |
175 | 176 | | |
176 | 177 | | |
| |||
235 | 236 | | |
236 | 237 | | |
237 | 238 | | |
| 239 | + | |
| 240 | + | |
238 | 241 | | |
239 | 242 | | |
240 | 243 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
14 | 24 | | |
15 | 25 | | |
16 | 26 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
364 | 364 | | |
365 | 365 | | |
366 | 366 | | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
367 | 395 | | |
368 | 396 | | |
369 | 397 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
97 | 98 | | |
98 | 99 | | |
99 | | - | |
| 100 | + | |
100 | 101 | | |
101 | 102 | | |
102 | 103 | | |
| |||
127 | 128 | | |
128 | 129 | | |
129 | 130 | | |
130 | | - | |
| 131 | + | |
131 | 132 | | |
132 | 133 | | |
133 | 134 | | |
134 | | - | |
| 135 | + | |
135 | 136 | | |
136 | | - | |
| 137 | + | |
137 | 138 | | |
138 | 139 | | |
139 | 140 | | |
| |||
240 | 241 | | |
241 | 242 | | |
242 | 243 | | |
243 | | - | |
| 244 | + | |
244 | 245 | | |
245 | 246 | | |
246 | 247 | | |
| |||
258 | 259 | | |
259 | 260 | | |
260 | 261 | | |
| 262 | + | |
261 | 263 | | |
262 | 264 | | |
263 | 265 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
7 | 8 | | |
8 | 9 | | |
9 | 10 | | |
| |||
153 | 154 | | |
154 | 155 | | |
155 | 156 | | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
156 | 184 | | |
157 | 185 | | |
158 | 186 | | |
| |||
0 commit comments