Skip to content

Commit d7631d5

Browse files
fix(spec): a refusing defineStack / composeStacks carries the ADR-0087 conversions it applied on its refusal — stackConversionsOf(error) reads them (#20651)
Fixes #20618 Clause-②: no ## What this lands This is the `packages/spec` half of #20583 (its location 2). #20583 keeps the CLI fold in the three catch-alls; this PR does not touch `packages/cli`. A `defineStack` call that converts an old spelling and then refuses now carries the conversions it applied on the ADR-0112 refusal it throws. The record uses the same `Symbol.for('objectstack.stack.conversions')` key and the same properties as the record on a built stack: a frozen `ConversionNotice[]`, non-enumerable, non-writable, non-configurable. It is the producer's own array as it stands at the throw. There is no second conversion pass, and nothing reads the warn-once stderr line. - **`stack.zod.ts`**: `defineStack` is now a thin wrapper around its unchanged body (`buildDefinedStack`). The wrapper holds the `appliedConversions` array that the conversion pass pushes to, and its one `catch` stamps that array on any `StackRefusalError` before it rethrows the same object. `composeStacks` gets the same wrapper, and its record formula moves into one helper (`composedConversions`) shared by the return and the refusal. One rule for which throw is stamped (`withRefusalConversions`): members of the `StackRefusalError` family only. Anything else is rethrown untouched. - **`stack-provenance.ts`**: `markRefusalConversions` is the refusing half of `markStackProvenance`: same writer, no mark, first stamp wins. It is module-internal and not re-exported. `stackConversionsOf` reads the record off a marked stack, as before, **or** off an `Error` that carries it as an own property. A plain object that carries the key without the mark is still not read, and neither is a record inherited through a prototype. The module header gains a section on the refusal record, and the reader's TSDoc section "What it cannot hold" is amended: the refusing-`defineStack` boundary is gone, and the non-refusal-throw boundary is stated. - **One changeset**, `@objectstack/spec: patch`. Each refusal keeps its `code`, `status`, `name`, message and `issues` byte-for-byte, and `hasStackProvenance(error)` still answers `false`. ## How a door reads it (for #20583's CLI fold) ```ts } catch (error) { conversions.push(...stackConversionsOf(error)); // readonly ConversionNotice[] ``` - The return is frozen. Each element is the whole `ConversionNotice` (`code`, `conversionId`, `surface`, `from`, `to`, `path`, `toMajor`, `retiresIn`, `message`), the same element `LoadedConfig.stackConversions` carries. `path` is relative to the refusing `defineStack` call. - It answers `[]` for a refusal whose source needed no conversion, for a plain `Error`, and for any throw that is not a producer refusal (the CLI's own "throw at load" fixture is one of these). - The reader never uses `instanceof` on the refusal class. It keys on `Symbol.for` plus `instanceof Error`, so a CLI and a config that resolve two copies of this package still agree, as long as they run in one realm. - A door's own step-2 pass never runs on the refusal path, so folding the record cannot double-count. ## Coverage: every throw between the first conversion and the return (hypothesis 1, measured by reading) The conversion pass itself (`normalizeStackInput` into `applyConversions`) is documented never to throw. After it, `defineStack` reaches exactly **10 throw sites**, all `StackRefusalError` subclasses: - **The 7 in the strict tail:** - the schema parse (`STACK_SCHEMA_INVALID`); - capability, cross-reference, namespace prefix, single app, hierarchy-scope capability and trigger capability. - **The 3 in `mergeActionsIntoObjects`,** all `STACK_SCHEMA_INVALID`: `objects` is not an array, an `objects` entry is not an object, and an `actions` shape is wrong. The merge ends **both** modes, so these 3 are reachable after a conversion under `strict: false`. This is a refusal the triage's coverage clause names ("every refusal thrown after a conversion") that sits outside the strict tail. The wrapper's single `catch` covers all 10. The census in the tests drives each site with a converting page and asserts the record: 10 rows, 7 distinct codes. No non-refusal throw is reachable by construction: - `warnUnknownAuthoringKeys`, the six `validate*` helpers and `warnEmailTemplateLocaleFloor` contain no `throw`, directly or through the modules they call. - `safeParse` returns its failure instead of throwing it. The residue is a throw from inside a zod refinement or transform, or from an author's own getter or proxy. Such a throw is not a producer refusal, so it is rethrown untouched and carries no record. The TSDoc states this, and the tests pin it through `composeStacks`' options parse. ## `composeStacks`: extended in place (the same defect class) A `composeStacks` refusal is thrown after its inputs' `defineStack` calls converted, and before this change it carried nothing, which is the same loss. All four in-place conditions hold: - it is the same defect class; - it is a mechanical fix whose shape this card pins (the same stamp, and the formula PR #20579 pinned for the artifact); - the file is in this claim's surface, and no other claim holds it; - it adds no new gate family. The guard wraps the whole body, so every throw in its call tree passes through the one `catch`. - **What is stamped:** 13 refusal sites: 10 in its own call tree (provenance, the concat shape, the artifact cross-reference, the action-key collision, the two `mergeObjects` refusals, the two function conflicts, the key conflict and the collection conflict) plus the 3 in `mergeActionsIntoObjects`. - **What is not stamped:** the two non-refusal throws, which are the `ComposeStacksOptionsSchema.parse` zod error and the internal-invariant `Error`. - **Pinned:** the provenance refusal (step 0), an object conflict (step 2), the empty-record control, and the options-parse non-refusal. The claim's file-surface parenthetical reads "defineStack's strict tail". The seat may amend it to cover the `strict: false` merge refusals and `composeStacks`. ## Where a refusing inner `defineStack` surfaces (hypothesis 3, measured) In `composeStacks([defineStack(A), defineStack(B)])`, B's refusal is thrown **while the array literal is being evaluated**, and `composeStacks` never runs. The test records that only A was built. The error's record is **B's own** notice: exactly one, not A's object (checked by identity), and B printed **0** stderr header lines because the warn-once set already had the key. ## API surface (hypothesis 2) `stackConversionsOf(value: unknown)` already accepted the error, but its body returned `[]` for anything without the provenance mark. It is reused, with one arm added. No export is added or changed: - `check:api-surface`: exit 0. - `check:export-origins`: exit 0. - `check:generated`: all 15 artifacts up to date against a fresh build. Hence `Clause-②: no` and a `patch` changeset. ## Verification record (HEAD `e6595835ae`) - **Build:** - `pnpm --filter @objectstack/spec build`: exit 0. - After the restart: `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, 71/71 tasks successful. - **Spec tests (`--project local`)**, in 4 shards, all passing: 144 + 144 + 144 + 143 files, 4582 + 4133 (1 todo) + 3721 + 4502 tests. - **Spec tests (`--project repo`):** the 8 repo-project files that reference the stack producers, 142 tests, passed. - **Typecheck:** `pnpm --filter @objectstack/spec typecheck` exit 0. `check:test-typecheck` answered OK, with the debt ledger unchanged. - **The record test file:** 39 tests, 16 pre-existing and 23 new. - **Ablation A** (committed state `9deca56296`, through `scripts/ablation-replace.mjs` in wrap mode with a trap). It deletes the stamp call in `withRefusalConversions`. - On disk: anchor x1 to x0, blob `f916adad1fe4` to `8aac6e049c3c`. - Result: 19 failed and 20 passed. The red rows are every refusal-record row. The 16 pre-existing rows, the "otherwise the same refusal" row, the census-count row, the non-refusal row and the plain-`Error` row stayed green. - Restore: blob equals HEAD, and `git diff HEAD` is empty. - **Ablation B** (the same method). It removes the reader's refusal arm. - On disk: anchor x1 to x0, blob `89278c468c95` to `ed6b5a82b5b3`. - Result: 17 failed and 22 passed. The two stamped-empty-record pins stayed green because they read the property descriptor directly. - Restore proven the same way. - Both ablations turned red, the expected direction. The tests import `src/` directly, so no `dist/` was on the measured path. - **Gates:** - `dispatch-gates --commands` derived 83 commands, all exit 0. - `--ran` reconciliation: "83 derived, 83 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). - Four gates first answered PREREQUISITE NOT MET (exit 3): `check:doc-formula-expressions`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. All four were re-run green after the full build. - **Lint (a proven narrowing, not the repo run):** - `eslint --no-inline-config --format json` over the 3 changed `.ts` files reports 3 files, 0 errors and 0 warnings. - The population is `eslint.config.mjs`'s `**/*.{ts,…}` block. - The config has no `parserOptions.project` and no typed rules, so the diff cannot move any untouched file's verdict. - `pnpm lint` over the repo is CI's. - **Full-repo pin sweep:** zero pins of the old semantics. By grep: - no test deep-equals a stack refusal; - no test reads a stack refusal's own symbol keys; - no test asserts that `stackConversionsOf` answers `[]` for a thrown refusal; - the boundary prose lived only in `stack-provenance.ts`. The CLI's "throw at load" control is a plain `Error` thrown before any producer, and stays `[]` by the new rule. Refusal assertions for illegal shapes are untouched. ## Acceptance notes - The `strict: false` merge refusals and `composeStacks` are covered beyond the claim's "strict tail" wording. The reasons are above. - **Not stamped, by decision:** non-refusal throws. - Inside `defineStack`, none is reachable by construction. - Inside `composeStacks`, two are: the options-parse zod error (an authored-options mistake) and the internal-invariant `Error`. - A door therefore answers `conversions: []` for those. Stamping arbitrary thrown values would hand a producer record to errors the producer did not construct, including frozen or primitive ones. - The record on a composed refusal is the whole composition's (all inputs, in input order), not only the inputs involved in the conflict. This is the same record the artifact would have carried. - `origin/main` moved after the merge (`0cb72cfc72` at report time). None of its commits touch these files, so the branch was not re-merged. CI's merge ref re-verifies the combination. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19fc8d6 commit d7631d5

4 files changed

Lines changed: 478 additions & 32 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
**A `defineStack` or `composeStacks` call that refuses now carries the ADR-0087 conversions it applied on the error it throws, so `stackConversionsOf(error)` reads them off a caught refusal.**
6+
7+
`defineStack` rewrites a deprecated metadata spelling to its canonical shape before it validates, and records each conversion on the stack it returns (`stackConversionsOf(stack)`). A call that then refused returned no stack, so the conversions it had applied were lost: they reached stderr only, as a warn-once line that a second stack with the same path does not print again. A tool that catches the refusal, such as a `--json` door, had no way to report both the refusal and the retiring spelling.
8+
9+
- `defineStack` (strict and `strict: false`) stamps the conversions applied so far on every ADR-0112 refusal it throws after its conversion pass: the schema parse, the six cross-field refusals and the bound-action merge's shape refusal. The record is the same `ConversionNotice[]` a built stack carries, under the same symbol key, non-enumerable and frozen. A refusal whose source needed no conversion carries an empty record.
10+
- `composeStacks` stamps its inputs' records on every refusal it throws, by the same rule it uses for the artifact it returns.
11+
- `stackConversionsOf(value)` now also reads the record off such a refusal: `catch (error) { const conversions = stackConversionsOf(error); }`. It still answers `[]` for any other value, including a plain `Error` and a throw that is not one of these refusals.
12+
13+
Nothing is accepted or refused differently. Each refusal keeps its `code`, `status`, `name`, message and `issues`, and `hasStackProvenance` still answers `false` for it. No export is added.
14+
15+
Clause-②: no

‎packages/spec/src/stack-conversions-record.test.ts‎

Lines changed: 280 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,13 @@
2121
* - it is dropped exactly where the mark is dropped, and a forged record on
2222
* an unmarked value is not read;
2323
* - `composeStacks`: the inputs' records concatenated in input order, one
24-
* application once, at every arity and through nesting.
24+
* application once, at every arity and through nesting;
25+
* - a producer that REFUSES carries the record on its ADR-0112 refusal, as it
26+
* stood at the throw: every `defineStack` refusal site (both modes), the
27+
* `composeStacks` refusals (its inputs' records), the empty record when
28+
* nothing was converted, and B's own notice when a refusing
29+
* `defineStack(B)` inside `composeStacks([...])` had its stderr line
30+
* swallowed by the warn-once. A non-refusal throw carries none.
2531
*/
2632
import { describe, it, expect, vi, afterEach } from 'vitest';
2733
import {
@@ -276,3 +282,276 @@ describe('composeStacks records its inputs’ records, in input order, one appli
276282
expect(stackConversionsOf(composed)).toEqual([]);
277283
});
278284
});
285+
286+
/** The thrown value (the test fails when nothing is thrown). */
287+
function thrown(run: () => unknown): Error & { code?: string; status?: number; issues?: readonly unknown[] } {
288+
try {
289+
run();
290+
} catch (error) {
291+
return error as Error & { code?: string; status?: number; issues?: readonly unknown[] };
292+
}
293+
throw new Error('expected a refusal, but the call returned');
294+
}
295+
296+
/** The record property as the stamp left it, or `undefined` when there is none. */
297+
const ownRecord = (value: object) => Object.getOwnPropertyDescriptor(value, Symbol.for('objectstack.stack.conversions'));
298+
299+
describe('a refusing producer carries the conversions it applied on its refusal', () => {
300+
it("⭐ triage pin: a convert-then-refuse call's error answers page-header-subtitle-alias", () => {
301+
quiet();
302+
const error = thrown(() =>
303+
defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never),
304+
);
305+
expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN');
306+
expect(error.status).toBe(422);
307+
const record = stackConversionsOf(error);
308+
expect(record.map(substance)).toEqual([HEADER_NOTICE]);
309+
// The element is the whole `ConversionNotice`, the same shape a built stack carries.
310+
expect(Object.keys(record[0]).sort()).toEqual(
311+
['code', 'conversionId', 'from', 'message', 'path', 'retiresIn', 'surface', 'to', 'toMajor'].sort(),
312+
);
313+
// An error is not a built stack: the record rides without the mark.
314+
expect(hasStackProvenance(error)).toBe(false);
315+
});
316+
317+
it('⭐ triage pin: a refusal with no conversion answers an empty record — stamped, not merely absent', () => {
318+
quiet();
319+
const error = thrown(() =>
320+
defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never),
321+
);
322+
expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN');
323+
expect(stackConversionsOf(error)).toEqual([]);
324+
// Anti-vacuity: `[]` is the refusal's own (empty) record, so the guard
325+
// covered this throw — an unstamped error answers `[]` too.
326+
const desc = ownRecord(error);
327+
expect(desc, 'the refusal carries the record property').toBeDefined();
328+
expect(desc?.value).toEqual([]);
329+
expect(Object.isFrozen(desc?.value)).toBe(true);
330+
});
331+
332+
it('the refusal is otherwise the same refusal: code, status, name, message and issues are unchanged', () => {
333+
quiet();
334+
const converting = thrown(() =>
335+
defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never),
336+
);
337+
const canonical = thrown(() =>
338+
defineStack({ ...source('rf', 'subtitle'), requires: ['no-such-capability'] } as never),
339+
);
340+
for (const key of ['code', 'status', 'name', 'message'] as const) {
341+
expect(converting[key], key).toBe(canonical[key]);
342+
}
343+
expect(converting.issues).toEqual(canonical.issues);
344+
expect(converting.message).toMatch(/^defineStack capability validation failed \(1 issue\):/);
345+
});
346+
347+
it('the record on a refusal is invisible to data readers and frozen, like the one on a stack', () => {
348+
quiet();
349+
const error = thrown(() =>
350+
defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never),
351+
);
352+
const desc = ownRecord(error);
353+
expect(desc?.enumerable).toBe(false);
354+
expect(desc?.writable).toBe(false);
355+
expect(desc?.configurable).toBe(false);
356+
expect(Object.keys(error).some((k) => k.includes('conversion'))).toBe(false);
357+
const record = stackConversionsOf(error);
358+
expect(record, 'anti-vacuity: there is a record to freeze').toHaveLength(1);
359+
expect(Object.isFrozen(record)).toBe(true);
360+
expect(record.every((n) => Object.isFrozen(n))).toBe(true);
361+
});
362+
363+
it('the record is what was applied so far: a built stack handed straight back and then refused keeps the record it arrived with', () => {
364+
quiet();
365+
// Built without validation, so the capability refusal is still owed.
366+
const inner = defineStack(
367+
{ ...source('rf', 'description'), requires: ['no-such-capability'] } as never,
368+
{ strict: false },
369+
);
370+
expect(stackConversionsOf(inner), 'anti-vacuity: the inner build recorded one').toHaveLength(1);
371+
// Handed straight back, strict: the pass finds nothing left to convert,
372+
// and the refusal carries the record the input arrived with.
373+
const error = thrown(() => defineStack(inner as never));
374+
expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN');
375+
expect(stackConversionsOf(error)).toEqual(stackConversionsOf(inner));
376+
// Control: a spread copy drops the record with the mark, and its source is
377+
// already canonical — so this call applied nothing, and says so. The
378+
// record is the producer's, never reconstructed.
379+
const copied = thrown(() => defineStack({ ...inner } as never));
380+
expect(copied.code).toBe('STACK_CAPABILITY_UNKNOWN');
381+
expect(stackConversionsOf(copied)).toEqual([]);
382+
});
383+
384+
it("⭐ triage pin: in composeStacks([defineStack(A), defineStack(B)]) the refusing B's error carries B's own notice, though its stderr line was swallowed", () => {
385+
const warn = quiet();
386+
const printed: string[][] = [];
387+
const built: unknown[] = [];
388+
/** `defineStack`, recording the stderr lines THIS call printed. */
389+
const define = (config: unknown) => {
390+
const start = warn.mock.calls.length;
391+
try {
392+
const stack = defineStack(config as never);
393+
built.push(stack);
394+
return stack;
395+
} finally {
396+
printed.push(warn.mock.calls.slice(start).map((c) => String(c[0])));
397+
}
398+
};
399+
const headerLines = (lines: string[]) =>
400+
lines.filter((l) => l.includes("conversion 'page-header-subtitle-alias'") && l.includes(HEADER_PATH));
401+
402+
// A and B author the same notice path, so their warn-once key is one key.
403+
const error = thrown(() =>
404+
composeStacks([
405+
define(source('ca', 'description')),
406+
define({ ...source('cb', 'description'), requires: ['no-such-capability'] }),
407+
] as ObjectStackDefinition[]),
408+
);
409+
410+
// Where it surfaces: B's own `defineStack` refusal, thrown while the array
411+
// was being built — `composeStacks` never ran.
412+
expect(error.code).toBe('STACK_CAPABILITY_UNKNOWN');
413+
expect(error.message).toMatch(/^defineStack capability validation failed/);
414+
expect(built, 'only A was built').toHaveLength(1);
415+
expect(printed).toHaveLength(2);
416+
// B printed nothing: the warn-once set had the key already (from A, or
417+
// from an earlier build of the same path in this process).
418+
expect(headerLines(printed[1])).toEqual([]);
419+
// …and its refusal still carries B's own notice — exactly one, and not A's object.
420+
const record = stackConversionsOf(error);
421+
expect(record.map(substance)).toEqual([HEADER_NOTICE]);
422+
const aRecord = stackConversionsOf(built[0]);
423+
expect(aRecord, 'anti-vacuity: A recorded the same notice').toHaveLength(1);
424+
expect(record[0]).not.toBe(aRecord[0]);
425+
});
426+
});
427+
428+
describe('every refusal site in defineStack carries the record — census, both modes', () => {
429+
const manifest = {
430+
id: 'com.example.refusalrecord',
431+
name: 'refusal-record-test',
432+
version: '1.0.0',
433+
type: 'app' as const,
434+
namespace: 'probe',
435+
};
436+
const task = { name: 'probe_task', label: 'Task', fields: { title: { type: 'text' as const, label: 'Title' } } };
437+
const app = (name: string) => ({
438+
name,
439+
label: name,
440+
navigation: [{ id: `nav_${name}`, type: 'object' as const, label: 'Tasks', objectName: task.name }],
441+
});
442+
const recordFlow = {
443+
name: 'task_fanout',
444+
label: 'task_fanout',
445+
type: 'record_change',
446+
nodes: [
447+
{ id: 'start', type: 'start', label: 'start', config: { objectName: task.name, triggerType: 'record-after-create' } },
448+
{ id: 'end', type: 'end', label: 'end' },
449+
],
450+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
451+
};
452+
/** The converting page every row carries: `description` on a `page:header`. */
453+
const pages = source('probe', 'description').pages;
454+
const grant = { name: 'managers', label: 'Managers', objects: { [task.name]: { allowRead: true, readScope: 'unit_and_below' } } };
455+
456+
const rows: Array<{ site: string; code: string; config: Record<string, unknown>; strict?: false }> = [
457+
{ site: 'schema parse', code: 'STACK_SCHEMA_INVALID', config: { manifest: {}, pages } },
458+
{ site: 'capability', code: 'STACK_CAPABILITY_UNKNOWN', config: { manifest, objects: [task], pages, requires: ['automations'] } },
459+
{
460+
site: 'cross-reference',
461+
code: 'STACK_CROSS_REFERENCE_INVALID',
462+
config: { manifest, objects: [task], pages, data: [{ object: 'missing_object', records: [] }] },
463+
},
464+
{ site: 'namespace prefix', code: 'STACK_NAMESPACE_PREFIX_INVALID', config: { manifest, objects: [{ ...task, name: 'task' }], pages } },
465+
{ site: 'single app', code: 'STACK_SINGLE_APP_VIOLATION', config: { manifest, objects: [task], pages, apps: [app('app_one'), app('app_two')] } },
466+
{ site: 'hierarchy-scope capability', code: 'STACK_HIERARCHY_SCOPE_CAPABILITY_REQUIRED', config: { manifest, objects: [task], pages, permissions: [grant] } },
467+
{
468+
site: 'trigger capability',
469+
code: 'STACK_TRIGGER_CAPABILITY_REQUIRED',
470+
config: { manifest, objects: [task], pages, requires: ['automation'], flows: [recordFlow] },
471+
},
472+
{ site: 'bound-action merge, strict: false — objects not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: 'nope', pages }, strict: false },
473+
{ site: 'bound-action merge, strict: false — an objects entry not an object', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [null], pages }, strict: false },
474+
{ site: 'bound-action merge, strict: false — actions not an array', code: 'STACK_SCHEMA_INVALID', config: { manifest, objects: [task], actions: 7, pages }, strict: false },
475+
];
476+
477+
for (const row of rows) {
478+
it(`${row.site}: ${row.code} carries the header notice`, () => {
479+
quiet();
480+
const error = thrown(() => defineStack(row.config as never, row.strict === false ? { strict: false } : undefined));
481+
expect(error.code).toBe(row.code);
482+
expect(error.status).toBe(422);
483+
expect(stackConversionsOf(error).map(substance)).toEqual([HEADER_NOTICE]);
484+
});
485+
}
486+
487+
it('the census reaches every code defineStack refuses with: seven distinct codes', () => {
488+
expect(new Set(rows.map((r) => r.code)).size).toBe(7);
489+
});
490+
});
491+
492+
describe('a composeStacks refusal carries its inputs’ records', () => {
493+
const build = (ns: string, headerKey: 'description' | 'subtitle', objectNs = ns) => {
494+
quiet();
495+
const stack = source(ns, headerKey);
496+
return defineStack(
497+
{ ...stack, objects: [{ ...stack.objects[0], name: `${objectNs}_thing` }] } as never,
498+
{ strict: false },
499+
);
500+
};
501+
502+
it('an object conflict: the inputs’ records, in input order — the record the artifact would have carried', () => {
503+
const a = build('ka', 'description', 'kk');
504+
const b = build('kb', 'subtitle', 'kk');
505+
const c = build('kc', 'description', 'kk');
506+
const error = thrown(() => composeStacks([a, b, c] as ObjectStackDefinition[]));
507+
expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT');
508+
expect(error.status).toBe(422);
509+
expect(stackConversionsOf(error)).toEqual([...stackConversionsOf(a), ...stackConversionsOf(c)]);
510+
expect(stackConversionsOf(error).map(substance)).toEqual([HEADER_NOTICE, HEADER_NOTICE]);
511+
});
512+
513+
it('an unbuilt input: the built inputs’ records ride the provenance refusal', () => {
514+
const a = build('pa', 'description');
515+
const error = thrown(() => composeStacks([a, { ...a }] as ObjectStackDefinition[]));
516+
expect(error.code).toBe('STACK_PROVENANCE_MISSING');
517+
expect(stackConversionsOf(error)).toEqual(stackConversionsOf(a));
518+
expect(stackConversionsOf(error), 'anti-vacuity').toHaveLength(1);
519+
});
520+
521+
it('⭐ control: inputs that converted nothing refuse with an empty record — stamped', () => {
522+
const error = thrown(() =>
523+
composeStacks([build('za', 'subtitle', 'zz'), build('zb', 'subtitle', 'zz')] as ObjectStackDefinition[]),
524+
);
525+
expect(error.code).toBe('STACK_COMPOSE_OBJECT_CONFLICT');
526+
expect(stackConversionsOf(error)).toEqual([]);
527+
expect(ownRecord(error)?.value).toEqual([]);
528+
});
529+
530+
it('a non-refusal throw is not given a record: the options parse error carries none', () => {
531+
const a = build('oa', 'description');
532+
const b = build('ob', 'subtitle');
533+
const error = thrown(() =>
534+
composeStacks([a, b] as ObjectStackDefinition[], { objectConflict: 'nope' } as never),
535+
);
536+
expect(error.code, 'not an ADR-0112 refusal').toBeUndefined();
537+
expect(ownRecord(error)).toBeUndefined();
538+
expect(stackConversionsOf(error)).toEqual([]);
539+
});
540+
});
541+
542+
describe('stackConversionsOf reads a refusal’s record and nothing that merely looks like one', () => {
543+
it('an Error nobody stamped answers []', () => {
544+
expect(stackConversionsOf(new Error('x'))).toEqual([]);
545+
});
546+
547+
it('a record inherited through the prototype, not stamped on the error itself, is not read', () => {
548+
quiet();
549+
const refusal = thrown(() =>
550+
defineStack({ ...source('rf', 'description'), requires: ['no-such-capability'] } as never),
551+
);
552+
expect(stackConversionsOf(refusal), 'anti-vacuity: the stamped refusal is read').toHaveLength(1);
553+
const heir = Object.create(refusal) as Error;
554+
expect(heir).toBeInstanceOf(Error);
555+
expect(stackConversionsOf(heir)).toEqual([]);
556+
});
557+
});

0 commit comments

Comments
 (0)