rcf-lite 0.18.0 → 0.19.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 (108) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/blueprints/application-api-rest/blueprint.json +12 -4
  3. package/blueprints/application-api-rest/contributions/adrs/adr-304-application-api-rest-logging.json +3 -3
  4. package/blueprints/application-api-rest/docs/topics.md +4 -2
  5. package/blueprints/application-error-handling/README.md +42 -0
  6. package/blueprints/application-error-handling/assets/schemas/error-record.schema.json +17 -0
  7. package/blueprints/application-error-handling/blueprint.json +27 -0
  8. package/blueprints/application-error-handling/contributions/adrs/adr-1701-application-error-handling-record-shape.json +25 -0
  9. package/blueprints/application-error-handling/contributions/adrs/adr-1702-application-error-handling-classification-vocabulary.json +20 -0
  10. package/blueprints/application-error-handling/contributions/adrs/adr-1703-application-error-handling-transport-mapping.json +20 -0
  11. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-001.json +15 -0
  12. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-002.json +15 -0
  13. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-003.json +15 -0
  14. package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-004.json +15 -0
  15. package/blueprints/application-error-handling/contributions/tacs/tac-1701-application-error-handling-boundary.json +45 -0
  16. package/blueprints/application-error-handling/contributions/tacs/tac-1702-application-error-handling-record-factory.json +40 -0
  17. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16101.json +25 -0
  18. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16102.json +25 -0
  19. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16103.json +34 -0
  20. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16104.json +25 -0
  21. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16105.json +25 -0
  22. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16106.json +25 -0
  23. package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16107.json +25 -0
  24. package/blueprints/application-error-handling/docs/topics.md +24 -0
  25. package/blueprints/application-error-handling/guide/application-error-handling.md +35 -0
  26. package/blueprints/application-spa/blueprint.json +12 -2
  27. package/blueprints/application-spa/docs/topics.md +2 -0
  28. package/blueprints/observability-logging/README.md +44 -0
  29. package/blueprints/observability-logging/assets/samples/log-line.json +13 -0
  30. package/blueprints/observability-logging/blueprint.json +27 -0
  31. package/blueprints/observability-logging/contributions/adrs/adr-1601-observability-logging-line-shape.json +25 -0
  32. package/blueprints/observability-logging/contributions/adrs/adr-1602-observability-logging-correlation-id-header.json +25 -0
  33. package/blueprints/observability-logging/contributions/adrs/adr-1603-observability-logging-redaction-categories.json +25 -0
  34. package/blueprints/observability-logging/contributions/adrs/adr-1604-observability-logging-level-vocabulary.json +20 -0
  35. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-001.json +15 -0
  36. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-002.json +15 -0
  37. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-003.json +15 -0
  38. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-004.json +15 -0
  39. package/blueprints/observability-logging/contributions/requirements/observability-logging-req-005.json +15 -0
  40. package/blueprints/observability-logging/contributions/tacs/tac-1601-observability-logging-logger-factory.json +46 -0
  41. package/blueprints/observability-logging/contributions/tacs/tac-1602-observability-logging-redaction-boundary.json +27 -0
  42. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15101.json +34 -0
  43. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15102.json +43 -0
  44. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15103.json +34 -0
  45. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15104.json +25 -0
  46. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15105.json +34 -0
  47. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15106.json +25 -0
  48. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15107.json +25 -0
  49. package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15108.json +25 -0
  50. package/blueprints/observability-logging/docs/topics.md +21 -0
  51. package/blueprints/observability-logging/guide/observability-logging.md +36 -0
  52. package/fixtures/canary-manifest.json +6 -6
  53. package/guidance/harness-template.md +8 -0
  54. package/guidance/managed/agent-instructions-block.hash +1 -1
  55. package/guidance/managed/agent-instructions-block.md +8 -0
  56. package/package.json +1 -1
  57. package/rcf/code-nodes/cn-077.json +14 -0
  58. package/rcf/code-nodes/cn-078.json +15 -0
  59. package/rcf/code-nodes/cn-079.json +14 -0
  60. package/rcf/code-nodes/cn-080.json +15 -0
  61. package/rcf/code-nodes/cn-081.json +15 -0
  62. package/rcf/code-nodes/cn-082.json +15 -0
  63. package/rcf/code-nodes/cn-083.json +14 -0
  64. package/rcf/code-nodes/cn-084.json +15 -0
  65. package/rcf/code-nodes/cn-085.json +14 -0
  66. package/rcf/code-nodes/cn-086.json +16 -0
  67. package/rcf/code-nodes/cn-087.json +14 -0
  68. package/rcf/code-nodes/cn-088.json +14 -0
  69. package/rcf/code-nodes/cn-089.json +14 -0
  70. package/rcf/code-nodes/cn-090.json +14 -0
  71. package/rcf/code-nodes/cn-091.json +14 -0
  72. package/rcf/code-nodes/cn-092.json +15 -0
  73. package/rcf/code-nodes/cn-093.json +15 -0
  74. package/rcf/code-nodes/cn-094.json +14 -0
  75. package/rcf/code-nodes/cn-095.json +14 -0
  76. package/rcf/code-nodes/cn-096.json +14 -0
  77. package/rcf/code-nodes/cn-097.json +14 -0
  78. package/rcf/fbs/fbs-028.json +27 -0
  79. package/rcf/fbs/fbs-029.json +27 -0
  80. package/rcf/fbs/fbs-030.json +27 -0
  81. package/rcf/fbs/fbs-031.json +27 -0
  82. package/rcf/fbs/fbs-032.json +27 -0
  83. package/rcf/fbs/fbs-033.json +27 -0
  84. package/rcf/fbs/fbs-034.json +27 -0
  85. package/rcf/requirements/req-013.json +22 -0
  86. package/rcf/requirements/req-014.json +22 -0
  87. package/rcf/requirements/req-015.json +21 -0
  88. package/rcf/test-suites/ts-038.json +41 -0
  89. package/rcf/test-suites/ts-039.json +41 -0
  90. package/rcf/test-suites/ts-040.json +57 -0
  91. package/rcf/test-suites/ts-041.json +57 -0
  92. package/rcf/test-suites/ts-042.json +57 -0
  93. package/rcf/test-suites/ts-043.json +49 -0
  94. package/rcf/test-suites/ts-044.json +41 -0
  95. package/rcf/user-stories/us-1301.json +43 -0
  96. package/rcf/user-stories/us-1302.json +43 -0
  97. package/rcf/user-stories/us-1401.json +43 -0
  98. package/rcf/user-stories/us-1402.json +43 -0
  99. package/rcf/user-stories/us-1403.json +43 -0
  100. package/rcf/user-stories/us-1404.json +43 -0
  101. package/rcf/user-stories/us-1501.json +43 -0
  102. package/releases/releases.yaml +11 -1
  103. package/src/blueprint/apply.js +8 -0
  104. package/src/blueprint/companions.js +485 -0
  105. package/src/blueprint/index.js +16 -0
  106. package/src/blueprint/loader.js +243 -1
  107. package/src/cli/blueprint.js +309 -0
  108. package/src/cli/validate.js +7 -0
