Skip to content

Commit c9727c1

Browse files
committed
fix(cli): declare and pin the third widening limb — a nameless functions 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
1 parent f974fd2 commit c9727c1

3 files changed

Lines changed: 48 additions & 12 deletions

File tree

.changeset/validate-lowers-inline-handlers-before-parse.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `ho
1010
`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`:
1111

1212
- **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`.
13-
- **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.
13+
- **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.
1414
- 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.
1515
- 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.
1616
- Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it.

packages/cli/src/commands/validate.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -234,17 +234,21 @@ export default class Validate extends Command {
234234
//
235235
// NOT A PURE NARROWING. The same pass also lowers an inline action
236236
// `target` callable (`actions[*]`, `objects[*].actions[*]`) to a ref
237-
// string plus `body`. `ActionSchema.target` is `z.string()`, and
238-
// `normalizeStackInput` never touches function values, so before
239-
// this step the un-lowered parse REFUSED such a config
240-
// (`invalid_type` at `actions.0.target`, exit 1) while `os build`
241-
// accepted it all along. It is accepted here now — an accepted-set
242-
// relaxation on this command, measured through the real CLI on
243-
// both sides and pinned in
244-
// `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`; parity
245-
// with the build is the intent, and it is declared rather than
246-
// assumed because a sibling's acceptance is evidence of intent, not
247-
// a declaration on this command's face.
237+
// string plus `body`, and names a nameless `functions` ARRAY entry
238+
// (`[{ handler: fn }]`) `anon_fn`. `ActionSchema.target` is
239+
// `z.string()`, the array entry requires `name`, and
240+
// `normalizeStackInput` touches neither, so before this step the
241+
// un-lowered parse REFUSED both configs (`invalid_type` at
242+
// `actions.0.target`; `invalid_union` at `functions`; exit 1) while
243+
// `os build` accepted them all along. Both are accepted here now —
244+
// accepted-set relaxations on this command, each measured through
245+
// the real CLI on both sides and pinned in
246+
// `test/lint-hook-rules-reach-handler-hooks.e2e.test.ts`. Not
247+
// limbs: `hooks[*].handler` accepts a function un-lowered, and the
248+
// `functions` MAP forms parse either way. Parity with the build is
249+
// the intent, and it is declared rather than assumed because a
250+
// sibling's acceptance is evidence of intent, not a declaration on
251+
// this command's face.
248252
//
249253
// Nothing is emitted, so `lowering.functions` is unused here, and
250254
// the extraction refusals in `bodyExtractionWarnings` are NOT

packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,28 @@ export default {
177177
};
178178
`;
179179

180+
/**
181+
* THE THIRD LIMB (#16544 re-review) — a nameless `functions` ARRAY entry.
182+
* `stack.zod.ts` requires `name: z.string()` on the array form, and
183+
* `normalizeStackInput` never touches `functions`, so `[{ handler: fn }]`
184+
* failed the `functions` union at the parse on the un-lowered stack (exit 1)
185+
* while `lowerBody` names it `anon_fn` before `os build`'s parse. The same
186+
* pass now names it here too, so `os validate` accepts it. The `functions`
187+
* MAP forms parse either way (not a limb); `hooks[*].handler` accepts a
188+
* function un-lowered (not a limb).
189+
*/
190+
const CONFIG_FUNCTIONS_NAMELESS = `
191+
export default {
192+
manifest: { id: 'com.example.reach_functions_nameless', name: 'reach_functions_nameless', version: '1.0.0', type: 'app' },
193+
objects: [${OBJECT}],
194+
functions: [{
195+
handler: async (ctx: any) => {
196+
return { ok: true, id: ctx.input.id };
197+
},
198+
}],
199+
};
200+
`;
201+
180202
const dirs: Record<string, string> = {};
181203

182204
function project(key: string, source: string): string {
@@ -191,6 +213,7 @@ beforeAll(() => {
191213
project('body', CONFIG_BODY);
192214
project('handlerOk', CONFIG_HANDLER_OK);
193215
project('actionTarget', CONFIG_ACTION_TARGET);
216+
project('functionsNameless', CONFIG_FUNCTIONS_NAMELESS);
194217
});
195218

196219
afterAll(() => {
@@ -292,4 +315,13 @@ describe('#16544 — the WIDENING limb: an inline action `target` callable is no
292315
const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.actionTarget);
293316
expect(run.code, label(run)).toBe(0);
294317
}, 90_000);
318+
319+
it('INTAKE — a nameless `functions` array entry is accepted too (the pass names it `anon_fn`; exit 0, valid)', async () => {
320+
// Red-first on the same BASE/HEAD pair: on BASE the `functions` union
321+
// refuses the entry (no `name`) and the run exits 1; on HEAD it parses.
322+
// `os build` accepted it on both sides, as for the action leg above.
323+
const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.functionsNameless);
324+
expect(run.code, label(run)).toBe(0);
325+
expect(JSON.parse(run.stdout).valid, label(run)).toBe(true);
326+
}, 60_000);
295327
});

0 commit comments

Comments
 (0)