From dc846dcd0801a383c71d8ca4d7ad777e96ebba2a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:31:26 +0000 Subject: [PATCH 01/13] wip(spec,cli): stack provenance mark and the author-time doors' refusal Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- packages/cli/src/commands/compile.ts | 11 +- packages/cli/src/commands/validate.ts | 11 +- packages/cli/src/utils/config.ts | 25 ++++ .../cli/src/utils/stack-provenance-refusal.ts | 82 +++++++++++++ .../spec/src/api/error-code-ledger.zod.ts | 15 +++ packages/spec/src/stack-provenance.ts | 108 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 72 +++++++++++- 7 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/utils/stack-provenance-refusal.ts create mode 100644 packages/spec/src/stack-provenance.ts diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index ed964783ea4..5d8a3ce0103 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -14,6 +14,7 @@ import { type ConversionNotice, } from '@objectstack/spec'; import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; +import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js'; import { lowerCallables } from '../utils/lower-callables.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; import { artifactPackages, runPerPackageAuthoringRules } from '../utils/artifact-packages.js'; @@ -249,7 +250,15 @@ export default class Compile extends Command { try { // 1. Load Configuration if (!flags.json) printStep('Loading configuration...'); - const { config, absolutePath, duration, namedExports } = await loadConfig(args.config); + const loaded = await loadConfig(args.config); + const { config, absolutePath, duration, namedExports } = loaded; + // 1a. [#20367 ruling B] One authoring shape: refuse a default export no + // stack producer built, BEFORE any other judgement — the `STACK_*` + // cross-field refusals run inside `defineStack` only, so an unbuilt + // export would otherwise pass this door unjudged. Throws into the + // catch-all below (`--json`: `error` + `code`, exit 1), the same + // envelope a `defineStack` refusal raised at load reaches. + refuseUnbuiltStack(loaded); if (!flags.json) { printKV('Config', path.relative(process.cwd(), absolutePath)); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index e30ff085b91..31b0458e4bc 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -13,6 +13,7 @@ import { type ConversionNotice, } from '@objectstack/spec'; import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; +import { refuseUnbuiltStack } from '../utils/stack-provenance-refusal.js'; import { lowerCallables } from '../utils/lower-callables.js'; import { authoringRuleUnionStack } from '../utils/stack-collections.js'; // [#18677] The per-package half of the author-time rule run, shared with @@ -212,7 +213,15 @@ export default class Validate extends Command { try { // 1. Load configuration if (!flags.json) printStep('Loading configuration...'); - const { config, absolutePath, duration, namedExports } = await loadConfig(args.config); + const loaded = await loadConfig(args.config); + const { config, absolutePath, duration, namedExports } = loaded; + // 1a. [#20367 ruling B] One authoring shape: refuse a default export no + // stack producer built, BEFORE any other judgement — the `STACK_*` + // cross-field refusals run inside `defineStack` only, so an unbuilt + // export would otherwise pass this door unjudged. Throws into the + // catch-all below (`--json`: `error` + `code`, exit 1), the same + // envelope a `defineStack` refusal raised at load reaches. + refuseUnbuiltStack(loaded); if (!flags.json) { printKV('Config', absolutePath); diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 34a6ec6ee09..29d291a14a2 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'; import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import type { Plugin } from 'esbuild'; +import { hasStackProvenance } from '@objectstack/spec'; import { printErrorToStderr, printWarningToStderr } from './format.js'; export interface LoadedConfig { @@ -39,6 +40,23 @@ export interface LoadedConfig { * finding does not depend on a caller opting in. */ shadowedNamedExports: readonly string[]; + + /** + * Whether the module's DEFAULT export was built by a stack producer — + * `defineStack` (either mode) or `composeStacks` — read with + * `hasStackProvenance` (`@objectstack/spec`) off `mod.default` itself. + * + * Read HERE, before the named-export merge, because the merge builds a new + * object with a spread and the provenance mark is non-enumerable: `config` + * never carries it once any named export is merged, so asking `config` would + * answer `false` for a correct `defineStack` project that also exports + * `onEnable`. `false` for a plain object literal, a spread or JSON copy of a + * built stack, and a module with no default export at all. + * + * `os validate` and `os build` refuse on `false` (`STACK_PROVENANCE_MISSING`, + * `refuseUnbuiltStack`); every other command reads the config as before. + */ + stackProvenance: boolean; } /** @@ -433,6 +451,12 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): throw new Error(`No default export found in ${path.basename(absolutePath)}`); } + // [#20367 ruling B] Read the producer's mark off the default export ITSELF, + // before the merge below spreads it into a new object and drops it (the mark + // is non-enumerable by design). `mod` stands in for a missing default, and a + // module namespace never carries the mark. + const stackProvenance = hasStackProvenance(baseConfig); + // Preserve named exports (e.g. the `onEnable` runtime hook and `functions`) // alongside the default-exported stack. Module-namespace named exports are // otherwise dropped when we unwrap `mod.default`, which prevents AppPlugin @@ -484,6 +508,7 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): duration: Date.now() - start, namedExports, shadowedNamedExports, + stackProvenance, }; } diff --git a/packages/cli/src/utils/stack-provenance-refusal.ts b/packages/cli/src/utils/stack-provenance-refusal.ts new file mode 100644 index 00000000000..5ce32a82c63 --- /dev/null +++ b/packages/cli/src/utils/stack-provenance-refusal.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import path from 'path'; +import type { LoadedConfig } from './config.js'; + +/** + * [#20367 ruling B] The author-time doors' provenance refusal: `os validate` + * and `os build` accept only a config whose default export a stack producer + * built — `defineStack(...)` (either mode) or `composeStacks(...)`. + * + * ## Why the doors check provenance instead of judging the export + * + * The cross-field refusals of the `STACK_*` family (capability vocabulary, + * cross-references, namespace prefix, single app, hierarchy-scope and trigger + * capability) run inside `defineStack`, and only there. A plain-object export + * reached both doors unjudged — the schema parse alone passed it at exit 0 and + * `os build` shipped the artifact — while the same stack inside `defineStack` + * was refused. Judging the export at the door instead is not an option: a built + * stack carries each bound action twice (top level and merged into its + * object), so re-running the family on `defineStack`'s own output refuses every + * correct project with a bound action. One authoring shape closes it: the door + * asks "did the producer build this?" (`hasStackProvenance`, read by + * `loadConfig` off the default export before its named-export merge) and + * refuses when it did not, so the family is raised at both doors by + * construction. + * + * ## Where it sits, and what it answers + * + * Right after `loadConfig`, before any other judgement — nothing the door + * could say about an unjudged stack is worth reading ahead of "it was never + * judged". It THROWS rather than printing, so it answers through each + * command's existing catch-all exactly as a `defineStack` refusal raised at + * load does: `--json` carries `error` + `code` (+ the `warnings` / + * `conversions` lists, empty at this point) and exits 1; the text face prints + * the message. The envelope gains no field. + * + * `status: 422` is the family's (`StackRefusalError`, `@objectstack/spec`): + * an unprocessable authored entity. The code is the one `composeStacks` + * raises for an unbuilt input — one condition, one vocabulary, two emitters, + * registered under both packages in the ADR-0112 ledger. + */ +export class StackProvenanceMissingError extends Error { + readonly code = 'STACK_PROVENANCE_MISSING'; + readonly status = 422; + /** One entry: the config file whose default export is refused. */ + readonly issues: readonly string[]; + + constructor(message: string, configFile: string) { + super(message); + this.name = 'StackProvenanceMissingError'; + this.issues = [configFile]; + } +} + +/** + * The prescription an author (or an AI writing the config) reads. It names + * the one-line fix first, then the two ways a correct-looking export loses the + * mark, because both have the same fix. + */ +export function stackProvenanceMissingMessage(configFile: string): string { + return ( + `${configFile}: the default export was not built by \`defineStack\` — ` + + `\`os validate\` and \`os build\` accept only a stack that \`defineStack(...)\` ` + + `(or \`composeStacks(...)\`) returned, because the stack's cross-field checks ` + + `(capabilities, cross-references, namespace prefix, single app, hierarchy scope, ` + + `triggers) run inside it. Wrap the export: ` + + `\`import { defineStack } from '@objectstack/spec'; export default defineStack({ … });\`. ` + + `A spread (\`{ ...stack, api: { … } }\`) or JSON copy of a built stack is not the built ` + + `stack either — put every key inside \`defineStack({ … })\` and export what it returns.` + ); +} + +/** + * Refuse a loaded config whose default export no stack producer built. Called + * by `os validate` and `os build` right after `loadConfig`; a no-op for every + * `defineStack` / `composeStacks` export. + */ +export function refuseUnbuiltStack(loaded: Pick): void { + if (loaded.stackProvenance) return; + const configFile = path.basename(loaded.absolutePath); + throw new StackProvenanceMissingError(stackProvenanceMissingMessage(configFile), configFile); +} diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index d8f2b58a011..948700a3061 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -973,6 +973,14 @@ export const ERROR_CODE_LEDGER = { // the code `@objectstack/cloud-connection` already registers — one // condition, one vocabulary; provenance, not identity (see above). 'ENVIRONMENT_NOT_FOUND', + // [#20367 ruling B] `os validate` / `os build` refuse a config whose default + // export no stack producer built (`hasStackProvenance`, `@objectstack/spec`) + // — right after load, before any other judgement, through each command's + // catch-all (`--json`: `code` beside `error`, exit 1). `door: 'none'`: a CLI + // exit, no HTTP boundary. Second EMITTER of the code `@objectstack/spec` + // registers for `composeStacks`' refusal of an unbuilt input — one + // condition, one vocabulary. + 'STACK_PROVENANCE_MISSING', ], '@objectstack/cloud-connection': [ 'CLOUD_FETCH_FAILED', // fetching the manifest/bundle from cloud failed @@ -1330,6 +1338,13 @@ export const ERROR_CODE_LEDGER = { 'STACK_CROSS_REFERENCE_INVALID', // items name objects the stack does not define (the ADR-0130 matrix, plus the duplicate-action-key / global-`update` / mapping-transform findings the same aggregate carries) 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', // a HIERARCHY permission scope while `requires` omits `hierarchy-security` 'STACK_NAMESPACE_PREFIX_INVALID', // an object name lacks the `manifest.namespace` prefix + // [#20367 ruling B] The provenance refusal of the same family: an input + // `composeStacks` was handed that no stack producer built (a plain object, + // a spread or JSON copy of a built stack), `status: 422`, one `issues` + // entry per refused input. `door: 'none'` on the same reading as the rows + // around it. Second emitter: `@objectstack/cli` raises the same code at + // `os validate` / `os build` for an unmarked default export — see its row. + 'STACK_PROVENANCE_MISSING', // the value was not built by `defineStack` / `composeStacks` (`hasStackProvenance` is false) 'STACK_SCHEMA_INVALID', // `ObjectStackDefinitionSchema.safeParse` failed; `issues` carries the zod issues structurally 'STACK_SINGLE_APP_VIOLATION', // an `app` package declares more than one app (ADR-0019 D3) 'STACK_TRIGGER_CAPABILITY_REQUIRED', // an auto-launched flow while `requires` omits `triggers` or `automation` (the pair installs its trigger) diff --git a/packages/spec/src/stack-provenance.ts b/packages/spec/src/stack-provenance.ts new file mode 100644 index 00000000000..3b2367f528b --- /dev/null +++ b/packages/spec/src/stack-provenance.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * STACK provenance — the spec-declared mark saying *this stack definition was + * built by a stack producer*: `defineStack` (both modes) or `composeStacks`. + * + * ## The rule this mark carries: one authoring shape + * + * A project's `objectstack.config.ts` has exactly one legal default export — + * what a producer returned. `defineStack` is not a typing no-op: it + * normalises the input, merges every bound standalone action into its object, + * and runs the cross-field refusals (capability vocabulary, cross-references, + * namespace prefix, single app, hierarchy-scope and trigger capability) that + * the schema parse alone cannot express. Its output is therefore an ARTIFACT, + * and a plain object literal is a different, unjudged dialect of the same + * data. Re-running those refusals on an artifact is not idempotent either — a + * built stack carries each bound action twice (top-level `actions[]` and the + * merged copy in `objects[].actions[]`), so "judge whatever was exported" + * refuses every correct project that has a bound action. + * + * So the author-time doors check PROVENANCE, not content: `os validate` and + * `os build` refuse an unmarked default export with a prescription to wrap it + * in `defineStack(...)`, and `composeStacks` refuses an unmarked input the + * same way (`STACK_PROVENANCE_MISSING`). The cross-field judgement stays in + * the one producer that can run it correctly. + * + * ## ⚠️ The fail direction is CLOSED + * + * **Unmarked ⇒ refused**, and every way to lose the mark lands there: + * + * - the mark lives under a SYMBOL key and is non-enumerable, so + * `JSON.stringify`, `{ ...spread }`, `Object.assign({}, …)`, structured + * clone and a JSON round-trip all DROP it — a copy of a built stack is not + * the built stack, and is refused with the same prescription; + * - {@link hasStackProvenance} answers `true` only for a value that is + * exactly one of the declared producer literals — a forged or corrupted + * mark is not a mark. + * + * The refusal names the fix that is right in every one of those cases (export + * what the producer returned), which is why a lost mark is a correct + * diagnosis rather than a false one. + * + * ## Why a symbol on the object, not a key in it + * + * The mark must be invisible to everything that reads the stack as DATA: the + * strict `ObjectStackDefinitionSchema` (which walks own enumerable keys and + * would refuse an undeclared one), the compiled `dist/objectstack.json` + * (which must not carry authoring bookkeeping), and every `Object.keys` walk + * in `composeStacks`. `Symbol.for` (the global registry) rather than a + * module-local symbol, so a duplicated copy of this package — a CLI that + * loads its own ESM build while the config it bundles resolves the CJS one — + * reads the same key. Precedent: `data/filter-subtree-provenance.ts`. + * + * Only {@link hasStackProvenance} is published; stamping belongs to the two + * producers in `stack.zod.ts` alone, so no third party can attest a judgement + * it did not run. + */ + +/** The producers whose output is a judged stack. */ +type StackProducer = 'defineStack' | 'composeStacks'; + +const STACK_PRODUCERS: ReadonlySet = new Set(['defineStack', 'composeStacks']); + +/** The symbol key the mark lives under on a built stack definition. */ +const STACK_PROVENANCE: symbol = Symbol.for('objectstack.stack.provenance'); + +/** + * Stamp the producer's mark on the stack it is about to return, and return it. + * + * Non-enumerable, non-writable and non-configurable: invisible to every data + * reader (see the module header) and not flippable by a later actor. A stack + * already carrying a valid mark is returned unchanged. A non-extensible + * object (frozen or sealed) cannot take the property in place, so it is + * shallow-copied first — the producer's own return value, never the author's + * input. + * + * Internal to the two producers: NOT re-exported from the package entry. + */ +export function markStackProvenance(stack: T, producer: StackProducer): T { + if (stack === null || typeof stack !== 'object') return stack; + if (hasStackProvenance(stack)) return stack; + const target = Object.isExtensible(stack) ? stack : ({ ...(stack as object) } as T); + Object.defineProperty(target, STACK_PROVENANCE, { + value: producer, + enumerable: false, + writable: false, + configurable: false, + }); + return target; +} + +/** + * Did a stack producer (`defineStack`, in either mode, or `composeStacks`) + * build this value? + * + * `true` only for an object carrying the mark with one of the declared + * producer literals; `false` for everything else — a plain object literal, a + * spread or JSON copy of a built stack, a module namespace, `null`, a + * primitive. A door that answers `false` refuses with the prescription to + * wrap the export in `defineStack(...)` (`STACK_PROVENANCE_MISSING`). + * + * Read it off the value the producer returned — BEFORE any spread or merge, + * which drops the mark by design. + */ +export function hasStackProvenance(value: unknown): boolean { + if (value === null || typeof value !== 'object') return false; + return STACK_PRODUCERS.has((value as Record)[STACK_PROVENANCE]); +} diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 4c0bfced0f0..3dded8337c2 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -13,6 +13,7 @@ import { hasPlatformObjectPrefix } from './system/constants/platform-object-name import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { strictObject } from './shared/strict-object'; import { deepEqualAuthored } from './shared/deep-equal'; +import { markStackProvenance, hasStackProvenance } from './stack-provenance'; import { normalizeStackInput, MAP_SUPPORTED_FIELDS, @@ -2377,6 +2378,28 @@ class StackTriggerCapabilityRequiredError extends StackRefusalError { } } +/** + * [ADR-0112 · #20367 ruling B] `composeStacks` refuses an input no stack + * producer built — the composition half of the one-authoring-shape rule + * (`stack-provenance.ts` states it). The per-stack refusals above run only + * inside `defineStack`, so an input that never passed through it arrives + * unjudged; composing it would publish those findings nowhere. `issues` carries + * one entry per refused input, naming it the way every other composition + * refusal does. + * + * The SAME code `os validate` / `os build` raise for an unmarked default export + * (`@objectstack/cli`, `utils/stack-provenance-refusal.ts`): one condition, one + * vocabulary, two emitters — the `ENVIRONMENT_NOT_FOUND` precedent in the + * ADR-0112 ledger. Module-local like every member above. + */ +class StackProvenanceMissingError extends StackRefusalError { + readonly code = 'STACK_PROVENANCE_MISSING'; + + constructor(message: string, issues: readonly string[]) { + super('StackProvenanceMissingError', message, issues); + } +} + /** * [ADR-0112 · #16348] The COMPOSITION half of the refusal family above. `composeStacks` * refuses six authored-entity conflicts, every one of them carrying the @@ -3582,8 +3605,11 @@ export function defineStack( warnUnknownAuthoringKeys(normalized); if (!strict) { - // Non-strict mode: skip validation (advanced use cases only). - return mergeActionsIntoObjects(normalized as ObjectStackDefinition); + // Non-strict mode: skip validation (advanced use cases only). The output is + // still this producer's, so it carries the provenance mark — `strict: false` + // is an explicit authoring choice made INSIDE the producer, which is what + // the doors check for (`stack-provenance.ts`). + return markStackProvenance(mergeActionsIntoObjects(normalized as ObjectStackDefinition), 'defineStack'); } @@ -3671,9 +3697,19 @@ export function defineStack( // reaches the caller. warnEmailTemplateLocaleFloor(data); - return mergeActionsIntoObjects(data); + // [#20367 ruling B] The mark the author-time doors and `composeStacks` check: + // this value went through the judgement above. Non-enumerable, so it reaches + // neither the schema nor the compiled artifact (`stack-provenance.ts`). + return markStackProvenance(mergeActionsIntoObjects(data), 'defineStack'); } +/** + * [#20367 ruling B] The one published half of stack provenance — see + * `stack-provenance.ts`. `os validate` / `os build` read it off the config's + * default export and refuse an unmarked one (`STACK_PROVENANCE_MISSING`). + */ +export { hasStackProvenance }; + // ─── composeStacks ────────────────────────────────────────────────── @@ -5063,7 +5099,30 @@ export function composeStacks( stacks: ObjectStackDefinition[], options?: ComposeStacksOptions, ): ObjectStackDefinition { - if (stacks.length === 0) return {} as ObjectStackDefinition; + // 0. [#20367 ruling B] Every input must be a stack a producer built. The + // per-stack refusals run only inside `defineStack`, so an input that never + // passed through it is unjudged, and nothing below re-judges it (the + // artifact pass in step 3b re-runs two artifact-scoped rules, not the + // family). FIRST, and before the single-input early return, so a lone + // plain object is refused as well rather than handed straight back. + const unbuilt = stacks.flatMap((stack, i) => { + if (hasStackProvenance(stack)) return []; + return [stack !== null && typeof stack === 'object' ? stackLabel(stack, i) : `stack #${i}`]; + }); + if (unbuilt.length > 0) { + const count = unbuilt.length; + throw new StackProvenanceMissingError( + `composeStacks provenance check failed (${count} input${count === 1 ? '' : 's'}): ` + + `${unbuilt.join(', ')} ${count === 1 ? 'was' : 'were'} not built by \`defineStack\`. ` + + `composeStacks composes only stacks \`defineStack\` (or a nested \`composeStacks\`) returned — ` + + `the cross-field refusals run inside \`defineStack\`, so a plain object here would reach the ` + + `artifact unjudged. Wrap each input: \`composeStacks([defineStack({ … }), …])\`. A spread ` + + `(\`{ ...stack }\`) or JSON copy of a built stack drops the mark too: pass the built stack itself.`, + unbuilt, + ); + } + + if (stacks.length === 0) return markStackProvenance({}, 'composeStacks') as ObjectStackDefinition; if (stacks.length === 1) return stacks[0]; const opts = ComposeStacksOptionsSchema.parse(options ?? {}); @@ -5250,5 +5309,8 @@ export function composeStacks( } } - return artifact as ObjectStackDefinition; + // [#20367 ruling B] Built from built inputs only (step 0), so the artifact is + // a producer's output too: a nested `composeStacks` or an author-time door + // accepts it. + return markStackProvenance(artifact, 'composeStacks') as ObjectStackDefinition; } From a8c0f5eee948aaa91a11ed5643de141a0ec521e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:45:44 +0000 Subject: [PATCH 02/13] test(spec): pin stack provenance and composeStacks' unbuilt-input refusal Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- ...pose-stacks-action-collision-shape.test.ts | 13 +- ...ompose-stacks-concat-shape-refusal.test.ts | 11 +- ...mpose-stacks-objects-shape-refusal.test.ts | 11 +- .../spec/src/stack-artifact-crossref.test.ts | 33 +++- packages/spec/src/stack-provenance.test.ts | 176 ++++++++++++++++++ 5 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 packages/spec/src/stack-provenance.test.ts diff --git a/packages/spec/src/compose-stacks-action-collision-shape.test.ts b/packages/spec/src/compose-stacks-action-collision-shape.test.ts index 022cfa961da..a79ed5466d5 100644 --- a/packages/spec/src/compose-stacks-action-collision-shape.test.ts +++ b/packages/spec/src/compose-stacks-action-collision-shape.test.ts @@ -32,7 +32,7 @@ * refused with the collision code at the declaring stack's own index. */ import { describe, it, expect } from 'vitest'; -import { composeStacks } from './stack.zod'; +import { composeStacks, defineStack } from './stack.zod'; type Envelope = Error & { code?: string; @@ -69,8 +69,17 @@ const withTop = (actions: unknown) => ({ manifest: mf('com.example.b'), objects: /** Stack B, hand-built, whose object `b_item` carries the given `actions`. */ const withOwn = (actions: unknown) => ({ manifest: mf('com.example.b'), objects: [obj('b_item', { actions })] }); +/** + * A BUILT stack whose keys were rewritten after `defineStack` returned — the + * shape nothing parsed. Since #20367 ruling B a hand-built literal is refused + * at `composeStacks`' step 0 (no provenance mark), but the mark survives an + * in-place mutation of a built stack, so this is the route by which a malformed + * collection still reaches step 6. + */ +const built = (stack: unknown) => Object.assign(defineStack({} as never, { strict: false }), stack as object); + const compose = (stacks: unknown[], options?: Record) => - composeStacks(stacks as never, options as never); + composeStacks(stacks.map(built) as never, options as never); function expectSchemaEnvelope(refused: Envelope | null, paths: PropertyKey[][], expected: 'array' | 'object'): void { expect(refused).toBeInstanceOf(Error); diff --git a/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts b/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts index b84e7cd8dc4..3063991b598 100644 --- a/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts +++ b/packages/spec/src/compose-stacks-concat-shape-refusal.test.ts @@ -49,8 +49,15 @@ function refusal(fn: () => unknown): Envelope | null { const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const }); -/** A stack object nobody parsed. */ -const handBuilt = (overrides: Record) => overrides as unknown as ObjectStackDefinition; +/** + * A BUILT stack whose keys were rewritten after `defineStack` returned — the + * shape nothing parsed. Since #20367 ruling B a hand-built literal is refused + * at `composeStacks`' step 0 (no provenance mark), but the mark survives an + * in-place mutation of a built stack, so this is the route by which a malformed + * collection still reaches the step under test. + */ +const handBuilt = (overrides: Record) => + Object.assign(defineStack({} as never, { strict: false }), overrides) as unknown as ObjectStackDefinition; /** A stack through `defineStack`'s `strict: false` door, which skips the parse. */ const unparsed = (overrides: Record) => defineStack(overrides as never, { strict: false }); diff --git a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts index 09739e8dfd5..3f6b1167ece 100644 --- a/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts +++ b/packages/spec/src/compose-stacks-objects-shape-refusal.test.ts @@ -61,8 +61,15 @@ const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', const obj = (name: string) => ({ name, label: name, fields: { title: { type: 'text' as const } } }); -/** A stack object nobody parsed — the shape the card's reproduction used. */ -const handBuilt = (overrides: Record) => overrides as unknown as ObjectStackDefinition; +/** + * A BUILT stack whose keys were rewritten after `defineStack` returned — the + * shape nothing parsed. Since #20367 ruling B a hand-built literal is refused + * at `composeStacks`' step 0 (no provenance mark), but the mark survives an + * in-place mutation of a built stack, so this is the route by which a malformed + * collection still reaches the step under test. + */ +const handBuilt = (overrides: Record) => + Object.assign(defineStack({} as never, { strict: false }), overrides) as unknown as ObjectStackDefinition; /** A stack through `defineStack`'s `strict: false` door, which skips the parse. */ const unparsed = (overrides: Record) => diff --git a/packages/spec/src/stack-artifact-crossref.test.ts b/packages/spec/src/stack-artifact-crossref.test.ts index 41dbf335172..a54db00c939 100644 --- a/packages/spec/src/stack-artifact-crossref.test.ts +++ b/packages/spec/src/stack-artifact-crossref.test.ts @@ -345,7 +345,10 @@ describe('#18202 — an input that bypassed the strict parse IS checked at compo const unparsedApp = (grantObject: string, seedObject: string) => defineStack(appConfig(grantObject, seedObject), { strict: false }); - /** The same config as a hand-built object — it never enters `defineStack` at all. */ + /** + * The same config as a hand-built object — it never enters `defineStack` at + * all, so it carries no provenance mark (`stack-provenance.ts`). + */ const handBuiltApp = (grantObject: string, seedObject: string) => appConfig(grantObject, seedObject) as unknown as ReturnType; @@ -374,13 +377,19 @@ describe('#18202 — an input that bypassed the strict parse IS checked at compo expect(refused?.issues).toContain(SEED_ON_NOWHERE); }); - it('REFUSES a hand-built stack object on the same two rules', () => { + it('REFUSES a hand-built stack object before either rule runs — it was never built (#20367 ruling B)', () => { + // Since ruling B a hand-built object no longer reaches the artifact pass: + // `composeStacks` refuses an input no stack producer built at its step 0, + // naming the input, so the two rules above never get to run on it. The + // refusal is the stronger one — the same dangling references inside + // `defineStack({ … })` are refused per stack, and the `strict: false` tests + // above keep the artifact pass itself pinned. const refused = refusalOf(() => composeStacks([serviceStack(), handBuiltApp(NOWHERE, NOWHERE)], { manifest: 'preserve' }), ); - expect(refused?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); - expect(refused?.issues).toContain(GRANT_ON_NOWHERE); - expect(refused?.issues).toContain(SEED_ON_NOWHERE); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toEqual([`'${appManifest.id}' (stack #1)`]); }); it('leaves every OTHER rule un-applied to an unparsed input — only these two cross', () => { @@ -429,14 +438,24 @@ describe('#18202 — a malformed collection on an unparsed input is skipped or r } } + /** + * A BUILT stack whose keys were rewritten after `defineStack` returned: the + * provenance mark survives an in-place mutation, so this — not a hand-built + * literal, which composition now refuses at step 0 (#20367 ruling B) — is the + * route by which a malformed collection still reaches the artifact pass. + */ const malformed = (overrides: Record) => - anyStack({ manifest: appManifest, objects: [account], ...overrides }) as unknown as ReturnType; + Object.assign(defineStack(anyStack({}), { strict: false }), { + manifest: appManifest, + objects: [account], + ...overrides, + }) as unknown as ReturnType; const composeWith = (stack: ReturnType) => () => composeStacks([serviceStack(), stack], { manifest: 'preserve' }); it('a non-array `permissions` is refused by the concat pass, never a bare TypeError (#19784)', () => { - // Map format on a hand-built stack (only `defineStack` normalizes it). It + // Map format written onto a built stack (only `defineStack` normalizes it). It // is NOT iterable, which is what makes this the case that distinguishes a // guard from a bare `TypeError`: a string value would iterate its // characters and never throw either way. diff --git a/packages/spec/src/stack-provenance.test.ts b/packages/spec/src/stack-provenance.test.ts new file mode 100644 index 00000000000..becee3aa559 --- /dev/null +++ b/packages/spec/src/stack-provenance.test.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Stack provenance (#20367 ruling B — one authoring shape). + * + * What is pinned: + * + * - both stack producers stamp their output (`defineStack` in BOTH modes, + * `composeStacks` at every arity), and {@link hasStackProvenance} is `false` + * for everything else — a literal, a spread copy, a JSON copy; + * - the mark is invisible to every data reader: `Object.keys`, the strict + * stack schema, `JSON.stringify` (so it never reaches a compiled artifact); + * - `composeStacks` refuses an unbuilt input with `STACK_PROVENANCE_MISSING` + * / 422, naming each refused input, before anything else — a lone input + * included; + * - the code is registered for both emitters in the ADR-0112 ledger. + * + * Every refusal has its control: the same content, built, is accepted. + */ +import { describe, it, expect } from 'vitest'; +import { composeStacks, defineStack, hasStackProvenance, ObjectStackDefinitionSchema, type ObjectStackDefinition } from './stack.zod'; +import { ERROR_CODE_LEDGER } from './api/error-code-ledger.zod'; + +type Envelope = Error & { code?: string; status?: number; issues?: readonly unknown[] }; + +function refusal(fn: () => unknown): Envelope | null { + try { + fn(); + return null; + } catch (e) { + return e as Envelope; + } +} + +const config = (id: string, ns: string) => ({ + manifest: { id, name: ns, version: '1.0.0', type: 'app' as const, namespace: ns }, + objects: [{ name: `${ns}_thing`, label: 'Thing', fields: { title: { type: 'text' as const, label: 'Title' } } }], + // A bound standalone action: `defineStack` merges it into `objects[].actions` + // as well, the echo a door-side re-judgement would refuse. + actions: [{ name: `${ns}_go`, label: 'Go', type: 'script' as const, target: 'noop', objectName: `${ns}_thing` }], +}); + +const literal = (id: string, ns: string) => config(id, ns) as unknown as ObjectStackDefinition; + +describe('the producers stamp their output', () => { + it('defineStack (strict) output carries provenance', () => { + expect(hasStackProvenance(defineStack(config('com.example.a', 'aa')))).toBe(true); + }); + + it('defineStack (strict: false) output carries provenance too — the choice is made inside the producer', () => { + expect(hasStackProvenance(defineStack(config('com.example.a', 'aa'), { strict: false }))).toBe(true); + }); + + it('composeStacks output carries provenance at every arity', () => { + const a = defineStack(config('com.example.a', 'aa')); + const b = defineStack(config('com.example.b', 'bb')); + expect(hasStackProvenance(composeStacks([]))).toBe(true); + expect(hasStackProvenance(composeStacks([a]))).toBe(true); + expect(hasStackProvenance(composeStacks([a, b]))).toBe(true); + expect(hasStackProvenance(composeStacks([a, b], { manifest: 'preserve' }))).toBe(true); + }); + + it('a nested composition accepts a composed stack as an input', () => { + const a = defineStack(config('com.example.a', 'aa')); + const b = defineStack(config('com.example.b', 'bb')); + const c = defineStack(config('com.example.c', 'cc')); + expect(refusal(() => composeStacks([composeStacks([a, b]), c]))).toBeNull(); + }); + + it('the stamp does not mutate the author input', () => { + const input = config('com.example.a', 'aa'); + defineStack(input); + defineStack(input, { strict: false }); + expect(hasStackProvenance(input)).toBe(false); + }); +}); + +describe('hasStackProvenance is false for everything no producer returned', () => { + const built = () => defineStack(config('com.example.a', 'aa')); + + it.each([ + ['a plain object literal', () => config('com.example.a', 'aa')], + ['a spread copy of a built stack', () => ({ ...built() })], + ['an Object.assign copy of a built stack', () => Object.assign({}, built())], + ['a JSON round-trip of a built stack', () => JSON.parse(JSON.stringify(built()))], + ['a structured clone of a built stack', () => structuredClone(built())], + ['null', () => null], + ['undefined', () => undefined], + ['a string', () => 'defineStack'], + ['an array', () => []], + ])('%s', (_label, make) => { + expect(hasStackProvenance(make())).toBe(false); + }); + + it('a mark carrying any value other than a producer literal is not a mark', () => { + const forged = config('com.example.a', 'aa'); + Object.defineProperty(forged, Symbol.for('objectstack.stack.provenance'), { value: 'hand', enumerable: false }); + expect(hasStackProvenance(forged)).toBe(false); + }); + + it('the mark survives an in-place mutation of the built stack (it marks the object, not its content)', () => { + const stack = built(); + (stack as Record).description = 'edited after build'; + expect(hasStackProvenance(stack)).toBe(true); + }); +}); + +describe('the mark is invisible to every data reader', () => { + const stack = defineStack(config('com.example.a', 'aa')); + const KEY = Symbol.for('objectstack.stack.provenance'); + + it('is non-enumerable, non-writable and not an own string key', () => { + const desc = Object.getOwnPropertyDescriptor(stack, KEY); + expect(desc?.enumerable).toBe(false); + expect(desc?.writable).toBe(false); + expect(desc?.configurable).toBe(false); + expect(Object.keys(stack)).not.toContain(String(KEY)); + }); + + it('never reaches JSON — the compiled artifact carries no authoring bookkeeping', () => { + expect(JSON.stringify(stack)).not.toContain('provenance'); + expect(JSON.stringify(stack)).toBe(JSON.stringify({ ...stack })); + }); + + it('the strict stack schema neither sees nor refuses it', () => { + expect(ObjectStackDefinitionSchema.safeParse(stack).success).toBe(true); + }); +}); + +describe('composeStacks refuses an input no producer built', () => { + const a = () => defineStack(config('com.example.a', 'aa')); + + it('refuses a plain object input with STACK_PROVENANCE_MISSING / 422, naming it', () => { + const refused = refusal(() => composeStacks([a(), literal('com.example.b', 'bb')])); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + expect(refused?.status).toBe(422); + expect(refused?.issues).toEqual(["'com.example.b' (stack #1)"]); + expect(refused?.message).toMatch(/^composeStacks provenance check failed \(1 input\): 'com\.example\.b' \(stack #1\) was not built by `defineStack`/); + }); + + it('names every unbuilt input, in input order', () => { + const refused = refusal(() => composeStacks([literal('com.example.b', 'bb'), a(), literal('com.example.c', 'cc')])); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + expect(refused?.issues).toEqual(["'com.example.b' (stack #0)", "'com.example.c' (stack #2)"]); + }); + + it('refuses a lone unbuilt input too — the check precedes the single-input early return', () => { + const refused = refusal(() => composeStacks([literal('com.example.b', 'bb')])); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + expect(refused?.issues).toEqual(["'com.example.b' (stack #0)"]); + }); + + it('refuses a spread copy of a built stack — the copy is not the built stack', () => { + const refused = refusal(() => composeStacks([a(), { ...defineStack(config('com.example.b', 'bb')) }])); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + }); + + it('refuses before any other judgement: a conflicting unbuilt input is refused for provenance, not the conflict', () => { + // Same object name in both inputs — the default `objectConflict: 'error'` + // would refuse STACK_COMPOSE_OBJECT_CONFLICT if it ever got that far. + const refused = refusal(() => composeStacks([a(), literal('com.example.b', 'aa')])); + expect(refused?.code).toBe('STACK_PROVENANCE_MISSING'); + }); + + it('control: the same inputs, built, compose — including the bound-action echo', () => { + const composed = refusal(() => composeStacks([a(), defineStack(config('com.example.b', 'bb'))])); + expect(composed).toBeNull(); + }); +}); + +describe('ADR-0112 ledger', () => { + it('registers STACK_PROVENANCE_MISSING under both emitters', () => { + expect(ERROR_CODE_LEDGER['@objectstack/spec']).toContain('STACK_PROVENANCE_MISSING'); + expect(ERROR_CODE_LEDGER['@objectstack/cli']).toContain('STACK_PROVENANCE_MISSING'); + }); +}); From 271dc624e4d5e1870f37be1e7d2b8b6b87ac3c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:51:19 +0000 Subject: [PATCH 03/13] wip(cli): compose through defineStack in two unit fixtures; roster row for the provenance refusal Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- packages/cli/src/utils/nav-contribution-groups.test.ts | 10 +++++++--- .../src/utils/permission-set-name-collisions.test.ts | 10 +++++++--- packages/cli/test/validate-build-gate-parity.test.ts | 8 ++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/utils/nav-contribution-groups.test.ts b/packages/cli/src/utils/nav-contribution-groups.test.ts index d82a769f548..cb9247b26e9 100644 --- a/packages/cli/src/utils/nav-contribution-groups.test.ts +++ b/packages/cli/src/utils/nav-contribution-groups.test.ts @@ -53,7 +53,7 @@ // must not pull the data engine in to judge a stack with no contributions. import '@objectstack/objectql/core'; import { describe, it, expect } from 'vitest'; -import { composeStacks, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { composeStacks, defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; import { artifactPackages } from './artifact-packages.js'; import { collectNavGroupInputs, findNavGroupDiagnostics } from './nav-contribution-groups.js'; @@ -114,9 +114,13 @@ const ordersStack = (group: string) => ({ }], }); -/** Compose exactly as `examples/app-multi-package/objectstack.config.ts` does. */ +/** + * Compose exactly as `examples/app-multi-package/objectstack.config.ts` does — + * each package through `defineStack` first, which `composeStacks` requires of + * every input (#20367 ruling B). + */ const artifact = (group: string): AnyRec => - composeStacks([ordersStack(group), coreStack()], { manifest: 'preserve' }) as unknown as AnyRec; + composeStacks([defineStack(ordersStack(group)), defineStack(coreStack())], { manifest: 'preserve' }) as unknown as AnyRec; /** * The composed artifact as `compile.ts` actually hands it to this check — diff --git a/packages/cli/src/utils/permission-set-name-collisions.test.ts b/packages/cli/src/utils/permission-set-name-collisions.test.ts index 458fa01ed49..562f7b752cb 100644 --- a/packages/cli/src/utils/permission-set-name-collisions.test.ts +++ b/packages/cli/src/utils/permission-set-name-collisions.test.ts @@ -45,7 +45,7 @@ // must not pull the security plugin in to judge a stack with no collisions. import '@objectstack/plugin-security'; import { describe, it, expect } from 'vitest'; -import { composeStacks, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { composeStacks, defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; import { PERMISSION_SET_NAME_COLLISION, formatPermissionSetNameCollisionDiagnostic, @@ -117,9 +117,13 @@ const ordersStack = (permissions: Set_[]) => ({ permissions, }); -/** Compose exactly as `examples/app-multi-package/objectstack.config.ts` does. */ +/** + * Compose exactly as `examples/app-multi-package/objectstack.config.ts` does — + * each package through `defineStack` first, which `composeStacks` requires of + * every input (#20367 ruling B). + */ const artifact = (stacks: unknown[]): AnyRec => - composeStacks(stacks as never, { manifest: 'preserve' }) as unknown as AnyRec; + composeStacks(stacks.map((s) => defineStack(s as never)), { manifest: 'preserve' }) as unknown as AnyRec; /** The two-package artifact, with the Module package's set name as the variable. */ const twoPackages = (ordersName: string) => diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 7e29dbd6505..c4c590d170b 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -123,6 +123,14 @@ const SHARED_NON_REGISTRY_GATES: readonly string[] = [ // `kind:'html'` page to check. Not a registry rule: the manifest is a file // in the working directory, not part of the stack a rule is handed. 'resolveJsxGateManifest', + // [#20367 ruling B] One authoring shape: a default export no stack producer + // built (`defineStack` / `composeStacks`) is refused right after load, before + // any other judgement — the `STACK_*` cross-field refusals run inside the + // producer only. Not a registry rule and it cannot become one: it judges the + // loaded MODULE's default export (the provenance mark `loadConfig` reads + // before its named-export merge), which no rule is ever handed. `os lint` is + // not an author-time door under the ruling and does not run it. + 'refuseUnbuiltStack', ]; /** From a02f9bed246cec1bbfe2af6eb084f944298f5005 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:01:44 +0000 Subject: [PATCH 04/13] wip(cli): integration-tier fixtures authored through defineStack Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- ...nav-contribution-groups.package-id.test.ts | 7 +- .../authoring-rule-command-parity.test.ts | 9 ++- .../build-text-face-advisory-count.test.ts | 14 ++-- packages/cli/test/emit-json-pipe.test.ts | 6 +- packages/cli/test/format-zod-union.test.ts | 14 ++-- .../cli/test/helpers/define-stack-fixture.ts | 67 +++++++++++++++++++ .../lint-per-package-authoring-parity.test.ts | 14 ++-- .../test/metadata-type-schema-gate.test.ts | 17 +++-- .../test/union-fold-command-parity.test.ts | 9 ++- ...validate-field-predicate-traversal.test.ts | 9 ++- ...idate-per-package-authoring-parity.test.ts | 14 ++-- .../test/validate-view-container-name.test.ts | 4 +- 12 files changed, 149 insertions(+), 35 deletions(-) create mode 100644 packages/cli/test/helpers/define-stack-fixture.ts diff --git a/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts b/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts index 42316b4168f..ffd5dd2f276 100644 --- a/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts +++ b/packages/cli/src/utils/nav-contribution-groups.package-id.test.ts @@ -59,7 +59,7 @@ */ import { describe, it, expect } from 'vitest'; -import { composeStacks, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; +import { composeStacks, defineStack, normalizeStackInput, ObjectStackDefinitionSchema } from '@objectstack/spec'; import { ObjectQL } from '@objectstack/objectql/core'; import { findNavGroupDiagnostics } from './nav-contribution-groups.js'; import { artifactPackages } from './artifact-packages.js'; @@ -137,8 +137,11 @@ const ordersStack = (group: string, id: string = ORDERS_ID) => ({ /** The composed artifact, up to but NOT through the parse — the parse is what two cases below read. */ const composedArtifact = (group: string, id: string = ORDERS_ID): AnyRec => { + // Each input through `defineStack` — `composeStacks` refuses any other + // (#20367 ruling B). `strict: false`: the `name: ''` manifest above is the + // input under test, not a finding for the producer to refuse first. const composed = composeStacks( - [ordersStack(group, id), coreStack()], + [defineStack(ordersStack(group, id), { strict: false }), defineStack(coreStack(), { strict: false })], { manifest: 'preserve' }, ) as unknown as Record; return normalizeStackInput(composed, { onConversionNotice: () => {} }) as unknown as AnyRec; diff --git a/packages/cli/test/authoring-rule-command-parity.test.ts b/packages/cli/test/authoring-rule-command-parity.test.ts index 0da5c8381b2..0517cb4ed23 100644 --- a/packages/cli/test/authoring-rule-command-parity.test.ts +++ b/packages/cli/test/authoring-rule-command-parity.test.ts @@ -30,6 +30,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { AUTHORING_COMMANDS, runAuthoringRules, type AuthoringCommand } from '@objectstack/lint'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js'); @@ -179,12 +180,14 @@ describe('every authoring command reaches the same verdict (#4409)', () => { it('the broken approval flow now exits non-zero on all three commands', () => { const dir = mkdtempSync(join(tmpdir(), 'os-authoring-parity-')); try { - // A plain literal config: no imports, so it resolves without a - // node_modules next to it. + // A `defineStack` config (#20367 ruling B: the doors refuse any other + // shape), `strict: false` so the rule under test is judged by each + // command rather than by the producer at load; spec is linked in. writeFileSync( join(dir, 'objectstack.config.mjs'), - `export default ${JSON.stringify(CASES[0].stack, null, 2)};\n`, + defineStackSource(CASES[0].stack, { strict: false }), ); + linkSpec(dir); for (const command of ['lint', 'validate', 'build']) { let exitCode = 0; diff --git a/packages/cli/test/build-text-face-advisory-count.test.ts b/packages/cli/test/build-text-face-advisory-count.test.ts index 99db107bcf7..2b3609667e5 100644 --- a/packages/cli/test/build-text-face-advisory-count.test.ts +++ b/packages/cli/test/build-text-face-advisory-count.test.ts @@ -82,6 +82,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; interface Run { code: number; @@ -196,7 +197,9 @@ const ordersViews = [ }, ]; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: coreManifest, objects: [...ordersObjects, ...coreObjects], apps: [...coreApps], @@ -205,7 +208,7 @@ export default { { manifest: { ...ordersManifest, objects: ordersObjects, views: ordersViews } }, { manifest: { ...coreManifest, objects: coreObjects, apps: coreApps } }, ], -}; +}, { strict: false }); `; /** @@ -215,7 +218,9 @@ export default { * through the defect. */ const CONFIG_SINGLE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.bcsingle', name: 'bcsingle', namespace: 'bs', version: '1.0.0', type: 'app', engines: { protocol: '^17' }, @@ -231,7 +236,7 @@ export default { name: 'bs_app', label: 'BS App', navigation: [{ id: 'nav_things', type: 'object', objectName: 'bs_thing', label: 'Things' }], }], -}; +}, { strict: false }); `; const dirs = { multi: '', single: '' }; @@ -240,6 +245,7 @@ function plant(config: string): string { const dir = mkdtempSync(join(tmpdir(), 'os-bcount-')); mkdirSync(join(dir, 'src'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + linkSpec(dir); writeFileSync( join(dir, 'package.json'), JSON.stringify({ name: 'bcount-fixture', private: true, type: 'module' }, null, 2), diff --git a/packages/cli/test/emit-json-pipe.test.ts b/packages/cli/test/emit-json-pipe.test.ts index 11e8a16138d..7f3981d9444 100644 --- a/packages/cli/test/emit-json-pipe.test.ts +++ b/packages/cli/test/emit-json-pipe.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const execFileP = promisify(execFile); @@ -185,8 +186,11 @@ describe('os validate --json over a pipe', () => { join(configDir, 'objectstack.config.ts'), // #8687: no stray top-level `name` — it would add a 901st (unrecognized_keys) // error and break the exact-count assertion below. - `export default ${JSON.stringify({ objects }, null, 2)};\n`, + // `strict: false`: the 900 issues are the DOOR's parse to report — the + // strict producer would refuse at load (#20367 ruling B keeps the shape). + defineStackSource({ objects }, { strict: false }), ); + linkSpec(configDir); }); afterAll(() => { diff --git a/packages/cli/test/format-zod-union.test.ts b/packages/cli/test/format-zod-union.test.ts index 43d57346391..0acb475b519 100644 --- a/packages/cli/test/format-zod-union.test.ts +++ b/packages/cli/test/format-zod-union.test.ts @@ -38,6 +38,7 @@ import { ObjectStackDefinitionSchema } from '@objectstack/spec'; import { NormalizedFilterSchema } from '@objectstack/spec/data'; import { formatZodErrors } from '../src/utils/format'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js'); @@ -209,11 +210,14 @@ const TOOLTIP_ALIAS_STACK = { function runCli(command: string, stack: Record, args: string[] = []): { exitCode: number; output: string } { const dir = mkdtempSync(join(tmpdir(), 'os-union-format-')); try { - // A plain literal, not `defineStack`/`defineView`: those factories parse - // eagerly and would throw through spec's OWN formatter, which has expanded - // unions since #4971 — the one thing this file must not accidentally - // measure instead of the CLI's renderer. - writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`); + // `defineStack(…, { strict: false })`, not a strict factory: a strict + // `defineStack` / `defineView` parses eagerly and would throw through + // spec's OWN formatter, which has expanded unions since #4971 — the one + // thing this file must not accidentally measure instead of the CLI's + // renderer. Non-strict skips that parse and still carries the provenance + // mark the doors require (#20367 ruling B); spec is linked in. + writeFileSync(join(dir, 'objectstack.config.mjs'), defineStackSource(stack, { strict: false })); + linkSpec(dir); try { const output = execFileSync(process.execPath, [cliBin, command, ...args], { cwd: dir, diff --git a/packages/cli/test/helpers/define-stack-fixture.ts b/packages/cli/test/helpers/define-stack-fixture.ts new file mode 100644 index 00000000000..b66bed27398 --- /dev/null +++ b/packages/cli/test/helpers/define-stack-fixture.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Fixture projects authored in the ONE legal shape (#20367 ruling B): + * `export default defineStack({ … })`. + * + * `os validate` and `os build` refuse a default export no stack producer built + * (`STACK_PROVENANCE_MISSING`), so a fixture that wants either door to judge its + * CONTENT has to be built by `defineStack` — which means the fixture project has + * to RESOLVE `@objectstack/spec`. An OS-tmpdir project cannot, so + * {@link linkSpec} gives it a `node_modules/@objectstack/spec` symlink to the + * package this one already depends on — the spelling `migrate-meta.e2e.test.ts` + * and `generate-skill.e2e.test.ts` established. Resolved through + * `node_modules` rather than by walking up from this file, so it is a package + * specifier and not a cross-package source read. + * + * `strict: false` is for a fixture whose content is the DOOR's to judge — a + * shape the strict producer would refuse at load, before the door runs + * anything (a schema error the door's own parse must report, an unknown + * `requires` token the capability preflight must render). The mark is the same + * in both modes; only what `defineStack` itself refuses differs. + */ +import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +export const SPEC_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json')); + +/** Make `@objectstack/spec` resolvable from `dir` (idempotent). */ +export function linkSpec(dir: string): void { + const scope = join(dir, 'node_modules', '@objectstack'); + const link = join(scope, 'spec'); + if (existsSync(link)) return; + mkdirSync(scope, { recursive: true }); + symlinkSync(SPEC_PACKAGE_ROOT, link, 'dir'); +} + +export interface DefineStackSourceOptions { + /** Emit `defineStack(…, { strict: false })` — see the module header for when. */ + strict?: false; +} + +/** The module source `export default defineStack()`, from a JSON-able value. */ +export function defineStackSource(stack: unknown, options: DefineStackSourceOptions = {}): string { + return defineStackSourceFromLiteral(JSON.stringify(stack, null, 2), options); +} + +/** The same, from an object-literal SOURCE string (for fixtures that carry functions). */ +export function defineStackSourceFromLiteral(literal: string, options: DefineStackSourceOptions = {}): string { + const trailer = options.strict === false ? ', { strict: false }' : ''; + return `import { defineStack } from '@objectstack/spec';\n\nexport default defineStack(${literal.trim()}${trailer});\n`; +} + +/** + * Write `/` as a `defineStack` module over `stack` and link spec + * into `dir`. `file` defaults to `objectstack.config.ts`. + */ +export function writeDefineStackConfig( + dir: string, + stack: unknown, + options: DefineStackSourceOptions & { file?: string } = {}, +): string { + const file = join(dir, options.file ?? 'objectstack.config.ts'); + writeFileSync(file, defineStackSource(stack, options)); + linkSpec(dir); + return file; +} diff --git a/packages/cli/test/lint-per-package-authoring-parity.test.ts b/packages/cli/test/lint-per-package-authoring-parity.test.ts index 80a607bbc62..cfe80d17ccc 100644 --- a/packages/cli/test/lint-per-package-authoring-parity.test.ts +++ b/packages/cli/test/lint-per-package-authoring-parity.test.ts @@ -55,6 +55,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; interface Run { code: number; @@ -161,7 +162,9 @@ const ordersViews = [ }, ]; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: coreManifest, objects: [...ordersObjects, ...coreObjects], apps: [...coreApps], @@ -170,7 +173,7 @@ export default { { manifest: { ...ordersManifest, objects: ordersObjects, views: ordersViews } }, { manifest: { ...coreManifest, objects: coreObjects, apps: coreApps } }, ], -}; +}, { strict: false }); `; /** @@ -180,7 +183,9 @@ export default { * cards' worth of parity files. */ const CONFIG_SINGLE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.ppsingle', name: 'ppsingle', namespace: 'ps', version: '1.0.0', type: 'app', engines: { protocol: '^17' }, @@ -196,7 +201,7 @@ export default { name: 'ps_app', label: 'PS App', navigation: [{ id: 'nav_things', type: 'object', objectName: 'ps_thing', label: 'Things' }], }], -}; +}, { strict: false }); `; const dirs = { flip: '', single: '' }; @@ -205,6 +210,7 @@ function plant(config: string): string { const dir = mkdtempSync(join(tmpdir(), 'os-lintpp-')); mkdirSync(join(dir, 'src'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + linkSpec(dir); writeFileSync( join(dir, 'package.json'), JSON.stringify({ name: 'lintpp-fixture', private: true, type: 'module' }, null, 2), diff --git a/packages/cli/test/metadata-type-schema-gate.test.ts b/packages/cli/test/metadata-type-schema-gate.test.ts index 3dda2920e9e..45c4fbc80a4 100644 --- a/packages/cli/test/metadata-type-schema-gate.test.ts +++ b/packages/cli/test/metadata-type-schema-gate.test.ts @@ -49,6 +49,7 @@ import { fileURLToPath } from 'node:url'; import { ObjectStackDefinitionSchema, lintUnknownAuthoringKeys, formatUnknownAuthoringKey } from '@objectstack/spec'; import { getMetadataTypeSchema, listMetadataTypeSchemaTypes } from '@objectstack/spec/kernel'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const cliBin = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'bin', 'run-dev.js'); @@ -200,18 +201,22 @@ function runCli(command: string, dir: string, args: string[] = []): { exitCode: } /** - * A config written as a plain literal — no `defineStack` / `definePage`. + * A config written through `defineStack(…, { strict: false })` — no strict + * `defineStack` / `definePage`. * - * Load-bearing: those factories parse eagerly, so a config authored through - * them is rejected before the command's own gate is ever consulted. #5000's - * repro edited `examples/app-showcase`, where every page goes through + * Load-bearing: the strict factories parse eagerly, so a config authored + * through them is rejected before the command's own gate is ever consulted. + * #5000's repro edited `examples/app-showcase`, where every page goes through * `definePage`, so its exit code could not distinguish "the CLI gates" from - * "the factory threw". A literal isolates the command's own parse. + * "the factory threw". Non-strict skips the producer's parse, which isolates + * the command's own, and still carries the provenance mark `os validate` / + * `os build` require of every default export (#20367 ruling B). */ function withConfig(stack: Record, body: (dir: string) => T): T { const dir = mkdtempSync(join(tmpdir(), 'os-metadata-gate-')); try { - writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`); + writeFileSync(join(dir, 'objectstack.config.mjs'), defineStackSource(stack, { strict: false })); + linkSpec(dir); return body(dir); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/packages/cli/test/union-fold-command-parity.test.ts b/packages/cli/test/union-fold-command-parity.test.ts index 62d9b9174e8..823ea80e014 100644 --- a/packages/cli/test/union-fold-command-parity.test.ts +++ b/packages/cli/test/union-fold-command-parity.test.ts @@ -107,6 +107,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -237,13 +238,15 @@ interface Run { /** * One authoring command over one option-B project, as a shell sees it. * - * A plain literal config with no imports, so it resolves with no `node_modules` - * next to it — the `authoring-rule-command-parity.test.ts` pattern. + * A `defineStack(…, { strict: false })` config with spec linked in — the + * `authoring-rule-command-parity.test.ts` pattern since #20367 ruling B, where + * `os validate` / `os build` refuse any other default export. */ function runCommand(command: string, stack: Record): Run { const dir = mkdtempSync(join(tmpdir(), 'os-union-fold-')); try { - writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`); + writeFileSync(join(dir, 'objectstack.config.mjs'), defineStackSource(stack, { strict: false })); + linkSpec(dir); try { const stdout = execFileSync(process.execPath, [CLI, command], { cwd: dir, diff --git a/packages/cli/test/validate-field-predicate-traversal.test.ts b/packages/cli/test/validate-field-predicate-traversal.test.ts index 31ba5bb02a9..16ab2297c96 100644 --- a/packages/cli/test/validate-field-predicate-traversal.test.ts +++ b/packages/cli/test/validate-field-predicate-traversal.test.ts @@ -43,13 +43,16 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); const objects = (orderFields: string, orderExtra = '') => ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.trav', name: 'trav', version: '1.0.0', type: 'app', namespace: 'trav' }, objects: [ { @@ -74,7 +77,7 @@ export default { }, ], apps: [{ name: 'trav_app', label: 'Trav App' }], -}; +}, { strict: false }); `; /** One read through `account` on each of the three slots. */ @@ -150,8 +153,10 @@ let controlDir: string; beforeAll(() => { traversingDir = mkdtempSync(join(tmpdir(), 'os-validate-traversal-')); writeFileSync(join(traversingDir, 'objectstack.config.ts'), TRAVERSING); + linkSpec(traversingDir); controlDir = mkdtempSync(join(tmpdir(), 'os-validate-traversal-control-')); writeFileSync(join(controlDir, 'objectstack.config.ts'), CONTROL); + linkSpec(controlDir); }); afterAll(() => { diff --git a/packages/cli/test/validate-per-package-authoring-parity.test.ts b/packages/cli/test/validate-per-package-authoring-parity.test.ts index adaaaf17879..69d58f6c92c 100644 --- a/packages/cli/test/validate-per-package-authoring-parity.test.ts +++ b/packages/cli/test/validate-per-package-authoring-parity.test.ts @@ -74,6 +74,7 @@ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CLI, TSX, childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; interface Run { code: number; @@ -176,7 +177,9 @@ const ordersViews = [ }, ]; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ // The ARTIFACT's own identity: \`preserve\` is additive, so the singular // manifest is still picked by the default 'last' rule (ADR-0019 D1) and // carries manifest fields ONLY — \`ManifestSchema\` is strict, and a @@ -193,7 +196,7 @@ export default { { manifest: { ...ordersManifest, objects: ordersObjects, views: ordersViews } }, { manifest: { ...coreManifest, objects: coreObjects, apps: coreApps } }, ], -}; +}, { strict: false }); `; /** @@ -204,7 +207,9 @@ export default { * anything. */ const CONFIG_SINGLE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.ppsingle', name: 'ppsingle', namespace: 'ps', version: '1.0.0', type: 'app', engines: { protocol: '^17' }, @@ -220,7 +225,7 @@ export default { name: 'ps_app', label: 'PS App', navigation: [{ id: 'nav_things', type: 'object', objectName: 'ps_thing', label: 'Things' }], }], -}; +}, { strict: false }); `; const dirs = { multi: '', single: '' }; @@ -229,6 +234,7 @@ function plant(config: string): string { const dir = mkdtempSync(join(tmpdir(), 'os-ppparity-')); mkdirSync(join(dir, 'src'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config, 'utf8'); + linkSpec(dir); writeFileSync( join(dir, 'package.json'), JSON.stringify({ name: 'ppparity-fixture', private: true, type: 'module' }, null, 2), diff --git a/packages/cli/test/validate-view-container-name.test.ts b/packages/cli/test/validate-view-container-name.test.ts index 6a2fbb20c10..4ae58d763f0 100644 --- a/packages/cli/test/validate-view-container-name.test.ts +++ b/packages/cli/test/validate-view-container-name.test.ts @@ -36,6 +36,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ObjectQL } from '@objectstack/objectql'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -123,7 +124,8 @@ beforeAll(() => { const make = (label: string, s: Record) => { const dir = join(root, label); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'objectstack.config.ts'), `export default ${JSON.stringify(s, null, 2)};\n`); + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource(s)); + linkSpec(dir); dirs[label] = dir; }; make('divergent', stack('order_line')); From 04036f63009f48c8577260b009f03b76f0e9df5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:07:54 +0000 Subject: [PATCH 05/13] =?UTF-8?q?feat(spec,cli)!:=20one=20stack=20authorin?= =?UTF-8?q?g=20shape=20=E2=80=94=20docs,=20README=20template,=20migration?= =?UTF-8?q?=20entry,=20changeset,=20door=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .changeset/20367-one-stack-authoring-shape.md | 37 ++++ content/docs/api/environment-routing.mdx | 7 +- packages/cli/src/commands/compile.ts | 7 +- packages/cli/src/commands/create.ts | 8 +- packages/cli/src/commands/generate.ts | 7 +- .../requires-retired-capability.e2e.test.ts | 79 ++++---- .../cli/test/stack-provenance-door.test.ts | 187 ++++++++++++++++++ ...k-config-default-export-unbuilt-refused.ts | 50 +++++ packages/spec/src/migrations/registry.ts | 46 +++++ 9 files changed, 379 insertions(+), 49 deletions(-) create mode 100644 .changeset/20367-one-stack-authoring-shape.md create mode 100644 packages/cli/test/stack-provenance-door.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.stack-config-default-export-unbuilt-refused.ts diff --git a/.changeset/20367-one-stack-authoring-shape.md b/.changeset/20367-one-stack-authoring-shape.md new file mode 100644 index 00000000000..2d5da75e813 --- /dev/null +++ b/.changeset/20367-one-stack-authoring-shape.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": minor +--- + +**BREAKING — one authoring shape for a stack config.** `objectstack validate` and `objectstack build` now refuse a config whose default export was not built by `defineStack(...)` (either mode) or `composeStacks(...)`, with `STACK_PROVENANCE_MISSING` and exit 1, right after the config loads and before any other check. `composeStacks` refuses an input no producer built the same way. + +Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead. + +- `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input. +- `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. + +**Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns: + +```ts +// FROM +export default { + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], +}; +// or: export default { ...defineStack({ … }), api: { … } }; + +// TO +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { id: 'com.example.app', namespace: 'app', version: '1.0.0', type: 'app', name: 'App' }, + objects: [/* … */], + // every stack key inside the call — `api`, `plugins`, `requires`, … +}); +``` + +One-line fix: wrap the export in `defineStack(...)`, and move any key spread onto a copy into the call. For compositions, wrap each input: `composeStacks([defineStack({ … }), …])`. Once wrapped, a config that used to pass can now fail with one of the family's own codes. Those findings were always there; the plain export hid them. Fix each one as its message says. Host-style configs whose `plugins` hold plugin instances are covered by the same rule, and the same wrap fixes them (`defineStack` accepts plugin instances). A project already exporting `defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected. + +Clause-②: yes (narrowing) + + diff --git a/content/docs/api/environment-routing.mdx b/content/docs/api/environment-routing.mdx index 9043d39080b..8d4de2583b4 100644 --- a/content/docs/api/environment-routing.mdx +++ b/content/docs/api/environment-routing.mdx @@ -72,9 +72,10 @@ declared stack key, so `defineStack()` rejects it as an unrecognized key. `api` is a declared top-level field on `ObjectStackDefinitionSchema`, so it survives `defineStack`'s strict parsing — you can pass it directly inside the -`defineStack({ ... })` call as shown above. (Older stacks that instead spread -it onto the exported config object, e.g. `export default { ...stack, api: {...} }`, -still work the same way.) The CLI reads the resolved value from the exported +`defineStack({ ... })` call as shown above — and keep it there. Spreading it onto +a copy of the built stack instead, e.g. `export default { ...stack, api: {...} }`, +exports an object `defineStack` did not return, which `os validate` and `os build` +refuse (`STACK_PROVENANCE_MISSING`). The CLI reads the resolved value from the exported config (`config.api`) when registering the REST and dispatcher plugins — but it reads it *after* the boot result has been merged in, which is why the standalone path forwards the boot builder's scoping decision rather than the author's. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 5d8a3ce0103..f97befdfe75 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -695,9 +695,10 @@ export default class Compile extends Command { // 3d. [#3786] Keys `ObjectSchema` / `FieldSchema` do not declare, and so // drop silently on the way to storage. PRE-parse, since the parse is // what strips them. `defineStack` already warns for configs authored - // through it; this covers the ones that skip it (a plain object - // default-export, `strict: false`) and would otherwise emit an - // artifact with the key quietly gone. Advisory, never fatal. + // through it; this covers the ones that skip it (`strict: false`; + // a plain-object default export no longer gets this far — step 1a + // refuses it) and would otherwise emit an artifact with the key + // quietly gone. Advisory, never fatal. // // [#11643] FORMATTED HERE, once, and consumed by BOTH faces — the // text block just below and the `--json` payload at the end of this diff --git a/packages/cli/src/commands/create.ts b/packages/cli/src/commands/create.ts index d3b831ca552..1e5a7a3621c 100644 --- a/packages/cli/src/commands/create.ts +++ b/packages/cli/src/commands/create.ts @@ -502,14 +502,16 @@ hyphen, an underscore, a leading digit) are folded away, so the exported symbol can differ from the name. \`\`\`typescript +import { defineStack } from '@objectstack/spec'; import { ${sanitizeIdentifier(name)}Plugin } from '${packageName}'; -// Use the plugin in your ObjectStack configuration -export default { +// Use the plugin in your ObjectStack configuration — always through +// defineStack(): \`os validate\` / \`os build\` refuse any other default export. +export default defineStack({ plugins: [ ${sanitizeIdentifier(name)}Plugin, ], -}; +}); \`\`\` ## License diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 9490ed958dc..dea1cc811e5 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -811,9 +811,10 @@ function nameCharsetRefusal(name: string): string | null { * of them silently generated `unknown` — a plausible-looking wrong type with * nothing to tell the author. The `|| 'unknown'` below stays, and now means * only what it always should have: this generator's answer for a `type` string - * that is not a `FieldType` at all, which the UNVALIDATED authoring door (a - * plain-object config export, `defineStack(x, { strict: false })`) can still - * deliver. + * that is not a `FieldType` at all, which the UNVALIDATED authoring mode + * (`defineStack(x, { strict: false })`) can still deliver. A plain-object config + * export is no longer an authoring door at all: `os validate` / `os build` + * refuse a default export `defineStack` did not build (`STACK_PROVENANCE_MISSING`). * * Values are MEASURED, not invented — each one is the shape the platform * actually implements, read from the spec's ADR-0104 D1 value classes diff --git a/packages/cli/test/requires-retired-capability.e2e.test.ts b/packages/cli/test/requires-retired-capability.e2e.test.ts index 13f2bb40486..e932220e0b5 100644 --- a/packages/cli/test/requires-retired-capability.e2e.test.ts +++ b/packages/cli/test/requires-retired-capability.e2e.test.ts @@ -4,22 +4,20 @@ * A RETIRED `requires` token reads as its retirement prescription at the * `os validate` / `os build` door — never as "check for a typo". * - * Both commands accept a PLAIN-OBJECT config (`export default { … }`, no - * `defineStack` call). That config is parsed with - * `ObjectStackDefinitionSchema.safeParse`, whose `requires` is a bare string - * array — the `defineStack` vocabulary check never runs — and the only text - * the author sees for a `requires` token is the capability preflight's - * `renderCapabilityMessage`. For a word that USED to be a capability (the - * saved-report stack's `reports`), the typo advice is wrong in a way that - * sends the author hunting for a spelling instead of deleting the token. + * [#20367 ruling B] Re-judged. This pin used to hold the PLAIN-OBJECT posture: + * `export default { … }` skipped `defineStack`, so the vocabulary check never + * ran and an unknown token was an ADVISORY at the door (exit 0, a + * `{ token, message }` record in `warnings`). Both doors now refuse a default + * export no stack producer built (`STACK_PROVENANCE_MISSING`), so the config is + * authored in the one legal shape — `defineStack({ … })` — and an unknown token + * is the producer's refusal at load: `STACK_CAPABILITY_UNKNOWN`, exit 1, on + * both doors, through the same `--json` envelope (`error` + `code`). * - * Posture is unchanged: an unknown token is an ADVISORY at this door (exit 0, - * a `{ token, message }` record in `warnings`); only its TEXT is asserted - * here, and it is asserted EQUAL to the spec-owned prescription — the same - * string `defineStack` refuses the token with and `os serve` warns with. - * - * A misspelled token rides in the same fixture as the control, so "the - * prescription is shown" cannot pass against a renderer that shows it for + * What stays pinned is the TEXT the author reads, now in that refusal: the + * retired token carries the spec-owned prescription verbatim (the same string + * `os serve` warns with), and only the misspelled token carries the typo + * advice. The misspelled token rides in the same fixture as the control, so + * "the prescription is shown" cannot pass against a renderer that shows it for * every unknown token. */ @@ -31,6 +29,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -59,29 +58,29 @@ function runCli(args: string[], cwd: string): Promise { }); } -function capabilityHints(run: Run, label: string): Map { - let payload: { warnings?: unknown }; +interface Refusal { + valid?: unknown; + success?: unknown; + error?: unknown; + code?: unknown; +} + +function refusalPayload(run: Run, label: string): Refusal { try { - payload = JSON.parse(run.stdout) as { warnings?: unknown }; + return JSON.parse(run.stdout) as Refusal; } catch { throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); } - const hints = new Map(); - for (const w of Array.isArray(payload.warnings) ? payload.warnings : []) { - if (typeof w === 'object' && w !== null && 'token' in w) { - const r = w as { token: unknown; message: unknown }; - hints.set(String(r.token), String(r.message)); - } - } - return hints; } const RETIRED_TOKEN = 'reports'; const TYPO_TOKEN = 'reportz'; -/** A plain-object config: no `defineStack`, so no parse-time vocabulary check. */ +/** The one legal shape: `defineStack` runs the vocabulary check at load. */ const CONFIG = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.retiredcap', name: 'retiredcap', version: '1.0.0', type: 'app', namespace: 'retiredcap' }, requires: ['${RETIRED_TOKEN}', '${TYPO_TOKEN}'], objects: [ @@ -92,7 +91,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }, ], -}; +}); `; let dir = ''; @@ -100,13 +99,14 @@ let dir = ''; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'os-retired-cap-')); writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG); + linkSpec(dir); }); afterAll(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); -describe('a retired `requires` token at the `os validate` / `os build` door (plain-object config)', () => { +describe('a retired `requires` token at the `os validate` / `os build` door (defineStack config)', () => { const prescription = RETIRED_PLATFORM_CAPABILITY_GUIDANCE[RETIRED_TOKEN]; it('the spec carries a prescription for the token under test', () => { @@ -114,14 +114,19 @@ describe('a retired `requires` token at the `os validate` / `os build` door (pla }); for (const command of ['validate', 'build'] as const) { - it(`os ${command} --json: advisory posture, the retired token carries the prescription, the typo keeps the typo hint`, async () => { + it(`os ${command} --json: STACK_CAPABILITY_UNKNOWN, exit 1 — the retired token carries the prescription, the typo keeps the typo hint`, async () => { const run = await runCli([command, '--json'], dir); - expect(run.code, `os ${command} --json failed:\n${run.stdout}${run.stderr}`).toBe(0); - const hints = capabilityHints(run, `os ${command} --json`); - expect([...hints.keys()].sort()).toEqual([RETIRED_TOKEN, TYPO_TOKEN].sort()); - expect(hints.get(RETIRED_TOKEN)).toBe(prescription); - expect(hints.get(RETIRED_TOKEN)).not.toContain('check for a typo'); - expect(hints.get(TYPO_TOKEN)).toContain('check for a typo'); + const payload = refusalPayload(run, `os ${command} --json`); + expect(run.code, `os ${command} --json:\n${run.stdout}${run.stderr}`).toBe(1); + expect(payload.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(command === 'validate' ? payload.valid : payload.success).toBe(false); + const message = String(payload.error); + // The retired token: its prescription, verbatim — and it is not typo advice. + expect(message).toContain(prescription); + expect(prescription).not.toContain('check for a typo'); + // The misspelled token: the typo advice, naming it — and only it. + expect(message).toContain(`requires: '${TYPO_TOKEN}' is not a known platform capability — check for a typo`); + expect(message.split('check for a typo').length - 1).toBe(1); }, 180_000); } }); diff --git a/packages/cli/test/stack-provenance-door.test.ts b/packages/cli/test/stack-provenance-door.test.ts new file mode 100644 index 00000000000..b1f1016ecc5 --- /dev/null +++ b/packages/cli/test/stack-provenance-door.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os validate` and `os build` accept one authoring shape (#20367 ruling B). + * + * ## What was wrong + * + * The `STACK_*` cross-field refusals (capability vocabulary, cross-references, + * namespace prefix, single app, hierarchy-scope and trigger capability) run + * inside `defineStack` and nowhere else. The same defective stack exported as + * a plain object literal skipped all of them: both doors ran only the schema + * parse and answered exit 0, and `os build` shipped the artifact. + * + * ## What is pinned — each row at BOTH doors, by `code` and exit status + * + * | default export | answer | + * |:---------------------------------------------------|:-----------------------------------------| + * | the defective stack as `defineStack({ … })` | the family's own code, exit 1 | + * | the SAME stack as a plain object | `STACK_PROVENANCE_MISSING`, exit 1 | + * | a host-shaped plain object (instantiated plugins) | `STACK_PROVENANCE_MISSING`, exit 1 | + * | the same host shape as `defineStack({ … })` | accepted, exit 0 | + * | a spread copy of a built stack | `STACK_PROVENANCE_MISSING`, exit 1 | + * | `defineStack` + a named export (`onEnable`) | accepted, exit 0 — the mark is read off | + * | | the default BEFORE the named-export merge | + * + * The plain-object refusal happens before any other judgement, so `os build` + * writes no artifact for it. The host-shaped rows are the ruling's premise as + * measured: a host-shaped export CAN reach these doors, it is refused only when + * no producer built it, and the prescribed fix (wrap it in `defineStack`) is + * accepted — `examples/app-showcase` is a host-shaped `defineStack` config. + * + * Refusal assertions read the envelope (`code`, exit status), never a bare + * throw; the first sentence of the prescription is asserted on top because the + * wording IS the fix an author (or an AI) applies. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { defineStackSourceFromLiteral, linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +interface Payload { + valid?: boolean; + success?: boolean; + error?: string; + code?: string; +} + +function payloadOf(run: Run, label: string): Payload { + try { + return JSON.parse(run.stdout) as Payload; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); + } +} + +/** A stack `defineStack` refuses: `requires` names a token no runtime provides. */ +const DEFECTIVE = `{ + manifest: { id: 'com.example.prov', name: 'prov', version: '1.0.0', type: 'app', namespace: 'prov' }, + requires: ['no-such-capability'], + objects: [{ name: 'prov_thing', label: 'Thing', fields: { title: { type: 'text', label: 'Title' } } }], +}`; + +/** A host-shaped stack: its `plugins` list carries an instantiated plugin object. */ +const HOST = `{ + manifest: { id: 'com.example.host', name: 'host', version: '1.0.0', type: 'app', namespace: 'host' }, + plugins: [{ name: 'com.example.host.probe', init: async () => {}, start: async () => {} }], +}`; + +const FIXTURES: Record = { + defineStackDefective: defineStackSourceFromLiteral(DEFECTIVE), + plainDefective: `export default ${DEFECTIVE};\n`, + plainHost: `export default ${HOST};\n`, + defineStackHost: defineStackSourceFromLiteral(HOST), + spreadCopy: + `import { defineStack } from '@objectstack/spec';\n\n` + + `const stack = defineStack({ manifest: { id: 'com.example.copy', name: 'copy', version: '1.0.0', type: 'app', namespace: 'copy' } });\n\n` + + `export default { ...stack, api: {} };\n`, + namedExport: + `import { defineStack } from '@objectstack/spec';\n\n` + + `export const onEnable = async () => {};\n\n` + + `export default defineStack({ manifest: { id: 'com.example.named', name: 'named', version: '1.0.0', type: 'app', namespace: 'named' } });\n`, +}; + +let root = ''; +const dirs: Record = {}; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-stack-provenance-')); + for (const [label, source] of Object.entries(FIXTURES)) { + const dir = join(root, label); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); + dirs[label] = dir; + } +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const PRESCRIPTION_HEAD = 'objectstack.config.ts: the default export was not built by `defineStack`'; + +for (const command of ['validate', 'build'] as const) { + const ok = (p: Payload) => (command === 'validate' ? p.valid : p.success); + + describe(`os ${command} --json — one authoring shape`, () => { + it('the defective stack as defineStack({ … }): the family\'s own code, exit 1', async () => { + const run = await runCli([command, '--json'], dirs.defineStackDefective); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(1); + expect(p.code).toBe('STACK_CAPABILITY_UNKNOWN'); + expect(ok(p)).toBe(false); + }, 180_000); + + it('the SAME stack as a plain object: STACK_PROVENANCE_MISSING, exit 1, the defineStack prescription', async () => { + const run = await runCli([command, '--json'], dirs.plainDefective); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(1); + expect(p.code).toBe('STACK_PROVENANCE_MISSING'); + expect(ok(p)).toBe(false); + expect(p.error?.startsWith(PRESCRIPTION_HEAD), p.error).toBe(true); + expect(p.error).toContain('export default defineStack({ … });'); + if (command === 'build') { + // Refused before any other step: no artifact is emitted. + expect(existsSync(join(dirs.plainDefective, 'dist', 'objectstack.json'))).toBe(false); + } + }, 180_000); + + it('a host-shaped plain object is refused the same way', async () => { + const run = await runCli([command, '--json'], dirs.plainHost); + expect(run.code, run.stdout + run.stderr).toBe(1); + expect(payloadOf(run, command).code).toBe('STACK_PROVENANCE_MISSING'); + }, 180_000); + + it('control: the same host shape as defineStack({ … }) is accepted', async () => { + const run = await runCli([command, '--json'], dirs.defineStackHost); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(ok(p)).toBe(true); + }, 180_000); + + it('a spread copy of a built stack is not the built stack: STACK_PROVENANCE_MISSING, exit 1', async () => { + const run = await runCli([command, '--json'], dirs.spreadCopy); + expect(run.code, run.stdout + run.stderr).toBe(1); + expect(payloadOf(run, command).code).toBe('STACK_PROVENANCE_MISSING'); + }, 180_000); + + it('control: defineStack plus a named export is accepted — the mark is read before the named-export merge', async () => { + const run = await runCli([command, '--json'], dirs.namedExport); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(ok(p)).toBe(true); + }, 180_000); + }); +} diff --git a/packages/spec/src/migrations/entries/semantic/18.stack-config-default-export-unbuilt-refused.ts b/packages/spec/src/migrations/entries/semantic/18.stack-config-default-export-unbuilt-refused.ts new file mode 100644 index 00000000000..cc0c4b2cd30 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.stack-config-default-export-unbuilt-refused.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// One authoring shape for a stack config (the one-authoring-shape ruling). No D2 conversion: +// the change is to the config MODULE's shape (what its default export is), not +// to any metadata key, so there is nothing `objectstack migrate meta` could +// rewrite in a stored row or a parsed stack — the fix is one wrapping call in +// the author's source. +// +// No backticks in `surface` — build-upgrade-guide.ts renders it inside a code +// span already, and a nested backtick would close it. +export const entry: SemanticMigration = { + id: 'stack-config-default-export-unbuilt-refused', + surface: + 'the default export of objectstack.config.ts when it is not the value defineStack or composeStacks ' + + 'returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of ' + + 'one, a module with no default export — and each input handed to composeStacks', + replacement: + 'export what the producer returned: `import { defineStack } from \'@objectstack/spec\'; export default ' + + 'defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or ' + + '`export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, ' + + '`functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it ' + + 'is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read', + reason: + 'The stack family\'s cross-field refusals — unknown `requires` capability, cross-references to objects ' + + 'the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and ' + + 'trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a ' + + 'plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema ' + + 'parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or ' + + 'never (a trigger flow that silently never fires). Judging the export at the door instead is not ' + + 'possible: a built stack carries each bound standalone action twice (top level and merged into its ' + + 'object), so re-running the family on `defineStack` output refuses every correct project with a bound ' + + 'action. So both producers stamp a non-enumerable provenance mark on what they return ' + + '(`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default ' + + 'export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; ' + + '`composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused ' + + 'too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is ' + + 'not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. ' + + '`objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087.', + acceptanceCriteria: + 'Run `objectstack validate` (and `objectstack build`) in every project. A refusal prints ' + + '`objectstack.config.ts: the default export was not built by defineStack` and, under `--json`, carries ' + + '`code: STACK_PROVENANCE_MISSING`. Wrap the export in `defineStack({ … })`, move any key that was ' + + 'spread onto a copy inside the call, and re-run: the command either passes or now reports the stack ' + + 'family\'s own findings (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, …) that the plain ' + + 'export had been hiding — fix those as each message prescribes. A project already exporting ' + + '`defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected and passes ' + + 'byte-identically.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6095aa93471..0aa31853da0 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -14700,6 +14700,52 @@ const step18: MigrationStep = { + '`language` fails to parse with its own prescription instead of being silently ' + 'stripped, and assigning it is a `tsc` error at the authoring site.', }, + // One authoring shape for a stack config (the one-authoring-shape ruling). No D2 conversion: + // the change is to the config MODULE's shape (what its default export is), not + // to any metadata key, so there is nothing `objectstack migrate meta` could + // rewrite in a stored row or a parsed stack — the fix is one wrapping call in + // the author's source. + // + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span already, and a nested backtick would close it. + { + id: 'stack-config-default-export-unbuilt-refused', + surface: + 'the default export of objectstack.config.ts when it is not the value defineStack or composeStacks ' + + 'returned: a plain object literal, a spread or Object.assign copy of a built stack, a JSON copy of ' + + 'one, a module with no default export — and each input handed to composeStacks', + replacement: + 'export what the producer returned: `import { defineStack } from \'@objectstack/spec\'; export default ' + + 'defineStack({ … });` with every stack key inside the call (`api`, `plugins`, `requires`, …), or ' + + '`export default composeStacks([defineStack({ … }), …])`. Named exports beside it (`onEnable`, ' + + '`functions`) are unaffected. `defineStack(config, { strict: false })` also satisfies the doors — it ' + + 'is still the producer — but skips its judgement, so reserve it for sources a strict parse cannot yet read', + reason: + 'The stack family\'s cross-field refusals — unknown `requires` capability, cross-references to objects ' + + 'the stack does not define, the namespace prefix, one app per app package, the hierarchy-scope and ' + + 'trigger capability requirements — run inside `defineStack` and nowhere else. A config exporting a ' + + 'plain object skipped all of them: `objectstack validate` and `objectstack build` ran only the schema ' + + 'parse, answered success, and the build shipped the artifact, so the defect surfaced at deploy or ' + + 'never (a trigger flow that silently never fires). Judging the export at the door instead is not ' + + 'possible: a built stack carries each bound standalone action twice (top level and merged into its ' + + 'object), so re-running the family on `defineStack` output refuses every correct project with a bound ' + + 'action. So both producers stamp a non-enumerable provenance mark on what they return ' + + '(`hasStackProvenance`), and `objectstack validate` / `objectstack build` refuse an unmarked default ' + + 'export right after load with `STACK_PROVENANCE_MISSING` (exit 1), before any other judgement; ' + + '`composeStacks` refuses an unmarked input with the same code. A copy of a built stack is refused ' + + 'too, because the mark does not survive a spread or JSON round-trip — by design, since the copy is ' + + 'not what the producer judged. ⚠️ No D2 conversion: the module shape is source code, not metadata. ' + + '`objectstack serve`, `objectstack migrate` and `objectstack lint` load the config as before. ADR-0087.', + acceptanceCriteria: + 'Run `objectstack validate` (and `objectstack build`) in every project. A refusal prints ' + + '`objectstack.config.ts: the default export was not built by defineStack` and, under `--json`, carries ' + + '`code: STACK_PROVENANCE_MISSING`. Wrap the export in `defineStack({ … })`, move any key that was ' + + 'spread onto a copy inside the call, and re-run: the command either passes or now reports the stack ' + + 'family\'s own findings (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, …) that the plain ' + + 'export had been hiding — fix those as each message prescribes. A project already exporting ' + + '`defineStack(...)` or `composeStacks([...])` of `defineStack` inputs is unaffected and passes ' + + 'byte-identically.', + }, // Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the // D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` // precedent): an authored theme has no lossless target. `app.branding` holds From 627b481f42f5fd8819e6e8073f2a0cc8a812a4f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:26:03 +0000 Subject: [PATCH 06/13] wip(cli): e2e fixtures authored through defineStack Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .../validate-json-strict-exit.e2e.test.ts | 23 +++++++++---- .../test/build-docs-step-count.e2e.test.ts | 10 ++++-- .../build-json-advisory-parity.e2e.test.ts | 14 +++++--- ...build-json-failure-conversions.e2e.test.ts | 12 +++++-- .../build-json-failure-warnings.e2e.test.ts | 26 ++++++++++----- ...ild-json-undeclared-key-parity.e2e.test.ts | 14 +++++--- .../build-multi-package-artifact.e2e.test.ts | 26 ++++++++++----- .../build-package-docs-attachment.e2e.test.ts | 15 ++++++--- .../compile-artifact-packages.e2e.test.ts | 20 ++++++++---- .../test/hook-body-build-reach.e2e.test.ts | 26 ++++++++++----- .../test/jsx-gate-manifest-notice.e2e.test.ts | 14 +++++--- ...hook-rules-reach-handler-hooks.e2e.test.ts | 32 +++++++++++++------ .../test/serve-app-runtime-hooks.e2e.test.ts | 8 +++-- ...idate-json-failure-conversions.e2e.test.ts | 12 +++++-- ...validate-json-failure-warnings.e2e.test.ts | 12 +++++-- .../validate-json-warning-parity.e2e.test.ts | 21 ++++++++---- .../validate-top-level-strict.e2e.test.ts | 15 ++++++--- 17 files changed, 215 insertions(+), 85 deletions(-) diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index f2136e95327..f97f4180147 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -108,6 +108,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { linkSpec } from '../../test/helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../../bin/run-dev.js'); @@ -121,15 +122,19 @@ const TSX = resolve(HERE, '../../../../node_modules/.bin/tsx'); * before any advisory is computed. */ const WARNS_SOURCE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ objects: [], apps: [], -}; +}, { strict: false }); `; /** The zero-warning control — pins the other end of the matrix. */ const CLEAN_SOURCE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' }, objects: [{ name: 'strictexit_ticket', @@ -138,7 +143,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }], apps: [{ name: 'strictexit_app', label: 'Strict Exit App' }], -}; +}, { strict: false }); `; /** @@ -151,7 +156,9 @@ export default { * assumed to be. */ const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.strictexit', name: 'strictexit', version: '1.0.0', type: 'app', namespace: 'strictexit' }, objects: [{ name: 'strictexit_ticket', @@ -167,7 +174,7 @@ export default { { type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } }, ] }], }], -}; +}, { strict: false }); `; interface Run { @@ -201,12 +208,16 @@ let conversionsCanonDir: string; beforeAll(() => { warnsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-warns-')); writeFileSync(join(warnsDir, 'objectstack.config.ts'), WARNS_SOURCE); + linkSpec(warnsDir); cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-clean-')); writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE); + linkSpec(cleanDir); conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-')); writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description')); + linkSpec(conversionsDir); conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-')); writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle')); + linkSpec(conversionsCanonDir); }); afterAll(() => { diff --git a/packages/cli/test/build-docs-step-count.e2e.test.ts b/packages/cli/test/build-docs-step-count.e2e.test.ts index 0dd731fb27f..20e076a9f8c 100644 --- a/packages/cli/test/build-docs-step-count.e2e.test.ts +++ b/packages/cli/test/build-docs-step-count.e2e.test.ts @@ -58,6 +58,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -112,7 +113,9 @@ function artifactDocCount(dir: string): number { * aborted run. */ const config = (ns: string) => ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [], objects: [ @@ -123,7 +126,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }, ], -}; +}, { strict: false }); `; const doc = (title: string) => `--- @@ -145,14 +148,17 @@ beforeAll(() => { dirs.absent = join(root, 'absent'); mkdirSync(join(dirs.absent, 'src'), { recursive: true }); writeFileSync(join(dirs.absent, 'objectstack.config.ts'), config('dsabsent')); + linkSpec(dirs.absent); dirs.empty = join(root, 'empty'); mkdirSync(join(dirs.empty, 'src', 'docs'), { recursive: true }); writeFileSync(join(dirs.empty, 'objectstack.config.ts'), config('dsempty')); + linkSpec(dirs.empty); dirs.two = join(root, 'two'); mkdirSync(join(dirs.two, 'src', 'docs'), { recursive: true }); writeFileSync(join(dirs.two, 'objectstack.config.ts'), config('dstwo')); + linkSpec(dirs.two); writeFileSync(join(dirs.two, 'src', 'docs', 'dstwo_intro.md'), doc('Intro')); writeFileSync(join(dirs.two, 'src', 'docs', 'dstwo_guide.md'), doc('Guide')); }); diff --git a/packages/cli/test/build-json-advisory-parity.e2e.test.ts b/packages/cli/test/build-json-advisory-parity.e2e.test.ts index b2ac90cbeef..8d44e2db726 100644 --- a/packages/cli/test/build-json-advisory-parity.e2e.test.ts +++ b/packages/cli/test/build-json-advisory-parity.e2e.test.ts @@ -78,6 +78,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -130,7 +131,9 @@ const PLANTED_DOC = 'advparity_guide.md'; * goes red here instead of passing quietly. */ const CONFIG_PLANTED = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.advparity', name: 'advparity', version: '1.0.0', type: 'app', namespace: 'advparity' }, requires: ['${PLANTED_TOKEN}'], objects: [ @@ -142,7 +145,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }, ], -}; +}, { strict: false }); `; const DOC_PLANTED = `--- @@ -159,7 +162,9 @@ Body text. * pass against a build that emitted one unconditionally. */ const CONFIG_CLEAN = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.advclean', name: 'advclean', version: '1.0.0', type: 'app', namespace: 'advclean' }, requires: [], objects: [ @@ -171,7 +176,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }, ], -}; +}, { strict: false }); `; const DOC_CLEAN = `--- @@ -213,6 +218,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(join(dir, 'src', 'docs'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), f.config); + linkSpec(dir); writeFileSync(join(dir, 'src', 'docs', f.docName), f.doc); dirs[name] = dir; } diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index a4d69ad0b99..96c2a780882 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -66,6 +66,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -116,7 +117,9 @@ function payloadOf(run: Run, label: string): Record { function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string; extraTop?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '', extraTop = '' } = opts; return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], @@ -130,7 +133,7 @@ export default { }, }, ],${extraTop} -}; +}, { strict: false }); `; } @@ -188,6 +191,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(join(dir, 'src', 'docs'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); + linkSpec(dir); for (const [file, body] of docs) writeFileSync(join(dir, 'src', 'docs', file), body); dirs[name] = dir; return dir; @@ -240,7 +244,9 @@ beforeAll(() => { // catch-all, AT LOAD — the config throws on import, ABOVE step 2. make('earlythrow', ` throw new Error('zzz_config_module_threw'); -export default {}; +import { defineStack } from '@objectstack/spec'; + +export default defineStack({}, { strict: false }); `); // The control — the same shape, reaching SUCCESS. diff --git a/packages/cli/test/build-json-failure-warnings.e2e.test.ts b/packages/cli/test/build-json-failure-warnings.e2e.test.ts index adab89a8e9a..d37d58eb60d 100644 --- a/packages/cli/test/build-json-failure-warnings.e2e.test.ts +++ b/packages/cli/test/build-json-failure-warnings.e2e.test.ts @@ -89,6 +89,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -151,7 +152,9 @@ const PLANTED_KEY = 'zzzUndeclaredProbeKey'; */ function stack(ns: string, requires: string[], extra = ''): string { return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], objects: [ @@ -169,7 +172,7 @@ export default { }, }, ],${extra} -}; +}, { strict: false }); `; } @@ -253,6 +256,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(join(dir, 'src', 'docs'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); + linkSpec(dir); for (const [file, body] of docs) writeFileSync(join(dir, 'src', 'docs', file), body); dirs[name] = dir; return dir; @@ -260,37 +264,43 @@ beforeAll(() => { // 2b — `--strict-body`, before any advisory has been computed. make('strictbody', ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.sbody', name: 'sbody', version: '1.0.0', type: 'app', namespace: 'sbody' }, requires: ['${PLANTED_TOKEN}'], objects: [{ name: 'sb_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], hooks: [{ name: 'sb_hook', object: 'sb_ticket', events: ['beforeInsert'], handler: async (ctx: any) => { const os = require('node:os'); return os.platform(); } }], -}; +}, { strict: false }); `); // 3 — the protocol parse itself fails, likewise before any advisory. make('zodfail', ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.zfail', name: 'zfail', version: '1.0.0', type: 'app', namespace: 'zfail' }, requires: ['${PLANTED_TOKEN}'], objects: [{ name: 'zf_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'this_is_not_a_field_type', label: 'Title' } } }], -}; +}, { strict: false }); `); // 3b — an author-time rule ERROR (a `record.` that does not resolve), // raised alongside the bare-`unique` advisory. make('rulefail', ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.rfail', name: 'rfail', version: '1.0.0', type: 'app', namespace: 'rfail' }, requires: ['${PLANTED_TOKEN}'], objects: [{ name: 'rf_ticket', label: 'Ticket', sharingModel: 'private', indexes: [{ name: 'rf_title_idx', fields: ['title'], unique: true }], fields: { title: { type: 'text', label: 'Title', visibleWhen: { dialect: 'cel', source: 'record.zzz_no_such_field' } } } }], -}; +}, { strict: false }); `); // 3c — one FATAL capability token beside the advisory one. diff --git a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts index 4403919b475..3aebed86347 100644 --- a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts +++ b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts @@ -71,6 +71,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -120,7 +121,9 @@ const PLANTED_KEY = 'zzzUndeclaredProbeKey'; * finding (a STRING), the subject of this file. */ const CONFIG_WITH_UNDECLARED_KEY = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.ukparity', name: 'ukparity', version: '1.0.0', type: 'app', namespace: 'ukparity' }, objects: [ { @@ -137,7 +140,7 @@ export default { }, }, ], -}; +}, { strict: false }); `; /** @@ -146,7 +149,9 @@ export default { * build that emitted one unconditionally. */ const CONFIG_CLEAN = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.ukclean', name: 'ukclean', version: '1.0.0', type: 'app', namespace: 'ukclean' }, objects: [ { @@ -163,7 +168,7 @@ export default { }, }, ], -}; +}, { strict: false }); `; /** Every undeclared-key line in a `warnings` list, whatever else rides beside it. */ @@ -183,6 +188,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); dirs[name] = dir; } }); diff --git a/packages/cli/test/build-multi-package-artifact.e2e.test.ts b/packages/cli/test/build-multi-package-artifact.e2e.test.ts index adb27031131..b3ea43f7118 100644 --- a/packages/cli/test/build-multi-package-artifact.e2e.test.ts +++ b/packages/cli/test/build-multi-package-artifact.e2e.test.ts @@ -44,6 +44,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -96,7 +97,9 @@ const ordersManifest = { dependencies: { 'com.example.mp.core': '^1.0.0' }, }; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: coreManifest, objects: [account, order], hooks: [orderHook], @@ -104,24 +107,28 @@ export default { { manifest: { ...ordersManifest, objects: [order], hooks: [orderHook] } }, { manifest: { ...coreManifest, objects: [account] } }, ], -}; +}, { strict: false }); `; /** The same project with ONE package and no `packages` key — the D7 branch. */ const CONFIG_SINGLE = ` const stampOrder = async () => { return { ok: true }; }; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.mp.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'mp' }, objects: [ { name: 'mp_order', label: 'Order', sharingModel: 'private', fields: { name: { type: 'text', label: 'Number' } } }, ], hooks: [{ name: 'mp_order_before_insert', object: 'mp_order', events: ['beforeInsert'], handler: stampOrder }], -}; +}, { strict: false }); `; /** A package body still carrying the AUTHORING manifest's glob patterns. */ const CONFIG_GLOBS = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.mp.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'mp' }, objects: [ { name: 'mp_account', label: 'Account', sharingModel: 'private', fields: { name: { type: 'text', label: 'Name' } } }, @@ -129,7 +136,7 @@ export default { packages: [ { manifest: { id: 'com.example.mp.core', name: 'core', version: '1.0.0', type: 'app', namespace: 'mp', objects: ['./src/objects/*.object.ts'] } }, ], -}; +}, { strict: false }); `; /** @@ -214,7 +221,9 @@ const probeOrder = { ], }; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: coreManifest, objects: [probeAccount], packages: [ @@ -227,7 +236,7 @@ export default { }, }, ], -}; +}, { strict: false }); `; interface Artifact { @@ -249,6 +258,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); + linkSpec(dir); dirs[name] = dir; } }); diff --git a/packages/cli/test/build-package-docs-attachment.e2e.test.ts b/packages/cli/test/build-package-docs-attachment.e2e.test.ts index f2f9f27bca2..503fb652134 100644 --- a/packages/cli/test/build-package-docs-attachment.e2e.test.ts +++ b/packages/cli/test/build-package-docs-attachment.e2e.test.ts @@ -34,6 +34,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -83,24 +84,28 @@ const ordersManifest = { dependencies: { 'com.example.pkgdocs.core': '^1.0.0' }, }; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: coreManifest, objects: [account, order], packages: [ { manifest: { ...coreManifest, objects: [account] } }, { manifest: { ...ordersManifest, objects: [order] } }, ], -}; +}, { strict: false }); `; /** A single-package project with a flat `src/docs/` — the shape that must not move. */ const CONFIG_FLAT = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' }, objects: [ { name: 'flat_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }, ], -}; +}, { strict: false }); `; const MARKER_PKG = 'MARKER-package-doc-18431'; @@ -124,6 +129,7 @@ beforeAll(() => { mkdirSync(join(dirs.multi, 'src', 'orders', 'docs'), { recursive: true }); mkdirSync(join(dirs.multi, 'src', 'docs'), { recursive: true }); writeFileSync(join(dirs.multi, 'objectstack.config.ts'), CONFIG_MULTI); + linkSpec(dirs.multi); // Owned by `com.example.pkgdocs.orders` — directory name === the last // dot-separated segment of that `id`, and its own namespace `ord` is what // the doc name must be prefixed with. @@ -137,6 +143,7 @@ beforeAll(() => { dirs.flat = join(root, 'flat'); mkdirSync(join(dirs.flat, 'src', 'docs'), { recursive: true }); writeFileSync(join(dirs.flat, 'objectstack.config.ts'), CONFIG_FLAT); + linkSpec(dirs.flat); writeFileSync(join(dirs.flat, 'src', 'docs', 'flat_index.md'), `# Flat Index\n\n${MARKER_FLAT}\n`); }); diff --git a/packages/cli/test/compile-artifact-packages.e2e.test.ts b/packages/cli/test/compile-artifact-packages.e2e.test.ts index 2fc70e806de..ffd37bf54e6 100644 --- a/packages/cli/test/compile-artifact-packages.e2e.test.ts +++ b/packages/cli/test/compile-artifact-packages.e2e.test.ts @@ -34,6 +34,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -72,7 +73,9 @@ function payloadOf(run: Run, label: string): Record { /** Today's shape: one package, declared through the singular `manifest`. */ const CONFIG_SINGLE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' }, objects: [ { @@ -82,12 +85,14 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }, ], -}; +}, { strict: false }); `; /** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */ const CONFIG_MULTI = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' }, packages: [ { manifest: { id: 'com.example.crm', name: 'crm', version: '1.0.0', type: 'app', namespace: 'crm' } }, @@ -101,7 +106,7 @@ export default { fields: { name: { type: 'text', label: 'Name' } }, }, ], -}; +}, { strict: false }); `; /** @@ -109,12 +114,14 @@ export default { * element. Must be refused at the compile door, not written to an artifact. */ const CONFIG_FLATTENED = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ packages: [ { id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' }, ], objects: [], -}; +}, { strict: false }); `; const dirs: Record = {}; @@ -130,6 +137,7 @@ beforeAll(() => { const dir = join(root, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); dirs[name] = dir; } }); diff --git a/packages/cli/test/hook-body-build-reach.e2e.test.ts b/packages/cli/test/hook-body-build-reach.e2e.test.ts index a5b1b75eb09..7458c427cd5 100644 --- a/packages/cli/test/hook-body-build-reach.e2e.test.ts +++ b/packages/cli/test/hook-body-build-reach.e2e.test.ts @@ -52,6 +52,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -105,7 +106,9 @@ const OBJECT = `{ * only place that is checked. */ const CONFIG_CAPABILITIES_DIRECTIVE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.hbcaps', name: 'hbcaps', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -118,7 +121,7 @@ export default { return rows; }, }], -}; +}, { strict: false }); `; /** @@ -129,7 +132,9 @@ export default { * and this is the surface that serves it. */ const CONFIG_EXPLICIT_BODY = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.hbbody', name: 'hbbody', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -138,12 +143,14 @@ export default { events: ['beforeInsert'], body: { language: 'js', source: 'return ctx;', capabilities: ['api.write', 'log'] }, }], -}; +}, { strict: false }); `; /** DEFECT 2 fixture: a CommonJS `require()` esbuild rewrites to `__require`. */ const CONFIG_REQUIRE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.hbreq', name: 'hbreq', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -155,12 +162,14 @@ export default { return os.platform(); }, }], -}; +}, { strict: false }); `; /** DEFECT 3 fixture: a forbidden pattern on the DEFAULT (warn-and-bundle) path. */ const CONFIG_FORBIDDEN = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.hbforbid', name: 'hbforbid', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -172,7 +181,7 @@ export default { return ctx; }, }], -}; +}, { strict: false }); `; const dirs: Record = {}; @@ -180,6 +189,7 @@ const dirs: Record = {}; function project(key: string, source: string): string { const dir = mkdtempSync(join(tmpdir(), `os-hookbody-${key}-`)); writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); dirs[key] = dir; return dir; } diff --git a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts index bfdf5595a42..712a4720463 100644 --- a/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts +++ b/packages/cli/test/jsx-gate-manifest-notice.e2e.test.ts @@ -47,6 +47,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -114,14 +115,16 @@ function stack(body: string | null): string { ? `{ id: 'nav_ticket', type: 'object', label: 'Tickets', objectName: 'jxg_ticket' }` : `{ id: 'nav_landing', type: 'page', label: 'Landing', pageName: 'jxg_landing' }`; return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^17' } }, ${pages} apps: [{ name: 'jxg_app', label: 'JXG', navigation: [${nav}] }], objects: [ { name: 'jxg_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }, ], -}; +}, { strict: false }); `; } @@ -132,7 +135,9 @@ export default { */ function packageCarried(top: string): string { return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.jxg', name: 'jxg', version: '1.0.0', type: 'app', namespace: 'jxg', engines: { protocol: '^17' } }, pages: ${top}, packages: [ @@ -147,7 +152,7 @@ export default { objects: [ { name: 'jxg_ticket', label: 'Ticket', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }, ], -}; +}, { strict: false }); `; } const TOP_EMPTY = '[]'; @@ -235,6 +240,7 @@ beforeAll(async () => { const dir = join(root, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), f.config); + linkSpec(dir); if (f.manifest !== undefined) writeFileSync(join(dir, 'sdui.manifest.json'), f.manifest); dirs[name] = dir; } diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index 7d80107d465..1394fd7bf3e 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -43,6 +43,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -85,7 +86,9 @@ const OBJECT = `{ /** INTAKE: the reference app's shape — an inline handler, no `body`. */ const CONFIG_HANDLER = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.reach-handler', name: 'reach_handler', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -96,12 +99,14 @@ export default { await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true }); }, }], -}; +}, { strict: false }); `; /** CONTROL: the identical statement authored as an explicit `body`. */ const CONFIG_BODY = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.reach-body', name: 'reach_body', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -113,7 +118,7 @@ export default { source: "await ctx.api.object('crm_case').update({ id: ctx.input.id, is_escalated: true });", }, }], -}; +}, { strict: false }); `; /** @@ -123,7 +128,9 @@ export default { * refused: a stack that validated green before #16544 validates green after. */ const CONFIG_HANDLER_OK = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.reach-handler-ok', name: 'reach_handler_ok', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], hooks: [{ @@ -134,7 +141,7 @@ export default { await ctx.api.object('crm_case').update({ id: ctx.input.id, title: 'seen' }); }, }], -}; +}, { strict: false }); `; /** @@ -150,7 +157,9 @@ export default { * `lowerActionCallable` handles (`actions[*]`, `objects[*].actions[*]`). */ const CONFIG_ACTION_TARGET = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.reach-action-target', name: 'reach_action_target', version: '1.0.0', type: 'app' }, objects: [{ name: 'crm_case', @@ -174,7 +183,7 @@ export default { return { ok: true, id: ctx.input.id }; }, }], -}; +}, { strict: false }); `; /** @@ -188,7 +197,9 @@ export default { * function un-lowered (not a limb). */ const CONFIG_FUNCTIONS_NAMELESS = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.reach-functions-nameless', name: 'reach_functions_nameless', version: '1.0.0', type: 'app' }, objects: [${OBJECT}], functions: [{ @@ -196,7 +207,7 @@ export default { return { ok: true, id: ctx.input.id }; }, }], -}; +}, { strict: false }); `; const dirs: Record = {}; @@ -204,6 +215,7 @@ const dirs: Record = {}; function project(key: string, source: string): string { const dir = mkdtempSync(join(tmpdir(), `os-reach-${key}-`)); writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); dirs[key] = dir; return dir; } diff --git a/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts b/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts index 0b51830bb47..d95f14a21ab 100644 --- a/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts +++ b/packages/cli/test/serve-app-runtime-hooks.e2e.test.ts @@ -28,6 +28,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { childEnv, runServe, randomPort, CLI, TSX } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const execFileP = promisify(execFile); @@ -42,7 +43,9 @@ export const onEnable = async (ctx) => { ctx.ql.registerAction('hookfix_task', 'doThing', () => ({ ok: true })); }; -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.hookfix', namespace: 'hookfix', @@ -64,7 +67,7 @@ export default { target: 'doThing', }], }], -}; +}, { strict: false }); `; let dir: string; @@ -72,6 +75,7 @@ let dir: string; beforeAll(async () => { dir = mkdtempSync(join(tmpdir(), 'os-runtime-hooks-e2e-')); writeFileSync(join(dir, 'objectstack.config.ts'), CONFIG, 'utf8'); + linkSpec(dir); // The artifact is REQUIRED by this test, and for a positive reason rather than // as a workaround: it is what makes `createStandaloneStack()` contribute an // artifact-derived `AppPlugin`, which is the bundle whose missing code this diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 3336f8580c1..6b79c53ee53 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -94,6 +94,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -145,7 +146,9 @@ function payloadOf(run: Run, label: string): Record { function stack(ns: string, opts: { pageKind?: string; requires?: string[]; extraFields?: string } = {}): string { const { pageKind = 'jsx', requires = [], extraFields = '' } = opts; return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], pages: [{ name: 'landing', label: 'Landing', kind: '${pageKind}', source: '
hi
' }], @@ -159,7 +162,7 @@ export default { }, }, ], -}; +}, { strict: false }); `; } @@ -208,6 +211,7 @@ beforeAll(() => { mkdirSync(dir, { recursive: true }); if (docs.length > 0) mkdirSync(join(dir, 'src', 'docs'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); + linkSpec(dir); for (const [file, body] of docs) writeFileSync(join(dir, 'src', 'docs', file), body); dirs[name] = dir; return dir; @@ -242,7 +246,9 @@ beforeAll(() => { // catch-all, AT LOAD — the config throws on import, ABOVE step 2. make('earlythrow', ` throw new Error('zzz_config_module_threw'); -export default {}; +import { defineStack } from '@objectstack/spec'; + +export default defineStack({}, { strict: false }); `); // The control — the same shape, reaching SUCCESS. diff --git a/packages/cli/test/validate-json-failure-warnings.e2e.test.ts b/packages/cli/test/validate-json-failure-warnings.e2e.test.ts index c59f6e4fee9..6b06604113e 100644 --- a/packages/cli/test/validate-json-failure-warnings.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-warnings.e2e.test.ts @@ -115,6 +115,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { maskComments } from '../../../scripts/js-comment-mask.mjs'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -178,7 +179,9 @@ function payloadOf(run: Run, label: string): Record { */ function stack(ns: string, requires: string[], extraFields = '', extraTop = ''): string { return ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }, requires: [${requires.map((r) => `'${r}'`).join(', ')}], objects: [ @@ -196,7 +199,7 @@ export default { }, }, ],${extraTop} -}; +}, { strict: false }); `; } @@ -278,6 +281,7 @@ beforeAll(() => { mkdirSync(dir, { recursive: true }); if (docs.length > 0) mkdirSync(join(dir, 'src', 'docs'), { recursive: true }); writeFileSync(join(dir, 'objectstack.config.ts'), config); + linkSpec(dir); for (const [file, body] of docs) writeFileSync(join(dir, 'src', 'docs', file), body); dirs[name] = dir; return dir; @@ -314,7 +318,9 @@ beforeAll(() => { // computed. The shape-constancy half: `warnings` is present and empty. make('earlythrow', ` throw new Error('zzz_config_module_threw'); -export default {}; +import { defineStack } from '@objectstack/spec'; + +export default defineStack({}, { strict: false }); `); // The structural control — the same shape, reaching SUCCESS. diff --git a/packages/cli/test/validate-json-warning-parity.e2e.test.ts b/packages/cli/test/validate-json-warning-parity.e2e.test.ts index 6f19fab9ef4..ebcf144f54b 100644 --- a/packages/cli/test/validate-json-warning-parity.e2e.test.ts +++ b/packages/cli/test/validate-json-warning-parity.e2e.test.ts @@ -63,6 +63,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -89,10 +90,12 @@ const FIXTURES: readonly Fixture[] = [ // `manifest` is present, so a config that merely omits the id fails the // parse and exits long before any warning is computed. source: ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ objects: [], apps: [], -}; +}, { strict: false }); `, floor: 4, }, @@ -106,7 +109,9 @@ export default { // string ones. `externalSharingModel` is ledger-marked `authorWarn`, which // is what raises the registry-side advisory. source: ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.parity', name: 'parity', version: '1.0.0', type: 'app', namespace: 'parity' }, objects: [{ name: 'parity_ticket', @@ -115,7 +120,7 @@ export default { externalSharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } }, }], -}; +}, { strict: false }); `, floor: 2, }, @@ -123,7 +128,9 @@ export default { /** The zero-warning control — see the test that uses it. */ const CLEAN_SOURCE = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.clean', name: 'clean', version: '1.0.0', type: 'app', namespace: 'clean' }, objects: [{ name: 'clean_ticket', @@ -132,7 +139,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }], apps: [{ name: 'clean_app', label: 'Clean App' }], -}; +}, { strict: false }); `; interface Run { @@ -217,10 +224,12 @@ beforeAll(() => { for (const f of FIXTURES) { const dir = mkdtempSync(join(tmpdir(), `os-validate-parity-${f.name}-`)); writeFileSync(join(dir, 'objectstack.config.ts'), f.source); + linkSpec(dir); dirs.set(f.name, dir); } const cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-parity-clean-')); writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE); + linkSpec(cleanDir); dirs.set('clean', cleanDir); }); diff --git a/packages/cli/test/validate-top-level-strict.e2e.test.ts b/packages/cli/test/validate-top-level-strict.e2e.test.ts index e1d6cdffc06..3f847d8b1e9 100644 --- a/packages/cli/test/validate-top-level-strict.e2e.test.ts +++ b/packages/cli/test/validate-top-level-strict.e2e.test.ts @@ -31,6 +31,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -38,7 +39,9 @@ const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); /** The card's failure shape: a valid stack plus ONE stray top-level key. */ const CONFIG_WITH_STRAY_KEY = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.straykey', name: 'straykey', version: '1.0.0', type: 'app' }, objects: [{ name: 'sk_ticket', @@ -48,11 +51,13 @@ export default { }], // One character off 'flows' — the family-dropping typo the card measured. flow: [], -}; +}, { strict: false }); `; const CONFIG_CLEAN = ` -export default { +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ manifest: { id: 'com.example.straykey', name: 'straykey', version: '1.0.0', type: 'app' }, objects: [{ name: 'sk_ticket', @@ -61,7 +66,7 @@ export default { fields: { title: { type: 'text', label: 'Title' } }, }], flows: [], -}; +}, { strict: false }); `; interface Run { @@ -93,8 +98,10 @@ let cleanDir: string; beforeAll(() => { strayDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-e2e-stray-')); writeFileSync(join(strayDir, 'objectstack.config.ts'), CONFIG_WITH_STRAY_KEY); + linkSpec(strayDir); cleanDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-e2e-clean-')); writeFileSync(join(cleanDir, 'objectstack.config.ts'), CONFIG_CLEAN); + linkSpec(cleanDir); }); afterAll(() => { From 771988fc2916163d721f098d7c1693fa0f033b19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:30:12 +0000 Subject: [PATCH 07/13] =?UTF-8?q?wip(cli):=20re-judge=20the=20conversions?= =?UTF-8?q?=20pins=20=E2=80=94=20the=20producer=20applies=20the=20conversi?= =?UTF-8?q?on=20at=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .../validate-json-strict-exit.e2e.test.ts | 63 ++++++++++--------- ...build-json-failure-conversions.e2e.test.ts | 49 ++++++++++----- ...idate-json-failure-conversions.e2e.test.ts | 43 +++++++++---- 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index f97f4180147..eb32a9c75d1 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -64,7 +64,14 @@ * was written: `description` → text `--strict` 1, `--json --strict` 1, `--json` * 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices. * - * The non-empty `conversions` assertion is the anti-vacuity guard, and it is + * ⚠️ Re-judged under the one-authoring-shape ruling (#20367): `os validate` + * refuses a default export `defineStack` did not build, and `defineStack` + * applies the conversion itself at load (stderr notice), so the door's + * `conversions` is empty and the cell below is unreachable by an accepted + * config. The pin now holds what IS true — both faces agree at exit 0, the + * notice fires in the producer — and the anti-vacuity guard reads stderr. + * + * The live-notice assertion (on stderr since the re-judgement) is the anti-vacuity guard, and it is * load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE * window that retires from the load path at protocol 18; the day it retires, * this fixture raises nothing and, without that assertion, the file would keep @@ -272,46 +279,40 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0); }, 120_000); - it('conversions-only: the cell where --strict is decided by a collection the payload keeps OUT of `warnings`', async () => { + it('conversions-only (ruling B): the PRODUCER consumes the conversion at load — both faces agree at exit 0', async () => { + // Re-judged under the one-authoring-shape ruling (#20367). A config is now + // always `defineStack(…)` output, and `defineStack` runs the D2 conversion + // itself (either mode) and reports it on stderr, so the door's own + // `normalizeStackInput` has nothing left to convert: the #11301 cell — + // `{ valid: true, warnings: [], conversions: [...] }` at exit 1 — is no + // longer reachable by a config the door accepts. ⚠️ Recorded, not endorsed: + // `--strict` therefore does not gate on a retiring conversion for ANY + // accepted config (it never did for a `defineStack` one); the PR reports + // that as an open finding rather than widening this change to fix it. const text = await runCli(['validate', '--strict'], conversionsDir); const json = await runCli(['validate', '--json', '--strict'], conversionsDir); - // Same floor as the first case: equality is only worth asserting over a run - // that genuinely had something to fail on. - expect( - text.code, - `text --strict must fail on the conversions-only config:\n${text.stdout}\n${text.stderr}`, - ).not.toBe(0); - - expect( - json.code, - `--json --strict exited ${json.code} where --strict exited ${text.code}, same config.\n` + - `json stdout:\n${json.stdout}\njson stderr:\n${json.stderr}`, - ).toBe(text.code); + // Parity, the #11174 contract this file exists for, still holds. + expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0); + expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(text.code); const payload = JSON.parse(json.stdout) as { valid?: unknown; warnings?: unknown; conversions?: unknown; }; - - // The cell spelled out. `warnings: []` is asserted, not tolerated: it is the - // whole point — narrow the gate to this field and the run above drops to 0 - // while the text face stays at 1. expect(payload.valid).toBe(true); expect(payload.warnings).toEqual([]); - expect( - Array.isArray(payload.conversions) && (payload.conversions as unknown[]).length, - 'the fixture raised NO conversion — the alias has most likely retired from ' + - 'the load path; re-point `headerPageSource` at a live entry in ' + - '`packages/spec/src/conversions/registry.ts` rather than deleting this line', - ).toBeGreaterThan(0); - - // Separates "gates on --strict" from "fails whenever a conversion is seen". - // The warnings fixture's own without-strict control cannot cover this: it - // raises no conversions, so it passes under either behaviour. - const loose = await runCli(['validate', '--json'], conversionsDir); - expect(loose.code, `--json without --strict must stay 0:\n${loose.stdout}\n${loose.stderr}`).toBe(0); + expect(payload.conversions, 'the door computed no conversion: the producer already applied it').toEqual([]); + + // Anti-vacuity: the conversion is LIVE — it fired, in the producer, on both + // faces. The day `page-header-subtitle-alias` retires this goes red; re-point + // `headerPageSource` at a live entry in `packages/spec/src/conversions/registry.ts`. + for (const run of [text, json]) { + expect(run.stderr, 'defineStack reported the conversion at load').toContain( + "conversion 'page-header-subtitle-alias'", + ); + } }, 120_000); it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => { @@ -326,6 +327,8 @@ describe('#11174 — --strict reaches the same exit status on both faces', () => const payload = JSON.parse(json.stdout) as { warnings?: unknown; conversions?: unknown }; expect(payload.warnings).toEqual([]); expect(payload.conversions).toEqual([]); + // The discriminator's other half: the canonical key raises no notice anywhere. + expect(json.stderr).not.toContain("conversion 'page-header-subtitle-alias'"); }, 120_000); it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => { diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index 96c2a780882..ac7dd2eae6a 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -1,6 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** + * ⚠️ RE-JUDGED under the one-authoring-shape ruling (#20367). The command now + * refuses a default export `defineStack` did not build, and `defineStack` + * applies every ADR-0087 D2 conversion itself at load (either mode), reporting + * it on stderr. So the door's step-2 sink converts nothing for any accepted + * config and every exit below carries `conversions: []` — which is still + * exactly "what the run computed". The pins now assert that, plus the live + * notice on the producer's stderr line (`expectTheOneNotice`). Whether the + * producer's notices should reach this envelope is an open question the PR + * reports; it was not this change's to decide. + * * #12125 — `os build --json`'s FAILURE payloads dropped the `conversions` field * the run had ALREADY COMPUTED, on all nine of its failure exits. * @@ -176,10 +186,18 @@ const THE_NOTICE = { }; /** Asserts EXACTLY the one computed notice. `toEqual` is the "and NO MORE" half. */ -function expectTheOneNotice(payload: Record, label: string): void { - expect(conversionsOf(payload), `${label}: expected exactly the one computed conversion notice`).toEqual([ - expect.objectContaining(THE_NOTICE), - ]); +function expectTheOneNotice(payload: Record, label: string, run: Run): void { + // [#20367 ruling B] Re-judged. The door accepts only `defineStack` output, + // and `defineStack` applies the D2 conversion at load (either mode) and + // reports it on stderr — so the notice is computed by the PRODUCER, and the + // door's own step-2 sink has nothing left to convert. What this exit carries + // is therefore exactly what the door computed: `[]`, asserted whole. The + // notice itself is asserted where it now lives — the producer's stderr line, + // by conversion id AND path, so a different conversion cannot satisfy it. + expect(conversionsOf(payload), `${label}: the door computes no conversion for a defineStack export`).toEqual([]); + expect(run.stderr, `${label}: the producer reported the conversion at load`).toContain( + `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`, + ); } const dirs: Record = {}; @@ -264,8 +282,9 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the control to build:\n${run.stdout}${run.stderr}`).toBe(0); const payload = payloadOf(run, 'control'); expect(payload.success).toBe(true); - expectTheOneNotice(payload, 'control'); - expect(typeof (conversionsOf(payload)[0] as { retiresIn?: unknown }).retiresIn).toBe('number'); + expectTheOneNotice(payload, 'control', run); + // The expiry still reaches the author — on the producer's line. + expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); }, 180_000); it('2b (--strict-body) — ⭐ carries the notice, where `warnings` is empty by construction', async () => { @@ -278,7 +297,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(payload.success).toBe(false); expect(String(payload.error)).toContain('strict-body'); expect(payload.warnings, 'no advisory is computed this early — the sibling pins this too').toEqual([]); - expectTheOneNotice(payload, 'strictbody'); + expectTheOneNotice(payload, 'strictbody', run); }, 180_000); it('3 (protocol parse) — ⭐ carries the notice, where `warnings` is empty by construction', async () => { @@ -288,7 +307,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(payload.success).toBe(false); expect(Array.isArray(payload.errors)).toBe(true); expect(payload.warnings).toEqual([]); - expectTheOneNotice(payload, 'zodfail'); + expectTheOneNotice(payload, 'zodfail', run); }, 180_000); it('⭐ converts nothing — the SAME exit reports `[]`, so the field tracks the run', async () => { @@ -298,6 +317,8 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(payload.success).toBe(false); expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); expect(payload.conversions, 'a canonical page kind converts nothing').toEqual([]); + // …and, since the producer is what converts now, raised no notice at load either. + expect(run.stderr).not.toContain("conversion 'page-kind-jsx-to-html'"); }, 180_000); it('3b (author-time rules) — the notice rides the rule gate', async () => { @@ -305,7 +326,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the rule gate to fail:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'rulefail'); expect(String(payload.error)).toContain('author-time rules failed'); - expectTheOneNotice(payload, 'rulefail'); + expectTheOneNotice(payload, 'rulefail', run); }, 180_000); it('3c (capability preflight) — the notice rides the preflight gate', async () => { @@ -313,7 +334,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the capability gate to fail:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'capfail'); expect(String(payload.error)).toContain('capability provider preflight failed'); - expectTheOneNotice(payload, 'capfail'); + expectTheOneNotice(payload, 'capfail', run); }, 180_000); it('3e (access-matrix drift) — the notice rides the drift gate', async () => { @@ -321,7 +342,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the drift gate to fail:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'amx'); expect(String(payload.error)).toContain('access matrix drift'); - expectTheOneNotice(payload, 'amx'); + expectTheOneNotice(payload, 'amx', run); }, 180_000); it('3f (package docs) — the notice rides the docs gate', async () => { @@ -329,7 +350,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the docs gate to fail:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'docsfail'); expect(String(payload.error)).toContain('docs validation failed'); - expectTheOneNotice(payload, 'docsfail'); + expectTheOneNotice(payload, 'docsfail', run); }, 180_000); it('4b (--no-runtime-bundle) — a late exit past every step carries the notice', async () => { @@ -337,7 +358,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected --no-runtime-bundle to fail:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'latefail'); expect(String(payload.error)).toContain('--no-runtime-bundle'); - expectTheOneNotice(payload, 'latefail'); + expectTheOneNotice(payload, 'latefail', run); }, 180_000); it('the catch-all — a THROWN failure still reports the computed notice', async () => { @@ -348,7 +369,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion expect(run.code, `expected the artifact write to throw:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'thrown'); expect(payload.success).toBe(false); - expectTheOneNotice(payload, 'thrown'); + expectTheOneNotice(payload, 'thrown', run); }, 180_000); it('catch-all (throw at load) — `conversions` is PRESENT and empty, because step 2 never ran', async () => { diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 6b79c53ee53..524e3f1ffa4 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -1,6 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** + * ⚠️ RE-JUDGED under the one-authoring-shape ruling (#20367). The command now + * refuses a default export `defineStack` did not build, and `defineStack` + * applies every ADR-0087 D2 conversion itself at load (either mode), reporting + * it on stderr. So the door's step-2 sink converts nothing for any accepted + * config and every exit below carries `conversions: []` — which is still + * exactly "what the run computed". The pins now assert that, plus the live + * notice on the producer's stderr line (`expectTheOneNotice`). Whether the + * producer's notices should reach this envelope is an open question the PR + * reports; it was not this change's to decide. + * * #12125 — `os validate --json`'s FAILURE payloads dropped the `conversions` * field the run had ALREADY COMPUTED, on all five of its failure exits. * @@ -195,10 +205,18 @@ const THE_NOTICE = { * Asserts the payload carries EXACTLY the one computed notice. `toEqual` over * the whole array is the "and NO MORE" half. */ -function expectTheOneNotice(payload: Record, label: string): void { - expect(conversionsOf(payload), `${label}: expected exactly the one computed conversion notice`).toEqual([ - expect.objectContaining(THE_NOTICE), - ]); +function expectTheOneNotice(payload: Record, label: string, run: Run): void { + // [#20367 ruling B] Re-judged. The door accepts only `defineStack` output, + // and `defineStack` applies the D2 conversion at load (either mode) and + // reports it on stderr — so the notice is computed by the PRODUCER, and the + // door's own step-2 sink has nothing left to convert. What this exit carries + // is therefore exactly what the door computed: `[]`, asserted whole. The + // notice itself is asserted where it now lives — the producer's stderr line, + // by conversion id AND path, so a different conversion cannot satisfy it. + expect(conversionsOf(payload), `${label}: the door computes no conversion for a defineStack export`).toEqual([]); + expect(run.stderr, `${label}: the producer reported the conversion at load`).toContain( + `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`, + ); } const dirs: Record = {}; @@ -268,9 +286,10 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expect(run.code, `expected the control to pass:\n${run.stdout}${run.stderr}`).toBe(0); const payload = payloadOf(run, 'control'); expect(payload.valid).toBe(true); - expectTheOneNotice(payload, 'control'); + expectTheOneNotice(payload, 'control', run); // The expiry is the reason this field cannot just be dropped into prose. - expect(typeof (conversionsOf(payload)[0] as { retiresIn?: unknown }).retiresIn).toBe('number'); + // The expiry still reaches the author — on the producer's line. + expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); }, 120_000); it('parse failure — THE HEADLINE: the notice computed at step 2 survives the schema error', async () => { @@ -279,7 +298,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'parsefail'); expect(payload.valid).toBe(false); expect(Array.isArray(payload.errors), 'the parse exit reports under `errors`').toBe(true); - expectTheOneNotice(payload, 'parsefail'); + expectTheOneNotice(payload, 'parsefail', run); }, 120_000); it('⭐ converts nothing — the SAME exit reports `[]`, so the field tracks the run', async () => { @@ -292,6 +311,8 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expect(payload.valid).toBe(false); expect('conversions' in payload, 'the field must be PRESENT even when empty').toBe(true); expect(payload.conversions, 'a canonical page kind converts nothing').toEqual([]); + // …and, since the producer is what converts now, raised no notice at load either. + expect(run.stderr).not.toContain("conversion 'page-kind-jsx-to-html'"); }, 120_000); it('rule errors — the notice rides the author-time gate', async () => { @@ -300,7 +321,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'rulefail'); expect(payload.valid).toBe(false); expect((payload.errors as Array<{ rule: string }>).map((i) => i.rule)).toContain('expression-invalid'); - expectTheOneNotice(payload, 'rulefail'); + expectTheOneNotice(payload, 'rulefail', run); }, 120_000); it('capability errors — the notice rides the #3366 preflight gate', async () => { @@ -309,7 +330,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'capfail'); expect(payload.valid).toBe(false); expect((payload.errors as Array<{ token: string }>).map((i) => i.token)).toEqual([FATAL_TOKEN]); - expectTheOneNotice(payload, 'capfail'); + expectTheOneNotice(payload, 'capfail', run); }, 120_000); it('doc errors — the notice rides the ADR-0046 docs gate', async () => { @@ -318,7 +339,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'docsfail'); expect(payload.valid).toBe(false); expect((payload.errors as Array<{ rule: string }>).map((i) => i.rule)).toEqual(['docs/namespace-prefix']); - expectTheOneNotice(payload, 'docsfail'); + expectTheOneNotice(payload, 'docsfail', run); }, 120_000); it('catch-all (late throw) — a THROWN failure still reports the computed notice', async () => { @@ -329,7 +350,7 @@ describe('#12125 — every `os validate --json` failure exit carries the convers expect(run.code, `expected the docs read to throw:\n${run.stdout}${run.stderr}`).toBe(1); const payload = payloadOf(run, 'thrown'); expect(String(payload.error)).toContain('ENOTDIR'); - expectTheOneNotice(payload, 'thrown'); + expectTheOneNotice(payload, 'thrown', run); }, 120_000); it('catch-all (throw at load) — `conversions` is PRESENT and empty, because step 2 never ran', async () => { From 33c76ccfada54701402873586de87a770106fca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:43:26 +0000 Subject: [PATCH 08/13] chore(spec): regenerate api-surface, export-origins and reference docs for hasStackProvenance / STACK_PROVENANCE_MISSING Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- content/docs/references/api/contract.mdx | 3 ++- content/docs/references/api/error-code-ledger.mdx | 1 + packages/spec/api-surface/root.json | 1 + packages/spec/export-origins/root.json | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index 7c166b8ee97..f6d7d3f6c25 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +321 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +322 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -335,6 +335,7 @@ const result = ApiErrorSchema.parse(data); * `STACK_CROSS_REFERENCE_INVALID` * `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` * `STACK_NAMESPACE_PREFIX_INVALID` +* `STACK_PROVENANCE_MISSING` * `STACK_SCHEMA_INVALID` * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index 1593341d531..4060ec19c7f 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -502,6 +502,7 @@ const result = ErrorCode.parse(data); * `STACK_CROSS_REFERENCE_INVALID` * `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED` * `STACK_NAMESPACE_PREFIX_INVALID` +* `STACK_PROVENANCE_MISSING` * `STACK_SCHEMA_INVALID` * `STACK_SINGLE_APP_VIOLATION` * `STACK_TRIGGER_CAPABILITY_REQUIRED` diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 6d5d38d750c..445a5739f5b 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -215,6 +215,7 @@ "formatUnknownAuthoringKey (function)", "formatZodError (function)", "formatZodIssue (function)", + "hasStackProvenance (function)", "isAggregatedViewContainer (function)", "isKnownPlatformCapability (function)", "isViewContainerShaped (function)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 11aa5bb3cf6..c5d402f8900 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -214,6 +214,7 @@ "formatUnknownAuthoringKey": "src/data/authoring-key-lint.ts#formatUnknownAuthoringKey (function)", "formatZodError": "src/shared/error-map.zod.ts#formatZodError (function)", "formatZodIssue": "src/shared/error-map.zod.ts#formatZodIssue (function)", + "hasStackProvenance": "src/stack-provenance.ts#hasStackProvenance (function)", "isAggregatedViewContainer": "src/ui/view.zod.ts#isAggregatedViewContainer (function)", "isKnownPlatformCapability": "src/kernel/platform-capabilities.ts#isKnownPlatformCapability (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", From def7f54429925e39db318f34a18b9001347f1f0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:47:27 +0000 Subject: [PATCH 09/13] fix(cli): keep the src-tier e2e test inside its tsconfig root (local spec link) Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .../validate-json-strict-exit.e2e.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index eb32a9c75d1..f59849cc38b 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -111,11 +111,24 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { execFile } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { linkSpec } from '../../test/helpers/define-stack-fixture.js'; + +/** + * The fixture projects are `defineStack` configs (`os validate` refuses any + * other default export — #20367 ruling B), so each OS-tmpdir project gets a + * `node_modules/@objectstack/spec` link to the package this one depends on — + * the `test/helpers/define-stack-fixture.ts` spelling, local here because this + * file lives under `src/`, outside the test helpers' tsconfig root. + */ +const SPEC_PACKAGE_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/spec/package.json')); +function linkSpec(dir: string): void { + mkdirSync(join(dir, 'node_modules', '@objectstack'), { recursive: true }); + symlinkSync(SPEC_PACKAGE_ROOT, join(dir, 'node_modules', '@objectstack', 'spec'), 'dir'); +} const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../../bin/run-dev.js'); From 885e6840a8da16d75c897f2a417d2f401c147701 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:02:33 +0000 Subject: [PATCH 10/13] test(metadata,runtime,plugin-dev): compose built stacks only Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .../plugin-artifact-packages-attribution.test.ts | 7 +++++-- .../src/dev-i18n-packages-reader.test.ts | 4 +++- packages/runtime/src/artifact-collections.test.ts | 15 ++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/metadata/src/plugin-artifact-packages-attribution.test.ts b/packages/metadata/src/plugin-artifact-packages-attribution.test.ts index 7d6f22afce5..005822cd193 100644 --- a/packages/metadata/src/plugin-artifact-packages-attribution.test.ts +++ b/packages/metadata/src/plugin-artifact-packages-attribution.test.ts @@ -61,7 +61,7 @@ */ import { describe, it, expect, vi } from 'vitest'; -import { composeStacks } from '@objectstack/spec'; +import { composeStacks, defineStack } from '@objectstack/spec'; import { MetadataPlugin } from './plugin.js'; const CORE_ID = 'com.example.multi.core'; @@ -130,7 +130,10 @@ const ordersStack = { /** The module is listed FIRST — array order must not be what orders the load. */ const twoPackageArtifact = () => JSON.parse(JSON.stringify(composeStacks( - [ordersStack, coreStack] as never, + // Through `defineStack` first — `composeStacks` refuses an input no + // producer built (#20367 ruling B); `strict: false` keeps each input + // exactly as authored here. + [defineStack(ordersStack as never, { strict: false }), defineStack(coreStack as never, { strict: false })], { manifest: 'preserve' }, ))); diff --git a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts index 16f8870762c..884bc32e7eb 100644 --- a/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts +++ b/packages/plugins/plugin-dev/src/dev-i18n-packages-reader.test.ts @@ -160,7 +160,9 @@ const additiveProject = (): Record => { */ const additiveNoI18nProject = (): Record => { const composed = composeStacks( - [modulePackage(), { ...corePackage(), translations: undefined } as ObjectStackDefinition], + // `Object.assign` onto the BUILT stack, not a spread copy: a copy drops the + // provenance mark and `composeStacks` refuses it (#20367 ruling B). + [modulePackage(), Object.assign(corePackage(), { translations: undefined }) as ObjectStackDefinition], { manifest: 'preserve' }, ) as unknown as Record; delete composed.translations; // absent already since #14512; deleted so the diff --git a/packages/runtime/src/artifact-collections.test.ts b/packages/runtime/src/artifact-collections.test.ts index 44a443f6c2f..823ea4e693d 100644 --- a/packages/runtime/src/artifact-collections.test.ts +++ b/packages/runtime/src/artifact-collections.test.ts @@ -29,7 +29,7 @@ import { describe, it, expect } from 'vitest'; -import { composeStacks } from '@objectstack/spec'; +import { composeStacks, defineStack } from '@objectstack/spec'; import { applyProtection } from '@objectstack/spec/shared'; import { resolveArtifactCollections, packageOwnedCollectionKeys } from './artifact-collections'; @@ -402,8 +402,13 @@ describe('resolveArtifactCollections', () => { manifest: { id, name: id, version: '1.0.0', type: 'module' as const, namespace: 'probe' }, objects: [{ name: 'probe_account', label: 'Account', fields: { [field]: { type: 'text' as const, label: field } } }], }); + // Each input through `defineStack` — `composeStacks` refuses any other + // (#20367 ruling B); `strict: false` keeps the input as authored. const artifact = composeStacks( - [pkg('com.example.a', 'from_a'), pkg('com.example.b', 'from_b')] as never, + [ + defineStack(pkg('com.example.a', 'from_a') as never, { strict: false }), + defineStack(pkg('com.example.b', 'from_b') as never, { strict: false }), + ], { manifest: 'preserve', objectConflict: 'merge' } as never, ) as Record; @@ -417,9 +422,9 @@ describe('resolveArtifactCollections', () => { // having quietly stopped stripping anything. const disjoint = composeStacks( [ - { manifest: { id: 'com.example.a', name: 'a', version: '1.0.0', type: 'module' as const, namespace: 'probe' }, objects: [{ name: 'probe_a', label: 'A', fields: {} }] }, - { manifest: { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'module' as const, namespace: 'probe' }, objects: [{ name: 'probe_b', label: 'B', fields: {} }] }, - ] as never, + defineStack({ manifest: { id: 'com.example.a', name: 'a', version: '1.0.0', type: 'module' as const, namespace: 'probe' }, objects: [{ name: 'probe_a', label: 'A', fields: {} }] } as never, { strict: false }), + defineStack({ manifest: { id: 'com.example.b', name: 'b', version: '1.0.0', type: 'module' as const, namespace: 'probe' }, objects: [{ name: 'probe_b', label: 'B', fields: {} }] } as never, { strict: false }), + ], { manifest: 'preserve' } as never, ) as Record; expect(disjoint.objects).toBeUndefined(); From f0506ed41add62939eff09e88f336401e0070704 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:02:47 +0000 Subject: [PATCH 11/13] docs(changeset): name the os dev compile path Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- .changeset/20367-one-stack-authoring-shape.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20367-one-stack-authoring-shape.md b/.changeset/20367-one-stack-authoring-shape.md index 2d5da75e813..607c41d8216 100644 --- a/.changeset/20367-one-stack-authoring-shape.md +++ b/.changeset/20367-one-stack-authoring-shape.md @@ -8,7 +8,7 @@ Why: the stack family's cross-field refusals (`STACK_CAPABILITY_UNKNOWN`, `STACK_CROSS_REFERENCE_INVALID`, `STACK_NAMESPACE_PREFIX_INVALID`, `STACK_SINGLE_APP_VIOLATION`, `STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED`, `STACK_TRIGGER_CAPABILITY_REQUIRED`) run inside `defineStack` only. The same defective stack exported as a plain object passed both commands at exit 0, and `objectstack build` shipped it. Re-running those refusals on whatever the config exports cannot fix that: a built stack carries each bound action twice, so the re-run refuses every correct project that has one. So the commands check who BUILT the export instead. - `@objectstack/spec`: `defineStack` and `composeStacks` stamp a non-enumerable `Symbol.for` provenance mark on what they return. The mark is invisible to the schema, to `Object.keys` and to `JSON.stringify`, so no compiled artifact changes. New export: `hasStackProvenance(value)` — `true` only for a value one of the two producers returned. New registered error code: `STACK_PROVENANCE_MISSING` (422), raised by `composeStacks` for an unbuilt input. -- `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. +- `@objectstack/cli`: `loadConfig` reads the mark off the default export before merging named exports into it (the merge is a spread, which drops the mark), and exposes it as `LoadedConfig.stackProvenance`. `objectstack validate` / `objectstack build` refuse on `false` through their existing error path: under `--json`, `error` + `code: 'STACK_PROVENANCE_MISSING'`. The envelope has no new fields. `objectstack dev` compiles through `objectstack build`, so it refuses the same way when it compiles. `objectstack serve`, `objectstack migrate`, `objectstack lint` and `objectstack generate` load configs exactly as before. **Migration** — FROM a plain-object (or copied) default export TO the value `defineStack` returns: From 0360658711033c6e70e8102136891e44f2b7c81a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 14:24:12 +0000 Subject: [PATCH 12/13] docs(cli): comments no longer describe a plain-object export reaching validate/build Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- packages/cli/src/commands/generate.ts | 5 +++-- packages/cli/test/capability-preflight.test.ts | 7 ++++--- .../test/lint-hook-rules-reach-handler-hooks.e2e.test.ts | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index dea1cc811e5..faee49b3564 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -813,8 +813,9 @@ function nameCharsetRefusal(name: string): string | null { * only what it always should have: this generator's answer for a `type` string * that is not a `FieldType` at all, which the UNVALIDATED authoring mode * (`defineStack(x, { strict: false })`) can still deliver. A plain-object config - * export is no longer an authoring door at all: `os validate` / `os build` - * refuse a default export `defineStack` did not build (`STACK_PROVENANCE_MISSING`). + * export is no longer a legal authoring shape — `os validate` / `os build` + * refuse a default export `defineStack` did not build (`STACK_PROVENANCE_MISSING`) + * — though this command, which checks no provenance, still loads one. * * Values are MEASURED, not invented — each one is the shape the platform * actually implements, read from the spec's ADR-0104 D1 value classes diff --git a/packages/cli/test/capability-preflight.test.ts b/packages/cli/test/capability-preflight.test.ts index beeec6663a9..a0fd5ff18e7 100644 --- a/packages/cli/test/capability-preflight.test.ts +++ b/packages/cli/test/capability-preflight.test.ts @@ -100,9 +100,10 @@ describe('renderCapabilityMessage (#3366)', () => { }); it('a RETIRED token renders its retirement prescription, never the typo hint', () => { - // `os validate` / `os build` parse a plain-object config without the - // `defineStack` vocabulary check, so this renderer is the only text an - // author at those doors sees. It must be the spec-owned prescription + // A `defineStack(x, { strict: false })` config reaches `os validate` / + // `os build` without the `defineStack` vocabulary check (a plain-object + // export no longer reaches them at all), so this renderer is the only text + // an author at those doors sees for it. It must be the spec-owned prescription // verbatim — the same words `defineStack` refuses the token with. const c = classifyRequiredCapability('reports', () => true); expect(c.status).toBe('unknown'); diff --git a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts index 1394fd7bf3e..52c616cb500 100644 --- a/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts +++ b/packages/cli/test/lint-hook-rules-reach-handler-hooks.e2e.test.ts @@ -147,7 +147,9 @@ export default defineStack({ /** * THE WIDENING LIMB (#16544 contract review) — the axis the hook legs above * cannot see. `ActionSchema.target` is `z.string()`, and `normalizeStackInput` - * never touches function values, so a plain-object config with an inline + * never touches function values, so a config that skipped the strict producer + * (a plain object then; `defineStack(…, { strict: false })` since the + * one-authoring-shape ruling) with an inline * action `target` callable hit `invalid_type` at the parse: `os validate` * REFUSED it before #16544 (exit 1) while `os build`, which lowers before it * parses, always accepted it. The same `lowerCallables` pass now rewrites the From 5355f47597da44acf15b0e760b827fe766bef349 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:43:56 +0000 Subject: [PATCH 13/13] test(cli): the #20459 build view-container fixtures authored through defineStack Claude-Session: https://claude.ai/code/session_01RTkKf8Dn5F4mepiZZfWoxH Co-authored-by: Claude --- packages/cli/test/build-view-container-name.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/build-view-container-name.test.ts b/packages/cli/test/build-view-container-name.test.ts index 97980a3f112..b731926cf23 100644 --- a/packages/cli/test/build-view-container-name.test.ts +++ b/packages/cli/test/build-view-container-name.test.ts @@ -37,6 +37,7 @@ import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ObjectQL } from '@objectstack/objectql'; import { childEnv } from './helpers/serve-process.js'; +import { defineStackSource, linkSpec } from './helpers/define-stack-fixture.js'; const HERE = resolve(fileURLToPath(import.meta.url), '..'); const CLI = resolve(HERE, '../bin/run-dev.js'); @@ -161,7 +162,10 @@ beforeAll(() => { const make = (label: string, s: Record) => { const dir = join(root, label); mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'objectstack.config.ts'), `export default ${JSON.stringify(s, null, 2)};\n`); + // A `defineStack` config (#20367 ruling B: `os build` refuses any other + // default export), spec linked in — the `validate-view-container-name` twin. + writeFileSync(join(dir, 'objectstack.config.ts'), defineStackSource(s)); + linkSpec(dir); dirs[label] = dir; }; make('divergent-json', stack('order_line'));