rahman-resources 0.12.0 → 0.13.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 CHANGED
@@ -33,6 +33,7 @@ import { runGraph } from "./graph.mjs";
33
33
  import { runCompose, preflight as composePreflight } from "./compose.mjs";
34
34
  import { runUpdate as runUpdate3Way } from "./update.mjs";
35
35
  import { runMigrate } from "./migrate.mjs";
36
+ import { runScan } from "./scan-consumers.mjs";
36
37
 
37
38
  const require = createRequire(import.meta.url);
38
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -86,6 +87,9 @@ async function main() {
86
87
  return runCompose(rest);
87
88
  case "migrate":
88
89
  return runMigrate(rest);
90
+ case "scan-consumers":
91
+ case "scan":
92
+ return runScan(rest);
89
93
  case "mcp":
90
94
  return runMcpHint();
91
95
  case undefined:
@@ -0,0 +1,301 @@
1
+ // Wave N+3 — `rr scan-consumers` CLI.
2
+ //
3
+ // Walks one or more consumer repos, reads each slice's `.kitab.json`, and
4
+ // diffs against the kitab's `frontend/slices/<slug>/slice.contract.ts`
5
+ // version. Prints a human ASCII table or `--json` for CI / MCP wiring.
6
+ //
7
+ // Usage:
8
+ // npx rahman-resources scan-consumers --path /home/rahman/projects/CareerPack
9
+ // npx rahman-resources scan-consumers --all
10
+ // npx rahman-resources scan-consumers --consumer careerpack --json
11
+
12
+ import { readFile, readdir } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { join, resolve, dirname } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import {
17
+ walkConsumerSlices,
18
+ diffSlice,
19
+ } from "../lib/consumer-manifest.mjs";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+
24
+ // Kitab repo root resolved relative to this file's location: bin/ → cli → packages → repo
25
+ const KITAB_ROOT = resolve(__dirname, "..", "..", "..");
26
+
27
+ /**
28
+ * Default consumer registry. Edit when adding new consumers. Paths are
29
+ * absolute on the operator workstation; CI invocation overrides via --path.
30
+ */
31
+ export const DEFAULT_CONSUMERS = [
32
+ { name: "careerpack", path: "/home/rahman/projects/CareerPack" },
33
+ { name: "notion", path: "/home/rahman/projects/notion-page-clone" },
34
+ { name: "rahmanef", path: "/home/rahman/projects/rahmanef.com" },
35
+ { name: "content", path: "/home/rahman/projects/content-rahmanef-com" },
36
+ { name: "superspace", path: "/home/rahman/projects/superspace" },
37
+ { name: "cescadesigns", path: "/home/rahman/projects/cescadesigns" },
38
+ ];
39
+
40
+ const COLOR = {
41
+ reset: "\x1b[0m",
42
+ red: "\x1b[31m",
43
+ green: "\x1b[32m",
44
+ yellow: "\x1b[33m",
45
+ cyan: "\x1b[36m",
46
+ dim: "\x1b[2m",
47
+ blue: "\x1b[34m",
48
+ };
49
+ function color(c, str, jsonMode) {
50
+ return process.stdout.isTTY && !jsonMode ? `${COLOR[c]}${str}${COLOR.reset}` : str;
51
+ }
52
+
53
+ const ID_RE = /\bid\s*:\s*["']([a-z][a-z0-9-]*)["']/;
54
+ const VERSION_RE = /\bversion\s*:\s*["'](\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)["']/;
55
+
56
+ /**
57
+ * Read all kitab contracts as {slug → version} via regex extraction.
58
+ * Skips folders without a contract file (drift scanner separately reports
59
+ * those — we only need version data here).
60
+ */
61
+ async function readKitabContractVersions() {
62
+ const slicesDir = join(KITAB_ROOT, "frontend", "slices");
63
+ const entries = await readdir(slicesDir, { withFileTypes: true });
64
+ const out = new Map();
65
+ for (const e of entries) {
66
+ if (!e.isDirectory() || e.name.startsWith("_")) continue;
67
+ const contractPath = join(slicesDir, e.name, "slice.contract.ts");
68
+ if (!existsSync(contractPath)) continue;
69
+ try {
70
+ const src = await readFile(contractPath, "utf8");
71
+ const idMatch = src.match(ID_RE);
72
+ const verMatch = src.match(VERSION_RE);
73
+ if (!idMatch || !verMatch) continue;
74
+ out.set(idMatch[1], verMatch[1]);
75
+ } catch {
76
+ // skip
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+
82
+ function parseArgs(argv) {
83
+ const args = { paths: [], consumers: [], all: false, json: false, help: false };
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i];
86
+ if (a === "--path") {
87
+ args.paths.push(argv[++i]);
88
+ } else if (a === "--consumer") {
89
+ args.consumers.push(argv[++i]);
90
+ } else if (a === "--all") {
91
+ args.all = true;
92
+ } else if (a === "--json") {
93
+ args.json = true;
94
+ } else if (a === "-h" || a === "--help") {
95
+ args.help = true;
96
+ } else if (a.startsWith("--")) {
97
+ throw new Error(`unknown flag: ${a}`);
98
+ }
99
+ }
100
+ return args;
101
+ }
102
+
103
+ function printHelp() {
104
+ console.log(`
105
+ Usage: rahman-resources scan-consumers [options]
106
+
107
+ Detect bidirectional sync state between the kitab and consumer repos by
108
+ reading each consumer slice's .kitab.json and diffing vs the kitab contract
109
+ version.
110
+
111
+ Options:
112
+ --path <dir> Scan one consumer repo at this path. Repeatable.
113
+ --consumer <name> Scan a registered consumer by name (see DEFAULT_CONSUMERS).
114
+ Repeatable.
115
+ --all Scan every registered consumer.
116
+ --json Machine-readable JSON output.
117
+ -h, --help Print this help.
118
+
119
+ Default if no flag given: --all.
120
+
121
+ Exit code:
122
+ 0 no UP-sync needed (DOWN-sync surfaced as info, not error)
123
+ 1 at least one consumer slice is up-needed/diverged AND policy != frozen
124
+ 2 malformed manifest in some consumer (also exits 1)
125
+ `);
126
+ }
127
+
128
+ function resolveTargets(args) {
129
+ const targets = [];
130
+ for (const p of args.paths) {
131
+ targets.push({ name: p, path: resolve(p) });
132
+ }
133
+ for (const cname of args.consumers) {
134
+ const found = DEFAULT_CONSUMERS.find((c) => c.name === cname);
135
+ if (!found) {
136
+ throw new Error(`unknown consumer "${cname}" — registered: ${DEFAULT_CONSUMERS.map((c) => c.name).join(", ")}`);
137
+ }
138
+ targets.push({ name: found.name, path: found.path });
139
+ }
140
+ if (args.all || (targets.length === 0 && !args.help)) {
141
+ for (const c of DEFAULT_CONSUMERS) {
142
+ if (!targets.find((t) => t.path === c.path)) targets.push(c);
143
+ }
144
+ }
145
+ return targets;
146
+ }
147
+
148
+ function verdictTone(v) {
149
+ switch (v) {
150
+ case "in-sync":
151
+ return "green";
152
+ case "up-needed":
153
+ return "blue";
154
+ case "down-needed":
155
+ return "yellow";
156
+ case "diverged":
157
+ return "red";
158
+ case "consumer-only":
159
+ return "cyan";
160
+ case "kitab-only":
161
+ return "dim";
162
+ default:
163
+ return "reset";
164
+ }
165
+ }
166
+
167
+ function pad(s, w) {
168
+ const str = String(s);
169
+ if (str.length >= w) return str;
170
+ return str + " ".repeat(w - str.length);
171
+ }
172
+
173
+ async function scanOne(target, kitabVersions) {
174
+ if (!existsSync(target.path)) {
175
+ return { name: target.name, path: target.path, error: "path does not exist" };
176
+ }
177
+ const walked = await walkConsumerSlices(target.path);
178
+ const diffs = [];
179
+ const errors = [];
180
+ const seen = new Set();
181
+ for (const w of walked) {
182
+ if (w.error) {
183
+ errors.push({ dir: w.dir, message: w.error });
184
+ continue;
185
+ }
186
+ const slug = w.manifest.kitabSlug;
187
+ seen.add(slug);
188
+ const kv = kitabVersions.get(slug) ?? null;
189
+ diffs.push(diffSlice({ slug, manifest: w.manifest, kitabVersion: kv }));
190
+ }
191
+ // Surface kitab-only slices the consumer hasn't adopted at all.
192
+ for (const [slug, version] of kitabVersions) {
193
+ if (!seen.has(slug)) {
194
+ diffs.push(diffSlice({ slug, manifest: null, kitabVersion: version }));
195
+ }
196
+ }
197
+ diffs.sort((a, b) => a.slug.localeCompare(b.slug));
198
+ return { name: target.name, path: target.path, diffs, errors };
199
+ }
200
+
201
+ export async function runScan(argv = []) {
202
+ let args;
203
+ try {
204
+ args = parseArgs(argv);
205
+ } catch (err) {
206
+ console.error(color("red", err.message, false));
207
+ process.exit(2);
208
+ }
209
+ if (args.help) {
210
+ printHelp();
211
+ process.exit(0);
212
+ }
213
+ const targets = resolveTargets(args);
214
+ const kitabVersions = await readKitabContractVersions();
215
+ const reports = await Promise.all(targets.map((t) => scanOne(t, kitabVersions)));
216
+
217
+ let upNeeded = 0;
218
+ let parseErrors = 0;
219
+ for (const r of reports) {
220
+ if (r.error) continue;
221
+ parseErrors += r.errors?.length ?? 0;
222
+ for (const d of r.diffs ?? []) {
223
+ if (d.direction === "up-needed" || d.direction === "diverged") upNeeded++;
224
+ }
225
+ }
226
+
227
+ if (args.json) {
228
+ process.stdout.write(
229
+ JSON.stringify(
230
+ {
231
+ kitabRoot: KITAB_ROOT,
232
+ kitabContracts: kitabVersions.size,
233
+ targets: reports,
234
+ upNeeded,
235
+ parseErrors,
236
+ },
237
+ null,
238
+ 2,
239
+ ),
240
+ );
241
+ process.exit(upNeeded > 0 || parseErrors > 0 ? 1 : 0);
242
+ }
243
+
244
+ console.log(color("cyan", "\n== consumer sync scan ==", false));
245
+ console.log(color("dim", `kitab root: ${KITAB_ROOT} (${kitabVersions.size} contracts)`, false));
246
+ for (const r of reports) {
247
+ console.log("");
248
+ console.log(color("cyan", `▸ ${r.name}`, false), color("dim", r.path, false));
249
+ if (r.error) {
250
+ console.log(" " + color("red", `error: ${r.error}`, false));
251
+ continue;
252
+ }
253
+ if (r.errors?.length) {
254
+ for (const e of r.errors) {
255
+ console.log(" " + color("red", `parse error @ ${e.dir}: ${e.message}`, false));
256
+ }
257
+ }
258
+ if (r.diffs.length === 0) {
259
+ console.log(" " + color("dim", "(no slices found)", false));
260
+ continue;
261
+ }
262
+ const headers = ["slug", "kitab", "consumer", "verdict", "actions"];
263
+ const rows = r.diffs.map((d) => [
264
+ d.slug,
265
+ d.kitabVersion ?? "—",
266
+ d.consumerVersion ?? "—",
267
+ d.direction,
268
+ d.allowedActions.length ? d.allowedActions.join(",") : "—",
269
+ ]);
270
+ const widths = headers.map((h, i) =>
271
+ Math.max(h.length, ...rows.map((r) => String(r[i]).length)),
272
+ );
273
+ console.log(" " + headers.map((h, i) => pad(h, widths[i])).join(" "));
274
+ console.log(" " + widths.map((w) => "-".repeat(w)).join(" "));
275
+ for (let i = 0; i < rows.length; i++) {
276
+ const d = r.diffs[i];
277
+ const tone = verdictTone(d.direction);
278
+ const cells = rows[i].map((c, ci) => pad(c, widths[ci]));
279
+ cells[3] = color(tone, cells[3], false);
280
+ console.log(" " + cells.join(" "));
281
+ }
282
+ }
283
+ console.log("");
284
+ console.log(
285
+ color("cyan", "Summary:", false),
286
+ `${reports.length} consumer(s) · ` +
287
+ color(upNeeded > 0 ? "blue" : "green", `${upNeeded} up-needed/diverged`, false) +
288
+ ` · ` +
289
+ color(parseErrors > 0 ? "red" : "green", `${parseErrors} parse error(s)`, false),
290
+ );
291
+ console.log("");
292
+ process.exit(upNeeded > 0 || parseErrors > 0 ? 1 : 0);
293
+ }
294
+
295
+ const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(__filename);
296
+ if (isMain) {
297
+ runScan(process.argv.slice(2)).catch((err) => {
298
+ console.error(`scan-consumers crashed: ${err.stack || err}`);
299
+ process.exit(2);
300
+ });
301
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Wave N+3 — Bidirectional Sync Detection Layer (BSDL).
3
+ *
4
+ * Consumer-side manifest contract: every slice copy in a consumer repo
5
+ * (CareerPack / notion / rahmanef / content / superspace / cescadesigns)
6
+ * declares which kitab slug + version it adopted, what the consumer-local
7
+ * version is, what sync direction is allowed, and how generalised the
8
+ * consumer-local copy is.
9
+ *
10
+ * The kitab `rr scan-consumers` command reads these and surfaces what
11
+ * needs UP-sync (`rr-send`) vs DOWN-sync (`rr update`).
12
+ *
13
+ * @module packages/cli/lib/consumer-manifest
14
+ */
15
+
16
+ export type SyncDirection =
17
+ | "bidirectional"
18
+ | "down-only"
19
+ | "up-only"
20
+ | "frozen";
21
+
22
+ export type GeneralizationStatus =
23
+ | "portable"
24
+ | "needs-adapter"
25
+ | "consumer-locked";
26
+
27
+ export type SyncDirectionVerdict =
28
+ | "in-sync"
29
+ | "up-needed"
30
+ | "down-needed"
31
+ | "diverged"
32
+ | "consumer-only"
33
+ | "kitab-only";
34
+
35
+ export interface ConsumerGeneralization {
36
+ /**
37
+ * Portable: the slice is fully generic and `rr-send` will accept it.
38
+ * needs-adapter: requires a thin adapter to plug consumer-specific
39
+ * props/labels/routes — kitab can ingest if blockers are addressed.
40
+ * consumer-locked: contains business-specific logic that cannot be
41
+ * generalised — UP-sync rejected; only DOWN-sync allowed.
42
+ */
43
+ status: GeneralizationStatus;
44
+ /** ISO date — when the audit ran. */
45
+ auditedAt: string;
46
+ /** Human-readable reasons preventing portability. Empty when portable. */
47
+ blockers: string[];
48
+ }
49
+
50
+ export interface ConsumerManifest {
51
+ $schema?: string;
52
+ /** Kebab-case slug — must match a kitab `slice.contract.ts` `id`. */
53
+ kitabSlug: string;
54
+ /** Semver of the kitab version this copy was last pulled from. */
55
+ kitabVersion: string;
56
+ /** Semver of the consumer-local divergence. Bump after each local edit. */
57
+ consumerVersion: string;
58
+ syncDirection: SyncDirection;
59
+ generalization: ConsumerGeneralization;
60
+ /** ISO timestamp of last successful `rr update --apply`. */
61
+ lastPullAt: string | null;
62
+ /** ISO timestamp of last successful `/rr-send`. */
63
+ lastPushAt: string | null;
64
+ }
65
+
66
+ export interface SyncDiff {
67
+ slug: string;
68
+ /** Kitab contract version, or null if slice not in kitab. */
69
+ kitabVersion: string | null;
70
+ /** Consumer manifest version, or null if no manifest in consumer. */
71
+ consumerVersion: string | null;
72
+ direction: SyncDirectionVerdict;
73
+ blockers: string[];
74
+ generalization: GeneralizationStatus | null;
75
+ /** Allowed sync directions per the manifest, narrowed by verdict. */
76
+ allowedActions: ("rr-send" | "rr-update")[];
77
+ }
78
+
79
+ export interface WalkedSlice {
80
+ /** Absolute path to the slice dir inside the consumer repo. */
81
+ dir: string;
82
+ /** Parsed manifest, or undefined if read failed. */
83
+ manifest?: ConsumerManifest;
84
+ /** Error message if manifest existed but failed validation. */
85
+ error?: string;
86
+ }
87
+
88
+ export function validateConsumerManifest(m: unknown): string[];
89
+ export function readConsumerManifest(filepath: string): Promise<ConsumerManifest>;
90
+ export function writeConsumerManifest(
91
+ filepath: string,
92
+ m: ConsumerManifest,
93
+ ): Promise<void>;
94
+ export function diffSlice(input: {
95
+ slug: string;
96
+ manifest: ConsumerManifest | null;
97
+ kitabVersion: string | null;
98
+ }): SyncDiff;
99
+ export function walkConsumerSlices(consumerRoot: string): Promise<WalkedSlice[]>;
100
+ export function compareSemver(a: string, b: string): number;
@@ -0,0 +1,216 @@
1
+ // Wave N+3 — Bidirectional Sync Detection Layer (BSDL).
2
+ //
3
+ // Consumer-side `.kitab.json` schema, reader/writer, semver compare, and the
4
+ // per-slice sync diff used by `rr scan-consumers`. See d.ts for the full type
5
+ // vocabulary and docs/consumer-manifest.md for the design rationale.
6
+
7
+ import { readFile, writeFile, readdir } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ const KEBAB_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
12
+ const SEMVER_RE =
13
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
14
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))?$/;
15
+
16
+ const SYNC_DIRECTIONS = new Set([
17
+ "bidirectional",
18
+ "down-only",
19
+ "up-only",
20
+ "frozen",
21
+ ]);
22
+ const GENERALIZATION_STATUS = new Set([
23
+ "portable",
24
+ "needs-adapter",
25
+ "consumer-locked",
26
+ ]);
27
+
28
+ export function validateConsumerManifest(m) {
29
+ const errors = [];
30
+ if (!m || typeof m !== "object") {
31
+ errors.push("manifest must be a non-null object");
32
+ return errors;
33
+ }
34
+ if (typeof m.kitabSlug !== "string" || !KEBAB_RE.test(m.kitabSlug)) {
35
+ errors.push(`kitabSlug "${String(m.kitabSlug)}" must be kebab-case`);
36
+ }
37
+ if (typeof m.kitabVersion !== "string" || !SEMVER_RE.test(m.kitabVersion)) {
38
+ errors.push(`kitabVersion "${String(m.kitabVersion)}" must be semver`);
39
+ }
40
+ if (
41
+ typeof m.consumerVersion !== "string" ||
42
+ !SEMVER_RE.test(m.consumerVersion)
43
+ ) {
44
+ errors.push(
45
+ `consumerVersion "${String(m.consumerVersion)}" must be semver`,
46
+ );
47
+ }
48
+ if (!SYNC_DIRECTIONS.has(m.syncDirection)) {
49
+ errors.push(
50
+ `syncDirection "${String(m.syncDirection)}" must be one of bidirectional|down-only|up-only|frozen`,
51
+ );
52
+ }
53
+ if (!m.generalization || typeof m.generalization !== "object") {
54
+ errors.push("generalization must be an object");
55
+ } else {
56
+ const g = m.generalization;
57
+ if (!GENERALIZATION_STATUS.has(g.status)) {
58
+ errors.push(
59
+ `generalization.status "${String(g.status)}" must be portable|needs-adapter|consumer-locked`,
60
+ );
61
+ }
62
+ if (typeof g.auditedAt !== "string" || !ISO_RE.test(g.auditedAt)) {
63
+ errors.push(
64
+ `generalization.auditedAt "${String(g.auditedAt)}" must be ISO date`,
65
+ );
66
+ }
67
+ if (!Array.isArray(g.blockers)) {
68
+ errors.push("generalization.blockers must be an array of strings");
69
+ } else {
70
+ for (const b of g.blockers) {
71
+ if (typeof b !== "string") {
72
+ errors.push(`generalization.blockers entry must be string, got ${typeof b}`);
73
+ }
74
+ }
75
+ }
76
+ if (g.status !== "portable" && (!Array.isArray(g.blockers) || g.blockers.length === 0)) {
77
+ errors.push(
78
+ `generalization.status "${g.status}" requires at least one entry in blockers[]`,
79
+ );
80
+ }
81
+ }
82
+ if (m.lastPullAt !== null && (typeof m.lastPullAt !== "string" || !ISO_RE.test(m.lastPullAt))) {
83
+ errors.push(`lastPullAt must be null or ISO timestamp`);
84
+ }
85
+ if (m.lastPushAt !== null && (typeof m.lastPushAt !== "string" || !ISO_RE.test(m.lastPushAt))) {
86
+ errors.push(`lastPushAt must be null or ISO timestamp`);
87
+ }
88
+ return errors;
89
+ }
90
+
91
+ export async function readConsumerManifest(filepath) {
92
+ const raw = await readFile(filepath, "utf8");
93
+ let m;
94
+ try {
95
+ m = JSON.parse(raw);
96
+ } catch (err) {
97
+ throw new Error(`${filepath}: invalid JSON — ${err.message}`);
98
+ }
99
+ const errors = validateConsumerManifest(m);
100
+ if (errors.length > 0) {
101
+ throw new Error(`${filepath}: ${errors.join("; ")}`);
102
+ }
103
+ return m;
104
+ }
105
+
106
+ export async function writeConsumerManifest(filepath, m) {
107
+ const errors = validateConsumerManifest(m);
108
+ if (errors.length > 0) {
109
+ throw new Error(`refusing to write invalid manifest: ${errors.join("; ")}`);
110
+ }
111
+ const ordered = {
112
+ $schema: m.$schema ?? "https://resource.rahmanef.com/schemas/kitab-consumer.json",
113
+ kitabSlug: m.kitabSlug,
114
+ kitabVersion: m.kitabVersion,
115
+ consumerVersion: m.consumerVersion,
116
+ syncDirection: m.syncDirection,
117
+ generalization: m.generalization,
118
+ lastPullAt: m.lastPullAt,
119
+ lastPushAt: m.lastPushAt,
120
+ };
121
+ await writeFile(filepath, JSON.stringify(ordered, null, 2) + "\n", "utf8");
122
+ }
123
+
124
+ /**
125
+ * Compare two semvers. Returns -1, 0, or 1. Pre-release tags ignored
126
+ * (compared on MAJOR.MINOR.PATCH only — sufficient for sync direction
127
+ * decisions; consumers shouldn't be depending on pre-release ordering for
128
+ * cross-repo sync gates).
129
+ */
130
+ export function compareSemver(a, b) {
131
+ const pa = String(a).split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10));
132
+ const pb = String(b).split(/[.+-]/).slice(0, 3).map((n) => parseInt(n, 10));
133
+ for (let i = 0; i < 3; i++) {
134
+ const av = Number.isFinite(pa[i]) ? pa[i] : 0;
135
+ const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
136
+ if (av !== bv) return av < bv ? -1 : 1;
137
+ }
138
+ return 0;
139
+ }
140
+
141
+ function allowedActionsFor(verdict, manifest) {
142
+ if (!manifest) return [];
143
+ const dir = manifest.syncDirection;
144
+ if (dir === "frozen") return [];
145
+ const actions = [];
146
+ if ((verdict === "up-needed" || verdict === "diverged") && (dir === "bidirectional" || dir === "up-only")) {
147
+ if (manifest.generalization.status === "portable") actions.push("rr-send");
148
+ }
149
+ if ((verdict === "down-needed" || verdict === "diverged") && (dir === "bidirectional" || dir === "down-only")) {
150
+ actions.push("rr-update");
151
+ }
152
+ return actions;
153
+ }
154
+
155
+ export function diffSlice({ slug, manifest, kitabVersion }) {
156
+ if (manifest && !kitabVersion) {
157
+ return {
158
+ slug,
159
+ kitabVersion: null,
160
+ consumerVersion: manifest.consumerVersion,
161
+ direction: "consumer-only",
162
+ blockers: manifest.generalization.blockers,
163
+ generalization: manifest.generalization.status,
164
+ allowedActions: manifest.generalization.status === "portable" ? ["rr-send"] : [],
165
+ };
166
+ }
167
+ if (!manifest && kitabVersion) {
168
+ return {
169
+ slug,
170
+ kitabVersion,
171
+ consumerVersion: null,
172
+ direction: "kitab-only",
173
+ blockers: [],
174
+ generalization: null,
175
+ allowedActions: ["rr-update"],
176
+ };
177
+ }
178
+ if (!manifest) {
179
+ return null;
180
+ }
181
+ const consumerCmpKitab = compareSemver(manifest.consumerVersion, manifest.kitabVersion);
182
+ const kitabCmpAdopted = compareSemver(kitabVersion, manifest.kitabVersion);
183
+ let direction;
184
+ if (consumerCmpKitab > 0 && kitabCmpAdopted > 0) direction = "diverged";
185
+ else if (consumerCmpKitab > 0) direction = "up-needed";
186
+ else if (kitabCmpAdopted > 0) direction = "down-needed";
187
+ else direction = "in-sync";
188
+ return {
189
+ slug,
190
+ kitabVersion,
191
+ consumerVersion: manifest.consumerVersion,
192
+ direction,
193
+ blockers: manifest.generalization.blockers,
194
+ generalization: manifest.generalization.status,
195
+ allowedActions: allowedActionsFor(direction, manifest),
196
+ };
197
+ }
198
+
199
+ export async function walkConsumerSlices(consumerRoot) {
200
+ const slicesDir = join(consumerRoot, "frontend", "slices");
201
+ if (!existsSync(slicesDir)) return [];
202
+ const out = [];
203
+ const entries = await readdir(slicesDir, { withFileTypes: true });
204
+ for (const e of entries) {
205
+ if (!e.isDirectory() || e.name.startsWith("_")) continue;
206
+ const manifestPath = join(slicesDir, e.name, ".kitab.json");
207
+ if (!existsSync(manifestPath)) continue;
208
+ try {
209
+ const m = await readConsumerManifest(manifestPath);
210
+ out.push({ dir: join(slicesDir, e.name), manifest: m });
211
+ } catch (err) {
212
+ out.push({ dir: join(slicesDir, e.name), error: err.message });
213
+ }
214
+ }
215
+ return out;
216
+ }
@@ -0,0 +1,253 @@
1
+ // Wave N+3 — consumer-manifest test suite.
2
+ //
3
+ // Covers: schema validation, semver compare, diffSlice verdict matrix,
4
+ // allowedActions gating by syncDirection + generalization, walkConsumerSlices
5
+ // against a fixture tree.
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ validateConsumerManifest,
13
+ compareSemver,
14
+ diffSlice,
15
+ walkConsumerSlices,
16
+ readConsumerManifest,
17
+ writeConsumerManifest,
18
+ } from "./consumer-manifest.mjs";
19
+
20
+ function baseManifest(over = {}) {
21
+ return {
22
+ kitabSlug: "comments",
23
+ kitabVersion: "0.1.0",
24
+ consumerVersion: "0.1.0",
25
+ syncDirection: "bidirectional",
26
+ generalization: {
27
+ status: "portable",
28
+ auditedAt: "2026-05-15",
29
+ blockers: [],
30
+ },
31
+ lastPullAt: null,
32
+ lastPushAt: null,
33
+ ...over,
34
+ };
35
+ }
36
+
37
+ describe("validateConsumerManifest", () => {
38
+ it("accepts a minimal valid manifest", () => {
39
+ expect(validateConsumerManifest(baseManifest())).toEqual([]);
40
+ });
41
+
42
+ it("rejects non-kebab slug", () => {
43
+ const errs = validateConsumerManifest(baseManifest({ kitabSlug: "Bad_Slug" }));
44
+ expect(errs.some((e) => e.includes("kebab-case"))).toBe(true);
45
+ });
46
+
47
+ it("rejects non-semver versions", () => {
48
+ const errs = validateConsumerManifest(baseManifest({ kitabVersion: "v0.1" }));
49
+ expect(errs.some((e) => e.includes("semver"))).toBe(true);
50
+ });
51
+
52
+ it("rejects unknown syncDirection", () => {
53
+ const errs = validateConsumerManifest(baseManifest({ syncDirection: "loose" }));
54
+ expect(errs.some((e) => e.includes("syncDirection"))).toBe(true);
55
+ });
56
+
57
+ it("requires blockers when status != portable", () => {
58
+ const errs = validateConsumerManifest(
59
+ baseManifest({
60
+ generalization: {
61
+ status: "needs-adapter",
62
+ auditedAt: "2026-05-15",
63
+ blockers: [],
64
+ },
65
+ }),
66
+ );
67
+ expect(errs.some((e) => e.includes("blockers"))).toBe(true);
68
+ });
69
+
70
+ it("accepts non-portable status with blockers populated", () => {
71
+ expect(
72
+ validateConsumerManifest(
73
+ baseManifest({
74
+ generalization: {
75
+ status: "consumer-locked",
76
+ auditedAt: "2026-05-15",
77
+ blockers: ["hardcoded business term"],
78
+ },
79
+ }),
80
+ ),
81
+ ).toEqual([]);
82
+ });
83
+ });
84
+
85
+ describe("compareSemver", () => {
86
+ it("orders MAJOR.MINOR.PATCH correctly", () => {
87
+ expect(compareSemver("0.1.0", "0.1.0")).toBe(0);
88
+ expect(compareSemver("0.1.1", "0.1.0")).toBe(1);
89
+ expect(compareSemver("0.1.0", "0.1.1")).toBe(-1);
90
+ expect(compareSemver("1.0.0", "0.99.99")).toBe(1);
91
+ });
92
+
93
+ it("strips pre-release/build tags", () => {
94
+ expect(compareSemver("0.1.0-beta", "0.1.0+build")).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("diffSlice verdicts", () => {
99
+ it("in-sync when all versions match", () => {
100
+ const r = diffSlice({
101
+ slug: "comments",
102
+ manifest: baseManifest(),
103
+ kitabVersion: "0.1.0",
104
+ });
105
+ expect(r.direction).toBe("in-sync");
106
+ expect(r.allowedActions).toEqual([]);
107
+ });
108
+
109
+ it("up-needed when consumer ahead", () => {
110
+ const r = diffSlice({
111
+ slug: "comments",
112
+ manifest: baseManifest({ consumerVersion: "0.1.3" }),
113
+ kitabVersion: "0.1.0",
114
+ });
115
+ expect(r.direction).toBe("up-needed");
116
+ expect(r.allowedActions).toContain("rr-send");
117
+ });
118
+
119
+ it("down-needed when kitab ahead", () => {
120
+ const r = diffSlice({
121
+ slug: "comments",
122
+ manifest: baseManifest({ consumerVersion: "0.1.0", kitabVersion: "0.1.0" }),
123
+ kitabVersion: "0.2.0",
124
+ });
125
+ expect(r.direction).toBe("down-needed");
126
+ expect(r.allowedActions).toContain("rr-update");
127
+ });
128
+
129
+ it("diverged when both ahead", () => {
130
+ const r = diffSlice({
131
+ slug: "comments",
132
+ manifest: baseManifest({ consumerVersion: "0.1.5", kitabVersion: "0.1.0" }),
133
+ kitabVersion: "0.2.0",
134
+ });
135
+ expect(r.direction).toBe("diverged");
136
+ expect(r.allowedActions).toContain("rr-send");
137
+ expect(r.allowedActions).toContain("rr-update");
138
+ });
139
+
140
+ it("kitab-only when no consumer manifest", () => {
141
+ const r = diffSlice({ slug: "comments", manifest: null, kitabVersion: "0.1.0" });
142
+ expect(r.direction).toBe("kitab-only");
143
+ expect(r.allowedActions).toEqual(["rr-update"]);
144
+ });
145
+
146
+ it("consumer-only when not in kitab", () => {
147
+ const r = diffSlice({
148
+ slug: "ghost",
149
+ manifest: baseManifest({ kitabSlug: "ghost" }),
150
+ kitabVersion: null,
151
+ });
152
+ expect(r.direction).toBe("consumer-only");
153
+ expect(r.allowedActions).toEqual(["rr-send"]);
154
+ });
155
+
156
+ it("frozen syncDirection blocks all actions", () => {
157
+ const r = diffSlice({
158
+ slug: "comments",
159
+ manifest: baseManifest({
160
+ consumerVersion: "0.1.5",
161
+ syncDirection: "frozen",
162
+ }),
163
+ kitabVersion: "0.2.0",
164
+ });
165
+ expect(r.direction).toBe("diverged");
166
+ expect(r.allowedActions).toEqual([]);
167
+ });
168
+
169
+ it("consumer-locked status blocks rr-send", () => {
170
+ const r = diffSlice({
171
+ slug: "comments",
172
+ manifest: baseManifest({
173
+ consumerVersion: "0.1.5",
174
+ generalization: {
175
+ status: "consumer-locked",
176
+ auditedAt: "2026-05-15",
177
+ blockers: ["business-specific table"],
178
+ },
179
+ }),
180
+ kitabVersion: "0.1.0",
181
+ });
182
+ expect(r.direction).toBe("up-needed");
183
+ expect(r.allowedActions).not.toContain("rr-send");
184
+ });
185
+
186
+ it("down-only direction blocks rr-send even when up-needed", () => {
187
+ const r = diffSlice({
188
+ slug: "comments",
189
+ manifest: baseManifest({
190
+ consumerVersion: "0.1.5",
191
+ syncDirection: "down-only",
192
+ }),
193
+ kitabVersion: "0.1.0",
194
+ });
195
+ expect(r.direction).toBe("up-needed");
196
+ expect(r.allowedActions).not.toContain("rr-send");
197
+ });
198
+ });
199
+
200
+ describe("walkConsumerSlices + read/write round-trip", () => {
201
+ it("reads valid manifests and surfaces parse errors per slice", async () => {
202
+ const root = await mkdtemp(join(tmpdir(), "rr-bsdl-"));
203
+ try {
204
+ const slicesDir = join(root, "frontend", "slices");
205
+ await mkdir(join(slicesDir, "comments"), { recursive: true });
206
+ await mkdir(join(slicesDir, "broken"), { recursive: true });
207
+ await mkdir(join(slicesDir, "no-manifest"), { recursive: true });
208
+ await mkdir(join(slicesDir, "_internal"), { recursive: true });
209
+
210
+ await writeConsumerManifest(
211
+ join(slicesDir, "comments", ".kitab.json"),
212
+ baseManifest({ kitabSlug: "comments" }),
213
+ );
214
+ // _internal also has a manifest but should be skipped (underscore prefix)
215
+ await writeFile(
216
+ join(slicesDir, "_internal", ".kitab.json"),
217
+ JSON.stringify(baseManifest({ kitabSlug: "internal" })),
218
+ );
219
+ await writeFile(
220
+ join(slicesDir, "broken", ".kitab.json"),
221
+ '{"not": "valid"}',
222
+ );
223
+
224
+ const walked = await walkConsumerSlices(root);
225
+ const slugs = walked.map((w) => w.dir.split("/").pop()).sort();
226
+ expect(slugs).toEqual(["broken", "comments"]);
227
+ const broken = walked.find((w) => w.dir.endsWith("broken"));
228
+ expect(broken.error).toBeDefined();
229
+ const ok = walked.find((w) => w.dir.endsWith("comments"));
230
+ expect(ok.manifest?.kitabSlug).toBe("comments");
231
+
232
+ const round = await readConsumerManifest(
233
+ join(slicesDir, "comments", ".kitab.json"),
234
+ );
235
+ expect(round.kitabSlug).toBe("comments");
236
+ expect(round.$schema).toBe(
237
+ "https://resource.rahmanef.com/schemas/kitab-consumer.json",
238
+ );
239
+ } finally {
240
+ await rm(root, { recursive: true, force: true });
241
+ }
242
+ });
243
+
244
+ it("returns empty when no slices dir", async () => {
245
+ const root = await mkdtemp(join(tmpdir(), "rr-bsdl-empty-"));
246
+ try {
247
+ const walked = await walkConsumerSlices(root);
248
+ expect(walked).toEqual([]);
249
+ } finally {
250
+ await rm(root, { recursive: true, force: true });
251
+ }
252
+ });
253
+ });
package/lib/contract.ts CHANGED
@@ -92,6 +92,66 @@ export interface SliceContractProvides {
92
92
  components?: string[];
93
93
  }
94
94
 
95
+ // ---------------------------------------------------------------------------
96
+ // `bidir` block — Wave N+3 (Bidirectional Sync Detection Layer)
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * How the kitab treats sync between this slice and consumer copies.
101
+ *
102
+ * - `auto-pr`: when `rr scan-consumers` sees an `up-needed` verdict on a
103
+ * consumer's `.kitab.json`, the operator workflow auto-opens a PR against
104
+ * the kitab. Reserved for slices with strict generalisation gates.
105
+ * - `notify`: surface in the scan report; no auto-action.
106
+ * - `manual`: default — operator picks up via `/rr-prep` + `/rr-send`.
107
+ * - `frozen`: kitab refuses both UP and DOWN sync. Lock for retired slices.
108
+ */
109
+ export type SliceSyncPolicy = "auto-pr" | "notify" | "manual" | "frozen";
110
+
111
+ /**
112
+ * Generalisation level a consumer-side `.kitab.json` MUST claim before
113
+ * `rr-send` accepts the push back into the kitab.
114
+ *
115
+ * - `portable`: no consumer-specific business terms baked in. UP-sync allowed.
116
+ * - `needs-adapter`: requires a thin adapter wired by the consumer; UP-sync
117
+ * blocked until blockers are addressed (or the contract drops the slice
118
+ * to `consumer-locked`).
119
+ * - `consumer-locked`: contains business-specific logic that cannot be
120
+ * generalised. Only DOWN-sync allowed.
121
+ */
122
+ export type GeneralizationLevel =
123
+ | "portable"
124
+ | "needs-adapter"
125
+ | "consumer-locked";
126
+
127
+ /**
128
+ * Generalisation contract — what the audit-bp `forbiddenTerms` rule scans
129
+ * for, and which props the consumer MUST inject.
130
+ */
131
+ export interface SliceGeneralization {
132
+ level: GeneralizationLevel;
133
+ /**
134
+ * Identifiers / business terms that MUST NOT appear in the slice source
135
+ * tree. Audit-bp scans .ts/.tsx files. Empty when the slice is generic.
136
+ */
137
+ forbiddenTerms?: string[];
138
+ /**
139
+ * Props the consumer must inject for the slice to remain portable —
140
+ * e.g. `["basePath", "labels", "permission"]`.
141
+ */
142
+ requiredProps?: string[];
143
+ }
144
+
145
+ /**
146
+ * Bidirectional sync block. Optional, additive — slices without it default to
147
+ * `{ syncPolicy: "manual", generalization: { level: "portable" } }` for
148
+ * legacy compatibility with Wave N+1 contracts.
149
+ */
150
+ export interface SliceBidirContract {
151
+ syncPolicy: SliceSyncPolicy;
152
+ generalization: SliceGeneralization;
153
+ }
154
+
95
155
  // ---------------------------------------------------------------------------
96
156
  // Top-level contract
97
157
  // ---------------------------------------------------------------------------
@@ -121,6 +181,8 @@ export interface SliceContract {
121
181
  conflicts?: string[];
122
182
  /** Map of previous-version → migration script id. */
123
183
  migrationFrom?: Record<string, string>;
184
+ /** Wave N+3 — bidirectional sync policy + generalisation gate. */
185
+ bidir?: SliceBidirContract;
124
186
  }
125
187
 
126
188
  // ---------------------------------------------------------------------------
@@ -222,6 +284,60 @@ export function defineSliceContract(c: SliceContract): SliceContract {
222
284
  }
223
285
  }
224
286
 
287
+ // bidir block — Wave N+3
288
+ if (c.bidir !== undefined) {
289
+ if (!c.bidir || typeof c.bidir !== "object") {
290
+ throw new Error(`defineSliceContract(${c.id}): bidir must be an object`);
291
+ }
292
+ const policies = ["auto-pr", "notify", "manual", "frozen"];
293
+ if (!policies.includes(c.bidir.syncPolicy)) {
294
+ throw new Error(
295
+ `defineSliceContract(${c.id}): bidir.syncPolicy "${String(c.bidir.syncPolicy)}" must be one of ${policies.join("|")}`,
296
+ );
297
+ }
298
+ if (!c.bidir.generalization || typeof c.bidir.generalization !== "object") {
299
+ throw new Error(
300
+ `defineSliceContract(${c.id}): bidir.generalization must be an object`,
301
+ );
302
+ }
303
+ const levels = ["portable", "needs-adapter", "consumer-locked"];
304
+ if (!levels.includes(c.bidir.generalization.level)) {
305
+ throw new Error(
306
+ `defineSliceContract(${c.id}): bidir.generalization.level "${String(c.bidir.generalization.level)}" must be one of ${levels.join("|")}`,
307
+ );
308
+ }
309
+ const ft = c.bidir.generalization.forbiddenTerms;
310
+ if (ft !== undefined) {
311
+ if (!Array.isArray(ft)) {
312
+ throw new Error(
313
+ `defineSliceContract(${c.id}): bidir.generalization.forbiddenTerms must be an array`,
314
+ );
315
+ }
316
+ for (const t of ft) {
317
+ if (typeof t !== "string" || t.length === 0) {
318
+ throw new Error(
319
+ `defineSliceContract(${c.id}): bidir.generalization.forbiddenTerms entries must be non-empty strings`,
320
+ );
321
+ }
322
+ }
323
+ }
324
+ const rp = c.bidir.generalization.requiredProps;
325
+ if (rp !== undefined) {
326
+ if (!Array.isArray(rp)) {
327
+ throw new Error(
328
+ `defineSliceContract(${c.id}): bidir.generalization.requiredProps must be an array`,
329
+ );
330
+ }
331
+ for (const p of rp) {
332
+ if (typeof p !== "string" || p.length === 0) {
333
+ throw new Error(
334
+ `defineSliceContract(${c.id}): bidir.generalization.requiredProps entries must be non-empty strings`,
335
+ );
336
+ }
337
+ }
338
+ }
339
+ }
340
+
225
341
  // Conflicts
226
342
  if (c.conflicts) {
227
343
  if (!Array.isArray(c.conflicts)) {
package/lib/manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 2,
3
- "generatedAt": "2026-05-15T03:23:22.231Z",
3
+ "generatedAt": "2026-05-15T05:11:02.213Z",
4
4
  "repo": "rahmanef63/resource-site",
5
5
  "branch": "main",
6
6
  "layouts": [
@@ -1273,6 +1273,24 @@
1273
1273
  "anthropic",
1274
1274
  "metadata-generator"
1275
1275
  ]
1276
+ },
1277
+ {
1278
+ "slug": "document-checklist",
1279
+ "title": "Document Checklist — Job-Search Doc Tracker",
1280
+ "category": "content",
1281
+ "description": "Track required job-search documents (CV, KTP, ijazah, etc.) with country-scoped seed templates and per-user completion state. Ships an Indonesian default checklist. Toggle completed + notes + expiry per item.",
1282
+ "source": "rahmanef63/CareerPack",
1283
+ "install": "npx rahman-resources add document-checklist",
1284
+ "npmPackages": [],
1285
+ "exampleCode": "",
1286
+ "agentRecipe": "Run `rr add document-checklist`. Wire <DocumentChecklist bindings={{ current, seed, updateStatus }} countryTemplateSlot={<CountryTemplateCard bindings={{ templates, getTemplate, instantiate }} />} /> — bindings sourced from api.features['document-checklist'].* queries/mutations.",
1287
+ "tags": [
1288
+ "career",
1289
+ "documents",
1290
+ "checklist",
1291
+ "job-search",
1292
+ "indonesia"
1293
+ ]
1276
1294
  }
1277
1295
  ],
1278
1296
  "slices": [
@@ -2152,6 +2170,53 @@
2152
2170
  "metadata-generator"
2153
2171
  ],
2154
2172
  "agentRecipe": "Run `rr add seo`. Call seo.generate from server actions or admin mutations. Cost guard rate-limits per-user within 24h via callsInWindow query."
2173
+ },
2174
+ {
2175
+ "slug": "document-checklist",
2176
+ "title": "Document Checklist — Job-Search Doc Tracker",
2177
+ "category": "content",
2178
+ "kind": "full",
2179
+ "version": "0.1.0",
2180
+ "description": "Track required job-search documents (CV, KTP, ijazah, etc.) with country-scoped seed templates and per-user completion state. Ships an Indonesian default checklist. Toggle completed + notes + expiry per item.",
2181
+ "source": "rahmanef63/CareerPack",
2182
+ "slicePath": "frontend/slices/document-checklist",
2183
+ "convexPaths": [
2184
+ "convex/features/document-checklist"
2185
+ ],
2186
+ "npm": [
2187
+ "lucide-react"
2188
+ ],
2189
+ "shadcn": [
2190
+ "badge",
2191
+ "button",
2192
+ "card",
2193
+ "dialog",
2194
+ "label",
2195
+ "popover",
2196
+ "calendar",
2197
+ "progress",
2198
+ "scroll-area",
2199
+ "skeleton",
2200
+ "tabs",
2201
+ "textarea"
2202
+ ],
2203
+ "env": [],
2204
+ "peers": [
2205
+ {
2206
+ "slug": "convex-auth",
2207
+ "range": "^0.1",
2208
+ "reason": "Auth identity for user-scoped checklist state."
2209
+ }
2210
+ ],
2211
+ "providers": [],
2212
+ "tags": [
2213
+ "career",
2214
+ "documents",
2215
+ "checklist",
2216
+ "job-search",
2217
+ "indonesia"
2218
+ ],
2219
+ "agentRecipe": "Run `rr add document-checklist`. Wire <DocumentChecklist bindings={{ current, seed, updateStatus }} countryTemplateSlot={<CountryTemplateCard bindings={{ templates, getTemplate, instantiate }} />} /> — bindings sourced from api.features['document-checklist'].* queries/mutations."
2155
2220
  }
2156
2221
  ]
2157
2222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rahman-resources",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Scaffolder + installer for Rahman Resources kitab — npx rahman-resources init/add/lift/scaffold-slice/publish-slice. Tier-3 portable feature slices + manifest + skills + CRUD workflows.",
5
5
  "type": "module",
6
6
  "license": "MIT",