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,523 @@
1
+ // compose-solver.mjs — Phase B of the Slice Composition Compiler.
2
+ //
3
+ // Given a target project's rr.json state plus a list of desired slice slugs,
4
+ // computes a compatible subset (or rejects with detailed conflicts).
5
+ //
6
+ // v2 highlights (Track I of Wave N+1):
7
+ // - rank-by-dependers conflict arbitration (was: reject-both).
8
+ // - uncontracted slugs accepted with warning by default; --strict escalates.
9
+ // - cycle detection prints the real path (no depth-cap heuristic).
10
+ // - new ConflictTypes: `uncontracted`, `both-installed-conflict`.
11
+ //
12
+ // Public API + types live in compose-solver.d.ts.
13
+ //
14
+ // Runtime contract:
15
+ // - `loadAllContracts(repoRoot)` is the only I/O entry point. It mirrors
16
+ // scripts/validation/validate-contract.mjs's tsx-eval strategy.
17
+ // - `compose(req, contracts)` is pure — no fs, no env access, no mutation
18
+ // of inputs. Always returns a fresh result object.
19
+
20
+ import { readdir } from "node:fs/promises";
21
+ import { existsSync } from "node:fs";
22
+ import { spawnSync } from "node:child_process";
23
+ import path from "node:path";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Public — loadAllContracts
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const SLICE_ROOT_GLOBS = [
30
+ ["frontend", "slices"],
31
+ ["template-base", "frontend", "slices"],
32
+ ];
33
+
34
+ /**
35
+ * Discover every `slice.contract.ts` under the kitab's known slice roots and
36
+ * load them via `npx tsx`. Returns a Map<slug, SliceContract>. Contracts that
37
+ * fail to load are silently skipped so a single broken file doesn't take
38
+ * down the whole solver — `npm run validate:contracts` is the place to surface
39
+ * those errors.
40
+ *
41
+ * @param {string} repoRoot Absolute path to the kitab repo root.
42
+ * @returns {Promise<Map<string, import("./contract").SliceContract>>}
43
+ */
44
+ export async function loadAllContracts(repoRoot) {
45
+ /** @type {Map<string, import("./contract").SliceContract>} */
46
+ const out = new Map();
47
+ const sliceFiles = await discoverContractFiles(repoRoot);
48
+ for (const filePath of sliceFiles) {
49
+ const contract = loadContractFile(repoRoot, filePath);
50
+ if (contract && typeof contract.id === "string") {
51
+ out.set(contract.id, contract);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ async function discoverContractFiles(repoRoot) {
58
+ const found = [];
59
+ for (const segs of SLICE_ROOT_GLOBS) {
60
+ const root = path.join(repoRoot, ...segs);
61
+ if (!existsSync(root)) continue;
62
+ const entries = await readdir(root, { withFileTypes: true });
63
+ for (const entry of entries) {
64
+ if (!entry.isDirectory()) continue;
65
+ if (entry.name.startsWith("_")) continue;
66
+ const filePath = path.join(root, entry.name, "slice.contract.ts");
67
+ if (existsSync(filePath)) found.push(filePath);
68
+ }
69
+ }
70
+ return found;
71
+ }
72
+
73
+ /**
74
+ * Dynamic-import a .ts contract file via `npx tsx -e` and JSON.parse the
75
+ * stringified `contract` export. Returns null on any failure.
76
+ */
77
+ function loadContractFile(repoRoot, filePath) {
78
+ const rel = "./" + path.relative(repoRoot, filePath);
79
+ const code = [
80
+ `import(${JSON.stringify(rel)})`,
81
+ ` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
82
+ ` .catch(() => process.exit(3));`,
83
+ ].join("\n");
84
+ const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
85
+ cwd: repoRoot,
86
+ encoding: "utf8",
87
+ });
88
+ if (res.status === 0 && res.stdout) {
89
+ try {
90
+ return JSON.parse(res.stdout);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Public — compose
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Pure solver. See compose-solver.d.ts for the full type contract.
104
+ *
105
+ * @param {import("./compose-solver").ComposeRequest} req
106
+ * @param {Map<string, import("./contract").SliceContract>} contracts
107
+ * @returns {import("./compose-solver").ComposeResult}
108
+ */
109
+ export function compose(req, contracts) {
110
+ const state = req?.state ?? {};
111
+ const desired = Array.isArray(req?.desired) ? [...req.desired] : [];
112
+ const resolveDeps = req?.resolveDeps !== false; // default true
113
+ const allowUnknownSlices = state.allowUnknownSlices !== false; // default true
114
+
115
+ const installed = new Set(state.slicesInstalled ?? []);
116
+ const envExisting = new Set(state.envExisting ?? []);
117
+ const rbacExisting = new Set(state.rbacRolesExisting ?? []);
118
+ const tablesExisting = new Set(state.convexTablesExisting ?? []);
119
+
120
+ /** @type {string[]} */
121
+ const proof = [];
122
+ /** @type {import("./compose-solver").Conflict[]} */
123
+ const allConflicts = [];
124
+ /** @type {Map<string, import("./compose-solver").Conflict[]>} */
125
+ const blockersBySlug = new Map();
126
+ /** @type {import("./compose-solver").Arbitration[]} */
127
+ const arbitrations = [];
128
+ /** @type {Map<string, string>} */
129
+ const notes = new Map();
130
+ /** @type {Set<string>} */
131
+ const uncontractedDesired = new Set();
132
+
133
+ // Helper: record a conflict and (if blocker) attribute it to a slug.
134
+ function record(conflict, attributeTo = conflict.slug) {
135
+ allConflicts.push(conflict);
136
+ if (conflict.severity === "blocker") {
137
+ const cur = blockersBySlug.get(attributeTo) ?? [];
138
+ cur.push(conflict);
139
+ blockersBySlug.set(attributeTo, cur);
140
+ }
141
+ }
142
+
143
+ // ── Step 1: validate desired entries, then BFS-resolve transitive deps. ──
144
+ /** @type {string[]} */
145
+ const candidateOrder = [];
146
+ const candidateSet = new Set();
147
+ /** @type {Set<string>} */
148
+ const userTyped = new Set(desired);
149
+
150
+ // First, fold in each desired slug. Unknown contracts are blocker-rejected
151
+ // (strict mode) or warning-accepted (default, `allowUnknownSlices: true`).
152
+ for (const slug of desired) {
153
+ if (candidateSet.has(slug)) continue;
154
+ const contract = contracts.get(slug);
155
+ if (!contract) {
156
+ if (allowUnknownSlices) {
157
+ record(
158
+ {
159
+ type: "uncontracted",
160
+ slug,
161
+ detail: `Slice "${slug}" has no registered slice.contract.ts — accepted under allowUnknownSlices, but conflict checks are skipped for it.`,
162
+ severity: "warning",
163
+ },
164
+ slug,
165
+ );
166
+ uncontractedDesired.add(slug);
167
+ candidateSet.add(slug);
168
+ candidateOrder.push(slug);
169
+ notes.set(slug, "uncontracted");
170
+ proof.push(`! ${slug}: accepted as uncontracted (no slice.contract.ts; skipping conflict checks)`);
171
+ } else {
172
+ record(
173
+ {
174
+ type: "missing-dep",
175
+ slug,
176
+ detail: `Contract not found for "${slug}" — no slice.contract.ts registered (strict mode).`,
177
+ severity: "blocker",
178
+ },
179
+ slug,
180
+ );
181
+ proof.push(`- ${slug}: rejected (no contract found, strict mode)`);
182
+ }
183
+ continue;
184
+ }
185
+ candidateSet.add(slug);
186
+ candidateOrder.push(slug);
187
+ }
188
+
189
+ if (resolveDeps) {
190
+ // BFS over requires.deps[]. We use a proper visited-path map per starting
191
+ // root so we can print the full cycle when one is encountered.
192
+ /** @type {Array<{ slug: string; chain: string[] }>} */
193
+ const queue = candidateOrder
194
+ .filter((s) => contracts.has(s))
195
+ .map((slug) => ({ slug, chain: [slug] }));
196
+ while (queue.length > 0) {
197
+ const { slug, chain } = queue.shift();
198
+ const contract = contracts.get(slug);
199
+ const deps = contract?.requires?.deps ?? [];
200
+ for (const dep of deps) {
201
+ if (chain.includes(dep)) {
202
+ const cyclePath = [...chain.slice(chain.indexOf(dep)), dep].join(" → ");
203
+ throw new Error(`dependency cycle detected: ${cyclePath}`);
204
+ }
205
+ if (installed.has(dep)) {
206
+ continue;
207
+ }
208
+ if (candidateSet.has(dep)) continue;
209
+ const depContract = contracts.get(dep);
210
+ if (!depContract) {
211
+ record(
212
+ {
213
+ type: "missing-dep",
214
+ slug,
215
+ withSlug: dep,
216
+ detail: `Slice "${slug}" requires "${dep}" but no contract is registered for it.`,
217
+ severity: "blocker",
218
+ },
219
+ slug,
220
+ );
221
+ proof.push(`- ${slug}: missing dep "${dep}" (not in candidates, not installed)`);
222
+ continue;
223
+ }
224
+ candidateSet.add(dep);
225
+ candidateOrder.push(dep);
226
+ proof.push(`+ ${dep}: pulled in as transitive dep of ${slug}`);
227
+ queue.push({ slug: dep, chain: [...chain, dep] });
228
+ }
229
+ }
230
+ } else {
231
+ for (const slug of candidateOrder) {
232
+ const deps = contracts.get(slug)?.requires?.deps ?? [];
233
+ for (const dep of deps) {
234
+ if (installed.has(dep) || candidateSet.has(dep)) continue;
235
+ record(
236
+ {
237
+ type: "missing-dep",
238
+ slug,
239
+ withSlug: dep,
240
+ detail: `Slice "${slug}" requires "${dep}" (resolveDeps disabled — not auto-pulled).`,
241
+ severity: "blocker",
242
+ },
243
+ slug,
244
+ );
245
+ }
246
+ }
247
+ }
248
+
249
+ // Build a dependers-count map: for each candidate, count how many OTHER
250
+ // candidates list it in their `requires.deps[]`. Used for arbitration
251
+ // ranking in Step 2.
252
+ /** @type {Map<string, number>} */
253
+ const dependersCount = new Map();
254
+ for (const slug of candidateOrder) dependersCount.set(slug, 0);
255
+ for (const slug of candidateOrder) {
256
+ const c = contracts.get(slug);
257
+ if (!c) continue;
258
+ for (const d of c.requires?.deps ?? []) {
259
+ if (dependersCount.has(d)) {
260
+ dependersCount.set(d, (dependersCount.get(d) ?? 0) + 1);
261
+ }
262
+ }
263
+ }
264
+
265
+ // ── Step 2: conflict checks. ────────────────────────────────────────────
266
+ for (const slug of candidateOrder) {
267
+ if (uncontractedDesired.has(slug)) continue;
268
+ const contract = contracts.get(slug);
269
+ if (!contract) continue;
270
+
271
+ const wantAuth = contract.requires?.auth;
272
+ if (wantAuth && state.auth && wantAuth !== state.auth && wantAuth !== "none") {
273
+ record(
274
+ {
275
+ type: "auth-mismatch",
276
+ slug,
277
+ detail: `Slice requires auth="${wantAuth}" but target rr.json has auth="${state.auth}".`,
278
+ severity: "blocker",
279
+ },
280
+ slug,
281
+ );
282
+ }
283
+
284
+ const provTables = contract.provides?.tables ?? [];
285
+ for (const t of provTables) {
286
+ if (tablesExisting.has(t)) {
287
+ record(
288
+ {
289
+ type: "table-collision",
290
+ slug,
291
+ detail: `Table "${t}" already exists in target Convex schema.`,
292
+ severity: "blocker",
293
+ },
294
+ slug,
295
+ );
296
+ }
297
+ }
298
+
299
+ const reqEnv = contract.requires?.env ?? [];
300
+ for (const e of reqEnv) {
301
+ if (!envExisting.has(e)) {
302
+ record(
303
+ {
304
+ type: "env-missing",
305
+ slug,
306
+ detail: `Env var "${e}" required by ${slug} not present in target.`,
307
+ severity: "warning",
308
+ },
309
+ slug,
310
+ );
311
+ }
312
+ }
313
+ }
314
+
315
+ // Build provides lookup once.
316
+ /** @type {Map<string, { tables: Set<string>; rbac: Set<string> }>} */
317
+ const lookup = new Map();
318
+ for (const slug of candidateOrder) {
319
+ if (uncontractedDesired.has(slug)) continue;
320
+ const c = contracts.get(slug);
321
+ if (!c) continue;
322
+ lookup.set(slug, {
323
+ tables: new Set(c.provides?.tables ?? []),
324
+ rbac: new Set(c.requires?.rbac ?? []),
325
+ });
326
+ }
327
+
328
+ /**
329
+ * Pair-conflict arbitration helper.
330
+ *
331
+ * @param {string} a
332
+ * @param {string} b
333
+ * @param {import("./compose-solver").ConflictType} type
334
+ * @param {string} detailA
335
+ * @param {string} detailB
336
+ */
337
+ function arbitratePair(a, b, type, detailA, detailB) {
338
+ const ca = /** @type {import("./compose-solver").Conflict} */ ({
339
+ type, slug: a, withSlug: b, detail: detailA, severity: "blocker",
340
+ });
341
+ const cb = /** @type {import("./compose-solver").Conflict} */ ({
342
+ type, slug: b, withSlug: a, detail: detailB, severity: "blocker",
343
+ });
344
+ const bothInstalled = installed.has(a) && installed.has(b);
345
+ if (bothInstalled) {
346
+ allConflicts.push({ ...ca, severity: "warning", type: "both-installed-conflict" });
347
+ allConflicts.push({ ...cb, severity: "warning", type: "both-installed-conflict" });
348
+ notes.set(a, notes.get(a) ?? "both-installed-conflict");
349
+ notes.set(b, notes.get(b) ?? "both-installed-conflict");
350
+ proof.push(`! ${a} ↔ ${b}: both already installed — conflict surfaced as warning, neither dropped`);
351
+ return;
352
+ }
353
+ if (installed.has(a) && !installed.has(b)) {
354
+ record(cb, b);
355
+ arbitrations.push({
356
+ conflict: cb, winner: a, loser: b,
357
+ reason: `"${a}" already installed — installed slice wins`,
358
+ });
359
+ proof.push(`- ${b}: arbitrated against ${a} (installed wins)`);
360
+ return;
361
+ }
362
+ if (installed.has(b) && !installed.has(a)) {
363
+ record(ca, a);
364
+ arbitrations.push({
365
+ conflict: ca, winner: b, loser: a,
366
+ reason: `"${b}" already installed — installed slice wins`,
367
+ });
368
+ proof.push(`- ${a}: arbitrated against ${b} (installed wins)`);
369
+ return;
370
+ }
371
+ const depA = dependersCount.get(a) ?? 0;
372
+ const depB = dependersCount.get(b) ?? 0;
373
+ let winner, loser, conflictForLoser, reason;
374
+ if (depA !== depB) {
375
+ if (depA > depB) {
376
+ winner = a; loser = b; conflictForLoser = cb;
377
+ reason = `"${a}" has ${depA} dependers vs "${b}" with ${depB} — most-dependers wins`;
378
+ } else {
379
+ winner = b; loser = a; conflictForLoser = ca;
380
+ reason = `"${b}" has ${depB} dependers vs "${a}" with ${depA} — most-dependers wins`;
381
+ }
382
+ } else {
383
+ const later = a > b ? a : b;
384
+ const earlier = a > b ? b : a;
385
+ winner = earlier;
386
+ loser = later;
387
+ conflictForLoser = later === a ? ca : cb;
388
+ reason = `tie at ${depA} dependers — alphabetical tiebreak drops "${later}"`;
389
+ }
390
+ record(conflictForLoser, loser);
391
+ arbitrations.push({ conflict: conflictForLoser, winner, loser, reason });
392
+ proof.push(`- ${loser}: arbitrated against ${winner} (${reason})`);
393
+ }
394
+
395
+ for (let i = 0; i < candidateOrder.length; i++) {
396
+ for (let j = i + 1; j < candidateOrder.length; j++) {
397
+ const a = candidateOrder[i];
398
+ const b = candidateOrder[j];
399
+ const la = lookup.get(a);
400
+ const lb = lookup.get(b);
401
+ if (!la || !lb) continue;
402
+
403
+ const tableHits = [];
404
+ for (const t of la.tables) if (lb.tables.has(t)) tableHits.push(t);
405
+ if (tableHits.length > 0) {
406
+ const detail = `Slices "${a}" and "${b}" both declare table${tableHits.length > 1 ? "s" : ""} ${tableHits.map((t) => `"${t}"`).join(", ")}.`;
407
+ arbitratePair(a, b, "table-collision", detail, detail);
408
+ }
409
+ for (const p of la.rbac) {
410
+ if (lb.rbac.has(p)) {
411
+ allConflicts.push({
412
+ type: "rbac-collision",
413
+ slug: a,
414
+ withSlug: b,
415
+ detail: `Slices "${a}" and "${b}" both declare RBAC permission "${p}".`,
416
+ severity: "warning",
417
+ });
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ // 2e. explicit-conflict — slice declares conflicts: ["<other>:<key>.<value>"].
424
+ for (const slug of candidateOrder) {
425
+ if (uncontractedDesired.has(slug)) continue;
426
+ const c = contracts.get(slug);
427
+ if (!c) continue;
428
+ const conflicts = c.conflicts ?? [];
429
+ for (const cf of conflicts) {
430
+ const colon = cf.indexOf(":");
431
+ const dot = cf.indexOf(".", colon);
432
+ if (colon < 0 || dot < 0) continue;
433
+ const otherSlug = cf.slice(0, colon);
434
+ const key = cf.slice(colon + 1, dot);
435
+ const value = cf.slice(dot + 1);
436
+ if (!candidateSet.has(otherSlug)) continue;
437
+ if (uncontractedDesired.has(otherSlug)) continue;
438
+ const other = contracts.get(otherSlug);
439
+ if (!other) continue;
440
+ const provided = other.provides?.[key];
441
+ if (Array.isArray(provided) && provided.includes(value)) {
442
+ const detailA = `Slice "${slug}" declares explicit conflict with "${otherSlug}" on ${key}.${value}.`;
443
+ const detailB = `Slice "${otherSlug}" is the target of "${slug}"'s explicit conflict on ${key}.${value}.`;
444
+ arbitratePair(slug, otherSlug, "explicit-conflict", detailA, detailB);
445
+ }
446
+ }
447
+ }
448
+
449
+ // ── Step 3: decide accepted / rejected. ─────────────────────────────────
450
+ /** @type {Set<string>} */
451
+ const finalRejected = new Set();
452
+ for (const [slug, blocks] of blockersBySlug) {
453
+ if (installed.has(slug)) continue;
454
+ if (blocks.length > 0) finalRejected.add(slug);
455
+ }
456
+
457
+ // ── Step 4: assemble result. ────────────────────────────────────────────
458
+ /** @type {string[]} */
459
+ const accepted = [];
460
+ /** @type {{ slug: string; reasons: import("./compose-solver").Conflict[]; note?: string }[]} */
461
+ const rejected = [];
462
+ /** @type {{ slug: string; tables: string[] }[]} */
463
+ const tablesAdded = [];
464
+ const envMissingSet = new Set();
465
+ const rbacToCreateSet = new Set();
466
+
467
+ for (const slug of candidateOrder) {
468
+ const contract = contracts.get(slug);
469
+ if (!contract) {
470
+ if (uncontractedDesired.has(slug)) {
471
+ accepted.push(slug);
472
+ proof.push(`+ ${slug}: accepted (uncontracted, no contract surface checked)`);
473
+ } else {
474
+ rejected.push({ slug, reasons: blockersBySlug.get(slug) ?? [] });
475
+ }
476
+ continue;
477
+ }
478
+ if (finalRejected.has(slug)) {
479
+ rejected.push({ slug, reasons: blockersBySlug.get(slug) ?? [] });
480
+ const reasons = (blockersBySlug.get(slug) ?? []).map((r) => r.type).join(", ");
481
+ proof.push(`- ${slug}: rejected (${reasons})`);
482
+ continue;
483
+ }
484
+ accepted.push(slug);
485
+ const tables = contract.provides?.tables ?? [];
486
+ if (tables.length > 0) tablesAdded.push({ slug, tables: [...tables] });
487
+ for (const e of contract.requires?.env ?? []) {
488
+ if (!envExisting.has(e)) envMissingSet.add(e);
489
+ }
490
+ for (const p of contract.requires?.rbac ?? []) {
491
+ if (!rbacExisting.has(p)) rbacToCreateSet.add(p);
492
+ }
493
+
494
+ const detail = [];
495
+ if (contract.requires?.auth) detail.push(`auth=${contract.requires.auth}`);
496
+ if (tables.length > 0) detail.push(`tables=${tables.join("+")}`);
497
+ if (userTyped.has(slug)) detail.push("user-requested");
498
+ else detail.push("transitive dep");
499
+ proof.push(`+ ${slug}: accepted (${detail.join(", ")})`);
500
+ }
501
+
502
+ // Re-handle desired slugs whose contract is missing AND strict-rejected.
503
+ for (const slug of desired) {
504
+ if (contracts.has(slug)) continue;
505
+ if (uncontractedDesired.has(slug)) continue;
506
+ if (rejected.some((r) => r.slug === slug)) continue;
507
+ if (accepted.includes(slug)) continue;
508
+ rejected.push({ slug, reasons: blockersBySlug.get(slug) ?? [] });
509
+ }
510
+
511
+ const notesObj = notes.size > 0 ? Object.fromEntries(notes) : undefined;
512
+ return {
513
+ accepted,
514
+ rejected,
515
+ conflicts: allConflicts,
516
+ envMissing: [...envMissingSet],
517
+ rbacToCreate: [...rbacToCreateSet],
518
+ tablesAdded,
519
+ proof,
520
+ ...(arbitrations.length > 0 ? { arbitrations } : {}),
521
+ ...(notesObj ? { notes: notesObj } : {}),
522
+ };
523
+ }