From 375ded2efeb6862313e499e809f56b8a414a8618 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 12:54:58 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(spec):=20`FlowFunctionEntrySchema`=20?= =?UTF-8?q?=E6=94=B6=E4=B8=8B=20lowered=20declaration,=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=BD=A2=E5=86=99=E6=89=8B=E9=87=8D=E6=96=B0=E6=89=9B=E5=BE=97?= =?UTF-8?q?=E4=BD=8F=20`objectstack=20build`=20(#4976)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lowerCallables` 自 #4396 起会把声明形 `functions` 条目降级成 `{ handler: '', effect: 'writes' }` —— 保留声明、只把可调用体换成字符串 ref。同一次改动没有同步扩 union,于是 CLI 亲手产出的形状被它自己必须通过的 schema 拒收,`objectstack build` 报 `invalid_union: Invalid input`,路径止步于 `functions`,不点名键、不点名条目、不给原因。 补上第四个 union 成员:lowered declaration。它由 `FlowFunctionDeclarationSchema.extend({ handler })` 派生而来,而不是在旁边重抄 一份 —— 两者只差一个字段,所以严格性、surface 名、别名表与 `` `efect` → `effect` `` 处方原样随行,声明形将来加键也自动带过去。别名区未 触碰(不新增 `strictObject` 注册)。 `effect` 在此保持 optional + 默认值:走 `defineStack` 的路径会先把 `'pure'` 默认值落实,但 `{ strict: false }` 会跳过那次 parse,要求必填就会把同一个 `invalid_union` 还给那条路径。 runtime 半边本就正确,未改一行:`normalizeFlowFunctionEntry` 对两种 lowered 形状都返回 `undefined`(都不携带可调用体),而 `mergeRuntimeModule` 在任何 collector 之前就把 sidecar 模块的函数重新挂回 JSON 携带的声明上,所以 `effect` 在构建路径上完整抵达 `collectBundleFunctionEntries`。 两半之间补上跨界 pin:走真实流水线(`defineStack` → `normalizeStackInput` → `lowerCallables` → parse),而不是手写一份「以为 lowering 会产出什么」的样例 —— 正是这条从未有人跨过的边界,让两侧各自全绿而 build 死在接缝上。 行为面唯一变化:手写 `{ handler: 'someName' }` 由拒收变为接受。该拒收无法与本 成员共存,也本不该存在 —— 裸字符串条目(`functions: { foo: 'foo' }`)自 #4343 起就被接受并附带「注册不到任何东西」的说明,只拒 record 拼法而放行 string 拼法 是同一个契约的两种方言。两者失败方式一致且响亮:execute 时 `no function named '…' is registered`(#1870)。 showcase 换回诚实拼法(`{ handler: sweepProjectHealth, effect: 'writes' }`), 并把「钉死裸形」的守卫倒转为「唯一真正写数据的条目必须声明」—— 那才是值得守的 事实。相邻缺口(数组形 `functions: [{ name, handler }]` 同样无法往返)另立 #6238,不在本 PR 范围。 --- .../flow-function-lowered-declaration.md | 70 ++++++++++++++ examples/app-showcase/objectstack.config.ts | 24 +++-- .../app-showcase/test/inert-wirings.test.ts | 32 +++---- .../cli/src/utils/lower-callables.test.ts | 94 ++++++++++++++++++ .../spec/src/automation/flow-function.test.ts | 66 ++++++++++++- .../spec/src/automation/flow-function.zod.ts | 96 ++++++++++++++----- 6 files changed, 323 insertions(+), 59 deletions(-) create mode 100644 .changeset/flow-function-lowered-declaration.md diff --git a/.changeset/flow-function-lowered-declaration.md b/.changeset/flow-function-lowered-declaration.md new file mode 100644 index 0000000000..9b8bcefb43 --- /dev/null +++ b/.changeset/flow-function-lowered-declaration.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec): `functions: { fn: { handler, effect: 'writes' } }` survives `objectstack build` (#4976) + +`FlowFunctionEntrySchema` gains a fourth union member — the **lowered +declaration**, a `functions` entry whose `handler` has been replaced by the +string ref `objectstack build` emits: + +``` +functions: { + sweepProjectHealth: { handler: 'sweepProjectHealth', effect: 'writes' }, +} +``` + +Nothing an author writes changes. This shape is produced by the CLI, not typed +by a person: `lowerCallables` replaces every inline callable with a serialisable +ref before the stack is parsed (it must — `z.function()` wraps callables and +would break the ref mapping), and since #4396 it keeps the declaration beside +the ref so what a function said about itself survives into the artifact. The +union was not extended in that change, so the artifact it started emitting was +rejected by the very schema it had to pass: + +``` + ✗ Validation failed + + functions: + ✗ functions + invalid_union: Invalid input +``` + +Loading from source was unaffected — `objectstack dev`, `objectstack validate` +and the test suite all passed — so the failure appeared only at build, on the +one spelling the platform asks writers to use. That is the same asymmetry #4343 +fixed for the bare handler ref, one shape over. + +**Why this was worse than a failed build.** `effect: 'writes'` exists so a +function that writes is not counted as having written nothing (#4396, #4354): a +`script` step reports no record metrics *because* flow functions are +contractually pure, and a declared writer instead reports `unmeasuredEffect` so +the run's broken-sweep query (`selected > 0 AND acted = 0 AND unmeasured = 0`) +stays off it. The error above names no key, no entry and no reason, so the +practical repair an author reaches for is deleting the declaration — shipping an +undeclared writer, which is exactly the state it exists to prevent, recorded +permanently in `sys_automation_run`. + +**One behaviour change worth stating.** `{ handler: 'someName' }` written by +hand now parses where it used to be rejected as "handler is not callable". The +rejection could not survive this member and should not have: a bare string entry +(`functions: { foo: 'foo' }`) has been accepted since #4343 with the caveat that +it registers nothing, so refusing the record spelling of the same mistake while +accepting the string spelling was two dialects for one contract. Both fail the +same way, loudly, at execute: `no function named '…' is registered` (#1870). +Everything else stays strict — the lowered member is *derived* from the authored +declaration rather than re-typed beside it, so `{ handler: 'fn', efect: 'writes' }` +still raises the named surface and the `` `efect` → `effect` `` prescription, an +unknown `effect` value is still refused, and an empty ref is still not a name. + +**Runtime is unchanged and was already correct.** `normalizeFlowFunctionEntry` +returns `undefined` for a lowered entry in both its shapes, because neither +carries a callable; `mergeRuntimeModule` re-attaches the sidecar module's +function to the declaration the JSON carried *before* any collector runs, so +`effect` reaches `collectBundleFunctionEntries` intact on the built path. + +The two halves are now pinned against each other by a round-trip test that +drives the real pipeline (`defineStack` → `normalizeStackInput` → +`lowerCallables` → parse) instead of a hand-written sample of what the lowering +is believed to emit — the crossing neither side previously made, which is why +both stayed green while the build failed on the join. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 0adf2045b5..14bceaa8d0 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -214,23 +214,21 @@ export default defineStack({ // `sweepProjectHealth` — the handler `HealthSweepJob` names — lives here too. // It is the case the pure contract does not cover: a nightly sweep has no // downstream declarative node to persist for it, so it writes over an engine - // handle captured at `onEnable`. + // handle captured at `onEnable`. That is why it is spelled the DECLARED way + // (#4396) — an undeclared writer is counted as having written nothing, which + // is indistinguishable from the broken sweep #4354 exists to detect. // - // ⚠️ Do NOT rewrite this as `{ handler: sweepProjectHealth, effect: 'writes' }`. - // That declared form (#4396) is the honest spelling for a writer and is what - // this entry wants — but it cannot survive `objectstack build` today: the CLI - // lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` and - // `FlowFunctionEntrySchema` accepts a bare callable, a declaration whose - // `handler` is a CALLABLE, or a bare string ref — never a declaration whose - // handler has been lowered to a string. `pnpm build` fails with - // `functions: invalid_union`. Filed as #4976; switch back once it lands. - // Nothing is lost at runtime meanwhile: `effect` has exactly one consumer, - // the `script` node's `unmeasuredEffect` metric, and the JOB path drops it - // (`collectBundleFunctions` keeps only the handler). + // This entry authored the bare form until #4976, not because the bare form was + // right but because the declared one could not survive `objectstack build`: + // the CLI lowers it to `{ handler: 'sweepProjectHealth', effect: 'writes' }` + // and `FlowFunctionEntrySchema` had no member for a declaration whose handler + // is a ref, so `pnpm build` failed with `functions: invalid_union`. #4976 + // added that member; the honest spelling is back, and this app is the + // end-to-end proof that it builds. functions: { summarizeCompletedTask: ({ input }: { input: Record }) => `Completed: ${String(input.title ?? 'task')} (priority ${String(input.priority ?? 'normal')}).`, - sweepProjectHealth, + sweepProjectHealth: { handler: sweepProjectHealth, effect: 'writes' as const }, }, jobs: allJobs, emailTemplates: allEmails, diff --git a/examples/app-showcase/test/inert-wirings.test.ts b/examples/app-showcase/test/inert-wirings.test.ts index 9ccf6afa2a..0afabf39f8 100644 --- a/examples/app-showcase/test/inert-wirings.test.ts +++ b/examples/app-showcase/test/inert-wirings.test.ts @@ -96,24 +96,22 @@ describe('declarative jobs resolve their handler (#4774 ①)', () => { }); } - it('every functions entry is authored in a form `objectstack build` can carry', () => { - // `objectstack build` LOWERS each inline callable to a serialisable string - // ref before the stack is parsed, and `FlowFunctionEntrySchema` accepts a - // bare callable, a declaration whose `handler` is a CALLABLE, or a bare - // string ref — but NOT a declaration whose handler has been lowered to a - // string, which is exactly what the CLI emits for the declared form - // (`{ handler: fn, effect: 'writes' }`, #4396). So authoring the declared - // form here builds green from source and fails `pnpm build` with - // `functions: invalid_union`. Filed as #4976. + it('the sweep DECLARES that it writes — an undeclared writer reads as a broken sweep', () => { + // The inverse of the guard that stood here until #4976. That one pinned + // every entry to the BARE form, because the declared spelling could not + // survive `objectstack build`: the CLI lowers it to + // `{ handler: 'sweepProjectHealth', effect: 'writes' }` and + // `FlowFunctionEntrySchema` had no member for a declaration whose handler + // is a ref, so the reference app was pinned to the dishonest spelling to + // keep `pnpm build` green. // - // Pinning the bare form keeps that failure out of the reference app until - // the schema accepts the lowered declaration. Delete this guard — don't - // work around it — when #4976 lands. - const declared = functionNames().filter((name) => typeof functionEntry(name) !== 'function'); - expect( - declared, - `declared-form functions entry/entries cannot survive \`objectstack build\` (#4976): ${declared.join(', ')}`, - ).toEqual([]); + // #4976 added that member, so the pin inverts rather than disappears — the + // thing worth guarding was never "bare", it was that the one entry which + // genuinely writes says so. `sweepProjectHealth` is a nightly job with no + // downstream declarative node to count its writes, so undeclared it reports + // `selected: N, acted: 0` — indistinguishable from the broken sweep #4354 + // exists to detect, permanently, in `sys_automation_run`. + expect(functionEntry('sweepProjectHealth')).toMatchObject({ effect: 'writes' }); }); }); diff --git a/packages/cli/src/utils/lower-callables.test.ts b/packages/cli/src/utils/lower-callables.test.ts index 2d637bcd38..2c8c3f7d10 100644 --- a/packages/cli/src/utils/lower-callables.test.ts +++ b/packages/cli/src/utils/lower-callables.test.ts @@ -1,6 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { FlowFunctionEntrySchema } from '@objectstack/spec/automation'; import { lowerCallables } from './lower-callables.js'; // ── #3855: `target` is the only handler slot ──────────────────────────────── @@ -124,3 +126,95 @@ describe('lowerCallables — declared `functions` entries (#4396)', () => { expect(entry).toEqual({ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' }); }); }); + +// ── #4976: the lowering and the schema must round-trip ────────────────────── +// +// Every test above stops at the shape `lowerCallables` EMITS, and every spec +// test parses only shapes an author WRITES. Nothing crossed the boundary — so +// when #4396 taught this step to keep a declared entry's declaration, and the +// union in `flow-function.zod.ts` was not extended in the same change, both +// halves stayed green and `objectstack build` failed on the join with +// `invalid_union: Invalid input` and no path past `functions`. +// +// These tests are that boundary, driven through the real build pipeline +// (`defineStack` → `normalizeStackInput` → `lowerCallables` → parse) rather +// than a hand-written sample of what the lowering is believed to emit: a +// hand-written sample is a third copy of the truth and drifts exactly the way +// the two halves already did. +// +// SCOPE: the map form. The ARRAY form (`functions: [{ name, handler }]`) does +// not round-trip either — in both its bare and declared spellings, since #4343 +// and #4976 each only ever touched the map — and its member lives in +// `stack.zod.ts` rather than in `FlowFunctionEntrySchema`. Filed as #6238; +// extend the parametrisation below when it lands. +describe('lowerCallables → the spec parses what it emits (#4976)', () => { + const base = { + manifest: { id: 'com.example.demo', name: 'demo', version: '1.0.0', type: 'app' as const }, + }; + + /** Exactly what `objectstack compile` does, in the order it does it. */ + const buildPipeline = (functions: Record) => { + const stack = defineStack({ ...base, functions } as never); + const normalized = normalizeStackInput(stack as Record); + return lowerCallables(normalized); + }; + + const cases: Array<[label: string, functions: Record]> = [ + ['a bare handler', { scoreLead: () => ({ score: 1 }) }], + ['a declared writer', { syncBilling: { handler: () => ({ ok: true }), effect: 'writes' } }], + ['a declaration that states the pure default', { scoreLead: { handler: () => ({ score: 1 }), effect: 'pure' } }], + ['a declaration that states nothing', { scoreLead: { handler: () => ({ score: 1 }) } }], + ['both spellings side by side', { + scoreLead: () => ({ score: 1 }), + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, + }], + ]; + + for (const [label, functions] of cases) { + it(`parses every entry it emits for ${label}`, () => { + const emitted = (buildPipeline(functions).lowered as { + functions: Record; + }).functions; + + for (const [name, entry] of Object.entries(emitted)) { + const result = FlowFunctionEntrySchema.safeParse(entry); + expect( + result.success, + `emitted entry '${name}' (${JSON.stringify(entry)}) is not a shape FlowFunctionEntrySchema accepts: ` + + JSON.stringify(result.success ? [] : result.error.issues), + ).toBe(true); + } + }); + + it(`parses the whole lowered stack for ${label}`, () => { + // The assertion the build itself makes (`compile.ts` step 3). Parsing the + // entries one by one can pass while the stack does not — `functions` is a + // union of a record and an array, so a rejected entry surfaces only as + // `invalid_union` on the parent, which is precisely the unreadable error + // the issue is about. + const { lowered } = buildPipeline(functions); + const result = ObjectStackDefinitionSchema.safeParse(lowered); + expect( + result.success, + `lowered stack rejected: ${JSON.stringify(result.success ? [] : result.error.issues)}`, + ).toBe(true); + }); + } + + it('carries the declaration into the artifact, not just past the parse', () => { + // Surviving the parse is worthless if `effect` is dropped on the way — that + // would re-create #4396's silent un-declaring with a green build. The + // artifact must still SAY 'writes', because that string is what + // `mergeRuntimeModule` re-attaches the module's callable to at boot. + const { lowered } = buildPipeline({ + syncBilling: { handler: () => ({ ok: true }), effect: 'writes' }, + }); + const parsed = ObjectStackDefinitionSchema.parse(lowered) as { + functions: Record; + }; + expect(parsed.functions.syncBilling).toEqual({ handler: 'syncBilling', effect: 'writes' }); + // And it is JSON — the artifact is `objectstack.json`, not a module. + expect(JSON.parse(JSON.stringify(lowered)).functions.syncBilling) + .toEqual({ handler: 'syncBilling', effect: 'writes' }); + }); +}); diff --git a/packages/spec/src/automation/flow-function.test.ts b/packages/spec/src/automation/flow-function.test.ts index c2f95ca31b..75685d7f9c 100644 --- a/packages/spec/src/automation/flow-function.test.ts +++ b/packages/spec/src/automation/flow-function.test.ts @@ -66,8 +66,28 @@ describe('FlowFunctionEntrySchema', () => { expect(FlowFunctionEntrySchema.safeParse({ handler: () => 1, effect: 'writes' }).success).toBe(true); }); - it('rejects a declaration whose handler is not callable', () => { - expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(false); + it('rejects a declaration whose handler is neither a callable nor a ref', () => { + // Narrowed in #4976, and the narrowing is the point rather than a + // concession. This assertion used to read `{ handler: 'scoreLead' }` — + // "handler is not callable" — but a string handler is exactly what + // `objectstack build` emits for a declared entry, so the union now accepts + // it (see the lowered-declaration cases below). What survives is the + // verdict on a handler that is neither: no callable, no name. + expect(FlowFunctionEntrySchema.safeParse({ handler: 42 }).success).toBe(false); + expect(FlowFunctionEntrySchema.safeParse({ handler: '' }).success).toBe(false); + expect(FlowFunctionEntrySchema.safeParse({ effect: 'writes' }).success).toBe(false); + }); + + it('accepts a hand-authored `{ handler: }` for the same reason it accepts a bare name', () => { + // The inversion #4976 causes, stated plainly rather than left as a + // surprise. Hand-authoring the lowered form registers nothing — but that + // was ALREADY true of the bare string member (`functions: { foo: 'foo' }`), + // which has been accepted since #4343 with exactly that caveat. Rejecting + // the record spelling while accepting the string spelling of one mistake + // was two dialects for one contract; the loud failure is the same either + // way, at execute: "no function named '…' is registered" (#1870). + expect(FlowFunctionEntrySchema.safeParse({ handler: 'scoreLead' }).success).toBe(true); + expect(FlowFunctionEntrySchema.safeParse('scoreLead').success).toBe(true); }); // #4343 — what `objectstack build` produces. The CLI lowers every inline @@ -82,10 +102,48 @@ describe('FlowFunctionEntrySchema', () => { expect(FlowFunctionEntrySchema.safeParse('').success).toBe(false); }); - it('drops a lowered ref when normalizing — it names a function without carrying one', () => { + // #4976 — the other half of what `objectstack build` emits. #4396 taught + // `lowerCallables` to keep a declared entry's declaration beside its lowered + // ref; this union was not extended in the same change, so the artifact + // `{ syncBilling: { handler: 'syncBilling', effect: 'writes' } }` failed the + // build with `invalid_union: Invalid input` — no path past `functions`, no + // key named. An author who cannot read that error deletes the declaration and + // ships an undeclared writer, which is the exact state `effect` exists to + // prevent (#4354). + it('accepts a lowered DECLARATION, the other form a built artifact carries', () => { + expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'writes' }).success).toBe(true); + expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'pure' }).success).toBe(true); + }); + + it('applies the pure default to a lowered declaration that states no effect', () => { + // `defineStack`'s parse normally materialises `effect` before the lowering + // ever runs, but `{ strict: false }` skips that parse — so the member must + // accept the shape without it, or that path keeps the failure this fixes. + const parsed = FlowFunctionEntrySchema.parse({ handler: 'syncBilling' }); + expect(parsed).toEqual({ handler: 'syncBilling', effect: 'pure' }); + }); + + it('keeps the declaration strict once lowered — a typo in a built artifact still names itself', () => { + // Derived from `FlowFunctionDeclarationSchema` rather than re-typed, so the + // surface name, the alias table and the prescription travel with it. + const result = FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', efect: 'writes' }); + expect(result.success).toBe(false); + const messages = JSON.stringify(result.error!.issues); + expect(messages).toContain('`functions` entry'); + expect(messages).toContain('`efect` → `effect`'); + // And a value the runtime has no meaning for is still refused. + expect(FlowFunctionEntrySchema.safeParse({ handler: 'syncBilling', effect: 'write' }).success).toBe(false); + }); + + it('drops BOTH lowered shapes when normalizing — each names a function without carrying one', () => { // The callable for that name comes from the sidecar ESM module the build - // emits; binding the string would register a name pointing at nothing. + // emits; binding the ref would register a name pointing at nothing. This is + // not where `effect` is lost on the built path — `mergeRuntimeModule` has + // already re-attached the callable to the declaration by the time a boot + // normalizes anything (pinned in `packages/runtime`'s + // `artifact-function-declarations.test.ts`). expect(normalizeFlowFunctionEntry('scoreLead')).toBeUndefined(); + expect(normalizeFlowFunctionEntry({ handler: 'syncBilling', effect: 'writes' })).toBeUndefined(); }); }); diff --git a/packages/spec/src/automation/flow-function.zod.ts b/packages/spec/src/automation/flow-function.zod.ts index 5fabee30f6..50fa11cff0 100644 --- a/packages/spec/src/automation/flow-function.zod.ts +++ b/packages/spec/src/automation/flow-function.zod.ts @@ -165,25 +165,62 @@ export type FlowFunctionDeclarationParsed = z.infer; /** - * One entry of the `functions` map: the handler alone (pure), a - * {@link FlowFunctionDeclarationSchema} that states its effect, or the - * **lowered handler ref** a built artifact carries. - * - * The first two are what an author writes. The third is what `objectstack - * build` produces and was, until #4343, the reason `defineStack({ functions })` - * could not survive a build at all: the CLI lowers every inline callable to a - * serialisable string ref BEFORE the stack is parsed (it must — `z.function()` - * wraps callables and would break the ref mapping), so the manifest reaching - * this schema holds `{ myFn: 'myFn' }`, which neither of the other two members - * accepts. The build failed on a mechanism its own docs call first-class. - * - * A string entry carries no callable, and that is correct rather than lossy: - * the real functions ride in the sibling ESM module esbuild emits, and - * {@link collectBundleFunctionEntries} merges both sources by name. The string - * is the artifact's record that the NAME exists — which is why - * {@link normalizeFlowFunctionEntry} deliberately drops it (see there). - * - * Authoring a string by hand therefore registers nothing. It fails loudly, not + * The **lowered** form of {@link FlowFunctionDeclarationSchema}: the same + * declaration, with its callable replaced by the string ref `objectstack build` + * emits (`{ handler: 'syncBilling', effect: 'writes' }`). + * + * Derived from the authored declaration rather than re-typed beside it, which + * is the whole point: the two shapes differ in exactly one field, so a key + * added to the declaration is carried by the artifact form automatically, and + * the strictness travels with it — a misspelled `effect` in a hand-edited + * `objectstack.json` still gets the same named surface, the same alias table + * and the same `` `efect` → `effect` `` prescription the authoring door gives. + * + * `effect` stays optional-with-a-default here for the same reason it is + * optional on the authored form. It is normally already present — the callable + * that gets lowered has been through `defineStack`'s parse, which materialises + * the `'pure'` default — but `defineStack({…}, { strict: false })` skips that + * parse, and requiring `effect` would hand that path the identical + * `invalid_union` this member exists to end. + */ +const FlowFunctionLoweredDeclarationSchema = lazySchema(() => FlowFunctionDeclarationSchema.extend({ + handler: z.string().min(1) + .describe('The lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'), +}).describe('A lowered `functions` declaration: what the function declared about itself, with its callable replaced by a handler ref (#4976)')); + +/** + * One entry of the `functions` map, in the four shapes it legitimately takes: + * the handler alone (pure), a {@link FlowFunctionDeclarationSchema} that states + * its effect, and each of those two **lowered** — a bare handler ref, or a + * declaration whose handler is a ref. + * + * The first two are what an author writes; the last two are what `objectstack + * build` produces. The CLI lowers every inline callable to a serialisable + * string ref BEFORE the stack is parsed (it must — `z.function()` wraps + * callables and would break the ref mapping), so the manifest reaching this + * schema holds `{ myFn: 'myFn' }` for a bare entry and + * `{ myFn: { handler: 'myFn', effect: 'writes' } }` for a declared one. + * + * Both lowered shapes have been the reason `defineStack({ functions })` could + * not survive a build, one after the other, and for the same reason each time — + * a lowering step taught a new shape while this union was not. #4343 added the + * bare ref. #4396 taught `lowerCallables` to keep what a declared entry + * declared, and the artifact it started emitting was rejected here until #4976: + * `invalid_union: Invalid input`, no path past `functions`, on the honest + * spelling of a writer. The practical outcome of that error was worse than a + * failed build — an author who cannot see which key is wrong deletes the + * declaration and ships an UNDECLARED writer, which is precisely the state + * `effect` exists to prevent (#4354). + * + * A lowered entry carries no callable in either shape, and that is correct + * rather than lossy: the real functions ride in the sibling ESM module esbuild + * emits, `mergeRuntimeModule` re-attaches each one to the declaration the JSON + * carried, and {@link collectBundleFunctionEntries} reads the result. The + * lowered entry is the artifact's record of what the function is NAMED and what + * it DECLARED — which is why {@link normalizeFlowFunctionEntry} deliberately + * drops both lowered shapes (see there). + * + * Authoring either by hand therefore registers nothing. It fails loudly, not * silently: a `script` node naming it refuses at execute with "no function * named '…' is registered" (#1870). */ @@ -191,7 +228,8 @@ export const FlowFunctionEntrySchema = lazySchema(() => z.union([ z.function(), FlowFunctionDeclarationSchema, z.string().min(1).describe('A lowered handler ref (built artifacts) — the callable rides in the sibling ESM module'), -]).describe('A named handler function, a declaration record stating its effect, or a lowered handler ref')); + FlowFunctionLoweredDeclarationSchema, +]).describe('A named handler function or a declaration record stating its effect — either as authored, or lowered to a handler ref by `objectstack build`')); export type FlowFunctionEntry = z.infer; /** Post-parse shape of {@link FlowFunctionEntry} — defaults applied, transforms run (ADR-0122). */ @@ -229,11 +267,19 @@ export function isFlowFunctionEffect(value: unknown): value is FlowFunctionEffec * the entry holds a live function, and the collectors that call this run on the * boot path where re-parsing every handler buys nothing. * - * A lowered string ref (the third member of that schema) returns `undefined` - * here BY DESIGN — it names a function without carrying one. The callable for - * that name comes from the built sidecar module, which the same collector - * merges in; treating the string as an entry would register a name bound to - * nothing. + * BOTH lowered shapes return `undefined` here BY DESIGN — the bare ref + * (`'syncBilling'`) and the lowered declaration + * (`{ handler: 'syncBilling', effect: 'writes' }`) alike. Each names a function + * without carrying one, and registering a name bound to nothing is worse than + * registering nothing. + * + * That is not where `effect` goes to die on the built path, and the ordering is + * what makes it safe: `mergeRuntimeModule` runs FIRST and rebuilds the entry as + * `{ ...declared, handler: }`, so by the time + * {@link collectBundleFunctionEntries} reaches this function the handler is a + * real callable and the declaration is intact — the string form is simply never + * the value a boot normalizes. `packages/runtime`'s + * `artifact-function-declarations.test.ts` pins that seam from the other side. */ export function normalizeFlowFunctionEntry(entry: unknown): NormalizedFlowFunction | undefined { if (typeof entry === 'function') { From 9bf2eebfab398c1e3dbbece82c0d6fa76b22233c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:17:59 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(qa/dogfood):=20showcase=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20fixture=20=E6=98=BE=E5=BC=8F=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=AE=83=E4=B8=8D=E6=90=BA=E5=B8=A6=20functions,=E8=80=8C?= =?UTF-8?q?=E4=B8=8D=E6=98=AF=E9=9D=A0=20JSON.stringify=20=E5=81=B7?= =?UTF-8?q?=E5=81=B7=E4=B8=A2=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `showcase-declarative-endpoints.dogfood.test.ts` 用 `JSON.stringify(showcaseStack)` 手搓一份 artifact 顶替 `objectstack build`, 但只做了后者的一半:真实构建先跑 `lowerCallables` 把每个 callable 换成字符串 ref,并把函数本体放进 sibling ESM 模块;`JSON.stringify` 没有这一步,它只是把 函数值的键整个省略。 于是这份 artifact 从来就没携带过 showcase 的任何 function —— 它只是"看起来" 携带了:裸条目(`sweepProjectHealth: fn`)连键一起消失,剩下 `functions: {}`, 照样 parse 通过。两个函数都被静默丢弃,没人看得见。 showcase 换成诚实的声明形之后这份静默就破了:`{ handler: fn, effect: 'writes' }` 会保留对象、只丢 `handler`,留下 `{ effect: 'writes' }` —— 一个为自己并不携带 的函数声明了 effect 的条目,`FlowFunctionEntrySchema` 四个成员一致拒收,完全 正确。 改为显式剔除 `functions` 并写明原因:本 boot 真正运行的函数来自交给 `bootStack` 的**活栈**,不来自这个文件;该文件的职责是把 `apis:` 块喂给 `MetadataPlugin`。静默丢失变成声明式省略。 --- ...case-declarative-endpoints.dogfood.test.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts index 95ce3b33b2..f43507ab7a 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -103,7 +103,27 @@ beforeAll(async () => { process.chdir(SHOWCASE_DIR); tempDir = mkdtempSync(join(tmpdir(), 'os-e8-endpoints-')); const artifactPath = join(tempDir, 'objectstack.json'); - writeFileSync(artifactPath, JSON.stringify(showcaseStack)); + // `functions` is dropped DELIBERATELY, and saying so is the point (#4976). + // + // This line stands in for `objectstack build`, but it is only half of it: the + // real build runs `lowerCallables` first, replacing every callable with a + // string ref and carrying the functions themselves in a sibling ESM module. + // A plain `JSON.stringify` has no such step — it simply omits function-valued + // keys — so this artifact never carried the showcase's functions at all. It + // merely LOOKED like it did, because a bare entry (`sweepProjectHealth: fn`) + // vanishes key and all and leaves `functions: {}` behind, which parses. + // + // That silence broke the moment the showcase spelled its writer the honest, + // declared way: `{ handler: fn, effect: 'writes' }` keeps the object and drops + // only `handler`, leaving `{ effect: 'writes' }` — an entry declaring an + // effect for a function it does not carry, which `FlowFunctionEntrySchema` + // refuses in all four of its members, exactly as it should. + // + // Nothing is lost by omitting the key: the functions this boot actually runs + // come from the LIVE stack handed to `bootStack` below, not from this file, + // whose job is to give `MetadataPlugin` the `apis:` block to ingest. + const { functions: _functionsLiveOnly, ...artifact } = showcaseStack as Record; + writeFileSync(artifactPath, JSON.stringify(artifact)); stack = await bootStack(showcaseStack, { // The `flow`-typed endpoint delegates to `IAutomationService.execute`;