rahman-resources 0.9.2 → 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 +105 -0
- package/bin/cli.js +114 -0
- package/bin/compose.mjs +287 -0
- package/bin/graph.mjs +247 -0
- package/bin/migrate.mjs +423 -0
- package/bin/update.mjs +413 -0
- package/lib/compose-solver.d.ts +179 -0
- package/lib/compose-solver.mjs +523 -0
- package/lib/compose-solver.test.mjs +483 -0
- package/lib/contract.ts +240 -0
- package/lib/dna.d.ts +65 -0
- package/lib/dna.mjs +239 -0
- package/lib/manifest.json +883 -185
- package/lib/merge3.d.ts +67 -0
- package/lib/merge3.mjs +431 -0
- package/lib/merge3.test.mjs +199 -0
- package/lib/migration-plan.d.ts +86 -0
- package/lib/migration-plan.mjs +414 -0
- package/lib/migration-plan.test.mjs +243 -0
- package/lib/slice-schema.json +13 -9
- package/lib/snapshot.d.ts +6 -0
- package/lib/snapshot.mjs +126 -0
- package/package.json +5 -2
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));
|
|
@@ -58,6 +62,9 @@ async function main() {
|
|
|
58
62
|
return runInit(rest);
|
|
59
63
|
case "add":
|
|
60
64
|
return runAdd(rest);
|
|
65
|
+
case "update":
|
|
66
|
+
case "update-slice":
|
|
67
|
+
return runUpdate(rest);
|
|
61
68
|
case "add-skill":
|
|
62
69
|
return runAddSkill(rest);
|
|
63
70
|
case "scaffold-slice":
|
|
@@ -73,6 +80,12 @@ async function main() {
|
|
|
73
80
|
return runInfo(rest);
|
|
74
81
|
case "doctor":
|
|
75
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);
|
|
76
89
|
case "mcp":
|
|
77
90
|
return runMcpHint();
|
|
78
91
|
case undefined:
|
|
@@ -111,6 +124,10 @@ ${kleur.bold("Usage:")}
|
|
|
111
124
|
npx rahman-resources list [layouts|recipes|features|skills|slices]
|
|
112
125
|
npx rahman-resources info <slug>
|
|
113
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]
|
|
114
131
|
npx rahman-resources mcp
|
|
115
132
|
|
|
116
133
|
${kleur.bold("Init flags:")}
|
|
@@ -124,6 +141,8 @@ ${kleur.bold("Add flags:")}
|
|
|
124
141
|
/preview/<slug> path constants in nav-config/robots/sitemap)
|
|
125
142
|
--at preview install template AT app/preview/<slug>/ (default — sandbox style)
|
|
126
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
|
|
127
146
|
|
|
128
147
|
${kleur.bold("Examples:")}
|
|
129
148
|
npx rahman-resources init my-app
|
|
@@ -160,6 +179,25 @@ function csv(s) {
|
|
|
160
179
|
return String(s).split(",").map((x) => x.trim()).filter(Boolean);
|
|
161
180
|
}
|
|
162
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
|
+
|
|
163
201
|
// ─── catalog lookups ──────────────────────────────────────────────────────
|
|
164
202
|
|
|
165
203
|
function findEntry(slug) {
|
|
@@ -429,12 +467,88 @@ async function runAdd(rest) {
|
|
|
429
467
|
const { kind, entry } = found;
|
|
430
468
|
const target = path.resolve(process.cwd(), targetArg);
|
|
431
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
|
+
|
|
432
511
|
if (kind === "slice") return runLift([`rahman:${entry.slug}`, ...(targetArg !== "." ? ["--target", targetArg] : [])]);
|
|
433
512
|
if (kind === "layout") return addLayout(entry, target, targetArg, flags);
|
|
434
513
|
if (kind === "feature") return addFeature(entry, target, targetArg);
|
|
435
514
|
if (kind === "recipe") return addRecipe(entry);
|
|
436
515
|
}
|
|
437
516
|
|
|
517
|
+
/**
|
|
518
|
+
* `update <slug> [target] [--dry-run]` — re-pull a slice from the kitab into
|
|
519
|
+
* an existing consumer directory. Use after `@rahman/shared` bumps or when
|
|
520
|
+
* the source slice has shipped patches. `--dry-run` previews changes via
|
|
521
|
+
* `runLift`'s built-in diff.
|
|
522
|
+
*
|
|
523
|
+
* For slices only (not layouts/features/recipes — those use `add`).
|
|
524
|
+
*/
|
|
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`.
|
|
529
|
+
const { positional, flags } = parseFlags(rest);
|
|
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);
|
|
548
|
+
}
|
|
549
|
+
return runUpdate3Way(rest);
|
|
550
|
+
}
|
|
551
|
+
|
|
438
552
|
async function addLayout(t, target, targetArg, flags = {}) {
|
|
439
553
|
console.log(kleur.bold(`\n→ Installing ${kleur.cyan(t.title)} into ${kleur.dim(target)}\n`));
|
|
440
554
|
if (!t.pullPaths || t.pullPaths.length === 0) {
|
package/bin/compose.mjs
ADDED
|
@@ -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
|
+
}
|