rahman-resources 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,6 +70,111 @@ Run `npx rahman-resources list` to see the live catalog. Highlights:
70
70
  | `cms-public-storefront` | cms | E-commerce / blog storefront |
71
71
  | `landing-*` | marketing | Hero/bento/masonry/kinetic landings |
72
72
 
73
+ ## DNA Graph
74
+
75
+ The kitab tracks slice **lineage** (where a slice came from and how it was transformed) and **adoption** (which downstream consumers picked it up and how much they drifted) in `.kitab/lineage/<slug>.dna.json` files. Together they form a directed graph traversable by humans and Claude (via MCP).
76
+
77
+ Each DNA file has three sections:
78
+
79
+ - `id`, `created_at` — slice identity
80
+ - `lineage[]` — `{ from, to?, at, transforms[], actor? }` rows recording every harvest hop. `from`/`to` use `<sourceRepo>:<path>` syntax (e.g. `superspace:frontend/slices/auth` → `kitab:0.1.0`). Transforms are tags like `alias-rewrite`, `clerk-strip`, `namespace-rename`.
81
+ - `consumers{}` — keyed by consumer name (notion, superspace, careerpack, content, rahmanef, cescadesigns). Each entry records `adopted_at`, `version`, `drift_score` (0-100), and optionally `last_synced_at`.
82
+
83
+ ### `rr graph` command
84
+
85
+ ```bash
86
+ npx rahman-resources graph # ASCII summary + adoption matrix across all slices
87
+ npx rahman-resources graph --all # same as above (explicit)
88
+ npx rahman-resources graph convex-auth # full lineage tree + consumer rows for one slice
89
+ npx rahman-resources graph --json # machine-readable graph JSON
90
+ npx rahman-resources graph convex-auth --json
91
+ ```
92
+
93
+ The summary highlights drift: green <15%, yellow 15-39%, red ≥40%. A red cell signals the consumer copy diverged enough that a re-sync from the kitab will conflict — time to lift improvements back UP via `/rr-prep` + `/rr-send`.
94
+
95
+ ### MCP surface
96
+
97
+ The companion `rahman-resources-mcp` server exposes the same data:
98
+
99
+ - `rr://graph/lineage` — full graph payload (`{ slices, graph: { nodes, edges } }`)
100
+ - `rr://graph/lineage/<slug>` — single slice DNA
101
+ - `rr://graph/consumers/<consumer-name>` — every slice that consumer adopted, with version + drift
102
+
103
+ ### Local-only shards
104
+
105
+ Files matching `.kitab/lineage/*.local.json` are gitignored, so contributors can stage experimental DNA without committing it. Promote to `<slug>.dna.json` to ship it.
106
+
107
+ ## Compose Solver
108
+
109
+ Phase B of the Slice Composition Compiler. The `compose` subcommand takes the project state from your `rr.json` plus a list of desired slice slugs, then computes a compatible subset (or rejects with a human-readable proof of every conflict).
110
+
111
+ ```bash
112
+ npx rahman-resources compose doku-payment mdx-blog
113
+ npx rahman-resources compose doku-payment midtrans-payment # arbitrates the conflict
114
+ npx rahman-resources compose doku-payment --json # machine-readable
115
+ npx rahman-resources compose doku-payment --rr-path ./apps/x/rr.json
116
+ npx rahman-resources compose doku-payment --no-deps # disable transitive dep resolution
117
+ npx rahman-resources compose doku-payment --strict # CI gate: uncontracted + warnings → blockers
118
+ ```
119
+
120
+ The solver enforces:
121
+
122
+ - **auth-mismatch** (blocker) — slice requires auth X, rr.json has Y.
123
+ - **table-collision** (blocker) — two slices declare the same Convex table, or a slice's table is already in the target's schema. Pair collisions are **arbitrated**: the slice with fewer dependers (or, on ties, the lex-later slug) is dropped — both are no longer rejected.
124
+ - **explicit-conflict** (blocker) — `contract.conflicts: ["<other>:tables.<value>"]` matches `<other>.provides.tables`. Also arbitrated.
125
+ - **missing-dep** (blocker) — slice's `requires.deps[]` missing from both the candidate set and `slicesInstalled`. Also fires for desired slugs without a contract **only in `--strict` mode**.
126
+ - **uncontracted** (warning) — desired slug with no `slice.contract.ts` registered. Accepted by default; flip to blocker with `--strict`.
127
+ - **both-installed-conflict** (warning) — both colliding slices are already in `state.slicesInstalled`; neither is dropped.
128
+ - **rbac-collision** (warning) — two slices declare the same permission. Surfaced; never blocks.
129
+ - **env-missing** (warning) — `requires.env[]` not in the target's `envExisting`. Surfaced; never blocks (elevated to blocker in `--strict`).
130
+
131
+ Transitive deps are pulled in automatically (BFS with proper visited-set; throws with the full path on cycle, e.g. `dependency cycle detected: a → b → c → a`).
132
+
133
+ ### Strict mode (`--strict`)
134
+
135
+ Pass `--strict` to either `compose` or `add` to flip into CI-gate behavior: every warning is elevated to a blocker, and uncontracted slugs are rejected with `missing-dep`. Use this in CI; use the default for day-to-day operator runs where most slices still ship without contracts.
136
+
137
+ ### Pre-flight gate on `rr add`
138
+
139
+ `rr add <slug>` runs the same solver against `[slug]` before any file copy. If any blocker conflicts surface, `add` aborts and prints the proof. Pass `--force` to skip the gate (a warning is logged), or `--strict` to enforce strict-mode checking.
140
+
141
+ A full algorithm walkthrough with worked examples lives in [`docs/compose-solver.md`](../../docs/compose-solver.md).
142
+
143
+ ## Bidirectional Sync
144
+
145
+ Most slice registries push updates one-way (registry → consumer). The kitab is bidirectional: when an upstream slice improves, consumers can pull the update via 3-way semantic merge — file-level + contract-surface — without losing local customizations.
146
+
147
+ ```bash
148
+ npx rahman-resources update <slug> # dry-run preview (default)
149
+ npx rahman-resources update <slug> --apply # write merged files
150
+ npx rahman-resources update <slug> --apply --force # apply even with conflicts (kitab wins)
151
+ npx rahman-resources update <slug> --json # machine-readable report
152
+ npx rahman-resources update <slug> --rr-path P # point at an rr.json outside cwd
153
+ ```
154
+
155
+ The engine emits a `MergeReport` with per-element outcomes (`auto-merged`, `consumer-wins-clean`, `kitab-wins-clean`, `conflict`, `identical`), a summary, and `driftAfterMerge` (0-100). When the merge is clean it also produces a ready-to-write `mergedSnapshot`.
156
+
157
+ Conflicts surface a `conflictHint` describing why (e.g. _"kitab dropped `paymentOrders`; consumer still relies on it"_). Re-sync activity is appended as a `3-way-merge` lineage entry, and the consumer's `drift_score` is updated so `rr graph` reflects reality.
158
+
159
+ See [docs/bidir-sync.md](../../docs/bidir-sync.md) for the full algorithm + drift-score formula.
160
+
161
+ ## Migration Planner
162
+
163
+ When a slice contract changes shape between versions, `rr migrate` generates a concrete, risk-scored migration plan — Convex schema deltas, env adds, RBAC patches — including ready-to-paste artifacts and ready-to-write `convex/migrations/*.ts` files.
164
+
165
+ ```bash
166
+ npx rahman-resources migrate <slug> --from <v1> [--to <v2>] # ASCII plan
167
+ npx rahman-resources migrate <slug> --from <v1> --json # machine-readable
168
+ npx rahman-resources migrate <slug> --from <v1> --write-files # materialize convex/migrations/
169
+ npx rahman-resources migrate <slug> --from <v1> --write-files --force-overwrite
170
+ ```
171
+
172
+ Step kinds: `convex-schema-{add,drop,rename}-table`, `env-{add,remove}`, `rbac-{add,remove}-permission`, `route-{add,remove}` (info-only). Each step carries `risk` (low/medium/high), `reversible` (bool), and pre-rendered artifacts (Convex schema snippet, full migration body, env line, RBAC patch).
173
+
174
+ Rename detection: when the new contract declares `migrationFrom: { "<old-version>": "<marker>" }`, the planner pairs single-sided table additions/removals positionally and emits a single reversible `convex-schema-rename-table` step instead of a destructive drop+add. The marker string is opaque — only its presence matters. See [docs/migration-planner.md](../../docs/migration-planner.md) for the full algorithm and the DOKU rename proof.
175
+
176
+ On `--write-files` the CLI appends a DNA lineage entry (`transforms: ["migration-applied", "<step-ids>"]`) so `rr graph` reflects the migration.
177
+
73
178
  ## Updating the manifest
