|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
| 4 | +import type { z } from 'zod'; |
4 | 5 | import { defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; |
5 | 6 | import { FlowFunctionEntrySchema } from '@objectstack/spec/automation'; |
6 | 7 | import { lowerCallables } from './lower-callables.js'; |
@@ -254,3 +255,119 @@ describe('lowerCallables → the spec parses what it emits (#4976, #6238)', () = |
254 | 255 | .toEqual([{ name: 'syncBilling', handler: 'syncBilling', effect: 'writes' }]); |
255 | 256 | }); |
256 | 257 | }); |
| 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 | +}); |
0 commit comments