Skip to content

Commit f34dda6

Browse files
os-steveclaude
andauthored
spec: declaresCollection reads a pipe's authorable side, so a preprocess-wrapped collection key cannot silently leave the merge refusal set (#19150) (#19314)
Fixes #19150 Clause-②: no `declaresCollection` (`packages/spec/src/stack.zod.ts`) read only `def.in` on its `pipe` arm, so a `z.preprocess`-wrapped collection key resolved to a `transform` node, fell through to `default: return false`, and silently left the key set `objectConflict: 'merge'` refuses to combine (#14848). ⭐ **No current behaviour is wrong and none changes here.** `objectCollectionKeys()` skips `fields` by name, and measured over all 43 top-level keys of `ObjectSchema` the derived refusal set is identical before and after. This is a finding fixed before it can bite, not a regression report. ## 1. The census — what the card asked for FIRST The card records this as NOT measured: "whether any OTHER `packages/spec` walker carries the same `pipe` arm … there were two copies of this arm and only one is fixed, which is a rate, not an anecdote." Scanned 6890 tracked TS/JS files (`node_modules/`, `dist/` excluded) on `origin/main` at `e6a03e6491` for every site that DISPATCHES on a zod `pipe` node — `case 'pipe'`, `type === 'pipe'`, `instanceof z.ZodPipe`. **13 sites**, each classified by hand from its arm: | reading | count | sites | |:---|:---|:---| | IN only | 4 | `spec/src/stack.zod.ts:3415` · `spec/src/compose-stacks-merge-collection-refusal.test.ts:222` · `lint/src/component-field-specs-liveness.test.ts:68` · `spec/src/ui/component.test.ts:2907` | | transform-discriminated | 5 | `spec/scripts/lib/zod-graph.ts:232` (`pipeAuthorableSide`, the canonical one) · `spec/scripts/liveness/check-liveness.mts:592` · `spec/scripts/liveness/tombstoned-row-status.test.ts:101` · `spec/src/kernel/metadata-authoring-lint.ts:134` · `spec/src/system/metadata-form-zod-reconciliation.test.ts:172` | | both sides | 2 | `spec/src/kernel/metadata-type-schemas.test.ts:128` (union of both) · `:558` (OUT first, then IN) | | pin / delegating, no side read of its own | 2 | `spec/scripts/zod-graph.test.ts:182` (the pin ON `pipeAuthorableSide`) · `lint/src/validate-predicate-path-refs.ts:369` counted above as transform-discriminated | Both known targets fire, which is the ruler check the card asked for: `stack.zod.ts` (this card) and the test-side copy. **Three corrections the census produces:** 1. **The test-side copy is NOT fixed on `main`.** `compose-stacks-merge-collection-refusal.test.ts:222` still reads `isCollection(def!.in, …)` at `e6a03e6491`. The card's "already fixed one file over" describes PR #19147's BRANCH, which is still open and draft. ⛔ Untouched here on purpose — that file is #19147's surface. 2. **The other two IN-only sites fail LOUD, not silent, so they are not instances of this card's class.** `component-field-specs-liveness.test.ts` records `"TYPE: props schema has no resolvable object shape"` (the type name, then that sentence) as a violation when the walk reaches no shape; `component.test.ts:2907` reads `.shape.properties` off the result and would throw. Neither can go quietly green on a preprocess-wrapped input. They are noted below, not filed. 3. **The rate, stated plainly:** of 13 pipe walkers, 2 carry this arm in a position where it fails SILENTLY — the production derivation and its test twin, i.e. both copies of one question — and this PR fixes the production one. The remaining 9 already read the pipe correctly, and 5 of them run the exact rule adopted here. ## 2. The fix shape — measured, then chosen The card deliberately left three candidates open. The landed rule reads **OUT only when IN unwraps to a transform stage**: ``` case 'pipe': return declaresCollection(pipeAuthorableSide(def), depth + 1); ``` - **Why not `in || out`** (the shape #19147 applied test-side): for a genuine `a.transform(fn).pipe(b)` the author writes `a`. `z.string().transform((s) => s.split(',')).pipe(z.array(z.string()))` is a key whose AUTHORED value is a scalar and whose parsed value is an array; `in || out` puts it in a refusal set that then tells the author their scalar is a collection whose entries would be dropped. Pinned as a dark-control assertion, not argued in prose: `eitherSideWalk` answers `true` for that shape, the landed rule answers `false`, and `composeStacks` composes it by later-wins. - **Why not "refuse to walk a transform"**: this walk runs inside `composeStacks` at author time; the derivation's job is to answer a structural question about every key, and a throw on a shape that is legal today would convert a silent gap into an outage. - **Why this one**: it is already the rule at four sibling sites (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts` since #5317, `metadata-authoring-lint.ts` and `metadata-form-zod-reconciliation.test.ts` since #5074, `packages/lint`'s `validate-predicate-path-refs.ts`), each carrying the #4488 citation. Adopting it makes this a fifth SITE of one rule rather than a fifth dialect. The unwrap before the transform test is load-bearing and is pinned: a transform one level down is still a transform. ## 3. The measurement, per key `ObjectSchema.shape` — 43 top-level keys, read off the built package: - pipe-shaped top-level keys: **1** — `titleFormat`, `optional > union[ pipe(in=string, out=transform) | object ]`, an `a.transform(fn)` pipe carrying a scalar. - keys whose verdict differs between the old reading, the landed reading and the declined `in || out`: **0 of 43**. - derived refusal set, identical under all three: `indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions` (9 keys). - `fields` is a plain `record` on `main` today and is excluded by NAME either way, so its own reading cannot move the set. After #19147 wraps it in `z.preprocess` its reading changes (IN-only `false`, authorable-side `true`) and the set is still unmoved, because the exclusion is by name. That invariant is an ASSERTION, not a claim in this body: `compose-stacks-collection-pipe-arm.test.ts`'s last block derives the set under all three readings from the unmocked shape and fails the day they stop agreeing — which is the day this fix starts doing observable work. ## 4. Tests — bright / main / dark, driven through the real production walk `declaresCollection` is internal and today's shape has no preprocess-wrapped collection key, so a pin written against the shape alone cannot tell a fixed walker from an unfixed one. The new file mounts three probe keys on `ObjectSchema.shape` through `vi.mock` — the only input `objectCollectionKeys()` reads — and drives them through `composeStacks` itself: - **anti-vacuity** — the probes really are the node shapes claimed (`pipe` with `in=transform, out=array`; and a `pipe` whose IN is itself the `.transform()` pipe). - **BRIGHT CONTROL** — the IN-only reading of the preprocess probe answers "not a collection"; the authorable-side reading answers "collection"; and the same holds when the transform sits behind a `prefault` wrapper. - **MAIN** — `composeStacks` refuses two differing declarations of that key, and the refusal message ENUMERATES the derived set, so the set change is read per key: the probe key joins, and the nine keys that were there before are still there, in order. Identical declarations still compose. - **DARK CONTROL** — the `.pipe()` probe and a plain scalar both compose by later-wins, unchanged; `actions` is still refused exactly as before; and `in || out` is pinned as the reading that WOULD have moved the `.pipe()` probe. Ablation (one-shot, on the committed state, `scripts/ablation-replace.mjs`): the arm reverted to `declaresCollection(def.in, depth + 1)`, mutation proven on disk (anchor `1 -> 0`, blob `bdb4aa8c12bc -> 82b7d2ba3774`, `grep -c` of the injected text `1` and of the removed text `0`) — **2 tests fail, both of them the MAIN leg**, with the other 12 green, which is the expected direction: the bright and dark legs do not depend on the fix. Restored by the same tool, verified `blob == HEAD (bdb4aa8)` and `git diff HEAD` empty. `dist/` is not on the resolution path here — the subject is reached by a same-package relative import from the test — so the rebuild-to-dist preflight does not apply and no dist marker was involved. Runs (all on `8c50307884`, this PR's head; shared box, so seconds are contention figures): - `pnpm --filter @objectstack/spec test` — **501 files / 14657 tests passed**, exit 0. - `pnpm --filter @objectstack/spec typecheck` — exit 0 (`tsc --noEmit` + scripts + test layer). - `pnpm --filter @objectstack/spec check:generated` — all 16 generated artifacts up to date; nothing to regenerate. - `pnpm lint` (repo-wide `eslint . --no-inline-config`) — exit 0, no narrowing claimed. - `scripts/pm/dispatch-gates.mjs --ran` — **80 derived families accounted for: 77 run green, 3 NOT MEASURED** (`check:type-check-debt`, `check:lean-entry-closure`, `check:dual-build-cjs-loads` — each exits 3 PREREQUISITE NOT MET without a full workspace build, which CI does first; none is a finding). - Dependency-closure build (①) is empty: `@objectstack/spec` declares no workspace dependency, so `pnpm --filter '@objectstack/spec^...' build` matches no project. ## 5. Clause-② — the push-back the dispatch asked for > ⭐ **Seat ruling, 2026-09-20T10:59Z — arm B taken.** The `domain:spec` seat 4 dispatch declared `Clause-②: yes`; this dev measured that published behaviour does not move by one row (0 of 43 `ObjectSchema` top-level key verdicts change, the derived refusal set is byte-identical, no export added or removed) and pushed back. The seat adopted the measurement and **re-declared `no`** — the card's claim comment carries the correction in place (`5749346170`), and line 3 of this body is edited to match, so the two carriers agree. ⛔ Over-declaring to stay on the safe side is the pathology #19099 documents; the reading governs. > > ⚠️ `check-widening-tells --declaration no` then exited **4** with **7 T2 tells** at `packages/spec/src/stack.zod.ts:3412-3418`. The dev did ⛔ not flip back to `yes` and did ⛔ not touch the matcher, which is correct. The tells are FALSE and the mechanism is named in the card follow-up (`5749357966`): T2's own sentence judges a new member of a **closed set** (`z.enum`, `z.union`, `z.discriminatedUnion`, or a `CORE_PLUGIN_TYPES`-shaped `as const` array) and this construct is none of the four — it is a `new Set([...])` of zod **internal node-type discriminants**, the same seven already standing as `case` labels in the very function this diff edits. What fired is the line-level `BARE_STRING_ELEMENT` matcher, which does not require one of the four openers above it. That matcher repair is ⛔ out of this PR's file surface and is reported as a finding. ⚠️ **Seat correction, 2026-09-20T14:31Z — the paragraph below describes the SUPERSEDED declaration.** It was written while the dispatch's `Clause-②: yes` still stood and was left in place when the 10:59Z ruling above re-declared `no`. Both of its claims are false at this head, measured rather than inferred: line 3 of this body reads `Clause-②: no`, and `.changeset/19150-declares-collection-pipe-authorable-side.md` grades `'@objectstack/spec': patch`, not `minor`. What survives from it is the path limb alone — `SUSPECT_TIER_GLOBS` = `packages/spec/src/**` makes this a contract-surface PR regardless of any declaration, which is why the lane owes the at-tier contract review that is now on record (comment `5750417684`, `Head-sha: 7d67e1e…`, **VERDICT: PASS**, `Clause-②: no` upheld by independent re-derivation). Kept rather than deleted, because a body that quietly loses what it once claimed is worse than one that carries its own correction: > ~~Declared `yes`, copied from the claim comment, and the path limb (`SUSPECT_TIER_GLOBS` = `packages/spec/src/**`) makes this a contract-surface PR regardless of any declaration. The changeset is graded `minor` because `check-changeset-no-major` requires at least one `minor`+ package from a `yes` PR.~~ ⭐ **The reading the dispatch asked for, and it points the other way:** published behaviour does not move by one row. 0 of 43 key verdicts change, the refusal set is identical, no export is added or removed (`check:api-surface` green), and no authored metadata changes meaning. By the gate's own words for clause ② — "this PR puts a new key on a published payload" — nothing here does. If the seat accepts that reading, the downgrade is three coordinated edits (the card's claim line, this body's line, and the changeset level) and is the PM's to make, not a dev's unilateral carrier split. ## Acceptance notes Out of scope, noted and NOT filed — neither is a reproducible defect, a declared-contract violation or a metadata-authoring trap: - `packages/lint/src/component-field-specs-liveness.test.ts:68` — `shapeOf` reads `def.in` only. A preprocess-wrapped `ComponentPropsMap` schema would make it record `"props schema has no resolvable object shape"` — a LOUD red, not a silent pass. Carrier: none today; no such schema exists. - `packages/spec/src/ui/component.test.ts:2907` — `def.in._zod.def.shape` on `PageComponentSchema` (`.strict().transform(…)`). Same shape, same loud failure (a TypeError on the next line). Carrier: none today. - **One divergence with a named carrier:** when #19147 lands, the sibling test's independent walk will read `in || out` while the production walk reads the authorable side. Measured on today's shape the two agree, and the invariance block above asserts it — but they are two rules answering one question, which is the drift the derivation exists to avoid. The one-line alignment belongs to whoever lands #19147, since that file is its surface today. Authored by Claude Code in session `session_01AmH9bKvGoLjiY86Q4Z3og2`; attribution is repeated in prose because the platform rewrites the footer block on some write channels. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8fc6a5f commit f34dda6