74
179
 
75
180
  The manifest is generated from `site/lib/content/layouts.ts`. To regenerate:
package/bin/cli.js CHANGED
@@ -29,6 +29,10 @@ import {
29
29
  addSkill as rrAddSkill,
30
30
  } from "../lib/rr.mjs";
31
31
  import { runPostInit } from "../lib/post-init.mjs";
32
+ import { runGraph } from "./graph.mjs";
33
+ import { runCompose, preflight as composePreflight } from "./compose.mjs";
34
+ import { runUpdate as runUpdate3Way } from "./update.mjs";
35
+ import { runMigrate } from "./migrate.mjs";
32
36
 
33
37
  const require = createRequire(import.meta.url);
34
38
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -76,6 +80,12 @@ async function main() {
76
80
  return runInfo(rest);
77
81
  case "doctor":
78
82
  return runDoctor(rest);
83
+ case "graph":
84
+ return runGraph(rest);
85
+ case "compose":
86
+ return runCompose(rest);
87
+ case "migrate":
88
+ return runMigrate(rest);
79
89
  case "mcp":
80
90
  return runMcpHint();
81
91
  case undefined:
@@ -114,6 +124,10 @@ ${kleur.bold("Usage:")}
114
124
  npx rahman-resources list [layouts|recipes|features|skills|slices]
115
125
  npx rahman-resources info <slug>
116
126
  npx rahman-resources doctor
127
+ npx rahman-resources graph [slug] [--all] [--json]
128
+ npx rahman-resources compose <slug>... [--json] [--rr-path <path>] [--no-deps] [--strict]
129
+ npx rahman-resources update <slug> [--apply] [--force] [--rr-path P] [--json]
130
+ npx rahman-resources migrate <slug> --from <v1> [--to <v2>] [--json] [--write-files] [--force-overwrite]
117
131
  npx rahman-resources mcp
118
132
 
119
133
  ${kleur.bold("Init flags:")}
@@ -127,6 +141,8 @@ ${kleur.bold("Add flags:")}
127
141
  /preview/<slug> path constants in nav-config/robots/sitemap)
