rcf-lite 0.7.1 → 0.9.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/bin/rcf.js +6 -0
  3. package/fixtures/canary-manifest.json +9 -9
  4. package/guidance/harness-template.md +11 -0
  5. package/guidance/managed/agent-instructions-block.hash +1 -1
  6. package/guidance/managed/agent-instructions-block.md +11 -0
  7. package/package.json +5 -3
  8. package/rcf/adrs/adr-010.json +30 -0
  9. package/rcf/code-nodes/cn-058.json +18 -0
  10. package/rcf/code-nodes/cn-059.json +14 -0
  11. package/rcf/code-nodes/cn-060.json +14 -0
  12. package/rcf/code-nodes/cn-061.json +14 -0
  13. package/rcf/code-nodes/cn-062.json +14 -0
  14. package/rcf/code-nodes/cn-063.json +15 -0
  15. package/rcf/code-nodes/cn-064.json +15 -0
  16. package/rcf/code-nodes/cn-065.json +16 -0
  17. package/rcf/code-nodes/cn-066.json +14 -0
  18. package/rcf/code-nodes/cn-067.json +15 -0
  19. package/rcf/code-nodes/cn-068.json +15 -0
  20. package/rcf/code-nodes/cn-069.json +16 -0
  21. package/rcf/fbs/fbs-016.json +39 -0
  22. package/rcf/fbs/fbs-017.json +40 -0
  23. package/rcf/fbs/fbs-018.json +34 -0
  24. package/rcf/fbs/fbs-019.json +33 -0
  25. package/rcf/requirements/req-010.json +20 -0
  26. package/rcf/test-suites/ts-026.json +54 -0
  27. package/rcf/test-suites/ts-027.json +115 -0
  28. package/rcf/test-suites/ts-028.json +46 -0
  29. package/rcf/test-suites/ts-029.json +46 -0
  30. package/rcf/user-stories/us-1001.json +56 -0
  31. package/rcf/user-stories/us-1002.json +96 -0
  32. package/rcf/user-stories/us-1003.json +48 -0
  33. package/rcf/user-stories/us-1004.json +48 -0
  34. package/src/admissibility/enforce.js +142 -0
  35. package/src/admissibility/index.js +8 -0
  36. package/src/admissibility/markers.js +104 -0
  37. package/src/admissibility/scope-lint.js +163 -0
  38. package/src/blueprint/apply.js +464 -0
  39. package/src/blueprint/conflicts.js +351 -0
  40. package/src/blueprint/diff.js +82 -0
  41. package/src/blueprint/index.js +12 -0
  42. package/src/blueprint/list.js +21 -0
  43. package/src/blueprint/loader.js +163 -0
  44. package/src/blueprint/manifest-writer.js +49 -0
  45. package/src/blueprint/namespace.js +145 -0
  46. package/src/blueprint/remove.js +105 -0
  47. package/src/blueprint/resolutions.js +83 -0
  48. package/src/blueprint/standards.js +148 -0
  49. package/src/blueprint/supersede.js +318 -0
  50. package/src/browser-verify/invariants.js +33 -6
  51. package/src/build/bundle.js +34 -11
  52. package/src/build/standards-selector.js +52 -0
  53. package/src/cli/blueprint.js +325 -0
  54. package/src/cli/create.js +49 -1
  55. package/src/cli/help.js +8 -0
  56. package/src/cli/init.js +20 -5
  57. package/src/cli/read.js +7 -1
  58. package/src/cli/standards.js +127 -0
  59. package/src/cli/test-suite.js +7 -2
  60. package/src/core/store/ids.js +168 -18
  61. package/src/core/store/loader.js +31 -17
  62. package/src/core/store/walker.js +62 -4
  63. package/src/core/store/writer.js +41 -11
  64. package/src/deployment/index.js +13 -0
  65. package/src/deployment/placeholder-detector.js +113 -0
  66. package/src/finalise/detect.js +51 -29
  67. package/src/finalise/index.js +16 -2
  68. package/src/finalise/ingest.js +41 -0
  69. package/src/mcp/tools.js +10 -2
  70. package/src/query/formatters/table.js +7 -10
  71. package/src/query/index.js +4 -0
  72. package/src/query/refuse-on-admissibility.js +73 -0
  73. package/src/query/trace.js +45 -4
  74. package/src/ruleset/index.js +140 -0
  75. package/src/ruleset/ruleset.json +146 -0
  76. package/src/verify/chain/index.js +31 -0
  77. package/src/verify/verdict/index.js +67 -0
