Skip to content

Commit bf4ebe2

Browse files
os-helpclaude
andauthored
fix(cli): pass unrecognised functions entries through the lowering (#7318) (#7461)
The map branch of the top-level `functions` lowering REBUILT the map rather than editing it: `out` admitted an entry only in the three shapes it knew — a bare callable, `{ handler: callable }`, or a plain string ref — and deleted everything else. No error, no warning, no key. Two failures came out of that. 1. Lowering stopped being IDEMPOTENT. `{ handler: 'syncBilling', effect: 'writes' }` — the shape this step itself emits for a declared writer, and the one `FlowFunctionLoweredDeclarationSchema` was added to accept in #4976 — matched none of the recognised shapes, so a second pass dropped the key and silently un-declared the writer the first pass had kept. Measured on `examples/app-showcase/dist/objectstack.json`: re-lowering it returned `{ summarizeCompletedTask: 'summarizeCompletedTask' }`, `sweepProjectHealth` gone, and the result still parsed green. 2. A MALFORMED entry was destroyed instead of reported. The headless husk `{ effect: 'writes' }` that a plain `JSON.stringify(stack)` leaves where a declaration was (#6293) left the lowering as `functions: {}` and the stack parsed green, so the build wrote an artifact missing the function rather than refusing — the evidence deleted before the parse could name it. Unrecognised entries now ride through under their own key, untouched, and `FlowFunctionEntrySchema` decides. The husk is refused where the build checks: `invalid_union` on `functions`, with `functions.sweep` in the branch tree that `formatZodErrors` (#5341) prints. The dedicated loop that re-added string entries is folded into the same pass. Bare callables, declared callables, pre-existing refs and the array form lower exactly as before. Tests pin both halves against the real pipeline: a second lowering of a lowered stack leaves the key set and the declarations unchanged (map and array), and the husk reaches the parse and is refused by key. `packages/qa/dogfood/test/build-shaped-artifact.ts` keeps its key-for-key reconciliation — it is now unreachable on this path but is still the only check that compares input `functions` keys against output ones, so it stays as the backstop against a producer that starts dropping again. Its measured claims and the showcase fixture's expectation are updated to the gate that now speaks. Claude-Session: https://claude.ai/code/session_01RiF5oDWaCLK57mPuBsyB4t Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2dce9b9 commit bf4ebe2

5 files changed

Lines changed: 215 additions & 25 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): stop `lowerCallables` deleting the `functions` entries it does not recognise (#7318)
6+
7+
The map branch of the top-level `functions` lowering REBUILT the map instead of
8+
editing it: `out` admitted an entry only in the three shapes it knew — a bare
9+
callable, `{ handler: callable }`, or a plain string ref — and everything else
10+
was dropped. No error, no warning, no key. Two distinct failures came out of
11+
that one line.
12+
13+
**A built artifact could not be lowered again.** The already-lowered declaration
14+
`{ handler: 'syncBilling', effect: 'writes' }` — the shape this very step emits
15+
for a declared writer, and the one `FlowFunctionLoweredDeclarationSchema` was
16+
added to accept in #4976 — matched none of the recognised shapes. A second pass
17+
therefore deleted the key outright, silently un-declaring the writer the first
18+
pass had gone out of its way to keep. Lowering is now idempotent: lower a
19+
lowered stack and the `functions` key set and the declared entries are
20+
unchanged, in both the map and the array spelling.
21+
22+
**A malformed entry was destroyed rather than reported.** The headless husk
23+
`{ effect: 'writes' }` — a declaration for a function that is not there, which
24+
is exactly what a plain `JSON.stringify(stack)` leaves where a declared writer
25+
was (#6293) — reached the lowering and left it as `functions: {}`. The stack
26+
then parsed GREEN, so `objectstack build` wrote an artifact missing the function
27+
instead of refusing, and the evidence had been deleted before the parse could
28+
name it.
29+
30+
Unrecognised entries now ride through under their own key, untouched, and
31+
`FlowFunctionEntrySchema` decides. The husk is refused where the build actually
32+
checks — `invalid_union` on `functions`, with the offending key nameable in the
33+
branch tree, which `formatZodErrors` (#5341) prints in the terminal.
34+
35+
Nothing changes for a stack that was building correctly: bare callables, declared
36+
callables, pre-existing string refs and the array form all lower exactly as
37+
before. A stack that was silently shipping a `functions` map missing an entry now
38+
fails its build, naming `functions` — which is the point.

packages/cli/src/utils/lower-callables.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4+
import type { z } from 'zod';
45
import { defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec';
56
import { FlowFunctionEntrySchema } from '@objectstack/spec/automation';
67
import { lowerCallables } from './lower-callables.js';
@@ -254,3 +255,119 @@ describe('lowerCallables → the spec parses what it emits (#4976, #6238)', () =
254255
.toEqual([{ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' }]);
255256
});
256257
});
258+
259+
// ── #7318: the `functions` map branch is a lowering, not a filter ───────────
260+
//
261+
// The map branch REBUILT the map from the shapes it recognised, so anything
262+
// else was deleted before the parse could see it. Two failures came out of that
263+
// one line, and both are pinned here:
264+
//
265+
// 1. Lowering stopped being IDEMPOTENT. The already-lowered declaration
266+
// `{ handler: 'syncBilling', effect: 'writes' }` — which #4976 taught
267+
// `FlowFunctionEntrySchema` to accept, and which is exactly what the first
268+
// pass emits — matched none of the recognised shapes, so a second pass
269+
// dropped the key entirely and silently un-declared the writer the first
270+
// pass had gone out of its way to keep.
271+
// 2. A MALFORMED entry was destroyed rather than reported. The headless husk
272+
// `{ effect: 'writes' }` (what a plain `JSON.stringify(stack)` leaves
273+
// where a declaration was, #6293) left the lowering as `functions: {}` and
274+
// the stack then parsed GREEN — the build writing an artifact missing the
275+
// function instead of refusing.
276+
describe('lowerCallables — unrecognised `functions` entries reach the parse (#7318)', () => {
277+
const base = {
278+
manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const },
279+
};
280+
281+
/** `objectstack compile`'s first three steps, then `JSON.stringify` — the artifact. */
282+
const buildArtifact = (functions: unknown) => {
283+
const stack = defineStack({ ...base, functions } as never);
284+
const { lowered } = lowerCallables(normalizeStackInput(stack as Record<string, unknown>));
285+
return JSON.parse(JSON.stringify(lowered)) as Record<string, unknown>;
286+
};
287+
288+
const functionsOf = (stack: Record<string, unknown>) =>
289+
stack.functions as Record<string, unknown>;
290+
291+
/** Every `path` in a Zod error, including the branches folded inside a union. */
292+
const allIssuePaths = (issues: readonly z.core.$ZodIssue[], prefix: PropertyKey[] = []): string[] =>
293+
issues.flatMap((issue) => {
294+
const path = [...prefix, ...issue.path];
295+
const nested = 'errors' in issue && Array.isArray(issue.errors)
296+
? (issue.errors as z.core.$ZodIssue[][]).flatMap((branch) => allIssuePaths(branch, path))
297+
: [];
298+
return [path.join('.'), ...nested];
299+
});
300+
301+
it('lowering a lowered stack changes nothing — same keys, same declarations', () => {
302+
// The artifact carries BOTH lowered shapes: a bare ref and a lowered
303+
// declaration. Neither may move, and no key may go missing.
304+
const once = buildArtifact({
305+
scoreLead: () => ({ score: 1 }),
306+
syncBilling: { handler: () => ({ ok: true }), effect: 'writes' },
307+
});
308+
expect(functionsOf(once)).toEqual({
309+
scoreLead: 'scoreLead',
310+
syncBilling: { handler: 'syncBilling', effect: 'writes' },
311+
});
312+
313+
const second = lowerCallables(once);
314+
315+
expect(
316+
Object.keys(functionsOf(second.lowered)).sort(),
317+
'the key set of an already-lowered `functions` map must survive a second pass',
318+
).toEqual(Object.keys(functionsOf(once)).sort());
319+
expect(functionsOf(second.lowered)).toEqual(functionsOf(once));
320+
// Nothing was left to lower, so nothing was registered — a lowered artifact
321+
// carries its callables in the sibling module, not here.
322+
expect(second.count).toBe(0);
323+
expect(ObjectStackDefinitionSchema.safeParse(second.lowered).success).toBe(true);
324+
});
325+
326+
it('is idempotent for the ARRAY form too', () => {
327+
const once = buildArtifact([{ name: 'syncBilling', handler: () => ({ ok: true }), effect: 'writes' }]);
328+
const second = lowerCallables(once);
329+
expect(second.lowered.functions).toEqual(once.functions);
330+
expect(second.count).toBe(0);
331+
});
332+
333+
it('keeps a pre-existing bare string ref under its own key (legacy bundles)', () => {
334+
const { lowered } = lowerCallables({ functions: { legacy: 'legacy' } });
335+
expect(lowered.functions).toEqual({ legacy: 'legacy' });
336+
});
337+
338+
it('passes the headless husk through, so the parse refuses it by key', () => {
339+
// The card's measured case. `{ sweep: { effect: 'writes' } }` is what
340+
// `JSON.stringify` leaves of a declared writer — a declaration for a
341+
// function that is not there.
342+
const husk = { sweep: { effect: 'writes' } };
343+
const { lowered, count } = lowerCallables({ ...base, functions: husk });
344+
345+
expect(
346+
lowered.functions,
347+
'the husk must reach the artifact intact — deleting it here is what made the bad build green',
348+
).toEqual(husk);
349+
expect(count).toBe(0);
350+
351+
// Refused at the entry…
352+
const entry = FlowFunctionEntrySchema.safeParse(husk.sweep);
353+
expect(entry.success).toBe(false);
354+
expect(entry.success ? [] : entry.error.issues.map((i) => i.code)).toContain('invalid_union');
355+
356+
// …and refused by the whole-stack parse the build actually runs, with the
357+
// offending key nameable in the tree rather than an `invalid_union` that
358+
// stops at `functions`.
359+
const result = ObjectStackDefinitionSchema.safeParse(lowered);
360+
expect(result.success, 'a stack whose `functions` map holds a husk must NOT parse green').toBe(false);
361+
const paths = result.success ? [] : allIssuePaths(result.error.issues);
362+
expect(paths).toContain('functions');
363+
expect(paths, 'the rejection must name the key it is about').toContain('functions.sweep');
364+
});
365+
366+
it('refuses a declaration whose `handler` is neither callable nor a ref', () => {
367+
// Same rule, the other way a declaration goes wrong: the key is kept and
368+
// the schema gets to name it.
369+
const { lowered } = lowerCallables({ ...base, functions: { sweep: { handler: 42, effect: 'writes' } } });
370+
expect(lowered.functions).toEqual({ sweep: { handler: 42, effect: 'writes' } });
371+
expect(ObjectStackDefinitionSchema.safeParse(lowered).success).toBe(false);
372+
});
373+
});

packages/cli/src/utils/lower-callables.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,34 @@ export function lowerCallables(input: Record<string, unknown>): LoweringResult {
165165
taken.add(ref);
166166
functions[ref] = value.handler as AnyFn;
167167
out[ref] = { ...value, handler: ref };
168+
} else {
169+
// NOTHING ELSE IS THIS STEP'S TO JUDGE (#7318). Everything that is not
170+
// a callable to lower rides through under its own key, untouched, and
171+
// `FlowFunctionEntrySchema` decides whether it is legal.
172+
//
173+
// Two kinds of value arrive here, and passing both through is the same
174+
// decision, not a compromise between two:
175+
//
176+
// ALREADY LOWERED — a bare ref (`'scoreLead'`, #4343) or a lowered
177+
// declaration (`{ handler: 'scoreLead', effect: 'writes' }`, #4976).
178+
// Both are shapes the schema accepts, so lowering a lowered stack
179+
// must be a no-op: same key set, same declarations. Rebuilding the
180+
// map around a fixed list of recognised shapes made that false — the
181+
// lowered declaration matched none of them and was deleted, so a
182+
// second pass (a re-lowered artifact, a fixture that lowers what it
183+
// read back) silently un-declared the writer the FIRST pass had
184+
// carefully kept.
185+
//
186+
// MALFORMED — the headless husk `{ effect: 'writes' }` that a plain
187+
// `JSON.stringify(stack)` leaves where a declaration was (#6293).
188+
// Deleting it here erased the evidence BEFORE the parse: the artifact
189+
// came out `functions: {}` and validated green, so the build shipped
190+
// an app missing the function instead of refusing. Handed on, it
191+
// reaches `FlowFunctionEntrySchema`, which names it — `invalid_union`
192+
// on this key — and `objectstack build` fails where it should.
193+
out[key] = value;
168194
}
169195
}
170-
// Preserve any pre-existing string entries (legacy bundles).
171-
for (const [key, value] of Object.entries(fnsField)) {
172-
if (typeof value === 'string') out[key] = value;
173-
}
174196
(lowered as Record<string, unknown>).functions = out;
175197
}
176198

packages/qa/dogfood/test/build-shaped-artifact.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,13 @@
2020
// The declared entry made a noise ONCE, on the path where the residue is fed
2121
// straight to the parse: `FlowFunctionEntrySchema` refuses an entry declaring an
2222
// effect for a function it does not carry, and that red CI job is the only
23-
// reason anybody learned about this (#4976). Measured here, it is not a general
24-
// guarantee — put the same husk back through the lowering and it never reaches
25-
// the schema at all (see the key-for-key check below). The BARE entry never made
26-
// a noise on any path: it vanishes key and all, the artifact holds
27-
// `functions: {}`, and the fixture parses green carrying zero of what it
28-
// advertises.
23+
// reason anybody learned about this (#4976). Putting the same husk back through
24+
// the lowering used to silence even that — the map branch deleted the entry
25+
// before the schema saw it — until #7318 taught the lowering to hand an
26+
// unrecognised entry on unchanged, so the refusal now happens on both paths.
27+
// The BARE entry still makes no noise anywhere: it vanishes key and all before
28+
// this module is ever called, the artifact holds `functions: {}`, and the
29+
// fixture parses green carrying zero of what it advertises.
2930
// `showcase-declarative-endpoints.dogfood.test.ts` shipped exactly that for its
3031
// whole existence. AGENTS.md, "Absence must be loud": a verifier that silently
3132
// degrades is worse than no verifier.
@@ -174,14 +175,22 @@ export function buildShapedArtifact(stack: Record<string, unknown>): BuildShaped
174175
);
175176
}
176177

