Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/validate-lowers-inline-handlers-before-parse.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@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. 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, 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.

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.
2 changes: 1 addition & 1 deletion content/docs/automation/hook-bodies.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 57 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -204,7 +205,62 @@ export default class Validate extends Command {
...lintUnknownStackKeys(normalized as Record<string, unknown>, ObjectStackDefinitionSchema),
...lintUnknownAuthoringKeys(normalized as Record<string, unknown>, 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. 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`, 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
// 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<string, unknown>);
const result = ObjectStackDefinitionSchema.safeParse(lowered);

if (!result.success) {
if (flags.json) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading