rahman-resources 0.9.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/update.mjs ADDED
@@ -0,0 +1,413 @@
1
+ // `rr update <slug>` — bidirectional sync: pull the latest kitab version of a
2
+ // slice into the consumer's local copy via 3-way semantic merge.
3
+ //
4
+ // Flow:
5
+ // 1. Locate the kitab repo root + consumer slice dir (from rr.json install
6
+ // records).
7
+ // 2. Build a base snapshot from the lineage-pinned kitab commit (via git
8
+ // show), falling back to the kitab tip when there's no DNA entry yet.
9
+ // 3. Build a kitab-tip snapshot from frontend/slices/<slug>/.
10
+ // 4. Build a consumer snapshot from the local slice dir.
11
+ // 5. Run merge3() — print the summary + outcomes table.
12
+ // 6. If --apply, applyMerge() to the consumer dir; refuse when conflicts
13
+ // remain unless --force.
14
+ // 7. Append a "3-way-merge" lineage entry and upsert the consumer's
15
+ // `drift_score = report.driftAfterMerge`.
16
+ //
17
+ // Flags:
18
+ // --apply write merged files to consumer dir (otherwise dry preview)
19
+ // --force allow apply even with conflicts (uses each side's last value)
20
+ // --rr-path P explicit rr.json path (overrides cwd discovery)
21
+ // --json emit machine-readable JSON instead of ASCII
22
+
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { spawnSync } from "node:child_process";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ import kleur from "kleur";
29
+
30
+ import { merge3, applyMerge } from "../lib/merge3.mjs";
31
+ import { snapshotFromDir } from "../lib/snapshot.mjs";
32
+ import {
33
+ readDNA,
34
+ appendLineage,
35
+ upsertConsumerAdoption,
36
+ } from "../lib/dna.mjs";
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+
40
+ /**
41
+ * Entry point invoked by cli.js with the post-`update` argv tail.
42
+ * @param {string[]} rest
43
+ */
44
+ export async function runUpdate(rest) {
45
+ const { positional, flags } = parseFlags(rest);
46
+ const slug = positional[0];
47
+ if (!slug) {
48
+ process.stderr.write(
49
+ kleur.red("Usage: rahman-resources update <slug> [--apply] [--force] [--rr-path P] [--json]\n"),
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ const asJson = !!flags.json;
55
+ const apply = !!flags.apply;
56
+ const force = !!flags.force;
57
+ const rrPath = typeof flags["rr-path"] === "string" ? flags["rr-path"] : null;
58
+
59
+ const ctx = resolveContext(slug, rrPath);
60
+
61
+ const kitabSnap = await buildKitabSnapshot(slug, ctx);
62
+ const baseSnap = await buildBaseSnapshot(slug, ctx, kitabSnap);
63
+ const consumerSnap = await buildConsumerSnapshot(slug, ctx);
64
+
65
+ const report = merge3({ base: baseSnap, kitab: kitabSnap, consumer: consumerSnap });
66
+
67
+ if (asJson) {
68
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
69
+ } else {
70
+ printReport(report, ctx);
71
+ }
72
+
73
+ const hasConflicts = report.summary.conflicts > 0;
74
+ if (apply) {
75
+ if (hasConflicts && !force) {
76
+ if (!asJson) {
77
+ process.stderr.write(
78
+ kleur.red(
79
+ `\n✖ Refusing to apply — ${report.summary.conflicts} conflict(s) remain. Resolve them or pass --force.\n`,
80
+ ),
81
+ );
82
+ }
83
+ process.exit(1);
84
+ }
85
+ const targetDir = ctx.consumerSliceDir;
86
+ if (hasConflicts && force) {
87
+ // Build a forced snapshot — prefer kitab on conflicts (matches "pull
88
+ // kitab" semantics; consumer can revert manually after).
89
+ const forced = buildForcedSnapshot(report, kitabSnap);
90
+ // Write files directly, bypassing applyMerge's conflict guard.
91
+ await writeForced(forced, targetDir);
92
+ } else {
93
+ await applyMerge(report, targetDir);
94
+ }
95
+ if (!asJson) {
96
+ process.stdout.write(kleur.green(`\n✓ Applied merged files to ${ctx.consumerSliceDir}\n`));
97
+ }
98
+ }
99
+
100
+ // Update DNA lineage — only when we actually applied OR when explicitly
101
+ // asked via --json (machine flows record every sync attempt).
102
+ if (apply || asJson) {
103
+ recordLineage(slug, ctx, report);
104
+ }
105
+
106
+ if (hasConflicts && !force) {
107
+ process.exit(1);
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Context resolution
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /**
116
+ * @typedef {{
117
+ * repoRoot: string,
118
+ * rrPath: string,
119
+ * rr: any,
120
+ * consumerName: string,
121
+ * kitabRoot: string,
122
+ * kitabSliceDir: string,
123
+ * consumerSliceDir: string,
124
+ * }} UpdateContext
125
+ */
126
+
127
+ function resolveContext(slug, explicitRrPath) {
128
+ // The kitab repo lives above packages/cli/bin/.
129
+ const kitabRoot = findKitabRoot();
130
+ const kitabSliceDir = path.join(kitabRoot, "frontend", "slices", slug);
131
+ if (!existsSync(kitabSliceDir)) {
132
+ throw new Error(
133
+ `update: kitab slice not found at ${kitabSliceDir}. (Did you mean a different slug?)`,
134
+ );
135
+ }
136
+
137
+ const rrPath = explicitRrPath ? path.resolve(explicitRrPath) : path.resolve(process.cwd(), "rr.json");
138
+ if (!existsSync(rrPath)) {
139
+ throw new Error(
140
+ `update: rr.json not found at ${rrPath}. Pass --rr-path or run from a consumer project.`,
141
+ );
142
+ }
143
+ const rr = JSON.parse(readFileSync(rrPath, "utf8"));
144
+
145
+ const consumerName = inferConsumerName(rr, rrPath);
146
+ const consumerSliceDir = resolveConsumerSliceDir(rr, rrPath, slug);
147
+
148
+ return {
149
+ repoRoot: kitabRoot,
150
+ rrPath,
151
+ rr,
152
+ consumerName,
153
+ kitabRoot,
154
+ kitabSliceDir,
155
+ consumerSliceDir,
156
+ };
157
+ }
158
+
159
+ function findKitabRoot() {
160
+ let dir = __dirname;
161
+ for (let i = 0; i < 8; i++) {
162
+ if (
163
+ existsSync(path.join(dir, "packages")) &&
164
+ existsSync(path.join(dir, "frontend", "slices"))
165
+ ) {
166
+ return dir;
167
+ }
168
+ const parent = path.dirname(dir);
169
+ if (parent === dir) break;
170
+ dir = parent;
171
+ }
172
+ return process.cwd();
173
+ }
174
+
175
+ function inferConsumerName(rr, rrPath) {
176
+ if (rr?.consumer && typeof rr.consumer === "string") return rr.consumer;
177
+ if (rr?.template?.slug && typeof rr.template.slug === "string") {
178
+ return rr.template.slug;
179
+ }
180
+ return path.basename(path.dirname(rrPath));
181
+ }
182
+
183
+ function resolveConsumerSliceDir(rr, rrPath, slug) {
184
+ const rrDir = path.dirname(rrPath);
185
+ // Honor a slice-root override if present in rr.json, else default to
186
+ // frontend/slices/<slug>/ — matches the kitab convention.
187
+ const sliceRoot =
188
+ rr?.layout?.sliceRoot && typeof rr.layout.sliceRoot === "string"
189
+ ? rr.layout.sliceRoot
190
+ : "frontend/slices";
191
+ return path.resolve(rrDir, sliceRoot, slug);
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Snapshot builders
196
+ // ---------------------------------------------------------------------------
197
+
198
+ async function buildKitabSnapshot(slug, ctx) {
199
+ return snapshotFromDir(slug, ctx.kitabSliceDir);
200
+ }
201
+
202
+ async function buildConsumerSnapshot(slug, ctx) {
203
+ if (!existsSync(ctx.consumerSliceDir)) {
204
+ // First-time sync — consumer has no copy yet; treat as empty snapshot.
205
+ return { slug, version: "0.0.0", files: {} };
206
+ }
207
+ return snapshotFromDir(slug, ctx.consumerSliceDir);
208
+ }
209
+
210
+ async function buildBaseSnapshot(slug, ctx, kitabSnap) {
211
+ // First-time sync (no DNA) → use kitab tip as base.
212
+ const dna = readDNA(slug);
213
+ const consumerAd = dna?.consumers?.[ctx.consumerName];
214
+ if (!consumerAd?.version) {
215
+ return cloneSnap(kitabSnap);
216
+ }
217
+
218
+ // Look up the commit by version tag, fall back to the most-recent commit
219
+ // touching the slice path.
220
+ const ref = findCommitForVersion(ctx.kitabRoot, slug, consumerAd.version);
221
+ if (!ref) return cloneSnap(kitabSnap);
222
+
223
+ const files = readSliceAtRef(ctx.kitabRoot, slug, ref);
224
+ // Snapshot at base ref typically lacks a parsed contract — that's OK; the
225
+ // merge algorithm treats it as no-membership, which mirrors "base had none".
226
+ return { slug, version: consumerAd.version, files };
227
+ }
228
+
229
+ function cloneSnap(snap) {
230
+ return {
231
+ slug: snap.slug,
232
+ version: snap.version,
233
+ files: { ...snap.files },
234
+ ...(snap.contract ? { contract: JSON.parse(JSON.stringify(snap.contract)) } : {}),
235
+ };
236
+ }
237
+
238
+ /** Try `git rev-list -1 <tag>` then `git log -1 --format=%H -- <slicePath>`. */
239
+ function findCommitForVersion(repo, slug, version) {
240
+ const sliceRel = `frontend/slices/${slug}`;
241
+ for (const tag of [version, `v${version}`, `${slug}@${version}`]) {
242
+ const r = spawnSync("git", ["rev-list", "-1", tag], {
243
+ cwd: repo,
244
+ encoding: "utf8",
245
+ });
246
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
247
+ }
248
+ // Fallback: most-recent commit touching the slice path.
249
+ const r = spawnSync(
250
+ "git",
251
+ ["log", "-1", "--format=%H", "main", "--", sliceRel],
252
+ { cwd: repo, encoding: "utf8" },
253
+ );
254
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
255
+ return null;
256
+ }
257
+
258
+ /** Read every tracked file under frontend/slices/<slug>/ at `ref` into a map. */
259
+ function readSliceAtRef(repo, slug, ref) {
260
+ const sliceRel = `frontend/slices/${slug}`;
261
+ const ls = spawnSync(
262
+ "git",
263
+ ["ls-tree", "-r", "--name-only", ref, "--", sliceRel],
264
+ { cwd: repo, encoding: "utf8" },
265
+ );
266
+ /** @type {Record<string,string>} */
267
+ const out = {};
268
+ if (ls.status !== 0) return out;
269
+ const lines = ls.stdout.split("\n").filter(Boolean);
270
+ for (const line of lines) {
271
+ const rel = line.startsWith(sliceRel + "/")
272
+ ? line.slice(sliceRel.length + 1)
273
+ : line;
274
+ // Skip files we don't snapshot (binary etc).
275
+ if (!/\.(ts|tsx|mjs|js|jsx|json|md|css)$/.test(rel)) continue;
276
+ const show = spawnSync("git", ["show", `${ref}:${line}`], {
277
+ cwd: repo,
278
+ encoding: "utf8",
279
+ });
280
+ if (show.status === 0) out[rel] = show.stdout;
281
+ }
282
+ return out;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Output / DNA / forced-apply helpers
287
+ // ---------------------------------------------------------------------------
288
+
289
+ function printReport(report, ctx) {
290
+ const s = report.summary;
291
+ process.stdout.write(
292
+ `\n${kleur.bold("3-way merge")} — ${kleur.cyan(report.slug)} ` +
293
+ kleur.dim(`(consumer: ${ctx.consumerName})`) +
294
+ "\n",
295
+ );
296
+ process.stdout.write(
297
+ ` ${kleur.green("auto-merged:")} ${s.autoMerged} ` +
298
+ `${kleur.green("kitab-clean:")} ${s.kitabWinsClean} ` +
299
+ `${kleur.yellow("consumer-clean:")} ${s.consumerWinsClean} ` +
300
+ `${kleur.red("conflicts:")} ${s.conflicts} ` +
301
+ `${kleur.dim("identical:")} ${s.identical}\n`,
302
+ );
303
+ process.stdout.write(
304
+ ` ${kleur.bold("drift after merge:")} ${formatDrift(report.driftAfterMerge)}\n`,
305
+ );
306
+
307
+ const nonIdentical = report.outcomes.filter((o) => o.kind !== "identical");
308
+ if (nonIdentical.length > 0) {
309
+ process.stdout.write(`\n${kleur.bold("Outcomes")}\n`);
310
+ for (const o of nonIdentical) {
311
+ const tag = kindTag(o.kind);
312
+ process.stdout.write(` ${tag} ${o.element}${o.conflictHint ? kleur.dim(` — ${o.conflictHint}`) : ""}\n`);
313
+ }
314
+ }
315
+ process.stdout.write("\n");
316
+ }
317
+
318
+ function formatDrift(d) {
319
+ if (d >= 40) return kleur.red(`${d}%`);
320
+ if (d >= 15) return kleur.yellow(`${d}%`);
321
+ return kleur.green(`${d}%`);
322
+ }
323
+
324
+ function kindTag(k) {
325
+ switch (k) {
326
+ case "auto-merged":
327
+ return kleur.green("[auto] ");
328
+ case "kitab-wins-clean":
329
+ return kleur.green("[kitab] ");
330
+ case "consumer-wins-clean":
331
+ return kleur.yellow("[consumer]");
332
+ case "conflict":
333
+ return kleur.red("[conflict]");
334
+ default:
335
+ return kleur.dim("[same] ");
336
+ }
337
+ }
338
+
339
+ function recordLineage(slug, ctx, report) {
340
+ const at = new Date().toISOString();
341
+ try {
342
+ appendLineage(slug, {
343
+ from: `kitab:frontend/slices/${slug}`,
344
+ to: `consumer:${ctx.consumerName}`,
345
+ at,
346
+ transforms: ["3-way-merge", "consumer-sync"],
347
+ actor: "rr update",
348
+ });
349
+ const dna = readDNA(slug);
350
+ const existing = dna?.consumers?.[ctx.consumerName];
351
+ upsertConsumerAdoption(slug, ctx.consumerName, {
352
+ adopted_at: existing?.adopted_at ?? at,
353
+ version: report.mergedSnapshot?.version ?? existing?.version ?? "0.0.0",
354
+ drift_score: report.driftAfterMerge,
355
+ last_synced_at: at,
356
+ });
357
+ } catch (err) {
358
+ process.stderr.write(
359
+ kleur.yellow(
360
+ ` (could not update DNA lineage: ${err.message ?? err})\n`,
361
+ ),
362
+ );
363
+ }
364
+ }
365
+
366
+ function buildForcedSnapshot(report, kitabSnap) {
367
+ /** @type {Record<string,string>} */
368
+ const files = {};
369
+ for (const o of report.outcomes) {
370
+ if (!o.element.startsWith("files/")) continue;
371
+ const rel = o.element.slice("files/".length);
372
+ if (o.kind === "conflict") {
373
+ // On force-apply, prefer kitab value (or consumer if kitab dropped).
374
+ const v = o.kitabValue ?? o.consumerValue;
375
+ if (v != null) files[rel] = /** @type {string} */ (v);
376
+ } else if (o.mergedValue != null) {
377
+ files[rel] = /** @type {string} */ (o.mergedValue);
378
+ }
379
+ }
380
+ return { slug: kitabSnap.slug, version: kitabSnap.version, files };
381
+ }
382
+
383
+ async function writeForced(snap, targetDir) {
384
+ const { mkdir, writeFile } = await import("node:fs/promises");
385
+ for (const [rel, content] of Object.entries(snap.files)) {
386
+ const dest = path.join(targetDir, rel);
387
+ await mkdir(path.dirname(dest), { recursive: true });
388
+ await writeFile(dest, content);
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+
394
+ function parseFlags(rest) {
395
+ const positional = [];
396
+ const flags = {};
397
+ for (let i = 0; i < rest.length; i++) {
398
+ const a = rest[i];
399
+ if (a.startsWith("--")) {
400
+ const key = a.slice(2);
401
+ const next = rest[i + 1];
402
+ if (next && !next.startsWith("--")) {
403
+ flags[key] = next;
404
+ i++;
405
+ } else {
406
+ flags[key] = true;
407
+ }
408
+ } else {
409
+ positional.push(a);
410
+ }
411
+ }
412
+ return { positional, flags };
413
+ }
@@ -0,0 +1,179 @@
1
+ // Type definitions for the slice compose solver (Phase B).
2
+ // Runtime in compose-solver.mjs; this file is hand-authored types for
3
+ // tsc/IDE consumers. Mirrors the Phase C `dna.d.ts` convention since
4
+ // `packages/**` is excluded from the root tsconfig.
5
+
6
+ import type { SliceContract } from "./contract";
7
+
8
+ /**
9
+ * The relevant slice of a project's `rr.json` (plus optional ambient
10
+ * data the solver needs but the schema doesn't yet codify).
11
+ *
12
+ * Every field is optional — an empty {} is the canonical "nothing
13
+ * pre-existing" state. The CLI dispatcher fills these from the parsed
14
+ * rr.json plus any environment scans.
15
+ */
16
+ export interface RrJsonState {
17
+ /**
18
+ * Target identity provider. Mapped from `rr.json#/auth.provider`
19
+ * ("convex-auth" → "convex") by the CLI dispatcher before passing in.
20
+ */
21
+ auth?: "convex" | "clerk" | "next-auth" | "none";
22
+ /** Env vars already set in target (e.g. parsed from `.env.example`). */
23
+ envExisting?: string[];
24
+ /** RBAC permissions already in target — e.g. "user.read". */
25
+ rbacRolesExisting?: string[];
26
+ /** Slugs already installed (mirrors `rr.json#/slices` + `/features`). */
27
+ slicesInstalled?: string[];
28
+ /** Convex table names already in target schema. */
29
+ convexTablesExisting?: string[];
30
+ /**
31
+ * When `true` (default), a desired slug with no registered contract is
32
+ * surfaced as an `uncontracted` warning and accepted with a `note`. When
33
+ * `false` (e.g. `--strict`), it becomes a blocker `missing-dep`. Useful for
34
+ * gradual migrations where most slices are still un-contracted.
35
+ */
36
+ allowUnknownSlices?: boolean;
37
+ }
38
+
39
+ /** Input bundle for {@link compose}. */
40
+ export interface ComposeRequest {
41
+ state: RrJsonState;
42
+ /** Slice slugs the user wants to add. */
43
+ desired: string[];
44
+ /**
45
+ * When true (default), the solver pulls in transitive `requires.deps[]`.
46
+ * Set to false to disable BFS dep resolution (CLI `--no-deps`).
47
+ */
48
+ resolveDeps?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Discriminator for {@link Conflict.type}.
53
+ *
54
+ * - `auth-mismatch` — slice requires auth X, target has Y. **blocker**.
55
+ * - `table-collision` — same table declared by 2 candidates, OR slice
56
+ * declares a table already in `state.convexTablesExisting`. **blocker**
57
+ * (between two new candidates → arbitrated, not reject-both).
58
+ * - `rbac-collision` — 2 candidates declare same RBAC permission.
59
+ * **warning** — operator can decide if sharing is OK.
60
+ * - `missing-dep` — slice declares `requires.deps[X]` but X is neither
61
+ * in the candidate set nor in `state.slicesInstalled`. **blocker**.
62
+ * ALSO emitted for desired slugs whose contract is unknown **only in
63
+ * strict mode** (`state.allowUnknownSlices === false`).
64
+ * - `env-missing` — `requires.env[]` ⊄ `state.envExisting`. **warning**.
65
+ * - `explicit-conflict` — slice's `conflicts: ["<other>:<key>.<value>"]`
66
+ * matched `<other>`'s `provides.<key>` when both are in the candidate
67
+ * set. **blocker** (arbitrated, not reject-both).
68
+ * - `uncontracted` — desired slug had no registered contract while
69
+ * `state.allowUnknownSlices` was true. **warning** — the slice is
70
+ * accepted but its surface is not inspected.
71
+ * - `both-installed-conflict` — an explicit-conflict / table-collision
72
+ * surfaced between two slices BOTH already in `state.slicesInstalled`.
73
+ * **warning** — neither is dropped, operator is told to clean up.
74
+ */
75
+ export type ConflictType =
76
+ | "auth-mismatch"
77
+ | "table-collision"
78
+ | "rbac-collision"
79
+ | "missing-dep"
80
+ | "env-missing"
81
+ | "explicit-conflict"
82
+ | "uncontracted"
83
+ | "both-installed-conflict";
84
+
85
+ /** A single conflict finding, surfaced in {@link ComposeResult.conflicts}. */
86
+ export interface Conflict {
87
+ type: ConflictType;
88
+ /** The slug this finding is anchored on. */
89
+ slug: string;
90
+ /** Human-readable explanation. */
91
+ detail: string;
92
+ /** When the conflict is between two slices, the other slug. */
93
+ withSlug?: string;
94
+ /**
95
+ * `"blocker"` causes the slice to be rejected.
96
+ * `"warning"` is informational and never blocks acceptance.
97
+ */
98
+ severity: "blocker" | "warning";
99
+ }
100
+
101
+ /**
102
+ * A single arbitration decision — when two candidates collide the solver
103
+ * ranks them by "most dependers wins" and drops the loser.
104
+ */
105
+ export interface Arbitration {
106
+ /** The conflict that triggered the arbitration. */
107
+ conflict: Conflict;
108
+ /** Slug that survived. */
109
+ winner: string;
110
+ /** Slug that was dropped. */
111
+ loser: string;
112
+ /** Why this side won (dep-count + tie-break note). */
113
+ reason: string;
114
+ }
115
+
116
+ /** Output bundle from {@link compose}. */
117
+ export interface ComposeResult {
118
+ /** Slugs the solver would install (in BFS-discovery order). */
119
+ accepted: string[];
120
+ /** Slugs the solver rejected, with the reasons attached. */
121
+ rejected: { slug: string; reasons: Conflict[]; note?: string }[];
122
+ /** All conflicts surfaced, including warning-level. */
123
+ conflicts: Conflict[];
124
+ /** Union of `requires.env` from accepted slices not in `state.envExisting`. */
125
+ envMissing: string[];
126
+ /** Union of `requires.rbac` from accepted slices not in `state.rbacRolesExisting`. */
127
+ rbacToCreate: string[];
128
+ /** Per-slice table additions, useful for printing schema previews. */
129
+ tablesAdded: { slug: string; tables: string[] }[];
130
+ /** Human-readable trace of every decision the solver made. */
131
+ proof: string[];
132
+ /**
133
+ * Conflict arbitration outcomes — populated when two new candidates collide
134
+ * and the solver picks a winner via dep-count ranking. Omitted when no such
135
+ * arbitration was needed.
136
+ */
137
+ arbitrations?: Arbitration[];
138
+ /**
139
+ * Per-slice notes (e.g. "uncontracted", "both-installed-conflict") attached
140
+ * to the corresponding `accepted` slug. Keyed by slug.
141
+ */
142
+ notes?: Record<string, string>;
143
+ }
144
+
145
+ /**
146
+ * Discover every `slice.contract.ts` under `<repoRoot>/frontend/slices/` and
147
+ * `<repoRoot>/template-base/frontend/slices/`, then dynamic-import each via
148
+ * tsx and return them keyed by `contract.id`. Contracts whose loader throws
149
+ * are silently skipped (a future iteration may surface them).
150
+ */
151
+ export function loadAllContracts(
152
+ repoRoot: string,
153
+ ): Promise<Map<string, SliceContract>>;
154
+
155
+ /**
156
+ * Greedy compose solver. Pure — no I/O. Returns a fresh {@link ComposeResult};
157
+ * does not mutate `req` or `contracts`.
158
+ *
159
+ * Algorithm: BFS resolve transitive deps (visited-set; throws on cycle with
160
+ * the full path in the message), then run conflict checks against state +
161
+ * sibling candidates. When a `table-collision` or `explicit-conflict` between
162
+ * two candidates is detected, the solver ranks them by dependers-count and
163
+ * drops the loser only (records an `Arbitration` entry). Slugs already in
164
+ * `state.slicesInstalled` win against new candidates; if BOTH sides of a
165
+ * conflict are installed, neither is dropped — instead a `both-installed-
166
+ * conflict` warning is surfaced.
167
+ *
168
+ * Un-contracted slugs in `desired` are accepted with an `uncontracted`
169
+ * warning when `state.allowUnknownSlices` is true (the default). Set it to
170
+ * false (or pass `--strict` from the CLI) to escalate them to blocker
171
+ * `missing-dep`.
172
+ *
173
+ * @throws Error when a dep cycle is detected — message includes the full
174
+ * cycle path, e.g. `"dependency cycle detected: a → b → c → a"`.
175
+ */
176
+ export function compose(
177
+ req: ComposeRequest,
178
+ contracts: Map<string, SliceContract>,
179
+ ): ComposeResult;