rahman-resources 1.4.0 → 1.6.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,165 @@
1
+ // compose-solver-resolve.mjs — desired-set normalisation + BFS dep resolution.
2
+ //
3
+ // Pure functions extracted from compose-solver.mjs. Operate on candidate
4
+ // state (Sets + arrays) — no I/O, no mutation of the caller's `contracts`
5
+ // map. Returns the new candidate state + an updated `proof`/`notes` log.
6
+
7
+ /**
8
+ * Fold the user's `desired` list into a candidate set, classifying each entry
9
+ * as contracted (with a known contract) or uncontracted (allowed under
10
+ * `allowUnknownSlices`, otherwise rejected with a `missing-dep` blocker).
11
+ *
12
+ * Side-effects via the `record` callback — caller owns the conflict store.
13
+ */
14
+ export function buildInitialCandidates({
15
+ desired,
16
+ contracts,
17
+ allowUnknownSlices,
18
+ record,
19
+ proof,
20
+ notes,
21
+ uncontractedDesired,
22
+ }) {
23
+ const candidateOrder = [];
24
+ const candidateSet = new Set();
25
+
26
+ for (const slug of desired) {
27
+ if (candidateSet.has(slug)) continue;
28
+ const contract = contracts.get(slug);
29
+ if (!contract) {
30
+ if (allowUnknownSlices) {
31
+ record(
32
+ {
33
+ type: "uncontracted",
34
+ slug,
35
+ detail: `Slice "${slug}" has no registered slice.contract.ts — accepted under allowUnknownSlices, but conflict checks are skipped for it.`,
36
+ severity: "warning",
37
+ },
38
+ slug,
39
+ );
40
+ uncontractedDesired.add(slug);
41
+ candidateSet.add(slug);
42
+ candidateOrder.push(slug);
43
+ notes.set(slug, "uncontracted");
44
+ proof.push(`! ${slug}: accepted as uncontracted (no slice.contract.ts; skipping conflict checks)`);
45
+ } else {
46
+ record(
47
+ {
48
+ type: "missing-dep",
49
+ slug,
50
+ detail: `Contract not found for "${slug}" — no slice.contract.ts registered (strict mode).`,
51
+ severity: "blocker",
52
+ },
53
+ slug,
54
+ );
55
+ proof.push(`- ${slug}: rejected (no contract found, strict mode)`);
56
+ }
57
+ continue;
58
+ }
59
+ candidateSet.add(slug);
60
+ candidateOrder.push(slug);
61
+ }
62
+ return { candidateOrder, candidateSet };
63
+ }
64
+
65
+ /**
66
+ * BFS over `requires.deps`, pulling transitive deps into the candidate set.
67
+ * Throws on cycles. Pushes informational lines to `proof`. Returns nothing —
68
+ * mutates `candidateOrder` / `candidateSet` in place.
69
+ */
70
+ export function resolveDependencies({
71
+ candidateOrder,
72
+ candidateSet,
73
+ contracts,
74
+ installed,
75
+ record,
76
+ proof,
77
+ }) {
78
+ /** @type {Array<{ slug: string; chain: string[] }>} */
79
+ const queue = candidateOrder
80
+ .filter((s) => contracts.has(s))
81
+ .map((slug) => ({ slug, chain: [slug] }));
82
+ while (queue.length > 0) {
83
+ const { slug, chain } = queue.shift();
84
+ const contract = contracts.get(slug);
85
+ const deps = contract?.requires?.deps ?? [];
86
+ for (const dep of deps) {
87
+ if (chain.includes(dep)) {
88
+ const cyclePath = [...chain.slice(chain.indexOf(dep)), dep].join(" → ");
89
+ throw new Error(`dependency cycle detected: ${cyclePath}`);
90
+ }
91
+ if (installed.has(dep)) continue;
92
+ if (candidateSet.has(dep)) continue;
93
+ const depContract = contracts.get(dep);
94
+ if (!depContract) {
95
+ record(
96
+ {
97
+ type: "missing-dep",
98
+ slug,
99
+ withSlug: dep,
100
+ detail: `Slice "${slug}" requires "${dep}" but no contract is registered for it.`,
101
+ severity: "blocker",
102
+ },
103
+ slug,
104
+ );
105
+ proof.push(`- ${slug}: missing dep "${dep}" (not in candidates, not installed)`);
106
+ continue;
107
+ }
108
+ candidateSet.add(dep);
109
+ candidateOrder.push(dep);
110
+ proof.push(`+ ${dep}: pulled in as transitive dep of ${slug}`);
111
+ queue.push({ slug: dep, chain: [...chain, dep] });
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Without dependency resolution: surface every unmet `requires.deps` entry
118
+ * as a blocker. Used when the caller passed `resolveDeps: false`.
119
+ */
120
+ export function reportUnresolvedDeps({
121
+ candidateOrder,
122
+ candidateSet,
123
+ contracts,
124
+ installed,
125
+ record,
126
+ }) {
127
+ for (const slug of candidateOrder) {
128
+ const deps = contracts.get(slug)?.requires?.deps ?? [];
129
+ for (const dep of deps) {
130
+ if (installed.has(dep) || candidateSet.has(dep)) continue;
131
+ record(
132
+ {
133
+ type: "missing-dep",
134
+ slug,
135
+ withSlug: dep,
136
+ detail: `Slice "${slug}" requires "${dep}" (resolveDeps disabled — not auto-pulled).`,
137
+ severity: "blocker",
138
+ },
139
+ slug,
140
+ );
141
+ }
142
+ }
143
+ }
144
+
145
+ /**
146
+ * For each candidate, count how many OTHER candidates list it in their
147
+ * `requires.deps[]`. Used for arbitration tiebreaks.
148
+ *
149
+ * @returns {Map<string, number>}
150
+ */
151
+ export function computeDependersCount(candidateOrder, contracts) {
152
+ /** @type {Map<string, number>} */
153
+ const dependersCount = new Map();
154
+ for (const slug of candidateOrder) dependersCount.set(slug, 0);
155
+ for (const slug of candidateOrder) {
156
+ const c = contracts.get(slug);
157
+ if (!c) continue;
158
+ for (const d of c.requires?.deps ?? []) {
159
+ if (dependersCount.has(d)) {
160
+ dependersCount.set(d, (dependersCount.get(d) ?? 0) + 1);
161
+ }
162
+ }
163
+ }
164
+ return dependersCount;
165
+ }
@@ -12,88 +12,31 @@
12
12
  // Public API + types live in compose-solver.d.ts.
13
13
  //
14
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.
15
+ // - `loadAllContracts(repoRoot)` (re-exported from compose-solver-loader)
16
+ // is the only I/O entry point.
17
17
  // - `compose(req, contracts)` is pure — no fs, no env access, no mutation
18
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
- }
19
+ //
20
+ // Module split (kept ≤200 LOC each):
21
+ // - compose-solver-loader.mjs — contract discovery + tsx-eval
22
+ // - compose-solver-resolve.mjs — desired-set + BFS dep resolution
23
+ // - compose-solver-conflicts.mjs — surface checks + pair arbitration
24
+
25
+ import {
26
+ buildInitialCandidates,
27
+ resolveDependencies,
28
+ reportUnresolvedDeps,
29
+ computeDependersCount,
30
+ } from "./compose-solver-resolve.mjs";
31
+ import {
32
+ runSurfaceChecks,
33
+ buildProvidesLookup,
34
+ makeArbitrator,
35
+ detectPairwiseCollisions,
36
+ detectExplicitConflicts,
37
+ } from "./compose-solver-conflicts.mjs";
38
+
39
+ export { loadAllContracts } from "./compose-solver-loader.mjs";
97
40
 
98
41
  // ---------------------------------------------------------------------------
99
42
  // Public — compose
@@ -129,6 +72,8 @@ export function compose(req, contracts) {
129
72
  const notes = new Map();
130
73
  /** @type {Set<string>} */
131
74
  const uncontractedDesired = new Set();
75
+ /** @type {Set<string>} */
76
+ const userTyped = new Set(desired);
132
77
 
133
78
  // Helper: record a conflict and (if blocker) attribute it to a slug.
134
79
  function record(conflict, attributeTo = conflict.slug) {
@@ -141,310 +86,31 @@ export function compose(req, contracts) {
141
86
  }
142
87
 
143
88
  // ── 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
- }
89
+ const { candidateOrder, candidateSet } = buildInitialCandidates({
90
+ desired, contracts, allowUnknownSlices, record, proof, notes, uncontractedDesired,
91
+ });
188
92
 
189
93
  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
- }
94
+ resolveDependencies({ candidateOrder, candidateSet, contracts, installed, record, proof });
230
95
  } 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
- }
96
+ reportUnresolvedDeps({ candidateOrder, candidateSet, contracts, installed, record });
247
97
  }
248
98
 
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
- }
99
+ const dependersCount = computeDependersCount(candidateOrder, contracts);
264
100
 
265
101
  // ── 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
- }
102
+ runSurfaceChecks({
103
+ candidateOrder, uncontractedDesired, contracts, state, tablesExisting, envExisting, record,
104
+ });
422
105
 
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
- }
106
+ const lookup = buildProvidesLookup(candidateOrder, uncontractedDesired, contracts);
107
+ const arbitratePair = makeArbitrator({
108
+ installed, dependersCount, allConflicts, arbitrations, notes, proof, record,
109
+ });
110
+ detectPairwiseCollisions({ candidateOrder, lookup, arbitratePair, allConflicts });
111
+ detectExplicitConflicts({
112
+ candidateOrder, candidateSet, uncontractedDesired, contracts, arbitratePair,
113
+ });
448
114
 
449
115
  // ── Step 3: decide accepted / rejected. ─────────────────────────────────
450
116
  /** @type {Set<string>} */