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.
Files changed (35) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/view-supervisor-child.mjs +0 -0
  3. package/blueprints/delivery-ci-workflows/README.md +4 -0
  4. package/blueprints/delivery-ci-workflows/assets/bootstrap/README.md +26 -0
  5. package/blueprints/delivery-ci-workflows/assets/bootstrap/adr-bootstrap-coverage-supersession.template.json +28 -0
  6. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/default-branch-checks.yml +12 -6
  7. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +16 -7
  8. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/release.yml +4 -0
  9. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/scheduled-audit.yml +4 -0
  10. package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +5 -5
  11. package/blueprints/delivery-ci-workflows/assets/report-samples/per-gate.json +1 -1
  12. package/blueprints/delivery-ci-workflows/blueprint.json +1 -1
  13. package/blueprints/delivery-ci-workflows/contributions/adrs/adr-702-delivery-ci-workflows-strict-coverage-gate.json +2 -2
  14. package/blueprints/delivery-ci-workflows/contributions/tacs/tac-701-delivery-ci-workflows-gate-runner.json +2 -2
  15. package/blueprints/delivery-ci-workflows/contributions/tacs/tac-704-delivery-ci-workflows-workflow-materialiser.json +7 -4
  16. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6111.json +2 -2
  17. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6114.json +6 -6
  18. package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6115.json +6 -6
  19. package/blueprints/delivery-ci-workflows/guide/delivery-ci-workflows.md +39 -1
  20. package/fixtures/canary-manifest.json +101 -1
  21. package/guidance/harness-template.md +9 -0
  22. package/guidance/managed/agent-instructions-block.hash +1 -1
  23. package/guidance/managed/agent-instructions-block.md +9 -0
  24. package/package.json +13 -15
  25. package/releases/releases.yaml +21 -1
  26. package/src/blueprint/apply.js +15 -6
  27. package/src/blueprint/index.js +22 -0
  28. package/src/blueprint/library-cache.js +143 -0
  29. package/src/blueprint/library-fetcher-git.js +347 -0
  30. package/src/blueprint/library-fetcher-tarball.js +379 -0
  31. package/src/blueprint/library-loader.js +21 -0
  32. package/src/blueprint/shelf-resolver.js +100 -7
  33. package/src/blueprint/supersede.js +56 -13
  34. package/src/cli/blueprint-library.js +427 -82
  35. package/src/cli/blueprint.js +6 -1
@@ -1,14 +1,16 @@
1
1
  // `rcf define blueprint library <verb>` CLI.
2
2
  //
3
- // Verbs (Phase 2b):
4
- // add register an external library on this project (local source in
5
- // 2b; network fetchers land in 2c)
3
+ // Verbs (Phase 2b + 2c):
4
+ // add register an external library on this project. Accepts a
5
+ // local path, a `git+<url>#<tag-or-sha>` ref, or a tarball
6
+ // URL with `--sha256 <hex>`.
6
7
  // list list registered libraries
7
8
  // remove unregister a library (refuses when any applied blueprint on
8
- // the project came through the library)
9
- // refresh re-validate an already-registered library's on-disk shape
9
+ // the project came through the library) and removes the cache
10
+ // refresh re-fetch the pinned ref, verify against the registry's
11
+ // resolved sha or tarball digest, refuse on drift
10
12
  //
11
- // Spec: external-blueprint-libraries-spec-2026-08-31.md sections 4, 8.
13
+ // Spec: external-blueprint-libraries-spec-2026-08-31.md sections 4, 6, 8.
12
14
 
13
15
  import { isAbsolute, resolve } from 'node:path';
14
16
  import { stat } from 'node:fs/promises';
@@ -27,6 +29,19 @@ import {
27
29
  } from '../blueprint/library-registry.js';
28
30
  import { knownShelfSlugs, packagedShelfPath } from '../blueprint/shelf-resolver.js';
29
31
  import { walkTree } from '#core/store';
32
+ import {
33
+ absoluteCachePath,
34
+ ensureEmptyCache,
35
+ relativeCachePath,
36
+ removeCache,
37
+ resolveCachePath,
38
+ } from '../blueprint/library-cache.js';
39
+ import {
40
+ fetchGitLibrary,
41
+ parseGitRef,
42
+ resolveRemoteSha,
43
+ } from '../blueprint/library-fetcher-git.js';
44
+ import { fetchTarballLibrary } from '../blueprint/library-fetcher-tarball.js';
30
45
 
