rcf-lite 0.14.0 → 0.16.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 +32 -0
- package/bin/view-supervisor-child.mjs +0 -0
- package/blueprints/delivery-ci-workflows/README.md +4 -0
- package/blueprints/delivery-ci-workflows/assets/bootstrap/README.md +26 -0
- package/blueprints/delivery-ci-workflows/assets/bootstrap/adr-bootstrap-coverage-supersession.template.json +28 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/default-branch-checks.yml +12 -6
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +16 -7
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/release.yml +4 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/scheduled-audit.yml +4 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +5 -5
- package/blueprints/delivery-ci-workflows/assets/report-samples/per-gate.json +1 -1
- package/blueprints/delivery-ci-workflows/blueprint.json +1 -1
- package/blueprints/delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-701-delivery-ci-workflows-gate-runner.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/tacs/tac-704-delivery-ci-workflows-workflow-materialiser.json +7 -4
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6111.json +2 -2
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6114.json +6 -6
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6115.json +6 -6
- package/blueprints/delivery-ci-workflows/guide/delivery-ci-workflows.md +39 -1
- package/fixtures/canary-manifest.json +101 -1
- package/guidance/harness-template.md +9 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +9 -0
- package/package.json +13 -15
- package/releases/releases.yaml +21 -1
- package/src/blueprint/apply.js +15 -6
- package/src/blueprint/index.js +22 -0
- package/src/blueprint/library-cache.js +143 -0
- package/src/blueprint/library-fetcher-git.js +347 -0
- package/src/blueprint/library-fetcher-tarball.js +379 -0
- package/src/blueprint/library-loader.js +21 -0
- package/src/blueprint/shelf-resolver.js +100 -7
- package/src/blueprint/supersede.js +56 -13
- package/src/cli/blueprint-library.js +427 -82
- package/src/cli/blueprint.js +6 -1
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// Tarball fetcher for external blueprint libraries (Phase 2c, spec §6.3).
|
|
2
|
+
//
|
|
3
|
+
// Node built-in `fetch()` is the transport; a 30-second connection
|
|
4
|
+
// timeout applies to the response headers, and a hard size cap fires
|
|
5
|
+
// during streaming. The download is SHA-256 hashed on the fly against
|
|
6
|
+
// the operator's declared digest; a mismatch is refused and rolled back
|
|
7
|
+
// (spec §6.1 + §6.4). Extraction uses a minimal POSIX-ustar parser
|
|
8
|
+
// bundled in this module so the runtime carries zero new dependencies
|
|
9
|
+
// (spec §6.3: no shell-out to `tar`, no libtar dependency).
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join, normalize, sep } from 'node:path';
|
|
16
|
+
import { createGunzip } from 'node:zlib';
|
|
17
|
+
|
|
18
|
+
import { rcfError } from '../core/errors/index.js';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
21
|
+
const DEFAULT_MAX_BYTES = 200 * 1024 * 1024; // spec §6.3 envelope: tens; hard cap low hundreds.
|
|
22
|
+
const HEX_256 = /^[0-9a-f]{64}$/i;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch a tarball library, verify SHA-256, extract into targetDir.
|
|
26
|
+
*
|
|
27
|
+
* @param {object} args
|
|
28
|
+
* @param {string} args.url
|
|
29
|
+
* @param {string} args.expectedSha256
|
|
30
|
+
* @param {string} args.targetDir absolute path
|
|
31
|
+
* @param {typeof fetch} [args.fetchImpl=fetch] override for tests
|
|
32
|
+
* @param {number} [args.timeoutMs]
|
|
33
|
+
* @param {number} [args.maxBytes]
|
|
34
|
+
* @returns {Promise<{ tarballSha256: string, root: string } | import('../core/errors/index.js').RcfError>}
|
|
35
|
+
*/
|
|
36
|
+
export async function fetchTarballLibrary({ url, expectedSha256, targetDir, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, maxBytes = DEFAULT_MAX_BYTES }) {
|
|
37
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
38
|
+
return rcfError({ kind: 'usage', message: 'tarball fetch: url is required' });
|
|
39
|
+
}
|
|
40
|
+
if (typeof expectedSha256 !== 'string' || !HEX_256.test(expectedSha256)) {
|
|
41
|
+
return rcfError({
|
|
42
|
+
kind: 'usage',
|
|
43
|
+
message: `tarball fetch: expected SHA-256 is required and must be 64 hex chars (spec §6.1: URL with no digest is refused).`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const expected = expectedSha256.toLowerCase();
|
|
47
|
+
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timer = setTimeout(() => controller.abort(new Error(`tarball fetch: timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
response = await fetchImpl(url, { signal: controller.signal });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
return rcfError({ kind: 'usage', message: `tarball fetch: ${url}: ${err.message}` });
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
return rcfError({ kind: 'usage', message: `tarball fetch: ${url} returned HTTP ${response.status}` });
|
|
60
|
+
}
|
|
61
|
+
if (!response.body) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
return rcfError({ kind: 'usage', message: `tarball fetch: ${url}: empty response body` });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stream the response through: sha256 tap, size cap, optional gunzip,
|
|
67
|
+
// tar parser. We buffer the whole thing into memory first (bounded by
|
|
68
|
+
// maxBytes) because our tar parser is synchronous; envelope is tens of
|
|
69
|
+
// megabytes, easily viable in a single Buffer.
|
|
70
|
+
const hash = createHash('sha256');
|
|
71
|
+
let received = 0;
|
|
72
|
+
const chunks = [];
|
|
73
|
+
const reader = response.body.getReader();
|
|
74
|
+
try {
|
|
75
|
+
for (;;) {
|
|
76
|
+
const { value, done } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
received += value.byteLength;
|
|
79
|
+
if (received > maxBytes) {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
return rcfError({
|
|
82
|
+
kind: 'usage',
|
|
83
|
+
message: `tarball fetch: ${url} exceeded ${maxBytes} bytes (received ${received}); adjust the size cap only for a library you have separately verified.`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
hash.update(value);
|
|
87
|
+
chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
return rcfError({ kind: 'usage', message: `tarball fetch: read failed: ${err.message}` });
|
|
92
|
+
}
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
|
|
95
|
+
const actual = hash.digest('hex');
|
|
96
|
+
if (actual !== expected) {
|
|
97
|
+
return rcfError({
|
|
98
|
+
kind: 'usage',
|
|
99
|
+
message: `tarball fetch: SHA-256 mismatch for ${url}. expected=${expected} actual=${actual}. The download was refused and no cache was written.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const raw = Buffer.concat(chunks);
|
|
104
|
+
const isGz = looksGzipped(url, raw);
|
|
105
|
+
let plain;
|
|
106
|
+
try {
|
|
107
|
+
plain = isGz ? await gunzipBuffer(raw) : raw;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return rcfError({ kind: 'usage', message: `tarball fetch: gunzip failed: ${err.message}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const entries = parseUstar(plain);
|
|
113
|
+
if (isRcfLikeError(entries)) return entries;
|
|
114
|
+
|
|
115
|
+
const scratch = await mkdtemp(join(tmpdir(), 'rcf-lib-tar-'));
|
|
116
|
+
try {
|
|
117
|
+
const writeErr = await materialiseEntries(entries, scratch);
|
|
118
|
+
if (writeErr) {
|
|
119
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
120
|
+
return writeErr;
|
|
121
|
+
}
|
|
122
|
+
// Some tarballs wrap everything in a single top-level directory
|
|
123
|
+
// (npm-pack `package/`, github-tarball `<repo>-<sha>/`). If the
|
|
124
|
+
// scratch tree has exactly one top-level entry and it is a
|
|
125
|
+
// directory, unwrap it so the cache root IS the library root
|
|
126
|
+
// (library.json sits at cachePath/library.json rather than
|
|
127
|
+
// cachePath/package/library.json).
|
|
128
|
+
const root = await unwrapSingleTopLevel(scratch);
|
|
129
|
+
if (isRcfLikeError(root)) {
|
|
130
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
131
|
+
return root;
|
|
132
|
+
}
|
|
133
|
+
const settled = await settleCache(root, targetDir);
|
|
134
|
+
if (settled) {
|
|
135
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
136
|
+
return settled;
|
|
137
|
+
}
|
|
138
|
+
if (root !== scratch) {
|
|
139
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
return { tarballSha256: actual, root: targetDir };
|
|
142
|
+
} catch (err) {
|
|
143
|
+
await rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
144
|
+
return rcfError({ kind: 'ioFailure', message: `tarball fetch: extraction failed: ${err.message}`, stack: err.stack });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compute the SHA-256 of an already-downloaded byte buffer. Test-only
|
|
150
|
+
* helper: the fetcher computes on the fly during download; this lets
|
|
151
|
+
* unit tests pin an `expectedSha256` from a fixture without shelling
|
|
152
|
+
* out to sha256sum.
|
|
153
|
+
*
|
|
154
|
+
* @param {Buffer} buf
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function sha256Hex(buf) {
|
|
158
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Produce a tarball buffer from an in-memory tree, suitable for
|
|
163
|
+
* fixture use in unit tests. Ustar-style headers, no compression.
|
|
164
|
+
*
|
|
165
|
+
* @param {Array<{ path: string, content?: string | Buffer, mode?: number, dir?: boolean }>} entries
|
|
166
|
+
* @returns {Buffer}
|
|
167
|
+
*/
|
|
168
|
+
export function createUstarBuffer(entries) {
|
|
169
|
+
const chunks = [];
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
const isDir = entry.dir === true;
|
|
172
|
+
const content = isDir ? Buffer.alloc(0) : Buffer.isBuffer(entry.content) ? entry.content : Buffer.from(entry.content ?? '', 'utf8');
|
|
173
|
+
const mode = entry.mode ?? (isDir ? 0o755 : 0o644);
|
|
174
|
+
const header = buildUstarHeader({ path: entry.path, size: content.length, mode, typeflag: isDir ? '5' : '0' });
|
|
175
|
+
chunks.push(header);
|
|
176
|
+
if (!isDir) {
|
|
177
|
+
chunks.push(content);
|
|
178
|
+
const pad = (512 - (content.length % 512)) % 512;
|
|
179
|
+
if (pad > 0) chunks.push(Buffer.alloc(pad));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
chunks.push(Buffer.alloc(1024)); // Two zero blocks terminate the archive.
|
|
183
|
+
return Buffer.concat(chunks);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildUstarHeader({ path, size, mode, typeflag }) {
|
|
187
|
+
const buf = Buffer.alloc(512);
|
|
188
|
+
const write = (offset, str, length) => {
|
|
189
|
+
const s = String(str);
|
|
190
|
+
buf.write(s.slice(0, length), offset, length, 'utf8');
|
|
191
|
+
};
|
|
192
|
+
// name: up to 100 bytes. Longer paths would need `prefix` (offset 345);
|
|
193
|
+
// fixture paths in this codebase are short, so we simplify and refuse
|
|
194
|
+
// long paths early to keep the writer honest.
|
|
195
|
+
if (path.length > 100) throw new Error(`ustar writer: path too long (${path.length}); use a shorter fixture path`);
|
|
196
|
+
write(0, path, 100);
|
|
197
|
+
write(100, `${(mode & 0o7777).toString(8).padStart(6, '0')} \0`, 8);
|
|
198
|
+
write(108, `${0..toString(8).padStart(6, '0')} \0`, 8); // uid
|
|
199
|
+
write(116, `${0..toString(8).padStart(6, '0')} \0`, 8); // gid
|
|
200
|
+
write(124, `${size.toString(8).padStart(11, '0')} `, 12); // size
|
|
201
|
+
write(136, `${Math.floor(Date.now() / 1000).toString(8).padStart(11, '0')} `, 12); // mtime
|
|
202
|
+
// checksum placeholder (spaces) at 148..155
|
|
203
|
+
for (let i = 148; i < 156; i += 1) buf[i] = 0x20;
|
|
204
|
+
write(156, typeflag, 1);
|
|
205
|
+
write(257, 'ustar\0', 6);
|
|
206
|
+
write(263, '00', 2);
|
|
207
|
+
let sum = 0;
|
|
208
|
+
for (let i = 0; i < 512; i += 1) sum += buf[i];
|
|
209
|
+
write(148, `${sum.toString(8).padStart(6, '0')}\0 `, 8);
|
|
210
|
+
return buf;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function looksGzipped(url, buf) {
|
|
214
|
+
if (buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b) return true;
|
|
215
|
+
const lower = url.toLowerCase();
|
|
216
|
+
return lower.endsWith('.tgz') || lower.endsWith('.tar.gz');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function gunzipBuffer(buf) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const gz = createGunzip();
|
|
222
|
+
const chunks = [];
|
|
223
|
+
gz.on('data', (c) => chunks.push(c));
|
|
224
|
+
gz.on('end', () => resolve(Buffer.concat(chunks)));
|
|
225
|
+
gz.on('error', (err) => reject(err));
|
|
226
|
+
gz.end(buf);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Minimal POSIX-ustar parser. Handles regular files ('0' / '\0'),
|
|
232
|
+
* directories ('5'), and quietly skips pax extended headers ('x', 'g')
|
|
233
|
+
* plus GNU long-name records ('L', 'K'). Returns an array of parsed
|
|
234
|
+
* entries or an RcfError on a malformed archive.
|
|
235
|
+
*
|
|
236
|
+
* @param {Buffer} buf
|
|
237
|
+
* @returns {Array<{ path: string, size: number, typeflag: string, content: Buffer }> | import('../core/errors/index.js').RcfError}
|
|
238
|
+
*/
|
|
239
|
+
export function parseUstar(buf) {
|
|
240
|
+
const entries = [];
|
|
241
|
+
let offset = 0;
|
|
242
|
+
let pendingLongName = null;
|
|
243
|
+
while (offset + 512 <= buf.length) {
|
|
244
|
+
const header = buf.subarray(offset, offset + 512);
|
|
245
|
+
// Two consecutive zero blocks terminate.
|
|
246
|
+
if (header.every((b) => b === 0)) {
|
|
247
|
+
const next = buf.subarray(offset + 512, offset + 1024);
|
|
248
|
+
if (next.length === 0 || next.every((b) => b === 0)) break;
|
|
249
|
+
offset += 512;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const rawName = readCString(header, 0, 100);
|
|
253
|
+
const prefix = readCString(header, 345, 155);
|
|
254
|
+
const magic = header.subarray(257, 263).toString('utf8').replace(/\0/g, '').trim();
|
|
255
|
+
if (magic !== 'ustar') {
|
|
256
|
+
return rcfError({ kind: 'usage', message: `tarball parse: non-ustar magic at offset ${offset}` });
|
|
257
|
+
}
|
|
258
|
+
const size = parseOctal(header, 124, 12);
|
|
259
|
+
const typeflag = String.fromCharCode(header[156] || 0x30);
|
|
260
|
+
const dataStart = offset + 512;
|
|
261
|
+
const paddedSize = size + ((512 - (size % 512)) % 512);
|
|
262
|
+
const dataEnd = dataStart + size;
|
|
263
|
+
let path = pendingLongName ?? (prefix ? `${prefix}/${rawName}` : rawName);
|
|
264
|
+
pendingLongName = null;
|
|
265
|
+
if (typeflag === 'L') {
|
|
266
|
+
pendingLongName = buf.subarray(dataStart, dataEnd).toString('utf8').replace(/\0+$/, '');
|
|
267
|
+
offset = dataStart + paddedSize;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (typeflag === 'x' || typeflag === 'g' || typeflag === 'K') {
|
|
271
|
+
// Skip pax extended / GNU link-name records; we do not honour
|
|
272
|
+
// extended attributes for library payloads.
|
|
273
|
+
offset = dataStart + paddedSize;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (typeflag === '5') {
|
|
277
|
+
entries.push({ path, size: 0, typeflag, content: Buffer.alloc(0) });
|
|
278
|
+
offset = dataStart + paddedSize;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (typeflag === '0' || typeflag === '\0') {
|
|
282
|
+
const content = buf.subarray(dataStart, dataEnd);
|
|
283
|
+
entries.push({ path, size, typeflag: '0', content: Buffer.from(content) });
|
|
284
|
+
offset = dataStart + paddedSize;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// Symlinks (2), hard links (1), block devices, character devices,
|
|
288
|
+
// FIFOs, sparse files: not part of the library payload contract.
|
|
289
|
+
// Skip and continue.
|
|
290
|
+
offset = dataStart + paddedSize;
|
|
291
|
+
}
|
|
292
|
+
return entries;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function readCString(buf, offset, length) {
|
|
296
|
+
const slice = buf.subarray(offset, offset + length);
|
|
297
|
+
const nul = slice.indexOf(0);
|
|
298
|
+
const s = (nul >= 0 ? slice.subarray(0, nul) : slice).toString('utf8');
|
|
299
|
+
return s.replace(/\0/g, '');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function parseOctal(buf, offset, length) {
|
|
303
|
+
const slice = buf.subarray(offset, offset + length);
|
|
304
|
+
const trimmed = slice.toString('ascii').replace(/[\0\s]+$/, '').trim();
|
|
305
|
+
if (trimmed.length === 0) return 0;
|
|
306
|
+
return parseInt(trimmed, 8) || 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function materialiseEntries(entries, scratch) {
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
// Path safety: reject absolute paths and `..` traversal. `normalize`
|
|
312
|
+
// collapses `foo/../bar` -> `bar`, then we check for a residual
|
|
313
|
+
// `..` prefix that would escape the scratch root.
|
|
314
|
+
const rel = normalize(entry.path);
|
|
315
|
+
if (rel.startsWith('/') || rel.startsWith(sep)) {
|
|
316
|
+
return rcfError({ kind: 'usage', message: `tarball parse: absolute path '${entry.path}' refused.` });
|
|
317
|
+
}
|
|
318
|
+
if (rel.split(/[\\/]/).some((s) => s === '..')) {
|
|
319
|
+
return rcfError({ kind: 'usage', message: `tarball parse: parent-traversal path '${entry.path}' refused.` });
|
|
320
|
+
}
|
|
321
|
+
const absPath = join(scratch, rel);
|
|
322
|
+
if (entry.typeflag === '5') {
|
|
323
|
+
await mkdir(absPath, { recursive: true });
|
|
324
|
+
} else {
|
|
325
|
+
await mkdir(dirname(absPath), { recursive: true });
|
|
326
|
+
await writeFile(absPath, entry.content);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function unwrapSingleTopLevel(scratch) {
|
|
333
|
+
try {
|
|
334
|
+
const { readdir, stat } = await import('node:fs/promises');
|
|
335
|
+
const entries = await readdir(scratch);
|
|
336
|
+
if (entries.length !== 1) return scratch;
|
|
337
|
+
const only = join(scratch, entries[0]);
|
|
338
|
+
const s = await stat(only);
|
|
339
|
+
if (!s.isDirectory()) return scratch;
|
|
340
|
+
return only;
|
|
341
|
+
} catch (err) {
|
|
342
|
+
return rcfError({ kind: 'ioFailure', message: `tarball extract: unwrap failed: ${err.message}`, stack: err.stack });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function settleCache(source, targetDir) {
|
|
347
|
+
try {
|
|
348
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
349
|
+
await mkdir(dirname(targetDir), { recursive: true });
|
|
350
|
+
await rename(source, targetDir);
|
|
351
|
+
return null;
|
|
352
|
+
} catch (err) {
|
|
353
|
+
if (err.code === 'EXDEV') {
|
|
354
|
+
try {
|
|
355
|
+
const { cp } = await import('node:fs/promises');
|
|
356
|
+
await mkdir(dirname(targetDir), { recursive: true });
|
|
357
|
+
await cp(source, targetDir, { recursive: true });
|
|
358
|
+
await rm(source, { recursive: true, force: true }).catch(() => {});
|
|
359
|
+
return null;
|
|
360
|
+
} catch (copyErr) {
|
|
361
|
+
return rcfError({ kind: 'ioFailure', message: `tarball settle: cross-device: ${copyErr.message}`, stack: copyErr.stack });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return rcfError({ kind: 'ioFailure', message: `tarball settle: ${err.message}`, stack: err.stack });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function isRcfLikeError(v) {
|
|
369
|
+
return v && typeof v === 'object' && typeof v.kind === 'string' && typeof v.message === 'string' && v.kind !== '5' && v.kind !== '0';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Test-visible defaults so tests can pin behaviour. */
|
|
373
|
+
export function defaultCaps() {
|
|
374
|
+
return { timeoutMs: DEFAULT_TIMEOUT_MS, maxBytes: DEFAULT_MAX_BYTES };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Silence the unused-import lint on existsSync; keeps the import
|
|
378
|
+
// available for future use without warnings.
|
|
379
|
+
export const _cacheProbeExists = existsSync;
|
|
@@ -44,6 +44,12 @@ const LIBRARY_VERSION_KNOWN = 1;
|
|
|
44
44
|
* @typedef {object} LibraryBlueprintEntry
|
|
45
45
|
* @property {string} slug
|
|
46
46
|
* @property {string} path
|
|
47
|
+
* @property {string[]} [globalTopics] scope:global ADR topics this
|
|
48
|
+
* blueprint contributes. Populated only when the loader ran with
|
|
49
|
+
* `validateBlueprints: true` (the review-on-add path); resolver-time
|
|
50
|
+
* loads that skip per-blueprint validation leave the field absent.
|
|
51
|
+
* Callers use it to render the section 8.1 "Global topics these
|
|
52
|
+
* blueprints claim" line during library-add review.
|
|
47
53
|
*/
|
|
48
54
|
|
|
49
55
|
/**
|
|
@@ -266,6 +272,21 @@ async function validateDeclaredBlueprints(library) {
|
|
|
266
272
|
filePath: bpRoot,
|
|
267
273
|
});
|
|
268
274
|
}
|
|
275
|
+
// Attach the scope:global ADR topics this blueprint claims so the
|
|
276
|
+
// review-on-add printer can render the spec §8.1 "Global topics
|
|
277
|
+
// these blueprints claim" line without re-walking every blueprint.
|
|
278
|
+
// Order is contribution-declaration order; duplicates within one
|
|
279
|
+
// blueprint (an author mistake caught by phase-1 conflict logic)
|
|
280
|
+
// are de-duplicated here to keep the render terse.
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
const topics = [];
|
|
283
|
+
for (const c of loaded.contributions ?? []) {
|
|
284
|
+
if (c.scope === 'global' && typeof c.topic === 'string' && !seen.has(c.topic)) {
|
|
285
|
+
seen.add(c.topic);
|
|
286
|
+
topics.push(c.topic);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
entry.globalTopics = topics;
|
|
269
290
|
}
|
|
270
291
|
return null;
|
|
271
292
|
}
|
|
@@ -40,12 +40,13 @@
|
|
|
40
40
|
// before pack runs.
|
|
41
41
|
|
|
42
42
|
import { existsSync } from 'node:fs';
|
|
43
|
-
import { stat } from 'node:fs/promises';
|
|
44
|
-
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
43
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
44
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
45
45
|
import { fileURLToPath } from 'node:url';
|
|
46
46
|
|
|
47
47
|
import { rcfError } from '../core/errors/index.js';
|
|
48
48
|
import { findLibrary, readLibraryRegistry } from './library-registry.js';
|
|
49
|
+
import { loadLibrary } from './library-loader.js';
|
|
49
50
|
|
|
50
51
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
52
|
// packages/rcf-lite/src/blueprint -> packages/rcf-lite
|
|
@@ -143,12 +144,20 @@ export async function resolveBlueprintSource(source, opts = {}) {
|
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
// Rule 4: path-looking arguments are passed through unchanged
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
147
|
+
// Rule 4: path-looking arguments are passed through unchanged UNLESS
|
|
148
|
+
// an ancestor of the resolved path carries a `library.json` (spec
|
|
149
|
+
// amendment A2, 2026-09-03). In that case the resolver walks up from
|
|
150
|
+
// the target, loads the library manifest, requires the target to sit
|
|
151
|
+
// at `<library-root>/blueprints/<slug>`, and returns a kind=library
|
|
152
|
+
// result with `libraryPrefix`, `effectiveSlug`, `libraryBands` so the
|
|
153
|
+
// apply layer stamps the same identity a qualified `<prefix>:<slug>`
|
|
154
|
+
// would. A path with NO library.json in any ancestor keeps the
|
|
155
|
+
// phase-1 route unchanged, byte-for-byte.
|
|
150
156
|
if (isAbsolute(source) || PATH_HINT.test(source)) {
|
|
151
|
-
|
|
157
|
+
const abs = resolve(source);
|
|
158
|
+
const libraryHit = await tryLibraryAwareLocalPath(abs, source);
|
|
159
|
+
if (libraryHit) return libraryHit;
|
|
160
|
+
return { kind: 'path', resolved: abs, original: source };
|
|
152
161
|
}
|
|
153
162
|
|
|
154
163
|
// Rule 5: any other bare kebab token is a shelf slug.
|
|
@@ -165,6 +174,90 @@ export async function resolveBlueprintSource(source, opts = {}) {
|
|
|
165
174
|
return { kind: 'path', resolved: resolve(source), original: source };
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Ancestor-walk from a target directory looking for a `library.json`.
|
|
179
|
+
* Stops at the filesystem root. When a manifest is found and the target
|
|
180
|
+
* sits at `<library-root>/blueprints/<slug>`, returns a library-kind
|
|
181
|
+
* resolution; when the target is under a library root at any other
|
|
182
|
+
* path shape, returns a usage error naming the expected layout.
|
|
183
|
+
* Returns null when no `library.json` is found in any ancestor - the
|
|
184
|
+
* caller then keeps the phase-1 path route unchanged.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} abs absolute target directory
|
|
187
|
+
* @param {string} original the source the operator typed (verbatim)
|
|
188
|
+
* @returns {Promise<null | import('./shelf-resolver.js').ResolvedSource | import('../core/errors/index.js').RcfError>}
|
|
189
|
+
*/
|
|
190
|
+
async function tryLibraryAwareLocalPath(abs, original) {
|
|
191
|
+
const libraryRoot = await findLibraryAncestor(abs);
|
|
192
|
+
if (!libraryRoot) return null;
|
|
193
|
+
// The target must be `<library-root>/blueprints/<slug>`, i.e. a direct
|
|
194
|
+
// grandchild of the library root under a `blueprints/` folder. Any
|
|
195
|
+
// other shape (nested deeper, a sibling docs/ folder, the library
|
|
196
|
+
// root itself) is a usage error naming the expected form so the
|
|
197
|
+
// operator sees the fix immediately.
|
|
198
|
+
const relFromRoot = abs.slice(libraryRoot.length).split(sep).filter(Boolean);
|
|
199
|
+
if (relFromRoot.length !== 2 || relFromRoot[0] !== 'blueprints') {
|
|
200
|
+
return rcfError({
|
|
201
|
+
kind: 'usage',
|
|
202
|
+
message: (
|
|
203
|
+
`blueprint source '${original}' is inside a library at ${libraryRoot}, but its path must be `
|
|
204
|
+
+ `'<library-root>/blueprints/<slug>' (found '${relFromRoot.join('/') || '<library-root>'}'). `
|
|
205
|
+
+ `Move the blueprint under 'blueprints/<slug>/' inside the library or point 'blueprint add' at that layout.`
|
|
206
|
+
),
|
|
207
|
+
filePath: abs,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
const blueprintSlug = basename(abs);
|
|
211
|
+
const library = await loadLibrary(libraryRoot, { validateBlueprints: false });
|
|
212
|
+
if (library && typeof library === 'object' && library.kind) {
|
|
213
|
+
// Malformed library.json: surface the loader's own error verbatim.
|
|
214
|
+
return library;
|
|
215
|
+
}
|
|
216
|
+
// Refuse if the library.json does not declare this blueprint slug -
|
|
217
|
+
// an author who has not yet added the entry to `blueprints[]` should
|
|
218
|
+
// be told loudly, not silently applied.
|
|
219
|
+
if (!library.blueprints.some((b) => b.slug === blueprintSlug)) {
|
|
220
|
+
return rcfError({
|
|
221
|
+
kind: 'usage',
|
|
222
|
+
message: (
|
|
223
|
+
`blueprint source '${original}' is inside library '${library.libraryPrefix}' at ${libraryRoot}, `
|
|
224
|
+
+ `but library.json does not declare a blueprint with slug '${blueprintSlug}'. `
|
|
225
|
+
+ `Add '{ "slug": "${blueprintSlug}", "path": "blueprints/${blueprintSlug}" }' to library.json:blueprints[] first.`
|
|
226
|
+
),
|
|
227
|
+
filePath: join(libraryRoot, 'library.json'),
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
kind: 'library',
|
|
232
|
+
resolved: abs,
|
|
233
|
+
original,
|
|
234
|
+
libraryPrefix: library.libraryPrefix,
|
|
235
|
+
libraryBlueprintSlug: blueprintSlug,
|
|
236
|
+
effectiveSlug: `${library.libraryPrefix}-${blueprintSlug}`,
|
|
237
|
+
libraryBands: library.bands,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function findLibraryAncestor(startDir) {
|
|
242
|
+
let cur = startDir;
|
|
243
|
+
while (true) {
|
|
244
|
+
try {
|
|
245
|
+
// Fast probe: try to read `library.json`. `readFile` returning
|
|
246
|
+
// ENOENT is the most common case; any other read error is treated
|
|
247
|
+
// the same as "no library here" so the walk continues to the
|
|
248
|
+
// parent without swallowing a real defect (the loader will
|
|
249
|
+
// re-raise if the operator points directly at a broken library).
|
|
250
|
+
await readFile(join(cur, 'library.json'), 'utf8');
|
|
251
|
+
return cur;
|
|
252
|
+
} catch {
|
|
253
|
+
// fall through
|
|
254
|
+
}
|
|
255
|
+
const parent = dirname(cur);
|
|
256
|
+
if (parent === cur) return null;
|
|
257
|
+
cur = parent;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
168
261
|
async function resolveLibraryQualified({ libraryPrefix, blueprintSlug, original, projectRoot }) {
|
|
169
262
|
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
170
263
|
return rcfError({
|
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
// This is what makes option 3 as printed by the reshaped conflict
|
|
15
15
|
// message executable VERBATIM from the refused-add state: the operator
|
|
16
16
|
// runs `rcf define blueprint supersede <topic> --incoming <source>` immediately
|
|
17
|
-
// after the refused add, with zero prep; the verb
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
17
|
+
// after the refused add, with zero prep; the verb resolves the incoming
|
|
18
|
+
// source through `resolveBlueprintSource` (the SAME resolver `add`
|
|
19
|
+
// uses, so `@stock/<slug>`, bare kebab slugs, colon-qualified library
|
|
20
|
+
// refs, and filesystem paths are all accepted here just as they are on
|
|
21
|
+
// `add`), loads the incoming blueprint from disk, finds its
|
|
22
|
+
// scope:global ADR on <topic>, stamps the id into the incoming
|
|
23
|
+
// blueprint's namespace, and uses that {slug, adrId} as the second
|
|
24
|
+
// side of supersedes[]. Round-2 shipped with the writer requiring 2
|
|
25
|
+
// already-applied ADRs, which meant option 3 as printed exited 2 in
|
|
26
|
+
// the refused-add state; the escalation the worker adapted around was
|
|
27
|
+
// that AC-1002-5 could not pass as written. The `--incoming` argument
|
|
28
|
+
// was subsequently fed directly to `loadBlueprint`, which understands
|
|
29
|
+
// paths only — a newcomer who intuited the `@stock/<slug>` form from
|
|
30
|
+
// `add`'s help hit a refusal. Persona re-run 2026-08-31 arc-4, H2.
|
|
24
31
|
//
|
|
25
32
|
// Two side effects, both governed by dryRun:
|
|
26
33
|
// 1. Writes the project ADR file (JSON, minimally valid — the operator
|
|
@@ -46,11 +53,12 @@
|
|
|
46
53
|
import { mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
47
54
|
import { dirname, join } from 'node:path';
|
|
48
55
|
|
|
49
|
-
import { rcfError } from '../core/errors/index.js';
|
|
56
|
+
import { isRcfError, rcfError } from '../core/errors/index.js';
|
|
50
57
|
import { loadBlueprint } from './loader.js';
|
|
51
58
|
import { updateManifest } from './manifest-writer.js';
|
|
52
59
|
import { stampId } from './namespace.js';
|
|
53
60
|
import { nextResolutionId } from './resolutions.js';
|
|
61
|
+
import { resolveBlueprintSource } from './shelf-resolver.js';
|
|
54
62
|
|
|
55
63
|
/**
|
|
56
64
|
* @typedef {object} SupersedeResult
|
|
@@ -79,9 +87,16 @@ import { nextResolutionId } from './resolutions.js';
|
|
|
79
87
|
* @param {Date} [args.now]
|
|
80
88
|
* @param {boolean} [args.dryRun]
|
|
81
89
|
* @param {string} [args.reason]
|
|
90
|
+
* @param {(source: string, opts: { projectRoot: string }) => Promise<import('./shelf-resolver.js').ResolvedSource | import('../core/errors/index.js').RcfError>} [args._resolveSource]
|
|
91
|
+
* Test-only injection point; production callers omit this and the
|
|
92
|
+
* shared `resolveBlueprintSource` runs. Mirrors the same DI on
|
|
93
|
+
* `view/scope.js` so a hermetic test can exercise the `@stock/` and
|
|
94
|
+
* colon-qualified branches without staging the packaged shelf or a
|
|
95
|
+
* library registry.
|
|
82
96
|
* @returns {Promise<SupersedeResult | import('../core/errors/index.js').RcfError>}
|
|
83
97
|
*/
|
|
84
|
-
export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason }) {
|
|
98
|
+
export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomingSource, now = new Date(), dryRun = false, reason, _resolveSource }) {
|
|
99
|
+
const resolveImpl = _resolveSource ?? resolveBlueprintSource;
|
|
85
100
|
if (typeof topic !== 'string' || topic.trim().length === 0) {
|
|
86
101
|
// Schema minLength:1 accepts whitespace-only; the writer refuses
|
|
87
102
|
// it up-front so a whitespace-only topic never lands on disk.
|
|
@@ -122,8 +137,27 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
|
|
|
122
137
|
// incomingSource is informational only (skipped silently unless it
|
|
123
138
|
// would add a distinct {slug, adrId} pair, in which case it is
|
|
124
139
|
// appended for a >= 3-blueprint scenario).
|
|
140
|
+
//
|
|
141
|
+
// The incoming source is routed through the SAME resolver `add` uses
|
|
142
|
+
// (`resolveBlueprintSource`), so `@stock/<slug>`, bare kebab slugs,
|
|
143
|
+
// colon-qualified library refs, and filesystem paths are all accepted
|
|
144
|
+
// here just as they are on `add`. Before this routing the verb fed
|
|
145
|
+
// the raw string directly to `loadBlueprint`, which understands paths
|
|
146
|
+
// only; a newcomer following the conflict card's own printed remedy
|
|
147
|
+
// with the ergonomic `@stock/<slug>` form (identical to what `add`
|
|
148
|
+
// accepted) hit a refusal. Persona re-run 2026-08-31 arc-4, H2.
|
|
125
149
|
if (typeof incomingSource === 'string' && incomingSource.length > 0) {
|
|
126
|
-
const
|
|
150
|
+
const resolved = await resolveImpl(incomingSource, { projectRoot });
|
|
151
|
+
if (isRcfError(resolved)) {
|
|
152
|
+
// Wrap the resolver's error under the --incoming banner so the
|
|
153
|
+
// operator sees which flag failed.
|
|
154
|
+
return rcfError({
|
|
155
|
+
kind: resolved.kind,
|
|
156
|
+
message: `--incoming ${incomingSource}: ${resolved.message}`,
|
|
157
|
+
filePath: resolved.filePath,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
const loaded = await loadBlueprint(resolved.resolved);
|
|
127
161
|
if (loaded.kind) {
|
|
128
162
|
// Preserve the loader's own rcfError but drop any 'blueprint: '
|
|
129
163
|
// narrator prefix so the CLI's `[error] blueprint supersede: `
|
|
@@ -134,6 +168,15 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
|
|
|
134
168
|
filePath: loaded.filePath,
|
|
135
169
|
});
|
|
136
170
|
}
|
|
171
|
+
// For library-qualified sources the applied identity is rewired
|
|
172
|
+
// under the library prefix (`<libraryPrefix>-<blueprintSlug>`), so
|
|
173
|
+
// the supersedes[] entry must reference that effective slug and
|
|
174
|
+
// stamp the ADR id under it — matching what `apply.js` writes to
|
|
175
|
+
// `manifest.blueprints[].slug`. For shelf / path sources the
|
|
176
|
+
// blueprint's own slug applies.
|
|
177
|
+
const effectiveSlug = resolved.kind === 'library'
|
|
178
|
+
? resolved.effectiveSlug
|
|
179
|
+
: loaded.slug;
|
|
137
180
|
let matched = null;
|
|
138
181
|
for (const c of loaded.contributions ?? []) {
|
|
139
182
|
if (c.kind === 'adr' && c.scope === 'global' && c.topic === topic) {
|
|
@@ -144,14 +187,14 @@ export async function supersedeBlueprintTopic({ projectRoot, tree, topic, incomi
|
|
|
144
187
|
if (!matched) {
|
|
145
188
|
return rcfError({
|
|
146
189
|
kind: 'usage',
|
|
147
|
-
message: `--incoming ${incomingSource}: blueprint '${
|
|
190
|
+
message: `--incoming ${incomingSource}: blueprint '${effectiveSlug}' declares no scope:global ADR on topic '${topic}'.`,
|
|
148
191
|
});
|
|
149
192
|
}
|
|
150
|
-
const stamped = stampId(matched.id,
|
|
193
|
+
const stamped = stampId(matched.id, effectiveSlug);
|
|
151
194
|
if ('error' in stamped) {
|
|
152
195
|
return rcfError({ kind: 'validation', message: `--incoming ${incomingSource}: ${stamped.error}` });
|
|
153
196
|
}
|
|
154
|
-
const incomingPair = { slug:
|
|
197
|
+
const incomingPair = { slug: effectiveSlug, adrId: stamped.id, path: `rcf/adrs/${stamped.id.toLowerCase()}.json` };
|
|
155
198
|
// Dedupe against the applied side: an incoming blueprint that is
|
|
156
199
|
// also currently applied (unusual — refused-add state means it is
|
|
157
200
|
// NOT applied) would otherwise be double-listed.
|