diff --git a/.changeset/adr0130-d4-artifact-packages-list.md b/.changeset/adr0130-d4-artifact-packages-list.md new file mode 100644 index 0000000000..0613cd4222 --- /dev/null +++ b/.changeset/adr0130-d4-artifact-packages-list.md @@ -0,0 +1,63 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): the release artifact may carry N package manifests — optional `packages[]` on `ObjectStackDefinitionSchema` (ADR-0130 D4, #14161) + +`ObjectStackDefinitionSchema` gains an **optional** `packages` key: an array of +package entries, so one release artifact can deliver a product split into +modules **without renaming a single object**. Renaming is what separate +namespaces would cost — the object `name` IS the table name, the REST path, the +formula token and the saved-view key (ADR-0129 D1–D2) — and rename-on-install is +ADR-0048's standing non-goal. + +**`manifest` (singular) is RETAINED, not replaced**, and both shapes are read: +`packages` present → iterate it; `packages` absent → treat `manifest` as a +single-element list. A replacement would break every artifact already built and +sitting on disk at every customer, which is why ADR-0130 states the read-both +rule as the schema decision rather than an implementation note: the schema shape +IS the compatibility mechanism. + +**Each entry is a wrapper object** — `{ manifest: { … } }` — never the manifest +body inlined flat as the array element. That position is reserved deliberately, +at schema time: when a future external-segment form lands it is +`{ ref, integrity }`, an **additive key on an existing object**, rather than a +reshape that would have to bolt transport keys onto the shared `ManifestSchema` +and make every required manifest field optional. ⛔ Segmented loading itself is +**not** implemented and is an explicit ADR-0130 Non-goal. Forward compatibility +rides the mechanism that already exists, `manifest.engines.protocol` (ADR-0025); +⛔ no new version-negotiation mechanism is introduced. + +**Graded `minor`: a pure widening, with no accept-set narrowing anywhere.** The +new key is optional, no existing key changed shape, and nothing that parsed +before is refused now. Measured rather than asserted — the acceptance criterion +was that existing single-`manifest` artifacts do not move, and both halves are +pinned: + +- schema layer (`packages/spec/src/stack-artifact-packages.test.ts`): parsing a + single-`manifest` artifact adds **no** top-level key, materialises no + `packages` list, and the serialised result contains no `"packages"`. The + near-miss this guards is a `.default([])`, which would have rewritten every + project's artifact on its next build; +- compiler (`packages/cli/test/compile-artifact-packages.e2e.test.ts`): the + artifact `os build` writes for a single-package project has the exact + top-level key set it had before. + +**No `@objectstack/cli` release is graded, and that is a measurement, not an +omission.** `os compile` / `os build` needed **no source change** to align: +`normalizeStackInput`, `lowerCallables` and the artifact write each shallow-clone +the top level, and the validation step parses with this very schema, so the new +key flows through end to end. What moved is the CLI's accept set, and it moved +**entirely through this package** — the CLI ships no changed line and takes the +new behaviour with its `@objectstack/spec` bump. The pass-through was verified by +compiling real projects in the e2e file above rather than read off the source, +because "it works by construction" is exactly the claim that stops being true the +day someone adds a whitelist to one of those three steps. + +⚠️ This ships the **shape** only. The load path that iterates the list in +dependency-topological order (ADR-0130 D5, through the one sorter +`resolvePluginOrder`) and the `installPackage` co-ownership gate with its +install-time object-name uniqueness check (D1/D3, which ADR-0130 requires to land +as one inseparable change) are separate, dependent cards. Until they land, a +multi-package artifact parses and carries its list and nothing downstream +iterates it — so authoring `packages` today registers no extra package. diff --git a/packages/cli/test/compile-artifact-packages.e2e.test.ts b/packages/cli/test/compile-artifact-packages.e2e.test.ts new file mode 100644 index 0000000000..2fc70e806d --- /dev/null +++ b/packages/cli/test/compile-artifact-packages.e2e.test.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 — `os compile` / `os build` write side, measured on the artifact + * the commands actually put on disk. + * + * D4's acceptance has two halves and they pull in opposite directions, which is + * why both are measured here from real runs rather than argued from the source: + * + * 1. A single-package project keeps writing `manifest` EXACTLY as today. The + * default compile output does not move — no new key, no reordering, no + * materialised empty list. This is the half a schema change breaks by + * accident (a `.default([])` on the new key would rewrite every project's + * artifact on its next build), so it is pinned as an exact top-level key + * set, not as a spot check. + * + * 2. An artifact that DOES declare `packages` survives the whole pipeline — + * `normalizeStackInput` → `lowerCallables` → `ObjectStackDefinitionSchema` + * → `JSON.stringify(finalBundle)`. Each of those steps shallow-clones the + * top level, so the key passes through *by construction*; construction is + * exactly the kind of claim that stops being true when someone adds a + * whitelist to one of the three, and nothing would have failed. + * + * ⛔ A green run here does NOT mean a multi-package artifact installs. The load + * path that iterates the list (ADR-0130 D5, topologically ordered through + * `resolvePluginOrder`) and the `installPackage` co-ownership gate (D1/D3) are + * separate, dependent cards. This file pins what the COMPILER writes. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync } 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'; + +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), + }); + }, + ); + }); +} + +function payloadOf(run: Run, label: string): Record { + try { + return JSON.parse(run.stdout) as Record; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); + } +} + +/** Today's shape: one package, declared through the singular `manifest`. */ +const CONFIG_SINGLE = ` +export default { + manifest: { id: 'com.example.solo', name: 'solo', version: '1.0.0', type: 'app', namespace: 'solo' }, + objects: [ + { + name: 'solo_ticket', + label: 'Ticket', + sharingModel: 'private', + fields: { title: { type: 'text', label: 'Title' } }, + }, + ], +}; +`; + +/** ADR-0130 D4: the artifact carries two co-owning packages, wrapper form. */ +const CONFIG_MULTI = ` +export default { + 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' } }, + { manifest: { id: 'com.example.crm.cpq', name: 'cpq', version: '1.0.0', type: 'module', namespace: 'crm' } }, + ], + objects: [ + { + name: 'crm_account', + label: 'Account', + sharingModel: 'private', + fields: { name: { type: 'text', label: 'Name' } }, + }, + ], +}; +`; + +/** + * The reservation, violated: the manifest body inlined flat as the array + * element. Must be refused at the compile door, not written to an artifact. + */ +const CONFIG_FLATTENED = ` +export default { + packages: [ + { id: 'com.example.flat', name: 'flat', version: '1.0.0', type: 'app', namespace: 'flat' }, + ], + objects: [], +}; +`; + +const dirs: Record = {}; +let root = ''; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-d4-packages-')); + for (const [name, source] of Object.entries({ + single: CONFIG_SINGLE, + multi: CONFIG_MULTI, + flattened: CONFIG_FLATTENED, + })) { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), source); + dirs[name] = dir; + } +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +const artifactOf = (payload: Record): Record => + JSON.parse(readFileSync(String(payload.output), 'utf8')) as Record; + +describe('ADR-0130 D4 — the default (single-package) compile output does not move', () => { + it('writes `manifest` and NO `packages` key', async () => { + const run = await runCli(['build', '--json'], dirs.single); + expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0); + + const artifact = artifactOf(payloadOf(run, 'os build --json')); + + expect((artifact.manifest as Record).id).toBe('com.example.solo'); + // The exact key set, so a materialised `"packages": []` — the near-miss + // this criterion exists for — fails here rather than being noticed by a + // customer diffing their artifact. + expect(Object.keys(artifact).sort()).toEqual(['manifest', 'objects']); + expect(readFileSync(String(payloadOf(run, 'os build --json').output), 'utf8')).not.toContain('"packages"'); + }, 180_000); +}); + +describe('ADR-0130 D4 — an artifact declaring `packages` compiles and keeps it', () => { + it('carries both package manifests through to the written artifact, in order', async () => { + const run = await runCli(['build', '--json'], dirs.multi); + expect(run.code, `os build --json failed:\n${run.stdout}\n${run.stderr}`).toBe(0); + + const artifact = artifactOf(payloadOf(run, 'os build --json')); + const packages = artifact.packages as { manifest: { id: string; type: string } }[]; + + expect(Array.isArray(packages), 'the compiler dropped the `packages` key').toBe(true); + expect(packages.map((p) => p.manifest.id)).toEqual([ + 'com.example.crm', + 'com.example.crm.cpq', + ]); + // The wrapper survives as a wrapper — not flattened, not unwrapped. + expect(packages[0]).toEqual({ manifest: expect.objectContaining({ id: 'com.example.crm' }) }); + expect(packages[1].manifest.type).toBe('module'); + }, 180_000); + + it('keeps the singular `manifest` beside it — retained, not replaced', async () => { + const run = await runCli(['build', '--json'], dirs.multi); + const artifact = artifactOf(payloadOf(run, 'os build --json')); + + expect((artifact.manifest as Record).id).toBe('com.example.crm'); + }, 180_000); +}); + +describe('ADR-0130 D4 — the compile door refuses a flattened entry', () => { + it('exits non-zero rather than writing an artifact in the unreserved shape', async () => { + const run = await runCli(['build', '--json'], dirs.flattened); + + expect(run.code, `expected a refusal, got exit 0:\n${run.stdout}`).not.toBe(0); + const payload = payloadOf(run, 'os build --json'); + expect(payload.success).toBe(false); + + // The refusal must point at the offending entry, so the author can find it + // in an artifact with N packages. + const errors = JSON.stringify(payload.errors ?? payload.error ?? ''); + expect(errors).toContain('packages'); + }, 180_000); +}); diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 501ca7cf70..af2bf3cfd8 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -9,6 +9,9 @@ "AUDIENCE_ANCHOR_POSITIONS (const)", "Agent (type)", "ApplyConversionsOptions (interface)", + "ArtifactPackageEntry (type)", + "ArtifactPackageEntryParsed (type)", + "ArtifactPackageEntrySchema (const)", "AssembledViewArtifact (type)", "AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema (const)", diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index a4f7c068aa..404969d9f0 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -9,6 +9,9 @@ "AUDIENCE_ANCHOR_POSITIONS": "src/identity/position.zod.ts#AUDIENCE_ANCHOR_POSITIONS (const)", "Agent": "src/ai/agent.zod.ts#Agent (type)", "ApplyConversionsOptions": "src/conversions/apply.ts#ApplyConversionsOptions (interface)", + "ArtifactPackageEntry": "src/stack.zod.ts#ArtifactPackageEntry (type)", + "ArtifactPackageEntryParsed": "src/stack.zod.ts#ArtifactPackageEntryParsed (type)", + "ArtifactPackageEntrySchema": "src/stack.zod.ts#ArtifactPackageEntrySchema (const)", "AssembledViewArtifact": "src/ui/assembled-views.zod.ts#AssembledViewArtifact (type)", "AssembledViewArtifactParsed": "src/ui/assembled-views.zod.ts#AssembledViewArtifactParsed (type)", "AssembledViewArtifactSchema": "src/ui/assembled-views.zod.ts#AssembledViewArtifactSchema (const)", diff --git a/packages/spec/src/stack-artifact-packages.test.ts b/packages/spec/src/stack-artifact-packages.test.ts new file mode 100644 index 0000000000..d3cadb1f6d --- /dev/null +++ b/packages/spec/src/stack-artifact-packages.test.ts @@ -0,0 +1,327 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 D4 — the release artifact may carry N package manifests, and BOTH + * shapes are read. + * + * The decision this file pins, in the record's own terms: + * + * - `packages` present → iterate it. + * - `packages` absent → treat `manifest` (singular) as a single-element list. + * + * `manifest` is RETAINED, not replaced: "A *replacement* of `manifest` by + * `packages` would break every artifact already built — the schema shape is the + * compatibility mechanism." So the acceptance criterion is a NEGATIVE one as + * much as a positive one — nothing about an existing single-`manifest` artifact + * may move — and that half is pinned here first. + * + * ## The structural reservation, and why it gets its own pins + * + * D4 reserves the segmented-artifact key POSITION at schema time and + * deliberately does not build it: each entry in `packages` is an **object** + * whose manifest body sits under `manifest:`, never the manifest body inlined + * flat as the array element. That is the whole reason a future + * `{ ref, integrity }` external segment is an ADDITIVE key rather than a + * reshape. A reservation nothing asserts is a reservation the next author + * flattens away without noticing — an artifact schema is on disk at every + * customer, so this is the cheapest possible moment to hold the shape. + * + * ⛔ Segmented loading itself is an explicit ADR-0130 Non-goal and is NOT + * implemented. So is the load path that iterates the list (D5, its own card) + * and the `installPackage` co-ownership gate (D1/D3, its own card). This file + * pins the SCHEMA, and says so where a reader might otherwise read a green test + * as "multi-package artifacts install". + * + * ## Rejection-pin convention + * + * Schema-layer rejections assert the Zod issue's **`code` and `path`** (plus + * the offending key where the issue carries one), matching + * `stack-top-level-strict.test.ts`: `status` is the publish door's uniform + * ADR-0112 wrap, applied where a parse failure crosses the HTTP boundary, not + * minted per-schema. A bare `expect(...).toThrow()` would be no pin at all + * here — every one of these inputs is refused by SOME issue, and the point is + * WHICH. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + ArtifactPackageEntrySchema, + ObjectStackDefinitionSchema, + composeStacks, + defineStack, + type ObjectStackDefinition, +} from './stack.zod'; + +// ─── Fixtures ─────────────────────────────────────────────────────── + +const crmManifest = { + id: 'com.example.crm', + name: 'crm', + version: '1.0.0', + type: 'app' as const, + namespace: 'crm', +}; + +const cpqManifest = { + id: 'com.example.crm.cpq', + name: 'cpq', + version: '1.0.0', + type: 'module' as const, + namespace: 'crm', +}; + +/** A representative artifact of the shape that exists on disk TODAY. */ +const singleManifestArtifact = () => ({ + manifest: { ...crmManifest }, + objects: [ + { name: 'crm_account', label: 'Account', fields: { name: { type: 'text', label: 'Name' } } }, + ], + apps: [], + requires: ['automation'], +}); + +const parse = (raw: Record) => ObjectStackDefinitionSchema.safeParse(raw); + +/** The single issue at `path`, or `undefined` — pins read one issue, not a set. */ +const issueAt = ( + result: ReturnType, + path: (string | number)[], +) => { + if (result.success) return undefined; + return result.error.issues.find( + (i) => JSON.stringify(i.path) === JSON.stringify(path), + ); +}; + +// ─── D4 branch 2 — `packages` absent: nothing moves ───────────────── + +describe('ADR-0130 D4 — an existing single-`manifest` artifact is untouched', () => { + it('parses, and every value it declared survives', () => { + const input = singleManifestArtifact(); + const result = parse(input); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.manifest).toMatchObject(crmManifest); + expect(result.data.objects?.[0]).toMatchObject({ name: 'crm_account', label: 'Account' }); + expect(result.data.requires).toEqual(['automation']); + }); + + it('adds NO top-level key to the parsed artifact — the compile output does not move', () => { + // ⚠️ Read what this asserts and what it does not. The parse legitimately + // fills DEFAULTS deep inside (`field.required: false`, `object.datasource` + // …), so the parsed value is not equal to the input and never was — a pin + // written that way fails on `main` for reasons that have nothing to do with + // this change. What this change could actually break is the TOP-LEVEL key + // set, and that is what is pinned: a `.default([])` on `packages` (the + // obvious near-miss) would materialise `"packages": []` into every + // artifact, rewriting the compile output of every project on its next + // build and putting a second source of truth for the same fact in the file. + // + // `os compile` writes `JSON.stringify(finalBundle, null, 2)` with + // `finalBundle` spread from this parse result + // (packages/cli/src/commands/compile.ts), so "no new top-level key" is the + // schema-layer half of the byte-unchanged criterion. The end-to-end half is + // measured against the pre-change compiler in the PR body. + const input = singleManifestArtifact(); + const result = parse(input); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(Object.keys(result.data).sort()).toEqual(Object.keys(input).sort()); + }); + + it('does NOT invent a `packages` key when the artifact has none', () => { + // The read-both rule makes `manifest` a single-element list at the LOAD + // path (ADR-0130 D5's card). The schema must not materialise that list into + // the artifact. + const result = parse(singleManifestArtifact()); + + expect(result.success).toBe(true); + if (!result.success) return; + expect('packages' in (result.data as Record)).toBe(false); + expect(result.data.packages).toBeUndefined(); + expect(JSON.stringify(result.data)).not.toContain('"packages"'); + }); + + it('keeps `packages` OPTIONAL — an artifact with neither key still parses', () => { + const result = parse({ objects: [] }); + expect(result.success).toBe(true); + }); +}); + +// ─── D4 branch 1 — `packages` present ─────────────────────────────── + +describe('ADR-0130 D4 — `packages` carries N manifests', () => { + it('accepts an artifact carrying two co-owning packages, in order', () => { + const result = parse({ + packages: [{ manifest: crmManifest }, { manifest: cpqManifest }], + objects: [], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.packages?.map((p) => p.manifest.id)).toEqual([ + 'com.example.crm', + 'com.example.crm.cpq', + ]); + }); + + it('accepts BOTH keys on one artifact — `manifest` is retained, not replaced', () => { + // A producer may keep writing the singular `manifest` so an older runtime + // still loads the artifact (D4's forward-compatibility posture rides + // `manifest.engines.protocol`, ADR-0025 — not a new negotiation + // mechanism). The schema must therefore not treat the two keys as mutually + // exclusive. + const result = parse({ + manifest: crmManifest, + packages: [{ manifest: crmManifest }, { manifest: cpqManifest }], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.manifest?.id).toBe('com.example.crm'); + expect(result.data.packages).toHaveLength(2); + }); + + it('accepts an EMPTY list — an empty artifact is not a schema error', () => { + // Whether an empty `packages` is meaningful is a LOAD-path question (it + // registers nothing), and inventing a `.min(1)` here would refuse an + // artifact the load path can read perfectly well. + expect(parse({ packages: [] }).success).toBe(true); + }); + + it('carries the whole manifest, `engines.protocol` included (ADR-0025)', () => { + // D4: "Forward compatibility rides on the mechanism that already exists: + // `manifest.engines.protocol`." That only holds if the per-entry manifest + // is the REAL manifest schema rather than a reduced copy of it. + const result = parse({ + packages: [{ manifest: { ...cpqManifest, engines: { protocol: '>=18 <19' } } }], + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.packages?.[0].manifest.engines?.protocol).toBe('>=18 <19'); + }); +}); + +// ─── D4 — the structural reservation ──────────────────────────────── + +describe('ADR-0130 D4 — each `packages` entry is an OBJECT wrapping its manifest', () => { + it('refuses a manifest body inlined flat as the array element', () => { + // THE reservation pin. `packages: [{ id, name, version, … }]` is the shape + // that would make a future `{ ref, integrity }` segment a reshape instead + // of an added key, so it must be refused now, while nothing has shipped it. + const result = parse({ packages: [{ ...crmManifest }] }); + + expect(result.success).toBe(false); + const issue = issueAt(result, ['packages', 0]); + expect(issue?.code).toBe('unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual( + expect.arrayContaining(['id', 'name', 'version', 'type', 'namespace']), + ); + // The refusal must teach the wrapper, not merely deny the input. + expect(issue?.message).toContain('manifest'); + }); + + it('refuses a non-object entry, naming the entry position', () => { + const result = parse({ packages: ['com.example.crm.cpq'] }); + + expect(result.success).toBe(false); + const issue = issueAt(result, ['packages', 0]); + expect(issue?.code).toBe('invalid_type'); + }); + + it('refuses an entry with no manifest at all', () => { + const result = parse({ packages: [{}] }); + + expect(result.success).toBe(false); + expect(issueAt(result, ['packages', 0, 'manifest'])?.code).toBe('invalid_type'); + }); + + it('refuses `packages` that is not an array', () => { + const result = parse({ packages: { 'com.example.crm': crmManifest } }); + + expect(result.success).toBe(false); + expect(issueAt(result, ['packages'])?.code).toBe('invalid_type'); + }); + + it('refuses TODAY\'s runtime the future `{ ref, integrity }` segment — cleanly', () => { + // ⚠️ DELIBERATE, and it is the forward half of the reservation: an older + // runtime must refuse a newer artifact rather than mis-parse it into a + // half-registered install (D4, ADR-0025). The refusal names `ref` because + // the entry is strict. + // + // When segmented loading lands (its own decision — an ADR-0130 Non-goal + // here), this pin is UPDATED on purpose, and the update is the visible + // record that the key position was spent. + const result = parse({ + packages: [{ ref: './segments/cpq.json', integrity: 'sha256-deadbeef' }], + }); + + expect(result.success).toBe(false); + const issue = issueAt(result, ['packages', 0]); + expect(issue?.code).toBe('unrecognized_keys'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual( + expect.arrayContaining(['ref', 'integrity']), + ); + }); + + it('exports the entry schema so the load path judges the same shape', () => { + // #14162's iteration and any consumer that reads one entry must reuse this + // schema rather than re-derive the wrapper — a second declaration of one + // shape is the drift ADR-0116 exists about, in miniature. + expect(ArtifactPackageEntrySchema.safeParse({ manifest: crmManifest }).success).toBe(true); + expect(ArtifactPackageEntrySchema.safeParse(crmManifest).success).toBe(false); + }); +}); + +// ─── Composition — the disposition is declared, not defaulted ─────── + +describe('ADR-0130 D4 — `packages` has a declared composition rule', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + const raw = (o: Record): ObjectStackDefinition => + defineStack(o as never, { strict: false }); + + it('concatenates entries in stack order', () => { + const composed = composeStacks([ + raw({ manifest: crmManifest, packages: [{ manifest: crmManifest }] }), + raw({ manifest: cpqManifest, packages: [{ manifest: cpqManifest }] }), + ]) as unknown as { packages: { manifest: { id: string } }[] }; + + expect(composed.packages.map((p) => p.manifest.id)).toEqual([ + 'com.example.crm', + 'com.example.crm.cpq', + ]); + }); + + it('does not warn about an undeclared composition rule (#5005 rule 3)', () => { + composeStacks([ + raw({ manifest: crmManifest, packages: [{ manifest: crmManifest }] }), + raw({ manifest: cpqManifest }), + ]); + + const warnings: string[] = warnSpy.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnings.some((w: string) => w.includes("'packages'"))).toBe(false); + }); + + it('leaves the singular `manifest` pick-one semantics alone', () => { + // ADR-0130's follow-up row 3 (a `composeStacks` preserve mode) is a + // separate, additive card. Until it lands, composing two stacks that each + // declare only `manifest` still keeps ONE — pinned so the follow-up is a + // visible change rather than a silent one. + const composed = composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })]); + + expect(composed.manifest?.id).toBe('com.example.crm.cpq'); + expect((composed as Record).packages).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index d27cab5da8..374e92fb5c 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -157,6 +157,67 @@ function applyApiEndpointGates( } } +/** + * One package carried by a release artifact (ADR-0130 D4). + * + * A release artifact MAY carry N package manifests: everything inside one + * artifact is delivered atomically by one publisher, and that joint delivery IS + * the co-ownership declaration (ADR-0130 D1). This schema is the ELEMENT of the + * artifact's `packages` list. + * + * ## ⛔ The entry is a WRAPPER object, and that is the whole point of its shape + * + * The manifest body sits UNDER a key (`manifest`); it is never inlined flat as + * the array element itself. ADR-0130 D4 reserves the position deliberately, at + * schema time, and says why: an artifact schema is on disk at every customer, + * so format is the hardest decision to revisit — 黑猫's 30-object artifact is + * already 2.6 MB, and modules accreting into one JSON grow both marketplace + * transfer and startup parse. When a future external-segment form lands, it is + * `{ ref, integrity }` — an ADDITIVE key on this existing object. Flatten the + * manifest into the array element instead and that same future is a SHAPE + * change: `ref`/`integrity` would have to be bolted onto `ManifestSchema` + * (which every other consumer shares) and every required manifest field would + * have to go optional, because a segment reference carries no manifest content + * at all. + * + * ⛔ Segmented loading itself is NOT implemented and is an explicit Non-goal of + * ADR-0130 ("D4 reserves the key position only. The segmented form itself needs + * its own decision."). This schema reserves structure, nothing else. + * + * Forward compatibility rides the mechanism that already exists — + * `manifest.engines.protocol` (ADR-0025, `kernel/manifest.zod.ts`). A + * new-format artifact declares a new protocol range and an older runtime + * refuses it cleanly instead of mis-parsing it into a half-registered install. + * ⛔ No new version-negotiation mechanism is introduced here. + * + * @example + * ```jsonc + * { + * "packages": [ + * { "manifest": { "id": "com.example.crm", "name": "crm", "version": "1.0.0", + * "type": "app", "namespace": "crm" } }, + * { "manifest": { "id": "com.example.crm.cpq", "name": "cpq", "version": "1.0.0", + * "type": "module", "namespace": "crm" } } + * ] + * } + * ``` + */ +export const ArtifactPackageEntrySchema = lazySchema(() => strictObject({ + surface: 'an artifact package entry', + history: + 'The entry is a wrapper object whose manifest body lives under `manifest:` — the ' + + 'structural position ADR-0130 D4 reserves so a future `{ ref, integrity }` external ' + + 'segment is an additive key rather than a reshape. An inlined manifest body (`id`, ' + + '`name`, `version`, … written directly on the array element) is therefore refused: ' + + 'wrap it as `{ manifest: { … } }`.', +}, { + manifest: ManifestSchema.describe('The package manifest this artifact entry carries'), +}).describe('One package carried by a release artifact (ADR-0130 D4)')); + +export type ArtifactPackageEntry = z.input; +/** Post-parse shape of {@link ArtifactPackageEntry} — defaults applied, transforms run (ADR-0122). */ +export type ArtifactPackageEntryParsed = z.infer; + /** * ObjectStack Ecosystem Definition * @@ -238,6 +299,38 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ }, { /** System Configuration */ manifest: ManifestSchema.optional().describe('Project Package Configuration'), + + /** + * The artifact's package list (ADR-0130 D4) — **optional, and additive**. + * + * A release artifact MAY carry N package manifests so a product can be split + * into modules **without renaming a single object** (which is what separate + * namespaces would cost: the object `name` IS the table name, the REST path, + * the formula token and the saved-view key — ADR-0129 D1–D2 — and + * rename-on-install is ADR-0048's standing non-goal). + * + * ## Read BOTH shapes — the schema shape IS the compatibility mechanism + * + * - `packages` present → iterate it. + * - `packages` absent → treat `manifest` (singular) as a **single-element + * list**. + * + * `manifest` is therefore RETAINED, not replaced. A replacement would break + * every artifact already built and on disk at every customer; the read-both + * rule is the term ADR-0130's whole compatibility claim rests on, which is + * why D4 states it as the schema decision rather than an implementation note. + * An existing single-`manifest` artifact takes the second branch and its + * behaviour is unchanged. + * + * ⚠️ This declares the SHAPE. The load path that iterates it — topologically + * ordered through the one sorter, `resolvePluginOrder` (ADR-0130 D5, + * ADR-0116) — and the `installPackage` co-ownership gate (ADR-0130 D1/D3) are + * separate, dependent changes. Until they land, a multi-package artifact + * parses and carries its list; nothing downstream iterates it yet. + */ + packages: z.array(ArtifactPackageEntrySchema).optional() + .describe('Package manifests carried by this release artifact (ADR-0130 D4)'), + datasources: z.array(DatasourceSchema).optional().describe('External Data Connections'), /** @@ -1832,6 +1925,20 @@ const COMPOSE_KEY_DISPOSITIONS: RecordSchema)` shape below and are + * nevertheless NOT metadata collections — so no site is expected to enumerate + * them, and their absence is not drift. + * + * ⚠️ Adding a name here is a claim, and the bar is high: it means the key does + * not name a metadata TYPE that the eight sites route, register, categorise or + * demonstrate. Get it wrong and a real collection silently leaves the reference + * set, which is this gate's own failure mode (an empty set reconciles against + * everything). The shape discriminator below stays the rule; this is the + * declared, reasoned exception list, and it is deliberately a list of names + * rather than a second heuristic. + * + * - `packages` (ADR-0130 D4) carries package MANIFESTS — the release artifact's + * co-owning packages — not authored metadata of some type. It has no singular + * metadata-type name (nothing in `PLURAL_TO_SINGULAR`), no artifact + * subdirectory, no `registerInMemory` kind and no map/record authoring form. + * Every one of the eight sites is correct to omit it, so counting it would + * manufacture eight simultaneous deviations and drive eight waiver rows that + * each assert the opposite of the truth. + */ +const NON_COLLECTION_ARRAY_KEYS = new Set(['packages']); + /** * The stack-collection set: top-level `ObjectStackDefinitionSchema` keys whose - * value is `z.array(Schema)`. + * value is `z.array(Schema)`, minus the declared + * {@link NON_COLLECTION_ARRAY_KEYS}. * * That shape is the discriminator, and it is what keeps this mechanical instead * of a second hand-maintained list: `plugins` (`z.array(z.unknown())`), @@ -334,7 +358,8 @@ export function stackCollections(stackSource) { if (!shape) return null; return objectEntries(shape.body) .filter(({ value }) => /^z\.array\(\s*[A-Za-z_$][\w$]*Schema\s*\)/.test(value)) - .map(({ key }) => key); + .map(({ key }) => key) + .filter((key) => !NON_COLLECTION_ARRAY_KEYS.has(key)); } // ─────────────────────────────────────────────────────────────────────────── @@ -868,10 +893,19 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ requires: z.array(z.string()).optional(), devPlugins: z.array(z.union([ManifestSchema, z.string()])).optional(), api: z.object({ nested: z.array(NestedSchema) }).optional(), + packages: z.array(ArtifactPackageEntrySchema).optional(), data: z.array(SeedSchema).optional(), }).superRefine(gates)); `; eq('stackCollections() takes z.array(Schema) only', stackCollections(stack), ['objects', 'data']); + // `packages` (ADR-0130 D4) matches the shape discriminator and is excluded by + // name — pinned here so the exclusion cannot be deleted quietly, and so the + // synthetic source above shows the exact declaration it is answering. + eq( + 'stackCollections() drops a declared NON_COLLECTION_ARRAY_KEYS member', + stackCollections(stack).includes('packages'), + false, + ); eq('stackCollections() returns null when the anchor is gone', stackCollections('export const Other = 1;'), null); eq( 'stackCollections() returns null when the shape argument is missing', @@ -956,7 +990,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => strictObject({ for (const f of failures) console.error(` • ${f}\n`); return 1; } - console.log('✓ check-stack-collection-maps --self-test: 15 assertions over synthetic sources'); + console.log('✓ check-stack-collection-maps --self-test: 16 assertions over synthetic sources'); return 0; }