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.
- package/CHANGELOG.md +97 -0
- package/bin/rcf.js +6 -0
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/harness-template.md +11 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +11 -0
- package/package.json +5 -3
- package/rcf/adrs/adr-010.json +30 -0
- package/rcf/code-nodes/cn-058.json +18 -0
- package/rcf/code-nodes/cn-059.json +14 -0
- package/rcf/code-nodes/cn-060.json +14 -0
- package/rcf/code-nodes/cn-061.json +14 -0
- package/rcf/code-nodes/cn-062.json +14 -0
- package/rcf/code-nodes/cn-063.json +15 -0
- package/rcf/code-nodes/cn-064.json +15 -0
- package/rcf/code-nodes/cn-065.json +16 -0
- package/rcf/code-nodes/cn-066.json +14 -0
- package/rcf/code-nodes/cn-067.json +15 -0
- package/rcf/code-nodes/cn-068.json +15 -0
- package/rcf/code-nodes/cn-069.json +16 -0
- package/rcf/fbs/fbs-016.json +39 -0
- package/rcf/fbs/fbs-017.json +40 -0
- package/rcf/fbs/fbs-018.json +34 -0
- package/rcf/fbs/fbs-019.json +33 -0
- package/rcf/requirements/req-010.json +20 -0
- package/rcf/test-suites/ts-026.json +54 -0
- package/rcf/test-suites/ts-027.json +115 -0
- package/rcf/test-suites/ts-028.json +46 -0
- package/rcf/test-suites/ts-029.json +46 -0
- package/rcf/user-stories/us-1001.json +56 -0
- package/rcf/user-stories/us-1002.json +96 -0
- package/rcf/user-stories/us-1003.json +48 -0
- package/rcf/user-stories/us-1004.json +48 -0
- package/src/admissibility/enforce.js +142 -0
- package/src/admissibility/index.js +8 -0
- package/src/admissibility/markers.js +104 -0
- package/src/admissibility/scope-lint.js +163 -0
- package/src/blueprint/apply.js +464 -0
- package/src/blueprint/conflicts.js +351 -0
- package/src/blueprint/diff.js +82 -0
- package/src/blueprint/index.js +12 -0
- package/src/blueprint/list.js +21 -0
- package/src/blueprint/loader.js +163 -0
- package/src/blueprint/manifest-writer.js +49 -0
- package/src/blueprint/namespace.js +145 -0
- package/src/blueprint/remove.js +105 -0
- package/src/blueprint/resolutions.js +83 -0
- package/src/blueprint/standards.js +148 -0
- package/src/blueprint/supersede.js +318 -0
- package/src/browser-verify/invariants.js +33 -6
- package/src/build/bundle.js +34 -11
- package/src/build/standards-selector.js +52 -0
- package/src/cli/blueprint.js +325 -0
- package/src/cli/create.js +49 -1
- package/src/cli/help.js +8 -0
- package/src/cli/init.js +20 -5
- package/src/cli/read.js +7 -1
- package/src/cli/standards.js +127 -0
- package/src/cli/test-suite.js +7 -2
- package/src/core/store/ids.js +168 -18
- package/src/core/store/loader.js +31 -17
- package/src/core/store/walker.js +62 -4
- package/src/core/store/writer.js +41 -11
- package/src/deployment/index.js +13 -0
- package/src/deployment/placeholder-detector.js +113 -0
- package/src/finalise/detect.js +51 -29
- package/src/finalise/index.js +16 -2
- package/src/finalise/ingest.js +41 -0
- package/src/mcp/tools.js +10 -2
- package/src/query/formatters/table.js +7 -10
- package/src/query/index.js +4 -0
- package/src/query/refuse-on-admissibility.js +73 -0
- package/src/query/trace.js +45 -4
- package/src/ruleset/index.js +140 -0
- package/src/ruleset/ruleset.json +146 -0
- package/src/verify/chain/index.js +31 -0
- 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
|
+
}
|