From ec2f9b6bb75a3eff047a11b7a36897e444e816f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 13:57:53 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20composeStacks=20gains=20manif?= =?UTF-8?q?est:=20'preserve'=20=E2=80=94=20N=20package=20identities=20surv?= =?UTF-8?q?ive=20composition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0130 follow-up row 3. `manifest: 'first' | 'last' | ` is a deliberate pick-one: it keeps one manifest and the other N-1 package identities are gone from the output. ADR-0130 needs the other case — a release artifact that CARRIES N packages, each keeping its own identity, so a product splits into modules without renaming a single object. `'preserve'` folds every input's package identity into `packages` (D4), in stack order. Which entries a stack contributes is D4's read-both rule applied to the inputs — `packages` present -> those entries; absent -> the singular `manifest` as a single-element list — so a stack carrying both contributes its list once and no de-duplication pass is needed. Entries are the `{ manifest: ... }` wrapper `ArtifactPackageEntrySchema` declares; the shape is not re-derived. The default stays `'last'`, and preserve is additive over it: the singular `manifest` is still selected by the same rule, so preserve's output is the default's output plus the package list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- ...adr0130-composestacks-manifest-preserve.md | 69 ++++ .../compose-stacks-manifest-preserve.test.ts | 319 ++++++++++++++++++ .../spec/src/stack-artifact-packages.test.ts | 18 +- packages/spec/src/stack.zod.ts | 136 +++++++- 4 files changed, 525 insertions(+), 17 deletions(-) create mode 100644 .changeset/adr0130-composestacks-manifest-preserve.md create mode 100644 packages/spec/src/compose-stacks-manifest-preserve.test.ts diff --git a/.changeset/adr0130-composestacks-manifest-preserve.md b/.changeset/adr0130-composestacks-manifest-preserve.md new file mode 100644 index 0000000000..1f8433de87 --- /dev/null +++ b/.changeset/adr0130-composestacks-manifest-preserve.md @@ -0,0 +1,69 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `composeStacks` gains `manifest: 'preserve'` — N package identities survive composition (ADR-0130 row 3, #14164) + +`composeStacks`' `manifest` option accepts a fourth value, `'preserve'`. Instead +of keeping one manifest and discarding the rest, it folds **every** input's +package identity into the composed artifact's `packages` list (ADR-0130 D4), in +stack order. + +**Why the existing values could not simply be fixed.** `'first' | 'last' | +` is a deliberate **pick-one**, and it is correct for the case it was +written for: several stacks assembled into ONE published package, which has one +identity. ADR-0130 introduces the other case — a release artifact that *carries* +N packages, each keeping its own identity, so a product splits into modules +**without renaming a single object** (the object `name` IS the table name, the +REST path, the formula token and the saved-view key, ADR-0129 D1–D2). Composing +N stacks under a pick strategy loses N−1 package identities, which is the +lossiness ADR-0130 §5 rejects `composeStacks`-as-is for. Both cases are real, so +the mode is a new value rather than a change of meaning for the old ones. + +**Which entries a stack contributes is D4's read-both rule, applied to the +inputs** — the same rule the load path applies to an artifact, so composition +and loading cannot disagree about what "the packages of this stack" means: + +- stack declares `packages` → those entries; +- stack declares no `packages` → its singular `manifest` as a **single-element + list**. + +A stack carrying both therefore contributes its list once, not its list plus its +manifest — nothing is emitted twice in the first place, so no de-duplication +pass exists to get wrong later. + +**Every emitted element is the `{ manifest: … }` wrapper object**, judged by +`ArtifactPackageEntrySchema` itself rather than by a re-derived literal — a +second declaration of one shape is the drift ADR-0116 exists about, and that +wrapper is the structural position ADR-0130 D4 reserves so a future +`{ ref, integrity }` external segment stays an **additive key** rather than a +reshape. + +**`'preserve'` is additive over the default, not a fourth pick.** The singular +`manifest` is still selected, by the same `'last'` rule, so a preserve +composition's output is the default's output **plus** the package list: the +artifact keeps an artifact-level identity (ADR-0130 D6 — one artifact, one +version) and no consumer reading `composed.manifest` sees a key disappear. +Nothing is registered twice either — D4's read-both rule reads a +`packages`-carrying artifact through `packages`, and `manifest` is the fallback +branch for artifacts that have none. + +**Graded `minor`: a pure widening.** The accept set gained exactly one option +value; the default is still `'last'`, no existing value changed meaning, and +nothing that parsed before is refused now. Existing callers — every caller that +passes no `manifest` option, and every caller that passes `'first'`, `'last'` or +an index — are unaffected, and that half is a machine criterion rather than a +reading of the diff (ADR-0130 D7: "Reviewer attention is not a mechanism"). It +is pinned in `packages/spec/src/compose-stacks-manifest-preserve.test.ts` +against the output of each existing strategy, **including the negative half**: +none of them mints a `packages` key. The pin #14161 deliberately left in +`stack-artifact-packages.test.ts` — "leaves the singular `manifest` pick-one +semantics alone" — is retitled `… BY DEFAULT` and keeps its assertions +unchanged, because that is precisely what this card did not touch. + +⚠️ This ships **composition** only. The load path that iterates `packages` in +dependency-topological order (D5, through the one sorter `resolvePluginOrder`) +and the `installPackage` co-ownership gate (D1/D3) are separate, dependent +cards. Until they land, a preserved artifact carries N package identities and +nothing downstream iterates them — so composing with `'preserve'` today +registers no extra package. diff --git a/packages/spec/src/compose-stacks-manifest-preserve.test.ts b/packages/spec/src/compose-stacks-manifest-preserve.test.ts new file mode 100644 index 0000000000..16e98ea7cd --- /dev/null +++ b/packages/spec/src/compose-stacks-manifest-preserve.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0130 follow-up row 3 — `composeStacks`' `manifest: 'preserve'` mode. + * + * ## What the mode is for + * + * `manifest: 'first' | 'last' | ` is a deliberate **pick-one**: + * composition keeps one manifest and the other N−1 package identities are gone + * from the output. ADR-0130 needs the other case — a release artifact that + * **carries** N packages, each keeping its own identity, so a product splits + * into modules **without renaming a single object** (the object `name` IS the + * table name, the REST path, the formula token and the saved-view key — + * ADR-0129 D1–D2). + * + * `'preserve'` folds every input's package identity into `packages` (ADR-0130 + * D4) instead of discarding all but one. + * + * ## The two halves this file pins, and why BOTH are load-bearing + * + * 1. **The default did not move.** ADR-0130's compatibility claim is that + * existing callers are unaffected, and the mode is opt-in. A pin that only + * demonstrated the new value would leave "and nothing else changed" as a + * reviewer's reading of a diff rather than a machine criterion — the exact + * substitution ADR-0130 D7 refuses ("Reviewer attention is not a + * mechanism"). So `'first'`, `'last'` and the index strategies are pinned + * here against their OUTPUT — including the negative half: none of them + * mints a `packages` key. + * 2. **Preserve's output is the artifact schema's shape.** Every emitted + * element is the `{ manifest: … }` wrapper object, asserted by feeding the + * composed result to the schemas themselves — `ArtifactPackageEntrySchema` + * per entry and `ObjectStackDefinitionSchema` over the whole artifact — + * rather than by eyeballing a literal. The wrapper is the structural + * position D4 reserves so a future `{ ref, integrity }` external segment is + * an ADDITIVE key rather than a reshape; a pin that accepted a flat inlined + * manifest body would be a pin that lets the next author spend that + * position without noticing. + * + * ⛔ Still NOT implemented, and not asserted here: the load path that iterates + * `packages` (D5, its own card) and the `installPackage` co-ownership gate + * (D1/D3, its own card). A green run of this file means a composed artifact + * CARRIES N package identities — not that a multi-package artifact installs. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + ArtifactPackageEntrySchema, + ObjectStackDefinitionSchema, + ComposeStacksOptionsSchema, + 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', + dependencies: { automation: '^1.0.0' }, +}; + +const cpqManifest = { + id: 'com.example.crm.cpq', + name: 'cpq', + version: '1.0.0', + type: 'module' as const, + namespace: 'crm', + dependencies: { 'com.example.crm': '^1.0.0' }, +}; + +const billingManifest = { + id: 'com.example.crm.billing', + name: 'billing', + version: '1.0.0', + type: 'module' as const, + namespace: 'crm', +}; + +/** `strict: false` so a hand-built stack shape reaches composition as written. */ +const raw = (o: Record): ObjectStackDefinition => + defineStack(o as never, { strict: false }); + +const packagesOf = (composed: ObjectStackDefinition): unknown[] | undefined => + (composed as unknown as { packages?: unknown[] }).packages; + +const idsOf = (composed: ObjectStackDefinition): (string | undefined)[] => + ((packagesOf(composed) ?? []) as { manifest?: { id?: string } }[]).map((e) => e.manifest?.id); + +// ─── Half 1 — the existing pick-one strategies are bit-unchanged ──── + +describe("ADR-0130 row 3 — today's pick-one strategies do not move", () => { + const stacks = () => [ + raw({ manifest: crmManifest }), + raw({ manifest: cpqManifest }), + raw({ manifest: billingManifest }), + ]; + + it("`'last'` (the default) keeps the last manifest and mints no `packages`", () => { + const explicit = composeStacks(stacks(), { manifest: 'last' }); + const byDefault = composeStacks(stacks()); + + expect(explicit.manifest?.id).toBe('com.example.crm.billing'); + expect(packagesOf(explicit)).toBeUndefined(); + // The default IS `'last'` — pinned as an equality, not as two assertions + // that happen to agree today. + expect(byDefault).toEqual(explicit); + }); + + it("`'first'` keeps the first manifest and mints no `packages`", () => { + const composed = composeStacks(stacks(), { manifest: 'first' }); + + expect(composed.manifest?.id).toBe('com.example.crm'); + expect(packagesOf(composed)).toBeUndefined(); + }); + + it('an index keeps that stack\'s manifest and mints no `packages`', () => { + const composed = composeStacks(stacks(), { manifest: 1 }); + + expect(composed.manifest?.id).toBe('com.example.crm.cpq'); + expect(packagesOf(composed)).toBeUndefined(); + }); + + it('the option schema still defaults to `last`', () => { + // The widening added a value; it must not have moved the default, which is + // the whole of ADR-0130's "existing callers are unaffected" for this card. + expect(ComposeStacksOptionsSchema.parse({}).manifest).toBe('last'); + }); + + it('rejects a manifest strategy that is neither a known value nor an index', () => { + // The accept set widened by exactly one value. Asserting the refusal of a + // neighbouring spelling is what makes that a measurement rather than a + // claim — `unrecognized` must not have become acceptable alongside + // `preserve`. + const result = ComposeStacksOptionsSchema.safeParse({ manifest: 'preserve-all' }); + + expect(result.success).toBe(false); + expect(ComposeStacksOptionsSchema.safeParse({ manifest: 'preserve' }).success).toBe(true); + expect(ComposeStacksOptionsSchema.safeParse({ manifest: -1 }).success).toBe(false); + }); +}); + +// ─── Half 2 — preserve keeps every package identity ───────────────── + +describe("ADR-0130 row 3 — `manifest: 'preserve'` keeps all N identities", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('folds N single-`manifest` stacks into `packages`, in stack order', () => { + const composed = composeStacks( + [raw({ manifest: crmManifest }), raw({ manifest: cpqManifest }), raw({ manifest: billingManifest })], + { manifest: 'preserve' }, + ); + + expect(idsOf(composed)).toEqual([ + 'com.example.crm', + 'com.example.crm.cpq', + 'com.example.crm.billing', + ]); + }); + + it('keeps each package\'s identity READABLE — id, namespace and dependencies', () => { + // The card's acceptance criterion is not "N elements exist" but "N package + // identities are completely readable", so it is asserted field by field on + // the sub-package, not by counting. + const composed = composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })], { + manifest: 'preserve', + }); + const entries = packagesOf(composed) as { manifest: Record }[]; + + expect(entries[1].manifest).toMatchObject({ + id: 'com.example.crm.cpq', + name: 'cpq', + version: '1.0.0', + type: 'module', + namespace: 'crm', + dependencies: { 'com.example.crm': '^1.0.0' }, + }); + // ADR-0130 D5 sorts by declared dependencies (ADR-0116's one sorter). The + // input to that sort is this field surviving composition — a preserve mode + // that dropped it would leave the ordering card nothing to sort by. + expect(entries[0].manifest.dependencies).toEqual({ automation: '^1.0.0' }); + }); + + it('emits the `{ manifest: … }` wrapper — judged by the schema, not by a literal', () => { + // ⛔ The wrapper shape is NOT re-derived here. `ArtifactPackageEntrySchema` + // is the single declaration (ADR-0116 drift), and it refuses a flat + // inlined manifest body — which is exactly the mistake this asserts the + // composer did not make. + const composed = composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })], { + manifest: 'preserve', + }); + + for (const entry of packagesOf(composed) ?? []) { + expect(ArtifactPackageEntrySchema.safeParse(entry).success).toBe(true); + } + // The negative half: an entry that WERE the flat body would not parse. + expect(ArtifactPackageEntrySchema.safeParse(crmManifest).success).toBe(false); + }); + + it('produces an artifact the artifact schema accepts', () => { + const composed = composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })], { + manifest: 'preserve', + }); + + const result = ObjectStackDefinitionSchema.safeParse(composed); + 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('is ADDITIVE — the singular `manifest` is still selected, by the default rule', () => { + // Preserve does not delete a key every previous output carried. The + // artifact keeps an artifact-level identity (D6 — one artifact, one + // version), and D4's read-both rule means nothing is registered twice: a + // `packages`-carrying artifact is read through `packages`, and `manifest` + // is the fallback branch for artifacts that have none. + const stacks = [raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })]; + const preserved = composeStacks(stacks, { manifest: 'preserve' }); + const byDefault = composeStacks(stacks); + + expect(preserved.manifest?.id).toBe('com.example.crm.cpq'); + expect(preserved.manifest).toEqual(byDefault.manifest); + // Stated as the whole-object relation, so "additive" is a machine + // criterion: preserve's output is the default's output plus `packages`. + expect({ ...preserved, packages: undefined }).toEqual({ ...byDefault, packages: undefined }); + }); + + it("applies D4's read-both rule per input — a stack carrying `packages` contributes those", () => { + // A stack that declares BOTH must not contribute its manifest twice. The + // rule is the same one the load path applies to an artifact, applied to + // each composition input — not a second rule, and not a de-duplication + // pass bolted on afterwards. + const composed = composeStacks( + [ + raw({ manifest: crmManifest, packages: [{ manifest: crmManifest }, { manifest: cpqManifest }] }), + raw({ manifest: billingManifest }), + ], + { manifest: 'preserve' }, + ); + + expect(idsOf(composed)).toEqual([ + 'com.example.crm', + 'com.example.crm.cpq', + 'com.example.crm.billing', + ]); + }); + + it('agrees with the declared `concat` disposition when every stack carries `packages`', () => { + // `packages` has a declared COMPOSE_KEY_DISPOSITIONS rule of `'concat'`. + // Preserve must not quietly mean something else for the same key: where + // concat has entries to work with, both produce the same list. + const stacks = [ + raw({ manifest: crmManifest, packages: [{ manifest: crmManifest }] }), + raw({ manifest: cpqManifest, packages: [{ manifest: cpqManifest }] }), + ]; + + expect(idsOf(composeStacks(stacks, { manifest: 'preserve' }))).toEqual( + idsOf(composeStacks(stacks)), + ); + }); + + it('leaves `packages` absent when there is nothing to preserve', () => { + // Not `[]`. Composing manifest-less stacks must not mint a key where today + // there is none — an empty `packages` would read downstream as "an + // artifact carrying zero packages", which is a different claim from "an + // artifact that does not use the multi-package shape". + const composed = composeStacks([raw({ apps: [] }), raw({ apps: [] })], { manifest: 'preserve' }); + + expect(packagesOf(composed)).toBeUndefined(); + expect(composed.manifest).toBeUndefined(); + }); + + it('skips a manifest-less stack rather than emitting a hole', () => { + const composed = composeStacks( + [raw({ manifest: crmManifest }), raw({ apps: [] }), raw({ manifest: cpqManifest })], + { manifest: 'preserve' }, + ); + + expect(idsOf(composed)).toEqual(['com.example.crm', 'com.example.crm.cpq']); + }); + + it('does not warn about an undeclared composition rule (#5005 rule 3)', () => { + composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })], { + manifest: 'preserve', + }); + + const warnings: string[] = warnSpy.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnings.some((w: string) => w.includes("'packages'"))).toBe(false); + }); + + it('short-circuits on a single stack, exactly as every other strategy does', () => { + // `composeStacks` returns a lone stack unchanged before options are even + // parsed. No identity is lost by that: an artifact with a singular + // `manifest` and no `packages` IS "one package" — it is D4's read-both + // branch 2, the same rule preserve applies to every other input. Pinned so + // nobody "fixes" preserve into rewriting a single stack's shape (which + // would also mean mutating the caller's own object, since this path returns + // it by identity). + const only = raw({ manifest: crmManifest }); + const composed = composeStacks([only], { manifest: 'preserve' }); + + expect(composed).toBe(only); + expect(packagesOf(composed)).toBeUndefined(); + }); +}); diff --git a/packages/spec/src/stack-artifact-packages.test.ts b/packages/spec/src/stack-artifact-packages.test.ts index d3cadb1f6d..5d4a1f4311 100644 --- a/packages/spec/src/stack-artifact-packages.test.ts +++ b/packages/spec/src/stack-artifact-packages.test.ts @@ -314,11 +314,19 @@ describe('ADR-0130 D4 — `packages` has a declared composition rule', () => { 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. + it('leaves the singular `manifest` pick-one semantics alone BY DEFAULT', () => { + // ⚠️ UPDATED, on purpose, by ADR-0130's follow-up row 3 — the card that + // added `composeStacks`' preserve mode. This pin was written to make that + // follow-up a VISIBLE change rather than a silent one, so here is what it + // now records: the assertions below did not move. Preserve is **opt-in** + // (`{ manifest: 'preserve' }`), the default is still `'last'`, and a caller + // that passes no options gets byte-for-byte what it got before — one + // manifest kept, and NO `packages` key minted underneath it. + // + // Only the pin's stated reason changed: "until the follow-up lands" became + // "the follow-up landed and deliberately did not touch this path". The + // preserve mode's own behaviour is pinned in + // `compose-stacks-manifest-preserve.test.ts`. const composed = composeStacks([raw({ manifest: crmManifest }), raw({ manifest: cpqManifest })]); expect(composed.manifest?.id).toBe('com.example.crm.cpq'); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 374e92fb5c..b540201d7c 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1844,12 +1844,50 @@ export const ComposeStacksOptionsSchema = lazySchema(() => z.object({ /** * Which manifest to keep when multiple stacks provide one. - * - `'first'` — Use the first manifest found. - * - `'last'` — Use the last manifest found (default). - * - A number — Use the manifest from the stack at the given index. + * + * - `'first'` — Use the first manifest found. + * - `'last'` — Use the last manifest found (default). + * - A number — Use the manifest from the stack at the given index. + * - `'preserve'` — Keep them ALL (ADR-0130 follow-up row 3). See below. + * + * ## `'preserve'` — the pick becomes additive instead of lossy + * + * The three values above are a deliberate **pick-one**: composition keeps one + * manifest and the other N−1 package identities are gone from the output. + * That is correct for the case they were written for (several stacks + * assembled into ONE published package) and wrong for the case ADR-0130 + * introduces — a release artifact that **carries** N packages, each of which + * keeps its own identity so a product can be split into modules without + * renaming a single object. + * + * `'preserve'` composes the artifact's package list instead of discarding it: + * every input stack contributes its packages to `packages` (ADR-0130 D4), in + * stack order, each element the `{ manifest: … }` wrapper object + * {@link ArtifactPackageEntrySchema} declares. + * + * Which entries a stack contributes is **D4's read-both rule applied to the + * inputs**, not a second rule invented here: + * + * - stack declares `packages` → those entries; + * - stack declares no `packages` → its singular `manifest` as a + * **single-element list**. + * + * So a stack that carries both does not contribute its manifest twice, and a + * plain single-`manifest` stack — every stack written before ADR-0130 — folds + * in as exactly one package. + * + * ⚠️ `'preserve'` is **additive, not a replacement**: the singular `manifest` + * is still selected, by the same `'last'` rule as the default, so the output + * is the default's output **plus** the package list. The artifact keeps an + * artifact-level identity (ADR-0130 D6 — one artifact, one version) and no + * consumer reading `composed.manifest` sees a key disappear. Nothing is + * registered twice: D4's read-both rule says a `packages`-carrying artifact + * is read through `packages`, and `manifest` is the fallback branch for + * artifacts that have none. + * * @default 'last' */ - manifest: z.union([z.enum(['first', 'last']), z.number().int().min(0)]).default('last'), + manifest: z.union([z.enum(['first', 'last', 'preserve']), z.number().int().min(0)]).default('last'), /** * Optional namespace prefix (reserved for Phase 2 — Marketplace isolation). @@ -1933,11 +1971,14 @@ const COMPOSE_KEY_DISPOSITIONS: Record).packages; + if (Array.isArray(declared)) { + entries.push(...(declared as ArtifactPackageEntry[])); + continue; + } + if (stack.manifest) entries.push({ manifest: stack.manifest }); + } + return entries; +} + /** * Declaratively compose multiple stack definitions into a single unified stack. * @@ -2274,7 +2359,9 @@ function selectManifest( * * **Array fields** (apps, views, dashboards, etc.) are concatenated in order. * **Objects** are merged according to the `objectConflict` strategy. - * **Manifest** is selected based on the `manifest` option. + * **Manifest** is selected based on the `manifest` option — or, with + * `manifest: 'preserve'`, every input's package identity is additionally folded + * into `packages` (ADR-0130 D4) instead of N−1 of them being discarded. * **Single-valued configuration** (`i18n`, `api`, `server`, `runtimeModule`) is * neither overridden nor merged: identical declarations pass through, and two * stacks declaring *different* values throw an error naming both stacks @@ -2299,6 +2386,10 @@ function selectManifest( * * // Merge strategy — fields from later stacks are shallow-merged * const combined = composeStacks([crm, todo], { objectConflict: 'merge' }); + * + * // Preserve — one artifact carrying BOTH package identities (ADR-0130 D4) + * const artifact = composeStacks([crm, cpq], { manifest: 'preserve' }); + * artifact.packages; // [{ manifest: crmManifest }, { manifest: cpqManifest }] * ``` */ export function composeStacks( @@ -2312,8 +2403,14 @@ export function composeStacks( const composed: Record = {}; - // 1. Manifest — pick based on strategy - composed.manifest = selectManifest(stacks, opts.manifest); + // 1. Manifest — pick based on strategy. + // + // `'preserve'` is additive over the default rather than a fourth pick: the + // singular `manifest` is still selected by `'last'`, so a preserve + // composition's output is the default's output PLUS the package list built + // in step 3a. The artifact keeps an artifact-level identity (ADR-0130 D6) + // and no consumer reading `composed.manifest` loses a key. + composed.manifest = selectManifest(stacks, opts.manifest === 'preserve' ? 'last' : opts.manifest); // 2. Objects — use conflict strategy const objects = mergeObjects(stacks, opts.objectConflict); @@ -2339,6 +2436,21 @@ export function composeStacks( } } + // 3a. `manifest: 'preserve'` — fold every input's package identity into the + // artifact package list (ADR-0130 D4, follow-up row 3). + // + // Deliberately AFTER the concat pass, which owns `packages`' declared + // disposition and its malformed-value warning. For stacks that already + // carry `packages`, preserve emits the same concatenation the pass just + // computed; what it adds is the single-`manifest` stacks the pass has + // nothing to concatenate for. Left undefined when there is nothing to + // preserve, so composing manifest-less stacks does not mint an empty + // array where today there is no key at all. + if (opts.manifest === 'preserve') { + const preserved = preservePackageEntries(stacks); + if (preserved.length > 0) composed.packages = preserved; + } + // 4. Named handler functions — merged by name (#5005). const functions = composeFunctions(stacks); if (functions.declared) { From a47ef3932791047aacabed1d0386ef6d211fc87a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 13:58:50 +0000 Subject: [PATCH 2/2] test(spec): close two vacuously-green preserve pins the ablation leg exposed Both passed while `manifest: 'preserve'` did nothing: a `for` over an absent `packages` list iterates zero times, and "the output is the default's output plus the list" holds trivially when the list is never added. Assert the addition first in each, so the pin measures the mode rather than tolerating its absence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UHvF5hyiZjnCyExFnfQB8m --- .../src/compose-stacks-manifest-preserve.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/compose-stacks-manifest-preserve.test.ts b/packages/spec/src/compose-stacks-manifest-preserve.test.ts index 16e98ea7cd..39d3f6ae6f 100644 --- a/packages/spec/src/compose-stacks-manifest-preserve.test.ts +++ b/packages/spec/src/compose-stacks-manifest-preserve.test.ts @@ -201,7 +201,13 @@ describe("ADR-0130 row 3 — `manifest: 'preserve'` keeps all N identities", () manifest: 'preserve', }); - for (const entry of packagesOf(composed) ?? []) { + // The count first, deliberately: a `for` over an absent list is vacuously + // green, so without this line the pin would pass on an implementation where + // preserve does nothing at all. (It did — measured in this card's ablation + // leg, which is how the line got here.) + const entries = packagesOf(composed); + expect(entries).toHaveLength(2); + for (const entry of entries ?? []) { expect(ArtifactPackageEntrySchema.safeParse(entry).success).toBe(true); } // The negative half: an entry that WERE the flat body would not parse. @@ -232,6 +238,10 @@ describe("ADR-0130 row 3 — `manifest: 'preserve'` keeps all N identities", () const preserved = composeStacks(stacks, { manifest: 'preserve' }); const byDefault = composeStacks(stacks); + // "Additive" is only a claim if something was added: assert the addition + // before asserting that nothing else moved — otherwise this pin is green on + // an implementation that adds nothing (measured in the ablation leg). + expect(idsOf(preserved)).toEqual(['com.example.crm', 'com.example.crm.cpq']); expect(preserved.manifest?.id).toBe('com.example.crm.cpq'); expect(preserved.manifest).toEqual(byDefault.manifest); // Stated as the whole-object relation, so "additive" is a machine