3 files changed

Lines changed: 431 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `declaresCollection` reads a `pipe` on the side the author writes, so a `z.preprocess`-wrapped collection key cannot silently leave the `objectConflict: 'merge'` refusal set (#19150)
6+
7+
Clause-②: no
8+
9+
`objectCollectionKeys()` derives — never transcribes — the object-level keys `composeStacks({ objectConflict: 'merge' })` refuses to combine (#14848), and the reason it derives them is written into its own docblock: a hand-written list "would fail in the silent direction: a collection key added to the object schema tomorrow would fall back to the wholesale replacement this rule exists to refuse". The walker behind it reintroduced exactly that silent direction through the derivation itself.
10+
11+
`declaresCollection`'s `pipe` arm read only `def.in`. Two constructs compile to the same `pipe` node with OPPOSITE authorable sides: `a.transform(fn)` keeps the accepted input shape in `in`, while `z.preprocess(fn, schema)` puts the transform STAGE in `in` and the real, validated schema in `out`. A preprocess-wrapped collection key therefore resolved to a `transform` node, fell through to `default: return false`, and left the refusal set with nothing anywhere reporting it — the failure shape being a wholesale replacement where a refusal was owed.
12+
13+
The arm now reads `out` only when `in` unwraps to a transform stage, which is the rule four sibling walkers in this tree already run (`pipeAuthorableSide` in `scripts/lib/zod-graph.ts`, `kernel/metadata-authoring-lint.ts`, `system/metadata-form-zod-reconciliation.test.ts`, and `packages/lint`'s `validate-predicate-path-refs.ts`) rather than a fifth dialect.
14+
15+
- **`in || out` was measured and declined.** For a genuine `a.transform(fn).pipe(b)` the author writes `a`; reading either side pulls a key whose authored value is a scalar into a refusal set that then names it a collection. The landed rule leaves every `.pipe()` verdict where it was, by construction rather than by fixture choice.
16+
- **No authored metadata changes meaning and no key changes its verdict on today's shape.** Measured over all 43 top-level keys of `ObjectSchema`: exactly one compiles to a `pipe` (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), and the derived refusal set is byte-identical under the old reading, the landed one and the declined candidate. The invariant is asserted, not claimed: `compose-stacks-collection-pipe-arm.test.ts` fails the day it stops holding.
17+
- **`fields` keeps its exclusion by name.** It is the one collection `'merge'` merges by shallow spread, so its own reading cannot move the set either way.
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19150] `declaresCollection` reads a `pipe` on the side the AUTHOR writes.
5+
*
6+
* The walker behind `objectCollectionKeys()` — the key set
7+
* `objectConflict: 'merge'` refuses to combine (#14848) — read only `def.in`
8+
* on its `pipe` arm. `z.preprocess(fn, schema)` puts a transform STAGE in `in`
9+
* and the real, validated schema in `out`, the opposite of `a.transform(fn)`,
10+
* so a preprocess-wrapped collection key resolved to a `transform` node, fell
11+
* through to `default: return false`, and left the refusal set in silence —
12+
* the exact failure direction the derivation exists to close ("a collection
13+
* key added to the object schema tomorrow would fall back to the wholesale
14+
* replacement this rule exists to refuse").
15+
*
16+
* ## Why this file exists beside `compose-stacks-merge-collection-refusal.test.ts`
17+
*
18+
* That file pins the refusal set against `ObjectSchema`'s shape AS IT STANDS.
19+
* Measured on this tree, exactly one of the 43 top-level keys compiles to a
20+
* pipe (`titleFormat`, an `a.transform(fn)` pipe carrying a scalar), so the
21+
* shape as authored today cannot tell a fixed walker from an unfixed one —
22+
* a pin written only against it would be green either way. The probe keys
23+
* below are the missing discrimination: a schema shaped like the one the next
24+
* author will write, walked by the REAL production code through
25+
* `composeStacks`.
26+
*
27+
* ## The three legs
28+
*
29+
* - BRIGHT CONTROL — the IN-only reading of the preprocess probe (the arm as
30+
* it stood before #19150) resolves to a `transform` and answers "not a
31+
* collection". Kept as executable text so the defect stays legible.
32+
* - MAIN — the same key, walked by the production code, is now IN the refusal
33+
* set: `composeStacks` refuses two differing declarations and its message
34+
* ENUMERATES the derived set, so the set change is read per key rather than
35+
* asserted in prose.
36+
* - DARK CONTROL — a genuine `.pipe()` whose authored side is a scalar and
37+
* whose OUT side is an array stays OUT of the set, and so does a plain
38+
* scalar. This is the leg that discriminates the landed rule from the
39+
* `in || out` candidate: `in || out` would pull that key IN, and the author
40+
* would be told their scalar is a collection.
41+
*
42+
* ## Today-invariance
43+
*
44+
* The last block asserts, against the UNMOCKED `ObjectSchema`, that all three
45+
* candidate readings agree on every top-level key — i.e. this change moves no
46+
* key on today's shape, and `fields` (the one collection `'merge'` merges, and
47+
* the key PR #19147 wraps in `z.preprocess`) is excluded by NAME either way.
48+
* It is written to go RED the day that stops being true, which is the day the
49+
* fix starts doing observable work; the remedy then is to re-measure and
50+
* re-state the invariant, never to relax the assertion.
51+
*/
52+
53+
import { describe, it, expect, vi } from 'vitest';
54+
import { z } from 'zod';
55+
56+
const NAMES = vi.hoisted(() => ({
57+
/** `z.preprocess(fn, z.array(...))` — IN is the transform, OUT is the array. */
58+
preprocess: 'probePreprocessCollection',
59+
/** `.transform(...).pipe(z.array(...))` — authored as a scalar, parsed to an array. */
60+
pipeScalar: 'probePipeScalarToArray',
61+
/** A plain scalar: the walk must not reach a collection by any route. */
62+
scalar: 'probeScalar',
63+
}));
64+
65+
// The probe keys ride on `ObjectSchema.shape` because that shape is the ONLY
66+
// input `objectCollectionKeys()` reads. Nothing else in the module graph is
67+
// replaced: the factory spreads the real module and the real shape.
68+
vi.mock('./data/object.zod', async (importOriginal) => {
69+
const actual = await importOriginal<typeof import('./data/object.zod')>();
70+
const { z: zod } = await import('zod');
71+
const patched = zod.object({
72+
...(actual.ObjectSchema.shape as Record<string, z.ZodType>),
73+
[NAMES.preprocess]: zod.preprocess((raw) => raw, zod.array(zod.string())).optional(),
74+
[NAMES.pipeScalar]: zod
75+
.string()
76+
.transform((s) => s.split(','))
77+
.pipe(zod.array(zod.string()))
78+
.optional(),
79+
[NAMES.scalar]: zod.string().optional(),
80+
});
81+
return { ...actual, ObjectSchema: patched };
82+
});
83+
84+
const { composeStacks, defineStack } = await import('./stack.zod');
85+
const { ObjectSchema } = await import('./data/object.zod');
86+
87+
// ── Independent walkers: the three candidate readings of a `pipe` ──────────
88+
//
89+
// Re-implemented here rather than imported — `declaresCollection` is internal,
90+
// and a pin that imports the subject cannot state what the subject REJECTED.
91+
92+
type Def = {
93+
type?: string;
94+
innerType?: unknown;
95+
in?: unknown;
96+
out?: unknown;
97+
options?: unknown[];
98+
getter?: () => unknown;
99+
};
100+
101+
/** `typeof === 'function'` included: `lazySchema` proxies are callable. */
102+
const defOf = (schema: unknown): Def | undefined =>
103+
schema === null || (typeof schema !== 'object' && typeof schema !== 'function')
104+
? undefined
105+
: (schema as { _zod?: { def?: Def } })._zod?.def;
106+
107+
const WRAPPERS = ['optional', 'nullable', 'default', 'prefault', 'readonly', 'nonoptional', 'catch'];
108+
109+
type Walk = (schema: unknown, depth?: number) => boolean;
110+
111+
function makeWalk(pipeArm: (def: Def, walk: Walk, depth: number) => boolean): Walk {
112+
const walk: Walk = (schema, depth = 0) => {
113+
if (depth > 8) return false;
114+
const def = defOf(schema);
115+
if (!def?.type) return false;
116+
if (def.type === 'array' || def.type === 'record') return true;
117+
if (WRAPPERS.includes(def.type)) return walk(def.innerType, depth + 1);
118+
if (def.type === 'lazy') return walk(def.getter?.(), depth + 1);
119+
if (def.type === 'pipe') return pipeArm(def, walk, depth);
120+
if (def.type === 'union') return (def.options ?? []).some((o) => walk(o, depth + 1));
121+
return false;
122+
};
123+
return walk;
124+
}
125+
126+
/** The arm as it stood before #19150 — the bright control. */
127+
const inOnlyWalk = makeWalk((def, walk, depth) => walk(def.in, depth + 1));
128+
129+
/** The candidate this change DECLINED (and the shape the sibling test walker took). */
130+
const eitherSideWalk = makeWalk((def, walk, depth) => walk(def.in, depth + 1) || walk(def.out, depth + 1));
131+
132+
/** The landed rule: OUT only when IN is a transform stage. */
133+
const authorableWalk = makeWalk((def, walk, depth) => {
134+
let node = def.in;
135+
for (let hops = 0; hops < 8; hops++) {
136+
const inner = defOf(node);
137+
if (!inner?.type) break;
138+
if (inner.type === 'transform') return walk(def.out, depth + 1);
139+
if (WRAPPERS.includes(inner.type)) {
140+
node = inner.innerType;
141+
continue;
142+
}
143+
if (inner.type === 'lazy') {
144+
node = inner.getter?.();
145+
continue;
146+
}
147+
break;
148+
}
149+
return walk(def.in, depth + 1);
150+
});
151+
152+
const shapeOf = (schema: unknown): Record<string, unknown> =>
153+
(schema as { shape: Record<string, unknown> }).shape;
154+
155+
const probe = (key: string): unknown => shapeOf(ObjectSchema)[key];
156+
157+
// ── Stack fixtures, `strict: false` so a probe key survives to composition ──
158+
159+
const mf = (id: string) => ({ id, name: id.split('.').pop()!, version: '1.0.0', type: 'app' as const });
160+
161+
const obj = (name: string, extra: Record<string, unknown> = {}) => ({
162+
name,
163+
label: name,
164+
fields: { title: { type: 'text' as const } },
165+
...extra,
166+
});
167+
168+
const stackWith = (id: string, extra: Record<string, unknown>) =>
169+
defineStack({ manifest: mf(id), objects: [obj('shared', extra)] }, { strict: false });
170+
171+
/** The thrown message, or `null` when the composition is accepted. */
172+
function refusal(fn: () => unknown): string | null {
173+
try {
174+
fn();
175+
return null;
176+
} catch (e) {
177+
return (e as Error).message;
178+
}
179+
}
180+
181+
const composedShared = (left: Record<string, unknown>, right: Record<string, unknown>) => {
182+
const out = composeStacks([stackWith('com.example.a', left), stackWith('com.example.b', right)], {
183+
objectConflict: 'merge',
184+
});
185+
return (out.objects ?? []).find((o) => o.name === 'shared') as Record<string, unknown> | undefined;
186+
};
187+
188+
const refuse = (key: string, left: unknown, right: unknown) =>
189+
refusal(() => composedShared({ [key]: left }, { [key]: right }));
190+
191+
describe('#19150 — the probe keys are the shapes this pin is about (anti-vacuity)', () => {
192+
it('the preprocess probe is a pipe whose IN is a transform and whose OUT is the array', () => {
193+
const def = defOf(defOf(probe(NAMES.preprocess))?.innerType);
194+
expect(def?.type).toBe('pipe');
195+
expect(defOf(def?.in)?.type).toBe('transform');
196+
expect(defOf(def?.out)?.type).toBe('array');
197+
});
198+
199+
it('the `.pipe()` probe is a pipe whose authored side is a scalar and whose OUT is an array', () => {
200+
const def = defOf(defOf(probe(NAMES.pipeScalar))?.innerType);
201+
expect(def?.type).toBe('pipe');
202+
// IN is itself the `.transform()` pipe — a pipe, NOT a transform stage, so
203+
// the authorable side stays IN and resolves to the authored `string`.
204+
expect(defOf(def?.in)?.type).toBe('pipe');
205+
expect(defOf(defOf(def?.in)?.in)?.type).toBe('string');
206+
expect(defOf(def?.out)?.type).toBe('array');
207+
});
208+
});
209+
210+
describe('#19150 BRIGHT CONTROL — the pre-fix reading of the preprocess probe', () => {
211+
it('reading IN alone answers "not a collection" — the silent direction', () => {
212+
expect(inOnlyWalk(probe(NAMES.preprocess))).toBe(false);
213+
});
214+
215+
it('the authorable-side reading answers "collection"', () => {
216+
expect(authorableWalk(probe(NAMES.preprocess))).toBe(true);
217+
});
218+
219+
it('resolves a transform stage that sits BEHIND a wrapper on the IN side', () => {
220+
// The shape `scripts/zod-graph.test.ts` pins for `pipeAuthorableSide`: the
221+
// unwrap before the transform test is load-bearing, because a transform one
222+
// level down is still a transform.
223+
const wrapped = z
224+
.transform((raw: unknown) => raw)
225+
.prefault('x')
226+
.pipe(z.array(z.string()));
227+
expect(inOnlyWalk(wrapped)).toBe(false);
228+
expect(authorableWalk(wrapped)).toBe(true);
229+
});
230+
});
231+
232+
describe('#19150 MAIN — the preprocess-wrapped collection key is IN the refusal set', () => {
233+
it('refuses two differing declarations, naming the object, the key and both stacks', () => {
234+
const msg = refuse(NAMES.preprocess, ['a'], ['b']);
235+
expect(msg).toContain(
236+
`composeStacks conflict: object 'shared' is defined in multiple stacks and its ` +
237+
`'${NAMES.preprocess}' is declared with different values by 'com.example.a' (stack #0) and ` +
238+
`'com.example.b' (stack #1).`,
239+
);
240+
});
241+
242+
it('the derived set the refusal ENUMERATES carries the key — the per-key reading', () => {
243+
const msg = refuse(NAMES.preprocess, ['a'], ['b']) ?? '';
244+
const enumerated = /Any other object-level collection \(([^)]*)\) is not merged/.exec(msg)?.[1] ?? '';
245+
const derived = enumerated.split(', ').filter(Boolean);
246+
expect(derived).toContain(NAMES.preprocess);
247+
// …and the keys it carried before this change are all still there, in order.
248+
expect(derived.filter((k) => k !== NAMES.preprocess)).toEqual([
249+
'indexes',
250+
'fieldGroups',
251+
'requiredPermissions',
252+
'validations',
253+
'activityMilestones',
254+
'highlightFields',
255+
'listViews',
256+
'searchableFields',
257+
'actions',
258+
]);
259+
});
260+
261+
it('identical declarations still compose — the refusal is about DIFFERING values only', () => {
262+
expect(refuse(NAMES.preprocess, ['a'], ['a'])).toBeNull();
263+
});
264+
});
265+
266+
describe('#19150 DARK CONTROL — keys whose verdict must not move', () => {
267+
it('a genuine `.pipe()` authored as a scalar composes by later-wins, and is NOT refused', () => {
268+
expect(refuse(NAMES.pipeScalar, 'a', 'b')).toBeNull();
269+
expect(composedShared({ [NAMES.pipeScalar]: 'a' }, { [NAMES.pipeScalar]: 'b' })?.[NAMES.pipeScalar]).toBe('b');
270+
});
271+
272+
it('`in || out` WOULD have moved that key — which is why the landed rule is not `in || out`', () => {
273+
expect(eitherSideWalk(probe(NAMES.pipeScalar))).toBe(true);
274+
expect(authorableWalk(probe(NAMES.pipeScalar))).toBe(false);
275+
expect(inOnlyWalk(probe(NAMES.pipeScalar))).toBe(false);
276+
});
277+
278+
it('a plain scalar key composes by later-wins', () => {
279+
expect(refuse(NAMES.scalar, 'a', 'b')).toBeNull();
280+
expect(composedShared({ [NAMES.scalar]: 'a' }, { [NAMES.scalar]: 'b' })?.[NAMES.scalar]).toBe('b');
281+
});
282+
283+
it('an ordinary collection key is refused exactly as before', () => {
284+
const msg = refuse('actions', [{ name: 'approve' }], [{ name: 'archive' }]);
285+
expect(msg).toContain("its 'actions' is declared with different values");
286+
});
287+
});
288+
289+
describe("#19150 TODAY-INVARIANCE — the fix moves no key on today's ObjectSchema", () => {
290+
const realShape = async (): Promise<Record<string, unknown>> => {
291+
const actual = await vi.importActual<typeof import('./data/object.zod')>('./data/object.zod');
292+
return shapeOf(actual.ObjectSchema);
293+
};
294+
295+
const setUnder = (shape: Record<string, unknown>, walk: Walk) =>
296+
Object.keys(shape).filter((key) => key !== 'fields' && walk(shape[key]));
297+
298+
it('all three candidate readings derive the SAME refusal set', async () => {
299+
const shape = await realShape();
300+
const inOnly = setUnder(shape, inOnlyWalk);
301+
expect(setUnder(shape, authorableWalk), 'the landed rule moved a key on the real shape').toEqual(inOnly);
302+
expect(setUnder(shape, eitherSideWalk), '`in || out` would move a key on the real shape').toEqual(inOnly);
303+
expect(inOnly).toEqual([
304+
'indexes',
305+
'fieldGroups',
306+
'requiredPermissions',
307+
'validations',
308+
'activityMilestones',
309+
'highlightFields',
310+
'listViews',
311+
'searchableFields',
312+
'actions',
313+
]);
314+
});
315+
316+
it("'fields' is excluded by NAME, so its own reading cannot move the set either way", async () => {
317+
const shape = await realShape();
318+
expect(Object.keys(shape)).toContain('fields');
319+
expect(setUnder(shape, inOnlyWalk)).not.toContain('fields');
320+
expect(setUnder(shape, authorableWalk)).not.toContain('fields');
321+
});
322+
});

0 commit comments

Comments
 (0)