@@ -26,6 +26,24 @@ const CONTRIBUTABLE_KINDS = new Set(['req', 'us', 'tac', 'adr', 'ts', 'cn']);
26
26
  const ROOT_SINGLETON_KINDS = new Set(['prd', 'tad', 'bs']);
27
27
  const EXCLUDED_KINDS = new Set(['fbs']);
28
28
 
29
+ // Role name grammar for providesRoles[] and suggestedCompanions[].role.
30
+ // Lower camelCase: starts with a lowercase letter, contains only letters
31
+ // and digits, no separators. Same shape as global-topic strings by
32
+ // design (a role name IS the topic name the paired ADR claims, per
33
+ // core-companions spec section 2.2), so `providesRoles: ["logging"]`
34
+ // and the ADR contribution `{"scope":"global","topic":"logging"}`
35
+ // co-locate the two facts on one string.
36
+ const ROLE_NAME_RE = /^[a-z][a-zA-Z0-9]*$/;
37
+
38
+ // Em-dash sentinel + emoji-ish detection for the suggestedCompanions
39
+ // reason string, per the estate-wide banned-tells baseline. Em-dash
40
+ // (U+2014) is refused outright; emojis approximated by a broad
41
+ // symbols / pictographs range. Reason is operator-facing prose, so the
42
+ // same discipline that applies to READMEs and guides applies here.
43
+ // Refused shapes surface as a validation rcfError before apply.
44
+ const EM_DASH = /—/;
45
+ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F000}-\u{1F2FF}]/u;
46
+
29
47
  /**
30
48
  * @typedef {object} BlueprintContribution
31
49
  * @property {string} id canonical id (bare or already namespaced)
@@ -33,6 +51,32 @@ const EXCLUDED_KINDS = new Set(['fbs']);
33
51
  * @property {string} path relative to the blueprint's contributions/
34
52
  * @property {'global'} [scope] ADR only; marks whole-project decisions
35
53
  * @property {string} [topic] ADR only when scope=global; conflict key
54
+ * @property {boolean} [recommendedDefault] ADR only. Standards-derived
55
+ * discipline (spec 3.2): marks a SHOULD
56
+ * clause, or a choice-shaped MUST per
57
+ * amendment A2 (Baz 2026-09-04T12:20:31Z).
58
+ * @property {boolean} [elicited] ADR only. Marks a MAY clause: the
59
+ * applying operator supplies the value
60
+ * at apply.
61
+ * @property {string} [standardsTraceClause] ADR only. Standard clause
62
+ * identifier verbatim (`WSD-001 clause 3.1`,
63
+ * `RFC 7807 section 3.1`) or the sentinel
64
+ * `"generic enterprise practice"`. Required
65
+ * on every ADR contribution when the
66
+ * blueprint declares standardsTrace[].
67
+ */
68
+
69
+ /**
70
+ * @typedef {object} SuggestedCompanion
71
+ * @property {string} role lower camelCase role name
72
+ * @property {string} reason one-sentence operator-facing reason string
73
+ * (no em-dashes, no emojis)
74
+ */
75
+
76
+ /**
77
+ * @typedef {object} StandardsTraceEntry
78
+ * @property {string} id standard identifier (e.g. `WSD-001`)
79
+ * @property {string} version standard version (free-form)
36
80
  */
37
81
 
