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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Slice Composition Compiler — Phase A: Typed contract DSL.
3
+ *
4
+ * This module exposes the {@link defineSliceContract} factory plus the
5
+ * supporting type vocabulary. A slice contract is the typed, code-shaped
6
+ * sibling of the legacy `slice.manifest.json`. Both coexist during the
7
+ * migration; new slices should ship a contract while keeping the JSON
8
+ * manifest for back-compat with the npm CLI manifest pipeline.
9
+ *
10
+ * @module packages/cli/lib/contract
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Primitive vocabulary
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Identity providers the kitab knows about.
19
+ *
20
+ * The kitab itself only ships `convex` (see CLAUDE.md "NO Clerk"). The other
21
+ * literals exist so consumer projects forking the contract DSL can target
22
+ * a different provider without losing type safety.
23
+ */
24
+ export type AuthProvider = "convex" | "clerk" | "next-auth" | "none";
25
+
26
+ /**
27
+ * RBAC permission string in `domain.action` shape — e.g. `"payment.refund"`.
28
+ *
29
+ * Enforced at compile time via a template-literal type, and at runtime by
30
+ * {@link defineSliceContract}. The runtime check rejects empty segments and
31
+ * any value with more than one dot.
32
+ */
33
+ export type RBACPermission = `${string}.${string}`;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // `requires` block
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Convex table-namespace declaration for a slice.
41
+ *
42
+ * Every Convex table the slice owns must start with {@link prefix}. Operator
43
+ * decision 2026-05-12 mandates per-provider prefixes (e.g. `doku_`, `midtrans_`)
44
+ * to prevent the historical `paymentOrders` collision between sibling
45
+ * payment slices.
46
+ */
47
+ export interface ConvexNamespace {
48
+ /** Required prefix — e.g. `"doku_"`. Must match `/^[a-z][a-z0-9_]*_$/`. */
49
+ prefix: string;
50
+ /** Tables this slice declares. Every entry must start with {@link prefix}. */
51
+ tables: string[];
52
+ }
53
+
54
+ /**
55
+ * Capabilities a slice REQUIRES the host application to satisfy before it
56
+ * can be composed in.
57
+ */
58
+ export interface SliceContractRequires {
59
+ /** Identity provider the slice expects. Omit when slice is auth-agnostic. */
60
+ auth?: AuthProvider;
61
+ /** RBAC permission strings the slice will call `requirePermission(...)` with. */
62
+ rbac?: RBACPermission[];
63
+ /** Env var names (server scope). Validators read this for `.env.example` drift checks. */
64
+ env?: string[];
65
+ /** Convex table namespace the slice owns. */
66
+ convex?: ConvexNamespace;
67
+ /** Other slice ids that must be installed first. */
68
+ deps?: string[];
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // `provides` block
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Surface area a slice exposes to the host application + downstream slices.
77
+ *
78
+ * All arrays are optional. Empty / omitted means "nothing exposed in that
79
+ * category". The keys must match the literal segment of a conflict path —
80
+ * `tables`, `routes`, `hooks`, `events`, `components`.
81
+ */
82
+ export interface SliceContractProvides {
83
+ /** Next.js route paths the slice mounts — e.g. `["/sign-in", "/sign-out"]`. */
84
+ routes?: string[];
85
+ /** Public hook export names — e.g. `["useDokuCheckout"]`. */
86
+ hooks?: string[];
87
+ /** Convex table names. Must match `requires.convex.prefix` if that is set. */
88
+ tables?: string[];
89
+ /** Event-bus event names the slice emits — e.g. `["payment.captured"]`. */
90
+ events?: string[];
91
+ /** Public component exports — e.g. `["DokuCheckoutButton"]`. */
92
+ components?: string[];
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Top-level contract
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * The full Phase-A slice contract shape.
101
+ *
102
+ * @see {@link defineSliceContract} for the runtime-checked constructor.
103
+ */
104
+ export interface SliceContract {
105
+ /** Slice slug — kebab-case, matches the folder name. */
106
+ id: string;
107
+ /** Semver — `MAJOR.MINOR.PATCH`, optional `-prerelease` and `+build`. */
108
+ version: string;
109
+ /** Host requirements. */
110
+ requires: SliceContractRequires;
111
+ /** Surface exposed to the host. */
112
+ provides: SliceContractProvides;
113
+ /**
114
+ * Known incompatibilities — `"<slug>:<provides-key>.<value>"`.
115
+ *
116
+ * Example: `"midtrans-payment:tables.paymentOrders"` declares that this
117
+ * slice collides with `midtrans-payment` over the `paymentOrders` table.
118
+ * The validator surfaces this as a P0 finding when both slices are
119
+ * composed into the same app.
120
+ */
121
+ conflicts?: string[];
122
+ /** Map of previous-version → migration script id. */
123
+ migrationFrom?: Record<string, string>;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Runtime regexes
128
+ // ---------------------------------------------------------------------------
129
+
130
+ const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
131
+ // Plain semver — also tolerates pre-release + build metadata.
132
+ const SEMVER =
133
+ /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
134
+ const PREFIX = /^[a-z][a-z0-9_]*_$/;
135
+ const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components)\.[A-Za-z0-9_\-\/]+$/;
136
+ const PERMISSION = /^[^.]+\.[^.]+$/;
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Factory
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Identity factory that runtime-validates a {@link SliceContract}.
144
+ *
145
+ * Throws a descriptive {@link Error} when the contract violates any of the
146
+ * Phase-A invariants:
147
+ *
148
+ * - `id` must be kebab-case (`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`).
149
+ * - `version` must be semver.
150
+ * - When `requires.convex` is set, every `requires.convex.tables[i]` must
151
+ * start with `requires.convex.prefix`.
152
+ * - When `requires.convex` is set and `provides.tables` is non-empty, every
153
+ * `provides.tables[i]` must start with `requires.convex.prefix`.
154
+ * - Each `conflicts[i]` must match
155
+ * `<kebab-slug>:<routes|hooks|tables|events|components>.<value>`.
156
+ * - Each `requires.rbac[i]` must be a `domain.action` pair (exactly one dot,
157
+ * non-empty halves).
158
+ *
159
+ * Returns the contract object unchanged so callers can write
160
+ * `export const contract = defineSliceContract({ ... })`.
161
+ *
162
+ * @param c The contract to validate.
163
+ * @returns The exact same object reference, narrowed to {@link SliceContract}.
164
+ */
165
+ export function defineSliceContract(c: SliceContract): SliceContract {
166
+ if (!c || typeof c !== "object") {
167
+ throw new Error("defineSliceContract: expected an object, got " + typeof c);
168
+ }
169
+ if (typeof c.id !== "string" || !KEBAB_CASE.test(c.id)) {
170
+ throw new Error(`defineSliceContract: id "${String(c.id)}" must be kebab-case`);
171
+ }
172
+ if (typeof c.version !== "string" || !SEMVER.test(c.version)) {
173
+ throw new Error(`defineSliceContract(${c.id}): version "${String(c.version)}" is not semver`);
174
+ }
175
+ if (!c.requires || typeof c.requires !== "object") {
176
+ throw new Error(`defineSliceContract(${c.id}): requires must be an object`);
177
+ }
178
+ if (!c.provides || typeof c.provides !== "object") {
179
+ throw new Error(`defineSliceContract(${c.id}): provides must be an object`);
180
+ }
181
+
182
+ // RBAC
183
+ if (c.requires.rbac) {
184
+ if (!Array.isArray(c.requires.rbac)) {
185
+ throw new Error(`defineSliceContract(${c.id}): requires.rbac must be an array`);
186
+ }
187
+ for (const p of c.requires.rbac) {
188
+ if (typeof p !== "string" || !PERMISSION.test(p)) {
189
+ throw new Error(
190
+ `defineSliceContract(${c.id}): rbac entry "${String(p)}" must be "<domain>.<action>"`,
191
+ );
192
+ }
193
+ }
194
+ }
195
+
196
+ // Convex prefix invariants
197
+ const cx = c.requires.convex;
198
+ if (cx) {
199
+ if (typeof cx.prefix !== "string" || !PREFIX.test(cx.prefix)) {
200
+ throw new Error(
201
+ `defineSliceContract(${c.id}): convex.prefix "${String(cx.prefix)}" must match /^[a-z][a-z0-9_]*_$/`,
202
+ );
203
+ }
204
+ if (!Array.isArray(cx.tables)) {
205
+ throw new Error(`defineSliceContract(${c.id}): convex.tables must be an array`);
206
+ }
207
+ for (const t of cx.tables) {
208
+ if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
209
+ throw new Error(
210
+ `defineSliceContract(${c.id}): convex.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
211
+ );
212
+ }
213
+ }
214
+ if (Array.isArray(c.provides.tables)) {
215
+ for (const t of c.provides.tables) {
216
+ if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
217
+ throw new Error(
218
+ `defineSliceContract(${c.id}): provides.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
219
+ );
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ // Conflicts
226
+ if (c.conflicts) {
227
+ if (!Array.isArray(c.conflicts)) {
228
+ throw new Error(`defineSliceContract(${c.id}): conflicts must be an array`);
229
+ }
230
+ for (const cf of c.conflicts) {
231
+ if (typeof cf !== "string" || !CONFLICT.test(cf)) {
232
+ throw new Error(
233
+ `defineSliceContract(${c.id}): conflicts entry "${String(cf)}" must match "<slug>:<routes|hooks|tables|events|components>.<value>"`,
234
+ );
235
+ }
236
+ }
237
+ }
238
+
239
+ return c;
240
+ }
package/lib/dna.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ // Type definitions for the slice-DNA lineage tracker.
2
+ // Runtime in dna.mjs; this file is hand-authored types for tsc/ide consumers.
3
+
4
+ export interface LineageEntry {
5
+ /** "<sourceRepo>:<path>" — e.g. "superspace:frontend/slices/auth". */
6
+ from: string;
7
+ /** Optional destination, e.g. "kitab:0.7.0" or "careerpack:adopt". */
8
+ to?: string;
9
+ /** ISO 8601 UTC timestamp. */
10
+ at: string;
11
+ /** Transform tags applied during the harvest (e.g. "alias-rewrite", "clerk-strip"). */
12
+ transforms: string[];
13
+ /** Human/agent that triggered the lineage entry. */
14
+ actor?: string;
15
+ }
16
+
17
+ export interface ConsumerAdoption {
18
+ adopted_at: string;
19
+ version: string;
20
+ /** 0-100, computed from file diff between kitab and consumer. */
21
+ drift_score: number;
22
+ last_synced_at?: string;
23
+ }
24
+
25
+ export interface SliceDNA {
26
+ /** Slice slug, kebab-case. */
27
+ id: string;
28
+ created_at: string;
29
+ lineage: LineageEntry[];
30
+ /** Keyed by consumer name (notion, superspace, careerpack, content, rahmanef, cescadesigns). */
31
+ consumers: Record<string, ConsumerAdoption>;
32
+ }
33
+
34
+ export interface LineageGraphNode {
35
+ id: string;
36
+ type: "slice" | "consumer" | "source";
37
+ }
38
+
39
+ export interface LineageGraphEdge {
40
+ from: string;
41
+ to: string;
42
+ transforms?: string[];
43
+ at: string;
44
+ }
45
+
46
+ export interface LineageGraph {
47
+ nodes: LineageGraphNode[];
48
+ edges: LineageGraphEdge[];
49
+ }
50
+
51
+ export function readDNA(slug: string): SliceDNA | null;
52
+ export function writeDNA(dna: SliceDNA): void;
53
+ export function appendLineage(slug: string, entry: LineageEntry): SliceDNA;
54
+ export function upsertConsumerAdoption(
55
+ slug: string,
56
+ consumer: string,
57
+ adoption: ConsumerAdoption,
58
+ ): SliceDNA;
59
+ export function listAllDNA(): SliceDNA[];
60
+ export function buildLineageGraph(): LineageGraph;
61
+
62
+ /** Resolve the absolute path to `.kitab/lineage/`. */
63
+ export function getLineageDir(): string;
64
+ /** Resolve the absolute path to `.kitab/lineage/<slug>.dna.json`. */
65
+ export function getDNAPath(slug: string): string;
package/lib/dna.mjs ADDED
@@ -0,0 +1,239 @@
1
+ // dna.mjs — slice-DNA lineage tracker (Phase C of the Slice Composition Compiler).
2
+ //
3
+ // Reader/writer for `.kitab/lineage/<slug>.dna.json`. Each DNA file records
4
+ // where a slice came from (lineage[]) and which consumers later adopted it
5
+ // (consumers{}). Types live in dna.d.ts.
6
+ //
7
+ // Runtime contract:
8
+ // - .kitab/lineage/ lives at the kitab repo root (resolved by walking up
9
+ // from this file). If the directory doesn't exist, write* creates it.
10
+ // - Files are pretty-printed JSON, trailing newline. Idempotent re-writes
11
+ // keep diffs clean.
12
+ //
13
+ // The functions here are deliberately synchronous despite the spec hinting
14
+ // at fs.promises — the consumers (CLI + MCP) are both single-shot processes
15
+ // where async I/O adds noise without buying parallelism. The d.ts mirrors
16
+ // this signature exactly.
17
+
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import path from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ // Walk up from packages/cli/lib/ to the kitab repo root (the directory
31
+ // containing `packages/`). Anchors the .kitab/lineage path regardless of
32
+ // where the CLI is invoked from.
33
+ function findRepoRoot() {
34
+ let dir = __dirname;
35
+ for (let i = 0; i < 8; i++) {
36
+ if (existsSync(path.join(dir, "packages")) && existsSync(path.join(dir, "package.json"))) {
37
+ return dir;
38
+ }
39
+ const parent = path.dirname(dir);
40
+ if (parent === dir) break;
41
+ dir = parent;
42
+ }
43
+ // Fallback: assume cwd is the repo root.
44
+ return process.cwd();
45
+ }
46
+
47
+ const REPO_ROOT = findRepoRoot();
48
+
49
+ export function getLineageDir() {
50
+ return path.join(REPO_ROOT, ".kitab", "lineage");
51
+ }
52
+
53
+ export function getDNAPath(slug) {
54
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(slug)) {
55
+ throw new Error(`dna: invalid slug "${slug}" — must be kebab-case`);
56
+ }
57
+ return path.join(getLineageDir(), `${slug}.dna.json`);
58
+ }
59
+
60
+ function ensureDir() {
61
+ const d = getLineageDir();
62
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
63
+ }
64
+
65
+ /** @returns {import("./dna").SliceDNA | null} */
66
+ export function readDNA(slug) {
67
+ const p = getDNAPath(slug);
68
+ if (!existsSync(p)) return null;
69
+ try {
70
+ const raw = readFileSync(p, "utf8");
71
+ const parsed = JSON.parse(raw);
72
+ return normalizeDNA(parsed, slug);
73
+ } catch (err) {
74
+ throw new Error(`dna: failed to parse ${p}: ${err.message ?? err}`);
75
+ }
76
+ }
77
+
78
+ /** @param {import("./dna").SliceDNA} dna */
79
+ export function writeDNA(dna) {
80
+ if (!dna || typeof dna !== "object" || !dna.id) {
81
+ throw new Error("dna.writeDNA: dna.id is required");
82
+ }
83
+ ensureDir();
84
+ const normalized = normalizeDNA(dna, dna.id);
85
+ const p = getDNAPath(normalized.id);
86
+ writeFileSync(p, JSON.stringify(normalized, null, 2) + "\n");
87
+ }
88
+
89
+ /** @param {string} slug @param {import("./dna").LineageEntry} entry */
90
+ export function appendLineage(slug, entry) {
91
+ const existing = readDNA(slug) ?? newDNA(slug);
92
+ if (!entry || typeof entry !== "object" || !entry.from || !entry.at) {
93
+ throw new Error("dna.appendLineage: entry.from and entry.at are required");
94
+ }
95
+ existing.lineage.push({
96
+ from: entry.from,
97
+ to: entry.to,
98
+ at: entry.at,
99
+ transforms: Array.isArray(entry.transforms) ? entry.transforms : [],
100
+ actor: entry.actor,
101
+ });
102
+ writeDNA(existing);
103
+ return existing;
104
+ }
105
+
106
+ /** @param {string} slug @param {string} consumer @param {import("./dna").ConsumerAdoption} adoption */
107
+ export function upsertConsumerAdoption(slug, consumer, adoption) {
108
+ if (!consumer) throw new Error("dna.upsertConsumerAdoption: consumer is required");
109
+ if (!adoption || typeof adoption !== "object") {
110
+ throw new Error("dna.upsertConsumerAdoption: adoption object is required");
111
+ }
112
+ const existing = readDNA(slug) ?? newDNA(slug);
113
+ existing.consumers[consumer] = {
114
+ adopted_at: adoption.adopted_at,
115
+ version: adoption.version,
116
+ drift_score: clampDrift(adoption.drift_score),
117
+ last_synced_at: adoption.last_synced_at,
118
+ };
119
+ writeDNA(existing);
120
+ return existing;
121
+ }
122
+
123
+ /** @returns {import("./dna").SliceDNA[]} */
124
+ export function listAllDNA() {
125
+ const d = getLineageDir();
126
+ if (!existsSync(d)) return [];
127
+ const files = readdirSync(d).filter((f) => f.endsWith(".dna.json"));
128
+ const out = [];
129
+ for (const f of files) {
130
+ const slug = f.replace(/\.dna\.json$/, "");
131
+ const dna = readDNA(slug);
132
+ if (dna) out.push(dna);
133
+ }
134
+ // Stable ordering — alphabetical by slug.
135
+ out.sort((a, b) => a.id.localeCompare(b.id));
136
+ return out;
137
+ }
138
+
139
+ /** @returns {import("./dna").LineageGraph} */
140
+ export function buildLineageGraph() {
141
+ const all = listAllDNA();
142
+ /** @type {Map<string, import("./dna").LineageGraphNode>} */
143
+ const nodes = new Map();
144
+ /** @type {import("./dna").LineageGraphEdge[]} */
145
+ const edges = [];
146
+
147
+ function addNode(id, type) {
148
+ if (!nodes.has(id)) nodes.set(id, { id, type });
149
+ }
150
+
151
+ for (const dna of all) {
152
+ const sliceId = `slice:${dna.id}`;
153
+ addNode(sliceId, "slice");
154
+
155
+ // lineage edges: source → slice
156
+ for (const entry of dna.lineage ?? []) {
157
+ const sourceId = `source:${entry.from}`;
158
+ addNode(sourceId, "source");
159
+ // If `to` looks like a different slice / kitab tag, prefer the slice node;
160
+ // otherwise treat `to` as the kitab itself and target the slice node.
161
+ const targetId = entry.to && entry.to.startsWith("kitab:") ? sliceId : sliceId;
162
+ edges.push({
163
+ from: sourceId,
164
+ to: targetId,
165
+ transforms: entry.transforms,
166
+ at: entry.at,
167
+ });
168
+ }
169
+
170
+ // consumer edges: slice → consumer
171
+ for (const [consumerName, adoption] of Object.entries(dna.consumers ?? {})) {
172
+ const consumerId = `consumer:${consumerName}`;
173
+ addNode(consumerId, "consumer");
174
+ edges.push({
175
+ from: sliceId,
176
+ to: consumerId,
177
+ transforms: [`drift:${adoption.drift_score}`, `v${adoption.version}`],
178
+ at: adoption.adopted_at,
179
+ });
180
+ }
181
+ }
182
+
183
+ return { nodes: [...nodes.values()], edges };
184
+ }
185
+
186
+ // ─── helpers ──────────────────────────────────────────────────────────────
187
+
188
+ /** Build a fresh DNA shell for a slug. */
189
+ function newDNA(slug) {
190
+ return {
191
+ id: slug,
192
+ created_at: new Date().toISOString(),
193
+ lineage: [],
194
+ consumers: {},
195
+ };
196
+ }
197
+
198
+ /** Fill in defaults + drop unknown fields. */
199
+ function normalizeDNA(raw, slug) {
200
+ const out = {
201
+ id: typeof raw.id === "string" ? raw.id : slug,
202
+ created_at:
203
+ typeof raw.created_at === "string" ? raw.created_at : new Date().toISOString(),
204
+ lineage: [],
205
+ consumers: {},
206
+ };
207
+ if (Array.isArray(raw.lineage)) {
208
+ for (const e of raw.lineage) {
209
+ if (!e || typeof e !== "object" || !e.from || !e.at) continue;
210
+ out.lineage.push({
211
+ from: String(e.from),
212
+ to: e.to ? String(e.to) : undefined,
213
+ at: String(e.at),
214
+ transforms: Array.isArray(e.transforms) ? e.transforms.map(String) : [],
215
+ actor: e.actor ? String(e.actor) : undefined,
216
+ });
217
+ }
218
+ }
219
+ if (raw.consumers && typeof raw.consumers === "object") {
220
+ for (const [k, v] of Object.entries(raw.consumers)) {
221
+ if (!v || typeof v !== "object") continue;
222
+ out.consumers[k] = {
223
+ adopted_at: String(v.adopted_at ?? ""),
224
+ version: String(v.version ?? "0.0.0"),
225
+ drift_score: clampDrift(v.drift_score),
226
+ last_synced_at: v.last_synced_at ? String(v.last_synced_at) : undefined,
227
+ };
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ function clampDrift(n) {
234
+ const x = Number(n);
235
+ if (!Number.isFinite(x)) return 0;
236
+ if (x < 0) return 0;
237
+ if (x > 100) return 100;
238
+ return Math.round(x);
239
+ }