diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index c76ce25258..eece03c039 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -33,6 +33,7 @@ "@objectstack/verify": "workspace:*" }, "devDependencies": { + "@objectstack/cli": "workspace:*", "@objectstack/core": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", diff --git a/packages/qa/dogfood/test/build-shaped-artifact.ts b/packages/qa/dogfood/test/build-shaped-artifact.ts new file mode 100644 index 0000000000..671c4188ad --- /dev/null +++ b/packages/qa/dogfood/test/build-shaped-artifact.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// A stand-in for `objectstack build`, for fixtures that need "the stack as a +// deployment receives it" (#6293). +// +// ## The trap this exists to close +// +// A fixture that wants a built-shaped artifact reaches for the one line that +// looks like the build — `JSON.stringify(stack)` — and gets something that is +// only half of it, in the half that is silent: +// +// bare callable `fn: () => …` → **key omitted entirely** +// declared callable `{ handler: fn, effect }` → `{ effect }`, a headless husk +// +// Measured on the showcase, whose `functions:` block holds one of each: +// +// JSON.parse(JSON.stringify(showcaseStack)).functions +// === { sweepProjectHealth: { effect: 'writes' } } +// +// The declared entry made a noise ONCE, on the path where the residue is fed +// straight to the parse: `FlowFunctionEntrySchema` refuses an entry declaring an +// effect for a function it does not carry, and that red CI job is the only +// reason anybody learned about this (#4976). Measured here, it is not a general +// guarantee — put the same husk back through the lowering and it never reaches +// the schema at all (see the key-for-key check below). The BARE entry never made +// a noise on any path: it vanishes key and all, the artifact holds +// `functions: {}`, and the fixture parses green carrying zero of what it +// advertises. +// `showcase-declarative-endpoints.dogfood.test.ts` shipped exactly that for its +// whole existence. AGENTS.md, "Absence must be loud": a verifier that silently +// degrades is worse than no verifier. +// +// ## What this module does instead +// +// It runs the REAL pipeline `packages/cli/src/commands/compile.ts` runs, in the +// same order, reusing the same functions — `normalizeStackInput` → +// `lowerCallables` → `ObjectStackDefinitionSchema` → `JSON.stringify`. It does +// not re-implement the lowering: a second copy of it would agree with the build +// only by re-derivation, which is the very failure this file is named after. +// +// ## What it deliberately does NOT do, and why +// +// Two things the real build writes are not pure functions of the stack, so they +// are out of reach here and stated rather than faked: +// +// `docs` collected by `collectAndLintDocs` from the package's +// `src/docs/` directory — filesystem input, not stack input. +// `runtimeModule` the esbuild-emitted `objectstack-runtime.{hash}.mjs` sibling +// and its hash. The callables themselves ARE returned here as +// `functions` (ref → fn), which is that module's content; what +// is missing is the bundling, not the mapping. +// +// Measured against a real `objectstack build` of `examples/app-showcase` +// (2026-08-10): the output of this module is byte-identical to +// `examples/app-showcase/dist/objectstack.json` for every top-level collection +// except those two keys, and two `plugins[]` entries whose options carry +// absolute paths baked in from the cwd of whichever process imported the config. +// +// ## Loudness +// +// Every callable the input carries must come out the other side as a string +// ref, or this throws. It cannot detect a callable the CALLER already lost +// (an input that was itself `JSON.stringify`-ed has nothing left to notice) — +// so a fixture that cares which callables reach its artifact must still ASSERT +// them by name. `showcase-declarative-endpoints.dogfood.test.ts` does. + +import { writeFileSync } from 'node:fs'; + +import { normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; +// The lowering itself, not a copy of it. `@objectstack/cli` declares no +// `exports` map, so this deep path is an ordinary internal import into a +// PRIVATE package — no published surface is added or widened by it (#6293's +// ruling: reach the goal without growing `@objectstack/cli`'s public entry). +import { lowerCallables } from '@objectstack/cli/dist/utils/lower-callables.js'; + +type AnyFn = (...args: unknown[]) => unknown; + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +export interface BuildShapedArtifact { + /** The JSON-safe artifact, as `objectstack build` would write it. */ + artifact: Record; + /** ref → the original callable. This is what the sibling `.mjs` bundle holds. */ + functions: Record; + /** Every ref the lowering minted, sorted. */ + refs: string[]; +} + +/** + * Every slot in a stack where `lowerCallables` binds a callable, as a + * human-readable address (`functions.sweepProjectHealth`, `hooks[2].handler`, …). + * + * This is a list of PLACES, deliberately not a second implementation of the + * lowering — its only job is to disagree out loud. The count it produces is + * reconciled against `lowering.count` below, in BOTH directions, so a lowering + * that grows a slot this walk cannot see fails here rather than shipping a + * stand-in that quietly represents less than the build does. + */ +function callableSlots(stack: Record): string[] { + const slots: string[] = []; + + if (Array.isArray(stack.hooks)) { + stack.hooks.forEach((hook, i) => { + if (isPlainObject(hook) && typeof hook.handler === 'function') slots.push(`hooks[${i}].handler`); + }); + } + + const pushActions = (actions: unknown, label: string) => { + if (!Array.isArray(actions)) return; + actions.forEach((action, i) => { + // `target` is the ONLY handler slot; a function on the removed `execute` + // alias is left for the parse to reject by name (#3855), and the lowering + // leaves it alone too — so it must not be counted here either. + if (isPlainObject(action) && typeof action.target === 'function') slots.push(`${label}[${i}].target`); + }); + }; + pushActions(stack.actions, 'actions'); + if (Array.isArray(stack.objects)) { + stack.objects.forEach((obj, i) => { + if (isPlainObject(obj)) pushActions(obj.actions, `objects[${i}].actions`); + }); + } + + const fns = stack.functions; + if (Array.isArray(fns)) { + fns.forEach((entry, i) => { + if (isPlainObject(entry) && typeof entry.handler === 'function') slots.push(`functions[${i}].handler`); + }); + } else if (isPlainObject(fns)) { + for (const [key, value] of Object.entries(fns)) { + if (typeof value === 'function') slots.push(`functions.${key}`); + else if (isPlainObject(value) && typeof value.handler === 'function') slots.push(`functions.${key}.handler`); + } + } + + return slots; +} + +/** Anything still function-valued after the lowering can never survive JSON. */ +function survivingCallables(value: unknown, path: string, out: string[]): void { + if (typeof value === 'function') { + out.push(path); + return; + } + if (Array.isArray(value)) { + value.forEach((v, i) => survivingCallables(v, `${path}[${i}]`, out)); + return; + } + if (isPlainObject(value)) { + for (const [k, v] of Object.entries(value)) survivingCallables(v, `${path}.${k}`, out); + } +} + +/** + * Lower, validate and serialize `stack` the way `objectstack build` does. + * + * Throws — loudly, naming what went missing — rather than returning a + * degraded artifact. + */ +export function buildShapedArtifact(stack: Record): BuildShapedArtifact { + const normalized = normalizeStackInput(stack); + const expected = callableSlots(normalized); + + const lowering = lowerCallables(normalized); + + if (lowering.count !== expected.length) { + throw new Error( + `build-shaped artifact: the lowering bound ${lowering.count} callable(s) but this stack ` + + `declares ${expected.length} (${expected.join(', ') || 'none'}). Either a callable was ` + + `dropped, or \`lowerCallables\` grew a slot \`callableSlots\` in ` + + `packages/qa/dogfood/test/build-shaped-artifact.ts does not know about — fix the walk, ` + + `never the assertion (#6293).`, + ); + } + + // Key-for-key on the `functions` MAP, which the lowering rebuilds rather than + // edits: its `out` object admits an entry only in the three shapes it knows + // (a callable, `{ handler: callable }`, a string ref), and anything else is + // dropped — no error, no warning, no key. Measured on this exact stack: hand + // the lowering the `{ effect: 'writes' }` husk `JSON.stringify` leaves behind + // and the artifact comes out with `functions: {}`, parsing green, which is the + // #6293 failure wearing a different hat. The parse below cannot see it: by the + // time it runs, the evidence has been deleted. + const inputFns = normalized.functions; + if (isPlainObject(inputFns)) { + const kept = new Set(Object.keys((lowering.lowered.functions ?? {}) as Record)); + const refs = Object.keys(lowering.functions); + const dropped = Object.keys(inputFns).filter( + (k) => !kept.has(k) && !refs.some((r) => r === k || r.startsWith(`${k}__`)), + ); + if (dropped.length > 0) { + throw new Error( + `build-shaped artifact: the lowering dropped ${dropped.length} \`functions\` entr(ies) ` + + `without a sound — ${dropped.join(', ')}. An entry it does not recognise is deleted ` + + `rather than handed to the schema, so the artifact would parse green carrying nothing ` + + `where these were. Most likely the entry is a headless husk (an object with an effect ` + + `and no handler), which is what a plain \`JSON.stringify\` of the stack leaves (#6293).`, + ); + } + } + + // The build refuses to EMIT an artifact that does not parse, so this stand-in + // must refuse too — otherwise a fixture learns about a malformed stack deep + // inside a boot, or not at all. + const parsed = ObjectStackDefinitionSchema.safeParse(lowering.lowered); + if (!parsed.success) { + const issues = parsed.error.issues + .slice(0, 10) + .map((i) => ` ${i.path.join('.')}: ${i.message}`) + .join('\n'); + throw new Error( + `build-shaped artifact: the lowered stack does not satisfy ObjectStackDefinitionSchema, so ` + + `\`objectstack build\` would refuse to write it:\n${issues}`, + ); + } + + // Last gate, and the one that needs no list of slots: NOTHING that reaches + // the artifact may still be a function, because `JSON.stringify` is the very + // next step and it removes those without a sound. + // + // `plugins` is the one exemption, and it is a measured one rather than a + // convenience. That array holds live plugin INSTANCES whose methods are own + // properties (28 of them on the showcase), and what a real + // `dist/objectstack.json` carries for each is the data-only descriptor — + // `{ name, version, options, … }`, methods gone. The drop there is the + // build's own behaviour, not a loss, so flagging it would make this gate cry + // wolf on every stack that composes a plugin, and a gate that cries wolf gets + // deleted by the next reader. + const leftover: string[] = []; + for (const [key, value] of Object.entries(parsed.data as Record)) { + if (key === 'plugins') continue; + survivingCallables(value, `.${key}`, leftover); + } + if (leftover.length > 0) { + throw new Error( + `build-shaped artifact: ${leftover.length} value(s) are still functions after lowering and ` + + `would be dropped by JSON.stringify without a sound: ${leftover.join(', ')} (#6293).`, + ); + } + + const artifact = JSON.parse(JSON.stringify(parsed.data)) as Record; + return { artifact, functions: lowering.functions, refs: Object.keys(lowering.functions).sort() }; +} + +/** {@link buildShapedArtifact}, written to `filePath` as the build writes it. */ +export function writeBuildShapedArtifact( + stack: Record, + filePath: string, +): BuildShapedArtifact { + const result = buildShapedArtifact(stack); + writeFileSync(filePath, JSON.stringify(result.artifact, null, 2)); + return result; +} 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 f43507ab7a..1a051c6f66 100644 --- a/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-declarative-endpoints.dogfood.test.ts @@ -45,7 +45,7 @@ // existing defect fail the executor's acceptance. Scope is the endpoint PATH // entries, which are this program's output. -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -58,6 +58,8 @@ import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; import showcaseStack from '@objectstack/example-showcase'; +import { buildShapedArtifact, writeBuildShapedArtifact } from './build-shaped-artifact.js'; + /** The ADR-0121 D1 mount, spelled here as a caller would type it. */ const TASKS = '/apps/showcase/tasks'; const PURGE = '/apps/showcase/inquiries/purge'; @@ -79,6 +81,8 @@ let tempDir: string; let prevCwd: string; let stack: VerifyStack; let adminToken: string; +/** Read back off disk — what `MetadataPlugin` actually ingested, not what we meant to write. */ +let artifactOnDisk: Record; /** * Boot the showcase the way a deployment boots it. @@ -103,27 +107,23 @@ beforeAll(async () => { process.chdir(SHOWCASE_DIR); tempDir = mkdtempSync(join(tmpdir(), 'os-e8-endpoints-')); const artifactPath = join(tempDir, 'objectstack.json'); - // `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. + // The artifact is written the way `objectstack build` writes one — the same + // `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema` + // pipeline `packages/cli/src/commands/compile.ts` runs, reusing those exact + // functions rather than re-deriving them (#6293). // - // 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)); + // Until #6293 this line was `JSON.stringify(stack)` minus `functions`, and + // the omission was deliberate and declared (#4976) because the substitute + // could not carry them: `JSON.stringify` drops a bare callable KEY AND ALL + // and reduces a declared one (`{ handler: fn, effect }`) to the headless husk + // `{ effect }`. The husk at least fails the parse — that red CI job is how + // anybody found out — but the bare entry left `functions: {}` behind and + // parsed green, so this boot advertised "the way a deployment boots it" while + // ingesting zero of the showcase's two functions, invisibly, for its whole + // existence. Declaring the omission was honest; agreeing with the build BY + // CONSTRUCTION is better, and the first test below is what keeps it true. + writeBuildShapedArtifact(showcaseStack as Record, artifactPath); + artifactOnDisk = JSON.parse(readFileSync(artifactPath, 'utf8')) as Record; stack = await bootStack(showcaseStack, { // The `flow`-typed endpoint delegates to `IAutomationService.execute`; @@ -154,6 +154,57 @@ afterAll(async () => { if (tempDir) rmSync(tempDir, { recursive: true, force: true }); }); +// ============================================================================ +// 0. The artifact this boot ingests is the shape the build writes (#6293) +// ============================================================================ + +describe('[#6293] the stand-in artifact carries what a built one carries', () => { + it('holds BOTH showcase callables as string refs — the bare form and the declared one', () => { + // The assertion the old shape could not make, and the reason this file + // spent its whole existence green while carrying nothing. Both spellings + // are pinned because they fail DIFFERENTLY: the declared entry degrades + // into a husk the schema refuses (loud), the bare entry disappears without + // a trace (silent) — and it is the silent one that has to be asserted by + // name, since nothing downstream can miss what was never there. + const functions = artifactOnDisk.functions as Record | undefined; + expect(functions, 'a built artifact carries `functions`; JSON.stringify would not').toBeDefined(); + + expect( + functions!.summarizeCompletedTask, + 'the BARE callable must reach the artifact as a resolvable string ref', + ).toBe('summarizeCompletedTask'); + + expect( + functions!.sweepProjectHealth, + 'the DECLARED callable keeps its declaration AND gains a ref — never one without the other', + ).toEqual({ handler: 'sweepProjectHealth', effect: 'writes' }); + }); + + it('the substitute it replaced still drops them — the trap, pinned', () => { + // Not a test of `JSON.stringify`: a test of why the helper above exists, + // executable at the one call site that was bitten. If this ever stops + // holding, the helper's whole premise is up for re-reading. + const naive = JSON.parse(JSON.stringify(showcaseStack)) as Record; + expect( + naive.functions, + 'the bare entry vanishes key and all; the declared one is left a headless husk', + ).toEqual({ sweepProjectHealth: { effect: 'writes' } }); + }); + + it('REFUSES to build an artifact out of that residue instead of quietly shrinking', () => { + // The reverse verification, kept in the suite rather than done once by hand: + // feed the helper the very thing this fixture used to write and it must + // fail, loudly, naming what went missing. Direction predicted before it was + // run — and the mechanism is NOT the one #4976 documented. The schema never + // sees the husk: `lowerCallables` rebuilds the `functions` map from the three + // shapes it recognises and deletes everything else, so the residue would have + // reached the parse as `functions: {}` and passed. The gate that speaks here + // is the helper's own key-for-key reconciliation. + const residue = JSON.parse(JSON.stringify(showcaseStack)) as Record; + expect(() => buildShapedArtifact(residue)).toThrowError(/dropped 1 `functions` entr.*sweepProjectHealth/s); + }); +}); + // ============================================================================ // 1. The declarations are ingested and visible on the metadata face // ============================================================================ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e88375c1c..68ab7c2a03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1812,6 +1812,9 @@ importers: specifier: workspace:* version: link:../../verify devDependencies: + '@objectstack/cli': + specifier: workspace:* + version: link:../../cli '@objectstack/core': specifier: workspace:* version: link:../../core