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.
- package/CHANGELOG.md +53 -0
- package/bin/rcf.js +5 -0
- package/blueprints/application-api-rest/blueprint.json +12 -4
- package/blueprints/application-api-rest/contributions/adrs/adr-304-application-api-rest-logging.json +3 -3
- package/blueprints/application-api-rest/docs/topics.md +4 -2
- package/blueprints/application-error-handling/README.md +42 -0
- package/blueprints/application-error-handling/assets/schemas/error-record.schema.json +17 -0
- package/blueprints/application-error-handling/blueprint.json +27 -0
- package/blueprints/application-error-handling/contributions/adrs/adr-1701-application-error-handling-record-shape.json +25 -0
- package/blueprints/application-error-handling/contributions/adrs/adr-1702-application-error-handling-classification-vocabulary.json +20 -0
- package/blueprints/application-error-handling/contributions/adrs/adr-1703-application-error-handling-transport-mapping.json +20 -0
- package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-001.json +15 -0
- package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-002.json +15 -0
- package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-003.json +15 -0
- package/blueprints/application-error-handling/contributions/requirements/application-error-handling-req-004.json +15 -0
- package/blueprints/application-error-handling/contributions/tacs/tac-1701-application-error-handling-boundary.json +45 -0
- package/blueprints/application-error-handling/contributions/tacs/tac-1702-application-error-handling-record-factory.json +40 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16101.json +25 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16102.json +25 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16103.json +34 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16104.json +25 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16105.json +25 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16106.json +25 -0
- package/blueprints/application-error-handling/contributions/user-stories/application-error-handling-us-16107.json +25 -0
- package/blueprints/application-error-handling/docs/topics.md +24 -0
- package/blueprints/application-error-handling/guide/application-error-handling.md +35 -0
- package/blueprints/application-spa/blueprint.json +12 -2
- package/blueprints/application-spa/docs/topics.md +2 -0
- package/blueprints/observability-logging/README.md +44 -0
- package/blueprints/observability-logging/assets/samples/log-line.json +13 -0
- package/blueprints/observability-logging/blueprint.json +27 -0
- package/blueprints/observability-logging/contributions/adrs/adr-1601-observability-logging-line-shape.json +25 -0
- package/blueprints/observability-logging/contributions/adrs/adr-1602-observability-logging-correlation-id-header.json +25 -0
- package/blueprints/observability-logging/contributions/adrs/adr-1603-observability-logging-redaction-categories.json +25 -0
- package/blueprints/observability-logging/contributions/adrs/adr-1604-observability-logging-level-vocabulary.json +20 -0
- package/blueprints/observability-logging/contributions/requirements/observability-logging-req-001.json +15 -0
- package/blueprints/observability-logging/contributions/requirements/observability-logging-req-002.json +15 -0
- package/blueprints/observability-logging/contributions/requirements/observability-logging-req-003.json +15 -0
- package/blueprints/observability-logging/contributions/requirements/observability-logging-req-004.json +15 -0
- package/blueprints/observability-logging/contributions/requirements/observability-logging-req-005.json +15 -0
- package/blueprints/observability-logging/contributions/tacs/tac-1601-observability-logging-logger-factory.json +46 -0
- package/blueprints/observability-logging/contributions/tacs/tac-1602-observability-logging-redaction-boundary.json +27 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15101.json +34 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15102.json +43 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15103.json +34 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15104.json +25 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15105.json +34 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15106.json +25 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15107.json +25 -0
- package/blueprints/observability-logging/contributions/user-stories/observability-logging-us-15108.json +25 -0
- package/blueprints/observability-logging/docs/topics.md +21 -0
- package/blueprints/observability-logging/guide/observability-logging.md +36 -0
- package/fixtures/canary-manifest.json +6 -6
- package/guidance/harness-template.md +8 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +8 -0
- package/package.json +2 -2
- package/rcf/code-nodes/cn-077.json +14 -0
- package/rcf/code-nodes/cn-078.json +15 -0
- package/rcf/code-nodes/cn-079.json +14 -0
- package/rcf/code-nodes/cn-080.json +15 -0
- package/rcf/code-nodes/cn-081.json +15 -0
- package/rcf/code-nodes/cn-082.json +15 -0
- package/rcf/code-nodes/cn-083.json +14 -0
- package/rcf/code-nodes/cn-084.json +15 -0
- package/rcf/code-nodes/cn-085.json +14 -0
- package/rcf/code-nodes/cn-086.json +16 -0
- package/rcf/code-nodes/cn-087.json +14 -0
- package/rcf/code-nodes/cn-088.json +14 -0
- package/rcf/code-nodes/cn-089.json +14 -0
- package/rcf/code-nodes/cn-090.json +14 -0
- package/rcf/code-nodes/cn-091.json +14 -0
- package/rcf/code-nodes/cn-092.json +15 -0
- package/rcf/code-nodes/cn-093.json +15 -0
- package/rcf/code-nodes/cn-094.json +14 -0
- package/rcf/code-nodes/cn-095.json +14 -0
- package/rcf/code-nodes/cn-096.json +14 -0
- package/rcf/code-nodes/cn-097.json +14 -0
- package/rcf/code-nodes/cn-098.json +13 -0
- package/rcf/code-nodes/cn-099.json +13 -0
- package/rcf/code-nodes/cn-100.json +13 -0
- package/rcf/code-nodes/cn-101.json +13 -0
- package/rcf/code-nodes/cn-102.json +13 -0
- package/rcf/code-nodes/cn-103.json +13 -0
- package/rcf/code-nodes/cn-104.json +13 -0
- package/rcf/code-nodes/cn-105.json +13 -0
- package/rcf/evals/eval-001.json +55 -0
- package/rcf/fbs/fbs-028.json +27 -0
- package/rcf/fbs/fbs-029.json +27 -0
- package/rcf/fbs/fbs-030.json +27 -0
- package/rcf/fbs/fbs-031.json +27 -0
- package/rcf/fbs/fbs-032.json +27 -0
- package/rcf/fbs/fbs-033.json +27 -0
- package/rcf/fbs/fbs-034.json +27 -0
- package/rcf/fbs/fbs-035.json +18 -0
- package/rcf/requirements/req-013.json +22 -0
- package/rcf/requirements/req-014.json +22 -0
- package/rcf/requirements/req-015.json +21 -0
- package/rcf/requirements/req-016.json +37 -0
- package/rcf/test-suites/ts-038.json +41 -0
- package/rcf/test-suites/ts-039.json +41 -0
- package/rcf/test-suites/ts-040.json +57 -0
- package/rcf/test-suites/ts-041.json +57 -0
- package/rcf/test-suites/ts-042.json +57 -0
- package/rcf/test-suites/ts-043.json +49 -0
- package/rcf/test-suites/ts-044.json +41 -0
- package/rcf/test-suites/ts-045.json +46 -0
- package/rcf/user-stories/us-1301.json +43 -0
- package/rcf/user-stories/us-1302.json +43 -0
- package/rcf/user-stories/us-1401.json +43 -0
- package/rcf/user-stories/us-1402.json +43 -0
- package/rcf/user-stories/us-1403.json +43 -0
- package/rcf/user-stories/us-1404.json +43 -0
- package/rcf/user-stories/us-1501.json +43 -0
- package/rcf/user-stories/us-1601.json +37 -0
- package/releases/releases.yaml +20 -1
- package/src/blueprint/apply.js +8 -0
- package/src/blueprint/companions.js +485 -0
- package/src/blueprint/index.js +16 -0
- package/src/blueprint/loader.js +243 -1
- package/src/cli/blueprint.js +309 -0
- package/src/cli/create.js +14 -0
- package/src/cli/eval-coverage.js +221 -0
- package/src/cli/eval.js +43 -0
- package/src/cli/finalise.js +64 -0
- package/src/cli/help.js +4 -0
- package/src/cli/validate.js +7 -0
- package/src/core/store/ids.js +5 -1
- package/src/core/store/init.js +4 -0
- package/src/core/store/loader.js +4 -0
- package/src/core/store/validator.js +8 -1
- package/src/core/store/walker.js +71 -2
- package/src/core/store/writer.js +4 -0
- package/src/eval/judge.js +338 -0
- package/src/finalise/index.js +8 -0
- package/src/finalise/ingest.js +27 -0
- package/src/finalise/ship-without-eval.js +123 -0
- package/src/query/eval-coverage.js +162 -0
- package/src/verify/chain/index.js +67 -0
- package/src/verify/cli/run.js +15 -0
- package/src/verify/engine/index.js +10 -0
- 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
|
+
}
|
package/src/blueprint/index.js
CHANGED
|
@@ -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,
|