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/README.md +105 -0
- package/bin/cli.js +114 -0
- package/bin/compose.mjs +287 -0
- package/bin/graph.mjs +247 -0
- package/bin/migrate.mjs +423 -0
- package/bin/update.mjs +413 -0
- package/lib/compose-solver.d.ts +179 -0
- package/lib/compose-solver.mjs +523 -0
- package/lib/compose-solver.test.mjs +483 -0
- package/lib/contract.ts +240 -0
- package/lib/dna.d.ts +65 -0
- package/lib/dna.mjs +239 -0
- package/lib/manifest.json +883 -185
- package/lib/merge3.d.ts +67 -0
- package/lib/merge3.mjs +431 -0
- package/lib/merge3.test.mjs +199 -0
- package/lib/migration-plan.d.ts +86 -0
- package/lib/migration-plan.mjs +414 -0
- package/lib/migration-plan.test.mjs +243 -0
- package/lib/slice-schema.json +13 -9
- package/lib/snapshot.d.ts +6 -0
- package/lib/snapshot.mjs +126 -0
- package/package.json +5 -2
|
@@ -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
|
+
}
|