From 5b4f49bd6f334f6b66926f98e507126bf243147a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:23:35 +0000 Subject: [PATCH 1/4] fix(cli): `os validate` lowers inline handlers before its parse, so the hook write-set family judges handler-authored hooks there too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- content/docs/automation/hook-bodies.mdx | 2 +- packages/cli/src/commands/compile.ts | 3 +- packages/cli/src/commands/validate.ts | 37 +++++++++- ...hook-rules-reach-handler-hooks.e2e.test.ts | 71 ++++++++++++++----- .../test/validate-build-gate-parity.test.ts | 13 +++- .../lint/src/validate-hook-body-writes.ts | 11 +-- .../lint/src/validate-readonly-hook-writes.ts | 11 +-- 7 files changed, 118 insertions(+), 30 deletions(-) diff --git a/content/docs/automation/hook-bodies.mdx b/content/docs/automation/hook-bodies.mdx index 38ec5c08e2..85b96614e8 100644 --- a/content/docs/automation/hook-bodies.mdx +++ b/content/docs/automation/hook-bodies.mdx @@ -268,7 +268,7 @@ The dropped case is the dangerous one: nothing fails, the step reports success, - `hook-api-update-readonly-field` — **error**. A body's literal `ctx.api.object('…').update()` / `.updateById()` / `.insert()` writes a field the named object declares `readonly: true`. Since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) the `insert` row of the table above is reported at build time exactly like the `update` row — same id, same severity, a message naming the verb — unless the hook declares `runAs: 'system'`. Only the static shape is judged on an insert: a `readonlyWhen` field has no prior record to lock on and the engine runs no conditional strip on INSERT, so no warning is produced there. - `hook-api-update-readonly-when-field` — **warning**. The same write against a `readonlyWhen` field, which strips per record *state*. The own-hook stamp **is** the workaround here, exactly as it is for static `readonly`: since [#9107](https://github.com/objectstack-ai/objectstack/issues/9107) the conditional strip judges the *caller's* entry payload, so a value a `beforeUpdate` hook **derives** is not caller-supplied and lands even on a locked record. (Deriving is the operative word — a hook that merely echoes the caller's own value back has written nothing the strip can tell from the caller's, and it still goes.) What does **not** help is elevation: unlike the static strip, the conditional lock is **not** waived by a system context, so neither `runAs: 'system'` nor the `sudo()` a body cannot reach makes a caller-supplied value survive. On this shape, confirm the write only targets records whose predicate is `false`, or derive the field in a `beforeUpdate` hook on the target object. -Which hooks these rules can *see* depends on the command, because every rule in this family opens on `body.language === 'js'`. A hook authored as an inline `handler` function carries no `body`, so it is judged only where the command has first lowered the handler to a metadata body: `objectstack build` always has (it lowers before it parses — see [How the build lowers a handler](#build-pipeline)), and since [#16095](https://github.com/objectstack-ai/objectstack/issues/16095) `objectstack lint` judges that same lowered view, so an author who runs only the pre-flight is told the same thing the build would refuse. `objectstack validate` parses without lowering, so there a handler-authored hook is not seen by this family — the explicit-`body` form is. A handler the build cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by the lowering rules instead, never guessed at here. +Which hooks these rules can *see* depends on the command, because every rule in this family opens on `body.language === 'js'`. A hook authored as an inline `handler` function carries no `body`, so it is judged only where the command has first lowered the handler to a metadata body: `objectstack build` always has (it lowers before it parses — see [How the build lowers a handler](#build-pipeline)), and since [#16095](https://github.com/objectstack-ai/objectstack/issues/16095) `objectstack lint` judges that same lowered view, so an author who runs only the pre-flight is told the same thing the build would refuse. Since [#16544](https://github.com/objectstack-ai/objectstack/issues/16544) `objectstack validate` lowers before it parses as well, so all three commands judge the same view of a handler-authored hook — a stack `objectstack validate` passes is one `objectstack build` does not refuse on this family. A handler the build cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by the lowering rules instead, never guessed at here. Only literal object names and literal payload keys are seen; a `sudo()` chain, a dynamic object name and an object this stack does not declare are all skipped, so the rule has no opinion on them. `.create()` is skipped too, for a reason about the **sandbox** rather than the engine: the VM-side `ctx.api.object()` installs `insert` / `update` / `delete` / `updateMany` / `deleteMany` / `upsert` and no `create` leaf, so a body calling `.create()` throws `TypeError: not a function` on its first run — a loud failure, not a silent drop — and the same payload spelled `.insert()` is what the rule judges. The flow surface has carried the same gate as `flow-update-readonly-field` since [#3425](https://github.com/objectstack-ai/objectstack/issues/3425), and since [#15394](https://github.com/objectstack-ai/objectstack/issues/15394) it reports a non-`runAs: 'system'` `create_record` node's static-`readonly` write at the same **error**, again with no conditional finding on a create. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 50b111a0d2..035379744f 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -856,7 +856,8 @@ export default class Compile extends Command { // the legacy .mjs bundle. A SEPARATE key on purpose, and the reason is // parity too — the opposite way round from `unknownKeyWarnings` just // above. `{origin,reason}` extraction records have NO counterpart in - // `os validate --json`: that command lowers no handlers, so there is + // `os validate --json`: that command lowers too since #16544 but + // EMITS nothing, so it surfaces no extraction record and there is // no cross-command list for these to join, and folding a shape only // ONE command can ever emit into the shared key would teach consumers // a shape the other command never ships. The undeclared-key findings diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index ee42edd1bb..7d82e81561 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -13,6 +13,7 @@ import { type ConversionNotice, } from '@objectstack/spec'; import { loadConfig } from '../utils/config.js'; +import { lowerCallables } from '../utils/lower-callables.js'; import { runAuthoringRules, splitBySeverity, authoringRulesFor } from '@objectstack/lint'; import { resolveSduiManifest } from '../utils/sdui-manifest.js'; import { preflightRequiredCapabilities, renderCapabilityMessage } from '../utils/capability-preflight.js'; @@ -204,7 +205,41 @@ export default class Validate extends Command { ...lintUnknownStackKeys(normalized as Record, ObjectStackDefinitionSchema), ...lintUnknownAuthoringKeys(normalized as Record, ObjectStackDefinitionSchema), ].map(formatUnknownAuthoringKey); - const result = ObjectStackDefinitionSchema.safeParse(normalized); + // 2b. [#16544] Lower inline `function` handlers (Hook.handler, action + // `target`, top-level `functions`) to a metadata `body` + string ref + // BEFORE the parse — the same `lowerCallables` call `os build` makes + // at its step 2b and `os lint` makes in `lintConfig`, not a copy. + // + // 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`, so on the + // un-lowered stack the whole family returned before reading + // anything, and this command passed (exit 0, no finding) a stack + // `os build` refuses with `hook-api-update-readonly-field` — the + // #3782 / #4409 class one door over, on the shape the reference app + // uses for 39 of 39 hooks. The body-authored control fired here all + // along, so the silence was the door, not the rule. + // + // POSITION IS LOAD-BEARING: after the two pre-parse unknown-key + // lints above, which keep reading `normalized` exactly as before, + // and before the parse, which now reads the lowered view — the order + // `compile.ts` runs. `lowerCallables` returns a NEW top-level object + // and never mutates its input, so `normalized` — the registry's + // `normalized` tier below, `collectMetadataStats(config)`, the + // structural advisories — is byte-for-byte what it was; only what + // the parse and the registry's `parsed` tier see changes. + // + // Nothing is emitted, so `lowering.functions` is unused here, and + // the extraction refusals in `bodyExtractionWarnings` are NOT + // surfaced: a handler the extractor refuses is left with no `body` + // on every door, the family stays silent on it, and the refusal is + // `os lint`'s `hook-body/*` rules' to report. Publishing it here + // would add a key to this command's `--json` payload, which is its + // own contract decision (`compile.ts` records why the key is + // build's alone). No step line is printed either: the text face is + // byte-for-byte what it was, and the docs transcripts stay true. + const { lowered } = lowerCallables(normalized as Record); + const result = ObjectStackDefinitionSchema.safeParse(lowered); if (!result.success) { if (flags.json) { diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index 9802d1b241..629488d0d6 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -17,12 +17,17 @@ * `os lint` used to judge the un-lowered normalized stack; since #16095 * `lintConfig` hands the registry's `parsed` tier the same * lowered view `os build` judges. This file's RED leg. - * `os validate` parses the normalized stack WITHOUT lowering, so a - * handler-authored hook carries no body there and the family - * does not fire. Recorded below as a MEASUREMENT of that door, - * not as a contract: an author who runs `os validate` alone is - * not told. Closing it changes what `os validate` refuses and - * is its own decision (see the card's report). + * `os validate` used to parse the normalized stack WITHOUT lowering, so a + * handler-authored hook carried no body there and the family + * did not fire — measured here under #16095 as a reading of + * that door, not a contract, because closing it changes what + * `os validate` refuses. #16544 closed it: `validate.ts` now + * runs the same `lowerCallables` pass between its pre-parse + * unknown-key lints and its parse, so this file's THIRD red + * leg. The control beside it fired before and fires after; a + * handler-authored hook the family has nothing to say about + * still passes, so the door refuses only what `os build` + * already refused. * * The fixture is the card's own: a readonly `is_escalated` written through * `ctx.api.object('crm_case').update(…)` from an `afterUpdate` hook — the write @@ -111,6 +116,27 @@ export default { }; `; +/** + * NEGATIVE CONTROL for #16544: handler-authored like the intake, but the write + * lands on a declared, writable field — nothing in the family objects. This is + * the leg that proves the door now refuses only what `os build` already + * refused: a stack that validated green before #16544 validates green after. + */ +const CONFIG_HANDLER_OK = ` +export default { + manifest: { id: 'com.example.reach_handler_ok', name: 'reach_handler_ok', version: '1.0.0', type: 'app' }, + objects: [${OBJECT}], + hooks: [{ + name: 'retitle', + object: 'crm_case', + events: ['afterUpdate'], + handler: async (ctx: any) => { + await ctx.api.object('crm_case').update({ id: ctx.input.id, title: 'seen' }); + }, + }], +}; +`; + const dirs: Record = {}; function project(key: string, source: string): string { @@ -123,6 +149,7 @@ function project(key: string, source: string): string { beforeAll(() => { project('handler', CONFIG_HANDLER); project('body', CONFIG_BODY); + project('handlerOk', CONFIG_HANDLER_OK); }); afterAll(() => { @@ -172,21 +199,33 @@ describe('#16095 — door: `os build` (the door that never had the gap)', () => }, 90_000); }); -describe('#16095 — door: `os validate` (measured, NOT lowered)', () => { - // A reading of the door as it stands, so a change to it is a change someone - // chose: `os validate` parses the normalized stack without lowering, and the - // handler-authored hook carries no body there. If this leg starts failing - // because `os validate` began lowering, the intake row becomes the control - // row — update the ledger in the file header, do not delete the pin. - it('INTAKE — the handler-authored hook is NOT seen by the family here (exit 0, no finding)', async () => { +describe('#16544 — door: `os validate` (lowers since #16544; measured NOT lowered under #16095)', () => { + // Under #16095 this leg pinned the door as it stood — exit 0, no finding — + // so that a change to it would be a change someone chose. #16544 chose it: + // `validate.ts` runs the same `lowerCallables` pass `os build` runs, between + // its pre-parse unknown-key lints and its parse, so the handler-authored hook + // carries a body here too. The intake row is now a RED row. The control + // beside it is unchanged — it fired before this change and fires after — so + // a red here is still a reading about the door, never about the rule. + it('INTAKE — the handler-authored hook IS refused here (error, exit 1) — the red-first leg of #16544', async () => { const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.handler); - expect(run.code, label(run)).toBe(0); - expect(rulesIn(run)).not.toContain(READONLY_RULE); + expect(run.code, label(run)).toBe(1); + expect(rulesIn(run)).toContain(READONLY_RULE); }, 60_000); - it('CONTROL — the explicit body IS refused here, so the silence above is the door, not the rule', async () => { + it('CONTROL — the explicit body is refused here, before and after, so the intake reading is about the door', async () => { const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.body); expect(run.code, label(run)).toBe(1); expect(rulesIn(run)).toContain(READONLY_RULE); }, 60_000); + + it('NEGATIVE CONTROL — a handler-authored hook the family has nothing to say about still passes (exit 0)', async () => { + // The lowered stack must PARSE (`handler: ''` beside the extracted + // `body`) and the family must stay silent on a legitimate write, or the + // door would have started refusing stacks `os build` ships. Read the exit + // and the absence of the rule together: a parse failure also exits 1. + const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.handlerOk); + expect(run.code, label(run)).toBe(0); + expect(rulesIn(run)).not.toContain(READONLY_RULE); + }, 60_000); }); diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index e12b3f90a5..86246c4a70 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -52,6 +52,16 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ 'lintUnknownAuthoringKeys', // [ADR-0046] Package docs: flatness, prefixed names, MDX/image ban, links. 'collectAndLintDocs', + // [#16544] The pre-parse lowering of inline `function` handlers to a + // metadata `body` + string ref. It refuses nothing itself; it decides what + // the parse — and the registry's `parsed` tier — SEES. Build-only until + // #16544, on the reasoning that "there is nothing to lower when nothing is + // emitted": measured false, because the `hook-body-*` / + // `hook-api-update-readonly-*` family opens on `body.language === 'js'`, so + // on the un-lowered stack `os validate` passed a handler-authored hook `os + // build` refuses. Both doors run the same call, between the two key lints + // above and the parse. + 'lowerCallables', ]; /** @@ -68,9 +78,6 @@ const BUILD_ONLY_GATES: Readonly> = { '[ADR-0090 D6] The snapshot gate reads (and with --update-access-matrix WRITES) access-matrix.json ' + 'next to the config. Rewriting a committed snapshot is not a read-only operation.', diffAccessMatrix: 'The comparison half of the same D6 snapshot gate.', - lowerCallables: - 'Lowers inline `function` handlers to string refs so they survive JSON.stringify. It exists to ' + - 'produce the artifact; there is nothing to lower when nothing is emitted.', buildRuntimeBundle: 'Emits the objectstack-runtime.{hash}.mjs sibling module. Artifact output by definition.', }; diff --git a/packages/lint/src/validate-hook-body-writes.ts b/packages/lint/src/validate-hook-body-writes.ts index 898f15e7d8..ed2b0a4fcf 100644 --- a/packages/lint/src/validate-hook-body-writes.ts +++ b/packages/lint/src/validate-hook-body-writes.ts @@ -734,10 +734,13 @@ export function extractHookBodyWriteSet(source: string): ExtractedHookBodyWriteS * scaffold validate `runScaffoldAuthoringRules` (`os init` / `dev` over * a rendered template) lowers before it parses too — * REACHED, always was, and pinned since #16095. - * `os validate` parses the normalized stack WITHOUT lowering — NOT - * reached; the body-authored control fires there. - * Changing that changes what `os validate` refuses - * and is its own decision, not this card's. + * `os validate` lowers before it parses since #16544 — the same + * `lowerCallables` call, between its pre-parse + * unknown-key lints and its parse — REACHED since + * #16544. Measured NOT reached under #16095, when it + * parsed the normalized stack without lowering while + * the body-authored control fired; closing it was + * its own accept/reject decision, taken on #16544. * direct call judges exactly the stack it is given — NOT reached * unless the caller lowers first; measured both ways. * diff --git a/packages/lint/src/validate-readonly-hook-writes.ts b/packages/lint/src/validate-readonly-hook-writes.ts index e991d5c4e0..91d81976ac 100644 --- a/packages/lint/src/validate-readonly-hook-writes.ts +++ b/packages/lint/src/validate-readonly-hook-writes.ts @@ -299,10 +299,13 @@ function isRec(v: unknown): v is AnyRec { * scaffold validate `runScaffoldAuthoringRules` (`os init` / `dev` over * a rendered template) lowers before it parses too — * REACHED, always was, and pinned since #16095. - * `os validate` parses the normalized stack WITHOUT lowering — NOT - * reached; the body-authored control fires there. - * Changing that changes what `os validate` refuses - * and is its own decision, not this card's. + * `os validate` lowers before it parses since #16544 — the same + * `lowerCallables` call, between its pre-parse + * unknown-key lints and its parse — REACHED since + * #16544. Measured NOT reached under #16095, when it + * parsed the normalized stack without lowering while + * the body-authored control fired; closing it was + * its own accept/reject decision, taken on #16544. * direct call judges exactly the stack it is given — NOT reached * unless the caller lowers first; measured both ways. * From 464c70d964ea19ee7664084cf0e328bbf64a071f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 01:54:23 +0000 Subject: [PATCH 2/4] changeset: `objectstack validate` lowers inline handlers before its parse (cli minor, lint minor) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...ate-lowers-inline-handlers-before-parse.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/validate-lowers-inline-handlers-before-parse.md diff --git a/.changeset/validate-lowers-inline-handlers-before-parse.md b/.changeset/validate-lowers-inline-handlers-before-parse.md new file mode 100644 index 0000000000..2518d3e38d --- /dev/null +++ b/.changeset/validate-lowers-inline-handlers-before-parse.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": minor +"@objectstack/lint": minor +--- + +`objectstack validate` now lowers hooks authored as inline `handler` functions to a metadata body before it parses, so the hook write-set rules judge them there exactly as `objectstack build` and `objectstack lint` already do. + +The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule. + +`objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. What this does and does not change: + +- A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. +- The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. +- The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. +- Nothing about what `objectstack build` accepts changes. + +Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, which is why the delta is zero rather than the family being unreached; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. + +`@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged. From f974fd2769757cda57251a25fca04da4bd825480 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:32:27 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(cli):=20declare=20and=20pin=20the=20wid?= =?UTF-8?q?ening=20limb=20=E2=80=94=20`os=20validate`=20now=20accepts=20an?= =?UTF-8?q?=20inline=20action=20`target`=20callable,=20as=20`os=20build`?= =?UTF-8?q?=20always=20did?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...ate-lowers-inline-handlers-before-parse.md | 9 +-- packages/cli/src/commands/validate.ts | 29 +++++++-- ...ild-json-undeclared-key-parity.e2e.test.ts | 5 +- ...hook-rules-reach-handler-hooks.e2e.test.ts | 64 +++++++++++++++++++ 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/.changeset/validate-lowers-inline-handlers-before-parse.md b/.changeset/validate-lowers-inline-handlers-before-parse.md index 2518d3e38d..69da477aee 100644 --- a/.changeset/validate-lowers-inline-handlers-before-parse.md +++ b/.changeset/validate-lowers-inline-handlers-before-parse.md @@ -7,13 +7,14 @@ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`, `hook-api-update-readonly-field` and `hook-api-update-readonly-when-field` rules open on `body.language === 'js'`. A hook written as `handler: async (ctx) => { … }` carries no `body`, and `objectstack validate` parsed the normalized stack without lowering — so on that command the whole family returned before reading anything, and a stack `objectstack build` refuses with `hook-api-update-readonly-field` (exit 1) passed `objectstack validate` with exit 0 and no finding. The same statement authored as an explicit `body: { language: 'js', source }` was refused by `objectstack validate` all along, so the silence was the command's intake, not the rule. -`objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. What this does and does not change: +`objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`: -- A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. +- **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. +- **Widening (actions).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). This is an accepted-set relaxation on a published command; it is declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. - The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. - The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. -- Nothing about what `objectstack build` accepts changes. +- Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it. -Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, which is why the delta is zero rather than the family being unreached; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. +Measured on this repository's ten `objectstack.config.ts` corpus files at `6ba0db4e0` with `objectstack validate --json`, before and after: **exit code, error text and rule-id list identical on 10 of 10 — zero findings change, zero verdicts change.** Six reach the rule registry (the four example apps and the `plugin-auth` / `plugin-security` / `service-i18n` configs); two (`driver-memory`, `plugin-hono-server`) are plugin manifests, not stacks, and are refused at the schema parse — after the lowering point — with the same top-level `unrecognized_keys` on both sides; two (`app-showcase`, the `blank` template) fail at load in the measuring environment, before the lowering point, on both sides. None of the repository's handler-authored hooks writes through `ctx.api`, and none of the ten carries an inline action `target` callable, which is why the delta is zero on both axes rather than either being unreached — a corpus with neither shape cannot see either limb, so both are pinned on their own fixtures; the reach itself is pinned by the card's own fixture, with the body-authored control beside it and a handler-authored hook the family has nothing to say about still passing. `@objectstack/lint` carries only the header ledger recording which intakes reach each hook rule; `objectstack validate` moves from "not reached" to "reached". Its behaviour is unchanged. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 7d82e81561..2d9f18a1f1 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -222,12 +222,29 @@ export default class Validate extends Command { // // POSITION IS LOAD-BEARING: after the two pre-parse unknown-key // lints above, which keep reading `normalized` exactly as before, - // and before the parse, which now reads the lowered view — the order - // `compile.ts` runs. `lowerCallables` returns a NEW top-level object - // and never mutates its input, so `normalized` — the registry's - // `normalized` tier below, `collectMetadataStats(config)`, the - // structural advisories — is byte-for-byte what it was; only what - // the parse and the registry's `parsed` tier see changes. + // and before the parse, which now reads the lowered view. That is + // `compile.ts`'s lower-BEFORE-parse order exactly; its key lints sit + // AFTER its parse, so on both doors what protects the lints' input + // is non-mutation, not ordering: `lowerCallables` returns a NEW + // top-level object and never mutates its input, so `normalized` — + // the registry's `normalized` tier below, `collectMetadataStats( + // config)`, the structural advisories — is byte-for-byte what it + // was; only what the parse and the registry's `parsed` tier see + // changes. + // + // NOT A PURE NARROWING. The same pass also lowers an inline action + // `target` callable (`actions[*]`, `objects[*].actions[*]`) to a ref + // string plus `body`. `ActionSchema.target` is `z.string()`, and + // `normalizeStackInput` never touches function values, so before + // this step the un-lowered parse REFUSED such a config + // (`invalid_type` at `actions.0.target`, exit 1) while `os build` + // accepted it all along. It is accepted here now — an accepted-set + // relaxation on this command, measured through the real CLI on + // both sides and pinned in + // `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`; parity + // with the build is the intent, and it is declared rather than + // assumed because a sibling's acceptance is evidence of intent, not + // a declaration on this command's face. // // Nothing is emitted, so `lowering.functions` is unused here, and // the extraction refusals in `bodyExtractionWarnings` are NOT diff --git a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts index 7f0ec70d99..ed75a6571c 100644 --- a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts +++ b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts @@ -43,8 +43,9 @@ * This is deliberately the opposite call from `bodyExtractionWarnings`, which * sits under its own key one line below in the payload. That is not an * inconsistency: `{origin,reason}` extraction records have NO counterpart in - * `os validate --json` (validate lowers no handlers), so there is no parity to - * hold and a sibling key is right. The undeclared-key findings do have a + * `os validate --json` (validate lowers too since #16544, but emits nothing, so + * it surfaces no extraction record), so there is no parity to hold and a + * sibling key is right. The undeclared-key findings do have a * counterpart, and it is already in `warnings`. * * The payload's top-level key set is pinned unchanged below for that reason: diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index 629488d0d6..377dd53751 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -137,6 +137,46 @@ export default { }; `; +/** + * THE WIDENING LIMB (#16544 contract review) — the axis the hook legs above + * cannot see. `ActionSchema.target` is `z.string()`, and `normalizeStackInput` + * never touches function values, so a plain-object config with an inline + * action `target` callable hit `invalid_type` at the parse: `os validate` + * REFUSED it before #16544 (exit 1) while `os build`, which lowers before it + * parses, always accepted it. The same `lowerCallables` pass now rewrites the + * callable to a ref string plus `body` on this door too, so `os validate` + * ACCEPTS it — an accepted-set relaxation on a published command, declared in + * the changeset and pinned here beside the hook legs. Both slots + * `lowerActionCallable` handles (`actions[*]`, `objects[*].actions[*]`). + */ +const CONFIG_ACTION_TARGET = ` +export default { + manifest: { id: 'com.example.reach_action_target', name: 'reach_action_target', version: '1.0.0', type: 'app' }, + objects: [{ + name: 'crm_case', + label: 'Case', + sharingModel: 'private', + fields: { + title: { type: 'text', label: 'Title' }, + }, + actions: [{ + name: 'ping_case', + label: 'Ping case', + target: async (ctx: any) => { + return { ok: true, id: ctx.input.id }; + }, + }], + }], + actions: [{ + name: 'ping_global', + label: 'Ping', + target: async (ctx: any) => { + return { ok: true, id: ctx.input.id }; + }, + }], +}; +`; + const dirs: Record = {}; function project(key: string, source: string): string { @@ -150,6 +190,7 @@ beforeAll(() => { project('handler', CONFIG_HANDLER); project('body', CONFIG_BODY); project('handlerOk', CONFIG_HANDLER_OK); + project('actionTarget', CONFIG_ACTION_TARGET); }); afterAll(() => { @@ -229,3 +270,26 @@ describe('#16544 — door: `os validate` (lowers since #16544; measured NOT lowe expect(rulesIn(run)).not.toContain(READONLY_RULE); }, 60_000); }); + +describe('#16544 — the WIDENING limb: an inline action `target` callable is now ACCEPTED by `os validate`', () => { + // Measured red-first on the same BASE/HEAD pair as the hook legs: on BASE + // this leg fails with `invalid_type` at `actions.0.target` and + // `objects.0.actions.0.target` (expected string, received function) and + // exit 1; on HEAD the lowered stack parses and the run exits 0. The build + // leg beside it is the parity reading: `os build` accepted this config on + // both sides, which is the intent — and the reason this is a declared + // relaxation rather than a narrowing. + it('INTAKE — `os validate` accepts the inline action target (exit 0, valid, no invalid_type)', async () => { + const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.actionTarget); + expect(run.code, label(run)).toBe(0); + const json = JSON.parse(run.stdout); + expect(json.valid, label(run)).toBe(true); + const codes = (Array.isArray(json.errors) ? json.errors : []).map((e: { code?: unknown }) => e?.code); + expect(codes).not.toContain('invalid_type'); + }, 60_000); + + it('PARITY — `os build` accepts the same config (it lowered before its parse all along)', async () => { + const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.actionTarget); + expect(run.code, label(run)).toBe(0); + }, 90_000); +}); From c9727c1ce1b718aa9a2a8d793b85e892ce15aa9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 03:12:28 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(cli):=20declare=20and=20pin=20the=20thi?= =?UTF-8?q?rd=20widening=20limb=20=E2=80=94=20a=20nameless=20`functions`?= =?UTF-8?q?=20array=20entry,=20which=20the=20same=20pass=20names=20`anon?= =?UTF-8?q?=5Ffn`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- ...ate-lowers-inline-handlers-before-parse.md | 2 +- packages/cli/src/commands/validate.ts | 26 ++++++++------- ...hook-rules-reach-handler-hooks.e2e.test.ts | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/.changeset/validate-lowers-inline-handlers-before-parse.md b/.changeset/validate-lowers-inline-handlers-before-parse.md index 69da477aee..e1f71d3754 100644 --- a/.changeset/validate-lowers-inline-handlers-before-parse.md +++ b/.changeset/validate-lowers-inline-handlers-before-parse.md @@ -10,7 +10,7 @@ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `ho `objectstack validate` now runs the same `lowerCallables` pass `objectstack build` runs before its parse — after its two pre-parse undeclared-key lints, which keep reading the un-lowered stack, and before the schema parse, which reads the lowered view — and hands the rule registry the parsed result as before. This moves what `objectstack validate` accepts in **both** directions, and both are parity with `objectstack build`: - **Narrowing (hooks).** A config whose inline handler writes a `readonly: true` field via `ctx.api.object(...).update()` / `.updateById()` / `.insert()` — and does not declare `runAs: 'system'` — now fails `objectstack validate` with `hook-api-update-readonly-field` (exit 1). It already failed `objectstack build` and (since #16095) `objectstack lint` with the same finding, so nothing that builds green starts failing `objectstack validate`. -- **Widening (actions).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). This is an accepted-set relaxation on a published command; it is declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. +- **Widening (actions, and a nameless `functions` array entry).** A plain-object config carrying an inline action `target` callable — `actions: [{ name, label, target: async (ctx) => { … } }]`, or the same on `objects[*].actions[*]` — was **refused** by `objectstack validate` before this change: `ActionSchema.target` is a string, and nothing lowered the function before the parse, so the run exited 1 with `invalid_type` at `actions.0.target` (measured through the real CLI: `valid=false errors=2 invalid_type@objects.0.actions.0.target | invalid_type@actions.0.target`). The same pass now lowers it to a ref string plus `body` on this command too, so `objectstack validate` **accepts** it (exit 0, `valid: true`) — exactly as `objectstack build` accepted it all along (exit 0 on both sides). Likewise a nameless `functions` **array** entry, `functions: [{ handler: async (ctx) => { … } }]`, which the same pass names `anon_fn`: the array form requires `name`, so `objectstack validate` refused it at the parse (measured: `valid=false errors=1 invalid_union@functions`, exit 1) and now accepts it (exit 0, `valid: true`), as `objectstack build` did (exit 0 on both sides). The `functions` map forms and `hooks[*].handler` parse either way and are not affected. These are accepted-set relaxations on a published command; they are declared here rather than inferred from the build's behaviour, and pinned beside the hook legs. - The warning-severity members of the family now report on inline handlers under `objectstack validate` too; they fail a run only with `--strict`, as every other advisory does. - The `--json` payload gains no key and the text face prints no new step: the lowering is a view for the parse and the rule registry. A handler the extractor cannot lower (a forbidden token, a module-scope identifier) has no body on any command and is reported by `objectstack lint`'s `hook-body/*` rules and `objectstack build`'s warn-and-bundle line, never guessed at here. - Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 2d9f18a1f1..eb92efbf82 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -234,17 +234,21 @@ export default class Validate extends Command { // // NOT A PURE NARROWING. The same pass also lowers an inline action // `target` callable (`actions[*]`, `objects[*].actions[*]`) to a ref - // string plus `body`. `ActionSchema.target` is `z.string()`, and - // `normalizeStackInput` never touches function values, so before - // this step the un-lowered parse REFUSED such a config - // (`invalid_type` at `actions.0.target`, exit 1) while `os build` - // accepted it all along. It is accepted here now — an accepted-set - // relaxation on this command, measured through the real CLI on - // both sides and pinned in - // `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`; parity - // with the build is the intent, and it is declared rather than - // assumed because a sibling's acceptance is evidence of intent, not - // a declaration on this command's face. + // string plus `body`, and names a nameless `functions` ARRAY entry + // (`[{ handler: fn }]`) `anon_fn`. `ActionSchema.target` is + // `z.string()`, the array entry requires `name`, and + // `normalizeStackInput` touches neither, so before this step the + // un-lowered parse REFUSED both configs (`invalid_type` at + // `actions.0.target`; `invalid_union` at `functions`; exit 1) while + // `os build` accepted them all along. Both are accepted here now — + // accepted-set relaxations on this command, each measured through + // the real CLI on both sides and pinned in + // `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`. Not + // limbs: `hooks[*].handler` accepts a function un-lowered, and the + // `functions` MAP forms parse either way. Parity with the build is + // the intent, and it is declared rather than assumed because a + // sibling's acceptance is evidence of intent, not a declaration on + // this command's face. // // Nothing is emitted, so `lowering.functions` is unused here, and // the extraction refusals in `bodyExtractionWarnings` are NOT diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index 377dd53751..df53b38b72 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -177,6 +177,28 @@ export default { }; `; +/** + * THE THIRD LIMB (#16544 re-review) — a nameless `functions` ARRAY entry. + * `stack.zod.ts` requires `name: z.string()` on the array form, and + * `normalizeStackInput` never touches `functions`, so `[{ handler: fn }]` + * failed the `functions` union at the parse on the un-lowered stack (exit 1) + * while `lowerBody` names it `anon_fn` before `os build`'s parse. The same + * pass now names it here too, so `os validate` accepts it. The `functions` + * MAP forms parse either way (not a limb); `hooks[*].handler` accepts a + * function un-lowered (not a limb). + */ +const CONFIG_FUNCTIONS_NAMELESS = ` +export default { + manifest: { id: 'com.example.reach_functions_nameless', name: 'reach_functions_nameless', version: '1.0.0', type: 'app' }, + objects: [${OBJECT}], + functions: [{ + handler: async (ctx: any) => { + return { ok: true, id: ctx.input.id }; + }, + }], +}; +`; + const dirs: Record = {}; function project(key: string, source: string): string { @@ -191,6 +213,7 @@ beforeAll(() => { project('body', CONFIG_BODY); project('handlerOk', CONFIG_HANDLER_OK); project('actionTarget', CONFIG_ACTION_TARGET); + project('functionsNameless', CONFIG_FUNCTIONS_NAMELESS); }); afterAll(() => { @@ -292,4 +315,13 @@ describe('#16544 — the WIDENING limb: an inline action `target` callable is no const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.actionTarget); expect(run.code, label(run)).toBe(0); }, 90_000); + + it('INTAKE — a nameless `functions` array entry is accepted too (the pass names it `anon_fn`; exit 0, valid)', async () => { + // Red-first on the same BASE/HEAD pair: on BASE the `functions` union + // refuses the entry (no `name`) and the run exits 1; on HEAD it parses. + // `os build` accepted it on both sides, as for the action leg above. + const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.functionsNameless); + expect(run.code, label(run)).toBe(0); + expect(JSON.parse(run.stdout).valid, label(run)).toBe(true); + }, 60_000); });