Skip to content

Commit f974fd2

Browse files
committed
fix(cli): declare and pin the widening limb — os validate now accepts an inline action target callable, as os build always did
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
1 parent 464c70d commit f974fd2

4 files changed

Lines changed: 95 additions & 12 deletions

File tree

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
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.
99

10-
`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:
10+
`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

12-
- 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`.
12+
- **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.
1314
- 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.
1415
- 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.
15-
- Nothing about what `objectstack build` accepts changes.
16+
- Nothing about what `objectstack build` accepts changes; on both axes above `objectstack validate` now agrees with it.
1617

17-
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.
18+
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.
1819

1920
`@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.

packages/cli/src/commands/validate.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,29 @@ export default class Validate extends Command {
222222
//
223223
// POSITION IS LOAD-BEARING: after the two pre-parse unknown-key
224224
// lints above, which keep reading `normalized` exactly as before,
225-
// and before the parse, which now reads the lowered view — the order
226-
// `compile.ts` runs. `lowerCallables` returns a NEW top-level object
227-
// and never mutates its input, so `normalized` — the registry's
228-
// `normalized` tier below, `collectMetadataStats(config)`, the
229-
// structural advisories — is byte-for-byte what it was; only what
230-
// the parse and the registry's `parsed` tier see changes.
225+
// and before the parse, which now reads the lowered view. That is
226+
// `compile.ts`'s lower-BEFORE-parse order exactly; its key lints sit
227+
// AFTER its parse, so on both doors what protects the lints' input
228+
// is non-mutation, not ordering: `lowerCallables` returns a NEW
229+
// top-level object and never mutates its input, so `normalized` —
230+
// the registry's `normalized` tier below, `collectMetadataStats(
231+
// config)`, the structural advisories — is byte-for-byte what it
232+
// was; only what the parse and the registry's `parsed` tier see
233+
// changes.
234+
//
235+
// NOT A PURE NARROWING. The same pass also lowers an inline action
236+
// `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.
231248
//
232249
// Nothing is emitted, so `lowering.functions` is unused here, and
233250
// the extraction refusals in `bodyExtractionWarnings` are NOT

packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,9 @@
4343
* This is deliberately the opposite call from `bodyExtractionWarnings`, which
4444
* sits under its own key one line below in the payload. That is not an
4545
* inconsistency: `{origin,reason}` extraction records have NO counterpart in
46-
* `os validate --json` (validate lowers no handlers), so there is no parity to
47-
* hold and a sibling key is right. The undeclared-key findings do have a
46+
* `os validate --json` (validate lowers too since #16544, but emits nothing, so
47+
* it surfaces no extraction record), so there is no parity to hold and a
48+
* sibling key is right. The undeclared-key findings do have a
4849
* counterpart, and it is already in `warnings`.
4950
*
5051
* The payload's top-level key set is pinned unchanged below for that reason:

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,46 @@ export default {
137137
};
138138
`;
139139

140+
/**
141+
* THE WIDENING LIMB (#16544 contract review) — the axis the hook legs above
142+
* cannot see. `ActionSchema.target` is `z.string()`, and `normalizeStackInput`
143+
* never touches function values, so a plain-object config with an inline
144+
* action `target` callable hit `invalid_type` at the parse: `os validate`
145+
* REFUSED it before #16544 (exit 1) while `os build`, which lowers before it
146+
* parses, always accepted it. The same `lowerCallables` pass now rewrites the
147+
* callable to a ref string plus `body` on this door too, so `os validate`
148+
* ACCEPTS it — an accepted-set relaxation on a published command, declared in
149+
* the changeset and pinned here beside the hook legs. Both slots
150+
* `lowerActionCallable` handles (`actions[*]`, `objects[*].actions[*]`).
151+
*/
152+
const CONFIG_ACTION_TARGET = `
153+
export default {
154+
manifest: { id: 'com.example.reach_action_target', name: 'reach_action_target', version: '1.0.0', type: 'app' },
155+
objects: [{
156+
name: 'crm_case',
157+
label: 'Case',
158+
sharingModel: 'private',
159+
fields: {
160+
title: { type: 'text', label: 'Title' },
161+
},
162+
actions: [{
163+
name: 'ping_case',
164+
label: 'Ping case',
165+
target: async (ctx: any) => {
166+
return { ok: true, id: ctx.input.id };
167+
},
168+
}],
169+
}],
170+
actions: [{
171+
name: 'ping_global',
172+
label: 'Ping',
173+
target: async (ctx: any) => {
174+
return { ok: true, id: ctx.input.id };
175+
},
176+
}],
177+
};
178+
`;
179+
140180
const dirs: Record<string, string> = {};
141181

142182
function project(key: string, source: string): string {
@@ -150,6 +190,7 @@ beforeAll(() => {
150190
project('handler', CONFIG_HANDLER);
151191
project('body', CONFIG_BODY);
152192
project('handlerOk', CONFIG_HANDLER_OK);
193+
project('actionTarget', CONFIG_ACTION_TARGET);
153194
});
154195

155196
afterAll(() => {
@@ -229,3 +270,26 @@ describe('#16544 — door: `os validate` (lowers since #16544; measured NOT lowe
229270
expect(rulesIn(run)).not.toContain(READONLY_RULE);
230271
}, 60_000);
231272
});
273+
274+
describe('#16544 — the WIDENING limb: an inline action `target` callable is now ACCEPTED by `os validate`', () => {
275+
// Measured red-first on the same BASE/HEAD pair as the hook legs: on BASE
276+
// this leg fails with `invalid_type` at `actions.0.target` and
277+
// `objects.0.actions.0.target` (expected string, received function) and
278+
// exit 1; on HEAD the lowered stack parses and the run exits 0. The build
279+
// leg beside it is the parity reading: `os build` accepted this config on
280+
// both sides, which is the intent — and the reason this is a declared
281+
// relaxation rather than a narrowing.
282+
it('INTAKE — `os validate` accepts the inline action target (exit 0, valid, no invalid_type)', async () => {
283+
const run = await runCli(['validate', 'objectstack.config.ts', '--json'], dirs.actionTarget);
284+
expect(run.code, label(run)).toBe(0);
285+
const json = JSON.parse(run.stdout);
286+
expect(json.valid, label(run)).toBe(true);
287+
const codes = (Array.isArray(json.errors) ? json.errors : []).map((e: { code?: unknown }) => e?.code);
288+
expect(codes).not.toContain('invalid_type');
289+
}, 60_000);
290+
291+
it('PARITY — `os build` accepts the same config (it lowered before its parse all along)', async () => {
292+
const run = await runCli(['build', 'objectstack.config.ts', '--json'], dirs.actionTarget);
293+
expect(run.code, label(run)).toBe(0);
294+
}, 90_000);
295+
});

0 commit comments

Comments
 (0)