rahman-resources 1.13.1 → 1.13.2

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/lib/post-init.mjs CHANGED
@@ -11,14 +11,7 @@
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import path from "node:path";
13
13
 
14
- import { DEFAULT_RR, writeRr, buildRr } from "./rr.mjs";
15
-
16
- const RR_ALIASES_TO_TSCONFIG = {
17
- // alias -> tsconfig key target relative-path
18
- "@/components/templates/_shared": "components/templates/_shared",
19
- "@/components/templates": "components/templates",
20
- "@/features": "features",
21
- };
14
+ import { writeRr, buildRr } from "./rr.mjs";
22
15
 
23
16
  export function runPostInit(targetDir, opts = {}) {
24
17
  const out = { changed: [], skipped: [] };
@@ -25,27 +25,27 @@ const required = [
25
25
  "scripts/setup-auth.mjs",
26
26
  "lib/headless-core/version.ts",
27
27
  ];
28
- for (const f of required) (existsSync(f) ? ok(f) : bad(`missing ${f}`));
28
+ for (const f of required) { if (existsSync(f)) ok(f); else bad(`missing ${f}`); }
29
29
 
30
30
  console.log("\n● Manifest + scripts");
31
31
  try {
32
32
  const v = JSON.parse(readFileSync("version.json", "utf8"));
33
- v.version && v.core ? ok(`version.json (v${v.version})`) : bad("version.json missing version/core");
33
+ if (v.version && v.core) ok(`version.json (v${v.version})`); else bad("version.json missing version/core");
34
34
  } catch { bad("version.json invalid JSON"); }
35
35
  try {
36
36
  const pkg = JSON.parse(readFileSync("package.json", "utf8"));
37
- pkg.scripts?.["build:auto"] ? ok("package.json build:auto present") : bad("package.json missing build:auto");
37
+ if (pkg.scripts?.["build:auto"]) ok("package.json build:auto present"); else bad("package.json missing build:auto");
38
38
  } catch { bad("package.json invalid"); }
39
39
  try {
40
40
  const vc = JSON.parse(readFileSync("vercel.json", "utf8"));
41
- /build:auto/.test(vc.buildCommand ?? "") ? ok("vercel.json buildCommand -> build:auto") : bad("vercel.json buildCommand wrong");
41
+ if (/build:auto/.test(vc.buildCommand ?? "")) ok("vercel.json buildCommand -> build:auto"); else bad("vercel.json buildCommand wrong");
42
42
  } catch { bad("vercel.json invalid"); }
43
43
 
44
44
  console.log("\n● Env documentation");
45
45
  try {
46
46
  const env = readFileSync(".env.example", "utf8");
47
47
  for (const k of ["NEXT_PUBLIC_CONVEX_URL", "CONVEX_DEPLOY_KEY"])
48
- (env.includes(k) ? ok(`.env.example documents ${k}`) : bad(`.env.example missing ${k}`));
48
+ { if (env.includes(k)) ok(`.env.example documents ${k}`); else bad(`.env.example missing ${k}`); }
49
49
  } catch { bad(".env.example unreadable"); }
50
50
 
51
51
  function run(label, cmd) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rahman-resources",
3
- "version": "1.13.1",
3
+ "version": "1.13.2",
4
4
  "description": "Rahman Resources (rr) — shadcn-style installer for vertical slices. `npx resources add <slug>` copies slice into your project's `slices/<slug>/`. You own the files.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "bin",
21
21
  "lib",
22
+ "!lib/**/*.test.mjs",
22
23
  "README.md"
23
24
  ],
24
25
  "engines": {
@@ -44,7 +45,6 @@
44
45
  },
