rcf-lite 0.8.0 → 0.10.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 +124 -51
- package/README.md +8 -4
- package/bin/rcf.js +147 -53
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/README.md +1 -1
- package/guidance/build-cycle-playbook.md +51 -51
- package/guidance/build-cycle.md +7 -7
- package/guidance/document-model.md +1 -1
- package/guidance/elicitation-playbook.md +29 -29
- package/guidance/harness-template.md +21 -10
- package/guidance/managed/README.md +1 -1
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +20 -9
- package/guidance/manifest.json +1 -1
- package/guidance/overview.md +4 -4
- package/package.json +5 -7
- package/rcf/adrs/adr-008.json +1 -1
- package/rcf/adrs/adr-009.json +4 -4
- package/rcf/adrs/adr-010.json +30 -0
- package/rcf/code-nodes/cn-016.json +1 -1
- package/rcf/code-nodes/cn-019.json +1 -1
- package/rcf/code-nodes/cn-020.json +1 -1
- package/rcf/code-nodes/cn-021.json +1 -1
- package/rcf/code-nodes/cn-022.json +1 -1
- package/rcf/code-nodes/cn-049.json +1 -1
- package/rcf/code-nodes/cn-055.json +1 -1
- package/rcf/code-nodes/cn-057.json +5 -5
- 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-005.json +1 -1
- package/rcf/fbs/fbs-006.json +2 -2
- package/rcf/fbs/fbs-007.json +1 -1
- package/rcf/fbs/fbs-014.json +1 -1
- package/rcf/fbs/fbs-015.json +9 -8
- 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-008.json +1 -1
- package/rcf/requirements/req-009.json +2 -2
- package/rcf/requirements/req-010.json +20 -0
- package/rcf/test-suites/PENDING.md +2 -2
- package/rcf/test-suites/ts-004.json +1 -1
- package/rcf/test-suites/ts-006.json +3 -3
- package/rcf/test-suites/ts-008.json +2 -2
- package/rcf/test-suites/ts-009.json +2 -2
- package/rcf/test-suites/ts-017.json +1 -1
- package/rcf/test-suites/ts-024.json +1 -1
- package/rcf/test-suites/ts-025.json +32 -18
- 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/rcf/user-stories/us-805.json +2 -2
- package/rcf/user-stories/us-901.json +13 -13
- 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 +37 -14
- package/src/build/formatters/markdown.js +9 -9
- package/src/build/mark.js +3 -3
- package/src/build/queue.js +1 -1
- package/src/build/standards-selector.js +52 -0
- package/src/cli/blueprint.js +325 -0
- package/src/cli/browser-verify.js +1 -1
- package/src/cli/build.js +139 -69
- package/src/cli/coverage.js +1 -1
- package/src/cli/create.js +48 -3
- package/src/cli/delete.js +2 -2
- package/src/cli/design.js +10 -10
- package/src/cli/fbs.js +1 -1
- package/src/cli/finalise.js +22 -20
- package/src/cli/help.js +269 -89
- package/src/cli/impact.js +1 -1
- package/src/cli/init.js +20 -5
- package/src/cli/intake.js +3 -3
- package/src/cli/link.js +3 -3
- package/src/cli/preflight.js +2 -2
- package/src/cli/read.js +1 -1
- package/src/cli/req-baseline.js +2 -2
- package/src/cli/req-classify.js +3 -3
- package/src/cli/review.js +1 -1
- package/src/cli/standards.js +127 -0
- package/src/cli/test-suite.js +1 -1
- package/src/cli/trace.js +1 -1
- package/src/cli/ui-baseline.js +3 -3
- package/src/cli/ui-classify.js +4 -4
- package/src/cli/update.js +2 -2
- package/src/cli/validate.js +2 -2
- package/src/cli/view.js +12 -10
- package/src/core/store/ids.js +168 -18
- package/src/core/store/loader.js +27 -16
- package/src/core/store/walker.js +27 -15
- package/src/core/store/writer.js +1 -1
- package/src/deployment/index.js +13 -0
- package/src/deployment/placeholder-detector.js +113 -0
- package/src/design/writer.js +3 -3
- package/src/finalise/detect.js +32 -38
- package/src/finalise/index.js +0 -1
- package/src/finalise/install.js +9 -8
- package/src/finalise/spawn.js +14 -10
- package/src/mcp/tools.js +1 -1
- package/src/query/formatters/table.js +7 -10
- package/src/query/trace.js +45 -4
- package/src/req-baseline/gate.js +1 -1
- package/src/ui-baseline/manifest-writer.js +2 -2
- package/src/verify/cli/cleanup.js +1 -1
- package/src/verify/cli/mcp.js +1 -1
- package/src/verify/cli/provision.js +1 -1
- package/src/verify/cli/report.js +1 -1
- package/src/verify/cli/run.js +1 -1
- package/src/view-supervisor/manifest-writer.js +2 -2
- package/bin/rcf-verify.js +0 -122
- package/src/verify/cli/help.js +0 -56
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"usId": "US-1004",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"reqId": "REQ-010",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"status": "draft",
|
|
7
|
+
"title": "Selective retrieval at bundle assembly (contextRequirements.standardIds)",
|
|
8
|
+
"description": "The build-context bundle assembler runs a deterministic selector during FBS bundle assembly that reads the FBS work text plus the standards manifest (tags, titles, and short summaries) and returns the subset of standard slugs relevant to the FBS. The selected slugs populate `contextRequirements.standardIds` for that bundle only. Operator override is supported by authoring the array on the FBS.",
|
|
9
|
+
"asA": "operator on an RCF project with standards registered",
|
|
10
|
+
"iWant": "the bundle assembler to select relevant standards per FBS automatically, and to honour my override when I author the array on the FBS itself",
|
|
11
|
+
"soThat": "the default path does not require operator authoring and the override path exists when the agentic selection needs correcting",
|
|
12
|
+
"acceptanceCriteria": [
|
|
13
|
+
{
|
|
14
|
+
"id": "AC-1004-1",
|
|
15
|
+
"description": "The bundle for an FBS with no operator-authored standardIds carries the agentically-selected subset",
|
|
16
|
+
"given": "a project with two standards packs (`wsd-naming` tagged `naming`, `security-baseline` tagged `security`) and an FBS whose summary names `security`",
|
|
17
|
+
"when": "the bundle assembler runs for that FBS with no operator-authored `standardIds`",
|
|
18
|
+
"then": "the bundle's `context` section carries `standardIds` including `security-baseline` and the pack's rendered summary in the standards payload",
|
|
19
|
+
"testable": true
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"id": "AC-1004-2",
|
|
23
|
+
"description": "Operator-authored `contextRequirements.standardIds` on the FBS overrides the agentic selection",
|
|
24
|
+
"given": "the same project and FBS as AC-1004-1, but with `contextRequirements.standardIds: ['wsd-naming']` authored on the FBS",
|
|
25
|
+
"when": "the bundle assembler runs",
|
|
26
|
+
"then": "the bundle's `context.standardIds` matches the operator-authored value byte-for-byte; the agentic selection is not invoked",
|
|
27
|
+
"testable": true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"id": "AC-1004-3",
|
|
31
|
+
"description": "The assembler never blocks on an empty selection",
|
|
32
|
+
"given": "a project with no standards registered, or an FBS whose work text matches no standard",
|
|
33
|
+
"when": "the bundle assembler runs",
|
|
34
|
+
"then": "the bundle assembles with `context.standardIds` empty (or absent) and no standards payload; the assembler does not error",
|
|
35
|
+
"testable": true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "AC-1004-4",
|
|
39
|
+
"description": "Selection is stable per input (deterministic tag-scoring; no wall-clock or random source)",
|
|
40
|
+
"given": "an FBS work text and a standards manifest, both fixed",
|
|
41
|
+
"when": "the selector is invoked twice",
|
|
42
|
+
"then": "the two returned selections are equal (same slugs, same order)",
|
|
43
|
+
"testable": true
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"createdAt": "2026-08-18T22:54:33.194Z",
|
|
47
|
+
"updatedAt": "2026-08-18T22:54:33.194Z"
|
|
48
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"createdAt": "2026-07-20T17:04:24.904Z",
|
|
3
3
|
"updatedAt": "2026-07-28T16:39:51.461Z",
|
|
4
|
-
"description": "The method carries guidance for a periodic and end-of-build fresh-context self-review that drives the app against its acceptance criteria to catch the defect classes green test suites miss, honestly scoped as an in-loop check subordinate to the independent verification gate that rcf finalise runs.",
|
|
4
|
+
"description": "The method carries guidance for a periodic and end-of-build fresh-context self-review that drives the app against its acceptance criteria to catch the defect classes green test suites miss, honestly scoped as an in-loop check subordinate to the independent verification gate that rcf build finalise runs.",
|
|
5
5
|
"asA": "non-coding product owner",
|
|
6
6
|
"iWant": "the build to include a periodic fresh look that actually drives the app against what it is supposed to do",
|
|
7
7
|
"soThat": "defects my passing tests miss are caught before I rely on the result",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"description": "The guidance scopes the self-review honestly as an in-loop check and not the independent gate",
|
|
42
42
|
"given": "the self-review guidance",
|
|
43
43
|
"when": "it frames the review",
|
|
44
|
-
"then": "it states plainly that this is an in-loop check and not the independent verification gate, names rcf finalise (section 7) as the gate, and says it is guidance and not a new subsystem",
|
|
44
|
+
"then": "it states plainly that this is an in-loop check and not the independent verification gate, names rcf build finalise (section 7) as the gate, and says it is guidance and not a new subsystem",
|
|
45
45
|
"testable": true
|
|
46
46
|
}
|
|
47
47
|
],
|
|
@@ -2,39 +2,39 @@
|
|
|
2
2
|
"usId": "US-901",
|
|
3
3
|
"prdId": "PRD-001",
|
|
4
4
|
"reqId": "REQ-009",
|
|
5
|
-
"title": "Route `rcf verify`
|
|
6
|
-
"description": "A consumer running the unified `rcf` CLI
|
|
5
|
+
"title": "Route the verify suite under the `rcf verify` group and delete the legacy alias bin",
|
|
6
|
+
"description": "A consumer running the unified `rcf` CLI reaches the adversarial ship-gate verifier as `rcf verify <verb>` (run | report | provision | cleanup | mcp | browser). The pre-0.10.0 standalone `rcf-verify` bin is deleted outright; the historical 0.7.1 transition-grace alias is gone. The old flat `rcf-verify <verb>` invocation form is not available at all; consumers scripting against it must move to `rcf verify <verb>`.",
|
|
7
7
|
"asA": "consumer of the RCF Lite CLI",
|
|
8
8
|
"iWant": "to invoke the verify suite as `rcf verify <verb>` inside the unified CLI",
|
|
9
9
|
"soThat": "verify sits inside the same verb space as the rest of the RCF chain and I only have to install one package",
|
|
10
10
|
"acceptanceCriteria": [
|
|
11
11
|
{
|
|
12
12
|
"id": "AC-901-1",
|
|
13
|
-
"description": "The `rcf` bin exposes `verify` as a top-level
|
|
13
|
+
"description": "The `rcf` bin exposes `verify` as a top-level group whose sub-verbs run | report | provision | cleanup | mcp | browser dispatch to the verify-suite handlers",
|
|
14
14
|
"given": "the unified `rcf` binary imported from bin/rcf.js",
|
|
15
|
-
"when": "`verify` is looked up on the top-level
|
|
16
|
-
"then": "the
|
|
15
|
+
"when": "`verify` is looked up on the top-level GROUPS map",
|
|
16
|
+
"then": "the sub-verb resolves to the corresponding main() from src/verify/cli/{run,report,provision,cleanup,mcp}.js or src/cli/browser-verify.js",
|
|
17
17
|
"testable": true
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"id": "AC-901-2",
|
|
21
|
-
"description": "The top-level `rcf
|
|
21
|
+
"description": "The top-level `rcf help` output advertises the `verify` group with its sub-verbs",
|
|
22
22
|
"given": "the unified `rcf` binary",
|
|
23
23
|
"when": "`--help` or `help` runs",
|
|
24
|
-
"then": "the printed usage lists
|
|
24
|
+
"then": "the printed usage lists the verify group header with the sub-verb catalogue (run, report, provision, cleanup, mcp, browser)",
|
|
25
25
|
"testable": true
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"id": "AC-901-3",
|
|
29
|
-
"description": "The `rcf-verify`
|
|
30
|
-
"given": "
|
|
31
|
-
"when": "the
|
|
32
|
-
"then": "
|
|
29
|
+
"description": "The legacy standalone `rcf-verify` bin is deleted; invoking `rcf-verify <verb>` is not possible from an rcf-lite install, and calling any old flat top-level token via the umbrella `rcf` bin exits 2 with a stderr hint naming the new grouped form",
|
|
30
|
+
"given": "an installed rcf-lite@0.10.0",
|
|
31
|
+
"when": "the operator invokes `rcf <old-flat-verb>` where old-flat-verb is a pre-0.10.0 name (validate, coverage, browser-verify, ...)",
|
|
32
|
+
"then": "package.json:bin carries no `rcf-verify` entry, bin/rcf-verify.js does not exist in the package, and bin/rcf.js writes '[error] usage unknown top-level command <verb>. Try rcf <group> <verb>' to stderr and exits 2 (not a back-compat fallback)",
|
|
33
33
|
"testable": true
|
|
34
34
|
}
|
|
35
35
|
],
|
|
36
|
-
"version": "0.
|
|
36
|
+
"version": "0.2.0",
|
|
37
37
|
"status": "draft",
|
|
38
38
|
"createdAt": "2026-08-11T00:00:00Z",
|
|
39
|
-
"updatedAt": "2026-08-
|
|
39
|
+
"updatedAt": "2026-08-26T00:00:00Z"
|
|
40
40
|
}
|
|
@@ -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
|
+
}
|