128
142
  --at preview install template AT app/preview/<slug>/ (default — sandbox style)
129
143
  --with-shadcn-all same as init flag
144
+ --strict enforce strict compose pre-flight (uncontracted/env-missing → blocker)
145
+ --force skip compose pre-flight entirely
130
146
 
131
147
  ${kleur.bold("Examples:")}
132
148
  npx rahman-resources init my-app
@@ -163,6 +179,25 @@ function csv(s) {
163
179
  return String(s).split(",").map((x) => x.trim()).filter(Boolean);
164
180
  }
165
181
 
182
+ // Walk up looking for the kitab repo root (`packages/` + `package.json`).
183
+ // Used by the compose pre-flight to anchor slice.contract.ts discovery
184
+ // regardless of where the CLI is invoked from.
185
+ function findRepoRoot(start) {
186
+ let dir = start;
187
+ for (let i = 0; i < 8; i++) {
188
+ if (
189
+ existsSync(path.join(dir, "packages")) &&
190
+ existsSync(path.join(dir, "package.json"))
191
+ ) {
192
+ return dir;
193
+ }
194
+ const parent = path.dirname(dir);
195
+ if (parent === dir) break;
196
+ dir = parent;
197
+ }
198
+ return process.cwd();
199
+ }
200
+
166
201
  // ─── catalog lookups ──────────────────────────────────────────────────────
167
202
 