177-
// Key-for-key on the `functions` MAP, which the lowering rebuilds rather than
178-
// edits: its `out` object admits an entry only in the three shapes it knows
179-
// (a callable, `{ handler: callable }`, a string ref), and anything else is
180-
// dropped — no error, no warning, no key. Measured on this exact stack: hand
181-
// the lowering the `{ effect: 'writes' }` husk `JSON.stringify` leaves behind
182-
// and the artifact comes out with `functions: {}`, parsing green, which is the
183-
// #6293 failure wearing a different hat. The parse below cannot see it: by the
184-
// time it runs, the evidence has been deleted.
178+
// Key-for-key on the `functions` MAP. This was the live gate until #7318: the
179+
// lowering rebuilt the map and admitted an entry only in the three shapes it
180+
// knew (a callable, `{ handler: callable }`, a string ref), dropping anything
181+
// else — no error, no warning, no key. Measured on this exact stack then:
182+
// hand the lowering the `{ effect: 'writes' }` husk `JSON.stringify` leaves
183+
// behind and the artifact came out `functions: {}`, parsing green, which is
184+
// the #6293 failure wearing a different hat; the parse below could not see it,
185+
// because by the time it ran the evidence had been deleted.
186+
//
187+
// The producer was fixed at the source — an entry `lowerCallables` does not
188+
// recognise now rides through under its own key and the parse below refuses
189+
// it by name — so this check no longer has anything to catch on that path.
190+
// It is KEPT as the backstop it always was: it is the only assertion that
191+
// reconciles the input's `functions` keys against the output's, so a future
192+
// lowering that starts deleting again fails here, named, instead of shrinking
193+
// this stand-in in silence.
185194
const inputFns = normalized.functions;
186195
if (isPlainObject(inputFns)) {
187196
const kept = new Set(Object.keys((lowering.lowered.functions ?? {}) as Record<string, unknown>));

packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,18 @@ describe('[#6293] the stand-in artifact carries what a built one carries', () =>
194194
it('REFUSES to build an artifact out of that residue instead of quietly shrinking', () => {
195195
// The reverse verification, kept in the suite rather than done once by hand:
196196
// feed the helper the very thing this fixture used to write and it must
197-
// fail, loudly, naming what went missing. Direction predicted before it was
198-
// run — and the mechanism is NOT the one #4976 documented. The schema never
199-
// sees the husk: `lowerCallables` rebuilds the `functions` map from the three
200-
// shapes it recognises and deletes everything else, so the residue would have
201-
// reached the parse as `functions: {}` and passed. The gate that speaks here
202-
// is the helper's own key-for-key reconciliation.
197+
// fail, loudly. Which gate speaks CHANGED in #7318, and the new one is the
198+
// mechanism #4976 documented: `lowerCallables` used to rebuild the
199+
// `functions` map from the shapes it recognised and delete everything else,
200+
// so the residue reached the parse as `functions: {}` and passed — only the
201+
// helper's own key-for-key reconciliation caught it. The lowering now hands
202+
// an unrecognised entry ON, so the husk reaches `FlowFunctionEntrySchema`
203+
// and the SPEC refuses it, here and in `objectstack build` alike. The
204+
// reconciliation stays as the backstop for a producer that starts dropping
205+
// again.
203206
const residue = JSON.parse(JSON.stringify(showcaseStack)) as Record<string, unknown>;
204-
expect(() => buildShapedArtifact(residue)).toThrowError(/dropped 1 `functions` entr.*sweepProjectHealth/s);
207+
expect(() => buildShapedArtifact(residue))
208+
.toThrowError(/does not satisfy ObjectStackDefinitionSchema[\s\S]*functions/);
205209
});
206210
});
207211

0 commit comments

Comments
 (0)