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.
- package/bin/cli.js +39 -6
- package/bin/compose-print.mjs +83 -0
- package/bin/compose-state.mjs +105 -0
- package/bin/compose.mjs +30 -194
- package/bin/graph-render.mjs +179 -0
- package/bin/graph.mjs +3 -182
- package/bin/migrate-load.mjs +189 -0
- package/bin/migrate-print.mjs +75 -0
- package/bin/migrate.mjs +56 -297
- package/bin/update-context.mjs +184 -0
- package/bin/update-output.mjs +110 -0
- package/bin/update.mjs +15 -293
- package/lib/compose-solver-arbitrate.mjs +84 -0
- package/lib/compose-solver-conflicts.mjs +163 -0
- package/lib/compose-solver-loader.mjs +79 -0
- package/lib/compose-solver-resolve.mjs +165 -0
- package/lib/compose-solver.mjs +42 -376
- package/lib/contract-types.ts +184 -0
- package/lib/contract-validate.ts +155 -0
- package/lib/contract.ts +31 -319
- package/lib/dna-graph.mjs +53 -0
- package/lib/dna.mjs +5 -46
- package/lib/env-augment.mjs +116 -0
- package/lib/manifest.json +303 -351
- package/lib/merge3-diff.mjs +187 -0
- package/lib/merge3-snapshot.mjs +108 -0
- package/lib/merge3.mjs +7 -305
- package/lib/migration-plan-render.mjs +111 -0
- package/lib/migration-plan-steps.mjs +144 -0
- package/lib/migration-plan.mjs +17 -258
- package/lib/post-init.mjs +1 -1
- package/lib/skills.json +1 -1
- package/package.json +1 -1
package/bin/update.mjs
CHANGED
|
@@ -19,23 +19,26 @@
|
|
|
19
19
|
// --force allow apply even with conflicts (uses each side's last value)
|
|
20
20
|
// --rr-path P explicit rr.json path (overrides cwd discovery)
|
|
21
21
|
// --json emit machine-readable JSON instead of ASCII
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
+
//
|
|
23
|
+
// Module split (kept ≤200 LOC each):
|
|
24
|
+
// - update-context.mjs — rr.json/kitab-root resolution + snapshot builders
|
|
25
|
+
// - update-output.mjs — ASCII reporter + force-apply / DNA helpers
|
|
27
26
|
|
|
28
27
|
import kleur from "kleur";
|
|
29
28
|
|
|
30
29
|
import { merge3, applyMerge } from "../lib/merge3.mjs";
|
|
31
|
-
import { snapshotFromDir } from "../lib/snapshot.mjs";
|
|
32
30
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
31
|
+
resolveContext,
|
|
32
|
+
buildKitabSnapshot,
|
|
33
|
+
buildBaseSnapshot,
|
|
34
|
+
buildConsumerSnapshot,
|
|
35
|
+
} from "./update-context.mjs";
|
|
36
|
+
import {
|
|
37
|
+
printReport,
|
|
38
|
+
recordLineage,
|
|
39
|
+
buildForcedSnapshot,
|
|
40
|
+
writeForced,
|
|
41
|
+
} from "./update-output.mjs";
|
|
39
42
|
|
|
40
43
|
/**
|
|
41
44
|
* Entry point invoked by cli.js with the post-`update` argv tail.
|
|
@@ -108,287 +111,6 @@ export async function runUpdate(rest) {
|
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
|
|
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
114
|
// ---------------------------------------------------------------------------
|
|
393
115
|
|
|
394
116
|
function parseFlags(rest) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// compose-solver-arbitrate.mjs — pair-arbitration helper.
|
|
2
|
+
//
|
|
3
|
+
// Split from compose-solver-conflicts.mjs to keep both files ≤200 LOC.
|
|
4
|
+
// Returns a closure that records the arbitration outcome + appends to
|
|
5
|
+
// `proof` for the chosen winner/loser.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pair-conflict arbitration helper factory. Returns a function that records
|
|
9
|
+
* the arbitration outcome + appends a `proof` entry.
|
|
10
|
+
*/
|
|
11
|
+
export function makeArbitrator({
|
|
12
|
+
installed,
|
|
13
|
+
dependersCount,
|
|
14
|
+
allConflicts,
|
|
15
|
+
arbitrations,
|
|
16
|
+
notes,
|
|
17
|
+
proof,
|
|
18
|
+
record,
|
|
19
|
+
}) {
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} a
|
|
22
|
+
* @param {string} b
|
|
23
|
+
* @param {import("./compose-solver").ConflictType} type
|
|
24
|
+
* @param {string} detailA
|
|
25
|
+
* @param {string} detailB
|
|
26
|
+
*/
|
|
27
|
+
return function arbitratePair(a, b, type, detailA, detailB) {
|
|
28
|
+
const ca = /** @type {import("./compose-solver").Conflict} */ ({
|
|
29
|
+
type, slug: a, withSlug: b, detail: detailA, severity: "blocker",
|
|
30
|
+
});
|
|
31
|
+
const cb = /** @type {import("./compose-solver").Conflict} */ ({
|
|
32
|
+
type, slug: b, withSlug: a, detail: detailB, severity: "blocker",
|
|
33
|
+
});
|
|
34
|
+
const bothInstalled = installed.has(a) && installed.has(b);
|
|
35
|
+
if (bothInstalled) {
|
|
36
|
+
allConflicts.push({ ...ca, severity: "warning", type: "both-installed-conflict" });
|
|
37
|
+
allConflicts.push({ ...cb, severity: "warning", type: "both-installed-conflict" });
|
|
38
|
+
notes.set(a, notes.get(a) ?? "both-installed-conflict");
|
|
39
|
+
notes.set(b, notes.get(b) ?? "both-installed-conflict");
|
|
40
|
+
proof.push(`! ${a} ↔ ${b}: both already installed — conflict surfaced as warning, neither dropped`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (installed.has(a) && !installed.has(b)) {
|
|
44
|
+
record(cb, b);
|
|
45
|
+
arbitrations.push({
|
|
46
|
+
conflict: cb, winner: a, loser: b,
|
|
47
|
+
reason: `"${a}" already installed — installed slice wins`,
|
|
48
|
+
});
|
|
49
|
+
proof.push(`- ${b}: arbitrated against ${a} (installed wins)`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (installed.has(b) && !installed.has(a)) {
|
|
53
|
+
record(ca, a);
|
|
54
|
+
arbitrations.push({
|
|
55
|
+
conflict: ca, winner: b, loser: a,
|
|
56
|
+
reason: `"${b}" already installed — installed slice wins`,
|
|
57
|
+
});
|
|
58
|
+
proof.push(`- ${a}: arbitrated against ${b} (installed wins)`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const depA = dependersCount.get(a) ?? 0;
|
|
62
|
+
const depB = dependersCount.get(b) ?? 0;
|
|
63
|
+
let winner, loser, conflictForLoser, reason;
|
|
64
|
+
if (depA !== depB) {
|
|
65
|
+
if (depA > depB) {
|
|
66
|
+
winner = a; loser = b; conflictForLoser = cb;
|
|
67
|
+
reason = `"${a}" has ${depA} dependers vs "${b}" with ${depB} — most-dependers wins`;
|
|
68
|
+
} else {
|
|
69
|
+
winner = b; loser = a; conflictForLoser = ca;
|
|
70
|
+
reason = `"${b}" has ${depB} dependers vs "${a}" with ${depA} — most-dependers wins`;
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
const later = a > b ? a : b;
|
|
74
|
+
const earlier = a > b ? b : a;
|
|
75
|
+
winner = earlier;
|
|
76
|
+
loser = later;
|
|
77
|
+
conflictForLoser = later === a ? ca : cb;
|
|
78
|
+
reason = `tie at ${depA} dependers — alphabetical tiebreak drops "${later}"`;
|
|
79
|
+
}
|
|
80
|
+
record(conflictForLoser, loser);
|
|
81
|
+
arbitrations.push({ conflict: conflictForLoser, winner, loser, reason });
|
|
82
|
+
proof.push(`- ${loser}: arbitrated against ${winner} (${reason})`);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// compose-solver-conflicts.mjs — auth/table/env/rbac/explicit conflict checks.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from compose-solver.mjs. Pair-arbitration helper lives in
|
|
4
|
+
// compose-solver-arbitrate.mjs; re-exported here so callers keep one
|
|
5
|
+
// conflicts entry point.
|
|
6
|
+
|
|
7
|
+
export { makeArbitrator } from "./compose-solver-arbitrate.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-candidate surface checks: auth mismatch, table collision (against
|
|
11
|
+
* existing schema), env missing, rbac (implicit via lookup table).
|
|
12
|
+
*/
|
|
13
|
+
export function runSurfaceChecks({
|
|
14
|
+
candidateOrder,
|
|
15
|
+
uncontractedDesired,
|
|
16
|
+
contracts,
|
|
17
|
+
state,
|
|
18
|
+
tablesExisting,
|
|
19
|
+
envExisting,
|
|
20
|
+
record,
|
|
21
|
+
}) {
|
|
22
|
+
for (const slug of candidateOrder) {
|
|
23
|
+
if (uncontractedDesired.has(slug)) continue;
|
|
24
|
+
const contract = contracts.get(slug);
|
|
25
|
+
if (!contract) continue;
|
|
26
|
+
|
|
27
|
+
const wantAuth = contract.requires?.auth;
|
|
28
|
+
if (wantAuth && state.auth && wantAuth !== state.auth && wantAuth !== "none") {
|
|
29
|
+
record(
|
|
30
|
+
{
|
|
31
|
+
type: "auth-mismatch",
|
|
32
|
+
slug,
|
|
33
|
+
detail: `Slice requires auth="${wantAuth}" but target rr.json has auth="${state.auth}".`,
|
|
34
|
+
severity: "blocker",
|
|
35
|
+
},
|
|
36
|
+
slug,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const provTables = contract.provides?.tables ?? [];
|
|
41
|
+
for (const t of provTables) {
|
|
42
|
+
if (tablesExisting.has(t)) {
|
|
43
|
+
record(
|
|
44
|
+
{
|
|
45
|
+
type: "table-collision",
|
|
46
|
+
slug,
|
|
47
|
+
detail: `Table "${t}" already exists in target Convex schema.`,
|
|
48
|
+
severity: "blocker",
|
|
49
|
+
},
|
|
50
|
+
slug,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reqEnv = contract.requires?.env ?? [];
|
|
56
|
+
for (const e of reqEnv) {
|
|
57
|
+
if (!envExisting.has(e)) {
|
|
58
|
+
record(
|
|
59
|
+
{
|
|
60
|
+
type: "env-missing",
|
|
61
|
+
slug,
|
|
62
|
+
detail: `Env var "${e}" required by ${slug} not present in target.`,
|
|
63
|
+
severity: "warning",
|
|
64
|
+
},
|
|
65
|
+
slug,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Build provides-lookup once: Map<slug, { tables: Set, rbac: Set }>. */
|
|
73
|
+
export function buildProvidesLookup(candidateOrder, uncontractedDesired, contracts) {
|
|
74
|
+
/** @type {Map<string, { tables: Set<string>; rbac: Set<string> }>} */
|
|
75
|
+
const lookup = new Map();
|
|
76
|
+
for (const slug of candidateOrder) {
|
|
77
|
+
if (uncontractedDesired.has(slug)) continue;
|
|
78
|
+
const c = contracts.get(slug);
|
|
79
|
+
if (!c) continue;
|
|
80
|
+
lookup.set(slug, {
|
|
81
|
+
tables: new Set(c.provides?.tables ?? []),
|
|
82
|
+
rbac: new Set(c.requires?.rbac ?? []),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return lookup;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pairwise scan: detect table collisions (arbitrated) + rbac overlaps
|
|
90
|
+
* (warning only) across all candidate pairs.
|
|
91
|
+
*/
|
|
92
|
+
export function detectPairwiseCollisions({
|
|
93
|
+
candidateOrder,
|
|
94
|
+
lookup,
|
|
95
|
+
arbitratePair,
|
|
96
|
+
allConflicts,
|
|
97
|
+
}) {
|
|
98
|
+
for (let i = 0; i < candidateOrder.length; i++) {
|
|
99
|
+
for (let j = i + 1; j < candidateOrder.length; j++) {
|
|
100
|
+
const a = candidateOrder[i];
|
|
101
|
+
const b = candidateOrder[j];
|
|
102
|
+
const la = lookup.get(a);
|
|
103
|
+
const lb = lookup.get(b);
|
|
104
|
+
if (!la || !lb) continue;
|
|
105
|
+
|
|
106
|
+
const tableHits = [];
|
|
107
|
+
for (const t of la.tables) if (lb.tables.has(t)) tableHits.push(t);
|
|
108
|
+
if (tableHits.length > 0) {
|
|
109
|
+
const detail = `Slices "${a}" and "${b}" both declare table${tableHits.length > 1 ? "s" : ""} ${tableHits.map((t) => `"${t}"`).join(", ")}.`;
|
|
110
|
+
arbitratePair(a, b, "table-collision", detail, detail);
|
|
111
|
+
}
|
|
112
|
+
for (const p of la.rbac) {
|
|
113
|
+
if (lb.rbac.has(p)) {
|
|
114
|
+
allConflicts.push({
|
|
115
|
+
type: "rbac-collision",
|
|
116
|
+
slug: a,
|
|
117
|
+
withSlug: b,
|
|
118
|
+
detail: `Slices "${a}" and "${b}" both declare RBAC permission "${p}".`,
|
|
119
|
+
severity: "warning",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Explicit `conflicts: ["<other>:<key>.<value>"]` declarations — when the
|
|
129
|
+
* other slice's `provides[key]` contains `value`, surface as arbitrated
|
|
130
|
+
* conflict.
|
|
131
|
+
*/
|
|
132
|
+
export function detectExplicitConflicts({
|
|
133
|
+
candidateOrder,
|
|
134
|
+
candidateSet,
|
|
135
|
+
uncontractedDesired,
|
|
136
|
+
contracts,
|
|
137
|
+
arbitratePair,
|
|
138
|
+
}) {
|
|
139
|
+
for (const slug of candidateOrder) {
|
|
140
|
+
if (uncontractedDesired.has(slug)) continue;
|
|
141
|
+
const c = contracts.get(slug);
|
|
142
|
+
if (!c) continue;
|
|
143
|
+
const conflicts = c.conflicts ?? [];
|
|
144
|
+
for (const cf of conflicts) {
|
|
145
|
+
const colon = cf.indexOf(":");
|
|
146
|
+
const dot = cf.indexOf(".", colon);
|
|
147
|
+
if (colon < 0 || dot < 0) continue;
|
|
148
|
+
const otherSlug = cf.slice(0, colon);
|
|
149
|
+
const key = cf.slice(colon + 1, dot);
|
|
150
|
+
const value = cf.slice(dot + 1);
|
|
151
|
+
if (!candidateSet.has(otherSlug)) continue;
|
|
152
|
+
if (uncontractedDesired.has(otherSlug)) continue;
|
|
153
|
+
const other = contracts.get(otherSlug);
|
|
154
|
+
if (!other) continue;
|
|
155
|
+
const provided = other.provides?.[key];
|
|
156
|
+
if (Array.isArray(provided) && provided.includes(value)) {
|
|
157
|
+
const detailA = `Slice "${slug}" declares explicit conflict with "${otherSlug}" on ${key}.${value}.`;
|
|
158
|
+
const detailB = `Slice "${otherSlug}" is the target of "${slug}"'s explicit conflict on ${key}.${value}.`;
|
|
159
|
+
arbitratePair(slug, otherSlug, "explicit-conflict", detailA, detailB);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// compose-solver-loader.mjs — contract discovery + tsx-eval loader.
|
|
2
|
+
//
|
|
3
|
+
// Split out from compose-solver.mjs to keep the pure solver < 200 LOC.
|
|
4
|
+
// I/O entry point only — discovers `slice.contract.ts` under known slice
|
|
5
|
+
// roots and shells out to `npx tsx` to JSON-serialize each export.
|
|
6
|
+
|
|
7
|
+
import { readdir } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const SLICE_ROOT_GLOBS = [
|
|
13
|
+
["frontend", "slices"],
|
|
14
|
+
["template-base", "frontend", "slices"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Discover every `slice.contract.ts` under the kitab's known slice roots and
|
|
19
|
+
* load them via `npx tsx`. Returns a Map<slug, SliceContract>. Contracts that
|
|
20
|
+
* fail to load are silently skipped so a single broken file doesn't take
|
|
21
|
+
* down the whole solver — `npm run validate:contracts` is the place to surface
|
|
22
|
+
* those errors.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} repoRoot Absolute path to the kitab repo root.
|
|
25
|
+
* @returns {Promise<Map<string, import("./contract").SliceContract>>}
|
|
26
|
+
*/
|
|
27
|
+
export async function loadAllContracts(repoRoot) {
|
|
28
|
+
/** @type {Map<string, import("./contract").SliceContract>} */
|
|
29
|
+
const out = new Map();
|
|
30
|
+
const sliceFiles = await discoverContractFiles(repoRoot);
|
|
31
|
+
for (const filePath of sliceFiles) {
|
|
32
|
+
const contract = loadContractFile(repoRoot, filePath);
|
|
33
|
+
if (contract && typeof contract.id === "string") {
|
|
34
|
+
out.set(contract.id, contract);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function discoverContractFiles(repoRoot) {
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const segs of SLICE_ROOT_GLOBS) {
|
|
43
|
+
const root = path.join(repoRoot, ...segs);
|
|
44
|
+
if (!existsSync(root)) continue;
|
|
45
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
46
|
+
for (const entry of entries) {
|
|
47
|
+
if (!entry.isDirectory()) continue;
|
|
48
|
+
if (entry.name.startsWith("_")) continue;
|
|
49
|
+
const filePath = path.join(root, entry.name, "slice.contract.ts");
|
|
50
|
+
if (existsSync(filePath)) found.push(filePath);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Dynamic-import a .ts contract file via `npx tsx -e` and JSON.parse the
|
|
58
|
+
* stringified `contract` export. Returns null on any failure.
|
|
59
|
+
*/
|
|
60
|
+
function loadContractFile(repoRoot, filePath) {
|
|
61
|
+
const rel = "./" + path.relative(repoRoot, filePath);
|
|
62
|
+
const code = [
|
|
63
|
+
`import(${JSON.stringify(rel)})`,
|
|
64
|
+
` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
|
|
65
|
+
` .catch(() => process.exit(3));`,
|
|
66
|
+
].join("\n");
|
|
67
|
+
const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
|
|
68
|
+
cwd: repoRoot,
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
});
|
|
71
|
+
if (res.status === 0 && res.stdout) {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(res.stdout);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|