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/migrate.mjs CHANGED
@@ -17,9 +17,12 @@
17
17
  // Side-effects (only with --write-files):
18
18
  // - Writes ./convex/migrations/<step-id>.ts
19
19
  // - Appends a DNA lineage entry with transforms ["migration-applied", ...].
20
+ //
21
+ // Module split (kept ≤200 LOC each):
22
+ // - migrate-load.mjs — contract-loading (current + historic via git show)
23
+ // - migrate-print.mjs — ASCII rendering of a MigrationPlan
20
24
 
21
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
22
- import { spawnSync } from "node:child_process";
25
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
23
26
  import path from "node:path";
24
27
  import { fileURLToPath } from "node:url";
25
28
 
@@ -27,6 +30,13 @@ import kleur from "kleur";
27
30
 
28
31
  import { diffContracts, planMigration } from "../lib/migration-plan.mjs";
29
32
  import { appendLineage } from "../lib/dna.mjs";
33
+ import {
34
+ loadCurrentContract,
35
+ loadHistoricContract,
36
+ parseFlags,
37
+ findRepoRoot,
38
+ } from "./migrate-load.mjs";
39
+ import { printPlan } from "./migrate-print.mjs";
30
40
 
31
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
42
 
@@ -111,313 +121,62 @@ export async function runMigrate(rest) {
111
121
 
112
122
  // 4) Optional write.
113
123
  if (writeFiles) {
114
- const writeDir = path.join(cwd, "convex", "migrations");
115
- mkdirSync(writeDir, { recursive: true });
116
- const writtenIds = [];
117
- for (const step of plan.steps) {
118
- const body = step.artifacts?.convexMigration;
119
- if (!body) continue;
120
- const file = path.join(writeDir, `${step.id}.ts`);
121
- if (existsSync(file) && !forceOverwrite) {
122
- process.stderr.write(
123
- kleur.red(
124
- `migrate: ${path.relative(cwd, file)} already exists. Pass --force-overwrite to replace.\n`,
125
- ),
126
- );
127
- process.exit(1);
128
- }
129
- writeFileSync(file, body + "\n");
130
- writtenIds.push(step.id);
131
- if (!asJson) {
132
- process.stdout.write(
133
- ` ${kleur.green("+")} ${path.relative(cwd, file)}\n`,
134
- );
135
- }
136
- }
124
+ writePlanFiles({ plan, cwd, forceOverwrite, asJson });
137
125
  // 5) Append DNA lineage.
138
- if (writtenIds.length > 0) {
139
- try {
140
- appendLineage(slug, {
141
- from: `contract:${slug}@${plan.fromVersion}`,
142
- to: `contract:${slug}@${plan.toVersion}`,
143
- at: new Date().toISOString(),
144
- transforms: ["migration-applied", ...writtenIds],
145
- });
146
- if (!asJson) {
147
- process.stdout.write(
148
- kleur.dim(
149
- ` lineage: appended "migration-applied" entry to .kitab/lineage/${slug}.dna.json\n`,
150
- ),
151
- );
152
- }
153
- } catch (err) {
154
- process.stderr.write(
155
- kleur.yellow(
156
- ` ⚠ lineage append failed (${err.message ?? err}). Files were still written.\n`,
157
- ),
158
- );
159
- }
160
- }
161
- }
162
- }
163
-
164
- // ---------------------------------------------------------------------------
165
- // Contract loading
166
- // ---------------------------------------------------------------------------
167
-
168
- const SLICE_ROOTS = [
169
- ["frontend", "slices"],
170
- ["template-base", "frontend", "slices"],
171
- ];
172
-
173
- function resolveContractPath(repoRoot, slug) {
174
- for (const segs of SLICE_ROOTS) {
175
- const p = path.join(repoRoot, ...segs, slug, "slice.contract.ts");
176
- if (existsSync(p)) return p;
126
+ appendMigrationLineage({ slug, plan, asJson });
177
127
  }
178
- return null;
179
128
  }
180
129
 
