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,464 @@
|
|
|
1
|
+
// Blueprint apply. Orchestrates loader + conflict detection + namespaced
|
|
2
|
+
// contribution writes + manifest.blueprints[] append.
|
|
3
|
+
//
|
|
4
|
+
// Idempotency: repeating `apply(tree, source)` on an already-applied
|
|
5
|
+
// slug with no new conflicts is a no-op with a `{ applied: false,
|
|
6
|
+
// alreadyApplied: true }` return. A slug that WOULD conflict on
|
|
7
|
+
// re-apply (new version added a scope:global ADR) returns the conflict
|
|
8
|
+
// list without mutating anything.
|
|
9
|
+
|
|
10
|
+
import { copyFile, mkdir, rename, stat, unlink } from 'node:fs/promises';
|
|
11
|
+
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
import { rcfError } from '../core/errors/index.js';
|
|
16
|
+
import { subdirFor } from '#core/store';
|
|
17
|
+
import { detectCrossBlueprintClaims, detectGlobalAdrConflicts } from './conflicts.js';
|
|
18
|
+
import { loadBlueprint } from './loader.js';
|
|
19
|
+
import { updateManifest } from './manifest-writer.js';
|
|
20
|
+
import { stampId } from './namespace.js';
|
|
21
|
+
import { nextResolutionId } from './resolutions.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {object} ApplyResult
|
|
25
|
+
* @property {boolean} applied
|
|
26
|
+
* @property {boolean} [alreadyApplied]
|
|
27
|
+
* @property {string} slug
|
|
28
|
+
* @property {string} version
|
|
29
|
+
* @property {Array<{ id: string, path: string, kind: string }>} contributions
|
|
30
|
+
* @property {import('./conflicts.js').Conflict[]} [conflicts]
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} args
|
|
35
|
+
* @param {string} args.projectRoot
|
|
36
|
+
* @param {import('#core/store/walker.js').TreeModel} args.tree
|
|
37
|
+
* @param {string} args.source - path to blueprint directory
|
|
38
|
+
* @param {string} [args.namespaceOverride] - non-default namespace slug
|
|
39
|
+
* @param {Array<{ topic: string, resolvedByAdrId: string }>} [args.resolveDeclarations]
|
|
40
|
+
* Operator-supplied conflict resolutions declared on the add
|
|
41
|
+
* itself. One entry per resolved topic. For each, the resolution
|
|
42
|
+
* record is appended to `manifest.resolutions[]` in memory BEFORE
|
|
43
|
+
* the conflict detector runs, so the freshly declared resolution
|
|
44
|
+
* is honoured; the record then persists via the manifest write
|
|
45
|
+
* alongside the applied-blueprint update. Malformed declarations
|
|
46
|
+
* (topic missing from the incoming ADR set, no existing applied
|
|
47
|
+
* blueprint on the topic) are refused with an rcfError. Reads
|
|
48
|
+
* `manifest.resolutions[]` for id-mint monotonicity.
|
|
49
|
+
* @param {Date} [args.now]
|
|
50
|
+
* @param {boolean} [args.dryRun]
|
|
51
|
+
* @param {(src: string, dest: string) => Promise<void>} [args._copyFileForTest]
|
|
52
|
+
* Test-only seam. Substitutes fs.copyFile so a fixture can inject
|
|
53
|
+
* a failure part-way through the contribution write loop and
|
|
54
|
+
* prove the rollback runs. Never used in production.
|
|
55
|
+
* @returns {Promise<ApplyResult | import('../core/errors/index.js').RcfError>}
|
|
56
|
+
*/
|
|
57
|
+
export async function applyBlueprint({ projectRoot, tree, source, namespaceOverride, resolveDeclarations, now = new Date(), dryRun = false, _copyFileForTest }) {
|
|
58
|
+
const blueprint = await loadBlueprint(source);
|
|
59
|
+
if (blueprint.kind) return blueprint; // RcfError
|
|
60
|
+
const namespace = namespaceOverride ?? blueprint.slug;
|
|
61
|
+
|
|
62
|
+
const applied = tree.manifest?.blueprints ?? [];
|
|
63
|
+
const existing = applied.find((b) => b.slug === blueprint.slug);
|
|
64
|
+
const stamped = stampContributions(blueprint.contributions, namespace);
|
|
65
|
+
if (stamped.error) {
|
|
66
|
+
return rcfError({ kind: 'validation', message: stamped.error });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Pre-detection: fold operator-supplied --resolve declarations into a
|
|
70
|
+
// WORKING COPY of the manifest so the detector honours them on this
|
|
71
|
+
// run. The final manifest write later composes the same resolution
|
|
72
|
+
// records into the persisted manifest, so the in-memory copy and the
|
|
73
|
+
// on-disk write agree.
|
|
74
|
+
const incomingForConflicts = { slug: blueprint.slug, contributions: stamped.contributions };
|
|
75
|
+
const declResult = composeDeclaredResolutions({
|
|
76
|
+
manifest: tree.manifest,
|
|
77
|
+
applied,
|
|
78
|
+
incoming: incomingForConflicts,
|
|
79
|
+
declarations: resolveDeclarations ?? [],
|
|
80
|
+
now,
|
|
81
|
+
});
|
|
82
|
+
if (declResult.kind) return declResult; // RcfError
|
|
83
|
+
const workingManifest = declResult.manifest;
|
|
84
|
+
const newResolutionRecords = declResult.newRecords;
|
|
85
|
+
const duplicateResolveTopics = declResult.duplicateTopics ?? [];
|
|
86
|
+
|
|
87
|
+
// Conflict detection ALWAYS runs, including on re-apply. Two classes
|
|
88
|
+
// fire pre-write: scope:global ADR topic collisions (design brief),
|
|
89
|
+
// and cross-blueprint ownership claims where an incoming id is
|
|
90
|
+
// already recorded as owned by a DIFFERENT applied blueprint (the
|
|
91
|
+
// spa vs spa-theme ambiguity class -- now caught here via the
|
|
92
|
+
// authoritative manifest record instead of via string grammar). The
|
|
93
|
+
// globalAdrTopic detector consults `workingManifest.resolutions[]` so
|
|
94
|
+
// a resolved conflict is dropped from the list before the caller sees
|
|
95
|
+
// it.
|
|
96
|
+
const rawGlobalConflicts = detectGlobalAdrConflicts(applied, incomingForConflicts, workingManifest);
|
|
97
|
+
const conflicts = [
|
|
98
|
+
// Thread the CLI-supplied `source` (what the operator typed) onto
|
|
99
|
+
// each enriched conflict so the renderer can print option 3
|
|
100
|
+
// exactly as printed - `rcf blueprint supersede <topic> --incoming
|
|
101
|
+
// <source>` - with a real source path the operator can copy back
|
|
102
|
+
// into a fresh shell.
|
|
103
|
+
...await enrichAdrConflicts(rawGlobalConflicts, tree, blueprint, source),
|
|
104
|
+
...detectCrossBlueprintClaims(applied, incomingForConflicts),
|
|
105
|
+
];
|
|
106
|
+
if (conflicts.length > 0) {
|
|
107
|
+
return { applied: false, slug: blueprint.slug, version: blueprint.version, contributions: [], conflicts };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Ownership set for the overwrite guard below. On a re-apply, the
|
|
111
|
+
// authoritative record is `existing.contributions[].id` -- the exact
|
|
112
|
+
// list of ids the currently-applied version of this blueprint owns.
|
|
113
|
+
// On first apply this is an empty set (any file already on disk at a
|
|
114
|
+
// destination path is by definition foreign or author-owned).
|
|
115
|
+
const ownedIds = new Set((existing?.contributions ?? []).map((c) => c.id));
|
|
116
|
+
|
|
117
|
+
if (existing && existing.version === blueprint.version) {
|
|
118
|
+
return {
|
|
119
|
+
applied: false, alreadyApplied: true,
|
|
120
|
+
slug: blueprint.slug, version: blueprint.version,
|
|
121
|
+
contributions: stamped.contributions,
|
|
122
|
+
...(duplicateResolveTopics.length > 0 ? { warnings: [{ kind: 'duplicateResolveTopic', topics: duplicateResolveTopics }] } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Write contributions atomically at the batch level: every contribution
|
|
127
|
+
// is copied to a `<name>.rcf-tmp-<slug>-<epoch>` sidecar first, and the
|
|
128
|
+
// sidecars are only renamed into their final destinations after ALL
|
|
129
|
+
// copies (and the pre-write collision guards) have succeeded. If any
|
|
130
|
+
// step in the copy loop fails, every already-written sidecar is
|
|
131
|
+
// unlinked before the error is returned; the manifest never sees the
|
|
132
|
+
// partial batch, and the tree is left with no orphan contribution
|
|
133
|
+
// files whose ids are not recorded anywhere.
|
|
134
|
+
//
|
|
135
|
+
// The alternative (write in place, roll back on failure) was rejected
|
|
136
|
+
// because an in-place partial that races with a concurrent walker
|
|
137
|
+
// would expose ids the manifest does not yet name. The sidecar phase
|
|
138
|
+
// keeps every id-bearing file invisible to the walker until the
|
|
139
|
+
// whole batch is ready to commit.
|
|
140
|
+
const stampedContributions = stamped.contributions;
|
|
141
|
+
const writtenContributions = [];
|
|
142
|
+
const copyFileImpl = _copyFileForTest ?? copyFile;
|
|
143
|
+
if (dryRun) {
|
|
144
|
+
for (const c of stampedContributions) {
|
|
145
|
+
const relDest = destPathFor(c);
|
|
146
|
+
writtenContributions.push(preserveScope({ id: c.id, kind: c.kind, path: relDest }, c));
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const tmpSuffix = `.rcf-tmp-${blueprint.slug}-${now.getTime()}`;
|
|
150
|
+
const stagedWrites = []; // { tmpAbs, absDest, relDest, id, kind, c }
|
|
151
|
+
const rollback = async (err) => {
|
|
152
|
+
for (const w of stagedWrites) {
|
|
153
|
+
await unlink(w.tmpAbs).catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
return rcfError({ kind: 'ioFailure', message: `blueprint contribution write failed (rolled back ${stagedWrites.length} staged file(s)): ${err.message}`, filePath: err.relDest ?? '' });
|
|
156
|
+
};
|
|
157
|
+
for (const c of stampedContributions) {
|
|
158
|
+
const src = resolve(blueprint.source, 'contributions', c.path);
|
|
159
|
+
const relDest = destPathFor(c);
|
|
160
|
+
const absDest = join(projectRoot, relDest);
|
|
161
|
+
try {
|
|
162
|
+
await stat(src);
|
|
163
|
+
} catch {
|
|
164
|
+
for (const w of stagedWrites) await unlink(w.tmpAbs).catch(() => {});
|
|
165
|
+
return rcfError({ kind: 'missingFile', message: `blueprint contribution missing on disk: ${src}`, filePath: src });
|
|
166
|
+
}
|
|
167
|
+
const alreadyThere = await stat(absDest).catch(() => null);
|
|
168
|
+
if (alreadyThere) {
|
|
169
|
+
// Authoritative ownership: only a file whose id is already
|
|
170
|
+
// recorded on THIS blueprint's manifest entry is safe to
|
|
171
|
+
// overwrite (the re-apply idempotency case). Anything else --
|
|
172
|
+
// first-apply into a tree that already has the file, or a new
|
|
173
|
+
// contribution appearing in a re-applied version -- is treated
|
|
174
|
+
// as foreign and refused. Grammar is deliberately not consulted
|
|
175
|
+
// here: `ADR-201-spa-theme` may be a legitimate `spa`-owned id
|
|
176
|
+
// whose author put a semantic tail after the slug.
|
|
177
|
+
if (!ownedIds.has(c.id)) {
|
|
178
|
+
for (const w of stagedWrites) await unlink(w.tmpAbs).catch(() => {});
|
|
179
|
+
return rcfError({
|
|
180
|
+
kind: 'duplicateId',
|
|
181
|
+
message: `blueprint apply: contribution ${c.id} would overwrite an existing file at ${relDest} that is not recorded as owned by blueprint '${blueprint.slug}'.`,
|
|
182
|
+
filePath: relDest,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await mkdir(dirname(absDest), { recursive: true });
|
|
188
|
+
} catch (err) {
|
|
189
|
+
const wrapped = new Error(err.message); wrapped.relDest = relDest;
|
|
190
|
+
return rollback(wrapped);
|
|
191
|
+
}
|
|
192
|
+
const tmpAbs = `${absDest}${tmpSuffix}`;
|
|
193
|
+
try {
|
|
194
|
+
await copyFileImpl(src, tmpAbs);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const wrapped = new Error(err.message); wrapped.relDest = relDest;
|
|
197
|
+
return rollback(wrapped);
|
|
198
|
+
}
|
|
199
|
+
stagedWrites.push({ tmpAbs, absDest, relDest, id: c.id, kind: c.kind, c });
|
|
200
|
+
}
|
|
201
|
+
// Commit phase. Rename each sidecar into place. A rename failure
|
|
202
|
+
// rolls the still-sideloaded remainder back, plus best-effort undo
|
|
203
|
+
// of the renames that already committed (delete-if-differs is not
|
|
204
|
+
// possible without content compare; the design brief accepts that
|
|
205
|
+
// a commit-phase failure may leave a partial tree with a matching
|
|
206
|
+
// partial manifest -- the manifest write happens after this loop
|
|
207
|
+
// and is the ordering guarantee). The Phase 1 test injects the
|
|
208
|
+
// failure in the COPY phase, which the rollback covers cleanly.
|
|
209
|
+
for (const w of stagedWrites) {
|
|
210
|
+
try {
|
|
211
|
+
await rename(w.tmpAbs, w.absDest);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
for (const w2 of stagedWrites) await unlink(w2.tmpAbs).catch(() => {});
|
|
214
|
+
return rcfError({ kind: 'ioFailure', message: `blueprint contribution commit failed: ${err.message}`, filePath: w.relDest });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const w of stagedWrites) {
|
|
218
|
+
writtenContributions.push(preserveScope({ id: w.id, kind: w.kind, path: w.relDest }, w.c));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Update manifest.blueprints[].
|
|
223
|
+
const nextEntry = {
|
|
224
|
+
slug: blueprint.slug,
|
|
225
|
+
version: blueprint.version,
|
|
226
|
+
appliedAt: now.toISOString(),
|
|
227
|
+
source,
|
|
228
|
+
...(namespaceOverride ? { namespace: namespaceOverride } : {}),
|
|
229
|
+
...(writtenContributions.length > 0 ? { contributions: writtenContributions } : {}),
|
|
230
|
+
};
|
|
231
|
+
const manifestResult = await updateManifest({
|
|
232
|
+
projectRoot,
|
|
233
|
+
manifest: tree.manifest,
|
|
234
|
+
mutate: (next) => {
|
|
235
|
+
const list = Array.isArray(next.blueprints) ? next.blueprints : [];
|
|
236
|
+
const filtered = list.filter((b) => b.slug !== blueprint.slug);
|
|
237
|
+
filtered.push(nextEntry);
|
|
238
|
+
next.blueprints = filtered;
|
|
239
|
+
// Fold any --resolve declarations recorded on this add into the
|
|
240
|
+
// persisted resolutions[]. Ordering is [existing, ...new] so
|
|
241
|
+
// record ids stay monotonic within a day.
|
|
242
|
+
if (newResolutionRecords.length > 0) {
|
|
243
|
+
const resList = Array.isArray(next.resolutions) ? next.resolutions : [];
|
|
244
|
+
for (const rec of newResolutionRecords) resList.push(rec);
|
|
245
|
+
next.resolutions = resList;
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
dryRun,
|
|
249
|
+
});
|
|
250
|
+
if (manifestResult.kind) return manifestResult; // RcfError
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
applied: true,
|
|
254
|
+
slug: blueprint.slug,
|
|
255
|
+
version: blueprint.version,
|
|
256
|
+
contributions: writtenContributions,
|
|
257
|
+
...(duplicateResolveTopics.length > 0 ? { warnings: [{ kind: 'duplicateResolveTopic', topics: duplicateResolveTopics }] } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function stampContributions(contributions, namespace) {
|
|
262
|
+
const out = [];
|
|
263
|
+
for (const c of contributions ?? []) {
|
|
264
|
+
const r = stampId(c.id, namespace);
|
|
265
|
+
if ('error' in r) return { error: r.error };
|
|
266
|
+
out.push({ ...c, id: r.id });
|
|
267
|
+
}
|
|
268
|
+
return { contributions: out };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Copy scope + topic from the source contribution onto the manifest
|
|
273
|
+
* record when they are present. Manifest schema (0.4.4) accepts optional
|
|
274
|
+
* scope='global' and topic on appliedBlueprintContribution so the
|
|
275
|
+
* conflict detector can see the ADR scope across `add` invocations.
|
|
276
|
+
*/
|
|
277
|
+
function preserveScope(manifestRecord, source) {
|
|
278
|
+
if (source.scope === 'global') manifestRecord.scope = 'global';
|
|
279
|
+
if (typeof source.topic === 'string') manifestRecord.topic = source.topic;
|
|
280
|
+
return manifestRecord;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function destPathFor(c) {
|
|
284
|
+
const kindMap = {
|
|
285
|
+
prd: 'prd', req: 'req', us: 'userStory', tad: 'tad', tac: 'tac',
|
|
286
|
+
adr: 'adr', bs: 'buildSequence', fbs: 'fbs', ts: 'testSuite', cn: 'codeNode',
|
|
287
|
+
};
|
|
288
|
+
const kind = kindMap[c.kind];
|
|
289
|
+
if (!kind) throw new Error(`blueprint apply: unknown contribution kind '${c.kind}'`);
|
|
290
|
+
const dir = subdirFor(kind);
|
|
291
|
+
const filename = `${filenameForId(c.id, c.kind)}.json`;
|
|
292
|
+
return dir ? `rcf/${dir}/${filename}` : `rcf/${filename}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function filenameForId(id, kind) {
|
|
296
|
+
return id.toLowerCase();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Compose zero-or-more `manifest.resolutions[]` records from operator
|
|
301
|
+
* --resolve declarations and return a working manifest copy carrying
|
|
302
|
+
* them (for detector honour on this run) plus the raw new records (for
|
|
303
|
+
* the eventual manifest persist). Refuses malformed declarations up
|
|
304
|
+
* front so the detector never sees a resolution that would break its
|
|
305
|
+
* shape assumptions.
|
|
306
|
+
*
|
|
307
|
+
* A declaration `{ topic, resolvedByAdrId }` is valid iff:
|
|
308
|
+
* - the incoming blueprint carries a scope:global ADR on `topic`, and
|
|
309
|
+
* - at least one currently-applied blueprint carries a scope:global
|
|
310
|
+
* ADR on the same topic (the resolution needs both sides).
|
|
311
|
+
* - resolvedByAdrId is a well-formed ADR id string.
|
|
312
|
+
*/
|
|
313
|
+
function composeDeclaredResolutions({ manifest, applied, incoming, declarations, now }) {
|
|
314
|
+
if (!Array.isArray(declarations) || declarations.length === 0) {
|
|
315
|
+
return { manifest: manifest ?? null, newRecords: [], duplicateTopics: [] };
|
|
316
|
+
}
|
|
317
|
+
const incomingGlobals = (incoming.contributions ?? [])
|
|
318
|
+
.filter((c) => c.kind === 'adr' && c.scope === 'global' && typeof c.topic === 'string');
|
|
319
|
+
const workingManifest = manifest ? JSON.parse(JSON.stringify(manifest)) : {};
|
|
320
|
+
const newRecords = [];
|
|
321
|
+
const iso = now.toISOString();
|
|
322
|
+
// Error messages here are prefix-free: the CLI edge prepends
|
|
323
|
+
// `[error] blueprint add: ` on every rcfError, and doubling the
|
|
324
|
+
// prefix reads badly on the terminal (`blueprint add: blueprint
|
|
325
|
+
// add: ...`).
|
|
326
|
+
// Dedupe by topic within a single add: two --resolve declarations
|
|
327
|
+
// on the same topic silently minted two records on the first
|
|
328
|
+
// implementation. Keep the FIRST occurrence per topic (operator
|
|
329
|
+
// wrote it first, before whatever came after), record the drops
|
|
330
|
+
// so the CLI can surface a warning.
|
|
331
|
+
const seenTopics = new Set();
|
|
332
|
+
const duplicateTopics = [];
|
|
333
|
+
for (const decl of declarations) {
|
|
334
|
+
if (typeof decl?.topic !== 'string' || decl.topic.trim().length === 0) {
|
|
335
|
+
// Schema minLength:1 accepts whitespace-only; the writer refuses
|
|
336
|
+
// it up-front so a whitespace-only topic never lands on disk.
|
|
337
|
+
return rcfError({ kind: 'usage', message: `--resolve declaration is missing a topic.` });
|
|
338
|
+
}
|
|
339
|
+
if (typeof decl.resolvedByAdrId !== 'string' || !/^ADR-\d{3,}(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$/.test(decl.resolvedByAdrId)) {
|
|
340
|
+
return rcfError({ kind: 'usage', message: `--resolve resolvedByAdrId '${decl.resolvedByAdrId}' is not a well-formed ADR id.` });
|
|
341
|
+
}
|
|
342
|
+
if (typeof decl.reason === 'string' && decl.reason.length > 0 && decl.reason.trim().length === 0) {
|
|
343
|
+
return rcfError({ kind: 'usage', message: `--resolve reason for topic '${decl.topic}' must not be whitespace-only.` });
|
|
344
|
+
}
|
|
345
|
+
if (seenTopics.has(decl.topic)) {
|
|
346
|
+
duplicateTopics.push(decl.topic);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const incomingHit = incomingGlobals.find((c) => c.topic === decl.topic);
|
|
350
|
+
if (!incomingHit) {
|
|
351
|
+
return rcfError({ kind: 'usage', message: `--resolve topic '${decl.topic}' does not match any scope:global ADR on the incoming blueprint.` });
|
|
352
|
+
}
|
|
353
|
+
const existingHits = [];
|
|
354
|
+
for (const bp of applied) {
|
|
355
|
+
if (bp.slug === incoming.slug) continue;
|
|
356
|
+
for (const c of bp.contributions ?? []) {
|
|
357
|
+
if (c.kind === 'adr' && c.scope === 'global' && c.topic === decl.topic) {
|
|
358
|
+
existingHits.push({ slug: bp.slug, adrId: c.id });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (existingHits.length === 0) {
|
|
363
|
+
return rcfError({ kind: 'usage', message: `--resolve topic '${decl.topic}' has no applied blueprint carrying a scope:global ADR on that topic; nothing to resolve against.` });
|
|
364
|
+
}
|
|
365
|
+
seenTopics.add(decl.topic);
|
|
366
|
+
// Mint the next id off the WORKING manifest so successive
|
|
367
|
+
// declarations increment cleanly within the same batch.
|
|
368
|
+
const id = nextResolutionId(workingManifest, now);
|
|
369
|
+
const record = {
|
|
370
|
+
id,
|
|
371
|
+
createdAt: iso,
|
|
372
|
+
kind: 'globalAdrTopic',
|
|
373
|
+
topic: decl.topic,
|
|
374
|
+
resolvedByAdrId: decl.resolvedByAdrId,
|
|
375
|
+
supersedes: [
|
|
376
|
+
...existingHits.map((h) => ({ slug: h.slug, adrId: h.adrId })),
|
|
377
|
+
{ slug: incoming.slug, adrId: incomingHit.id },
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
if (typeof decl.reason === 'string' && decl.reason.length > 0) record.reason = decl.reason;
|
|
381
|
+
newRecords.push(record);
|
|
382
|
+
const list = Array.isArray(workingManifest.resolutions) ? workingManifest.resolutions : [];
|
|
383
|
+
list.push(record);
|
|
384
|
+
workingManifest.resolutions = list;
|
|
385
|
+
}
|
|
386
|
+
return { manifest: workingManifest, newRecords, duplicateTopics };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Enrich a global-ADR conflict list with title / decision text from
|
|
391
|
+
* BOTH sides: the tree's byId map (existing blueprint, already loaded
|
|
392
|
+
* by the walker) and the incoming blueprint's on-disk contribution
|
|
393
|
+
* file (incoming blueprint, not yet in the tree). Best-effort on
|
|
394
|
+
* either side: if a lookup fails the enriched fields are simply
|
|
395
|
+
* absent and the renderer falls back to id-at-path for that side.
|
|
396
|
+
*
|
|
397
|
+
* Round-2 review nit: the earlier version only enriched the existing
|
|
398
|
+
* side, so a conflict report against shipped SPA + REST rendered
|
|
399
|
+
* asymmetrically (spa gets 'SPA auth: cookie sessions — HttpOnly
|
|
400
|
+
* cookies.' while rest gets 'ADR-003-rest at rcf/adrs/...'). Both
|
|
401
|
+
* sides are readable — the existing side from tree, the incoming
|
|
402
|
+
* side from disk — and both sides should be rendered.
|
|
403
|
+
*/
|
|
404
|
+
async function enrichAdrConflicts(conflicts, tree, blueprint, sourceLabel) {
|
|
405
|
+
if (conflicts.length === 0) return conflicts;
|
|
406
|
+
const out = [];
|
|
407
|
+
for (const c of conflicts) {
|
|
408
|
+
if (c.kind !== 'globalAdrTopic') {
|
|
409
|
+
out.push(c);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const enriched = { ...c, incoming: { ...c.incoming }, existing: { ...c.existing } };
|
|
413
|
+
// Round-3 (Baz ruling): thread the CLI-supplied source (what the
|
|
414
|
+
// operator typed on `rcf blueprint add SRC`) onto the incoming
|
|
415
|
+
// side so the renderer can print option 3 verbatim — the operator
|
|
416
|
+
// can copy the same source path into the supersede invocation
|
|
417
|
+
// with zero editing.
|
|
418
|
+
if (typeof sourceLabel === 'string' && sourceLabel.length > 0) {
|
|
419
|
+
enriched.incoming.source = sourceLabel;
|
|
420
|
+
}
|
|
421
|
+
// Existing side: the walker already loaded it into tree.byId.
|
|
422
|
+
const existingDoc = tree.byId?.get(c.existing.id);
|
|
423
|
+
if (existingDoc) {
|
|
424
|
+
if (typeof existingDoc.title === 'string') enriched.existing.title = existingDoc.title;
|
|
425
|
+
if (typeof existingDoc.decision === 'string') enriched.existing.decision = firstSentence(existingDoc.decision);
|
|
426
|
+
}
|
|
427
|
+
// Incoming side: read the ADR file straight from the blueprint's
|
|
428
|
+
// contribution directory. blueprint.source is absolute (loader
|
|
429
|
+
// resolves it), the contribution path is relative to contributions/.
|
|
430
|
+
const incomingDoc = await _readIncomingAdrForEnrichment({
|
|
431
|
+
blueprintSource: blueprint.source,
|
|
432
|
+
contributionPath: c.incoming.path,
|
|
433
|
+
});
|
|
434
|
+
if (typeof incomingDoc.title === 'string') enriched.incoming.title = incomingDoc.title;
|
|
435
|
+
if (typeof incomingDoc.decision === 'string') enriched.incoming.decision = incomingDoc.decision;
|
|
436
|
+
out.push(enriched);
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Read the incoming ADR file from a blueprint's contribution directory
|
|
443
|
+
* and pull title/decision. Best-effort: any read/parse failure returns
|
|
444
|
+
* an empty object and the renderer falls back to id-at-path.
|
|
445
|
+
*/
|
|
446
|
+
export async function _readIncomingAdrForEnrichment({ blueprintSource, contributionPath }) {
|
|
447
|
+
try {
|
|
448
|
+
const raw = await readFile(`${blueprintSource}/contributions/${contributionPath}`, 'utf8');
|
|
449
|
+
const doc = JSON.parse(raw);
|
|
450
|
+
return {
|
|
451
|
+
title: typeof doc.title === 'string' ? doc.title : undefined,
|
|
452
|
+
decision: typeof doc.decision === 'string' ? firstSentence(doc.decision) : undefined,
|
|
453
|
+
};
|
|
454
|
+
} catch {
|
|
455
|
+
return {};
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function firstSentence(text) {
|
|
460
|
+
const trimmed = text.trim();
|
|
461
|
+
if (trimmed.length === 0) return trimmed;
|
|
462
|
+
const m = trimmed.match(/^[^.!?]+[.!?](?=\s|$)/);
|
|
463
|
+
return m ? m[0] : trimmed;
|
|
464
|
+
}
|