31
46
  /**
32
47
  * Union of core-shelf blueprint slugs used for the prefix-collision
@@ -49,33 +64,46 @@ export const LIBRARY_HELP = `Usage: rcf define blueprint library <verb> [options
49
64
 
50
65
  Verbs:
51
66
  add <ref> Register an external library on this project.
52
- <ref> is either a local absolute or relative path
53
- to a library root (a directory containing
54
- library.json). Fetches metadata, runs review-on-
55
- add, and writes an entry to
56
- rcf/blueprint-libraries.json.
57
-
58
- Phase 2b covers local sources; the git and
59
- tarball fetchers land in Phase 2c. A private-repo
60
- library today is registered via a local clone.
67
+ <ref> is one of:
68
+ - a local absolute or relative path to a library
69
+ root (a directory containing library.json);
70
+ - a git ref pinned to an annotated tag or a
71
+ commit sha, e.g.
72
+ git+https://github.com/wsd-team/wsd-blueprint-library.git#v1.2.0
73
+ or git+ssh://git@github.com/... . Floating
74
+ branches (main, master, HEAD, latest) refuse.
75
+ - a tarball URL (.tar / .tar.gz / .tgz) paired
76
+ with --sha256 <hex>; SHA-256 is verified on
77
+ the download bytes and stored as the pin.
78
+ Fetches metadata, runs review-on-add, writes an
79
+ entry to rcf/blueprint-libraries.json, and lands
80
+ the extracted content under
81
+ rcf/.blueprint-libraries/<prefix>/<ref>/ (checked
82
+ into git as ordinary tree content so a fresh
83
+ clone can 'rcf define blueprint list' without a
84
+ re-fetch).
61
85
 
62
86
  list [--json] List every registered library on this project
63
87
  (prefix, source, publisher, blueprint count).
64
88
 
65
- remove <prefix> Unregister a library. Refuses when any applied
66
- blueprint on the project came through this
67
- library.
89
+ remove <prefix> Unregister a library and drop its cache. Refuses
90
+ when any applied blueprint on the project came
91
+ through this library.
68
92
 
69
- refresh <prefix> Re-validate an already-registered library's
70
- on-disk shape. Phase 2b re-reads the local
71
- library.json and reports any drift from the
72
- registry snapshot; phase 2c will re-fetch a git
73
- or tarball source and verify the sha.
93
+ refresh <prefix> Re-fetch the pinned ref for a network library and
94
+ verify against the registry's resolved sha or
95
+ tarball digest; refuse-and-report on drift (spec
96
+ §6.4 / §9.12: annotated-tag moves are a supply
97
+ chain event, not an auto-update). Local sources
98
+ re-validate the on-disk library.json against the
99
+ registry snapshot.
74
100
 
75
101
  Options:
76
102
  --prefix <slug> (add) Override the library's declared prefix.
77
103
  Rarely needed; only useful when two libraries
78
104
  collide on prefix locally.
105
+ --sha256 <hex> (add) Required when <ref> is a tarball URL. The
106
+ expected SHA-256 (64 hex chars) of the download.
79
107
  --i-have-reviewed (add) Skip the interactive review-on-add prompt.
80
108
  Required companion to --no-review when scripting;
81
109
  the two-flag form keeps a library-add trust
@@ -90,6 +118,7 @@ Options:
90
118
 
91
119
  const LIBRARY_OPTION_SPEC = {
92
120
  prefix: { type: 'string' },
121
+ sha256: { type: 'string' },
93
122
  'i-have-reviewed': { type: 'boolean' },
94
123
  'no-review': { type: 'boolean' },
95
124
  json: { type: 'boolean' },
@@ -143,33 +172,26 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
143
172
  }
144
173
  const ref = args[0];
145
174
 
146
- // Phase 2b: local sources only. Fetchers land in 2c.
147
175
  const kind = classifySourceRef(ref);
148
- if (kind !== 'local') {
149
- stderr.write(
150
- `[error] blueprint library add: source kind '${kind}' requires the network fetchers landing in Phase 2c. `
151
- + 'In 2b, register the library from a local path (a directory carrying library.json). '
152
- + 'For private git repos today, clone the repo locally and point add at the clone.\n',
153
- );
154
- return 2;
155
- }
176
+ // Fetch phase: land the library on disk under a temporary cachePath
177
+ // computed against the library's declared prefix (loader reads it from
178
+ // library.json) once we know it. For local sources the cache path is
179
+ // the operator's own directory - no copy, no cache slot. For git and
180
+ // tarball we fetch into an on-disk cache and load from there.
181
+ const dryRun = parsed.values['dry-run'] === true;
182
+ const fetchResult = await performFetch({ kind, ref, parsed, projectRoot, stderr });
183
+ if (typeof fetchResult === 'number') return fetchResult;
156
184
 
157
- const localRoot = isAbsolute(ref) ? ref : resolve(projectRoot, ref);
158
- let libStat;
159
- try {
160
- libStat = await stat(localRoot);
161
- } catch (err) {
162
- stderr.write(`[error] blueprint library add: path '${localRoot}' cannot be read: ${err.message}\n`);
163
- return 2;
164
- }
165
- if (!libStat.isDirectory()) {
166
- stderr.write(`[error] blueprint library add: path '${localRoot}' is not a directory.\n`);
167
- return 2;
168
- }
185
+ const { libraryRoot, sourceKind, sourceRef, resolvedSha, tarballSha256, cachePathAbs, cachePathRel } = fetchResult;
169
186
 
170
- const library = await loadLibrary(localRoot, { validateBlueprints: true });
187
+ const library = await loadLibrary(libraryRoot, { validateBlueprints: true });
171
188
  if (isRcfError(library)) {
172
189
  stderr.write(`[error] blueprint library add: ${library.message}\n`);
190
+ if (sourceKind !== 'local' && cachePathAbs) {
191
+ // Roll back a fetched-but-invalid library so the cache does not
192
+ // linger as ghost state.
193
+ await removeCache(cachePathAbs);
194
+ }
173
195
  return 2;
174
196
  }
175
197
 
@@ -210,20 +232,31 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
210
232
  return 2;
211
233
  }
212
234
  if (wantsReview) {
213
- printReview({ stdout, ref, library, libraryPrefix, coreReservations });
235
+ printReview({ stdout, ref, library, libraryPrefix, coreReservations, sourceKind, resolvedSha, tarballSha256 });
214
236
  if (!iHaveReviewed) {
215
237
  const proceed = await prompt(stdin, stdout, 'Proceed with add? [y/N] ');
216
238
  if (!/^y(es)?$/i.test((proceed ?? '').trim())) {
217
239
  stdout.write('[blueprint library] aborted by operator; no registry entry written.\n');
240
+ if (sourceKind !== 'local' && cachePathAbs) {
241
+ await removeCache(cachePathAbs);
242
+ }
218
243
  return 0;
219
244
  }
220
245
  }
221
246
  }
222
247
 
248
+ const provenance = { tier: sourceKind };
249
+ if (sourceKind === 'git') {
250
+ provenance.shaVerifiedAt = now.toISOString();
251
+ } else if (sourceKind === 'tarball') {
252
+ provenance.shaVerifiedAt = now.toISOString();
253
+ provenance.tarballSha256 = tarballSha256;
254
+ }
223
255
  const entry = {
224
256
  libraryPrefix,
225
- sourceKind: 'local',
226
- sourceRef: localRoot,
257
+ sourceKind,
258
+ sourceRef,
259
+ ...(resolvedSha ? { resolvedSha } : {}),
227
260
  displayName: library.displayName,
228
261
  publisher: { ...library.publisher },
229
262
  libraryRef: library.libraryRef,
@@ -231,39 +264,200 @@ async function handleAdd({ args, parsed, projectRoot, now, stdout, stderr, stdin
231
264
  blueprints: library.blueprints.map((b) => ({ slug: b.slug, path: b.path })),
232
265
  addedAt: now.toISOString(),
233
266
  reviewedBy: 'operator',
234
- provenance: { tier: 'local' },
235
- cachePath: localRoot,
267
+ provenance,
268
+ cachePath: sourceKind === 'local' ? libraryRoot : cachePathRel,
236
269
  };
237
270
 
238
271
  const nextRegistry = {
239
272
  registryVersion: registry.registryVersion,
240
273
  libraries: [...registry.libraries, entry],
241
274
  };
242
- const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun: parsed.values['dry-run'] === true });
275
+ const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun });
243
276
  if (isRcfError(write)) {
244
277
  stderr.write(`[error] blueprint library add: ${write.message}\n`);
278
+ if (sourceKind !== 'local' && cachePathAbs) await removeCache(cachePathAbs);
245
279
  return 2;
246
280
  }
247
281
  if (parsed.values.json) {
248
282
  stdout.write(`${JSON.stringify({
249
- added: !parsed.values['dry-run'],
250
- dryRun: parsed.values['dry-run'] === true,
283
+ added: !dryRun,
284
+ dryRun,
251
285
  libraryPrefix: entry.libraryPrefix,
286
+ sourceKind: entry.sourceKind,
287
+ resolvedSha: entry.resolvedSha ?? null,
288
+ tarballSha256: entry.provenance.tarballSha256 ?? null,
289
+ cachePath: entry.cachePath,
252
290
  blueprintCount: entry.blueprints.length,
253
291
  registryPath: write.path,
254
292
  })}\n`);
255
293
  return 0;
256
294
  }
257
295
  if (!parsed.values.quiet) {
258
- if (parsed.values['dry-run']) {
259
- stdout.write(`[blueprint library] dry-run: would add '${libraryPrefix}' (${entry.blueprints.length} blueprint(s)) to ${write.path}.\n`);
296
+ if (dryRun) {
297
+ stdout.write(`[blueprint library] dry-run: would add '${libraryPrefix}' (${entry.blueprints.length} blueprint(s), source=${sourceKind}) to ${write.path}.\n`);
260
298
  } else {
261
- stdout.write(`[blueprint library] added '${libraryPrefix}' (${entry.blueprints.length} blueprint(s)) to ${write.path}.\n`);
299
+ const pinNote = resolvedSha ? ` (sha ${resolvedSha.slice(0, 12)})` : (tarballSha256 ? ` (sha256 ${tarballSha256.slice(0, 12)})` : '');
300
+ stdout.write(`[blueprint library] added '${libraryPrefix}' (${entry.blueprints.length} blueprint(s), ${sourceKind})${pinNote} to ${write.path}.\n`);
262
301
  }
263
302
  }
264
303
  return 0;
265
304
  }
266
305
 
306
+ /**
307
+ * Land the library on disk. Returns either a numeric exit code (error
308
+ * already reported to stderr) or an object describing the fetched
309
+ * library placement.
310
+ *
311
+ * @returns {Promise<number | { libraryRoot: string, sourceKind: 'local' | 'git' | 'tarball', sourceRef: string, resolvedSha?: string, tarballSha256?: string, cachePathAbs?: string, cachePathRel?: string }>}
312
+ */
313
+ async function performFetch({ kind, ref, parsed, projectRoot, stderr }) {
314
+ const dryRun = parsed.values['dry-run'] === true;
315
+ if (kind === 'local') {
316
+ const localRoot = isAbsolute(ref) ? ref : resolve(projectRoot, ref);
317
+ let libStat;
318
+ try {
319
+ libStat = await stat(localRoot);
320
+ } catch (err) {
321
+ stderr.write(`[error] blueprint library add: path '${localRoot}' cannot be read: ${err.message}\n`);
322
+ return 2;
323
+ }
324
+ if (!libStat.isDirectory()) {
325
+ stderr.write(`[error] blueprint library add: path '${localRoot}' is not a directory.\n`);
326
+ return 2;
327
+ }
328
+ return { libraryRoot: localRoot, sourceKind: 'local', sourceRef: localRoot };
329
+ }
330
+ if (kind === 'git') {
331
+ const parsed_ref = parseGitRef(ref);
332
+ if (isRcfError(parsed_ref)) {
333
+ stderr.write(`[error] blueprint library add: ${parsed_ref.message}\n`);
334
+ return 2;
335
+ }
336
+ // Provisional cache slot keyed by tag or short sha. The final slot
337
+ // moves to the library-declared libraryRef if it differs, but for
338
+ // git the ref-as-typed is what the operator will use to look this
339
+ // library up in the registry.
340
+ const provisionalRef = parsed_ref.ref;
341
+ // We do not yet know libraryPrefix (that comes from library.json)
342
+ // so we fetch into a scratch dir and rename later based on the
343
+ // loaded prefix. Fetch into <projectRoot>/rcf/.blueprint-libraries/.pending-<pid>/
344
+ const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
345
+ const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, provisionalRef);
346
+ const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
347
+ if (prepErr) {
348
+ stderr.write(`[error] blueprint library add: ${prepErr.message}\n`);
349
+ return 2;
350
+ }
351
+ if (dryRun) {
352
+ stderr.write(`[error] blueprint library add: --dry-run is not supported for network fetches (would fetch ${parsed_ref.url}#${parsed_ref.ref}).\n`);
353
+ await removeCache(scratchAbs);
354
+ return 2;
355
+ }
356
+ const fetched = await fetchGitLibrary({ url: parsed_ref.url, ref: parsed_ref.ref, refKind: parsed_ref.refKind, targetDir: scratchAbs });
357
+ if (isRcfError(fetched)) {
358
+ stderr.write(`[error] blueprint library add: ${fetched.message}\n`);
359
+ await removeCache(scratchAbs);
360
+ return 2;
361
+ }
362
+ // Peek at library.json before we settle the cache slot so we know
363
+ // the prefix and libraryRef the operator committed to.
364
+ const peek = await loadLibrary(scratchAbs, { validateBlueprints: false });
365
+ if (isRcfError(peek)) {
366
+ stderr.write(`[error] blueprint library add: ${peek.message}\n`);
367
+ await removeCache(scratchAbs);
368
+ return 2;
369
+ }
370
+ const finalRel = relativeCachePath(peek.libraryPrefix, peek.libraryRef);
371
+ const finalAbs = absoluteCachePath(projectRoot, peek.libraryPrefix, peek.libraryRef);
372
+ const settleErr = await settleFinal(scratchAbs, finalAbs);
373
+ if (settleErr) {
374
+ stderr.write(`[error] blueprint library add: ${settleErr.message}\n`);
375
+ return 2;
376
+ }
377
+ return {
378
+ libraryRoot: finalAbs,
379
+ sourceKind: 'git',
380
+ sourceRef: ref,
381
+ resolvedSha: fetched.resolvedSha,
382
+ cachePathAbs: finalAbs,
383
+ cachePathRel: finalRel,
384
+ };
385
+ }
386
+ if (kind === 'tarball') {
387
+ const expected = typeof parsed.values.sha256 === 'string' ? parsed.values.sha256 : '';
388
+ if (!expected) {
389
+ stderr.write("[error] blueprint library add: tarball source requires --sha256 <hex> (64 hex chars); the SHA-256 is the pin (spec §6.1).\n");
390
+ return 2;
391
+ }
392
+ if (dryRun) {
393
+ stderr.write(`[error] blueprint library add: --dry-run is not supported for network fetches (would fetch tarball ${ref}).\n`);
394
+ return 2;
395
+ }
396
+ const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
397
+ const provisionalRef = 'downloading';
398
+ const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, provisionalRef);
399
+ const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
400
+ if (prepErr) {
401
+ stderr.write(`[error] blueprint library add: ${prepErr.message}\n`);
402
+ return 2;
403
+ }
404
+ const fetched = await fetchTarballLibrary({ url: ref, expectedSha256: expected, targetDir: scratchAbs });
405
+ if (isRcfError(fetched)) {
406
+ stderr.write(`[error] blueprint library add: ${fetched.message}\n`);
407
+ await removeCache(scratchAbs);
408
+ return 2;
409
+ }
410
+ const peek = await loadLibrary(scratchAbs, { validateBlueprints: false });
411
+ if (isRcfError(peek)) {
412
+ stderr.write(`[error] blueprint library add: ${peek.message}\n`);
413
+ await removeCache(scratchAbs);
414
+ return 2;
415
+ }
416
+ const finalRel = relativeCachePath(peek.libraryPrefix, peek.libraryRef);
417
+ const finalAbs = absoluteCachePath(projectRoot, peek.libraryPrefix, peek.libraryRef);
418
+ const settleErr = await settleFinal(scratchAbs, finalAbs);
419
+ if (settleErr) {
420
+ stderr.write(`[error] blueprint library add: ${settleErr.message}\n`);
421
+ return 2;
422
+ }
423
+ return {
424
+ libraryRoot: finalAbs,
425
+ sourceKind: 'tarball',
426
+ sourceRef: ref,
427
+ tarballSha256: fetched.tarballSha256,
428
+ cachePathAbs: finalAbs,
429
+ cachePathRel: finalRel,
430
+ };
431
+ }
432
+ stderr.write(`[error] blueprint library add: unknown source kind '${kind}'.\n`);
433
+ return 2;
434
+ }
435
+
436
+ async function settleFinal(scratchAbs, finalAbs) {
437
+ const { rename, mkdir, rm, readdir } = await import('node:fs/promises');
438
+ const { dirname } = await import('node:path');
439
+ try {
440
+ await rm(finalAbs, { recursive: true, force: true });
441
+ await mkdir(dirname(finalAbs), { recursive: true });
442
+ await rename(scratchAbs, finalAbs);
443
+ // Sweep the empty `.pending-<pid>-<ts>/` parent dir so a chain of
444
+ // adds does not leave scratch skeletons under the cache root. Only
445
+ // remove if the parent is now empty AND itself sits under
446
+ // `.blueprint-libraries/` (safety belt against removing a
447
+ // legitimate prefix directory).
448
+ const scratchParent = dirname(scratchAbs);
449
+ if (scratchParent.includes('.blueprint-libraries')) {
450
+ try {
451
+ const remaining = await readdir(scratchParent);
452
+ if (remaining.length === 0) await rm(scratchParent, { recursive: true, force: true });
453
+ } catch { /* dir already gone */ }
454
+ }
455
+ return null;
456
+ } catch (err) {
457
+ return { kind: 'ioFailure', message: `library cache settle: ${err.message}` };
458
+ }
459
+ }
460
+
267
461
  async function handleList({ parsed, projectRoot, stdout, stderr }) {
268
462
  const registry = await readLibraryRegistry(projectRoot);
269
463
  if (isRcfError(registry)) {
@@ -313,13 +507,26 @@ async function handleRemove({ args, parsed, projectRoot, stdout, stderr }) {
313
507
  return 2;
314
508
  }
315
509
  // Refuse when any applied blueprint on the project came through the
316
- // library. Signal: `manifest.blueprints[].source` starts with `<prefix>:`.
510
+ // library. The ownership fact lives on the record itself as
511
+ // `libraryPrefix` (stamped at apply-time when the apply resolved
512
+ // through this registry). Records applied before the field shipped
513
+ // (@stravica-ai/rcf-schemas 0.5.1) carry no `libraryPrefix`; for
514
+ // those we fall back to the pre-field signal, `source` starting with
515
+ // `<prefix>:`. Preferring the record over the string match makes the
516
+ // ownership durable across registry edits: a library re-registered
517
+ // under a different prefix does not orphan the records applied under
518
+ // the previous prefix.
317
519
  const { tree, errors } = await walkTree({ projectRoot });
318
520
  if (errors.length > 0) {
319
521
  for (const e of errors) stderr.write(`[tree] ${e.kind}: ${e.message}\n`);
320
522
  return 2;
321
523
  }
322
- const referring = (tree.manifest?.blueprints ?? []).filter((b) => typeof b.source === 'string' && b.source.startsWith(`${libraryPrefix}:`));
524
+ const referring = (tree.manifest?.blueprints ?? []).filter((b) => {
525
+ if (typeof b.libraryPrefix === 'string' && b.libraryPrefix.length > 0) {
526
+ return b.libraryPrefix === libraryPrefix;
527
+ }
528
+ return typeof b.source === 'string' && b.source.startsWith(`${libraryPrefix}:`);
529
+ });
323
530
  if (referring.length > 0) {
324
531
  stderr.write(`[error] blueprint library remove: ${referring.length} applied blueprint(s) came through '${libraryPrefix}':\n`);
325
532
  for (const r of referring) stderr.write(` ${r.slug} <- ${r.source}\n`);
@@ -330,11 +537,22 @@ async function handleRemove({ args, parsed, projectRoot, stdout, stderr }) {
330
537
  registryVersion: registry.registryVersion,
331
538
  libraries: registry.libraries.filter((l) => l.libraryPrefix !== libraryPrefix),
332
539
  };
333
- const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun: parsed.values['dry-run'] === true });
540
+ const dryRun = parsed.values['dry-run'] === true;
541
+ const write = await writeLibraryRegistry(projectRoot, nextRegistry, { dryRun });
334
542
  if (isRcfError(write)) {
335
543
  stderr.write(`[error] blueprint library remove: ${write.message}\n`);
336
544
  return 2;
337
545
  }
546
+ // Clean the on-disk cache for network sources so a subsequent `add`
547
+ // starts from a clean slate. Local sources point at the operator's
548
+ // own directory and must never be touched.
549
+ if (!dryRun && entry.sourceKind !== 'local') {
550
+ const cachePathAbs = resolveCachePath(projectRoot, entry.cachePath);
551
+ const cacheErr = await removeCache(cachePathAbs);
552
+ if (cacheErr) {
553
+ stderr.write(`[warn] blueprint library remove: registry entry gone; cache cleanup failed: ${cacheErr.message}\n`);
554
+ }
555
+ }
338
556
  if (!parsed.values.quiet) {
339
557
  stdout.write(`[blueprint library] removed '${libraryPrefix}' from ${write.path}.\n`);
340
558
  }
@@ -357,45 +575,157 @@ async function handleRefresh({ args, parsed, projectRoot, stdout, stderr }) {
357
575
  stderr.write(`[error] blueprint library refresh: library '${libraryPrefix}' is not registered.\n`);
358
576
  return 2;
359
577
  }
360
- if (entry.sourceKind !== 'local') {
361
- stderr.write(`[error] blueprint library refresh: source kind '${entry.sourceKind}' requires the network fetchers landing in Phase 2c.\n`);
362
- return 2;
578
+ if (entry.sourceKind === 'local') {
579
+ const library = await loadLibrary(entry.cachePath, { validateBlueprints: true });
580
+ if (isRcfError(library)) {
581
+ stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
582
+ return 2;
583
+ }
584
+ const drifted = compareLibrarySnapshot(entry, library);
585
+ if (drifted.length > 0) {
586
+ stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
587
+ for (const d of drifted) stderr.write(` ${d}\n`);
588
+ stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library (spec §10.1: freshness is advisory, adoption is an operator act).\n");
589
+ return 3;
590
+ }
591
+ if (!parsed.values.quiet) {
592
+ stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: on-disk library matches the registry snapshot.\n`);
593
+ }
594
+ return 0;
363
595
  }
364
- const library = await loadLibrary(entry.cachePath, { validateBlueprints: true });
365
- if (isRcfError(library)) {
366
- stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
367
- return 2;
596
+ if (entry.sourceKind === 'git') {
597
+ const parsedGit = parseGitRef(entry.sourceRef);
598
+ if (isRcfError(parsedGit)) {
599
+ stderr.write(`[error] blueprint library refresh: registered sourceRef is malformed: ${parsedGit.message}\n`);
600
+ return 2;
601
+ }
602
+ const upstream = await resolveRemoteSha({ url: parsedGit.url, ref: parsedGit.ref, refKind: parsedGit.refKind });
603
+ if (isRcfError(upstream)) {
604
+ stderr.write(`[error] blueprint library refresh: ${upstream.message}\n`);
605
+ return 2;
606
+ }
607
+ if (typeof entry.resolvedSha === 'string' && entry.resolvedSha.toLowerCase() !== upstream.resolvedSha) {
608
+ stderr.write(
609
+ `[blueprint library refresh] '${libraryPrefix}' pin drift: the tag '${parsedGit.ref}' at ${parsedGit.url} has moved from sha `
610
+ + `${entry.resolvedSha} to ${upstream.resolvedSha}. Publishers should not move annotated tags. `
611
+ + `Re-add explicitly if intentional (spec §6.4 / §9.12).\n`,
612
+ );
613
+ return 3;
614
+ }
615
+ // Sha matches; re-fetch into a scratch cache and verify the tree
616
+ // still validates. This catches the (rare) case of a cache tree
617
+ // that has been corrupted or hand-edited between refreshes.
618
+ const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
619
+ const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, parsedGit.ref);
620
+ const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
621
+ if (prepErr) { stderr.write(`[error] blueprint library refresh: ${prepErr.message}\n`); return 2; }
622
+ const fetched = await fetchGitLibrary({ url: parsedGit.url, ref: parsedGit.ref, refKind: parsedGit.refKind, targetDir: scratchAbs });
623
+ if (isRcfError(fetched)) {
624
+ await removeCache(scratchAbs);
625
+ stderr.write(`[error] blueprint library refresh: ${fetched.message}\n`);
626
+ return 2;
627
+ }
628
+ const library = await loadLibrary(scratchAbs, { validateBlueprints: true });
629
+ if (isRcfError(library)) {
630
+ await removeCache(scratchAbs);
631
+ stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
632
+ return 2;
633
+ }
634
+ const drifted = compareLibrarySnapshot(entry, library);
635
+ if (drifted.length > 0) {
636
+ await removeCache(scratchAbs);
637
+ stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
638
+ for (const d of drifted) stderr.write(` ${d}\n`);
639
+ stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library.\n");
640
+ return 3;
641
+ }
642
+ // Land the fresh tree in the registered cache slot.
643
+ const finalAbs = resolveCachePath(projectRoot, entry.cachePath);
644
+ const settleErr = await settleFinal(scratchAbs, finalAbs);
645
+ if (settleErr) { stderr.write(`[error] blueprint library refresh: ${settleErr.message}\n`); return 2; }
646
+ if (!parsed.values.quiet) {
647
+ stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: git ref '${parsedGit.ref}' still resolves to ${upstream.resolvedSha.slice(0, 12)}.\n`);
648
+ }
649
+ return 0;
368
650
  }
369
- // Detect drift from the registry snapshot on load-bearing fields.
651
+ if (entry.sourceKind === 'tarball') {
652
+ const expected = entry.provenance?.tarballSha256;
653
+ if (typeof expected !== 'string' || expected.length === 0) {
654
+ stderr.write("[error] blueprint library refresh: tarball entry has no provenance.tarballSha256 pin.\n");
655
+ return 2;
656
+ }
657
+ const scratchPrefix = `.pending-${process.pid}-${Date.now().toString(36)}`;
658
+ const scratchAbs = absoluteCachePath(projectRoot, scratchPrefix, 'refreshing');
659
+ const prepErr = await ensureEmptyCache(scratchAbs, { replace: true });
660
+ if (prepErr) { stderr.write(`[error] blueprint library refresh: ${prepErr.message}\n`); return 2; }
661
+ const fetched = await fetchTarballLibrary({ url: entry.sourceRef, expectedSha256: expected, targetDir: scratchAbs });
662
+ if (isRcfError(fetched)) {
663
+ await removeCache(scratchAbs);
664
+ stderr.write(`[error] blueprint library refresh: ${fetched.message}\n`);
665
+ return 2;
666
+ }
667
+ const library = await loadLibrary(scratchAbs, { validateBlueprints: true });
668
+ if (isRcfError(library)) {
669
+ await removeCache(scratchAbs);
670
+ stderr.write(`[error] blueprint library refresh: ${library.message}\n`);
671
+ return 2;
672
+ }
673
+ const drifted = compareLibrarySnapshot(entry, library);
674
+ if (drifted.length > 0) {
675
+ await removeCache(scratchAbs);
676
+ stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
677
+ for (const d of drifted) stderr.write(` ${d}\n`);
678
+ stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library.\n");
679
+ return 3;
680
+ }
681
+ const finalAbs = resolveCachePath(projectRoot, entry.cachePath);
682
+ const settleErr = await settleFinal(scratchAbs, finalAbs);
683
+ if (settleErr) { stderr.write(`[error] blueprint library refresh: ${settleErr.message}\n`); return 2; }
684
+ if (!parsed.values.quiet) {
685
+ stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: tarball SHA-256 still matches ${expected.slice(0, 12)}.\n`);
686
+ }
687
+ return 0;
688
+ }
689
+ stderr.write(`[error] blueprint library refresh: unknown source kind '${entry.sourceKind}'.\n`);
690
+ return 2;
691
+ }
692
+
693
+ function compareLibrarySnapshot(entry, library) {
370
694
  const drifted = [];
371
695
  if (library.libraryPrefix !== entry.libraryPrefix) drifted.push(`libraryPrefix '${entry.libraryPrefix}' -> '${library.libraryPrefix}'`);
372
696
  if (library.libraryRef !== entry.libraryRef) drifted.push(`libraryRef '${entry.libraryRef}' -> '${library.libraryRef}'`);
373
697
  if (JSON.stringify(library.bands) !== JSON.stringify(entry.bands)) drifted.push('bands changed');
374
- if (drifted.length > 0) {
375
- stderr.write(`[blueprint library refresh] '${libraryPrefix}' has drifted from the registered snapshot:\n`);
376
- for (const d of drifted) stderr.write(` ${d}\n`);
377
- stderr.write("re-run 'rcf define blueprint library add <ref>' to pick up the newer library (spec §10.1: freshness is advisory, adoption is an operator act).\n");
378
- return 3;
379
- }
380
- if (!parsed.values.quiet) {
381
- stdout.write(`[blueprint library] '${libraryPrefix}' refresh clean: on-disk library matches the registry snapshot.\n`);
382
- }
383
- return 0;
698
+ return drifted;
384
699
  }
385
700
 
386
701
  function classifySourceRef(ref) {
387
- if (ref.startsWith('git+') || ref.startsWith('git@') || ref.startsWith('https://') || ref.startsWith('http://') || ref.startsWith('ssh://')) return 'git';
388
- if (ref.endsWith('.tar.gz') || ref.endsWith('.tgz') || ref.endsWith('.tar')) return 'tarball';
702
+ // Tarball extension wins over the transport check because `https://.../foo.tar.gz`
703
+ // is a valid tarball ref and would otherwise be misclassified as git.
704
+ // Strip an optional `#` fragment before the extension check so a
705
+ // fragmented tarball URL still classifies correctly.
706
+ const noFragment = ref.split('#')[0];
707
+ if (noFragment.endsWith('.tar.gz') || noFragment.endsWith('.tgz') || noFragment.endsWith('.tar')) return 'tarball';
708
+ if (ref.startsWith('git+') || ref.startsWith('git@') || ref.startsWith('ssh://')) return 'git';
709
+ // http(s) with a `.git` in the path or a `#<ref>` fragment reads as
710
+ // a git URL; unadorned http(s) without either signal falls back to
711
+ // local so an accidental URL does not silently invoke a git clone.
712
+ if ((ref.startsWith('https://') || ref.startsWith('http://')) && (ref.includes('.git') || ref.includes('#'))) return 'git';
389
713
  return 'local';
390
714
  }
391
715
 
392
- function printReview({ stdout, ref, library, libraryPrefix, coreReservations }) {
716
+ function printReview({ stdout, ref, library, libraryPrefix, coreReservations, sourceKind = 'local', resolvedSha, tarballSha256 }) {
393
717
  const suffix = (library.bands.suffixBlocks ?? []).map((b) => `${b.kind} ${b.start}-${b.end}`).join(', ');
394
718
  stdout.write(`\nREVIEW - you are about to add this library to the project registry.\n\n`);
395
719
  stdout.write(` Library : ${library.displayName}\n`);
396
720
  stdout.write(` Prefix : ${libraryPrefix}\n`);
397
721
  stdout.write(` Publisher : ${library.publisher.displayName}${library.publisher.contact ? ` <${library.publisher.contact}>` : ''}\n`);
398
- stdout.write(` Source : ${ref} (local)\n`);
722
+ stdout.write(` Source : ${ref} (${sourceKind})\n`);
723
+ if (typeof resolvedSha === 'string' && resolvedSha.length > 0) {
724
+ stdout.write(` Pinned sha : ${resolvedSha}\n`);
725
+ }
726
+ if (typeof tarballSha256 === 'string' && tarballSha256.length > 0) {
727
+ stdout.write(` Tarball sha : ${tarballSha256}\n`);
728
+ }
399
729
  stdout.write(` Library ref : ${library.libraryRef}\n`);
400
730
  stdout.write(` AC band : ${library.bands.ac.start} - ${library.bands.ac.end}\n`);
401
731
  if (suffix.length > 0) stdout.write(` Suffix blocks: ${suffix}\n`);
@@ -403,8 +733,23 @@ function printReview({ stdout, ref, library, libraryPrefix, coreReservations })
403
733
  for (const bp of library.blueprints) {
404
734
  stdout.write(` ${libraryPrefix}:${bp.slug}\n`);
405
735
  }
406
- stdout.write(`\n Provenance : local (dev use)\n`);
736
+ // Spec §8.1: surface the scope:global ADR topics each blueprint
737
+ // claims so the operator sees, at the review moment, which cross-
738
+ // library / cross-core trust-boundary interactions this add commits
739
+ // them to. Suppressed only when no blueprint on the library claims
740
+ // any global topic (the render would otherwise be a lonely header).
741
+ const withTopics = library.blueprints.filter((bp) => Array.isArray(bp.globalTopics) && bp.globalTopics.length > 0);
742
+ if (withTopics.length > 0) {
743
+ const qualifiedWidth = Math.max(...withTopics.map((bp) => `${libraryPrefix}:${bp.slug}`.length));
744
+ stdout.write(`\n Global topics these blueprints claim (may conflict with core or with other libraries):\n`);
745
+ for (const bp of withTopics) {
746
+ const qualified = `${libraryPrefix}:${bp.slug}`;
747
+ stdout.write(` ${qualified.padEnd(qualifiedWidth)} -> ${bp.globalTopics.join(', ')}\n`);
748
+ }
749
+ }
750
+ stdout.write(`\n Provenance : ${sourceKind}${sourceKind === 'local' ? ' (dev use)' : ''}\n`);
407
751
  stdout.write(` Band check : cross-checked ${coreReservations.ac.length} core AC row(s), ${coreReservations.suffixBlocks.length} core suffix block(s); no overlap.\n`);
752
+ stdout.write(` Prefix check : '${libraryPrefix}' does not collide with any core slug.\n`);
408
753
  stdout.write(`\n`);
409
754
  }
410
755