168
203
  function findEntry(slug) {
@@ -432,6 +467,47 @@ async function runAdd(rest) {
432
467
  const { kind, entry } = found;
433
468
  const target = path.resolve(process.cwd(), targetArg);
434
469
 
470
+ // ── Pre-flight compose check (Phase B). Skipped with --force. ───────────
471
+ // Only run when the target has an rr.json — fresh dirs / non-rr consumers
472
+ // have no state to check against.
473
+ if (!flags.force && existsSync(path.join(target, "rr.json"))) {
474
+ try {
475
+ const repoRoot = findRepoRoot(__dirname);
476
+ const { result } = await composePreflight(slug, repoRoot, target, { strict: !!flags.strict });
477
+ const blockers = result.conflicts.filter((c) => c.severity === "blocker");
478
+ if (blockers.length > 0) {
479
+ console.error(kleur.red(`\n✖ compose pre-flight blocked "${slug}".`));
480
+ for (const line of result.proof) {
481
+ const colored = line.startsWith("+ ")
482
+ ? kleur.green(line)
483
+ : line.startsWith("- ")
484
+ ? kleur.red(line)
485
+ : line;
486
+ console.error(" " + colored);
487
+ }
488
+ for (const b of blockers) {
489
+ console.error(
490
+ " " + kleur.red(`[${b.type}]`) + " " + b.detail,
491
+ );
492
+ }
493
+ console.error(
494
+ kleur.dim(`\n Pass --force to skip this check.\n`),
495
+ );
496
+ process.exit(1);
497
+ }
498
+ } catch (err) {
499
+ // Pre-flight failure (e.g. tsx not available) shouldn't break legacy
500
+ // add flow — warn but proceed.
501
+ console.error(
502
+ kleur.yellow(
503
+ ` ⚠ compose pre-flight skipped (${err.message ?? err}).`,
504
+ ),
505
+ );
506
+ }
507
+ } else if (flags.force) {
508
+ console.log(kleur.yellow(` ⚠ --force: skipping compose pre-flight.`));
509
+ }
510
+
435
511
  if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
436
512
  if (kind === "layout") return addLayout(entry, target, targetArg, flags);
437
513
  if (kind === "feature") return addFeature(entry, target, targetArg);
@@ -447,24 +523,30 @@ async function runAdd(rest) {
447
523
  * For slices only (not layouts/features/recipes — those use `add`).
448
524
  */
449
525
  async function runUpdate(rest) {
526
+ // Bidirectional sync via 3-way merge engine (Phase D). The legacy
527
+ // raw-tiged re-pull path is reachable via `update --legacy <slug>` for
528
+ // back-compat with older docs that piggybacked on `runLift`.
450
529
  const { positional, flags } = parseFlags(rest);
451
- const [slug, targetArg = "."] = positional;
452
- if (!slug) {
453
- console.error(kleur.red("Missing slug. Usage: rahman-resources update <slug> [target] [--dry-run]"));
454
- process.exit(1);
455
- }
456
- const found = findEntry(slug);
457
- if (!found) throw new Error(`"${slug}" not found. Run ${kleur.cyan("npx rahman-resources list slices")}.`);
458
- const { kind, entry } = found;
459
- if (kind !== "slice") {
460
- console.error(kleur.red(`update only supported for slices. "${slug}" is a ${kind} — use \`add\` instead.`));
461
- process.exit(1);
530
+ if (flags.legacy) {
531
+ const [slug, targetArg = "."] = positional;
532
+ if (!slug) {
533
+ console.error(kleur.red("Missing slug. Usage: rahman-resources update <slug> [--apply] [--json]"));
534
+ process.exit(1);
535
+ }
536
+ const found = findEntry(slug);
537
+ if (!found) throw new Error(`"${slug}" not found. Run ${kleur.cyan("npx rahman-resources list slices")}.`);
538
+ const { kind, entry } = found;
539
+ if (kind !== "slice") {
540
+ console.error(kleur.red(`update --legacy only supported for slices. "${slug}" is a ${kind}.`));
541
+ process.exit(1);
542
+ }
543
+ console.log(kleur.bold(`\n→ (legacy) Re-pulling ${kleur.cyan(entry.slug)} into ${kleur.dim(targetArg)}\n`));
544
+ const liftArgs = [`rahman:${entry.slug}`];
545
+ if (targetArg !== ".") liftArgs.push("--target", targetArg);
546
+ if (flags["dry-run"]) liftArgs.push("--dry-run");
547
+ return runLift(liftArgs);
462
548
  }
463
- console.log(kleur.bold(`\n→ Re-pulling ${kleur.cyan(entry.slug)} into ${kleur.dim(targetArg)}\n`));
464
- const liftArgs = [`rahman:${entry.slug}`];
465
- if (targetArg !== ".") liftArgs.push("--target", targetArg);
466
- if (flags["dry-run"]) liftArgs.push("--dry-run");
467
- return runLift(liftArgs);
549
+ return runUpdate3Way(rest);
468
550
  }
469
551
 
470
552
  async function addLayout(t, target, targetArg, flags = {}) {
@@ -0,0 +1,287 @@
1
+ // `rr compose <slug1> <slug2> ...` — run the Phase B compose solver.
2
+ //
3
+ // rr compose doku-payment mdx-blog
4
+ // rr compose doku-payment midtrans-payment --json
5
+ // rr compose doku-payment --rr-path ./path/to/rr.json
6
+ // rr compose doku-payment --no-deps
7
+ //
8
+ // Dispatched from bin/cli.js. Imports the pure solver from
9
+ // ../lib/compose-solver.mjs and only handles I/O + presentation.
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import path from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import kleur from "kleur";
16
+
17
+ import { loadAllContracts, compose } from "../lib/compose-solver.mjs";
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+
21
+ /**
22
+ * Entry point invoked by cli.js with the post-`compose` argv tail.
23
+ * @param {string[]} rest
24
+ */
25
+ export async function runCompose(rest) {
26
+ const { positional, flags } = parseFlags(rest);
27
+ const slugs = positional.filter(Boolean);
28
+ if (slugs.length === 0) {
29
+ process.stderr.write(
30
+ "Usage: rahman-resources compose <slug1> [<slug2> ...] [--json] [--rr-path <path>] [--no-deps] [--strict]\n",
31
+ );
32
+ process.exit(1);
33
+ }
34
+ const asJson = !!flags.json;
35
+ const resolveDeps = !flags["no-deps"];
36
+ const strict = !!flags.strict;
37
+
38
+ // Locate rr.json (optional — solver tolerates an empty state).
39
+ const rrPath = typeof flags["rr-path"] === "string"
40
+ ? path.resolve(process.cwd(), flags["rr-path"])
41
+ : path.resolve(process.cwd(), "rr.json");
42
+ const state = readStateFromRr(rrPath);
43
+ if (strict) state.allowUnknownSlices = false;
44
+
45
+ // Walk up from packages/cli/bin/ to the kitab repo root.
46
+ const repoRoot = findRepoRoot(__dirname);
47
+ const contracts = await loadAllContracts(repoRoot);
48
+
49
+ let result;
50
+ try {
51
+ result = compose({ state, desired: slugs, resolveDeps }, contracts);
52
+ } catch (err) {
53
+ process.stderr.write(kleur.red(`compose: ${err.message ?? err}\n`));
54
+ process.exit(1);
55
+ }
56
+
57
+ // In strict mode, elevate ALL warnings → blockers and re-derive
58
+ // accepted / rejected so CI gates flag every soft issue.
59
+ if (strict) result = applyStrictMode(result);
60
+
61
+ if (asJson) {
62
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
63
+ if (hasBlocker(result)) process.exit(1);
64
+ return;
65
+ }
66
+
67
+ printHuman(result, { rrPath, repoRoot });
68
+ if (hasBlocker(result)) process.exit(1);
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Helpers
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function parseFlags(rest) {
76
+ const positional = [];
77
+ const flags = {};
78
+ for (let i = 0; i < rest.length; i++) {
79
+ const a = rest[i];
80
+ if (a.startsWith("--")) {
81
+ const key = a.slice(2);
82
+ const next = rest[i + 1];
83
+ if (next && !next.startsWith("--")) {
84
+ flags[key] = next;
85
+ i++;
86
+ } else {
87
+ flags[key] = true;
88
+ }
89
+ } else {
90
+ positional.push(a);
91
+ }
92
+ }
93
+ return { positional, flags };
94
+ }
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
+ }