|
| 1 | +--- |
| 2 | +'@objectstack/cli': minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(cli): `os lint` refuses a hook body that silently stopped being metadata (#13651) |
| 6 | + |
| 7 | +An L2 hook handler is lowered to a metadata-only `body.source` and evaluated in |
| 8 | +QuickJS with no module scope. When the handler reaches out of that scope, |
| 9 | +`extractHookBody` refuses — and `lowerCallables` caught the refusal, recorded it, |
| 10 | +and shipped the callable through the back-compat `.mjs` bundle instead. `os build` |
| 11 | +exited 0. The hook kept working locally. What changed silently was the |
| 12 | +**deployment shape**: the app had stopped being shippable as pure metadata. |
| 13 | + |
| 14 | +The refusal was never the missing piece — `extractHookBody` had already computed |
| 15 | +the exact free-identifier list. What was missing is that nothing said no to the |
| 16 | +recorded array. |
| 17 | + |
| 18 | +**The refusal now carries its classification — and what that publishes rides |
| 19 | +on two CLI surfaces, not on new API exports.** Internally, |
| 20 | +`HookBodyExtractionError` (with `HookBodyRefusalKind`) names which rule |
| 21 | +refused — `free-identifiers` / `forbidden-token` / `unparseable` — and |
| 22 | +`lowerCallables` records it beside the identifier list on each |
| 23 | +`bodyExtractionWarnings` entry. Those types are module-internal: the package |
| 24 | +`exports` map exposes only `.` and `./console`, and `src/index.ts` re-exports |
| 25 | +none of them. What this release actually publishes is: |
| 26 | + |
| 27 | +- **`os lint`'s exit contract** — the new `hook-body/not-lowerable` rule is an |
| 28 | + `error`, so `os lint` can now exit 1 where it previously exited 0. |
| 29 | +- **`os build --json`** — each `bodyExtractionWarnings` entry now carries |
| 30 | + `kind` and `freeIdentifiers` fields beside the unchanged `origin`/`reason`. |
| 31 | + |
| 32 | +**`os lint` is the first consumer, and it tells the classes apart.** They |
| 33 | +used to share one catch, so they shared one fate: |
| 34 | + |
| 35 | +- **accidental** (`free-identifiers`) — the handler *is* expressible as a |
| 36 | + metadata body; it merely names a module-scope const, helper or import. Now a |
| 37 | + lint **`error`**, so a gate can fail on it. `os lint` exits 1. |
| 38 | +- **structural** (`forbidden-token`) — `fetch`/`require`/`process`/… are |
| 39 | + capabilities the sandbox does not have, so writing one *is* choosing a bundled |
| 40 | + closure and the bundle is the designed answer. Reported as a **`warning`**, |
| 41 | + never fatal. |
| 42 | +- **instrument** (`unparseable`, or a failure of the extractor itself) — the |
| 43 | + tool could not judge the body at all. Reported as a **`warning`** under its |
| 44 | + own rule, with prose that names the instrument — never as if the author chose |
| 45 | + a bundle, because an instrument failure is not a verdict about the author. |
| 46 | + |
| 47 | +An author who deliberately wants a bundled closure keeps two channels that |
| 48 | +already existed and are still silent: give the hook an explicit `body`, or move |
| 49 | +the function into the top-level `functions:` map and reference it by name. |
| 50 | + |
| 51 | +**What did NOT change: what `os build` accepts.** The catch in `lowerCallables` |
| 52 | +stays, both classes still fall back to bundling, and the build still exits 0 — |
| 53 | +verified over a real spawned `os build`. Flipping that default is a separate |
| 54 | +contract decision. The new rule runs the *same* `extractHookBody` the build |
| 55 | +runs, so the lint verdict cannot drift from what the build would do to the same |
| 56 | +handler. |
0 commit comments