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/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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
+
}
|