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..39d3f6ae6f --- /dev/null +++ b/packages/spec/src/compose-stacks-manifest-preserve.test.ts @@ -0,0 +1,329 @@ +// 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', + }); + + // 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. + 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); + + // "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 + // 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) {