@@ -0,0 +1,351 @@
1
+ // Blueprint conflict detection at apply time.
2
+ //
3
+ // Two conflict classes:
4
+ //
5
+ // - `globalAdrTopic`: two applied blueprints both contributing a
6
+ // scope:global ADR on the same topic. The Phase 1 design brief
7
+ // named this class explicitly. From 0.4.5 the detector consults
8
+ // `manifest.resolutions[]` and skips a would-be conflict when a
9
+ // resolution matches the topic + both {slug, adrId} pairs; the two
10
+ // blueprint ADRs then co-reside on disk as superseded history
11
+ // alongside the project-level ADR that supersedes them.
12
+ //
13
+ // - `crossBlueprintOwnership`: an incoming contribution declares an
14
+ // id that another currently-applied blueprint already owns in its
15
+ // manifest record. The manifest's appliedBlueprintRecord.contributions[]
16
+ // list is the authoritative ownership record; two blueprints cannot
17
+ // both claim the same id. This is the ambiguity-class check that
18
+ // used to live inside `isNamespacedFor` grammar (blueprint `spa`
19
+ // silently claiming `ADR-005-spa-theme` because the string
20
+ // startsWith `spa-`). Grammar is no longer a trust surface here --
21
+ // the manifest is.
22
+ //
23
+ // Every other class the design brief names (non-global TACs/ADRs/REQs/USs
24
+ // /FBSes, standards-value conflicts, manifest overlap) is either
25
+ // namespaced away or deferred to Phase 3 iteration per the design
26
+ // brief's prototype-unknown #1.
27
+ //
28
+ // Pure functions. Read the applied blueprints' contributions (as stored
29
+ // in `manifest.blueprints[]`) plus the incoming blueprint's contribution
30
+ // list, and return zero-or-more Conflict records. No I/O.
31
+
32
+ import { matchingResolution } from './resolutions.js';
33
+
34
+ /**
35
+ * @typedef {(
36
+ * { kind: 'globalAdrTopic',
37
+ * topic: string,
38
+ * incoming: { slug: string, id: string, path: string, title?: string, decision?: string, source?: string },
39
+ * existing: { slug: string, id: string, path: string, title?: string, decision?: string } }
40
+ * |
41
+ * { kind: 'crossBlueprintOwnership',
42
+ * id: string,
43
+ * incoming: { slug: string, path: string },
44
+ * existing: { slug: string, path: string } }
45
+ * )} Conflict
46
+ *
47
+ * `incoming.source` on the globalAdrTopic shape is the CLI-supplied
48
+ * source label the operator typed on `rcf blueprint add SRC`; the
49
+ * renderer inlines it into option 3 (`rcf blueprint supersede <topic>
50
+ * --incoming <source>`) so the printed command is copy-paste-runnable
51
+ * verbatim from the refused-add state. Absent when the caller did not
52
+ * thread a source label (unit-test paths).
53
+ */
54
+
55
+ /**
56
+ * scope:global ADR topic conflicts across two applied blueprints.
57
+ *
58
+ * Optional `manifest` argument: when supplied and the manifest carries a
59
+ * matching entry in `manifest.resolutions[]`, the conflict is honoured
60
+ * (dropped from the returned list). Callers that want the raw shape
61
+ * (`rcf blueprint diff <topic>` needs to inspect both ADRs even when a
62
+ * resolution exists) can pass `manifest: null` to bypass the honour step.
63
+ *
64
+ * @param {Array<{
65
+ * slug: string,
66
+ * contributions?: Array<{ id: string, kind: string, path: string, scope?: string, topic?: string }>
67
+ * }>} appliedBlueprints
68
+ * @param {{
69
+ * slug: string,
70
+ * contributions?: Array<{ id: string, kind: string, path: string, scope?: string, topic?: string }>
71
+ * }} incoming
72
+ * @param {object|null} [manifest] - optional manifest for resolutions[] lookup
73
+ * @returns {Conflict[]}
74
+ */
75
+ export function detectGlobalAdrConflicts(appliedBlueprints, incoming, manifest = null) {
76
+ const conflicts = [];
77
+ const incomingGlobals = (incoming.contributions ?? [])
78
+ .filter((c) => c.kind === 'adr' && c.scope === 'global' && typeof c.topic === 'string');
79
+ if (incomingGlobals.length === 0) return conflicts;
80
+
81
+ for (const applied of appliedBlueprints ?? []) {
82
+ if (applied.slug === incoming.slug) continue; // re-apply of the same blueprint is not a conflict
83
+ for (const existing of applied.contributions ?? []) {
84
+ if (existing.kind !== 'adr' || existing.scope !== 'global' || typeof existing.topic !== 'string') continue;
85
+ for (const incomingAdr of incomingGlobals) {
86
+ if (existing.topic !== incomingAdr.topic) continue;
87
+ // Honour a matching resolution if the caller passed a manifest.
88
+ // The resolution list is small (one entry per resolved topic
89
+ // per project) so the linear scan inside matchingResolution is
90
+ // fine at this scale.
91
+ if (manifest !== null) {
92
+ const resolved = matchingResolution(manifest, {
93
+ topic: existing.topic,
94
+ incoming: { slug: incoming.slug, adrId: incomingAdr.id },
95
+ existing: { slug: applied.slug, adrId: existing.id },
96
+ });
97
+ if (resolved) continue;
98
+ }
99
+ conflicts.push({
100
+ kind: 'globalAdrTopic',
101
+ topic: existing.topic,
102
+ incoming: { slug: incoming.slug, id: incomingAdr.id, path: incomingAdr.path },
103
+ existing: { slug: applied.slug, id: existing.id, path: existing.path },
104
+ });
105
+ }
106
+ }
107
+ }
108
+ return conflicts;
109
+ }
110
+
111
+ /**
112
+ * Cross-blueprint ownership conflicts: an incoming contribution id is
113
+ * already recorded as owned by a DIFFERENT applied blueprint. This
114
+ * detector is what makes `spa` vs `spa-theme` cross-claim impossible
115
+ * once one of the two has applied -- the second-mover's incoming id
116
+ * hits the first-mover's manifest record and is refused, regardless
117
+ * of how the id string parses.
118
+ *
119
+ * Re-apply of the same slug (`applied.slug === incoming.slug`) is
120
+ * skipped: same blueprint owning its own ids across re-apply is the
121
+ * intended idempotent case.
122
+ *
123
+ * @param {Array<{
124
+ * slug: string,
125
+ * contributions?: Array<{ id: string, kind: string, path: string }>
126
+ * }>} appliedBlueprints
127
+ * @param {{
128
+ * slug: string,
129
+ * contributions?: Array<{ id: string, kind: string, path: string }>
130
+ * }} incoming
131
+ * @returns {Conflict[]}
132
+ */
133
+ export function detectCrossBlueprintClaims(appliedBlueprints, incoming) {
134
+ const conflicts = [];
135
+ const incomingList = incoming.contributions ?? [];
136
+ if (incomingList.length === 0) return conflicts;
137
+
138
+ // Index applied contributions by id -> { slug, path } for a single-
139
+ // pass scan. Skip the incoming blueprint's own previously-recorded
140
+ // entries (that is the re-apply path, not a claim).
141
+ const owned = new Map(); // id -> { slug, path }
142
+ for (const applied of appliedBlueprints ?? []) {
143
+ if (applied.slug === incoming.slug) continue;
144
+ for (const c of applied.contributions ?? []) {
145
+ if (!owned.has(c.id)) owned.set(c.id, { slug: applied.slug, path: c.path });
146
+ }
147
+ }
148
+ if (owned.size === 0) return conflicts;
149
+
150
+ for (const incomingC of incomingList) {
151
+ const priorOwner = owned.get(incomingC.id);
152
+ if (!priorOwner) continue;
153
+ conflicts.push({
154
+ kind: 'crossBlueprintOwnership',
155
+ id: incomingC.id,
156
+ incoming: { slug: incoming.slug, path: incomingC.path },
157
+ existing: { slug: priorOwner.slug, path: priorOwner.path },
158
+ });
159
+ }
160
+ return conflicts;
161
+ }
162
+
163
+ /**
164
+ * Render a conflict report for operator eyes. Reshaped in 0.4.5:
165
+ *
166
+ * - Per-conflict block LEADS with the ADR titles and a one-sentence
167
+ * decision each (topic string in parens), ids and paths as a
168
+ * footer. If titles / decisions are not available (the detector
169
+ * was not given tree access), the block falls back to the id +
170
+ * path pair as the header.
171
+ * - Resolution list carries only implemented options with actual
172
+ * blueprint slugs filled in (no more placeholder `<slug>` prose).
173
+ * A follow-up superset-add reintroduces the conflict; the operator
174
+ * is not offered rename ceremonies for that.
175
+ *
176
+ * The old three-line resolutions blurb ("run `rcf blueprint remove
177
+ * <slug>` on the incoming side's slug if it is already partially in-
178
+ * flight, then leave things as they are") is gone -- neither `<slug>`
179
+ * placeholder was actually implementable end-to-end, and the operator
180
+ * has been reading past it since Phase 1.
181
+ *
182
+ * @param {Conflict[]} conflicts
183
+ * @returns {string}
184
+ */
185
+ export function renderConflictReport(conflicts) {
186
+ if (conflicts.length === 0) return '';
187
+ const lines = [];
188
+ lines.push(`[rcf] blueprint add refused: ${conflicts.length} conflict(s) detected.`);
189
+ lines.push('');
190
+ for (let i = 0; i < conflicts.length; i += 1) {
191
+ const c = conflicts[i];
192
+ if (i > 0) lines.push('');
193
+ if (c.kind === 'globalAdrTopic') {
194
+ // Header: title + decision when available, id + path otherwise.
195
+ const incomingHeader = renderAdrHeader(c.incoming);
196
+ const existingHeader = renderAdrHeader(c.existing);
197
+ lines.push(`conflict on topic (${c.topic}):`);
198
+ lines.push(` incoming blueprint ${c.incoming.slug}: ${incomingHeader}`);
199
+ lines.push(` existing blueprint ${c.existing.slug}: ${existingHeader}`);
200
+ lines.push(` refs: ${c.incoming.id} at ${c.incoming.path}`);
201
+ lines.push(` ${c.existing.id} at ${c.existing.path}`);
202
+ lines.push('');
203
+ // Round-3 (Baz ruling): option 3 must be executable VERBATIM
204
+ // from this refused-add state. supersede accepts `--incoming
205
+ // <source>` and carries the SAME source path the operator just
206
+ // typed on `rcf blueprint add SRC`, so the printed command is
207
+ // copy-paste-runnable. Fall back to `<source>` only when the
208
+ // caller did not thread the source label onto the conflict
209
+ // (unit-test paths that call renderConflictReport directly).
210
+ const sourceLabel = typeof c.incoming.source === 'string' && c.incoming.source.length > 0
211
+ ? c.incoming.source
212
+ : '<source>';
213
+ lines.push(' resolutions (pick one, honest options only):');
214
+ lines.push(` 1. Adopt the incoming blueprint. Run:`);
215
+ lines.push(` rcf blueprint remove ${c.existing.slug}`);
216
+ lines.push(` then re-run \`rcf blueprint add ${sourceLabel}\`.`);
217
+ lines.push(` 2. Keep the existing blueprint. Do not add ${c.incoming.slug} on this project.`);
218
+ lines.push(` 3. Author a project-level ADR that supersedes both. Run:`);
219
+ lines.push(` rcf blueprint supersede ${c.topic} --incoming ${sourceLabel}`);
220
+ lines.push(` which scaffolds the project ADR (both blueprint ADRs listed as superseded)`);
221
+ lines.push(` and registers the resolution in the manifest, then re-run \`rcf blueprint add ${sourceLabel}\`.`);
222
+ lines.push(` 4. Declare the resolution on the add itself:`);
223
+ lines.push(` rcf blueprint add ${sourceLabel} --resolve ${c.topic}=project:<ADR-id>`);
224
+ lines.push(` which records the resolution and skips the remove/re-add ceremony.`);
225
+ } else if (c.kind === 'crossBlueprintOwnership') {
226
+ lines.push(`conflict on id ${c.id}:`);
227
+ lines.push(` incoming blueprint ${c.incoming.slug} declares ${c.id} at ${c.incoming.path}`);
228
+ lines.push(` existing blueprint ${c.existing.slug} already owns ${c.id} at ${c.existing.path}`);
229
+ lines.push('');
230
+ lines.push(' resolutions (pick one, honest options only):');
231
+ lines.push(` 1. Adopt the incoming blueprint. Run:`);
232
+ lines.push(` rcf blueprint remove ${c.existing.slug}`);
233
+ lines.push(` then re-run the incoming add.`);
234
+ lines.push(` 2. Keep the existing blueprint. Do not add ${c.incoming.slug} on this project.`);
235
+ lines.push(` 3. Fix the incoming blueprint's contribution ids (author-side change);`);
236
+ lines.push(` cross-blueprint id claims cannot be resolved by a manifest ruling.`);
237
+ }
238
+ }
239
+ lines.push('');
240
+ return `${lines.join('\n')}\n`;
241
+ }
242
+
243
+ /**
244
+ * Render a conflict report as a JSON object for agent-driven
245
+ * composition. Shape:
246
+ *
247
+ * ```
248
+ * {
249
+ * "refused": true,
250
+ * "conflictCount": N,
251
+ * "conflicts": [
252
+ * { kind: 'globalAdrTopic', topic, incoming: {slug,id,path,title?,decision?}, existing: {...}, resolutions: [...] },
253
+ * { kind: 'crossBlueprintOwnership', id, incoming, existing, resolutions: [...] }
254
+ * ]
255
+ * }
256
+ * ```
257
+ *
258
+ * The resolutions list on each conflict is the machine-readable
259
+ * counterpart to the prose in renderConflictReport: `{ id, description,
260
+ * commands }` triples, no placeholders.
261
+ *
262
+ * @param {Conflict[]} conflicts
263
+ * @returns {object}
264
+ */
265
+ export function conflictReportJson(conflicts) {
266
+ return {
267
+ refused: conflicts.length > 0,
268
+ conflictCount: conflicts.length,
269
+ conflicts: conflicts.map((c) => {
270
+ if (c.kind === 'globalAdrTopic') {
271
+ const sourceLabel = typeof c.incoming.source === 'string' && c.incoming.source.length > 0
272
+ ? c.incoming.source
273
+ : '<source>';
274
+ return {
275
+ kind: c.kind,
276
+ topic: c.topic,
277
+ incoming: pruneAdrRef(c.incoming),
278
+ existing: pruneAdrRef(c.existing),
279
+ resolutions: [
280
+ {
281
+ id: 'adoptIncoming',
282
+ description: `Adopt the incoming blueprint (${c.incoming.slug}); remove the existing (${c.existing.slug}).`,
283
+ commands: [`rcf blueprint remove ${c.existing.slug}`, `rcf blueprint add ${sourceLabel}`],
284
+ },
285
+ {
286
+ id: 'keepExisting',
287
+ description: `Keep the existing blueprint (${c.existing.slug}); do not add ${c.incoming.slug}.`,
288
+ commands: [],
289
+ },
290
+ {
291
+ id: 'supersede',
292
+ description: `Author a project-level ADR that supersedes both blueprint ADRs on topic '${c.topic}'.`,
293
+ commands: [`rcf blueprint supersede ${c.topic} --incoming ${sourceLabel}`, `rcf blueprint add ${sourceLabel}`],
294
+ },
295
+ {
296
+ id: 'declareOnAdd',
297
+ description: `Declare the resolution on the incoming add itself.`,
298
+ commands: [`rcf blueprint add ${sourceLabel} --resolve ${c.topic}=project:<ADR-id>`],
299
+ },
300
+ ],
301
+ };
302
+ }
303
+ // crossBlueprintOwnership
304
+ return {
305
+ kind: c.kind,
306
+ id: c.id,
307
+ incoming: c.incoming,
308
+ existing: c.existing,
309
+ resolutions: [
310
+ {
311
+ id: 'adoptIncoming',
312
+ description: `Adopt the incoming blueprint (${c.incoming.slug}); remove the existing (${c.existing.slug}).`,
313
+ commands: [`rcf blueprint remove ${c.existing.slug}`],
314
+ },
315
+ {
316
+ id: 'keepExisting',
317
+ description: `Keep the existing blueprint (${c.existing.slug}); do not add ${c.incoming.slug}.`,
318
+ commands: [],
319
+ },
320
+ {
321
+ id: 'fixIncomingIds',
322
+ description: `Fix the incoming blueprint's contribution ids at authoring; cross-blueprint id claims cannot be resolved by a manifest ruling.`,
323
+ commands: [],
324
+ },
325
+ ],
326
+ };
327
+ }),
328
+ };
329
+ }
330
+
331
+ function renderAdrHeader(side) {
332
+ if (typeof side.title === 'string' && side.title.length > 0) {
333
+ if (typeof side.decision === 'string' && side.decision.length > 0) {
334
+ return `${side.title} — ${side.decision}`;
335
+ }
336
+ return side.title;
337
+ }
338
+ return `${side.id} at ${side.path}`;
339
+ }
340
+
341
+ function pruneAdrRef(side) {
342
+ const out = { slug: side.slug, id: side.id, path: side.path };
343
+ if (typeof side.title === 'string' && side.title.length > 0) out.title = side.title;
344
+ if (typeof side.decision === 'string' && side.decision.length > 0) out.decision = side.decision;
345
+ // Round-3: the incoming side may also carry `source` (the CLI arg
346
+ // to `rcf blueprint add SRC`); surface it on --json so agent-driven
347
+ // composition has the same copy-paste-runnable command the terminal
348
+ // renderer produces.
349
+ if (typeof side.source === 'string' && side.source.length > 0) out.source = side.source;
350
+ return out;
351
+ }
@@ -0,0 +1,82 @@
1
+ // `rcf blueprint diff <topic>` implementation.
2
+ //
3
+ // Side-by-side view of every applied blueprint's scope:global ADR on a
4
+ // given topic. Reads titles + decisions off the tree's byId map (the
5
+ // walker already parsed and validated the ADR bodies) and returns
6
+ // them in a shape a CLI renderer can format into two columns.
7
+ //
8
+ // This verb is read-only. It does not run the conflict detector -- an
9
+ // operator staring at a diff is usually deciding how to resolve the
10
+ // conflict, not asking whether one exists.
11
+
12
+ /**
13
+ * @typedef {object} DiffAdrEntry
14
+ * @property {string} slug blueprint slug
15
+ * @property {string} adrId ADR id inside that blueprint's contribution set
16
+ * @property {string} path rcf-relative path to the ADR file
17
+ * @property {string} [title] ADR title (when the tree carries the loaded doc)
18
+ * @property {string} [decision] ADR decision (ditto)
19
+ * @property {string} [context] ADR context (ditto)
20
+ * @property {string} [status] ADR status (ditto)
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} DiffResult
25
+ * @property {string} topic
26
+ * @property {DiffAdrEntry[]} entries two or more when a conflict exists
27
+ */
28
+
29
+ /**
30
+ * @param {object} args
31
+ * @param {import('#core/store/walker.js').TreeModel} args.tree
32
+ * @param {string} args.topic
33
+ * @returns {DiffResult}
34
+ */
35
+ export function diffBlueprintTopic({ tree, topic }) {
36
+ const entries = [];
37
+ const applied = Array.isArray(tree.manifest?.blueprints) ? tree.manifest.blueprints : [];
38
+ for (const bp of applied) {
39
+ for (const c of bp.contributions ?? []) {
40
+ if (c.kind !== 'adr' || c.scope !== 'global' || c.topic !== topic) continue;
41
+ const entry = { slug: bp.slug, adrId: c.id, path: c.path };
42
+ const doc = tree.byId?.get(c.id);
43
+ if (doc && typeof doc === 'object') {
44
+ if (typeof doc.title === 'string') entry.title = doc.title;
45
+ if (typeof doc.decision === 'string') entry.decision = doc.decision;
46
+ if (typeof doc.context === 'string') entry.context = doc.context;
47
+ if (typeof doc.status === 'string') entry.status = doc.status;
48
+ }
49
+ entries.push(entry);
50
+ }
51
+ }
52
+ return { topic, entries };
53
+ }
54
+
55
+ /**
56
+ * Render a DiffResult as an operator-readable two-column-ish block.
57
+ * The renderer intentionally stays plain-text and terminal-friendly
58
+ * (no ANSI, no fancy box-drawing) so it composes with piped output.
59
+ *
60
+ * @param {DiffResult} diff
61
+ * @returns {string}
62
+ */
63
+ export function renderDiff(diff) {
64
+ const lines = [];
65
+ lines.push(`[rcf] blueprint diff on topic (${diff.topic}): ${diff.entries.length} scope:global ADR(s).`);
66
+ if (diff.entries.length === 0) {
67
+ lines.push('');
68
+ lines.push(` no applied blueprint carries a scope:global ADR on '${diff.topic}'.`);
69
+ return `${lines.join('\n')}\n`;
70
+ }
71
+ for (let i = 0; i < diff.entries.length; i += 1) {
72
+ const e = diff.entries[i];
73
+ lines.push('');
74
+ lines.push(` [${i + 1}] blueprint ${e.slug}`);
75
+ lines.push(` id: ${e.adrId}`);
76
+ lines.push(` path: ${e.path}`);
77
+ if (e.title) lines.push(` title: ${e.title}`);
78
+ if (e.status) lines.push(` status: ${e.status}`);
79
+ if (e.decision) lines.push(` decision: ${e.decision}`);
80
+ }
81
+ return `${lines.join('\n')}\n`;
82
+ }
@@ -0,0 +1,12 @@
1
+ // Public surface for the blueprint mechanism.
2
+
3
+ export { applyBlueprint } from './apply.js';
4
+ export { listBlueprints } from './list.js';
5
+ export { removeBlueprint } from './remove.js';
6
+ export { loadBlueprint } from './loader.js';
7
+ export { detectGlobalAdrConflicts, detectCrossBlueprintClaims, renderConflictReport, conflictReportJson } from './conflicts.js';
8
+ export { stampId, parseIdParts, namespaceStyleFor, isNamespacedFor } from './namespace.js';
9
+ export { registerStandardsPack, listStandards } from './standards.js';
10
+ export { nextResolutionId, matchingResolution } from './resolutions.js';
11
+ export { supersedeBlueprintTopic } from './supersede.js';
12
+ export { diffBlueprintTopic, renderDiff } from './diff.js';
@@ -0,0 +1,21 @@
1
+ // Blueprint list. Reads manifest.blueprints[] and returns rows in
2
+ // appliedAt order. Rows are already validated by the schema; this
3
+ // module is a projection.
4
+
5
+ /**
6
+ * @param {import('#core/store/walker.js').TreeModel} tree
7
+ * @returns {Array<{ slug: string, version: string, appliedAt: string, source: string, namespace: string | null, contributionCount: number }>}
8
+ */
9
+ export function listBlueprints(tree) {
10
+ const list = tree.manifest?.blueprints ?? [];
11
+ return [...list]
12
+ .sort((a, b) => String(a.appliedAt).localeCompare(String(b.appliedAt)))
13
+ .map((b) => ({
14
+ slug: b.slug,
15
+ version: b.version,
16
+ appliedAt: b.appliedAt,
17
+ source: b.source,
18
+ namespace: b.namespace ?? null,
19
+ contributionCount: Array.isArray(b.contributions) ? b.contributions.length : 0,
20
+ }));
21
+ }
@@ -0,0 +1,163 @@
1
+ // Blueprint loader. Phase 1 keeps the loader local: a blueprint source
2
+ // is a filesystem directory containing a `blueprint.json` metadata file
3
+ // plus a `contributions/` subdirectory. The metadata declares the
4
+ // blueprint's slug, version, and every contribution's { id, kind, path
5
+ // (relative to contributions/) }. ADR contributions may carry a
6
+ // `scope: 'global'` tag plus a `topic` string; these are the only
7
+ // contributions the conflict detector reasons about.
8
+ //
9
+ // The registry / git-ref resolver is a Phase 2 concern (SPA and REST
10
+ // blueprints ship as npm packages); this loader intentionally has no
11
+ // network path so tests are hermetic.
12
+
13
+ import { readFile, stat } from 'node:fs/promises';
14
+ import { isAbsolute, join, resolve } from 'node:path';
15
+
16
+ import { rcfError } from '../core/errors/index.js';
17
+
18
+ // Contribution kinds a blueprint MAY carry. The RCF hierarchy is composed
19
+ // downward: a blueprint contributes REQuirements, UserStories, TACs and
20
+ // ADRs (plus their supporting FBS / TS / CN artefacts written by later
21
+ // phases). PRDs, TADs and the Build Sequence are project-level singletons
22
+ // -- one PRD per project, one TAD per project, one BS per project -- so
23
+ // no blueprint gets to own them. FBS is excluded by ratified principle
24
+ // (composition happens at the requirements layer, not the build layer).
25
+ const CONTRIBUTABLE_KINDS = new Set(['req', 'us', 'tac', 'adr', 'ts', 'cn']);
26
+ const ROOT_SINGLETON_KINDS = new Set(['prd', 'tad', 'bs']);
27
+ const EXCLUDED_KINDS = new Set(['fbs']);
28
+
29
+ /**
30
+ * @typedef {object} BlueprintContribution
31
+ * @property {string} id canonical id (bare or already namespaced)
32
+ * @property {'prd'|'req'|'us'|'tad'|'tac'|'adr'|'bs'|'fbs'|'ts'|'cn'} kind
33
+ * @property {string} path relative to the blueprint's contributions/
34
+ * @property {'global'} [scope] ADR only; marks whole-project decisions
35
+ * @property {string} [topic] ADR only when scope=global; conflict key
36
+ */
37
+
38
+ /**
39
+ * @typedef {object} LoadedBlueprint
40
+ * @property {string} slug
41
+ * @property {string} version
42
+ * @property {string} source source directory (absolute)
43
+ * @property {BlueprintContribution[]} contributions
44
+ */
45
+
46
+ /**
47
+ * Load a blueprint's metadata from a directory containing blueprint.json.
48
+ *
49
+ * @param {string} source - path to the blueprint's root directory
50
+ * @returns {Promise<LoadedBlueprint | import('../core/errors/index.js').RcfError>}
51
+ */
52
+ export async function loadBlueprint(source) {
53
+ const root = resolve(source);
54
+ const metaPath = join(root, 'blueprint.json');
55
+ try {
56
+ await stat(metaPath);
57
+ } catch (err) {
58
+ if (err.code === 'ENOENT') {
59
+ return rcfError({
60
+ kind: 'usage',
61
+ message: `blueprint: no blueprint.json found at ${metaPath}`,
62
+ filePath: metaPath,
63
+ });
64
+ }
65
+ return rcfError({ kind: 'ioFailure', message: `blueprint: ${err.message}`, filePath: metaPath });
66
+ }
67
+ let raw;
68
+ try {
69
+ raw = await readFile(metaPath, 'utf8');
70
+ } catch (err) {
71
+ return rcfError({ kind: 'ioFailure', message: `blueprint: read failed: ${err.message}`, filePath: metaPath });
72
+ }
73
+ let doc;
74
+ try {
75
+ doc = JSON.parse(raw);
76
+ } catch (err) {
77
+ return rcfError({ kind: 'parseFailure', message: `blueprint: JSON parse failed: ${err.message}`, filePath: metaPath });
78
+ }
79
+ const validation = validateMetadata(doc, metaPath);
80
+ if (validation) return validation;
81
+ return {
82
+ slug: doc.slug,
83
+ version: doc.version,
84
+ source: root,
85
+ contributions: Array.isArray(doc.contributions) ? doc.contributions : [],
86
+ };
87
+ }
88
+
89
+ function validateMetadata(doc, metaPath) {
90
+ if (typeof doc !== 'object' || doc === null) {
91
+ return rcfError({ kind: 'validation', message: 'blueprint.json must be a JSON object', filePath: metaPath });
92
+ }
93
+ if (typeof doc.slug !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(doc.slug)) {
94
+ return rcfError({ kind: 'validation', message: `blueprint.json: slug '${doc.slug}' is not a valid kebab slug`, filePath: metaPath });
95
+ }
96
+ if (typeof doc.version !== 'string' || !/^\d+\.\d+\.\d+$/.test(doc.version)) {
97
+ return rcfError({ kind: 'validation', message: `blueprint.json: version '${doc.version}' is not semver`, filePath: metaPath });
98
+ }
99
+ if (doc.contributions !== undefined && !Array.isArray(doc.contributions)) {
100
+ return rcfError({ kind: 'validation', message: 'blueprint.json: contributions must be an array', filePath: metaPath });
101
+ }
102
+ for (const c of doc.contributions ?? []) {
103
+ if (typeof c.id !== 'string' || typeof c.kind !== 'string' || typeof c.path !== 'string') {
104
+ return rcfError({ kind: 'validation', message: 'blueprint.json: every contribution needs { id, kind, path }', filePath: metaPath });
105
+ }
106
+ // Kind gate. Blueprints compose downward from project singletons;
107
+ // PRD / TAD / BS are per-project artefacts a blueprint never gets
108
+ // to overwrite, and FBS is excluded by ratified principle
109
+ // (composition sits at the requirements layer). This is enforced
110
+ // pre-registry so a mis-authored blueprint fails at load time
111
+ // rather than at the apply-time collision.
112
+ if (ROOT_SINGLETON_KINDS.has(c.kind)) {
113
+ return rcfError({
114
+ kind: 'validation',
115
+ message: `blueprint.json: contribution ${c.id} kind '${c.kind}' is a project singleton and cannot be blueprint-owned`,
116
+ filePath: metaPath,
117
+ });
118
+ }
119
+ if (EXCLUDED_KINDS.has(c.kind)) {
120
+ return rcfError({
121
+ kind: 'validation',
122
+ message: `blueprint.json: contribution ${c.id} kind '${c.kind}' is excluded from blueprint composition by ratified principle (FBS lives at the project's build layer)`,
123
+ filePath: metaPath,
124
+ });
125
+ }
126
+ if (!CONTRIBUTABLE_KINDS.has(c.kind)) {
127
+ return rcfError({
128
+ kind: 'validation',
129
+ message: `blueprint.json: contribution ${c.id} kind '${c.kind}' is not a recognised contributable kind (expected one of: ${[...CONTRIBUTABLE_KINDS].join(', ')})`,
130
+ filePath: metaPath,
131
+ });
132
+ }
133
+ // Path guard. Contribution paths are ALWAYS relative to the
134
+ // blueprint's contributions/ directory. Reject absolute paths and
135
+ // `..` traversal outright; a registry-fetched blueprint that
136
+ // slipped through with an escaping path would land arbitrary
137
+ // bytes wherever the resolved path pointed. Belt-and-braces
138
+ // before Phase 2 fronts this loader with a registry.
139
+ const pathError = validateContributionPath(c.path, c.id);
140
+ if (pathError) return rcfError({ kind: 'validation', message: pathError, filePath: metaPath });
141
+ if (c.scope !== undefined && c.scope !== 'global') {
142
+ return rcfError({ kind: 'validation', message: `blueprint.json: contribution ${c.id} scope must be 'global' when present`, filePath: metaPath });
143
+ }
144
+ if (c.scope === 'global' && typeof c.topic !== 'string') {
145
+ return rcfError({ kind: 'validation', message: `blueprint.json: scope=global contribution ${c.id} requires a topic`, filePath: metaPath });
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function validateContributionPath(p, id) {
152
+ if (typeof p !== 'string' || p.length === 0) {
153
+ return `blueprint.json: contribution ${id} path must be a non-empty string`;
154
+ }
155
+ if (isAbsolute(p) || /^[A-Za-z]:[\\/]/.test(p)) {
156
+ return `blueprint.json: contribution ${id} path '${p}' must be relative (absolute paths are refused)`;
157
+ }
158
+ const segments = p.split(/[\\/]/);
159
+ if (segments.some((s) => s === '..')) {
160
+ return `blueprint.json: contribution ${id} path '${p}' contains a '..' segment (parent-directory traversal is refused)`;
161
+ }
162
+ return null;
163
+ }
@@ -0,0 +1,49 @@
1
+ // Shared atomic writer for the manifest.blueprints[] and
2
+ // manifest.standards[] sections. Modelled on
3
+ // src/ui-baseline/manifest-writer.js (schema-validate the composed
4
+ // manifest, tmp-write + rename).
5
+
6
+ import { mkdir, rename, unlink, writeFile } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ import { rcfError } from '../core/errors/index.js';
10
+ import { validateDocument } from '#core/store';
11
+
12
+ /**
13
+ * Read the manifest from tree, apply `mutate(manifest)`, revalidate,
14
+ * atomically write. Returns the updated manifest or an RcfError.
15
+ *
16
+ * @param {object} args
17
+ * @param {string} args.projectRoot
18
+ * @param {object} args.manifest - the current manifest object
19
+ * @param {(next: object) => void} args.mutate - mutates `next` in place
20
+ * @param {boolean} [args.dryRun]
21
+ * @returns {Promise<{ manifest: object } | import('../core/errors/index.js').RcfError>}
22
+ */
23
+ export async function updateManifest({ projectRoot, manifest, mutate, dryRun = false }) {
24
+ const next = deepClone(manifest ?? {});
25
+ mutate(next);
26
+ const relPath = 'rcf/manifest.json';
27
+ const validation = validateDocument({ doc: next, kind: 'manifest', filePath: relPath });
28
+ if (validation) return validation;
29
+ if (dryRun) return { manifest: next };
30
+ const absPath = join(projectRoot, 'rcf', 'manifest.json');
31
+ try {
32
+ await mkdir(dirname(absPath), { recursive: true });
33
+ const tmp = `${absPath}.tmp`;
34
+ await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
35
+ try {
36
+ await rename(tmp, absPath);
37
+ } catch (err) {
38
+ try { await unlink(tmp); } catch { /* ignore */ }
39
+ throw err;
40
+ }
41
+ } catch (err) {
42
+ return rcfError({ kind: 'ioFailure', message: `manifest write failed: ${err.message}`, filePath: relPath });
43
+ }
44
+ return { manifest: next };
45
+ }
46
+
47
+ function deepClone(x) {
48
+ return JSON.parse(JSON.stringify(x));
49
+ }