rahman-resources 1.13.0 → 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/manifest.json CHANGED
@@ -1987,7 +1987,7 @@
1987
1987
  "install": "npx rahman-resources add user-management",
1988
1988
  "npmPackages": [],
1989
1989
  "exampleCode": "",
1990
- "agentRecipe": "Run `npx rr add user-management` (pulls rbac-roles + convex-auth). Frontend: <MembersPanel members={useQuery(api[\"features/user-management/query\"].listMembers,{tenantId})} roles={ROLE_PRESETS.map(r=>({slug:r.slug,name:r.name,color:r.color}))} currentPerms={actorPerms} onUpdateRole={useMutation(...updateMemberRole)} onRemove={useMutation(...removeMember)} onInvite={openInvite} />. Wire roles + currentPerms from rbac-roles at the app level — the slice itself imports no other slice. Convex: spread userManagementTables; listMembers/mutations gate via rbac-roles requirePermission.",
1990
+ "agentRecipe": "Run `npx rr add user-management` (pulls rbac-roles + convex-auth). Frontend: <MembersPanel members={useQuery(api[\"features/user_management/query\"].listMembers,{tenantId})} roles={ROLE_PRESETS.map(r=>({slug:r.slug,name:r.name,color:r.color}))} currentPerms={actorPerms} onUpdateRole={useMutation(...updateMemberRole)} onRemove={useMutation(...removeMember)} onInvite={openInvite} />. Wire roles + currentPerms from rbac-roles at the app level — the slice itself imports no other slice. Convex: spread userManagementTables; listMembers/mutations gate via rbac-roles requirePermission.",
1991
1991
  "tags": [
1992
1992
  "user-management",
1993
1993
  "members",
@@ -3915,7 +3915,7 @@
3915
3915
  "source": "superspace",
3916
3916
  "slicePath": "frontend/slices/rbac-roles",
3917
3917
  "convexPaths": [
3918
- "convex/features/rbac-roles"
3918
+ "convex/features/rbac_roles"
3919
3919
  ],
3920
3920
  "npm": [],
3921
3921
  "shadcn": [
@@ -3959,7 +3959,7 @@
3959
3959
  "source": "superspace",
3960
3960
  "slicePath": "frontend/slices/user-management",
3961
3961
  "convexPaths": [
3962
- "convex/features/user-management"
3962
+ "convex/features/user_management"
3963
3963
  ],
3964
3964
  "npm": [],
3965
3965
  "shadcn": [
@@ -4001,7 +4001,7 @@
4001
4001
  "convex",
4002
4002
  "no-clerk"
4003
4003
  ],
4004
- "agentRecipe": "Run `npx rr add user-management` (pulls rbac-roles + convex-auth). Frontend: <MembersPanel members={useQuery(api[\"features/user-management/query\"].listMembers,{tenantId})} roles={ROLE_PRESETS.map(r=>({slug:r.slug,name:r.name,color:r.color}))} currentPerms={actorPerms} onUpdateRole={useMutation(...updateMemberRole)} onRemove={useMutation(...removeMember)} onInvite={openInvite} />. Wire roles + currentPerms from rbac-roles at the app level — the slice itself imports no other slice. Convex: spread userManagementTables; listMembers/mutations gate via rbac-roles requirePermission."
4004
+ "agentRecipe": "Run `npx rr add user-management` (pulls rbac-roles + convex-auth). Frontend: <MembersPanel members={useQuery(api[\"features/user_management/query\"].listMembers,{tenantId})} roles={ROLE_PRESETS.map(r=>({slug:r.slug,name:r.name,color:r.color}))} currentPerms={actorPerms} onUpdateRole={useMutation(...updateMemberRole)} onRemove={useMutation(...removeMember)} onInvite={openInvite} />. Wire roles + currentPerms from rbac-roles at the app level — the slice itself imports no other slice. Convex: spread userManagementTables; listMembers/mutations gate via rbac-roles requirePermission."
4005
4005
  },
4006
4006
  {
4007
4007
  "slug": "admin-panel",
@@ -4160,7 +4160,7 @@
4160
4160
  "source": "rahmanef.com",
4161
4161
  "slicePath": "frontend/slices/rate-limit",
4162
4162
  "convexPaths": [
4163
- "convex/features/rate-limit"
4163
+ "convex/features/rate_limit"
4164
4164
  ],
4165
4165
  "npm": [],
4166
4166
  "shadcn": [],
@@ -4263,7 +4263,7 @@
4263
4263
  "source": "rahmanef.com",
4264
4264
  "slicePath": "frontend/slices/create-your-mcp",
4265
4265
  "convexPaths": [
4266
- "convex/features/create-your-mcp"
4266
+ "convex/features/create_your_mcp"
4267
4267
  ],
4268
4268
  "npm": [],
4269
4269
  "shadcn": [],
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.0",
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,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
- });