181
- function resolveContractRelPath(repoRoot, slug) {
182
- for (const segs of SLICE_ROOTS) {
183
- const rel = [...segs, slug, "slice.contract.ts"].join("/");
184
- if (existsSync(path.join(repoRoot, rel))) return rel;
185
- }
186
- return null;
187
- }
188
-
189
- function loadCurrentContract(repoRoot, slug) {
190
- const p = resolveContractPath(repoRoot, slug);
191
- if (!p) return null;
192
- return evalContract(repoRoot, p, null);
193
- }
194
-
195
- /**
196
- * Resolve the contract at a historic version. Strategy:
197
- * 1. Try git tags `v<ver>`, `<ver>`, `<slug>-v<ver>` against the contract file.
198
- * 2. If none have the right version, scan recent commits touching the file
199
- * and pick the most recent one whose contract.version === fromVersion.
200
- */
201
- function loadHistoricContract(repoRoot, slug, fromVersion) {
202
- const rel = resolveContractRelPath(repoRoot, slug);
203
- if (!rel) return null;
204
-
205
- const candidates = [`v${fromVersion}`, fromVersion, `${slug}-v${fromVersion}`];
206
- for (const ref of candidates) {
207
- const text = gitShowFile(repoRoot, ref, rel);
208
- if (text) {
209
- const c = evalContract(repoRoot, null, text);
210
- if (c && c.version === fromVersion) return c;
130
+ function writePlanFiles({ plan, cwd, forceOverwrite, asJson }) {
131
+ const writeDir = path.join(cwd, "convex", "migrations");
132
+ mkdirSync(writeDir, { recursive: true });
133
+ for (const step of plan.steps) {
134
+ const body = step.artifacts?.convexMigration;
135
+ if (!body) continue;
136
+ const file = path.join(writeDir, `${step.id}.ts`);
137
+ if (existsSync(file) && !forceOverwrite) {
138
+ process.stderr.write(
139
+ kleur.red(
140
+ `migrate: ${path.relative(cwd, file)} already exists. Pass --force-overwrite to replace.\n`,
141
+ ),
142
+ );
143
+ process.exit(1);
211
144
  }
212
- }
213
-
214
- // Fallback: scan commit history for the file.
215
- const log = spawnSync("git", ["log", "--format=%H", "--", rel], {
216
- cwd: repoRoot,
217
- encoding: "utf8",
218
- });
219
- if (log.status === 0 && log.stdout) {
220
- const hashes = log.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
221
- for (const sha of hashes) {
222
- const text = gitShowFile(repoRoot, sha, rel);
223
- if (!text) continue;
224
- const c = evalContract(repoRoot, null, text);
225
- if (c && c.version === fromVersion) return c;
145
+ writeFileSync(file, body + "\n");
146
+ if (!asJson) {
147
+ process.stdout.write(
148
+ ` ${kleur.green("+")} ${path.relative(cwd, file)}\n`,
149
+ );
226
150
  }
227
151
  }
228
- return null;
229
152
  }
230
153
 
231
- function gitShowFile(repoRoot, ref, relPath) {
232
- const res = spawnSync("git", ["show", `${ref}:${relPath}`], {
233
- cwd: repoRoot,
234
- encoding: "utf8",
235
- });
236
- if (res.status === 0 && typeof res.stdout === "string") return res.stdout;
237
- return null;
238
- }
239
-
240
- /**
241
- * Eval a contract file by spawning `npx tsx` and reading the JSON output.
242
- * Pass `filePath` (absolute) for the on-disk version OR `inlineText` for a
243
- * historic version pulled via git show.
244
- */
245
- function evalContract(repoRoot, filePath, inlineText) {
246
- if (filePath) {
247
- const rel = "./" + path.relative(repoRoot, filePath);
248
- const code = [
249
- `import(${JSON.stringify(rel)})`,
250
- ` .then(m => { const c = m.contract || (m.default && m.default.contract); if (!c) { process.exit(2); } process.stdout.write(JSON.stringify(c)); })`,
251
- ` .catch((e) => { process.stderr.write(String(e && e.stack || e)); process.exit(3); });`,
252
- ].join("\n");
253
- const res = spawnSync("npx", ["--no-install", "tsx", "-e", code], {
254
- cwd: repoRoot,
255
- encoding: "utf8",
256
- });
257
- if (res.status === 0 && res.stdout) {
258
- try {
259
- return JSON.parse(res.stdout);
260
- } catch {
261
- return null;
262
- }
263
- }
264
- return null;
265
- }
266
-
267
- // Inline mode: write to a temp file under the kitab root (so its tsconfig +
268
- // path aliases resolve) and eval. We use a `.migration-tmp/` folder so the
269
- // temp file is colocated with packages/ but easy to ignore.
270
- if (typeof inlineText !== "string") return null;
271
- const tmpDir = path.join(repoRoot, ".migration-tmp");
272
- mkdirSync(tmpDir, { recursive: true });
273
- const tmpFile = path.join(
274
- tmpDir,
275
- `contract-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
276
- );
154
+ function appendMigrationLineage({ slug, plan, asJson }) {
155
+ const writtenIds = plan.steps
156
+ .filter((s) => !!s.artifacts?.convexMigration)
157
+ .map((s) => s.id);
158
+ if (writtenIds.length === 0) return;
277
159
  try {
278
- // Rewrite the import path back to the absolute kitab `contract` module so
279
- // `../../../packages/cli/lib/contract` (from a historic file pulled via
280
- // git show) keeps resolving after we move it to a different directory.
281
- const adjusted = adjustContractImport(inlineText, repoRoot, tmpDir);
282
- writeFileSync(tmpFile, adjusted);
283
- return evalContract(repoRoot, tmpFile, null);
284
- } finally {
285
- try {
286
- if (existsSync(tmpFile)) unlinkSync(tmpFile);
287
- } catch {
288
- // ignore
160
+ appendLineage(slug, {
161
+ from: `contract:${slug}@${plan.fromVersion}`,
162
+ to: `contract:${slug}@${plan.toVersion}`,
163
+ at: new Date().toISOString(),
164
+ transforms: ["migration-applied", ...writtenIds],
165
+ });
166
+ if (!asJson) {
167
+ process.stdout.write(
168
+ kleur.dim(
169
+ ` lineage: appended "migration-applied" entry to .kitab/lineage/${slug}.dna.json\n`,
170
+ ),
171
+ );
289
172
  }
290
- }
291
- }
292
-
293
- /**
294
- * Replace any `from "..contract"` import in the contract body with an
295
- * absolute path so the temp-file copy still resolves the same module.
296
- */
297
- function adjustContractImport(text, repoRoot, tmpDir) {
298
- const contractMod = path.join(repoRoot, "packages", "cli", "lib", "contract");
299
- const relFromTmp = path
300
- .relative(tmpDir, contractMod)
301
- .split(path.sep)
302
- .join("/");
303
- // Match imports ending in /contract or /contract.ts (with or without .ts).
304
- return text.replace(
305
- /from\s+["'][^"']*\/contract(?:\.ts)?["']/g,
306
- `from "${relFromTmp}"`,
307
- );
308
- }
309
-
310
- // ---------------------------------------------------------------------------
311
- // Presentation
312
- // ---------------------------------------------------------------------------
313
-
314
- function printPlan(plan, _ctx) {
315
- const head = plan.summary.totalSteps === 0
316
- ? kleur.green("✓ no migration needed")
317
- : kleur.bold(`→ ${plan.slug}: v${plan.fromVersion} → v${plan.toVersion}`);
318
- process.stdout.write(`\n${head}\n\n`);
319
-
320
- if (plan.summary.totalSteps === 0) {
321
- process.stdout.write(kleur.dim(" (contracts are equivalent — nothing to migrate)\n\n"));
322
- return;
323
- }
324
-
325
- // Header row.
326
- const cols = ["id", "kind", "risk", "rev", "description"];
327
- const widths = [28, 26, 6, 4, 50];
328
- const sep =
329
- "+" + widths.map((w) => "-".repeat(w + 2)).join("+") + "+\n";
330
- const row = (vals) =>
331
- "|" +
332
- vals
333
- .map((v, i) => " " + String(v).padEnd(widths[i]).slice(0, widths[i]) + " ")
334
- .join("|") +
335
- "|\n";
336
-
337
- process.stdout.write(sep);
338
- process.stdout.write(row(cols));
339
- process.stdout.write(sep);
340
- for (const step of plan.steps) {
341
- process.stdout.write(
342
- row([
343
- step.id,
344
- step.kind,
345
- step.risk,
346
- step.reversible ? "yes" : "NO",
347
- step.description.replace(/\s+/g, " ").slice(0, widths[4]),
348
- ]),
173
+ } catch (err) {
174
+ process.stderr.write(
175
+ kleur.yellow(
176
+ ` ⚠ lineage append failed (${err.message ?? err}). Files were still written.\n`,
177
+ ),
349
178
  );
350
179
  }
351
- process.stdout.write(sep);
352
-
353
- // Per-step detail.
354
- for (const step of plan.steps) {
355
- process.stdout.write(`\n${kleur.bold(step.id)} ${kleur.dim("(" + step.kind + ")")}\n`);
356
- process.stdout.write(` ${step.description}\n`);
357
- if (step.artifacts.note) {
358
- process.stdout.write(` ${kleur.dim("note:")} ${step.artifacts.note}\n`);
359
- }
360
- if (step.artifacts.envExample) {
361
- process.stdout.write(` ${kleur.dim(".env.example:")} ${step.artifacts.envExample}\n`);
362
- }
363
- }
364
-
365
- process.stdout.write(
366
- `\n${kleur.bold("summary")}: ${plan.summary.totalSteps} step(s) — ` +
367
- `${kleur.red(plan.summary.highRisk + " high-risk")}, ` +
368
- `${kleur.yellow(plan.summary.irreversible + " irreversible")}\n`,
369
- );
370
-
371
- if (plan.warnings.length > 0) {
372
- process.stdout.write(`\n${kleur.bold("warnings:")}\n`);
373
- for (const w of plan.warnings) {
374
- process.stdout.write(` ${kleur.yellow("⚠")} ${w}\n`);
375
- }
376
- }
377
- process.stdout.write(
378
- `\n${kleur.dim(
379
- "pass --json for machine-readable output, --write-files to materialize convex/migrations/.",
380
- )}\n\n`,
381
- );
382
180
  }
383
181
 
384
- // ---------------------------------------------------------------------------
385
- // Helpers
386
- // ---------------------------------------------------------------------------
387
-
388
- function parseFlags(rest) {
389
- const positional = [];
390
- const flags = {};
391
- for (let i = 0; i < rest.length; i++) {
392
- const a = rest[i];
393
- if (a.startsWith("--")) {
394
- const key = a.slice(2);
395
- const next = rest[i + 1];
396
- if (next && !next.startsWith("--")) {
397
- flags[key] = next;
398
- i++;
399
- } else {
400
- flags[key] = true;
401
- }
402
- } else {
403
- positional.push(a);
404
- }
405
- }
406
- return { positional, flags };
407
- }
408
-
409
- function findRepoRoot(start) {
410
- let dir = start;
411
- for (let i = 0; i < 8; i++) {
412
- if (
413
- existsSync(path.join(dir, "packages")) &&
414
- existsSync(path.join(dir, "package.json"))
415
- ) {
416
- return dir;
417
- }
418
- const parent = path.dirname(dir);
419
- if (parent === dir) break;
420
- dir = parent;
421
- }
422
- return process.cwd();
423
- }
182
+ // parseFlags + findRepoRoot live in migrate-load.mjs (single-source helpers).
@@ -0,0 +1,184 @@
1
+ // update-context.mjs — rr.json / kitab-root / consumer-slice-dir resolution
2
+ // + snapshot builders for `rr update`.
3
+ //
4
+ // Extracted from update.mjs.
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ import { snapshotFromDir } from "../lib/snapshot.mjs";
12
+ import { readDNA } from "../lib/dna.mjs";
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+
16
+ /**
17
+ * @typedef {{
18
+ * repoRoot: string,
19
+ * rrPath: string,
20
+ * rr: any,
21
+ * consumerName: string,
22
+ * kitabRoot: string,
23
+ * kitabSliceDir: string,
24
+ * consumerSliceDir: string,
25
+ * }} UpdateContext
26
+ */
27
+
28
+ export function resolveContext(slug, explicitRrPath) {
29
+ // The kitab repo lives above packages/cli/bin/.
30
+ const kitabRoot = findKitabRoot();
31
+ const kitabSliceDir = path.join(kitabRoot, "frontend", "slices", slug);
32
+ if (!existsSync(kitabSliceDir)) {
33
+ throw new Error(
34
+ `update: kitab slice not found at ${kitabSliceDir}. (Did you mean a different slug?)`,
35
+ );
36
+ }
37
+
38
+ const rrPath = explicitRrPath ? path.resolve(explicitRrPath) : path.resolve(process.cwd(), "rr.json");
39
+ if (!existsSync(rrPath)) {
40
+ throw new Error(
41
+ `update: rr.json not found at ${rrPath}. Pass --rr-path or run from a consumer project.`,
42
+ );
43
+ }
44
+ const rr = JSON.parse(readFileSync(rrPath, "utf8"));
45
+
46
+ const consumerName = inferConsumerName(rr, rrPath);
47
+ const consumerSliceDir = resolveConsumerSliceDir(rr, rrPath, slug);
48
+
49
+ return {
50
+ repoRoot: kitabRoot,
51
+ rrPath,
52
+ rr,
53
+ consumerName,
54
+ kitabRoot,
55
+ kitabSliceDir,
56
+ consumerSliceDir,
57
+ };
58
+ }
59
+
60
+ function findKitabRoot() {
61
+ let dir = __dirname;
62
+ for (let i = 0; i < 8; i++) {
63
+ if (
64
+ existsSync(path.join(dir, "packages")) &&
65
+ existsSync(path.join(dir, "frontend", "slices"))
66
+ ) {
67
+ return dir;
68
+ }
69
+ const parent = path.dirname(dir);
70
+ if (parent === dir) break;
71
+ dir = parent;
72
+ }
73
+ return process.cwd();
74
+ }
75
+
76
+ function inferConsumerName(rr, rrPath) {
77
+ if (rr?.consumer && typeof rr.consumer === "string") return rr.consumer;
78
+ if (rr?.template?.slug && typeof rr.template.slug === "string") {
79
+ return rr.template.slug;
80
+ }
81
+ return path.basename(path.dirname(rrPath));
82
+ }
83
+
84
+ function resolveConsumerSliceDir(rr, rrPath, slug) {
85
+ const rrDir = path.dirname(rrPath);
86
+ // Honor a slice-root override if present in rr.json, else default to
87
+ // frontend/slices/<slug>/ — matches the kitab convention.
88
+ const sliceRoot =
89
+ rr?.layout?.sliceRoot && typeof rr.layout.sliceRoot === "string"
90
+ ? rr.layout.sliceRoot
91
+ : "frontend/slices";
92
+ return path.resolve(rrDir, sliceRoot, slug);
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Snapshot builders
97
+ // ---------------------------------------------------------------------------
98
+
99
+ export async function buildKitabSnapshot(slug, ctx) {
100
+ return snapshotFromDir(slug, ctx.kitabSliceDir);
101
+ }
102
+
103
+ export async function buildConsumerSnapshot(slug, ctx) {
104
+ if (!existsSync(ctx.consumerSliceDir)) {
105
+ // First-time sync — consumer has no copy yet; treat as empty snapshot.
106
+ return { slug, version: "0.0.0", files: {} };
107
+ }
108
+ return snapshotFromDir(slug, ctx.consumerSliceDir);
109
+ }
110
+
111
+ export async function buildBaseSnapshot(slug, ctx, kitabSnap) {
112
+ // First-time sync (no DNA) → use kitab tip as base.
113
+ const dna = readDNA(slug);
114
+ const consumerAd = dna?.consumers?.[ctx.consumerName];
115
+ if (!consumerAd?.version) {
116
+ return cloneSnap(kitabSnap);
117
+ }
118
+
119
+ // Look up the commit by version tag, fall back to the most-recent commit
120
+ // touching the slice path.
121
+ const ref = findCommitForVersion(ctx.kitabRoot, slug, consumerAd.version);
122
+ if (!ref) return cloneSnap(kitabSnap);
123
+
124
+ const files = readSliceAtRef(ctx.kitabRoot, slug, ref);
125
+ // Snapshot at base ref typically lacks a parsed contract — that's OK; the
126
+ // merge algorithm treats it as no-membership, which mirrors "base had none".
127
+ return { slug, version: consumerAd.version, files };
128
+ }
129
+
130
+ function cloneSnap(snap) {
131
+ return {
132
+ slug: snap.slug,
133
+ version: snap.version,
134
+ files: { ...snap.files },
135
+ ...(snap.contract ? { contract: JSON.parse(JSON.stringify(snap.contract)) } : {}),
136
+ };
137
+ }
138
+
139
+ /** Try `git rev-list -1 <tag>` then `git log -1 --format=%H -- <slicePath>`. */
140
+ function findCommitForVersion(repo, slug, version) {
141
+ const sliceRel = `frontend/slices/${slug}`;
142
+ for (const tag of [version, `v${version}`, `${slug}@${version}`]) {
143
+ const r = spawnSync("git", ["rev-list", "-1", tag], {
144
+ cwd: repo,
145
+ encoding: "utf8",
146
+ });
147
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
148
+ }
149
+ // Fallback: most-recent commit touching the slice path.
150
+ const r = spawnSync(
151
+ "git",
152
+ ["log", "-1", "--format=%H", "main", "--", sliceRel],
153
+ { cwd: repo, encoding: "utf8" },
154
+ );
155
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
156
+ return null;
157
+ }
158
+
159
+ /** Read every tracked file under frontend/slices/<slug>/ at `ref` into a map. */
160
+ function readSliceAtRef(repo, slug, ref) {
161
+ const sliceRel = `frontend/slices/${slug}`;
162
+ const ls = spawnSync(
163
+ "git",
164
+ ["ls-tree", "-r", "--name-only", ref, "--", sliceRel],
165
+ { cwd: repo, encoding: "utf8" },
166
+ );
167
+ /** @type {Record<string,string>} */
168
+ const out = {};
169
+ if (ls.status !== 0) return out;
170
+ const lines = ls.stdout.split("\n").filter(Boolean);
171
+ for (const line of lines) {
172
+ const rel = line.startsWith(sliceRel + "/")
173
+ ? line.slice(sliceRel.length + 1)
174
+ : line;
175
+ // Skip files we don't snapshot (binary etc).
176
+ if (!/\.(ts|tsx|mjs|js|jsx|json|md|css)$/.test(rel)) continue;
177
+ const show = spawnSync("git", ["show", `${ref}:${line}`], {
178
+ cwd: repo,
179
+ encoding: "utf8",
180
+ });
181
+ if (show.status === 0) out[rel] = show.stdout;
182
+ }
183
+ return out;
184
+ }
@@ -0,0 +1,110 @@
1
+ // update-output.mjs — ASCII reporter + force-apply / DNA helpers for
2
+ // `rr update`. Extracted from update.mjs.
3
+
4
+ import path from "node:path";
5
+ import kleur from "kleur";
6
+
7
+ import { appendLineage, readDNA, upsertConsumerAdoption } from "../lib/dna.mjs";
8
+
9
+ export function printReport(report, ctx) {
10
+ const s = report.summary;
11
+ process.stdout.write(
12
+ `\n${kleur.bold("3-way merge")} — ${kleur.cyan(report.slug)} ` +
13
+ kleur.dim(`(consumer: ${ctx.consumerName})`) +
14
+ "\n",
15
+ );
16
+ process.stdout.write(
17
+ ` ${kleur.green("auto-merged:")} ${s.autoMerged} ` +
18
+ `${kleur.green("kitab-clean:")} ${s.kitabWinsClean} ` +
19
+ `${kleur.yellow("consumer-clean:")} ${s.consumerWinsClean} ` +
20
+ `${kleur.red("conflicts:")} ${s.conflicts} ` +
21
+ `${kleur.dim("identical:")} ${s.identical}\n`,
22
+ );
23
+ process.stdout.write(
24
+ ` ${kleur.bold("drift after merge:")} ${formatDrift(report.driftAfterMerge)}\n`,
25
+ );
26
+
27
+ const nonIdentical = report.outcomes.filter((o) => o.kind !== "identical");
28
+ if (nonIdentical.length > 0) {
29
+ process.stdout.write(`\n${kleur.bold("Outcomes")}\n`);
30
+ for (const o of nonIdentical) {
31
+ const tag = kindTag(o.kind);
32
+ process.stdout.write(` ${tag} ${o.element}${o.conflictHint ? kleur.dim(` — ${o.conflictHint}`) : ""}\n`);
33
+ }
34
+ }
35
+ process.stdout.write("\n");
36
+ }
37
+
38
+ function formatDrift(d) {
39
+ if (d >= 40) return kleur.red(`${d}%`);
40
+ if (d >= 15) return kleur.yellow(`${d}%`);
41
+ return kleur.green(`${d}%`);
42
+ }
43
+
44
+ function kindTag(k) {
45
+ switch (k) {
46
+ case "auto-merged":
47
+ return kleur.green("[auto] ");
48
+ case "kitab-wins-clean":
49
+ return kleur.green("[kitab] ");
50
+ case "consumer-wins-clean":
51
+ return kleur.yellow("[consumer]");
52
+ case "conflict":
53
+ return kleur.red("[conflict]");
54
+ default:
55
+ return kleur.dim("[same] ");
56
+ }
57
+ }
58
+
59
+ export function recordLineage(slug, ctx, report) {
60
+ const at = new Date().toISOString();
61
+ try {
62
+ appendLineage(slug, {
63
+ from: `kitab:frontend/slices/${slug}`,
64
+ to: `consumer:${ctx.consumerName}`,
65
+ at,
66
+ transforms: ["3-way-merge", "consumer-sync"],
67
+ actor: "rr update",
68
+ });
69
+ const dna = readDNA(slug);
70
+ const existing = dna?.consumers?.[ctx.consumerName];
71
+ upsertConsumerAdoption(slug, ctx.consumerName, {
72
+ adopted_at: existing?.adopted_at ?? at,
73
+ version: report.mergedSnapshot?.version ?? existing?.version ?? "0.0.0",
74
+ drift_score: report.driftAfterMerge,
75
+ last_synced_at: at,
76
+ });
77
+ } catch (err) {
78
+ process.stderr.write(
79
+ kleur.yellow(
80
+ ` (could not update DNA lineage: ${err.message ?? err})\n`,
81
+ ),
82
+ );
83
+ }
84
+ }
85
+
86
+ export function buildForcedSnapshot(report, kitabSnap) {
87
+ /** @type {Record<string,string>} */
88
+ const files = {};
89
+ for (const o of report.outcomes) {
90
+ if (!o.element.startsWith("files/")) continue;
91
+ const rel = o.element.slice("files/".length);
92
+ if (o.kind === "conflict") {
93
+ // On force-apply, prefer kitab value (or consumer if kitab dropped).
94
+ const v = o.kitabValue ?? o.consumerValue;
95
+ if (v != null) files[rel] = /** @type {string} */ (v);
96
+ } else if (o.mergedValue != null) {
97
+ files[rel] = /** @type {string} */ (o.mergedValue);
98
+ }
99
+ }
100
+ return { slug: kitabSnap.slug, version: kitabSnap.version, files };
101
+ }
102
+
103
+ export async function writeForced(snap, targetDir) {
104
+ const { mkdir, writeFile } = await import("node:fs/promises");
105
+ for (const [rel, content] of Object.entries(snap.files)) {
106
+ const dest = path.join(targetDir, rel);
107
+ await mkdir(path.dirname(dest), { recursive: true });
108
+ await writeFile(dest, content);
109
+ }
110
+ }