rahman-resources 0.9.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ // Type definitions for the 3-way semantic slice-element merge engine.
2
+ // Runtime in merge3.mjs.
3
+
4
+ import type { SliceContract } from "./contract";
5
+
6
+ export interface SliceSnapshot {
7
+ /** Slice slug, kebab-case. */
8
+ slug: string;
9
+ /** Semver of the snapshot. */
10
+ version: string;
11
+ /** Map of relative path → file content. */
12
+ files: Record<string, string>;
13
+ /** Parsed contract, if a slice.contract.ts was present. */
14
+ contract?: SliceContract;
15
+ }
16
+
17
+ export interface MergeRequest {
18
+ /** Common ancestor — last kitab version the consumer adopted. */
19
+ base: SliceSnapshot;
20
+ /** Latest kitab version. */
21
+ kitab: SliceSnapshot;
22
+ /** Consumer's current state — may have local edits. */
23
+ consumer: SliceSnapshot;
24
+ }
25
+
26
+ export type MergeOutcomeKind =
27
+ | "auto-merged"
28
+ | "consumer-wins-clean"
29
+ | "kitab-wins-clean"
30
+ | "conflict"
31
+ | "identical";
32
+
33
+ export interface ElementOutcome {
34
+ /** Semantic key — e.g. "files/page.tsx", "contract.requires.env",
35
+ * "contract.provides.tables". For set elements, the suffix encodes the
36
+ * member (e.g. "contract.provides.tables:auth_users"). */
37
+ element: string;
38
+ kind: MergeOutcomeKind;
39
+ baseValue?: string | string[] | null;
40
+ kitabValue?: string | string[] | null;
41
+ consumerValue?: string | string[] | null;
42
+ /** The merged value, present when kind !== "conflict". */
43
+ mergedValue?: string | string[] | null;
44
+ /** Human-readable suggestion when kind === "conflict". */
45
+ conflictHint?: string;
46
+ }
47
+
48
+ export interface MergeSummary {
49
+ autoMerged: number;
50
+ kitabWinsClean: number;
51
+ consumerWinsClean: number;
52
+ conflicts: number;
53
+ identical: number;
54
+ }
55
+
56
+ export interface MergeReport {
57
+ slug: string;
58
+ outcomes: ElementOutcome[];
59
+ summary: MergeSummary;
60
+ /** 0-100, 0 = perfectly synced, higher = more divergence. */
61
+ driftAfterMerge: number;
62
+ /** Present only when no conflicts (clean auto-merge). */
63
+ mergedSnapshot?: SliceSnapshot;
64
+ }
65
+
66
+ export function merge3(req: MergeRequest): MergeReport;
67
+ export function applyMerge(report: MergeReport, targetDir: string): Promise<void>;
package/lib/merge3.mjs ADDED
@@ -0,0 +1,431 @@
1
+ // merge3.mjs — Slice Composition Compiler Phase D.
2
+ //
3
+ // 3-way semantic merge engine. Operates on `SliceSnapshot` trios (base / kitab
4
+ // / consumer) and produces a structured `MergeReport`. Unlike a raw text
5
+ // merge, the unit of conflict resolution is a slice element:
6
+ // - a single file (path → content)
7
+ // - a single contract surface entry (env var, RBAC permission, route, …)
8
+ //
9
+ // Algorithm overview, per element:
10
+ // base === kitab === consumer → identical
11
+ // kitab changed, consumer unchanged → auto-merged (apply kitab)
12
+ // kitab unchanged, consumer changed → consumer-wins-clean
13
+ // both changed (and differ from each other) → conflict
14
+ //
15
+ // Files / contract sets use the same three-way logic, with kind-specific
16
+ // hints for the conflict messages so a human reviewer can act.
17
+ //
18
+ // Types live in merge3.d.ts. The module is dependency-free node:fs plus
19
+ // node:path — no third-party libs.
20
+
21
+ import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
22
+ import path from "node:path";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Public API
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * @param {import("./merge3").MergeRequest} req
30
+ * @returns {import("./merge3").MergeReport}
31
+ */
32
+ export function merge3(req) {
33
+ if (!req || typeof req !== "object") {
34
+ throw new Error("merge3: request object required");
35
+ }
36
+ const { base, kitab, consumer } = req;
37
+ for (const [name, snap] of [
38
+ ["base", base],
39
+ ["kitab", kitab],
40
+ ["consumer", consumer],
41
+ ]) {
42
+ if (!snap || typeof snap !== "object") {
43
+ throw new Error(`merge3: ${name} snapshot required`);
44
+ }
45
+ if (typeof snap.slug !== "string" || !snap.slug) {
46
+ throw new Error(`merge3: ${name}.slug required`);
47
+ }
48
+ if (!snap.files || typeof snap.files !== "object") {
49
+ throw new Error(`merge3: ${name}.files must be an object`);
50
+ }
51
+ }
52
+ if (base.slug !== kitab.slug || kitab.slug !== consumer.slug) {
53
+ throw new Error(
54
+ `merge3: slug mismatch — base="${base.slug}" kitab="${kitab.slug}" consumer="${consumer.slug}"`,
55
+ );
56
+ }
57
+
58
+ const outcomes = [];
59
+
60
+ // ── Files ──────────────────────────────────────────────────────────────
61
+ const allPaths = new Set([
62
+ ...Object.keys(base.files),
63
+ ...Object.keys(kitab.files),
64
+ ...Object.keys(consumer.files),
65
+ ]);
66
+ const sortedPaths = [...allPaths].sort();
67
+ for (const p of sortedPaths) {
68
+ outcomes.push(diffFile(p, base.files[p], kitab.files[p], consumer.files[p]));
69
+ }
70
+
71
+ // ── Contract surfaces ──────────────────────────────────────────────────
72
+ // Each is a set of strings that we three-way-merge with set semantics.
73
+ const SET_KEYS = [
74
+ ["contract.requires.env", (c) => c?.requires?.env],
75
+ ["contract.requires.rbac", (c) => c?.requires?.rbac],
76
+ ["contract.requires.deps", (c) => c?.requires?.deps],
77
+ ["contract.provides.tables", (c) => c?.provides?.tables],
78
+ ["contract.provides.routes", (c) => c?.provides?.routes],
79
+ ["contract.provides.hooks", (c) => c?.provides?.hooks],
80
+ ["contract.provides.components", (c) => c?.provides?.components],
81
+ ["contract.provides.events", (c) => c?.provides?.events],
82
+ ];
83
+
84
+ for (const [keyPath, accessor] of SET_KEYS) {
85
+ const bSet = toSet(accessor(base.contract));
86
+ const kSet = toSet(accessor(kitab.contract));
87
+ const cSet = toSet(accessor(consumer.contract));
88
+ const members = new Set([...bSet, ...kSet, ...cSet]);
89
+ for (const member of [...members].sort()) {
90
+ outcomes.push(
91
+ diffSetMember(`${keyPath}:${member}`, member, bSet.has(member), kSet.has(member), cSet.has(member)),
92
+ );
93
+ }
94
+ }
95
+
96
+ // ── Summary ────────────────────────────────────────────────────────────
97
+ const summary = {
98
+ autoMerged: 0,
99
+ kitabWinsClean: 0,
100
+ consumerWinsClean: 0,
101
+ conflicts: 0,
102
+ identical: 0,
103
+ };
104
+ for (const o of outcomes) {
105
+ switch (o.kind) {
106
+ case "auto-merged":
107
+ summary.autoMerged++;
108
+ break;
109
+ case "kitab-wins-clean":
110
+ summary.kitabWinsClean++;
111
+ break;
112
+ case "consumer-wins-clean":
113
+ summary.consumerWinsClean++;
114
+ break;
115
+ case "conflict":
116
+ summary.conflicts++;
117
+ break;
118
+ case "identical":
119
+ summary.identical++;
120
+ break;
121
+ }
122
+ }
123
+
124
+ const total = outcomes.length || 1;
125
+ const driftAfterMerge = Math.round(
126
+ (100 * (summary.conflicts + summary.consumerWinsClean)) / total,
127
+ );
128
+
129
+ /** @type {import("./merge3").MergeReport} */
130
+ const report = {
131
+ slug: kitab.slug,
132
+ outcomes,
133
+ summary,
134
+ driftAfterMerge,
135
+ };
136
+
137
+ if (summary.conflicts === 0) {
138
+ report.mergedSnapshot = buildMergedSnapshot(req, outcomes);
139
+ }
140
+
141
+ return report;
142
+ }
143
+
144
+ /**
145
+ * Write merged files to a target directory. Throws if the report contains
146
+ * any conflicts (no clean snapshot to apply).
147
+ *
148
+ * @param {import("./merge3").MergeReport} report
149
+ * @param {string} targetDir
150
+ */
151
+ export async function applyMerge(report, targetDir) {
152
+ if (!report || typeof report !== "object") {
153
+ throw new Error("applyMerge: report required");
154
+ }
155
+ if (report.summary.conflicts > 0) {
156
+ throw new Error(
157
+ `applyMerge: refusing to apply — ${report.summary.conflicts} conflict(s) remain in slice "${report.slug}". Resolve them first or pass --force.`,
158
+ );
159
+ }
160
+ if (!report.mergedSnapshot) {
161
+ throw new Error("applyMerge: report has no mergedSnapshot (was the merge clean?)");
162
+ }
163
+ if (!targetDir || typeof targetDir !== "string") {
164
+ throw new Error("applyMerge: targetDir required");
165
+ }
166
+
167
+ const merged = report.mergedSnapshot.files;
168
+ // First write/overwrite all merged paths.
169
+ for (const [rel, content] of Object.entries(merged)) {
170
+ const dest = path.join(targetDir, rel);
171
+ mkdirSync(path.dirname(dest), { recursive: true });
172
+ writeFileSync(dest, content);
173
+ }
174
+ // Then delete files that were dropped in the merged snapshot. We infer
175
+ // "dropped" from file outcomes whose mergedValue is null but baseValue or
176
+ // consumerValue existed.
177
+ for (const o of report.outcomes) {
178
+ if (!o.element.startsWith("files/")) continue;
179
+ if (o.mergedValue === null && (o.baseValue != null || o.consumerValue != null)) {
180
+ const rel = o.element.slice("files/".length);
181
+ const dest = path.join(targetDir, rel);
182
+ if (existsSync(dest)) rmSync(dest, { force: true });
183
+ }
184
+ }
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Internals
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function diffFile(pathRel, baseVal, kitabVal, consumerVal) {
192
+ const bExists = baseVal !== undefined;
193
+ const kExists = kitabVal !== undefined;
194
+ const cExists = consumerVal !== undefined;
195
+
196
+ // Normalize to null when absent for the report payload, since `undefined`
197
+ // round-trips poorly through JSON.
198
+ const out = {
199
+ element: `files/${pathRel}`,
200
+ kind: "identical",
201
+ baseValue: bExists ? baseVal : null,
202
+ kitabValue: kExists ? kitabVal : null,
203
+ consumerValue: cExists ? consumerVal : null,
204
+ };
205
+
206
+ if (!bExists && !kExists && !cExists) {
207
+ // unreachable in practice, but keeps the function total
208
+ out.mergedValue = null;
209
+ return out;
210
+ }
211
+
212
+ // Case: only kitab has it → kitab added (consumer never saw it).
213
+ if (!bExists && kExists && !cExists) {
214
+ out.kind = "auto-merged";
215
+ out.mergedValue = kitabVal;
216
+ return out;
217
+ }
218
+ // Case: only consumer has it → consumer added locally.
219
+ if (!bExists && !kExists && cExists) {
220
+ out.kind = "consumer-wins-clean";
221
+ out.mergedValue = consumerVal;
222
+ return out;
223
+ }
224
+ // Case: kitab and consumer both added it (no base). Identical adds → ok,
225
+ // differing adds → conflict.
226
+ if (!bExists && kExists && cExists) {
227
+ if (kitabVal === consumerVal) {
228
+ out.kind = "identical";
229
+ out.mergedValue = kitabVal;
230
+ return out;
231
+ }
232
+ out.kind = "conflict";
233
+ out.conflictHint = `both kitab and consumer added "${pathRel}" with different contents`;
234
+ return out;
235
+ }
236
+ // Case: kitab removed (base had it, kitab doesn't).
237
+ if (bExists && !kExists) {
238
+ if (!cExists) {
239
+ // both sides removed → identical removal
240
+ out.kind = "identical";
241
+ out.mergedValue = null;
242
+ return out;
243
+ }
244
+ if (consumerVal === baseVal) {
245
+ // consumer untouched, kitab dropped → apply removal
246
+ out.kind = "auto-merged";
247
+ out.mergedValue = null;
248
+ return out;
249
+ }
250
+ // kitab removed, consumer modified → conflict
251
+ out.kind = "conflict";
252
+ out.conflictHint = `kitab removed "${pathRel}"; consumer modified it — keep or drop?`;
253
+ return out;
254
+ }
255
+ // Case: consumer removed (base had it, kitab still does, consumer dropped).
256
+ if (bExists && kExists && !cExists) {
257
+ if (kitabVal === baseVal) {
258
+ // consumer-only deletion — respect it
259
+ out.kind = "consumer-wins-clean";
260
+ out.mergedValue = null;
261
+ return out;
262
+ }
263
+ // consumer removed, kitab modified → conflict
264
+ out.kind = "conflict";
265
+ out.conflictHint = `consumer removed "${pathRel}"; kitab modified it — re-add or drop?`;
266
+ return out;
267
+ }
268
+ // Case: all three exist. Classic 3-way diff.
269
+ if (baseVal === kitabVal && baseVal === consumerVal) {
270
+ out.kind = "identical";
271
+ out.mergedValue = baseVal;
272
+ return out;
273
+ }
274
+ if (baseVal !== kitabVal && baseVal === consumerVal) {
275
+ out.kind = "auto-merged";
276
+ out.mergedValue = kitabVal;
277
+ return out;
278
+ }
279
+ if (baseVal === kitabVal && baseVal !== consumerVal) {
280
+ out.kind = "consumer-wins-clean";
281
+ out.mergedValue = consumerVal;
282
+ return out;
283
+ }
284
+ // Both differ from base — coincidentally equal? then identical-merge.
285
+ if (kitabVal === consumerVal) {
286
+ out.kind = "identical";
287
+ out.mergedValue = kitabVal;
288
+ return out;
289
+ }
290
+ out.kind = "conflict";
291
+ out.conflictHint = `both kitab and consumer modified "${pathRel}"; review needed`;
292
+ return out;
293
+ }
294
+
295
+ function diffSetMember(elementKey, member, inBase, inKitab, inConsumer) {
296
+ const out = {
297
+ element: elementKey,
298
+ kind: "identical",
299
+ baseValue: inBase ? member : null,
300
+ kitabValue: inKitab ? member : null,
301
+ consumerValue: inConsumer ? member : null,
302
+ };
303
+
304
+ // Already present everywhere it should be → identical
305
+ if (inBase && inKitab && inConsumer) {
306
+ out.kind = "identical";
307
+ out.mergedValue = member;
308
+ return out;
309
+ }
310
+ if (!inBase && !inKitab && !inConsumer) {
311
+ // unreachable
312
+ out.mergedValue = null;
313
+ return out;
314
+ }
315
+
316
+ // Added in kitab only, consumer hasn't seen it
317
+ if (!inBase && inKitab && !inConsumer) {
318
+ out.kind = "auto-merged";
319
+ out.mergedValue = member;
320
+ return out;
321
+ }
322
+ // Added in consumer only
323
+ if (!inBase && !inKitab && inConsumer) {
324
+ out.kind = "consumer-wins-clean";
325
+ out.mergedValue = member;
326
+ return out;
327
+ }
328
+ // Added in both kitab and consumer independently (same value) — identical
329
+ if (!inBase && inKitab && inConsumer) {
330
+ out.kind = "identical";
331
+ out.mergedValue = member;
332
+ return out;
333
+ }
334
+ // Removed in kitab, kept in consumer → conflict (consumer still relies on it)
335
+ if (inBase && !inKitab && inConsumer) {
336
+ out.kind = "conflict";
337
+ out.conflictHint = `kitab dropped "${member}" (${elementKey.split(":")[0]}); consumer still relies on it`;
338
+ return out;
339
+ }
340
+ // Removed in consumer, kept in kitab → consumer-wins-clean (consumer removed for its own reasons)
341
+ if (inBase && inKitab && !inConsumer) {
342
+ out.kind = "consumer-wins-clean";
343
+ out.mergedValue = null;
344
+ return out;
345
+ }
346
+ // Removed in both (only in base) → identical removal
347
+ if (inBase && !inKitab && !inConsumer) {
348
+ out.kind = "identical";
349
+ out.mergedValue = null;
350
+ return out;
351
+ }
352
+
353
+ // Fallback (shouldn't reach)
354
+ out.kind = "conflict";
355
+ out.conflictHint = `unexpected set-membership pattern for "${member}"`;
356
+ return out;
357
+ }
358
+
359
+ function toSet(arr) {
360
+ if (!Array.isArray(arr)) return new Set();
361
+ return new Set(arr.filter((x) => typeof x === "string"));
362
+ }
363
+
364
+ /**
365
+ * Produce a merged SliceSnapshot from the outcomes. Only called when there
366
+ * are zero conflicts.
367
+ */
368
+ function buildMergedSnapshot(req, outcomes) {
369
+ const { kitab, consumer } = req;
370
+ /** @type {Record<string, string>} */
371
+ const files = {};
372
+ for (const o of outcomes) {
373
+ if (!o.element.startsWith("files/")) continue;
374
+ if (o.mergedValue == null) continue; // file removed in merge
375
+ const rel = o.element.slice("files/".length);
376
+ files[rel] = /** @type {string} */ (o.mergedValue);
377
+ }
378
+
379
+ // Rebuild contract by applying merged set memberships back into the
380
+ // kitab-side contract shape (so requires.convex.prefix etc. propagate).
381
+ let mergedContract;
382
+ if (kitab.contract || consumer.contract) {
383
+ const baseContract = kitab.contract ?? consumer.contract;
384
+ mergedContract = JSON.parse(JSON.stringify(baseContract));
385
+ mergedContract.requires = mergedContract.requires ?? {};
386
+ mergedContract.provides = mergedContract.provides ?? {};
387
+ const SET_PATHS = {
388
+ "contract.requires.env": ["requires", "env"],
389
+ "contract.requires.rbac": ["requires", "rbac"],
390
+ "contract.requires.deps": ["requires", "deps"],
391
+ "contract.provides.tables": ["provides", "tables"],
392
+ "contract.provides.routes": ["provides", "routes"],
393
+ "contract.provides.hooks": ["provides", "hooks"],
394
+ "contract.provides.components": ["provides", "components"],
395
+ "contract.provides.events": ["provides", "events"],
396
+ };
397
+ /** @type {Record<string, Set<string>>} */
398
+ const merged = {};
399
+ for (const o of outcomes) {
400
+ const colon = o.element.indexOf(":");
401
+ if (colon < 0) continue;
402
+ const key = o.element.slice(0, colon);
403
+ if (!Object.prototype.hasOwnProperty.call(SET_PATHS, key)) continue;
404
+ const member = o.element.slice(colon + 1);
405
+ if (!merged[key]) merged[key] = new Set();
406
+ if (o.mergedValue != null && o.kind !== "conflict") {
407
+ merged[key].add(member);
408
+ }
409
+ }
410
+ for (const [keyPath, [a, b]] of Object.entries(SET_PATHS)) {
411
+ const set = merged[keyPath];
412
+ if (!set) continue;
413
+ const arr = [...set].sort();
414
+ if (arr.length === 0) {
415
+ if (mergedContract[a]) delete mergedContract[a][b];
416
+ } else {
417
+ mergedContract[a] = mergedContract[a] ?? {};
418
+ mergedContract[a][b] = arr;
419
+ }
420
+ }
421
+ }
422
+
423
+ /** @type {import("./merge3").SliceSnapshot} */
424
+ const snap = {
425
+ slug: kitab.slug,
426
+ version: kitab.version || consumer.version,
427
+ files,
428
+ };
429
+ if (mergedContract) snap.contract = mergedContract;
430
+ return snap;
431
+ }
@@ -0,0 +1,199 @@
1
+ // merge3.test.mjs — vitest coverage for the 3-way merge engine.
2
+ //
3
+ // We exercise the file-element and contract-element branches plus the drift
4
+ // arithmetic and the applyMerge guard.
5
+
6
+ import { describe, it, expect } from "vitest";
7
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { merge3, applyMerge } from "./merge3.mjs";
12
+
13
+ /** Tiny snapshot factory keeping the test setup compact. */
14
+ function snap(files = {}, contract, slug = "demo-slice", version = "0.1.0") {
15
+ return { slug, version, files, contract };
16
+ }
17
+
18
+ describe("merge3 — file element diffs", () => {
19
+ it("all identical → all outcomes identical, drift 0", () => {
20
+ const s = snap({ "page.tsx": "A", "lib.ts": "B" });
21
+ const r = merge3({ base: s, kitab: s, consumer: s });
22
+ expect(r.summary.identical).toBe(2);
23
+ expect(r.summary.conflicts).toBe(0);
24
+ expect(r.driftAfterMerge).toBe(0);
25
+ expect(r.mergedSnapshot?.files).toEqual({ "page.tsx": "A", "lib.ts": "B" });
26
+ });
27
+
28
+ it("kitab adds file → auto-merged", () => {
29
+ const base = snap({ "page.tsx": "A" });
30
+ const kitab = snap({ "page.tsx": "A", "new.tsx": "N" });
31
+ const consumer = snap({ "page.tsx": "A" });
32
+ const r = merge3({ base, kitab, consumer });
33
+ const o = r.outcomes.find((x) => x.element === "files/new.tsx");
34
+ expect(o?.kind).toBe("auto-merged");
35
+ expect(r.summary.autoMerged).toBe(1);
36
+ expect(r.mergedSnapshot?.files["new.tsx"]).toBe("N");
37
+ });
38
+
39
+ it("consumer adds file → consumer-wins-clean", () => {
40
+ const base = snap({ "page.tsx": "A" });
41
+ const kitab = snap({ "page.tsx": "A" });
42
+ const consumer = snap({ "page.tsx": "A", "local.tsx": "L" });
43
+ const r = merge3({ base, kitab, consumer });
44
+ const o = r.outcomes.find((x) => x.element === "files/local.tsx");
45
+ expect(o?.kind).toBe("consumer-wins-clean");
46
+ expect(r.summary.consumerWinsClean).toBe(1);
47
+ });
48
+
49
+ it("same file changed in both → conflict", () => {
50
+ const base = snap({ "page.tsx": "A" });
51
+ const kitab = snap({ "page.tsx": "K" });
52
+ const consumer = snap({ "page.tsx": "C" });
53
+ const r = merge3({ base, kitab, consumer });
54
+ const o = r.outcomes.find((x) => x.element === "files/page.tsx");
55
+ expect(o?.kind).toBe("conflict");
56
+ expect(o?.conflictHint).toMatch(/both kitab and consumer modified/);
57
+ expect(r.summary.conflicts).toBe(1);
58
+ expect(r.mergedSnapshot).toBeUndefined();
59
+ });
60
+
61
+ it("kitab removed, consumer modified → conflict", () => {
62
+ const base = snap({ "old.tsx": "A" });
63
+ const kitab = snap({});
64
+ const consumer = snap({ "old.tsx": "patched" });
65
+ const r = merge3({ base, kitab, consumer });
66
+ const o = r.outcomes.find((x) => x.element === "files/old.tsx");
67
+ expect(o?.kind).toBe("conflict");
68
+ expect(o?.conflictHint).toMatch(/kitab removed/);
69
+ });
70
+ });
71
+
72
+ describe("merge3 — contract surface diffs", () => {
73
+ const baseContract = {
74
+ id: "demo-slice",
75
+ version: "0.1.0",
76
+ requires: { env: ["FOO"] },
77
+ provides: { tables: ["t_users"], routes: ["/x"] },
78
+ };
79
+
80
+ it("contract env added in kitab → auto-merged", () => {
81
+ const base = snap({}, baseContract);
82
+ const kitab = snap({}, { ...baseContract, requires: { env: ["FOO", "BAR"] } });
83
+ const consumer = snap({}, baseContract);
84
+ const r = merge3({ base, kitab, consumer });
85
+ const o = r.outcomes.find((x) => x.element === "contract.requires.env:BAR");
86
+ expect(o?.kind).toBe("auto-merged");
87
+ expect(r.mergedSnapshot?.contract?.requires?.env).toEqual(["BAR", "FOO"]);
88
+ });
89
+
90
+ it("contract env added in consumer → consumer-wins-clean", () => {
91
+ const base = snap({}, baseContract);
92
+ const kitab = snap({}, baseContract);
93
+ const consumer = snap({}, { ...baseContract, requires: { env: ["FOO", "LOCAL"] } });
94
+ const r = merge3({ base, kitab, consumer });
95
+ const o = r.outcomes.find((x) => x.element === "contract.requires.env:LOCAL");
96
+ expect(o?.kind).toBe("consumer-wins-clean");
97
+ });
98
+
99
+ it("contract table removed in kitab, kept in consumer → conflict", () => {
100
+ const base = snap({}, baseContract);
101
+ const kitab = snap({}, { ...baseContract, provides: { tables: [], routes: ["/x"] } });
102
+ const consumer = snap({}, baseContract);
103
+ const r = merge3({ base, kitab, consumer });
104
+ const o = r.outcomes.find((x) => x.element === "contract.provides.tables:t_users");
105
+ expect(o?.kind).toBe("conflict");
106
+ expect(o?.conflictHint).toMatch(/kitab dropped/);
107
+ });
108
+ });
109
+
110
+ describe("merge3 — summary, drift, applyMerge", () => {
111
+ it("mixed: 2 auto-merge + 1 conflict + 3 identical → no mergedSnapshot", () => {
112
+ const base = snap({ a: "1", b: "2", c: "3", d: "4" });
113
+ // kitab: changes 'a' and adds 'e' (auto), modifies 'b' (consumer also touches → conflict)
114
+ const kitab = snap({ a: "1k", b: "2k", c: "3", d: "4", e: "E" });
115
+ // consumer: modifies 'b' independently. c,d untouched.
116
+ const consumer = snap({ a: "1", b: "2c", c: "3", d: "4" });
117
+ const r = merge3({ base, kitab, consumer });
118
+ expect(r.summary.autoMerged).toBe(2); // a and e
119
+ expect(r.summary.conflicts).toBe(1); // b
120
+ expect(r.summary.identical).toBe(2); // c, d
121
+ expect(r.mergedSnapshot).toBeUndefined();
122
+ });
123
+
124
+ it("driftAfterMerge math: 10 elements, 2 conflicts, 1 consumer-only → 30", () => {
125
+ // Construct 10 file elements with the exact mix.
126
+ const baseFiles = {};
127
+ const kitabFiles = {};
128
+ const consumerFiles = {};
129
+ for (let i = 0; i < 7; i++) {
130
+ // 7 identical
131
+ baseFiles[`i${i}`] = "x";
132
+ kitabFiles[`i${i}`] = "x";
133
+ consumerFiles[`i${i}`] = "x";
134
+ }
135
+ // 2 conflicts (both sides edit)
136
+ for (let i = 0; i < 2; i++) {
137
+ baseFiles[`c${i}`] = "b";
138
+ kitabFiles[`c${i}`] = "k";
139
+ consumerFiles[`c${i}`] = "c";
140
+ }
141
+ // 1 consumer-only edit
142
+ baseFiles["uo"] = "b";
143
+ kitabFiles["uo"] = "b";
144
+ consumerFiles["uo"] = "consumer-edit";
145
+
146
+ const r = merge3({
147
+ base: snap(baseFiles),
148
+ kitab: snap(kitabFiles),
149
+ consumer: snap(consumerFiles),
150
+ });
151
+ expect(r.outcomes.length).toBe(10);
152
+ expect(r.summary.conflicts).toBe(2);
153
+ expect(r.summary.consumerWinsClean).toBe(1);
154
+ expect(r.driftAfterMerge).toBe(30);
155
+ });
156
+
157
+ it("applyMerge throws on a report with conflicts", async () => {
158
+ const base = snap({ "a.tsx": "A" });
159
+ const kitab = snap({ "a.tsx": "K" });
160
+ const consumer = snap({ "a.tsx": "C" });
161
+ const r = merge3({ base, kitab, consumer });
162
+ await expect(applyMerge(r, "/tmp/should-not-write")).rejects.toThrow(
163
+ /refusing to apply/,
164
+ );
165
+ });
166
+
167
+ it("applyMerge writes merged files to target directory", async () => {
168
+ const dir = mkdtempSync(path.join(tmpdir(), "merge3-apply-"));
169
+ try {
170
+ const base = snap({ "page.tsx": "old" });
171
+ const kitab = snap({ "page.tsx": "new", "added.tsx": "X" });
172
+ const consumer = snap({ "page.tsx": "old", "local.tsx": "L" });
173
+ const r = merge3({ base, kitab, consumer });
174
+ expect(r.summary.conflicts).toBe(0);
175
+ await applyMerge(r, dir);
176
+ expect(readFileSync(path.join(dir, "page.tsx"), "utf8")).toBe("new");
177
+ expect(readFileSync(path.join(dir, "added.tsx"), "utf8")).toBe("X");
178
+ expect(readFileSync(path.join(dir, "local.tsx"), "utf8")).toBe("L");
179
+ } finally {
180
+ rmSync(dir, { recursive: true, force: true });
181
+ }
182
+ });
183
+
184
+ it("kitab-only changes with no consumer drift → drift 0 after clean auto-merge", () => {
185
+ const base = snap({ "a": "1", "b": "2" });
186
+ const kitab = snap({ "a": "1-new", "b": "2", "c": "3" });
187
+ const consumer = snap({ "a": "1", "b": "2" });
188
+ const r = merge3({ base, kitab, consumer });
189
+ expect(r.driftAfterMerge).toBe(0);
190
+ expect(r.summary.autoMerged).toBe(2);
191
+ expect(r.mergedSnapshot).toBeDefined();
192
+ });
193
+
194
+ it("rejects mismatched slugs across snapshots", () => {
195
+ const a = snap({}, undefined, "alpha");
196
+ const b = snap({}, undefined, "beta");
197
+ expect(() => merge3({ base: a, kitab: b, consumer: a })).toThrow(/slug mismatch/);
198
+ });
199
+ });