rahman-resources 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +39 -6
- package/bin/compose-print.mjs +83 -0
- package/bin/compose-state.mjs +105 -0
- package/bin/compose.mjs +30 -194
- package/bin/graph-render.mjs +179 -0
- package/bin/graph.mjs +3 -182
- package/bin/migrate-load.mjs +189 -0
- package/bin/migrate-print.mjs +75 -0
- package/bin/migrate.mjs +56 -297
- package/bin/update-context.mjs +184 -0
- package/bin/update-output.mjs +110 -0
- package/bin/update.mjs +15 -293
- package/lib/compose-solver-arbitrate.mjs +84 -0
- package/lib/compose-solver-conflicts.mjs +163 -0
- package/lib/compose-solver-loader.mjs +79 -0
- package/lib/compose-solver-resolve.mjs +165 -0
- package/lib/compose-solver.mjs +42 -376
- package/lib/contract-types.ts +184 -0
- package/lib/contract-validate.ts +155 -0
- package/lib/contract.ts +31 -319
- package/lib/dna-graph.mjs +53 -0
- package/lib/dna.mjs +5 -46
- package/lib/env-augment.mjs +116 -0
- package/lib/manifest.json +303 -351
- package/lib/merge3-diff.mjs +187 -0
- package/lib/merge3-snapshot.mjs +108 -0
- package/lib/merge3.mjs +7 -305
- package/lib/migration-plan-render.mjs +111 -0
- package/lib/migration-plan-steps.mjs +144 -0
- package/lib/migration-plan.mjs +17 -258
- package/lib/post-init.mjs +1 -1
- package/lib/skills.json +1 -1
- package/package.json +1 -1
package/lib/contract.ts
CHANGED
|
@@ -1,201 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Slice Composition Compiler — Phase A:
|
|
2
|
+
* Slice Composition Compiler — Phase A: typed contract DSL.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* migration; new slices should ship a contract while keeping the JSON
|
|
8
|
-
* manifest for back-compat with the npm CLI manifest pipeline.
|
|
4
|
+
* Re-exports the type vocabulary from {@link ./contract-types} and the
|
|
5
|
+
* runtime-checked {@link defineSliceContract} factory. The actual invariant
|
|
6
|
+
* blocks live in {@link ./contract-validate} so each module stays ≤200 LOC.
|
|
9
7
|
*
|
|
10
8
|
* @module packages/cli/lib/contract
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
// `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
|
-
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
// Top-level contract
|
|
157
|
-
// ---------------------------------------------------------------------------
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The full Phase-A slice contract shape.
|
|
161
|
-
*
|
|
162
|
-
* @see {@link defineSliceContract} for the runtime-checked constructor.
|
|
163
|
-
*/
|
|
164
|
-
export interface SliceContract {
|
|
165
|
-
/** Slice slug — kebab-case, matches the folder name. */
|
|
166
|
-
id: string;
|
|
167
|
-
/** Semver — `MAJOR.MINOR.PATCH`, optional `-prerelease` and `+build`. */
|
|
168
|
-
version: string;
|
|
169
|
-
/** Host requirements. */
|
|
170
|
-
requires: SliceContractRequires;
|
|
171
|
-
/** Surface exposed to the host. */
|
|
172
|
-
provides: SliceContractProvides;
|
|
173
|
-
/**
|
|
174
|
-
* Known incompatibilities — `"<slug>:<provides-key>.<value>"`.
|
|
175
|
-
*
|
|
176
|
-
* Example: `"midtrans-payment:tables.paymentOrders"` declares that this
|
|
177
|
-
* slice collides with `midtrans-payment` over the `paymentOrders` table.
|
|
178
|
-
* The validator surfaces this as a P0 finding when both slices are
|
|
179
|
-
* composed into the same app.
|
|
180
|
-
*/
|
|
181
|
-
conflicts?: string[];
|
|
182
|
-
/** Map of previous-version → migration script id. */
|
|
183
|
-
migrationFrom?: Record<string, string>;
|
|
184
|
-
/** Wave N+3 — bidirectional sync policy + generalisation gate. */
|
|
185
|
-
bidir?: SliceBidirContract;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ---------------------------------------------------------------------------
|
|
189
|
-
// Runtime regexes
|
|
190
|
-
// ---------------------------------------------------------------------------
|
|
191
|
-
|
|
192
|
-
const KEBAB_CASE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
193
|
-
// Plain semver — also tolerates pre-release + build metadata.
|
|
194
|
-
const SEMVER =
|
|
195
|
-
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
196
|
-
const PREFIX = /^[a-z][a-z0-9_]*_$/;
|
|
197
|
-
const CONFLICT = /^[a-z][a-z0-9-]*:(routes|hooks|tables|events|components)\.[A-Za-z0-9_\-\/]+$/;
|
|
198
|
-
const PERMISSION = /^[^.]+\.[^.]+$/;
|
|
11
|
+
import type { SliceContract } from "./contract-types";
|
|
12
|
+
import {
|
|
13
|
+
validateHeader,
|
|
14
|
+
validateRbac,
|
|
15
|
+
validateConvex,
|
|
16
|
+
validateBidir,
|
|
17
|
+
validateConflicts,
|
|
18
|
+
} from "./contract-validate";
|
|
19
|
+
|
|
20
|
+
// Re-export the full type surface so existing imports keep working.
|
|
21
|
+
export type {
|
|
22
|
+
AuthProvider,
|
|
23
|
+
RBACPermission,
|
|
24
|
+
ConvexNamespace,
|
|
25
|
+
SliceContractRequires,
|
|
26
|
+
SliceContractProvides,
|
|
27
|
+
SliceSyncPolicy,
|
|
28
|
+
GeneralizationLevel,
|
|
29
|
+
SliceGeneralization,
|
|
30
|
+
SliceBidirContract,
|
|
31
|
+
SliceContract,
|
|
32
|
+
} from "./contract-types";
|
|
199
33
|
|
|
200
34
|
// ---------------------------------------------------------------------------
|
|
201
35
|
// Factory
|
|
@@ -225,132 +59,10 @@ const PERMISSION = /^[^.]+\.[^.]+$/;
|
|
|
225
59
|
* @returns The exact same object reference, narrowed to {@link SliceContract}.
|
|
226
60
|
*/
|
|
227
61
|
export function defineSliceContract(c: SliceContract): SliceContract {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
if (typeof c.version !== "string" || !SEMVER.test(c.version)) {
|
|
235
|
-
throw new Error(`defineSliceContract(${c.id}): version "${String(c.version)}" is not semver`);
|
|
236
|
-
}
|
|
237
|
-
if (!c.requires || typeof c.requires !== "object") {
|
|
238
|
-
throw new Error(`defineSliceContract(${c.id}): requires must be an object`);
|
|
239
|
-
}
|
|
240
|
-
if (!c.provides || typeof c.provides !== "object") {
|
|
241
|
-
throw new Error(`defineSliceContract(${c.id}): provides must be an object`);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// RBAC
|
|
245
|
-
if (c.requires.rbac) {
|
|
246
|
-
if (!Array.isArray(c.requires.rbac)) {
|
|
247
|
-
throw new Error(`defineSliceContract(${c.id}): requires.rbac must be an array`);
|
|
248
|
-
}
|
|
249
|
-
for (const p of c.requires.rbac) {
|
|
250
|
-
if (typeof p !== "string" || !PERMISSION.test(p)) {
|
|
251
|
-
throw new Error(
|
|
252
|
-
`defineSliceContract(${c.id}): rbac entry "${String(p)}" must be "<domain>.<action>"`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Convex prefix invariants
|
|
259
|
-
const cx = c.requires.convex;
|
|
260
|
-
if (cx) {
|
|
261
|
-
if (typeof cx.prefix !== "string" || !PREFIX.test(cx.prefix)) {
|
|
262
|
-
throw new Error(
|
|
263
|
-
`defineSliceContract(${c.id}): convex.prefix "${String(cx.prefix)}" must match /^[a-z][a-z0-9_]*_$/`,
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
if (!Array.isArray(cx.tables)) {
|
|
267
|
-
throw new Error(`defineSliceContract(${c.id}): convex.tables must be an array`);
|
|
268
|
-
}
|
|
269
|
-
for (const t of cx.tables) {
|
|
270
|
-
if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
|
|
271
|
-
throw new Error(
|
|
272
|
-
`defineSliceContract(${c.id}): convex.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (Array.isArray(c.provides.tables)) {
|
|
277
|
-
for (const t of c.provides.tables) {
|
|
278
|
-
if (typeof t !== "string" || !t.startsWith(cx.prefix)) {
|
|
279
|
-
throw new Error(
|
|
280
|
-
`defineSliceContract(${c.id}): provides.tables entry "${String(t)}" must start with prefix "${cx.prefix}"`,
|
|
281
|
-
);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
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
|
-
|
|
341
|
-
// Conflicts
|
|
342
|
-
if (c.conflicts) {
|
|
343
|
-
if (!Array.isArray(c.conflicts)) {
|
|
344
|
-
throw new Error(`defineSliceContract(${c.id}): conflicts must be an array`);
|
|
345
|
-
}
|
|
346
|
-
for (const cf of c.conflicts) {
|
|
347
|
-
if (typeof cf !== "string" || !CONFLICT.test(cf)) {
|
|
348
|
-
throw new Error(
|
|
349
|
-
`defineSliceContract(${c.id}): conflicts entry "${String(cf)}" must match "<slug>:<routes|hooks|tables|events|components>.<value>"`,
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
62
|
+
validateHeader(c);
|
|
63
|
+
validateRbac(c);
|
|
64
|
+
validateConvex(c);
|
|
65
|
+
validateBidir(c);
|
|
66
|
+
validateConflicts(c);
|
|
355
67
|
return c;
|
|
356
68
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// dna-graph.mjs — lineage graph builder.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from dna.mjs. Reads all DNA files via `listAllDNA` and produces
|
|
4
|
+
// a node/edge graph keyed by source / slice / consumer.
|
|
5
|
+
|
|
6
|
+
import { listAllDNA } from "./dna.mjs";
|
|
7
|
+
|
|
8
|
+
/** @returns {import("./dna").LineageGraph} */
|
|
9
|
+
export function buildLineageGraph() {
|
|
10
|
+
const all = listAllDNA();
|
|
11
|
+
/** @type {Map<string, import("./dna").LineageGraphNode>} */
|
|
12
|
+
const nodes = new Map();
|
|
13
|
+
/** @type {import("./dna").LineageGraphEdge[]} */
|
|
14
|
+
const edges = [];
|
|
15
|
+
|
|
16
|
+
function addNode(id, type) {
|
|
17
|
+
if (!nodes.has(id)) nodes.set(id, { id, type });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const dna of all) {
|
|
21
|
+
const sliceId = `slice:${dna.id}`;
|
|
22
|
+
addNode(sliceId, "slice");
|
|
23
|
+
|
|
24
|
+
// lineage edges: source → slice
|
|
25
|
+
for (const entry of dna.lineage ?? []) {
|
|
26
|
+
const sourceId = `source:${entry.from}`;
|
|
27
|
+
addNode(sourceId, "source");
|
|
28
|
+
// If `to` looks like a different slice / kitab tag, prefer the slice node;
|
|
29
|
+
// otherwise treat `to` as the kitab itself and target the slice node.
|
|
30
|
+
const targetId = entry.to && entry.to.startsWith("kitab:") ? sliceId : sliceId;
|
|
31
|
+
edges.push({
|
|
32
|
+
from: sourceId,
|
|
33
|
+
to: targetId,
|
|
34
|
+
transforms: entry.transforms,
|
|
35
|
+
at: entry.at,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// consumer edges: slice → consumer
|
|
40
|
+
for (const [consumerName, adoption] of Object.entries(dna.consumers ?? {})) {
|
|
41
|
+
const consumerId = `consumer:${consumerName}`;
|
|
42
|
+
addNode(consumerId, "consumer");
|
|
43
|
+
edges.push({
|
|
44
|
+
from: sliceId,
|
|
45
|
+
to: consumerId,
|
|
46
|
+
transforms: [`drift:${adoption.drift_score}`, `v${adoption.version}`],
|
|
47
|
+
at: adoption.adopted_at,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { nodes: [...nodes.values()], edges };
|
|
53
|
+
}
|
package/lib/dna.mjs
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// at fs.promises — the consumers (CLI + MCP) are both single-shot processes
|
|
15
15
|
// where async I/O adds noise without buying parallelism. The d.ts mirrors
|
|
16
16
|
// this signature exactly.
|
|
17
|
+
//
|
|
18
|
+
// `buildLineageGraph` lives in dna-graph.mjs and is re-exported below so
|
|
19
|
+
// existing imports (`import { buildLineageGraph } from "../lib/dna.mjs"`)
|
|
20
|
+
// keep working.
|
|
17
21
|
|
|
18
22
|
import {
|
|
19
23
|
existsSync,
|
|
@@ -136,52 +140,7 @@ export function listAllDNA() {
|
|
|
136
140
|
return out;
|
|
137
141
|
}
|
|
138
142
|
|
|
139
|
-
|
|
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
|
-
}
|
|
143
|
+
export { buildLineageGraph } from "./dna-graph.mjs";
|
|
185
144
|
|
|
186
145
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
187
146
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// env-augment.mjs — append slice env requirements to consumer .env.example.
|
|
2
|
+
//
|
|
3
|
+
// Invoked from `npx rr add <slice>` after slice files land. Idempotent:
|
|
4
|
+
// re-running `add` does not duplicate entries. Never creates .env.example
|
|
5
|
+
// (consumer may not want one); warns and skips instead. Never touches
|
|
6
|
+
// .env.local (operator-only).
|
|
7
|
+
//
|
|
8
|
+
// Reads env requirements from the manifest entry passed in by the CLI
|
|
9
|
+
// (already normalised from slice.json `deps.env` + contract `requires.env`
|
|
10
|
+
// at sync time). Shape: `{ name, scope?, required?, description? }`.
|
|
11
|
+
//
|
|
12
|
+
// Contract: see CLAUDE.md "augmentConsumerEnv contract". Function ≤200 LOC.
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
import kleur from "kleur";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {{ slug: string, env?: Array<{ name: string, scope?: string, required?: boolean, description?: string }> }} slice
|
|
21
|
+
* @param {string} target absolute path to consumer project root
|
|
22
|
+
*/
|
|
23
|
+
export function augmentConsumerEnv(slice, target) {
|
|
24
|
+
const envList = Array.isArray(slice?.env) ? slice.env : [];
|
|
25
|
+
if (envList.length === 0) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const envFile = path.join(target, ".env.example");
|
|
30
|
+
if (!existsSync(envFile)) {
|
|
31
|
+
console.log(
|
|
32
|
+
kleur.yellow(
|
|
33
|
+
` ⚠ .env.example not found in ${target} — skipping env augment.`,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
console.log(
|
|
37
|
+
kleur.dim(
|
|
38
|
+
` Required env for ${slice.slug}: ${envList
|
|
39
|
+
.map((e) => prefixName(e))
|
|
40
|
+
.join(", ")}`,
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const existing = readFileSync(envFile, "utf8");
|
|
47
|
+
const present = parseExistingNames(existing);
|
|
48
|
+
|
|
49
|
+
const missing = envList.filter((e) => !present.has(prefixName(e)));
|
|
50
|
+
if (missing.length === 0) {
|
|
51
|
+
console.log(kleur.dim(` (no new env to add to .env.example)`));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const block = renderBlock(slice.slug, missing);
|
|
56
|
+
const sep = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
57
|
+
writeFileSync(envFile, existing + sep + block, "utf8");
|
|
58
|
+
|
|
59
|
+
console.log(
|
|
60
|
+
kleur.green(
|
|
61
|
+
` ✓ appended ${missing.length} env var(s) to .env.example`,
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
for (const e of missing) {
|
|
65
|
+
console.log(
|
|
66
|
+
kleur.dim(
|
|
67
|
+
` + ${prefixName(e)}${e.required ? "" : " (optional)"}`,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── helpers ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function prefixName(e) {
|
|
76
|
+
// `scope: "next-public"` means the consumer reads it via `process.env` on
|
|
77
|
+
// the client, so it needs the NEXT_PUBLIC_ prefix to be exposed.
|
|
78
|
+
return e.scope === "next-public" && !e.name.startsWith("NEXT_PUBLIC_")
|
|
79
|
+
? `NEXT_PUBLIC_${e.name}`
|
|
80
|
+
: e.name;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseExistingNames(text) {
|
|
84
|
+
const set = new Set();
|
|
85
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
86
|
+
const line = rawLine.trim();
|
|
87
|
+
if (!line || line.startsWith("#")) continue;
|
|
88
|
+
const eq = line.indexOf("=");
|
|
89
|
+
if (eq <= 0) continue;
|
|
90
|
+
// Strip an optional `export ` prefix.
|
|
91
|
+
const lhs = line.slice(0, eq).replace(/^export\s+/, "").trim();
|
|
92
|
+
if (lhs) set.add(lhs);
|
|
93
|
+
}
|
|
94
|
+
return set;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderBlock(slug, entries) {
|
|
98
|
+
const lines = [];
|
|
99
|
+
lines.push("");
|
|
100
|
+
lines.push(`# ─── ${slug} ───`);
|
|
101
|
+
for (const e of entries) {
|
|
102
|
+
const name = prefixName(e);
|
|
103
|
+
const tag =
|
|
104
|
+
e.required === false ? "optional" : e.required ? "required" : null;
|
|
105
|
+
const meta = [e.scope, tag].filter(Boolean).join(", ");
|
|
106
|
+
if (e.description) {
|
|
107
|
+
lines.push(`# ${e.description}${meta ? ` (${meta})` : ""}`);
|
|
108
|
+
} else if (meta) {
|
|
109
|
+
lines.push(`# ${meta}`);
|
|
110
|
+
}
|
|
111
|
+
// Secrets get an empty placeholder — never a fake value.
|
|
112
|
+
lines.push(`${name}=`);
|
|
113
|
+
}
|
|
114
|
+
lines.push("");
|
|
115
|
+
return lines.join("\n");
|
|
116
|
+
}
|