38
82
  /**
@@ -51,6 +95,12 @@ const EXCLUDED_KINDS = new Set(['fbs']);
51
95
  * vocabulary, so a new category can be
52
96
  * minted by adding it to the standard
53
97
  * without a code change.
98
+ * @property {string[]} [providesRoles] core-companions spec 2.1
99
+ * @property {SuggestedCompanion[]} [suggestedCompanions] core-companions spec 2.1
100
+ * @property {StandardsTraceEntry[]} [standardsTrace] standards-derived
101
+ * discipline (spec 3.2). When set, every
102
+ * ADR contribution MUST carry a non-null
103
+ * standardsTraceClause.
54
104
  * @property {BlueprintContribution[]} contributions
55
105
  */
56
106
 
@@ -94,10 +144,33 @@ export async function loadBlueprint(source) {
94
144
  version: doc.version,
95
145
  source: root,
96
146
  ...(typeof doc.category === 'string' ? { category: doc.category } : {}),
97
- contributions: Array.isArray(doc.contributions) ? doc.contributions : [],
147
+ ...(Array.isArray(doc.providesRoles) ? { providesRoles: doc.providesRoles.slice() } : {}),
148
+ ...(Array.isArray(doc.suggestedCompanions)
149
+ ? { suggestedCompanions: doc.suggestedCompanions.map((s) => ({ role: s.role, reason: s.reason })) }
150
+ : {}),
151
+ ...(Array.isArray(doc.standardsTrace)
152
+ ? { standardsTrace: doc.standardsTrace.map((s) => ({ id: s.id, version: s.version })) }
153
+ : {}),
154
+ contributions: Array.isArray(doc.contributions) ? doc.contributions.map(preserveAdrDisciplineFields) : [],
98
155
  };
99
156
  }
100
157
 
