rcf-lite 0.15.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.
@@ -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;
@@ -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. We look
147
- // at the SHAPE only (does it contain a separator, does it start with a
148
- // relative-path marker, is it absolute), so an existing operator with a
149
- // `./blueprints/foo` invocation keeps the current behaviour byte-for-byte.
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
- return { kind: 'path', resolved: resolve(source), original: source };
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({