rcf-lite 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/bin/rcf.js +5 -0
  3. package/blueprints/application-api-rest/blueprint.json +12 -4
  4. package/blueprints/application-api-rest/contributions/adrs/adr-304-application-api-rest-logging.json +3 -3
  5. package/blueprints/application-api-rest/docs/topics.md +4 -2
  6. package/blueprints/application-error-handling/README.md +42 -0
  7. package/blueprints/application-error-handling/assets/schemas/error-record.schema.json +17 -0
  8. package/blueprints/application-error-handling/blueprint.json +27 -0
  9. package/blueprints/application-error-handling/contributions/adrs/adr-1701-application-error-handling-record-shape.json +25 -0
  10. package/blueprints/application-error-handling/contributions/adrs/adr-1702-application-error-handling-classification-vocabulary.json +20 -0
  11. package/blueprints/application-error-handling/contributions/adrs/adr-1703-application-error-handling-transport-mapping.json +20 -0
  12. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-001.json +15 -0
  13. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-002.json +15 -0
  14. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-003.json +15 -0
  15. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-004.json +15 -0
  16. package/blueprints/application-error-handling/contributions/tacs/tac-1701-application-error-handling-boundary.json +45 -0
  17. package/blueprints/application-error-handling/contributions/tacs/tac-1702-application-error-handling-record-factory.json +40 -0
  18. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16101.json +25 -0
  19. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16102.json +25 -0
  20. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16103.json +34 -0
  21. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16104.json +25 -0
  22. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16105.json +25 -0
  23. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16106.json +25 -0
  24. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16107.json +25 -0
  25. package/blueprints/application-error-handling/docs/topics.md +24 -0
  26. package/blueprints/application-error-handling/guide/application-error-handling.md +35 -0
  27. package/blueprints/application-spa/blueprint.json +12 -2
  28. package/blueprints/application-spa/docs/topics.md +2 -0
  29. package/blueprints/observability-logging/README.md +44 -0
  30. package/blueprints/observability-logging/assets/samples/log-line.json +13 -0
  31. package/blueprints/observability-logging/blueprint.json +27 -0
  32. package/blueprints/observability-logging/contributions/adrs/adr-1601-observability-logging-line-shape.json +25 -0
  33. package/blueprints/observability-logging/contributions/adrs/adr-1602-observability-logging-correlation-id-header.json +25 -0
  34. package/blueprints/observability-logging/contributions/adrs/adr-1603-observability-logging-redaction-categories.json +25 -0
  35. package/blueprints/observability-logging/contributions/adrs/adr-1604-observability-logging-level-vocabulary.json +20 -0
  36. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-001.json +15 -0
  37. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-002.json +15 -0
  38. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-003.json +15 -0
  39. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-004.json +15 -0
  40. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-005.json +15 -0
  41. package/blueprints/observability-logging/contributions/tacs/tac-1601-observability-logging-logger-factory.json +46 -0
  42. package/blueprints/observability-logging/contributions/tacs/tac-1602-observability-logging-redaction-boundary.json +27 -0
  43. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15101.json +34 -0
  44. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15102.json +43 -0
  45. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15103.json +34 -0
  46. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15104.json +25 -0
  47. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15105.json +34 -0
  48. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15106.json +25 -0
  49. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15107.json +25 -0
  50. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15108.json +25 -0
  51. package/blueprints/observability-logging/docs/topics.md +21 -0
  52. package/blueprints/observability-logging/guide/observability-logging.md +36 -0
  53. package/fixtures/canary-manifest.json +6 -6
  54. package/guidance/harness-template.md +8 -0
  55. package/guidance/managed/agent-instructions-block.hash +1 -1
  56. package/guidance/managed/agent-instructions-block.md +8 -0
  57. package/package.json +2 -2
  58. package/rcf/code-nodes/cn-077.json +14 -0
  59. package/rcf/code-nodes/cn-078.json +15 -0
  60. package/rcf/code-nodes/cn-079.json +14 -0
  61. package/rcf/code-nodes/cn-080.json +15 -0
  62. package/rcf/code-nodes/cn-081.json +15 -0
  63. package/rcf/code-nodes/cn-082.json +15 -0
  64. package/rcf/code-nodes/cn-083.json +14 -0
  65. package/rcf/code-nodes/cn-084.json +15 -0
  66. package/rcf/code-nodes/cn-085.json +14 -0
  67. package/rcf/code-nodes/cn-086.json +16 -0
  68. package/rcf/code-nodes/cn-087.json +14 -0
  69. package/rcf/code-nodes/cn-088.json +14 -0
  70. package/rcf/code-nodes/cn-089.json +14 -0
  71. package/rcf/code-nodes/cn-090.json +14 -0
  72. package/rcf/code-nodes/cn-091.json +14 -0
  73. package/rcf/code-nodes/cn-092.json +15 -0
  74. package/rcf/code-nodes/cn-093.json +15 -0
  75. package/rcf/code-nodes/cn-094.json +14 -0
  76. package/rcf/code-nodes/cn-095.json +14 -0
  77. package/rcf/code-nodes/cn-096.json +14 -0
  78. package/rcf/code-nodes/cn-097.json +14 -0
  79. package/rcf/code-nodes/cn-098.json +13 -0
  80. package/rcf/code-nodes/cn-099.json +13 -0
  81. package/rcf/code-nodes/cn-100.json +13 -0
  82. package/rcf/code-nodes/cn-101.json +13 -0
  83. package/rcf/code-nodes/cn-102.json +13 -0
  84. package/rcf/code-nodes/cn-103.json +13 -0
  85. package/rcf/code-nodes/cn-104.json +13 -0
  86. package/rcf/code-nodes/cn-105.json +13 -0
  87. package/rcf/evals/eval-001.json +55 -0
  88. package/rcf/fbs/fbs-028.json +27 -0
  89. package/rcf/fbs/fbs-029.json +27 -0
  90. package/rcf/fbs/fbs-030.json +27 -0
  91. package/rcf/fbs/fbs-031.json +27 -0
  92. package/rcf/fbs/fbs-032.json +27 -0
  93. package/rcf/fbs/fbs-033.json +27 -0
  94. package/rcf/fbs/fbs-034.json +27 -0
  95. package/rcf/fbs/fbs-035.json +18 -0
  96. package/rcf/requirements/req-013.json +22 -0
  97. package/rcf/requirements/req-014.json +22 -0
  98. package/rcf/requirements/req-015.json +21 -0
  99. package/rcf/requirements/req-016.json +37 -0
  100. package/rcf/test-suites/ts-038.json +41 -0
  101. package/rcf/test-suites/ts-039.json +41 -0
  102. package/rcf/test-suites/ts-040.json +57 -0
  103. package/rcf/test-suites/ts-041.json +57 -0
  104. package/rcf/test-suites/ts-042.json +57 -0
  105. package/rcf/test-suites/ts-043.json +49 -0
  106. package/rcf/test-suites/ts-044.json +41 -0
  107. package/rcf/test-suites/ts-045.json +46 -0
  108. package/rcf/user-stories/us-1301.json +43 -0
  109. package/rcf/user-stories/us-1302.json +43 -0
  110. package/rcf/user-stories/us-1401.json +43 -0
  111. package/rcf/user-stories/us-1402.json +43 -0
  112. package/rcf/user-stories/us-1403.json +43 -0
  113. package/rcf/user-stories/us-1404.json +43 -0
  114. package/rcf/user-stories/us-1501.json +43 -0
  115. package/rcf/user-stories/us-1601.json +37 -0
  116. package/releases/releases.yaml +20 -1
  117. package/src/blueprint/apply.js +8 -0
  118. package/src/blueprint/companions.js +485 -0
  119. package/src/blueprint/index.js +16 -0
  120. package/src/blueprint/loader.js +243 -1
  121. package/src/cli/blueprint.js +309 -0
  122. package/src/cli/create.js +14 -0
  123. package/src/cli/eval-coverage.js +221 -0
  124. package/src/cli/eval.js +43 -0
  125. package/src/cli/finalise.js +64 -0
  126. package/src/cli/help.js +4 -0
  127. package/src/cli/validate.js +7 -0
  128. package/src/core/store/ids.js +5 -1
  129. package/src/core/store/init.js +4 -0
  130. package/src/core/store/loader.js +4 -0
  131. package/src/core/store/validator.js +8 -1
  132. package/src/core/store/walker.js +71 -2
  133. package/src/core/store/writer.js +4 -0
  134. package/src/eval/judge.js +338 -0
  135. package/src/finalise/index.js +8 -0
  136. package/src/finalise/ingest.js +27 -0
  137. package/src/finalise/ship-without-eval.js +123 -0
  138. package/src/query/eval-coverage.js +162 -0
  139. package/src/verify/chain/index.js +67 -0
  140. package/src/verify/cli/run.js +15 -0
  141. package/src/verify/engine/index.js +10 -0
  142. package/src/verify/verdict/index.js +46 -0
