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.
- package/README.md +105 -0
- package/bin/cli.js +114 -0
- package/bin/compose.mjs +287 -0
- package/bin/graph.mjs +247 -0
- package/bin/migrate.mjs +423 -0
- package/bin/update.mjs +413 -0
- package/lib/compose-solver.d.ts +179 -0
- package/lib/compose-solver.mjs +523 -0
- package/lib/compose-solver.test.mjs +483 -0
- package/lib/contract.ts +240 -0
- package/lib/dna.d.ts +65 -0
- package/lib/dna.mjs +239 -0
- package/lib/manifest.json +883 -185
- package/lib/merge3.d.ts +67 -0
- package/lib/merge3.mjs +431 -0
- package/lib/merge3.test.mjs +199 -0
- package/lib/migration-plan.d.ts +86 -0
- package/lib/migration-plan.mjs +414 -0
- package/lib/migration-plan.test.mjs +243 -0
- package/lib/slice-schema.json +13 -9
- package/lib/snapshot.d.ts +6 -0
- package/lib/snapshot.mjs +126 -0
- package/package.json +5 -2
package/lib/merge3.d.ts
ADDED
|
@@ -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
|
+
});
|