Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .changeset/adr0130-composestacks-manifest-preserve.md
Original file line numberDiff line numberDiff line change
@@ -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' |
<index>` 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.
329 changes: 329 additions & 0 deletions packages/spec/src/compose-stacks-manifest-preserve.test.ts
Original file line numberDiff line numberDiff line change
@@ -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' | <index>` 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<string, unknown>): 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<typeof vi.spyOn>;

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<string, unknown> }[];

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();
});
});
Loading
Loading