rahman-resources 1.5.1 → 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 +108 -94
- 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/cli.js
CHANGED
|
@@ -34,6 +34,7 @@ import { runGraph } from "./graph.mjs";
|
|
|
34
34
|
import { runCompose, preflight as composePreflight } from "./compose.mjs";
|
|
35
35
|
import { runUpdate as runUpdate3Way } from "./update.mjs";
|
|
36
36
|
import { runMigrate } from "./migrate.mjs";
|
|
37
|
+
import { augmentConsumerEnv } from "../lib/env-augment.mjs";
|
|
37
38
|
|
|
38
39
|
const require = createRequire(import.meta.url);
|
|
39
40
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -141,9 +142,10 @@ ${kleur.bold("Init flags:")}
|
|
|
141
142
|
(heavy; ~50 components — use only if you'll customize beyond the template)
|
|
142
143
|
|
|
143
144
|
${kleur.bold("Add flags:")}
|
|
144
|
-
--at root install template AT app/(public)/ + app/admin/ (
|
|
145
|
+
--at root install template AT app/(public)/ + app/admin/ (default — rewrites
|
|
145
146
|
/preview/<slug> path constants in nav-config/robots/sitemap)
|
|
146
|
-
--at preview install template AT app/preview/<slug>/ (
|
|
147
|
+
--at preview install template AT app/preview/<slug>/ (sandbox; keeps hardcoded
|
|
148
|
+
/preview/<slug> URLs — only useful for parallel demo of multiple templates)
|
|
147
149
|
--with-shadcn-all same as init flag
|
|
148
150
|
--strict enforce strict compose pre-flight (uncontracted/env-missing → blocker)
|
|
149
151
|
--force skip compose pre-flight entirely
|
|
@@ -516,7 +518,22 @@ async function runAdd(rest) {
|
|
|
516
518
|
console.log(kleur.yellow(` ⚠ --force: skipping compose pre-flight.`));
|
|
517
519
|
}
|
|
518
520
|
|
|
519
|
-
if (kind === "slice")
|
|
521
|
+
if (kind === "slice") {
|
|
522
|
+
console.log(
|
|
523
|
+
kleur.bold(`\n→ Adding slice ${kleur.cyan(entry.slug)} `) +
|
|
524
|
+
kleur.dim(`[SLICE — drop-in feature]`) +
|
|
525
|
+
kleur.bold(` to ${kleur.dim(target)}\n`),
|
|
526
|
+
);
|
|
527
|
+
await runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
|
|
528
|
+
// Augment consumer .env.example with this slice's env requirements.
|
|
529
|
+
// Idempotent — re-running `add` does not duplicate entries.
|
|
530
|
+
try {
|
|
531
|
+
augmentConsumerEnv(entry, target);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
console.error(kleur.yellow(` ⚠ env augment skipped: ${err.message ?? err}`));
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
520
537
|
if (kind === "layout") return addLayout(entry, target, targetArg, flags);
|
|
521
538
|
if (kind === "feature") return addFeature(entry, target, targetArg);
|
|
522
539
|
if (kind === "recipe") return addRecipe(entry);
|
|
@@ -558,14 +575,26 @@ async function runUpdate(rest) {
|
|
|
558
575
|
}
|
|
559
576
|
|
|
560
577
|
async function addLayout(t, target, targetArg, flags = {}) {
|
|
561
|
-
console.log(
|
|
578
|
+
console.log(
|
|
579
|
+
kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} `) +
|
|
580
|
+
kleur.dim(`[TEMPLATE — full-app scaffold]`) +
|
|
581
|
+
kleur.bold(` into ${kleur.dim(target)}\n`),
|
|
582
|
+
);
|
|
562
583
|
if (!t.pullPaths || t.pullPaths.length === 0) {
|
|
563
584
|
throw new Error(`Layout "${t.slug}" has no valid pullPaths in manifest.`);
|
|
564
585
|
}
|
|
565
|
-
const at = typeof flags.at === "string" ? flags.at : "
|
|
586
|
+
const at = typeof flags.at === "string" ? flags.at : "root";
|
|
566
587
|
if (!["root", "preview"].includes(at)) {
|
|
567
588
|
throw new Error(`--at must be "root" or "preview" (got "${at}").`);
|
|
568
589
|
}
|
|
590
|
+
if (at === "preview") {
|
|
591
|
+
console.log(
|
|
592
|
+
kleur.yellow(
|
|
593
|
+
` ⚠ --at preview: routes stay under /preview/${t.slug}/* (sandbox).\n` +
|
|
594
|
+
` For a production-ready install, omit --at or pass --at root.\n`,
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
}
|
|
569
598
|
|
|
570
599
|
for (const p of t.pullPaths) {
|
|
571
600
|
const dest = path.join(target, p);
|
|
@@ -756,7 +785,11 @@ function rewritePreviewPaths(target, slug) {
|
|
|
756
785
|
}
|
|
757
786
|
|
|
758
787
|
async function addFeature(t, target, targetArg) {
|
|
759
|
-
console.log(
|
|
788
|
+
console.log(
|
|
789
|
+
kleur.bold(`\n→ Adding feature ${kleur.cyan(t.title)} `) +
|
|
790
|
+
kleur.dim(`[SLICE — drop-in feature]`) +
|
|
791
|
+
kleur.bold(` to ${kleur.dim(target)}\n`),
|
|
792
|
+
);
|
|
760
793
|
if (!t.npmPackages || t.npmPackages.length === 0) {
|
|
761
794
|
console.log(kleur.dim(` No npm packages to install (${t.install}).`));
|
|
762
795
|
} else {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// compose-print.mjs — human ASCII renderer for `rr compose`.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from compose.mjs.
|
|
4
|
+
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import kleur from "kleur";
|
|
7
|
+
|
|
8
|
+
import { hasBlocker } from "./compose-state.mjs";
|
|
9
|
+
|
|
10
|
+
export function printHuman(result, { rrPath, repoRoot }) {
|
|
11
|
+
const blocker = hasBlocker(result);
|
|
12
|
+
const head = blocker
|
|
13
|
+
? kleur.red("✖ compose blocked")
|
|
14
|
+
: kleur.green("✓ compose ok");
|
|
15
|
+
console.log(
|
|
16
|
+
`\n${head} ${kleur.dim(`rr.json: ${path.relative(repoRoot, rrPath) || rrPath}`)}\n`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
console.log(kleur.bold("Proof"));
|
|
20
|
+
if (result.proof.length === 0) {
|
|
21
|
+
console.log(" " + kleur.dim("(no decisions — empty desired set)"));
|
|
22
|
+
} else {
|
|
23
|
+
for (const line of result.proof) {
|
|
24
|
+
const colored = line.startsWith("+ ")
|
|
25
|
+
? kleur.green(line)
|
|
26
|
+
: line.startsWith("- ")
|
|
27
|
+
? kleur.red(line)
|
|
28
|
+
: line;
|
|
29
|
+
console.log(" " + colored);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (result.accepted.length > 0) {
|
|
34
|
+
console.log(`\n${kleur.bold("Accepted")} ${kleur.dim(`(${result.accepted.length})`)}`);
|
|
35
|
+
for (const s of result.accepted) console.log(" " + kleur.cyan(s));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (result.rejected.length > 0) {
|
|
39
|
+
console.log(`\n${kleur.bold("Rejected")} ${kleur.dim(`(${result.rejected.length})`)}`);
|
|
40
|
+
for (const r of result.rejected) {
|
|
41
|
+
console.log(" " + kleur.red(r.slug));
|
|
42
|
+
for (const reason of r.reasons) {
|
|
43
|
+
console.log(
|
|
44
|
+
" " +
|
|
45
|
+
kleur.dim(`[${reason.type}]`) +
|
|
46
|
+
" " +
|
|
47
|
+
reason.detail,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const warnings = result.conflicts.filter((c) => c.severity === "warning");
|
|
54
|
+
if (warnings.length > 0) {
|
|
55
|
+
console.log(`\n${kleur.bold("Warnings")} ${kleur.dim(`(${warnings.length})`)}`);
|
|
56
|
+
for (const w of warnings) {
|
|
57
|
+
console.log(" " + kleur.yellow(`[${w.type}]`) + " " + w.detail);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (result.envMissing.length > 0) {
|
|
62
|
+
console.log(`\n${kleur.bold("Env needed")}`);
|
|
63
|
+
for (const e of result.envMissing) console.log(" " + kleur.yellow(e));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (result.rbacToCreate.length > 0) {
|
|
67
|
+
console.log(`\n${kleur.bold("RBAC to create")}`);
|
|
68
|
+
for (const p of result.rbacToCreate) console.log(" " + kleur.yellow(p));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (result.tablesAdded.length > 0) {
|
|
72
|
+
console.log(`\n${kleur.bold("Tables added")}`);
|
|
73
|
+
for (const t of result.tablesAdded) {
|
|
74
|
+
console.log(
|
|
75
|
+
" " + kleur.cyan(t.slug) + ": " + kleur.dim(t.tables.join(", ")),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log(
|
|
81
|
+
`\n${kleur.dim("appendix: pass --json for machine-readable output")}\n`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// compose-state.mjs — rr.json reader + strict-mode helper + repo-root finder.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from compose.mjs.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Walk up from `start` looking for the directory that contains a `packages/`
|
|
10
|
+
* folder + `package.json`. Falls back to cwd.
|
|
11
|
+
*/
|
|
12
|
+
export function findRepoRoot(start) {
|
|
13
|
+
let dir = start;
|
|
14
|
+
for (let i = 0; i < 8; i++) {
|
|
15
|
+
if (
|
|
16
|
+
existsSync(path.join(dir, "packages")) &&
|
|
17
|
+
existsSync(path.join(dir, "package.json"))
|
|
18
|
+
) {
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
const parent = path.dirname(dir);
|
|
22
|
+
if (parent === dir) break;
|
|
23
|
+
dir = parent;
|
|
24
|
+
}
|
|
25
|
+
return process.cwd();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse rr.json (if present) into a {@link RrJsonState}. Tolerates a
|
|
30
|
+
* missing file by returning an empty state.
|
|
31
|
+
*
|
|
32
|
+
* Maps `rr.json#/auth.provider` to the solver's `auth` enum:
|
|
33
|
+
* "convex-auth" → "convex"
|
|
34
|
+
* "clerk" / "next-auth" / "none" → passthrough
|
|
35
|
+
*/
|
|
36
|
+
export function readStateFromRr(rrPath) {
|
|
37
|
+
if (!existsSync(rrPath)) return {};
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = JSON.parse(readFileSync(rrPath, "utf8"));
|
|
41
|
+
} catch {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
const provider = raw?.auth?.provider;
|
|
45
|
+
const authMap = {
|
|
46
|
+
"convex-auth": "convex",
|
|
47
|
+
clerk: "clerk",
|
|
48
|
+
"next-auth": "next-auth",
|
|
49
|
+
none: "none",
|
|
50
|
+
};
|
|
51
|
+
const auth = provider ? authMap[provider] ?? undefined : undefined;
|
|
52
|
+
|
|
53
|
+
const slicesInstalled = [
|
|
54
|
+
...(raw?.slices ?? []).map((s) => s.slug).filter(Boolean),
|
|
55
|
+
...(raw?.features ?? []).map((f) => f.slug).filter(Boolean),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
auth,
|
|
60
|
+
slicesInstalled,
|
|
61
|
+
envExisting: Array.isArray(raw?.envExisting) ? raw.envExisting : [],
|
|
62
|
+
rbacRolesExisting: Array.isArray(raw?.rbacRolesExisting)
|
|
63
|
+
? raw.rbacRolesExisting
|
|
64
|
+
: [],
|
|
65
|
+
convexTablesExisting: Array.isArray(raw?.convexTablesExisting)
|
|
66
|
+
? raw.convexTablesExisting
|
|
67
|
+
: [],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function hasBlocker(result) {
|
|
72
|
+
return result.conflicts.some((c) => c.severity === "blocker");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Elevate every `warning`-level conflict to `blocker`, then re-derive the
|
|
77
|
+
* accepted / rejected sets so the slice that triggered any warning gets
|
|
78
|
+
* rejected. Used for `--strict` / CI gating.
|
|
79
|
+
*
|
|
80
|
+
* @param {import("../lib/compose-solver").ComposeResult} result
|
|
81
|
+
* @returns {import("../lib/compose-solver").ComposeResult}
|
|
82
|
+
*/
|
|
83
|
+
export function applyStrictMode(result) {
|
|
84
|
+
const conflicts = result.conflicts.map((c) =>
|
|
85
|
+
c.severity === "warning" ? { ...c, severity: "blocker" } : c,
|
|
86
|
+
);
|
|
87
|
+
const blockersBySlug = new Map();
|
|
88
|
+
for (const c of conflicts) {
|
|
89
|
+
if (c.severity !== "blocker") continue;
|
|
90
|
+
const cur = blockersBySlug.get(c.slug) ?? [];
|
|
91
|
+
cur.push(c);
|
|
92
|
+
blockersBySlug.set(c.slug, cur);
|
|
93
|
+
}
|
|
94
|
+
const newRejected = [...result.rejected];
|
|
95
|
+
const newAccepted = [];
|
|
96
|
+
for (const slug of result.accepted) {
|
|
97
|
+
const bl = blockersBySlug.get(slug);
|
|
98
|
+
if (bl && bl.length > 0) {
|
|
99
|
+
newRejected.push({ slug, reasons: bl, note: "strict-mode" });
|
|
100
|
+
} else {
|
|
101
|
+
newAccepted.push(slug);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return { ...result, conflicts, accepted: newAccepted, rejected: newRejected };
|
|
105
|
+
}
|
package/bin/compose.mjs
CHANGED
|
@@ -7,14 +7,24 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Dispatched from bin/cli.js. Imports the pure solver from
|
|
9
9
|
// ../lib/compose-solver.mjs and only handles I/O + presentation.
|
|
10
|
+
//
|
|
11
|
+
// Module split (kept ≤200 LOC each):
|
|
12
|
+
// - compose-state.mjs — rr.json reader + strict-mode + repo-root finder
|
|
13
|
+
// - compose-print.mjs — ASCII renderer for the human output mode
|
|
10
14
|
|
|
11
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
12
15
|
import path from "node:path";
|
|
13
16
|
import { fileURLToPath } from "node:url";
|
|
14
17
|
|
|
15
18
|
import kleur from "kleur";
|
|
16
19
|
|
|
17
20
|
import { loadAllContracts, compose } from "../lib/compose-solver.mjs";
|
|
21
|
+
import {
|
|
22
|
+
findRepoRoot,
|
|
23
|
+
readStateFromRr,
|
|
24
|
+
hasBlocker,
|
|
25
|
+
applyStrictMode,
|
|
26
|
+
} from "./compose-state.mjs";
|
|
27
|
+
import { printHuman } from "./compose-print.mjs";
|
|
18
28
|
|
|
19
29
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
30
|
|
|
@@ -68,6 +78,25 @@ export async function runCompose(rest) {
|
|
|
68
78
|
if (hasBlocker(result)) process.exit(1);
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Programmatic helper used by `rr add` for the pre-flight gate.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} slug
|
|
85
|
+
* @param {string} repoRoot
|
|
86
|
+
* @param {string} targetDir Directory holding rr.json (cwd by default).
|
|
87
|
+
* @param {{ strict?: boolean }} [opts]
|
|
88
|
+
* @returns {Promise<{ result: import("../lib/compose-solver").ComposeResult; rrPath: string }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function preflight(slug, repoRoot, targetDir = process.cwd(), opts = {}) {
|
|
91
|
+
const rrPath = path.join(targetDir, "rr.json");
|
|
92
|
+
const state = readStateFromRr(rrPath);
|
|
93
|
+
if (opts.strict) state.allowUnknownSlices = false;
|
|
94
|
+
const contracts = await loadAllContracts(repoRoot);
|
|
95
|
+
let result = compose({ state, desired: [slug], resolveDeps: true }, contracts);
|
|
96
|
+
if (opts.strict) result = applyStrictMode(result);
|
|
97
|
+
return { result, rrPath };
|
|
98
|
+
}
|
|
99
|
+
|
|
71
100
|
// ---------------------------------------------------------------------------
|
|
72
101
|
// Helpers
|
|
73
102
|
// ---------------------------------------------------------------------------
|
|
@@ -92,196 +121,3 @@ function parseFlags(rest) {
|
|
|
92
121
|
}
|
|
93
122
|
return { positional, flags };
|
|
94
123
|
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Walk up from `start` looking for the directory that contains a `packages/`
|
|
98
|
-
* folder + `package.json`. Falls back to cwd.
|
|
99
|
-
*/
|
|
100
|
-
function findRepoRoot(start) {
|
|
101
|
-
let dir = start;
|
|
102
|
-
for (let i = 0; i < 8; i++) {
|
|
103
|
-
if (
|
|
104
|
-
existsSync(path.join(dir, "packages")) &&
|
|
105
|
-
existsSync(path.join(dir, "package.json"))
|
|
106
|
-
) {
|
|
107
|
-
return dir;
|
|
108
|
-
}
|
|
109
|
-
const parent = path.dirname(dir);
|
|
110
|
-
if (parent === dir) break;
|
|
111
|
-
dir = parent;
|
|
112
|
-
}
|
|
113
|
-
return process.cwd();
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Parse rr.json (if present) into a {@link RrJsonState}. Tolerates a
|
|
118
|
-
* missing file by returning an empty state.
|
|
119
|
-
*
|
|
120
|
-
* Maps `rr.json#/auth.provider` to the solver's `auth` enum:
|
|
121
|
-
* "convex-auth" → "convex"
|
|
122
|
-
* "clerk" / "next-auth" / "none" → passthrough
|
|
123
|
-
*/
|
|
124
|
-
function readStateFromRr(rrPath) {
|
|
125
|
-
if (!existsSync(rrPath)) return {};
|
|
126
|
-
let raw;
|
|
127
|
-
try {
|
|
128
|
-
raw = JSON.parse(readFileSync(rrPath, "utf8"));
|
|
129
|
-
} catch {
|
|
130
|
-
return {};
|
|
131
|
-
}
|
|
132
|
-
const provider = raw?.auth?.provider;
|
|
133
|
-
const authMap = {
|
|
134
|
-
"convex-auth": "convex",
|
|
135
|
-
clerk: "clerk",
|
|
136
|
-
"next-auth": "next-auth",
|
|
137
|
-
none: "none",
|
|
138
|
-
};
|
|
139
|
-
const auth = provider ? authMap[provider] ?? undefined : undefined;
|
|
140
|
-
|
|
141
|
-
const slicesInstalled = [
|
|
142
|
-
...(raw?.slices ?? []).map((s) => s.slug).filter(Boolean),
|
|
143
|
-
...(raw?.features ?? []).map((f) => f.slug).filter(Boolean),
|
|
144
|
-
];
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
auth,
|
|
148
|
-
slicesInstalled,
|
|
149
|
-
envExisting: Array.isArray(raw?.envExisting) ? raw.envExisting : [],
|
|
150
|
-
rbacRolesExisting: Array.isArray(raw?.rbacRolesExisting)
|
|
151
|
-
? raw.rbacRolesExisting
|
|
152
|
-
: [],
|
|
153
|
-
convexTablesExisting: Array.isArray(raw?.convexTablesExisting)
|
|
154
|
-
? raw.convexTablesExisting
|
|
155
|
-
: [],
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function hasBlocker(result) {
|
|
160
|
-
return result.conflicts.some((c) => c.severity === "blocker");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function printHuman(result, { rrPath, repoRoot }) {
|
|
164
|
-
const blocker = hasBlocker(result);
|
|
165
|
-
const head = blocker
|
|
166
|
-
? kleur.red("✖ compose blocked")
|
|
167
|
-
: kleur.green("✓ compose ok");
|
|
168
|
-
console.log(
|
|
169
|
-
`\n${head} ${kleur.dim(`rr.json: ${path.relative(repoRoot, rrPath) || rrPath}`)}\n`,
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
console.log(kleur.bold("Proof"));
|
|
173
|
-
if (result.proof.length === 0) {
|
|
174
|
-
console.log(" " + kleur.dim("(no decisions — empty desired set)"));
|
|
175
|
-
} else {
|
|
176
|
-
for (const line of result.proof) {
|
|
177
|
-
const colored = line.startsWith("+ ")
|
|
178
|
-
? kleur.green(line)
|
|
179
|
-
: line.startsWith("- ")
|
|
180
|
-
? kleur.red(line)
|
|
181
|
-
: line;
|
|
182
|
-
console.log(" " + colored);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (result.accepted.length > 0) {
|
|
187
|
-
console.log(`\n${kleur.bold("Accepted")} ${kleur.dim(`(${result.accepted.length})`)}`);
|
|
188
|
-
for (const s of result.accepted) console.log(" " + kleur.cyan(s));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
if (result.rejected.length > 0) {
|
|
192
|
-
console.log(`\n${kleur.bold("Rejected")} ${kleur.dim(`(${result.rejected.length})`)}`);
|
|
193
|
-
for (const r of result.rejected) {
|
|
194
|
-
console.log(" " + kleur.red(r.slug));
|
|
195
|
-
for (const reason of r.reasons) {
|
|
196
|
-
console.log(
|
|
197
|
-
" " +
|
|
198
|
-
kleur.dim(`[${reason.type}]`) +
|
|
199
|
-
" " +
|
|
200
|
-
reason.detail,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const warnings = result.conflicts.filter((c) => c.severity === "warning");
|
|
207
|
-
if (warnings.length > 0) {
|
|
208
|
-
console.log(`\n${kleur.bold("Warnings")} ${kleur.dim(`(${warnings.length})`)}`);
|
|
209
|
-
for (const w of warnings) {
|
|
210
|
-
console.log(" " + kleur.yellow(`[${w.type}]`) + " " + w.detail);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
if (result.envMissing.length > 0) {
|
|
215
|
-
console.log(`\n${kleur.bold("Env needed")}`);
|
|
216
|
-
for (const e of result.envMissing) console.log(" " + kleur.yellow(e));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (result.rbacToCreate.length > 0) {
|
|
220
|
-
console.log(`\n${kleur.bold("RBAC to create")}`);
|
|
221
|
-
for (const p of result.rbacToCreate) console.log(" " + kleur.yellow(p));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (result.tablesAdded.length > 0) {
|
|
225
|
-
console.log(`\n${kleur.bold("Tables added")}`);
|
|
226
|
-
for (const t of result.tablesAdded) {
|
|
227
|
-
console.log(
|
|
228
|
-
" " + kleur.cyan(t.slug) + ": " + kleur.dim(t.tables.join(", ")),
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
console.log(
|
|
234
|
-
`\n${kleur.dim("appendix: pass --json for machine-readable output")}\n`,
|
|
235
|
-
);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Programmatic helper used by `rr add` for the pre-flight gate.
|
|
240
|
-
*
|
|
241
|
-
* @param {string} slug
|
|
242
|
-
* @param {string} repoRoot
|
|
243
|
-
* @param {string} targetDir Directory holding rr.json (cwd by default).
|
|
244
|
-
* @param {{ strict?: boolean }} [opts]
|
|
245
|
-
* @returns {Promise<{ result: import("../lib/compose-solver").ComposeResult; rrPath: string }>}
|
|
246
|
-
*/
|
|
247
|
-
export async function preflight(slug, repoRoot, targetDir = process.cwd(), opts = {}) {
|
|
248
|
-
const rrPath = path.join(targetDir, "rr.json");
|
|
249
|
-
const state = readStateFromRr(rrPath);
|
|
250
|
-
if (opts.strict) state.allowUnknownSlices = false;
|
|
251
|
-
const contracts = await loadAllContracts(repoRoot);
|
|
252
|
-
let result = compose({ state, desired: [slug], resolveDeps: true }, contracts);
|
|
253
|
-
if (opts.strict) result = applyStrictMode(result);
|
|
254
|
-
return { result, rrPath };
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Elevate every `warning`-level conflict to `blocker`, then re-derive the
|
|
259
|
-
* accepted / rejected sets so the slice that triggered any warning gets
|
|
260
|
-
* rejected. Used for `--strict` / CI gating.
|
|
261
|
-
*
|
|
262
|
-
* @param {import("../lib/compose-solver").ComposeResult} result
|
|
263
|
-
* @returns {import("../lib/compose-solver").ComposeResult}
|
|
264
|
-
*/
|
|
265
|
-
function applyStrictMode(result) {
|
|
266
|
-
const conflicts = result.conflicts.map((c) =>
|
|
267
|
-
c.severity === "warning" ? { ...c, severity: "blocker" } : c,
|
|
268
|
-
);
|
|
269
|
-
const blockersBySlug = new Map();
|
|
270
|
-
for (const c of conflicts) {
|
|
271
|
-
if (c.severity !== "blocker") continue;
|
|
272
|
-
const cur = blockersBySlug.get(c.slug) ?? [];
|
|
273
|
-
cur.push(c);
|
|
274
|
-
blockersBySlug.set(c.slug, cur);
|
|
275
|
-
}
|
|
276
|
-
const newRejected = [...result.rejected];
|
|
277
|
-
const newAccepted = [];
|
|
278
|
-
for (const slug of result.accepted) {
|
|
279
|
-
const bl = blockersBySlug.get(slug);
|
|
280
|
-
if (bl && bl.length > 0) {
|
|
281
|
-
newRejected.push({ slug, reasons: bl, note: "strict-mode" });
|
|
282
|
-
} else {
|
|
283
|
-
newAccepted.push(slug);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return { ...result, conflicts, accepted: newAccepted, rejected: newRejected };
|
|
287
|
-
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// graph-render.mjs — ASCII renderers for `rr graph`.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from graph.mjs.
|
|
4
|
+
|
|
5
|
+
import kleur from "kleur";
|
|
6
|
+
|
|
7
|
+
import { listAllDNA, readDNA } from "../lib/dna.mjs";
|
|
8
|
+
|
|
9
|
+
export function printSummary() {
|
|
10
|
+
const slices = listAllDNA();
|
|
11
|
+
if (slices.length === 0) {
|
|
12
|
+
console.log(
|
|
13
|
+
kleur.yellow("No DNA files in .kitab/lineage/. ") +
|
|
14
|
+
kleur.dim("Seed some via `rr graph` integrations or manual write."),
|
|
15
|
+
);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
console.log(kleur.bold(`\nDNA graph — ${slices.length} slice(s)\n`));
|
|
19
|
+
|
|
20
|
+
// Header table: slug | lineage count | consumers | newest source
|
|
21
|
+
const rows = [];
|
|
22
|
+
for (const dna of slices) {
|
|
23
|
+
const newest = dna.lineage[dna.lineage.length - 1];
|
|
24
|
+
rows.push({
|
|
25
|
+
slug: dna.id,
|
|
26
|
+
lineage: dna.lineage.length,
|
|
27
|
+
consumers: Object.keys(dna.consumers).length,
|
|
28
|
+
source: newest ? newest.from : "—",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const w1 = Math.max(4, ...rows.map((r) => r.slug.length));
|
|
32
|
+
const w4 = Math.max(6, ...rows.map((r) => String(r.source).length));
|
|
33
|
+
console.log(
|
|
34
|
+
" " +
|
|
35
|
+
kleur.bold("slug".padEnd(w1)) +
|
|
36
|
+
" " +
|
|
37
|
+
kleur.bold("hops".padEnd(4)) +
|
|
38
|
+
" " +
|
|
39
|
+
kleur.bold("adopts".padEnd(6)) +
|
|
40
|
+
" " +
|
|
41
|
+
kleur.bold("latest source".padEnd(w4)),
|
|
42
|
+
);
|
|
43
|
+
console.log(
|
|
44
|
+
" " +
|
|
45
|
+
kleur.dim("-".repeat(w1)) +
|
|
46
|
+
" " +
|
|
47
|
+
kleur.dim("----") +
|
|
48
|
+
" " +
|
|
49
|
+
kleur.dim("------") +
|
|
50
|
+
" " +
|
|
51
|
+
kleur.dim("-".repeat(w4)),
|
|
52
|
+
);
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
console.log(
|
|
55
|
+
" " +
|
|
56
|
+
kleur.cyan(r.slug.padEnd(w1)) +
|
|
57
|
+
" " +
|
|
58
|
+
String(r.lineage).padEnd(4) +
|
|
59
|
+
" " +
|
|
60
|
+
String(r.consumers).padEnd(6) +
|
|
61
|
+
" " +
|
|
62
|
+
kleur.dim(r.source),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Consumer adoption matrix.
|
|
67
|
+
/** @type {Set<string>} */
|
|
68
|
+
const consumerSet = new Set();
|
|
69
|
+
for (const dna of slices) {
|
|
70
|
+
for (const c of Object.keys(dna.consumers)) consumerSet.add(c);
|
|
71
|
+
}
|
|
72
|
+
const consumers = [...consumerSet].sort();
|
|
73
|
+
if (consumers.length > 0) {
|
|
74
|
+
console.log(kleur.bold(`\nAdoption matrix\n`));
|
|
75
|
+
const cw = Math.max(4, ...rows.map((r) => r.slug.length));
|
|
76
|
+
console.log(
|
|
77
|
+
" " +
|
|
78
|
+
" ".repeat(cw) +
|
|
79
|
+
" " +
|
|
80
|
+
consumers
|
|
81
|
+
.map((c) => kleur.bold(c.slice(0, 10).padEnd(10)))
|
|
82
|
+
.join(" "),
|
|
83
|
+
);
|
|
84
|
+
for (const dna of slices) {
|
|
85
|
+
const cells = consumers
|
|
86
|
+
.map((c) => {
|
|
87
|
+
const ad = dna.consumers[c];
|
|
88
|
+
if (!ad) return kleur.dim("·".padEnd(10));
|
|
89
|
+
const drift = ad.drift_score;
|
|
90
|
+
const tag = `v${ad.version} ${drift}%`.slice(0, 10).padEnd(10);
|
|
91
|
+
if (drift >= 40) return kleur.red(tag);
|
|
92
|
+
if (drift >= 15) return kleur.yellow(tag);
|
|
93
|
+
return kleur.green(tag);
|
|
94
|
+
})
|
|
95
|
+
.join(" ");
|
|
96
|
+
console.log(" " + kleur.cyan(dna.id.padEnd(cw)) + " " + cells);
|
|
97
|
+
}
|
|
98
|
+
console.log(
|
|
99
|
+
kleur.dim(
|
|
100
|
+
`\n legend: green <15% drift, yellow 15-39%, red >=40%, · = not adopted`,
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log(
|
|
106
|
+
kleur.dim(
|
|
107
|
+
`\nRun \`rr graph <slug>\` for the full lineage tree, \`--json\` for machine output.\n`,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function printSliceTree(slug) {
|
|
113
|
+
const dna = readDNA(slug);
|
|
114
|
+
if (!dna) {
|
|
115
|
+
console.error(
|
|
116
|
+
kleur.red(`✖ No DNA found for "${slug}".`) +
|
|
117
|
+
kleur.dim(` (expected .kitab/lineage/${slug}.dna.json)`),
|
|
118
|
+
);
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log(
|
|
122
|
+
`\n${kleur.bold(dna.id)} ${kleur.dim(
|
|
123
|
+
`created ${dna.created_at}`,
|
|
124
|
+
)}\n`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Lineage chain — chronological top-down tree.
|
|
128
|
+
console.log(kleur.bold("Lineage"));
|
|
129
|
+
if (dna.lineage.length === 0) {
|
|
130
|
+
console.log(" " + kleur.dim("(no lineage entries)"));
|
|
131
|
+
} else {
|
|
132
|
+
const sorted = [...dna.lineage].sort((a, b) =>
|
|
133
|
+
String(a.at).localeCompare(String(b.at)),
|
|
134
|
+
);
|
|
135
|
+
sorted.forEach((entry, i) => {
|
|
136
|
+
const isLast = i === sorted.length - 1;
|
|
137
|
+
const bullet = isLast ? "└─" : "├─";
|
|
138
|
+
const cont = isLast ? " " : "│ ";
|
|
139
|
+
console.log(
|
|
140
|
+
` ${kleur.dim(bullet)} ${kleur.cyan(entry.from)}` +
|
|
141
|
+
(entry.to ? ` ${kleur.dim("→")} ${kleur.green(entry.to)}` : "") +
|
|
142
|
+
` ${kleur.dim(entry.at)}`,
|
|
143
|
+
);
|
|
144
|
+
if (entry.transforms?.length) {
|
|
145
|
+
console.log(
|
|
146
|
+
` ${kleur.dim(cont)} ${kleur.dim("transforms:")} ${entry.transforms.join(", ")}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (entry.actor) {
|
|
150
|
+
console.log(` ${kleur.dim(cont)} ${kleur.dim("actor:")} ${entry.actor}`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Consumer adoption rows.
|
|
156
|
+
console.log(`\n${kleur.bold("Consumers")}`);
|
|
157
|
+
const consumers = Object.entries(dna.consumers);
|
|
158
|
+
if (consumers.length === 0) {
|
|
159
|
+
console.log(" " + kleur.dim("(no consumers recorded)"));
|
|
160
|
+
} else {
|
|
161
|
+
const w = Math.max(4, ...consumers.map(([c]) => c.length));
|
|
162
|
+
for (const [name, ad] of consumers) {
|
|
163
|
+
const drift = ad.drift_score;
|
|
164
|
+
const driftStr =
|
|
165
|
+
drift >= 40
|
|
166
|
+
? kleur.red(`${drift}%`)
|
|
167
|
+
: drift >= 15
|
|
168
|
+
? kleur.yellow(`${drift}%`)
|
|
169
|
+
: kleur.green(`${drift}%`);
|
|
170
|
+
const sync = ad.last_synced_at ? ` synced ${ad.last_synced_at}` : "";
|
|
171
|
+
console.log(
|
|
172
|
+
` ${kleur.cyan(name.padEnd(w))} v${ad.version} drift ${driftStr} ${kleur.dim(
|
|
173
|
+
`adopted ${ad.adopted_at}${sync}`,
|
|
174
|
+
)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
console.log("");
|
|
179
|
+
}
|