rahman-resources 0.9.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
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
+ });