@@ -0,0 +1,485 @@
1
+ // Companion-suggestion mechanism (core-companions spec 2026-09-04
2
+ // sections 2 and 4). Additive on top of the loader, apply, CLI and
3
+ // validate surfaces. Two data shapes it owns end to end:
4
+ //
5
+ // 1. Resolution: given a service blueprint's `suggestedCompanions[]`,
6
+ // walk the deterministic tier ladder (applied provider >
7
+ // registered library > core shelf, with rcf/companions.json
8
+ // pin overriding the last two) and return an ordered result per
9
+ // role.
10
+ //
11
+ // 2. Pin file: rcf/companions.json with { schemaVersion: 1, roles:
12
+ // { <role>: { provider, pinnedAt } } }. Written and read only
13
+ // through this module; validate consults it via `resolvePin`.
14
+ //
15
+ // The mechanism is suggestion, never compulsion (spec 2.6): no code
16
+ // path here calls applyBlueprint. Reads only, plus the pin file write
17
+ // path invoked from the CLI verb and from the apply --companion flag.
18
+
19
+ import { existsSync } from 'node:fs';
20
+ import { mkdir, readFile, readdir, writeFile, unlink } from 'node:fs/promises';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ import { rcfError } from '../core/errors/index.js';
24
+ import { loadBlueprint } from './loader.js';
25
+ import { readLibraryRegistry } from './library-registry.js';
26
+ import { packagedShelfPath } from './shelf-resolver.js';
27
+
28
+ export const COMPANIONS_PATH = 'rcf/companions.json';
29
+ export const COMPANIONS_SCHEMA_VERSION = 1;
30
+
31
+ /**
32
+ * @typedef {object} CompanionsPinFile
33
+ * @property {number} schemaVersion
34
+ * @property {Object<string, { provider: string, pinnedAt: string }>} roles
35
+ */
36
+
37
+ /**
38
+ * @typedef {object} ResolvedCompanion
39
+ * @property {string} role lower camelCase role name
40
+ * @property {string} reason verbatim from the service blueprint's suggestedCompanions[]
41
+ * @property {string|null} provider library-qualified slug ("wsd:logging"),
42
+ * bare shelf slug ("observability-logging"),
43
+ * applied slug, or null when unresolved
44
+ * @property {'appliedProvider'|'pinnedLibrary'|'pinnedShelf'|'registeredLibrary'|'shelfFallback'|'ambiguousLibraries'|'unresolved'} origin
45
+ * @property {string} [notes] human-readable annotation for the render
46
+ * @property {string[]} [ambiguousProviders] present only when origin='ambiguousLibraries'
47
+ */
48
+
49
+ /**
50
+ * Read the pin file if present. Absent file returns null (no pin
51
+ * state exists yet). Malformed file returns an rcfError.
52
+ *
53
+ * @param {string} projectRoot
54
+ * @returns {Promise<CompanionsPinFile|null|import('../core/errors/index.js').RcfError>}
55
+ */
56
+ export async function readCompanionsFile(projectRoot) {
57
+ const path = join(projectRoot, COMPANIONS_PATH);
58
+ if (!existsSync(path)) return null;
59
+ let raw;
60
+ try {
61
+ raw = await readFile(path, 'utf8');
62
+ } catch (err) {
63
+ return rcfError({ kind: 'ioFailure', message: `companions.json: read failed: ${err.message}`, filePath: path });
64
+ }
65
+ let doc;
66
+ try {
67
+ doc = JSON.parse(raw);
68
+ } catch (err) {
69
+ return rcfError({ kind: 'parseFailure', message: `companions.json: JSON parse failed: ${err.message}`, filePath: path });
70
+ }
71
+ const err = validateCompanionsFile(doc, path);
72
+ if (err) return err;
73
+ return doc;
74
+ }
75
+
76
+ /**
77
+ * Validate the pin file shape. Returns null clean or an rcfError.
78
+ */
79
+ export function validateCompanionsFile(doc, filePath) {
80
+ if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) {
81
+ return rcfError({ kind: 'validation', message: 'companions.json must be a JSON object', filePath });
82
+ }
83
+ if (doc.schemaVersion !== COMPANIONS_SCHEMA_VERSION) {
84
+ return rcfError({
85
+ kind: 'validation',
86
+ message: `companions.json: schemaVersion must be ${COMPANIONS_SCHEMA_VERSION} (got ${JSON.stringify(doc.schemaVersion)})`,
87
+ filePath,
88
+ });
89
+ }
90
+ if (typeof doc.roles !== 'object' || doc.roles === null || Array.isArray(doc.roles)) {
91
+ return rcfError({ kind: 'validation', message: 'companions.json: roles must be an object', filePath });
92
+ }
93
+ for (const [role, entry] of Object.entries(doc.roles)) {
94
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(role)) {
95
+ return rcfError({
96
+ kind: 'validation',
97
+ message: `companions.json: role name '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).`,
98
+ filePath,
99
+ });
100
+ }
101
+ if (typeof entry !== 'object' || entry === null) {
102
+ return rcfError({ kind: 'validation', message: `companions.json: roles.${role} must be an object with provider and pinnedAt`, filePath });
103
+ }
104
+ if (typeof entry.provider !== 'string' || entry.provider.length === 0) {
105
+ return rcfError({ kind: 'validation', message: `companions.json: roles.${role}.provider must be a non-empty string`, filePath });
106
+ }
107
+ if (typeof entry.pinnedAt !== 'string' || entry.pinnedAt.length === 0) {
108
+ return rcfError({ kind: 'validation', message: `companions.json: roles.${role}.pinnedAt must be a non-empty ISO-8601 string`, filePath });
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Write a pin to the file, creating it lazily. Overwrites an existing
116
+ * pin for the same role. Returns { written, previousProvider, path }.
117
+ *
118
+ * @param {object} args
119
+ * @param {string} args.projectRoot
120
+ * @param {string} args.role
121
+ * @param {string} args.provider
122
+ * @param {Date} [args.now]
123
+ * @param {boolean} [args.dryRun]
124
+ * @returns {Promise<{ written: boolean, previousProvider: string|null, path: string } | import('../core/errors/index.js').RcfError>}
125
+ */
126
+ export async function setCompanionPin({ projectRoot, role, provider, now = new Date(), dryRun = false }) {
127
+ if (typeof role !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(role)) {
128
+ return rcfError({ kind: 'usage', message: `companions set: role '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).` });
129
+ }
130
+ if (typeof provider !== 'string' || provider.length === 0) {
131
+ return rcfError({ kind: 'usage', message: `companions set: provider slug must be a non-empty string.` });
132
+ }
133
+ const existing = await readCompanionsFile(projectRoot);
134
+ if (existing && existing.kind) return existing;
135
+ const doc = existing ?? { schemaVersion: COMPANIONS_SCHEMA_VERSION, roles: {} };
136
+ const previousProvider = doc.roles?.[role]?.provider ?? null;
137
+ doc.roles[role] = { provider, pinnedAt: now.toISOString() };
138
+ const path = join(projectRoot, COMPANIONS_PATH);
139
+ if (dryRun) return { written: false, previousProvider, path };
140
+ try {
141
+ await mkdir(dirname(path), { recursive: true });
142
+ await writeFile(path, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
143
+ } catch (err) {
144
+ return rcfError({ kind: 'ioFailure', message: `companions.json: write failed: ${err.message}`, filePath: path });
145
+ }
146
+ return { written: true, previousProvider, path };
147
+ }
148
+
149
+ /**
150
+ * Remove a pin. Returns { removed, path }. Refuses when no pin exists
151
+ * for the role (usage error, exit 2). Leaves the rest of the file
152
+ * intact; removes the file when the last pin is gone.
153
+ */
154
+ export async function unsetCompanionPin({ projectRoot, role, dryRun = false }) {
155
+ if (typeof role !== 'string' || !/^[a-z][a-zA-Z0-9]*$/.test(role)) {
156
+ return rcfError({ kind: 'usage', message: `companions unset: role '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).` });
157
+ }
158
+ const existing = await readCompanionsFile(projectRoot);
159
+ if (existing && existing.kind) return existing;
160
+ if (!existing || !Object.prototype.hasOwnProperty.call(existing.roles ?? {}, role)) {
161
+ return rcfError({ kind: 'usage', message: `companions unset: no pin for role '${role}' on this project.` });
162
+ }
163
+ const path = join(projectRoot, COMPANIONS_PATH);
164
+ delete existing.roles[role];
165
+ if (dryRun) return { removed: true, path };
166
+ try {
167
+ if (Object.keys(existing.roles).length === 0) {
168
+ await unlink(path).catch(() => {});
169
+ } else {
170
+ await writeFile(path, `${JSON.stringify(existing, null, 2)}\n`, 'utf8');
171
+ }
172
+ } catch (err) {
173
+ return rcfError({ kind: 'ioFailure', message: `companions.json: write failed: ${err.message}`, filePath: path });
174
+ }
175
+ return { removed: true, path };
176
+ }
177
+
178
+ /**
179
+ * Enumerate the packaged shelf providers for a role. Reads every
180
+ * blueprint.json under the shelf and returns the slugs whose
181
+ * providesRoles[] contains the role.
182
+ *
183
+ * @param {string} role
184
+ * @param {string} [shelfDir]
185
+ * @returns {Promise<string[]>}
186
+ */
187
+ export async function enumerateShelfProviders(role, shelfDir = packagedShelfPath()) {
188
+ const providers = [];
189
+ let entries;
190
+ try {
191
+ entries = await readdir(shelfDir, { withFileTypes: true });
192
+ } catch {
193
+ return providers;
194
+ }
195
+ for (const e of entries) {
196
+ if (!e.isDirectory()) continue;
197
+ const bp = await loadBlueprint(join(shelfDir, e.name));
198
+ if (bp.kind) continue;
199
+ if (Array.isArray(bp.providesRoles) && bp.providesRoles.includes(role)) {
200
+ providers.push(bp.slug);
201
+ }
202
+ }
203
+ return providers.sort();
204
+ }
205
+
206
+ /**
207
+ * Enumerate the registered libraries' providers for a role. Reads the
208
+ * project's registry, then each library's blueprints[] entries, and
209
+ * returns entries { libraryPrefix, slug } where the referenced
210
+ * blueprint declares providesRoles[] containing the role.
211
+ *
212
+ * @param {object} args
213
+ * @param {string} args.projectRoot
214
+ * @returns {Promise<Array<{libraryPrefix: string, slug: string, source: string}>>}
215
+ */
216
+ export async function enumerateLibraryProviders({ projectRoot, role }) {
217
+ const registry = await readLibraryRegistry(projectRoot);
218
+ if (registry.kind) return [];
219
+ const out = [];
220
+ for (const lib of registry.libraries ?? []) {
221
+ for (const entry of lib.blueprints ?? []) {
222
+ const bpPath = join(lib.cachePath, entry.path);
223
+ const bp = await loadBlueprint(bpPath);
224
+ if (bp.kind) continue;
225
+ if (Array.isArray(bp.providesRoles) && bp.providesRoles.includes(role)) {
226
+ out.push({ libraryPrefix: lib.libraryPrefix, slug: entry.slug, source: bpPath });
227
+ }
228
+ }
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Enumerate applied providers for a role from the walker tree. Reads
235
+ * each applied blueprint's SOURCE blueprint.json to consult its
236
+ * providesRoles[] (the applied manifest record does not carry it, per
237
+ * spec section 2 "not on the applied record"). A source that no longer
238
+ * resolves is skipped silently; the resolver falls through to the
239
+ * library / shelf tiers.
240
+ */
241
+ export async function enumerateAppliedProviders({ tree, role }) {
242
+ const applied = tree?.manifest?.blueprints ?? [];
243
+ const out = [];
244
+ for (const bp of applied) {
245
+ if (typeof bp.source !== 'string' || bp.source.length === 0) continue;
246
+ // Library-qualified sources (wsd:auth-oauth2) resolve through the
247
+ // library cache; skipping them here is fine because the library
248
+ // enumeration also covers them (a library-applied provider is
249
+ // catchable in the applied tier only when a manual reader passes
250
+ // an absolute path). The safest fallback: try to loadBlueprint at
251
+ // the source as an absolute path.
252
+ if (bp.source.includes(':') && !bp.source.startsWith('/')) continue;
253
+ const src = bp.source;
254
+ // eslint-disable-next-line no-await-in-loop
255
+ const b = await loadBlueprint(src);
256
+ if (b.kind) continue;
257
+ if (Array.isArray(b.providesRoles) && b.providesRoles.includes(role)) {
258
+ out.push({ slug: bp.slug, source: src });
259
+ }
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /**
265
+ * Resolve one role through the deterministic tier ladder (spec 2.3).
266
+ * Returns a ResolvedCompanion.
267
+ *
268
+ * The pin (rcf/companions.json) overrides steps 2 and 3 (registered
269
+ * library over shelf); it does NOT override step 1 (an applied
270
+ * provider is already the operator's realised choice, and pinning to
271
+ * something else would be a silent contradiction).
272
+ *
273
+ * @param {object} args
274
+ * @param {string} args.projectRoot
275
+ * @param {object} args.tree walker TreeModel
276
+ * @param {{role: string, reason: string}} args.suggestion
277
+ * @param {CompanionsPinFile|null} [args.pins]
278
+ * @returns {Promise<ResolvedCompanion>}
279
+ */
280
+ export async function resolveCompanionRole({ projectRoot, tree, suggestion, pins }) {
281
+ const role = suggestion.role;
282
+ const reason = suggestion.reason;
283
+ // Tier 1: applied providers.
284
+ const applied = await enumerateAppliedProviders({ tree, role });
285
+ if (applied.length > 0) {
286
+ const primary = applied[0];
287
+ return {
288
+ role,
289
+ reason,
290
+ provider: primary.slug,
291
+ origin: 'appliedProvider',
292
+ notes: `already applied on this project (${primary.slug})`,
293
+ };
294
+ }
295
+ // Pin tier (spec 2.4): overrides library / shelf when present.
296
+ const pin = pins?.roles?.[role];
297
+ if (pin) {
298
+ return {
299
+ role,
300
+ reason,
301
+ provider: pin.provider,
302
+ origin: pin.provider.includes(':') ? 'pinnedLibrary' : 'pinnedShelf',
303
+ notes: `pinned via rcf/companions.json to ${pin.provider}`,
304
+ };
305
+ }
306
+ // Tier 2: registered libraries.
307
+ const libraries = await enumerateLibraryProviders({ projectRoot, role });
308
+ if (libraries.length === 1) {
309
+ const [lib] = libraries;
310
+ const providerLabel = `${lib.libraryPrefix}:${lib.slug}`;
311
+ // Also enumerate the shelf so the notes can name the overridden shelf provider.
312
+ const shelfProviders = await enumerateShelfProviders(role);
313
+ const shelfNote = shelfProviders.length === 1 ? ` (overrides shelf provider ${shelfProviders[0]})` : '';
314
+ return {
315
+ role,
316
+ reason,
317
+ provider: providerLabel,
318
+ origin: 'registeredLibrary',
319
+ notes: `registered library '${lib.libraryPrefix}'${shelfNote}`,
320
+ };
321
+ }
322
+ if (libraries.length > 1) {
323
+ return {
324
+ role,
325
+ reason,
326
+ provider: null,
327
+ origin: 'ambiguousLibraries',
328
+ ambiguousProviders: libraries.map((l) => `${l.libraryPrefix}:${l.slug}`).sort(),
329
+ notes: `two or more registered libraries provide role '${role}'; explicit selection required`,
330
+ };
331
+ }
332
+ // Tier 3: core shelf.
333
+ const shelfProviders = await enumerateShelfProviders(role);
334
+ if (shelfProviders.length === 1) {
335
+ return {
336
+ role,
337
+ reason,
338
+ provider: shelfProviders[0],
339
+ origin: 'shelfFallback',
340
+ notes: `shelf fallback (no registered library provides this role)`,
341
+ };
342
+ }
343
+ if (shelfProviders.length > 1) {
344
+ // Not currently expected on the core shelf (one provider per
345
+ // role) but shape the resolver to disambiguate the same way.
346
+ return {
347
+ role,
348
+ reason,
349
+ provider: null,
350
+ origin: 'ambiguousLibraries',
351
+ ambiguousProviders: shelfProviders,
352
+ notes: `two or more shelf blueprints provide role '${role}'; explicit selection required`,
353
+ };
354
+ }
355
+ return {
356
+ role,
357
+ reason,
358
+ provider: null,
359
+ origin: 'unresolved',
360
+ notes: `no provider found for role '${role}' (no applied blueprint, no registered library, no shelf blueprint declares providesRoles containing '${role}').`,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Resolve every role in a service blueprint's suggestedCompanions[].
366
+ * Preserves the order of the suggestedCompanions[] array (spec 2.6).
367
+ *
368
+ * @param {object} args
369
+ * @param {string} args.projectRoot
370
+ * @param {object} args.tree
371
+ * @param {Array<{role: string, reason: string}>} args.suggestedCompanions
372
+ * @param {CompanionsPinFile|null} [args.pins]
373
+ * @returns {Promise<ResolvedCompanion[]>}
374
+ */
375
+ export async function resolveCompanions({ projectRoot, tree, suggestedCompanions, pins }) {
376
+ const out = [];
377
+ for (const suggestion of suggestedCompanions ?? []) {
378
+ // eslint-disable-next-line no-await-in-loop
379
+ out.push(await resolveCompanionRole({ projectRoot, tree, suggestion, pins }));
380
+ }
381
+ return out;
382
+ }
383
+
384
+ /**
385
+ * Render a resolved-companion list to a text block (spec 2.5 / 2.6
386
+ * output shape). Fixed 16-column role column for legibility.
387
+ *
388
+ * @param {ResolvedCompanion[]} resolved
389
+ * @returns {string}
390
+ */
391
+ export function renderCompanionLines(resolved) {
392
+ const lines = [];
393
+ for (const r of resolved) {
394
+ const rolePad = r.role.padEnd(16, ' ');
395
+ if (r.origin === 'ambiguousLibraries') {
396
+ lines.push(` ${rolePad} -> (ambiguous) ${r.notes}`);
397
+ } else if (r.origin === 'unresolved') {
398
+ lines.push(` ${rolePad} -> (unresolved) ${r.notes}`);
399
+ } else {
400
+ lines.push(` ${rolePad} -> ${r.provider} (${r.notes})`);
401
+ }
402
+ }
403
+ return lines.join('\n');
404
+ }
405
+
406
+ /**
407
+ * Two-libraries-refusal message shape (spec 2.4). Called by the CLI
408
+ * on ambiguousLibraries origin. Returns the multi-line text.
409
+ *
410
+ * @param {object} args
411
+ * @param {string} args.role
412
+ * @param {string[]} args.providers library-qualified slugs, e.g. ["wsd:logging","acme:log-emit"]
413
+ * @param {string} [args.serviceSlug] service blueprint being applied; used in the resolution paths
414
+ * @returns {string}
415
+ */
416
+ export function renderAmbiguousLibraryRefusal({ role, providers, serviceSlug }) {
417
+ const numbered = providers.map((p, i) => ` ${i + 1}. ${p} (in registered library '${p.split(':')[0]}')`).join('\n');
418
+ const applyLine = serviceSlug
419
+ ? ` rcf define blueprint add ${serviceSlug} --companion ${role}=${providers[0]}`
420
+ : ` rcf define blueprint add <service-slug> --companion ${role}=${providers[0]}`;
421
+ return [
422
+ `Two or more registered libraries provide role '${role}':`,
423
+ numbered,
424
+ '',
425
+ 'Resolve one of these ways:',
426
+ '',
427
+ ' 1. Adopt one explicitly at apply:',
428
+ applyLine,
429
+ ' 2. Pin one for every future apply on this project:',
430
+ ` rcf define blueprint companions set ${role} ${providers[0]}`,
431
+ ' (writes rcf/companions.json; the file is a project record and rides git)',
432
+ ' 3. Remove one of the libraries if the project does not need both:',
433
+ ' rcf define blueprint library remove <prefix>',
434
+ '',
435
+ ].join('\n');
436
+ }
437
+
438
+ /**
439
+ * Validate rcf/companions.json against the resolvable-providers gate
440
+ * (spec 5): a pin that names no known provider (no applied, no
441
+ * registered library, no shelf) refuses with a validation error.
442
+ * Called from `rcf define validate`; the walker has already run and
443
+ * has the tree. Returns null clean or an array of rcfError entries.
444
+ *
445
+ * @param {object} args
446
+ * @param {string} args.projectRoot
447
+ * @param {object} args.tree
448
+ * @returns {Promise<import('../core/errors/index.js').RcfError[]>}
449
+ */
450
+ export async function validateCompanionPinsResolvable({ projectRoot, tree }) {
451
+ const file = await readCompanionsFile(projectRoot);
452
+ if (file === null) return [];
453
+ if (file.kind) return [file];
454
+ const errors = [];
455
+ for (const [role, entry] of Object.entries(file.roles ?? {})) {
456
+ const provider = entry.provider;
457
+ // Applied?
458
+ const applied = (tree?.manifest?.blueprints ?? []).find((bp) => bp.slug === provider);
459
+ if (applied) continue;
460
+ // Library-qualified?
461
+ if (provider.includes(':')) {
462
+ const [prefix, slug] = provider.split(':');
463
+ const registry = await readLibraryRegistry(projectRoot);
464
+ if (!registry.kind) {
465
+ const lib = (registry.libraries ?? []).find((l) => l.libraryPrefix === prefix);
466
+ if (lib && (lib.blueprints ?? []).some((b) => b.slug === slug)) continue;
467
+ }
468
+ errors.push(rcfError({
469
+ kind: 'validation',
470
+ message: `rcf/companions.json pins role '${role}' to '${provider}' but no such provider is applied, registered or on the shelf.`,
471
+ filePath: join(projectRoot, COMPANIONS_PATH),
472
+ }));
473
+ continue;
474
+ }
475
+ // Shelf slug?
476
+ const shelfProviders = await enumerateShelfProviders(role);
477
+ if (shelfProviders.includes(provider)) continue;
478
+ errors.push(rcfError({
479
+ kind: 'validation',
480
+ message: `rcf/companions.json pins role '${role}' to '${provider}' but no such provider is applied, registered or on the shelf.`,
481
+ filePath: join(projectRoot, COMPANIONS_PATH),
482
+ }));
483
+ }
484
+ return errors;
485
+ }
@@ -13,6 +13,22 @@ export { supersedeBlueprintTopic } from './supersede.js';
13
13
  export { diffBlueprintTopic, renderDiff } from './diff.js';
14
14
  export { resolveBlueprintSource, knownShelfSlugs, packagedShelfPath } from './shelf-resolver.js';
15
15
  export { loadLibrary } from './library-loader.js';
16
+ export {
17
+ COMPANIONS_PATH,
18
+ COMPANIONS_SCHEMA_VERSION,
19
+ readCompanionsFile,
20
+ validateCompanionsFile,
21
+ setCompanionPin,
22
+ unsetCompanionPin,
23
+ enumerateShelfProviders,
24
+ enumerateLibraryProviders,
25
+ enumerateAppliedProviders,
26
+ resolveCompanionRole,
27
+ resolveCompanions,
28
+ renderCompanionLines,
29
+ renderAmbiguousLibraryRefusal,
30
+ validateCompanionPinsResolvable,
31
+ } from './companions.js';
16
32
  export {
17
33
  REGISTRY_PATH,
18
34
  REGISTRY_VERSION,