45
46
  "keywords": [
46
47
  "rahman",
47
- "kitab",
48
48
  "template",
49
49
  "installer",
50
50
  "nextjs",
@@ -1,48 +0,0 @@
1
- // Alias fall-through e2e — `rr info <old-slug>` must resolve through
2
- // manifest.aliases with a "superseded by" warning (UX wave U3 contract).
3
- // Spawns the real CLI against the real bundled manifest, fully offline.
4
- import { describe, expect, it } from "vitest";
5
- import { spawnSync } from "node:child_process";
6
- import { readFileSync } from "node:fs";
7
- import path from "node:path";
8
- import { fileURLToPath } from "node:url";
9
-
10
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const CLI = path.resolve(__dirname, "../bin/cli.js");
12
- const manifest = JSON.parse(
13
- readFileSync(path.resolve(__dirname, "manifest.json"), "utf8"),
14
- );
15
-
16
- const run = (...args) =>
17
- spawnSync("node", [CLI, ...args], { encoding: "utf8" });
18
-
19
- describe("manifest aliases", () => {
20
- it("manifest carries the U3 alias map", () => {
21
- expect(manifest.aliases).toBeTruthy();
22
- expect(manifest.aliases["blog-section"]).toBe("landing-sections");
23
- });
24
-
25
- it("aliased slugs are NOT listed as their own entries", () => {
26
- const slugs = new Set(
27
- [...(manifest.slices ?? []), ...(manifest.features ?? [])].map(
28
- (s) => s.slug,
29
- ),
30
- );
31
- for (const old of Object.keys(manifest.aliases)) {
32
- expect(slugs.has(old)).toBe(false);
33
- }
34
- });
35
-
36
- it("info <old-slug> falls through with a superseded warning", () => {
37
- const r = run("info", "blog-section");
38
- expect(r.status).toBe(0);
39
- const all = r.stdout + r.stderr;
40
- expect(all).toContain("superseded by");
41
- expect(all).toContain("landing-sections");
42
- });
43
-
44
- it("info <unknown> still fails", () => {
45
- const r = run("info", "definitely-not-a-slice");
46
- expect(r.status).not.toBe(0);
47
- });
48
- });
@@ -1,483 +0,0 @@
1
- // Vitest suite for the Phase B compose solver.
2
- // Uses synthetic in-memory contracts so the tests are independent of the
3
- // real frontend/slices/*/slice.contract.ts files. The loader is exercised
4
- // indirectly by the CLI smoke test in the worktree-verify step.
5
-
6
- import { describe, expect, it } from "vitest";
7
- import { compose } from "./compose-solver.mjs";
8
-
9
- /**
10
- * Tiny helper — build a Map of slug → contract from an array of inline
11
- * contract objects so each test reads as a single scenario block.
12
- */
13
- function makeContracts(arr) {
14
- return new Map(arr.map((c) => [c.id, c]));
15
- }
16
-
17
- describe("compose() — empty + happy paths", () => {
18
- it("empty desired → empty accepted/rejected/conflicts", () => {
19
- const result = compose(
20
- { state: {}, desired: [] },
21
- makeContracts([]),
22
- );
23
- expect(result.accepted).toEqual([]);
24
- expect(result.rejected).toEqual([]);
25
- expect(result.conflicts).toEqual([]);
26
- expect(result.envMissing).toEqual([]);
27
- expect(result.rbacToCreate).toEqual([]);
28
- expect(result.tablesAdded).toEqual([]);
29
- expect(result.proof).toEqual([]);
30
- });
31
-
32
- it("single slice, no conflicts → accepted", () => {
33
- const result = compose(
34
- { state: { auth: "convex" }, desired: ["mdx-blog"] },
35
- makeContracts([
36
- {
37
- id: "mdx-blog",
38
- version: "0.1.0",
39
- requires: {},
40
- provides: { routes: ["/blog"] },
41
- },
42
- ]),
43
- );
44
- expect(result.accepted).toEqual(["mdx-blog"]);
45
- expect(result.rejected).toEqual([]);
46
- expect(result.conflicts.filter((c) => c.severity === "blocker")).toEqual([]);
47
- expect(result.proof.some((l) => l.startsWith("+ mdx-blog"))).toBe(true);
48
- });
49
- });
50
-
51
- describe("compose() — auth-mismatch", () => {
52
- it("slice wants convex but target has clerk → blocker", () => {
53
- const result = compose(
54
- { state: { auth: "clerk" }, desired: ["convex-auth"] },
55
- makeContracts([
56
- {
57
- id: "convex-auth",
58
- version: "0.1.0",
59
- requires: { auth: "convex" },
60
- provides: {},
61
- },
62
- ]),
63
- );
64
- expect(result.accepted).toEqual([]);
65
- expect(result.rejected).toHaveLength(1);
66
- expect(result.rejected[0].slug).toBe("convex-auth");
67
- const blockers = result.rejected[0].reasons.filter((r) => r.severity === "blocker");
68
- expect(blockers.map((b) => b.type)).toContain("auth-mismatch");
69
- });
70
- });
71
-
72
- describe("compose() — explicit-conflict (v2 arbitration)", () => {
73
- it("doku + midtrans collide on paymentOrders → arbitration drops loser only", () => {
74
- const contracts = makeContracts([
75
- {
76
- id: "doku-payment",
77
- version: "0.1.0",
78
- requires: {
79
- auth: "convex",
80
- convex: { prefix: "doku_", tables: ["doku_orders"] },
81
- },
82
- provides: { tables: ["doku_orders"] },
83
- conflicts: ["midtrans-payment:tables.paymentOrders"],
84
- },
85
- {
86
- id: "midtrans-payment",
87
- version: "0.1.0",
88
- requires: {
89
- auth: "convex",
90
- convex: { prefix: "midtrans_", tables: ["paymentOrders"] },
91
- },
92
- provides: { tables: ["paymentOrders"] },
93
- },
94
- ]);
95
- const result = compose(
96
- { state: { auth: "convex" }, desired: ["doku-payment", "midtrans-payment"] },
97
- contracts,
98
- );
99
- // Equal dependers (0 each) → alphabetical tiebreak drops "midtrans-payment".
100
- expect(result.accepted).toEqual(["doku-payment"]);
101
- expect(result.rejected.map((r) => r.slug)).toEqual(["midtrans-payment"]);
102
- const explicit = result.conflicts.filter((c) => c.type === "explicit-conflict");
103
- expect(explicit.length).toBeGreaterThanOrEqual(1);
104
- expect(result.arbitrations).toBeDefined();
105
- expect(result.arbitrations[0].winner).toBe("doku-payment");
106
- expect(result.arbitrations[0].loser).toBe("midtrans-payment");
107
- });
108
- });
109
-
110
- describe("compose() — missing dep", () => {
111
- it("desired contract not found (strict) → blocker missing-dep", () => {
112
- const result = compose(
113
- { state: { allowUnknownSlices: false }, desired: ["nonexistent"] },
114
- makeContracts([]),
115
- );
116
- expect(result.accepted).toEqual([]);
117
- expect(result.rejected.map((r) => r.slug)).toEqual(["nonexistent"]);
118
- expect(result.conflicts[0]?.type).toBe("missing-dep");
119
- });
120
-
121
- it("slice deps on unknown slice → blocker missing-dep on parent", () => {
122
- const contracts = makeContracts([
123
- {
124
- id: "child",
125
- version: "0.1.0",
126
- requires: { deps: ["ghost"] },
127
- provides: {},
128
- },
129
- ]);
130
- const result = compose({ state: {}, desired: ["child"] }, contracts);
131
- expect(result.accepted).toEqual([]);
132
- const blockers = result.conflicts.filter(
133
- (c) => c.type === "missing-dep" && c.withSlug === "ghost",
134
- );
135
- expect(blockers.length).toBe(1);
136
- });
137
- });
138
-
139
- describe("compose() — transitive dep resolution", () => {
140
- it("desired pulls in dep automatically", () => {
141
- const contracts = makeContracts([
142
- {
143
- id: "doku",
144
- version: "0.1.0",
145
- requires: { auth: "convex", deps: ["convex-auth"] },
146
- provides: {},
147
- },
148
- {
149
- id: "convex-auth",
150
- version: "0.1.0",
151
- requires: { auth: "convex" },
152
- provides: {},
153
- },
154
- ]);
155
- const result = compose(
156
- { state: { auth: "convex" }, desired: ["doku"] },
157
- contracts,
158
- );
159
- expect(result.accepted.sort()).toEqual(["convex-auth", "doku"]);
160
- expect(result.proof.some((l) => l.includes("transitive dep"))).toBe(true);
161
- });
162
- });
163
-
164
- describe("compose() — installed wins", () => {
165
- it("a slice already installed isn't rejected even if it conflicts with a new one", () => {
166
- const contracts = makeContracts([
167
- {
168
- id: "doku-payment",
169
- version: "0.1.0",
170
- requires: { auth: "convex" },
171
- provides: { tables: ["doku_orders"] },
172
- conflicts: ["midtrans-payment:tables.paymentOrders"],
173
- },
174
- {
175
- id: "midtrans-payment",
176
- version: "0.1.0",
177
- requires: { auth: "convex" },
178
- provides: { tables: ["paymentOrders"] },
179
- },
180
- ]);
181
- const result = compose(
182
- {
183
- state: { auth: "convex", slicesInstalled: ["doku-payment"] },
184
- desired: ["doku-payment", "midtrans-payment"],
185
- },
186
- contracts,
187
- );
188
- // doku is installed — wins; midtrans gets rejected.
189
- expect(result.accepted).toContain("doku-payment");
190
- expect(result.rejected.map((r) => r.slug)).toContain("midtrans-payment");
191
- });
192
- });
193
-
194
- describe("compose() — cycle detection", () => {
195
- it("throws when deps form a cycle", () => {
196
- const contracts = makeContracts([
197
- { id: "a", version: "0.1.0", requires: { deps: ["b"] }, provides: {} },
198
- { id: "b", version: "0.1.0", requires: { deps: ["a"] }, provides: {} },
199
- ]);
200
- expect(() =>
201
- compose({ state: {}, desired: ["a"] }, contracts),
202
- ).toThrowError(/cycle/);
203
- });
204
- });
205
-
206
- describe("compose() — env warning is non-blocking", () => {
207
- it("missing env surfaces as warning but slice is accepted", () => {
208
- const contracts = makeContracts([
209
- {
210
- id: "doku",
211
- version: "0.1.0",
212
- requires: { auth: "convex", env: ["DOKU_CLIENT_ID"] },
213
- provides: {},
214
- },
215
- ]);
216
- const result = compose(
217
- { state: { auth: "convex", envExisting: [] }, desired: ["doku"] },
218
- contracts,
219
- );
220
- expect(result.accepted).toEqual(["doku"]);
221
- expect(result.envMissing).toContain("DOKU_CLIENT_ID");
222
- const warnings = result.conflicts.filter((c) => c.severity === "warning");
223
- expect(warnings.some((w) => w.type === "env-missing")).toBe(true);
224
- });
225
- });
226
-
227
- describe("compose() — table collision with target state", () => {
228
- it("blocks when slice declares a table already in convexTablesExisting", () => {
229
- const contracts = makeContracts([
230
- {
231
- id: "doku",
232
- version: "0.1.0",
233
- requires: { auth: "convex" },
234
- provides: { tables: ["doku_orders"] },
235
- },
236
- ]);
237
- const result = compose(
238
- {
239
- state: { auth: "convex", convexTablesExisting: ["doku_orders"] },
240
- desired: ["doku"],
241
- },
242
- contracts,
243
- );
244
- expect(result.accepted).toEqual([]);
245
- expect(result.rejected[0].reasons.map((r) => r.type)).toContain(
246
- "table-collision",
247
- );
248
- });
249
- });
250
-
251
- describe("compose() — rbacToCreate aggregation", () => {
252
- it("collects rbac perms not already in target", () => {
253
- const contracts = makeContracts([
254
- {
255
- id: "convex-auth",
256
- version: "0.1.0",
257
- requires: {
258
- auth: "convex",
259
- rbac: ["auth.sign-in", "auth.sign-out"],
260
- },
261
- provides: {},
262
- },
263
- ]);
264
- const result = compose(
265
- {
266
- state: { auth: "convex", rbacRolesExisting: ["auth.sign-in"] },
267
- desired: ["convex-auth"],
268
- },
269
- contracts,
270
- );
271
- expect(result.accepted).toEqual(["convex-auth"]);
272
- expect(result.rbacToCreate).toEqual(["auth.sign-out"]);
273
- });
274
- });
275
-
276
- // ─── v2 — arbitration by dependers ──────────────────────────────────────────
277
-
278
- describe("compose() — arbitration: most-dependers wins", () => {
279
- it("two slices conflict, A has more dependers than B → A accepted, B rejected", () => {
280
- // a is pulled in as transitive dep by `client1` and `client2`; b has zero
281
- // dependers. They conflict on a shared table → a wins.
282
- const contracts = makeContracts([
283
- {
284
- id: "a",
285
- version: "0.1.0",
286
- requires: { auth: "convex" },
287
- provides: { tables: ["shared_table"] },
288
- },
289
- {
290
- id: "b",
291
- version: "0.1.0",
292
- requires: { auth: "convex" },
293
- provides: { tables: ["shared_table"] },
294
- },
295
- {
296
- id: "client1",
297
- version: "0.1.0",
298
- requires: { auth: "convex", deps: ["a"] },
299
- provides: {},
300
- },
301
- {
302
- id: "client2",
303
- version: "0.1.0",
304
- requires: { auth: "convex", deps: ["a"] },
305
- provides: {},
306
- },
307
- ]);
308
- const result = compose(
309
- { state: { auth: "convex" }, desired: ["a", "b", "client1", "client2"] },
310
- contracts,
311
- );
312
- expect(result.accepted).toContain("a");
313
- expect(result.accepted).toContain("client1");
314
- expect(result.accepted).toContain("client2");
315
- expect(result.rejected.map((r) => r.slug)).toEqual(["b"]);
316
- expect(result.arbitrations).toBeDefined();
317
- expect(result.arbitrations[0].winner).toBe("a");
318
- expect(result.arbitrations[0].loser).toBe("b");
319
- expect(result.arbitrations[0].reason).toMatch(/dependers/);
320
- });
321
- });
322
-
323
- describe("compose() — arbitration: alphabetical tiebreak", () => {
324
- it("equal dependers → drop lex-later slug", () => {
325
- const contracts = makeContracts([
326
- {
327
- id: "alpha",
328
- version: "0.1.0",
329
- requires: { auth: "convex" },
330
- provides: { tables: ["t"] },
331
- },
332
- {
333
- id: "zeta",
334
- version: "0.1.0",
335
- requires: { auth: "convex" },
336
- provides: { tables: ["t"] },
337
- },
338
- ]);
339
- const result = compose(
340
- { state: { auth: "convex" }, desired: ["alpha", "zeta"] },
341
- contracts,
342
- );
343
- expect(result.accepted).toEqual(["alpha"]);
344
- expect(result.rejected.map((r) => r.slug)).toEqual(["zeta"]);
345
- expect(result.arbitrations[0].reason).toMatch(/alphabetical/);
346
- });
347
- });
348
-
349
- describe("compose() — arbitration: both installed", () => {
350
- it("both slices already installed and in conflict → kept with warning, no arbitration drop", () => {
351
- const contracts = makeContracts([
352
- {
353
- id: "a",
354
- version: "0.1.0",
355
- requires: { auth: "convex" },
356
- provides: { tables: ["t"] },
357
- },
358
- {
359
- id: "b",
360
- version: "0.1.0",
361
- requires: { auth: "convex" },
362
- provides: { tables: ["t"] },
363
- },
364
- ]);
365
- const result = compose(
366
- {
367
- state: { auth: "convex", slicesInstalled: ["a", "b"] },
368
- desired: ["a", "b"],
369
- },
370
- contracts,
371
- );
372
- expect(result.accepted.sort()).toEqual(["a", "b"]);
373
- expect(result.rejected).toEqual([]);
374
- expect(result.arbitrations).toBeUndefined();
375
- const both = result.conflicts.filter((c) => c.type === "both-installed-conflict");
376
- expect(both.length).toBeGreaterThan(0);
377
- expect(both[0].severity).toBe("warning");
378
- expect(result.notes?.a).toBe("both-installed-conflict");
379
- expect(result.notes?.b).toBe("both-installed-conflict");
380
- });
381
- });
382
-
383
- // ─── v2 — uncontracted slices ───────────────────────────────────────────────
384
-
385
- describe("compose() — uncontracted slice (default allowUnknownSlices=true)", () => {
386
- it("desired slug with no contract → accepted with uncontracted warning", () => {
387
- const result = compose(
388
- { state: {}, desired: ["nonexistent-slice"] },
389
- makeContracts([]),
390
- );
391
- expect(result.accepted).toEqual(["nonexistent-slice"]);
392
- expect(result.rejected).toEqual([]);
393
- const warn = result.conflicts.find((c) => c.type === "uncontracted");
394
- expect(warn).toBeDefined();
395
- expect(warn.severity).toBe("warning");
396
- expect(result.notes?.["nonexistent-slice"]).toBe("uncontracted");
397
- });
398
-
399
- it("strict mode (allowUnknownSlices: false) flips uncontracted to blocker missing-dep", () => {
400
- const result = compose(
401
- { state: { allowUnknownSlices: false }, desired: ["nonexistent-slice"] },
402
- makeContracts([]),
403
- );
404
- expect(result.accepted).toEqual([]);
405
- expect(result.rejected.map((r) => r.slug)).toEqual(["nonexistent-slice"]);
406
- const blocker = result.conflicts.find((c) => c.type === "missing-dep");
407
- expect(blocker?.severity).toBe("blocker");
408
- });
409
- });
410
-
411
- // ─── v2 — cycle path printing ───────────────────────────────────────────────
412
-
413
- describe("compose() — cycle detection prints full path", () => {
414
- it("a → b → c → a throws with full cycle in message", () => {
415
- const contracts = makeContracts([
416
- { id: "a", version: "0.1.0", requires: { deps: ["b"] }, provides: {} },
417
- { id: "b", version: "0.1.0", requires: { deps: ["c"] }, provides: {} },
418
- { id: "c", version: "0.1.0", requires: { deps: ["a"] }, provides: {} },
419
- ]);
420
- expect(() => compose({ state: {}, desired: ["a"] }, contracts)).toThrowError(
421
- /dependency cycle detected: a → b → c → a/,
422
- );
423
- });
424
- });
425
-
426
- // ─── v2 — strict mode escalates warnings ────────────────────────────────────
427
-
428
- describe("compose() — strict mode flips env-missing warning to blocker", () => {
429
- // The solver itself doesn't auto-elevate severity (that's a CLI concern),
430
- // but strict-mode state propagation must still surface env-missing so the
431
- // CLI wrapper can re-classify. The CLI test (hand-test) covers elevation.
432
- it("strict mode still surfaces env-missing — CLI elevates to blocker", () => {
433
- const contracts = makeContracts([
434
- {
435
- id: "doku",
436
- version: "0.1.0",
437
- requires: { auth: "convex", env: ["DOKU_CLIENT_ID"] },
438
- provides: {},
439
- },
440
- ]);
441
- const result = compose(
442
- {
443
- state: { auth: "convex", envExisting: [], allowUnknownSlices: false },
444
- desired: ["doku"],
445
- },
446
- contracts,
447
- );
448
- // The slice itself has a registered contract — strict doesn't block it.
449
- expect(result.accepted).toEqual(["doku"]);
450
- // env-missing is still a warning at the solver layer; CLI will elevate it
451
- // when --strict is set.
452
- const envWarn = result.conflicts.find((c) => c.type === "env-missing");
453
- expect(envWarn).toBeDefined();
454
- expect(envWarn.severity).toBe("warning");
455
- });
456
- });
457
-
458
- // ─── v2 — pair-collision arbitration on plain table-collision ───────────────
459
-
460
- describe("compose() — table-collision arbitration vs reject-both", () => {
461
- it("two new candidates with shared table → loser dropped, winner kept", () => {
462
- const contracts = makeContracts([
463
- {
464
- id: "a",
465
- version: "0.1.0",
466
- requires: { auth: "convex" },
467
- provides: { tables: ["shared"] },
468
- },
469
- {
470
- id: "b",
471
- version: "0.1.0",
472
- requires: { auth: "convex" },
473
- provides: { tables: ["shared"] },
474
- },
475
- ]);
476
- const result = compose(
477
- { state: { auth: "convex" }, desired: ["a", "b"] },
478
- contracts,
479
- );
480
- expect(result.accepted).toEqual(["a"]);
481
- expect(result.rejected.map((r) => r.slug)).toEqual(["b"]);
482
- });
483
- });
@@ -1,199 +0,0 @@
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
- });
@@ -1,243 +0,0 @@
1
- // migration-plan.test.mjs — vitest coverage for the Phase E migration planner.
2
-
3
- import { describe, it, expect } from "vitest";
4
-
5
- import { diffContracts, planMigration } from "./migration-plan.mjs";
6
-
7
- /** Tiny factory so each test only declares the bits it cares about. */
8
- function makeContract(over = {}) {
9
- return {
10
- id: "demo",
11
- version: "0.1.0",
12
- requires: { auth: "convex", env: [], rbac: [] },
13
- provides: { tables: [], routes: [] },
14
- ...over,
15
- requires: { auth: "convex", env: [], rbac: [], ...(over.requires ?? {}) },
16
- provides: { tables: [], routes: [], ...(over.provides ?? {}) },
17
- };
18
- }
19
-
20
- describe("diffContracts", () => {
21
- it("identical contracts produce an empty diff", () => {
22
- const c = makeContract({ provides: { tables: ["demo_a"] } });
23
- const d = diffContracts(c, c);
24
- expect(d.added).toEqual({});
25
- expect(d.removed).toEqual({});
26
- expect(d.renamed).toEqual({});
27
- });
28
-
29
- it("detects added tables", () => {
30
- const from = makeContract({ provides: { tables: ["demo_a"] } });
31
- const to = makeContract({
32
- version: "0.2.0",
33
- provides: { tables: ["demo_a", "demo_b"] },
34
- });
35
- const d = diffContracts(from, to);
36
- expect(d.added.tables).toEqual(["demo_b"]);
37
- expect(d.removed).toEqual({});
38
- });
39
-
40
- it("rename detection requires the migrationFrom marker", () => {
41
- const from = makeContract({ provides: { tables: ["old_x"] } });
42
- const to = makeContract({
43
- version: "0.2.0",
44
- provides: { tables: ["new_x"] },
45
- // No migrationFrom — must NOT pair up.
46
- });
47
- const d = diffContracts(from, to);
48
- expect(d.renamed.tables).toBeUndefined();
49
- expect(d.added.tables).toEqual(["new_x"]);
50
- expect(d.removed.tables).toEqual(["old_x"]);
51
- });
52
-
53
- it("pairs renames when migrationFrom is set", () => {
54
- const from = makeContract({ provides: { tables: ["old_x"] } });
55
- const to = makeContract({
56
- version: "0.2.0",
57
- provides: { tables: ["new_x"] },
58
- migrationFrom: { "0.1.0": "rename-2026-05" },
59
- });
60
- const d = diffContracts(from, to);
61
- expect(d.renamed.tables).toEqual([{ from: "old_x", to: "new_x" }]);
62
- expect(d.added).toEqual({});
63
- expect(d.removed).toEqual({});
64
- });
65
- });
66
-
67
- describe("planMigration", () => {
68
- it("no-diff (same contract) → empty plan", () => {
69
- const c = makeContract({ provides: { tables: ["demo_a"] } });
70
- const plan = planMigration(diffContracts(c, c));
71
- expect(plan.steps).toEqual([]);
72
- expect(plan.summary.totalSteps).toBe(0);
73
- expect(plan.summary.highRisk).toBe(0);
74
- expect(plan.summary.irreversible).toBe(0);
75
- expect(plan.warnings).toEqual([]);
76
- });
77
-
78
- it("add 1 table → 1 low-risk reversible step", () => {
79
- const from = makeContract({ provides: { tables: ["demo_a"] } });
80
- const to = makeContract({
81
- version: "0.2.0",
82
- provides: { tables: ["demo_a", "demo_b"] },
83
- });
84
- const plan = planMigration(diffContracts(from, to));
85
- expect(plan.steps).toHaveLength(1);
86
- const step = plan.steps[0];
87
- expect(step.kind).toBe("convex-schema-add-table");
88
- expect(step.risk).toBe("low");
89
- expect(step.reversible).toBe(true);
90
- expect(step.artifacts.convexSchema).toContain("demo_b");
91
- expect(plan.summary.highRisk).toBe(0);
92
- });
93
-
94
- it("drop 1 table → 1 high-risk irreversible step + warning", () => {
95
- const from = makeContract({ provides: { tables: ["demo_a"] } });
96
- const to = makeContract({
97
- version: "0.2.0",
98
- provides: { tables: [] },
99
- });
100
- const plan = planMigration(diffContracts(from, to));
101
- expect(plan.steps).toHaveLength(1);
102
- const step = plan.steps[0];
103
- expect(step.kind).toBe("convex-schema-drop-table");
104
- expect(step.risk).toBe("high");
105
- expect(step.reversible).toBe(false);
106
- expect(step.artifacts.convexMigration).toContain("demo_a");
107
- expect(plan.warnings.length).toBe(1);
108
- expect(plan.warnings[0]).toMatch(/irreversible/i);
109
- expect(plan.summary.highRisk).toBe(1);
110
- expect(plan.summary.irreversible).toBe(1);
111
- });
112
-
113
- it("rename detected → 1 rename step (medium risk, reversible)", () => {
114
- const from = makeContract({ provides: { tables: ["old_x"] } });
115
- const to = makeContract({
116
- version: "0.2.0",
117
- provides: { tables: ["new_x"] },
118
- migrationFrom: { "0.1.0": "rename-2026-05" },
119
- });
120
- const plan = planMigration(diffContracts(from, to));
121
- expect(plan.steps).toHaveLength(1);
122
- const step = plan.steps[0];
123
- expect(step.kind).toBe("convex-schema-rename-table");
124
- expect(step.risk).toBe("medium");
125
- expect(step.reversible).toBe(true);
126
- expect(step.artifacts.convexMigration).toContain("old_x");
127
- expect(step.artifacts.convexMigration).toContain("new_x");
128
- expect(plan.summary.highRisk).toBe(0);
129
- expect(plan.summary.irreversible).toBe(0);
130
- });
131
-
132
- it("multiple env adds emit env-add steps with envExample artifacts", () => {
133
- const from = makeContract({ requires: { env: ["A"] } });
134
- const to = makeContract({
135
- version: "0.2.0",
136
- requires: { env: ["A", "B", "C"] },
137
- });
138
- const plan = planMigration(diffContracts(from, to));
139
- const envSteps = plan.steps.filter((s) => s.kind === "env-add");
140
- expect(envSteps).toHaveLength(2);
141
- for (const s of envSteps) {
142
- expect(s.artifacts.envExample).toBeDefined();
143
- expect(s.risk).toBe("low");
144
- expect(s.reversible).toBe(true);
145
- }
146
- });
147
-
148
- it("rbac add emits step with rbacPatch artifact", () => {
149
- const from = makeContract({ requires: { rbac: ["payment.refund"] } });
150
- const to = makeContract({
151
- version: "0.2.0",
152
- requires: { rbac: ["payment.refund", "payment.webhook-process"] },
153
- });
154
- const plan = planMigration(diffContracts(from, to));
155
- const rbac = plan.steps.find((s) => s.kind === "rbac-add-permission");
156
- expect(rbac).toBeDefined();
157
- expect(rbac.artifacts.rbacPatch).toContain("payment.webhook-process");
158
- });
159
-
160
- it("mixed: 2 added + 1 dropped table → 3 steps + correct summary", () => {
161
- const from = makeContract({ provides: { tables: ["a", "old"] } });
162
- const to = makeContract({
163
- version: "0.2.0",
164
- provides: { tables: ["a", "b", "c"] },
165
- });
166
- const plan = planMigration(diffContracts(from, to));
167
- expect(plan.summary.totalSteps).toBe(3);
168
- expect(plan.summary.highRisk).toBe(1); // the drop
169
- expect(plan.summary.irreversible).toBe(1);
170
- const kinds = plan.steps.map((s) => s.kind).sort();
171
- expect(kinds).toEqual([
172
- "convex-schema-add-table",
173
- "convex-schema-add-table",
174
- "convex-schema-drop-table",
175
- ]);
176
- });
177
-
178
- it("summary counts match drops (highRisk + irreversible)", () => {
179
- const from = makeContract({
180
- provides: { tables: ["x", "y", "z"] },
181
- });
182
- const to = makeContract({
183
- version: "0.2.0",
184
- provides: { tables: [] },
185
- });
186
- const plan = planMigration(diffContracts(from, to));
187
- expect(plan.summary.totalSteps).toBe(3);
188
- expect(plan.summary.highRisk).toBe(3);
189
- expect(plan.summary.irreversible).toBe(3);
190
- expect(plan.warnings.length).toBe(3);
191
- });
192
-
193
- it("step ids are unique + ordered (adds precede drops)", () => {
194
- const from = makeContract({
195
- provides: { tables: ["drop_me"] },
196
- });
197
- const to = makeContract({
198
- version: "0.2.0",
199
- provides: { tables: ["added_a", "added_b"] },
200
- });
201
- const plan = planMigration(diffContracts(from, to));
202
- const ids = plan.steps.map((s) => s.id);
203
- expect(new Set(ids).size).toBe(ids.length);
204
- const kinds = plan.steps.map((s) => s.kind);
205
- const firstAdd = kinds.indexOf("convex-schema-add-table");
206
- const firstDrop = kinds.indexOf("convex-schema-drop-table");
207
- expect(firstAdd).toBeGreaterThanOrEqual(0);
208
- expect(firstDrop).toBeGreaterThanOrEqual(0);
209
- expect(firstAdd).toBeLessThan(firstDrop);
210
- });
211
-
212
- it("doku rename smoke: paymentOrders/paymentWebhookEvents → doku_orders/doku_webhook_events", () => {
213
- const from = {
214
- id: "doku-payment",
215
- version: "0.9.0",
216
- requires: { auth: "convex", env: [], rbac: [] },
217
- provides: {
218
- tables: ["paymentOrders", "paymentWebhookEvents"],
219
- },
220
- };
221
- const to = {
222
- id: "doku-payment",
223
- version: "0.1.0",
224
- requires: { auth: "convex", env: [], rbac: [] },
225
- provides: {
226
- tables: ["doku_orders", "doku_webhook_events"],
227
- },
228
- migrationFrom: { "0.9.0": "namespace-rename-2026-05" },
229
- };
230
- const plan = planMigration(diffContracts(from, to));
231
- const renameKinds = plan.steps.filter(
232
- (s) => s.kind === "convex-schema-rename-table",
233
- );
234
- expect(renameKinds).toHaveLength(2);
235
- const drops = plan.steps.filter(
236
- (s) => s.kind === "convex-schema-drop-table",
237
- );
238
- expect(drops).toHaveLength(0);
239
- // Reverse mapping present in artifacts
240
- expect(renameKinds[0].artifacts.convexMigration).toContain("paymentOrders");
241
- expect(renameKinds[0].artifacts.convexMigration).toContain("doku_orders");
242
- });
243
- });