158
+ // Copy the standards-derived-discipline ADR fields (recommendedDefault,
159
+ // elicited, standardsTraceClause) onto the returned contribution shape
160
+ // verbatim so consumers (apply, tests, tooling) can read them without
161
+ // re-loading the blueprint.json. Non-ADR contributions ignore these
162
+ // fields; the loader does not enforce a kind gate on them because a
163
+ // blueprint author can meaningfully attach recommendedDefault to any
164
+ // ADR-flavoured contribution (the discipline is prose in section 8a,
165
+ // not code, per amendment A2).
166
+ function preserveAdrDisciplineFields(c) {
167
+ const out = { ...c };
168
+ if (out.recommendedDefault !== undefined) out.recommendedDefault = c.recommendedDefault === true;
169
+ if (out.elicited !== undefined) out.elicited = c.elicited === true;
170
+ if (typeof c.standardsTraceClause === 'string') out.standardsTraceClause = c.standardsTraceClause;
171
+ return out;
172
+ }
173
+
101
174
  function validateMetadata(doc, metaPath) {
102
175
  if (typeof doc !== 'object' || doc === null) {
103
176
  return rcfError({ kind: 'validation', message: 'blueprint.json must be a JSON object', filePath: metaPath });
@@ -169,6 +242,175 @@ function validateMetadata(doc, metaPath) {
169
242
  if (c.scope === 'global' && typeof c.topic !== 'string') {
170
243
  return rcfError({ kind: 'validation', message: `blueprint.json: scope=global contribution ${c.id} requires a topic`, filePath: metaPath });
171
244
  }
245
+ // Standards-derived discipline (spec 3.2) per-ADR fields. Shape
246
+ // gates only; whether a MUST clause landed on an AC vs an ADR is
247
+ // prose in blueprint-authoring.md section 8a, not code (amendment
248
+ // A2 Baz 2026-09-04T12:20:31Z).
249
+ if (c.recommendedDefault !== undefined && typeof c.recommendedDefault !== 'boolean') {
250
+ return rcfError({ kind: 'validation', message: `blueprint.json: contribution ${c.id} recommendedDefault must be a boolean when set`, filePath: metaPath });
251
+ }
252
+ if (c.elicited !== undefined && typeof c.elicited !== 'boolean') {
253
+ return rcfError({ kind: 'validation', message: `blueprint.json: contribution ${c.id} elicited must be a boolean when set`, filePath: metaPath });
254
+ }
255
+ if (c.standardsTraceClause !== undefined) {
256
+ if (typeof c.standardsTraceClause !== 'string' || c.standardsTraceClause.trim().length === 0) {
257
+ return rcfError({ kind: 'validation', message: `blueprint.json: contribution ${c.id} standardsTraceClause must be a non-empty string when set`, filePath: metaPath });
258
+ }
259
+ }
260
+ }
261
+ // Companion-suggestion mechanism fields (core-companions spec 2.1).
262
+ const rolesError = validateProvidesRoles(doc, metaPath);
263
+ if (rolesError) return rolesError;
264
+ const suggestedError = validateSuggestedCompanions(doc, metaPath);
265
+ if (suggestedError) return suggestedError;
266
+ // Paired-ADR gate (spec 2.1): a blueprint declaring a role in
267
+ // providesRoles[] MUST carry a scope:global ADR whose topic string
268
+ // equals the role name. Enforced after per-contribution validation
269
+ // so a mis-authored contribution fails first with the more specific
270
+ // shape error.
271
+ const pairedError = validateProvidesRolesPairedAdrs(doc, metaPath);
272
+ if (pairedError) return pairedError;
273
+ // Standards-derived discipline (spec 3.3): if standardsTrace[] is
274
+ // set, every ADR contribution MUST carry a non-null
275
+ // standardsTraceClause. The loader does NOT cross-check clause
276
+ // severity to kind (per amendment A2); the discipline is prose.
277
+ const stError = validateStandardsTrace(doc, metaPath);
278
+ if (stError) return stError;
279
+ return null;
280
+ }
281
+
282
+ /**
283
+ * Validate providesRoles[] shape (spec 2.1). Optional; when present
284
+ * must be a non-empty array of lower camelCase strings on the pattern
285
+ * ^[a-z][a-zA-Z0-9]*$.
286
+ */
287
+ function validateProvidesRoles(doc, metaPath) {
288
+ if (doc.providesRoles === undefined) return null;
289
+ if (!Array.isArray(doc.providesRoles) || doc.providesRoles.length === 0) {
290
+ return rcfError({ kind: 'validation', message: 'blueprint.json: providesRoles must be a non-empty array when set', filePath: metaPath });
291
+ }
292
+ for (let i = 0; i < doc.providesRoles.length; i += 1) {
293
+ const role = doc.providesRoles[i];
294
+ if (typeof role !== 'string' || !ROLE_NAME_RE.test(role)) {
295
+ return rcfError({
296
+ kind: 'validation',
297
+ message: `blueprint.json: providesRoles[${i}] '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).`,
298
+ filePath: metaPath,
299
+ });
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
305
+ /**
306
+ * Validate suggestedCompanions[] shape (spec 2.1). Optional; when
307
+ * present must be a non-empty array of `{role, reason}` objects; role
308
+ * is a lower camelCase string; reason is a non-empty string with no
309
+ * em-dashes and no emojis (banned-tells baseline applies to operator-
310
+ * facing prose).
311
+ */
312
+ function validateSuggestedCompanions(doc, metaPath) {
313
+ if (doc.suggestedCompanions === undefined) return null;
314
+ if (!Array.isArray(doc.suggestedCompanions) || doc.suggestedCompanions.length === 0) {
315
+ return rcfError({ kind: 'validation', message: 'blueprint.json: suggestedCompanions must be a non-empty array when set', filePath: metaPath });
316
+ }
317
+ for (let i = 0; i < doc.suggestedCompanions.length; i += 1) {
318
+ const entry = doc.suggestedCompanions[i];
319
+ if (typeof entry !== 'object' || entry === null) {
320
+ return rcfError({ kind: 'validation', message: `blueprint.json: suggestedCompanions[${i}] must be an object with role and reason`, filePath: metaPath });
321
+ }
322
+ if (typeof entry.role !== 'string' || !ROLE_NAME_RE.test(entry.role)) {
323
+ return rcfError({
324
+ kind: 'validation',
325
+ message: `blueprint.json: suggestedCompanions[${i}].role '${entry.role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).`,
326
+ filePath: metaPath,
327
+ });
328
+ }
329
+ if (typeof entry.reason !== 'string' || entry.reason.trim().length === 0) {
330
+ return rcfError({
331
+ kind: 'validation',
332
+ message: `blueprint.json: suggestedCompanions[${i}].reason for role '${entry.role}' must be a non-empty string.`,
333
+ filePath: metaPath,
334
+ });
335
+ }
336
+ if (EM_DASH.test(entry.reason)) {
337
+ return rcfError({
338
+ kind: 'validation',
339
+ message: `blueprint.json: suggestedCompanions[${i}].reason for role '${entry.role}' contains an em-dash; use a comma, a full stop, or a colon.`,
340
+ filePath: metaPath,
341
+ });
342
+ }
343
+ if (EMOJI_RE.test(entry.reason)) {
344
+ return rcfError({
345
+ kind: 'validation',
346
+ message: `blueprint.json: suggestedCompanions[${i}].reason for role '${entry.role}' contains an emoji; operator-facing prose must be plain text.`,
347
+ filePath: metaPath,
348
+ });
349
+ }
350
+ }
351
+ return null;
352
+ }
353
+
354
+ /**
355
+ * Paired-ADR gate for providesRoles (spec 2.1). A blueprint that
356
+ * declares a role MUST also carry a scope:global ADR whose topic
357
+ * string equals the role name. The check runs after per-contribution
358
+ * shape validation so the more specific per-contribution message
359
+ * fires first on a mis-authored file.
360
+ */
361
+ function validateProvidesRolesPairedAdrs(doc, metaPath) {
362
+ if (!Array.isArray(doc.providesRoles) || doc.providesRoles.length === 0) return null;
363
+ const globalTopics = new Set(
364
+ (doc.contributions ?? [])
365
+ .filter((c) => c.kind === 'adr' && c.scope === 'global' && typeof c.topic === 'string')
366
+ .map((c) => c.topic),
367
+ );
368
+ for (const role of doc.providesRoles) {
369
+ if (!globalTopics.has(role)) {
370
+ return rcfError({
371
+ kind: 'validation',
372
+ message: `blueprint.json: providesRoles[] names '${role}' but no scope:global ADR carries topic '${role}'.`,
373
+ filePath: metaPath,
374
+ });
375
+ }
376
+ }
377
+ return null;
378
+ }
379
+
380
+ /**
381
+ * Standards-derived-discipline gate (spec 3.3). If standardsTrace[]
382
+ * is declared, every ADR contribution MUST carry a non-null
383
+ * standardsTraceClause. No cross-check on severity-to-kind mapping
384
+ * (amendment A2 Baz 2026-09-04T12:20:31Z): the discipline is prose in
385
+ * blueprint-authoring.md section 8a, not code.
386
+ */
387
+ function validateStandardsTrace(doc, metaPath) {
388
+ if (doc.standardsTrace === undefined) return null;
389
+ if (!Array.isArray(doc.standardsTrace)) {
390
+ return rcfError({ kind: 'validation', message: 'blueprint.json: standardsTrace must be an array when set', filePath: metaPath });
391
+ }
392
+ for (let i = 0; i < doc.standardsTrace.length; i += 1) {
393
+ const entry = doc.standardsTrace[i];
394
+ if (typeof entry !== 'object' || entry === null) {
395
+ return rcfError({ kind: 'validation', message: `blueprint.json: standardsTrace[${i}] must be an object with id and version`, filePath: metaPath });
396
+ }
397
+ if (typeof entry.id !== 'string' || entry.id.trim().length === 0) {
398
+ return rcfError({ kind: 'validation', message: `blueprint.json: standardsTrace[${i}].id must be a non-empty string`, filePath: metaPath });
399
+ }
400
+ if (typeof entry.version !== 'string' || entry.version.trim().length === 0) {
401
+ return rcfError({ kind: 'validation', message: `blueprint.json: standardsTrace[${i}].version must be a non-empty string`, filePath: metaPath });
402
+ }
403
+ }
404
+ const slug = doc.slug;
405
+ for (const c of doc.contributions ?? []) {
406
+ if (c.kind !== 'adr') continue;
407
+ if (typeof c.standardsTraceClause !== 'string' || c.standardsTraceClause.trim().length === 0) {
408
+ return rcfError({
409
+ kind: 'validation',
410
+ message: `blueprint '${slug}' declares standardsTrace but ADR contribution '${c.id}' has no standardsTraceClause; every ADR must reference a standard clause or the sentinel 'generic enterprise practice'.`,
411
+ filePath: metaPath,
412
+ });
413
+ }
172
414
  }
173
415
  return null;
174
416
  }
@@ -19,11 +19,20 @@ import {
19
19
  enrichRowsWithCategories,
20
20
  groupRowsByCategory,
21
21
  listBlueprints,
22
+ loadBlueprint,
22
23
  removeBlueprint,
23
24
  removeResolution,
24
25
  renderDiff,
25
26
  resolveBlueprintSource,
26
27
  supersedeBlueprintTopic,
28
+ readCompanionsFile,
29
+ setCompanionPin,
30
+ unsetCompanionPin,
31
+ resolveCompanions,
32
+ renderCompanionLines,
33
+ renderAmbiguousLibraryRefusal,
34
+ enumerateShelfProviders,
35
+ enumerateLibraryProviders,
27
36
  } from '../blueprint/index.js';
28
37
  import { conflictReportJson, renderConflictReport } from '../blueprint/conflicts.js';
29
38
  import { handleLibraryVerb, LIBRARY_HELP } from './blueprint-library.js';
@@ -81,6 +90,26 @@ Verbs:
81
90
  names an ADR present on the tree but is no
82
91
  longer on any resolutions[] entry, prints
83
92
  "nothing to remove" and exits 0.
93
+ companions <slug> Print the resolved companion set for the
94
+ named applied service blueprint. Reads its
95
+ source blueprint.json's suggestedCompanions[],
96
+ walks the deterministic tier ladder (applied
97
+ > registered library > core shelf, with
98
+ rcf/companions.json pin overriding library +
99
+ shelf), and prints one line per role. Refuses
100
+ exit 3 when two or more registered libraries
101
+ provide the same role with no pin. \`--json\`
102
+ emits a machine-readable envelope.
103
+ companions set <role> <slug>
104
+ Write a project-level pin to rcf/companions.json
105
+ under roles.<role>.provider. <slug> accepts
106
+ \`<libraryPrefix>:<slug>\` for a library provider
107
+ or a bare kebab slug for a shelf provider.
108
+ Refuses exit 2 when the named provider does
109
+ not declare providesRoles containing <role>.
110
+ companions unset <role>
111
+ Remove a pin from rcf/companions.json. Refuses
112
+ exit 2 when no pin exists for <role>.
84
113
  library <verb> Manage external blueprint libraries. Sub-verbs:
85
114
  add, list, remove, refresh. See
86
115
  'rcf define blueprint library --help' for the
@@ -102,6 +131,21 @@ Options:
102
131
  --reason <text> (supersede, add --resolve) Optional operator
103
132
  note attached to the manifest.resolutions[]
104
133
  record.
134
+ --companion <role>=<slug>
135
+ (add only, repeatable) Pin a companion
136
+ provider at apply time; writes the pin to
137
+ rcf/companions.json (schemaVersion 1, current
138
+ pin per role) with pinnedAt. <slug> is
139
+ \`<libraryPrefix>:<slug>\` for a library
140
+ provider or a bare kebab slug for a shelf
141
+ provider. Refuses exit 2 when the named
142
+ provider does not declare providesRoles
143
+ containing <role>.
144
+ --no-companion-suggestions
145
+ (add only) Suppress both the --companion pin
146
+ phase and the post-apply resolved suggestion
147
+ block; every other apply behaviour is
148
+ unchanged.
105
149
  --json (add only) Emit the result (or conflict
106
150
  report) as a machine-readable JSON object.
107
151
  Exit code is unchanged (0 on apply, 3 on
@@ -137,6 +181,9 @@ const OPTION_SPEC = {
137
181
  'dry-run': { type: 'boolean' },
138
182
  quiet: { type: 'boolean' },
139
183
  help: { type: 'boolean' },
184
+ // Companion-suggestion mechanism (core-companions spec section 2).
185
+ companion: { type: 'string', multiple: true },
186
+ 'no-companion-suggestions': { type: 'boolean' },
140
187
  };
141
188
 
142
189
  /**
@@ -220,6 +267,25 @@ export async function main(argv, deps = {}) {
220
267
  stderr.write(`[error] blueprint add: ${resolveDeclarations.error}\n`);
221
268
  return 2;
222
269
  }
270
+ // Pre-flight --companion selectors (core-companions spec section
271
+ // 5). Validate BEFORE the apply so a bad selector refuses without
272
+ // side effects (the pin write and the applied contributions both
273
+ // stay untouched). --no-companion-suggestions suppresses the
274
+ // pre-flight too.
275
+ const companionSelectorsPre = parseCompanionOptions(parsed.values.companion);
276
+ if (companionSelectorsPre.error) {
277
+ stderr.write(`[error] blueprint add: ${companionSelectorsPre.error}\n`);
278
+ return 2;
279
+ }
280
+ if (!parsed.values['no-companion-suggestions']) {
281
+ for (const sel of companionSelectorsPre.value) {
282
+ const providerCheck = await validateCompanionProvider({ selector: sel, projectRoot, tree });
283
+ if (providerCheck.error) {
284
+ stderr.write(`[error] blueprint add: ${providerCheck.error}\n`);
285
+ return 2;
286
+ }
287
+ }
288
+ }
223
289
  const result = await applyBlueprint({
224
290
  projectRoot, tree, source,
225
291
  namespaceOverride: parsed.values.namespace,
@@ -288,6 +354,57 @@ export async function main(argv, deps = {}) {
288
354
  if (!parsed.values.quiet) {
289
355
  stdout.write(`[blueprint] applied '${result.slug}' at ${result.version} (${result.contributions.length} contribution(s)).\n`);
290
356
  }
357
+ // Companion-suggestion mechanism (spec 2.6). Runs AFTER the apply
358
+ // writes so a failed apply never produces a suggestion block.
359
+ // Composed of two phases: (a) --companion selectors record pins to
360
+ // rcf/companions.json (spec 2.4); (b) print the resolved
361
+ // suggestion block using the current tree + pins + libraries + shelf.
362
+ // Both phases are suppressed by --no-companion-suggestions.
363
+ if (!parsed.values['no-companion-suggestions']) {
364
+ // Re-walk the tree so applied writes are visible to the resolver.
365
+ const { tree: postTree } = await walkTree({ projectRoot });
366
+ // Phase (a): --companion selectors. Pre-flight validated the
367
+ // shape and provider gate before the apply; here we only write
368
+ // the pins.
369
+ for (const sel of companionSelectorsPre.value) {
370
+ const pinRes = await setCompanionPin({ projectRoot, role: sel.role, provider: sel.provider, now });
371
+ if (pinRes.kind) {
372
+ stderr.write(`[error] blueprint add: ${pinRes.message}\n`);
373
+ return 2;
374
+ }
375
+ if (pinRes.previousProvider && pinRes.previousProvider !== sel.provider) {
376
+ stderr.write(`[blueprint] companion pin for role '${sel.role}' updated: '${pinRes.previousProvider}' -> '${sel.provider}'\n`);
377
+ }
378
+ }
379
+ // Phase (b): print the resolved suggestion block for the just-
380
+ // applied service blueprint. Skipped if the applied blueprint
381
+ // declared no suggestedCompanions[].
382
+ if (Array.isArray(result.suggestedCompanions) && result.suggestedCompanions.length > 0) {
383
+ const pins = await readCompanionsFile(projectRoot);
384
+ const pinsClean = pins && pins.kind ? null : pins;
385
+ const resolved = await resolveCompanions({
386
+ projectRoot,
387
+ tree: postTree,
388
+ suggestedCompanions: result.suggestedCompanions,
389
+ pins: pinsClean,
390
+ });
391
+ // Surface ambiguous refusal as a hard exit-3 (spec 2.4).
392
+ const ambiguous = resolved.find((r) => r.origin === 'ambiguousLibraries');
393
+ if (ambiguous) {
394
+ stderr.write(renderAmbiguousLibraryRefusal({
395
+ role: ambiguous.role,
396
+ providers: ambiguous.ambiguousProviders,
397
+ serviceSlug: result.slug,
398
+ }));
399
+ return 3;
400
+ }
401
+ if (!parsed.values.quiet) {
402
+ stdout.write(`\nSuggested companions this blueprint recommends alongside it:\n`);
403
+ stdout.write(`${renderCompanionLines(resolved)}\n`);
404
+ stdout.write(`Apply either with: rcf define blueprint add <slug>\n`);
405
+ }
406
+ }
407
+ }
291
408
  return 0;
292
409
  }
293
410
 
@@ -405,11 +522,203 @@ export async function main(argv, deps = {}) {
405
522
  return 0;
406
523
  }
407
524
 
525
+ if (verb === 'companions') {
526
+ return handleCompanionsVerb({
527
+ rest,
528
+ parsed,
529
+ projectRoot,
530
+ tree,
531
+ now,
532
+ stdout,
533
+ stderr,
534
+ });
535
+ }
536
+
408
537
  stderr.write(`[error] blueprint: unknown verb '${verb}'\n`);
409
538
  stderr.write(HELP);
410
539
  return 2;
411
540
  }
412
541
 
542
+ /**
543
+ * Parse `--companion <role>=<providerSlug>` occurrences. Slug accepts
544
+ * either `<libraryPrefix>:<slug>` for a library-qualified provider or a
545
+ * bare kebab slug for a shelf provider. Returns { value: [{role,
546
+ * provider}] } on success or { error: string }.
547
+ */
548
+ function parseCompanionOptions(rawList) {
549
+ if (!Array.isArray(rawList) || rawList.length === 0) return { value: [] };
550
+ const out = [];
551
+ for (const raw of rawList) {
552
+ if (typeof raw !== 'string' || raw.length === 0) {
553
+ return { error: `--companion expects <role>=<slug>, got '${raw}'` };
554
+ }
555
+ const eq = raw.indexOf('=');
556
+ if (eq === -1) {
557
+ return { error: `--companion expects <role>=<slug>, got '${raw}' (missing '=')` };
558
+ }
559
+ const role = raw.slice(0, eq);
560
+ const provider = raw.slice(eq + 1);
561
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(role)) {
562
+ return { error: `--companion role '${role}' is not lower camelCase (^[a-z][a-zA-Z0-9]*$).` };
563
+ }
564
+ if (provider.length === 0) {
565
+ return { error: `--companion provider slug is empty for role '${role}'` };
566
+ }
567
+ out.push({ role, provider });
568
+ }
569
+ return { value: out };
570
+ }
571
+
572
+ /**
573
+ * Validate a --companion selector: locate the named provider blueprint
574
+ * (library-qualified or shelf), load it, and refuse when its
575
+ * `providesRoles[]` does not contain the requested role. Message shape
576
+ * per spec 5.
577
+ */
578
+ async function validateCompanionProvider({ selector, projectRoot, tree }) {
579
+ const { role, provider } = selector;
580
+ // Applied?
581
+ const applied = (tree?.manifest?.blueprints ?? []).find((bp) => bp.slug === provider);
582
+ if (applied && typeof applied.source === 'string' && !applied.source.includes(':')) {
583
+ const bp = await loadBlueprint(applied.source);
584
+ if (bp.kind) return { error: `--companion ${role}=${provider}: source blueprint failed to load: ${bp.message}` };
585
+ if (Array.isArray(bp.providesRoles) && bp.providesRoles.includes(role)) return { ok: true };
586
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
587
+ }
588
+ // Library-qualified?
589
+ if (provider.includes(':')) {
590
+ const libraries = await enumerateLibraryProviders({ projectRoot, role });
591
+ if (libraries.some((l) => `${l.libraryPrefix}:${l.slug}` === provider)) return { ok: true };
592
+ // Additional diagnostic: check if the library exists but the blueprint does not declare the role
593
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
594
+ }
595
+ // Shelf?
596
+ const shelfProviders = await enumerateShelfProviders(role);
597
+ if (shelfProviders.includes(provider)) return { ok: true };
598
+ return { error: `--companion ${role}=${provider}: blueprint '${provider}' does not declare providesRoles containing '${role}'.` };
599
+ }
600
+
601
+ /**
602
+ * `rcf define blueprint companions <slug>|set|unset` sub-verb
603
+ * dispatcher (spec 2.5). Read-only against the blueprint tree; set /
604
+ * unset only touch rcf/companions.json.
605
+ */
606
+ async function handleCompanionsVerb({ rest, parsed, projectRoot, tree, now, stdout, stderr }) {
607
+ const asJson = parsed.values.json === true;
608
+ const quiet = parsed.values.quiet === true;
609
+ if (rest.length === 0) {
610
+ stderr.write('[error] blueprint companions: missing <slug> or sub-verb (set|unset)\n');
611
+ return 2;
612
+ }
613
+ const head = rest[0];
614
+ // set / unset sub-verbs.
615
+ if (head === 'set') {
616
+ if (rest.length < 3) {
617
+ stderr.write('[error] blueprint companions set: expected <role> <slug>\n');
618
+ return 2;
619
+ }
620
+ const role = rest[1];
621
+ const provider = rest[2];
622
+ const validation = await validateCompanionProvider({ selector: { role, provider }, projectRoot, tree });
623
+ if (validation.error) {
624
+ stderr.write(`[error] blueprint companions set: ${validation.error}\n`);
625
+ return 2;
626
+ }
627
+ const res = await setCompanionPin({ projectRoot, role, provider, now });
628
+ if (res.kind) {
629
+ stderr.write(`[error] blueprint companions set: ${res.message}\n`);
630
+ return 2;
631
+ }
632
+ if (asJson) {
633
+ stdout.write(`${JSON.stringify({ ok: true, role, provider, previousProvider: res.previousProvider, pinnedAt: now.toISOString() })}\n`);
634
+ } else if (!quiet) {
635
+ if (res.previousProvider && res.previousProvider !== provider) {
636
+ stdout.write(`[blueprint] companion pin for role '${role}' updated: '${res.previousProvider}' -> '${provider}'\n`);
637
+ } else {
638
+ stdout.write(`[blueprint] companion pin for role '${role}' set to '${provider}'\n`);
639
+ }
640
+ }
641
+ return 0;
642
+ }
643
+ if (head === 'unset') {
644
+ if (rest.length < 2) {
645
+ stderr.write('[error] blueprint companions unset: expected <role>\n');
646
+ return 2;
647
+ }
648
+ const role = rest[1];
649
+ const res = await unsetCompanionPin({ projectRoot, role });
650
+ if (res.kind) {
651
+ stderr.write(`[error] blueprint companions unset: ${res.message}\n`);
652
+ return 2;
653
+ }
654
+ if (asJson) {
655
+ stdout.write(`${JSON.stringify({ ok: true, role, removed: true })}\n`);
656
+ } else if (!quiet) {
657
+ stdout.write(`[blueprint] companion pin for role '${role}' removed\n`);
658
+ }
659
+ return 0;
660
+ }
661
+ // <slug> form: print the resolved companion set for an applied
662
+ // service blueprint (spec 2.5 first form).
663
+ const slug = head;
664
+ const applied = (tree?.manifest?.blueprints ?? []).find((bp) => bp.slug === slug);
665
+ if (!applied) {
666
+ stderr.write(`[error] blueprint companions: no applied blueprint with slug '${slug}' on this project.\n`);
667
+ return 2;
668
+ }
669
+ if (typeof applied.source !== 'string' || applied.source.length === 0) {
670
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' has no readable source.\n`);
671
+ return 2;
672
+ }
673
+ if (applied.source.includes(':') && !applied.source.startsWith('/')) {
674
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' has a library-qualified source '${applied.source}' the companions reader cannot resolve directly; refresh the library and retry, or supply the source path.\n`);
675
+ return 2;
676
+ }
677
+ const bp = await loadBlueprint(applied.source);
678
+ if (bp.kind) {
679
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' source failed to load: ${bp.message}\n`);
680
+ return 2;
681
+ }
682
+ if (!Array.isArray(bp.suggestedCompanions) || bp.suggestedCompanions.length === 0) {
683
+ stderr.write(`[error] blueprint companions: applied blueprint '${slug}' declares no suggestedCompanions[].\n`);
684
+ return 2;
685
+ }
686
+ const pins = await readCompanionsFile(projectRoot);
687
+ const pinsClean = pins && pins.kind ? null : pins;
688
+ const resolved = await resolveCompanions({
689
+ projectRoot,
690
+ tree,
691
+ suggestedCompanions: bp.suggestedCompanions,
692
+ pins: pinsClean,
693
+ });
694
+ const ambiguous = resolved.find((r) => r.origin === 'ambiguousLibraries');
695
+ if (ambiguous && !asJson) {
696
+ stderr.write(renderAmbiguousLibraryRefusal({
697
+ role: ambiguous.role,
698
+ providers: ambiguous.ambiguousProviders,
699
+ serviceSlug: slug,
700
+ }));
701
+ return 3;
702
+ }
703
+ if (asJson) {
704
+ stdout.write(`${JSON.stringify({
705
+ slug,
706
+ suggestions: resolved.map((r) => ({
707
+ role: r.role,
708
+ reason: r.reason,
709
+ provider: r.provider,
710
+ origin: r.origin,
711
+ notes: r.notes,
712
+ ...(r.ambiguousProviders ? { ambiguousProviders: r.ambiguousProviders } : {}),
713
+ })),
714
+ }, null, 2)}\n`);
715
+ return ambiguous ? 3 : 0;
716
+ }
717
+ stdout.write(`${slug} suggests companions:\n`);
718
+ stdout.write(`${renderCompanionLines(resolved)}\n`);
719
+ return 0;
720
+ }
721
+
413
722
  /**
414
723
  * Parse `--resolve <topic>=project:<ADR-id>` occurrences into a list
415
724
  * of declarations, attaching an optional shared `reason` (from the
@@ -6,6 +6,7 @@ import { parseArgs } from 'node:util';
6
6
 
7
7
  import { formatErrors } from '#core/errors';
8
8
  import { checkCodeNodeResolution, walkTree } from '#core/store';
9
+ import { validateCompanionPinsResolvable } from '../blueprint/companions.js';
9
10
  import { findProjectRoot } from '../view/index.js';
10
11
 
11
12
  const OPTION_SPEC = {
@@ -126,6 +127,12 @@ export async function main(argv, deps = {}) {
126
127
  const staleErrors = await checkCodeNodeResolution({ projectRoot, tree });
127
128
  errors.push(...staleErrors);
128
129
  }
130
+ // Core-companions spec section 5: rcf/companions.json refusals ride
131
+ // the same exit path as schema / referential-integrity errors. An
132
+ // absent companions.json is not itself an error; a malformed file or
133
+ // a pin that names no known provider is exit 3.
134
+ const companionsErrors = await validateCompanionPinsResolvable({ projectRoot, tree });
135
+ errors.push(...companionsErrors);
129
136
  if (flags.json) {
130
137
  const issues = errors.map((e) => ({
131
138
  id: e.documentId ?? null,