release-skill 0.1.4 → 0.1.6

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 (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +104 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +233 -8
  8. package/README.zh-CN.md +188 -8
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
  12. package/adapters/claude/bin/release-skill.mjs +24 -4
  13. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  16. package/adapters/claude/schemas/.render-manifest.json +10 -10
  17. package/adapters/claude/schemas/release-project.schema.json +141 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  19. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  20. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  21. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
  24. package/adapters/codex/bin/release-skill.mjs +24 -4
  25. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  26. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  27. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  28. package/adapters/codex/schemas/.render-manifest.json +10 -10
  29. package/adapters/codex/schemas/release-project.schema.json +141 -0
  30. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  31. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  32. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  33. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  34. package/bin/release-skill-cli.mjs +163 -4
  35. package/bin/release-skill.bundle.mjs +14164 -9912
  36. package/bin/release-skill.mjs +24 -4
  37. package/native/safe-write/binding.gyp +2 -1
  38. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  39. package/native/safe-write/prebuilds.json +1 -1
  40. package/package.json +2 -2
  41. package/references/.render-manifest.json +4 -4
  42. package/references/02-project-config.md +24 -0
  43. package/references/05-evidence-and-errors.md +5 -0
  44. package/schemas/.render-manifest.json +10 -10
  45. package/schemas/release-project.schema.json +141 -0
  46. package/scripts/build-bundle.mjs +15 -2
  47. package/skills/release-help/SKILL.md +21 -0
  48. package/skills/release-prepare/SKILL.md +17 -6
  49. package/skills/release-publish/SKILL.md +3 -1
  50. package/skills/release-reconcile/SKILL.md +1 -1
  51. package/skills-src/release-help/SKILL.md +21 -0
  52. package/skills-src/release-prepare/SKILL.md +17 -6
  53. package/skills-src/release-publish/SKILL.md +3 -1
  54. package/skills-src/release-reconcile/SKILL.md +1 -1
  55. package/src/adapters/plugin-marketplace.mjs +70 -3
  56. package/src/artifacts/transaction-journal.mjs +1126 -105
  57. package/src/artifacts/transaction.mjs +313 -130
  58. package/src/commands/docs.mjs +332 -0
  59. package/src/commands/prepare.mjs +324 -17
  60. package/src/commands/reconcile.mjs +4 -1
  61. package/src/commands/verify.mjs +4 -1
  62. package/src/core/errors.mjs +64 -2
  63. package/src/core/plan.mjs +59 -1
  64. package/src/core/redact.mjs +206 -0
  65. package/src/docs/changelog-renderer.mjs +853 -0
  66. package/src/docs/config.mjs +337 -0
  67. package/src/docs/notes-loader.mjs +432 -0
  68. package/src/docs/notes.mjs +553 -0
  69. package/src/docs/readme-renderer.mjs +647 -0
  70. package/src/docs/refresh-planner.mjs +542 -0
  71. package/src/docs/refresh-service.mjs +675 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Safe loader for the structured release-notes source
3
+ * (2026-07-21-release-docs-refresh-protocol §2/§3).
4
+ *
5
+ * `loadReleaseNotesSource({ unitRoot, config, version, maxBytes, seam, backendFactory })`:
6
+ *
7
+ * 1. normalizes `config` through `normalizeReleaseDocumentsConfig` (closed
8
+ * semantic validation first);
9
+ * 2. substitutes every `{version}` placeholder in `notesSource` and
10
+ * re-canonicalizes the result through the shared `canonicalArtifactPath`
11
+ * helper; the version itself must be a safe single path segment;
12
+ * 3. reads the target exclusively through directory handles provided by the
13
+ * safe-filesystem backend (`artifacts/safe-fs.mjs`): `openRoot` on the
14
+ * caller-supplied unit root AS-IS (the loader never realpath's it; the
15
+ * backend's per-segment O_NOFOLLOW walk rejects a symlinked root or
16
+ * ancestor), then `openDir` per ancestor segment (openat, no-follow),
17
+ * then `readEntry` + `readFile` on the leaf relative to the parent
18
+ * handle. No absolute path is ever opened after the root handle is
19
+ * established, so a parent directory replaced with a symlink between
20
+ * the walk and the leaf read cannot redirect the read (openat stays
21
+ * bound to the original directory inode). All handles are closed in
22
+ * reverse order. Every identity consumed from `readEntry` (pre-read)
23
+ * and `readFile` (post-read) is strictly validated first — `size`,
24
+ * `dev`, `ino`, `nlink` must be non-negative safe integers and `bytes`
25
+ * must be a `Buffer`; anything else fails closed before parsing. The
26
+ * identity is then verified: regular-file type, `nlink === 1`, size
27
+ * within `maxBytes`, and agreement between reported size, returned
28
+ * bytes length, and the pre-read `readEntry` metadata (dev/ino/size) —
29
+ * any mismatch fails closed;
30
+ * 4. delegates to `parseReleaseNotes` with the format chosen by suffix
31
+ * (yaml/yml/json);
32
+ * 5. returns a deeply frozen `{ relativePath, bytesDigest, notes }` where
33
+ * `bytesDigest` is `sha256:` + lowercase hex over the raw bytes.
34
+ *
35
+ * If the safe backend is unavailable the loader fails closed with the
36
+ * backend's stable error (e.g. SAFE_WRITE_UNAVAILABLE); it never degrades
37
+ * to absolute-path opens.
38
+ *
39
+ * The handle walk itself is exported as `readSafeFileThroughHandles` so the
40
+ * refresh service reads every release-document target through the identical
41
+ * safe-read primitive (same O_NOFOLLOW walk, same identity checks, plus the
42
+ * permission mode captured by the same stable readFile).
43
+ *
44
+ * Injection points (tests only):
45
+ * - `backendFactory: async () => backend` — supply a backend built from a
46
+ * fake addon via `createBackend` to cover race/identity branches
47
+ * deterministically;
48
+ * - `seam.beforeLeafRead({ parentDir })` — awaited after every ancestor
49
+ * handle is established and before the leaf read. It receives ONLY the
50
+ * relative parent directory (never an absolute path, never unitRoot).
51
+ *
52
+ * Path/race problems throw the existing stable code `PATH_UNSAFE`; semantic
53
+ * problems throw `RELEASE_DOCS_INVALID`; parse problems propagate their own
54
+ * codes (including `RELEASE_DOCS_TRANSLATION_MISSING`). Error messages and
55
+ * details never carry absolute paths or note body text.
56
+ *
57
+ * @module src/docs/notes-loader
58
+ */
59
+
60
+ import { loadSafeFs } from '../artifacts/safe-fs.mjs';
61
+ import { canonicalArtifactPath } from '../artifacts/path-key.mjs';
62
+ import { sha256Hex } from '../core/digest.mjs';
63
+ import {
64
+ ReleaseError,
65
+ PATH_UNSAFE,
66
+ RELEASE_DOCS_INVALID,
67
+ } from '../core/errors.mjs';
68
+ import { normalizeReleaseDocumentsConfig } from './config.mjs';
69
+ import { parseReleaseNotes, DEFAULT_MAX_NOTES_BYTES } from './notes.mjs';
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Error helpers (never include absolute paths or body text)
73
+ // ---------------------------------------------------------------------------
74
+
75
+ function unsafe(reason, message, details = {}) {
76
+ throw new ReleaseError(PATH_UNSAFE, message, { reason, ...details });
77
+ }
78
+
79
+ function invalid(reason, message, details = {}) {
80
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, { reason, ...details });
81
+ }
82
+
83
+ /** Wrap a backend error without forwarding its (already sanitized) message. */
84
+ function unsafeCause(reason, message, err, details = {}) {
85
+ throw new ReleaseError(PATH_UNSAFE, message, {
86
+ reason,
87
+ cause: err?.code ?? 'BACKEND_ERROR',
88
+ ...details,
89
+ });
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Placeholder substitution and format selection
94
+ // ---------------------------------------------------------------------------
95
+
96
+ const FORMAT_BY_SUFFIX = Object.freeze({ yaml: 'yaml', yml: 'yml', json: 'json' });
97
+
98
+ /**
99
+ * Substitute every `{version}` placeholder and re-canonicalize the path.
100
+ *
101
+ * @param {string} notesSource Canonical notesSource from config validation.
102
+ * @param {string} version Release version; must be a safe single segment.
103
+ * @returns {{ relativePath: string, format: string }}
104
+ */
105
+ function resolveNotesPath(notesSource, version) {
106
+ // The version must itself be a safe single path segment before it may be
107
+ // embedded into a filesystem path. canonicalArtifactPath covers traversal,
108
+ // separators (backslash), colon, NUL and reserved names; the single-segment
109
+ // requirement is enforced explicitly here.
110
+ if (version.includes('/')) {
111
+ invalid(
112
+ 'UNSAFE_VERSION_SEGMENT',
113
+ 'version is not a safe file-name fragment for notesSource substitution',
114
+ { version, cause: 'PATH_UNSAFE' },
115
+ );
116
+ }
117
+ try {
118
+ canonicalArtifactPath(version);
119
+ } catch (err) {
120
+ invalid(
121
+ 'UNSAFE_VERSION_SEGMENT',
122
+ 'version is not a safe file-name fragment for notesSource substitution',
123
+ { version, cause: err.code ?? 'PATH_UNSAFE' },
124
+ );
125
+ }
126
+
127
+ const substituted = notesSource.split('{version}').join(version);
128
+ if (substituted.includes('{') || substituted.includes('}')) {
129
+ invalid(
130
+ 'RESIDUAL_PLACEHOLDER',
131
+ 'notesSource contains a placeholder other than {version}',
132
+ { notesSource },
133
+ );
134
+ }
135
+
136
+ let relativePath;
137
+ try {
138
+ relativePath = canonicalArtifactPath(substituted).path;
139
+ } catch (err) {
140
+ invalid(
141
+ 'UNSAFE_NOTES_PATH',
142
+ `notesSource is unsafe after version substitution: ${err.message}`,
143
+ { notesSource, cause: err.code ?? 'PATH_UNSAFE' },
144
+ );
145
+ }
146
+
147
+ const dot = relativePath.lastIndexOf('.');
148
+ const suffix = dot >= 0 ? relativePath.slice(dot + 1) : '';
149
+ const format = FORMAT_BY_SUFFIX[suffix];
150
+ if (!format) {
151
+ invalid(
152
+ 'UNSUPPORTED_SUFFIX',
153
+ 'notesSource must end with .yaml, .yml, or .json after version substitution',
154
+ { notesSource, suffix },
155
+ );
156
+ }
157
+
158
+ return { relativePath, format };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Handle-based safe read (openat / no-follow; no absolute-path opens)
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const IDENTITY_FIELDS = Object.freeze(['size', 'dev', 'ino', 'nlink']);
166
+
167
+ /**
168
+ * Strictly validate identity metadata reported by the backend before any of
169
+ * it is trusted. `size`, `dev`, `ino`, `nlink` must each be a non-negative
170
+ * safe integer (rejecting NaN, Infinity, negatives, fractions, strings,
171
+ * null/undefined, and values beyond the safe-integer range); when `withBytes`
172
+ * is set, `bytes` must be a Buffer. Any violation fails closed with
173
+ * PATH_UNSAFE before the parser is ever reached.
174
+ *
175
+ * @param {object} entry readEntry or readFile result from the backend.
176
+ * @param {string} relativePath Canonical relative path (error details only).
177
+ * @param {{ withBytes?: boolean }} [options]
178
+ * @param {string} [subject] Error-message subject (default 'notes source').
179
+ */
180
+ function validateIdentity(entry, relativePath, { withBytes = false } = {}, subject = 'notes source') {
181
+ for (const field of IDENTITY_FIELDS) {
182
+ const value = entry[field];
183
+ if (!Number.isSafeInteger(value) || value < 0) {
184
+ unsafe('UNSAFE_IDENTITY', `${subject} metadata is not a trustworthy identity`, {
185
+ relativePath,
186
+ field,
187
+ });
188
+ }
189
+ }
190
+ if (withBytes && !Buffer.isBuffer(entry.bytes)) {
191
+ unsafe('UNSAFE_IDENTITY', `${subject} read did not return bytes`, {
192
+ relativePath,
193
+ field: 'bytes',
194
+ });
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Read one file through directory handles only (openat / no-follow per
200
+ * segment; never an absolute-path open after the root handle is open).
201
+ *
202
+ * Shared safe-read primitive: the notes source itself and every
203
+ * release-document target are read through this exact walk (regular file,
204
+ * nlink === 1, size limit, pre/post identity agreement). There is no
205
+ * absolute-path `fs.readFile` fallback; when the backend is unavailable the
206
+ * read fails closed.
207
+ *
208
+ * @param {object} backend Safe-fs backend (loadSafeFs or injected fake).
209
+ * @param {string} unitRoot Caller-supplied unit root, handed verbatim to
210
+ * backend.openRoot — never realpath'd; openRoot's per-segment O_NOFOLLOW
211
+ * walk rejects a symlinked root or ancestor.
212
+ * @param {string} relativePath Canonical relative path (POSIX segments).
213
+ * @param {number} limit Size limit in bytes.
214
+ * @param {{ beforeLeafRead?: (info: { parentDir: string }) => Promise<void> }} [seam]
215
+ * @param {string} [subject] Error-message subject (default 'notes source';
216
+ * release-document target reads pass 'release document target').
217
+ * @returns {Promise<Readonly<{ bytes: Buffer, mode: number }>>} fresh byte
218
+ * copy plus the permission mode captured by the same stable readFile.
219
+ */
220
+ export async function readSafeFileThroughHandles(backend, unitRoot, relativePath, limit, seam, subject = 'notes source') {
221
+ const segments = relativePath.split('/');
222
+ const handleStack = [];
223
+ let readResult = null;
224
+ let primaryError = null;
225
+
226
+ try {
227
+ let rootHandle;
228
+ try {
229
+ rootHandle = await backend.openRoot(unitRoot);
230
+ } catch (err) {
231
+ unsafeCause('ROOT_OPEN_FAILED', 'release unit root cannot be opened safely', err, { relativePath });
232
+ }
233
+ handleStack.push(rootHandle);
234
+
235
+ // Walk ancestors relative to the parent handle (openat, no-follow).
236
+ for (let i = 0; i < segments.length - 1; i += 1) {
237
+ const segment = segments[i];
238
+ const parent = handleStack[handleStack.length - 1];
239
+ let entry;
240
+ try {
241
+ entry = await parent.readEntry(segment);
242
+ } catch (err) {
243
+ unsafeCause('ANCESTOR_READ_FAILED', `${subject} path component cannot be inspected`, err, { relativePath });
244
+ }
245
+ if (entry === null || entry === undefined) {
246
+ unsafe('MISSING', `${subject} path component does not exist`, { relativePath });
247
+ }
248
+ if (entry.type === 'symlink') {
249
+ unsafe('ANCESTOR_SYMLINK', `${subject} path contains a symlinked directory`, { relativePath });
250
+ }
251
+ if (entry.type !== 'directory') {
252
+ unsafe('ANCESTOR_NOT_DIRECTORY', `${subject} path component is not a directory`, { relativePath });
253
+ }
254
+ let child;
255
+ try {
256
+ child = await parent.openDir(segment);
257
+ } catch (err) {
258
+ unsafeCause('ANCESTOR_OPEN_FAILED', `${subject} directory cannot be opened safely`, err, { relativePath });
259
+ }
260
+ handleStack.push(child);
261
+ }
262
+
263
+ // Test-only seam: parent handles are established; the leaf is not read
264
+ // yet. Only the relative parent directory is exposed — never an
265
+ // absolute path.
266
+ if (seam && typeof seam.beforeLeafRead === 'function') {
267
+ const parentDir = segments.length > 1 ? segments.slice(0, -1).join('/') : '.';
268
+ await seam.beforeLeafRead({ parentDir });
269
+ }
270
+
271
+ // Leaf identity check before reading anything.
272
+ const leaf = segments[segments.length - 1];
273
+ const parentHandle = handleStack[handleStack.length - 1];
274
+ let leafEntry;
275
+ try {
276
+ leafEntry = await parentHandle.readEntry(leaf);
277
+ } catch (err) {
278
+ unsafeCause('LEAF_READ_FAILED', `${subject} file cannot be inspected`, err, { relativePath });
279
+ }
280
+ if (leafEntry === null || leafEntry === undefined) {
281
+ unsafe('MISSING', `${subject} file does not exist`, { relativePath });
282
+ }
283
+ if (leafEntry.type === 'symlink') {
284
+ unsafe('TARGET_SYMLINK', `${subject} target must not be a symlink`, { relativePath });
285
+ }
286
+ if (leafEntry.type !== 'file') {
287
+ unsafe('NOT_REGULAR_FILE', `${subject} target must be a regular file`, { relativePath });
288
+ }
289
+ validateIdentity(leafEntry, relativePath, {}, subject);
290
+ if (leafEntry.nlink !== 1) {
291
+ unsafe('HARDLINK', `${subject} target must not be hardlinked`, { relativePath, nlink: leafEntry.nlink });
292
+ }
293
+ if (leafEntry.size > limit) {
294
+ invalid('INPUT_TOO_LARGE', `${subject} exceeds the size limit`, {
295
+ size: leafEntry.size,
296
+ maxBytes: limit,
297
+ });
298
+ }
299
+
300
+ // Read through the parent handle (openat, no-follow).
301
+ try {
302
+ readResult = await parentHandle.readFile(leaf);
303
+ } catch (err) {
304
+ unsafeCause('LEAF_READ_FAILED', `${subject} file cannot be read safely`, err, { relativePath });
305
+ }
306
+ if (readResult === null || readResult === undefined) {
307
+ unsafe('MISSING', `${subject} file disappeared during read`, { relativePath });
308
+ }
309
+
310
+ // Strictly validate the identity returned by readFile before trusting it.
311
+ validateIdentity(readResult, relativePath, { withBytes: true }, subject);
312
+ if (readResult.nlink !== 1) {
313
+ unsafe('HARDLINK', `${subject} target must not be hardlinked`, { relativePath, nlink: readResult.nlink });
314
+ }
315
+ if (readResult.size > limit) {
316
+ invalid('INPUT_TOO_LARGE', `${subject} exceeds the size limit`, {
317
+ size: readResult.size,
318
+ maxBytes: limit,
319
+ });
320
+ }
321
+ if (readResult.bytes.length !== readResult.size) {
322
+ unsafe('CHANGED_DURING_READ', `${subject} bytes disagree with reported size`, { relativePath });
323
+ }
324
+ if (readResult.size !== leafEntry.size
325
+ || readResult.dev !== leafEntry.dev
326
+ || readResult.ino !== leafEntry.ino) {
327
+ unsafe('CHANGED_DURING_READ', `${subject} identity changed between inspection and read`, { relativePath });
328
+ }
329
+ } catch (err) {
330
+ primaryError = err instanceof ReleaseError
331
+ ? err
332
+ : new ReleaseError(PATH_UNSAFE, `${subject} read failed`, { reason: 'READ_FAILED' });
333
+ }
334
+
335
+ // Close every handle in reverse order; a close failure after a successful
336
+ // read still fails closed.
337
+ const closeFailures = [];
338
+ for (let i = handleStack.length - 1; i >= 0; i -= 1) {
339
+ try {
340
+ await handleStack[i].close();
341
+ } catch (closeErr) {
342
+ closeFailures.push(closeErr?.code ?? 'CLOSE_FAILED');
343
+ }
344
+ }
345
+
346
+ if (primaryError) {
347
+ throw primaryError;
348
+ }
349
+ if (closeFailures.length > 0) {
350
+ unsafe('CLOSE_FAILED', `${subject} handle close failed after read`, {
351
+ relativePath,
352
+ closeFailures,
353
+ });
354
+ }
355
+
356
+ return Object.freeze({ bytes: Buffer.from(readResult.bytes), mode: readResult.mode });
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Public API
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Safely load and parse the structured release-notes source for one release
365
+ * unit.
366
+ *
367
+ * @param {object} options
368
+ * @param {string} options.unitRoot Absolute release-unit root directory.
369
+ * @param {object} options.config Raw releaseDocuments config block.
370
+ * @param {string} options.version Exact expected release version.
371
+ * @param {number} [options.maxBytes] Size limit (default 1 MiB).
372
+ * @param {{ beforeLeafRead?: (info: { parentDir: string }) => Promise<void> }} [options.seam]
373
+ * Test-only injection point; receives only the relative parent directory.
374
+ * @param {() => Promise<object>} [options.backendFactory]
375
+ * Test-only safe-fs backend factory (default: loadSafeFs). Failures fail
376
+ * closed; there is no fallback to absolute-path opens.
377
+ * @returns {Promise<Readonly<{
378
+ * relativePath: string,
379
+ * bytesDigest: string,
380
+ * notes: object,
381
+ * }>>} deeply frozen result; `relativePath` is workspace-relative, never absolute
382
+ * @throws {ReleaseError} PATH_UNSAFE on path/race problems; RELEASE_DOCS_INVALID
383
+ * on semantic problems; backend/parse-layer codes propagate (including
384
+ * SAFE_WRITE_UNAVAILABLE and RELEASE_DOCS_TRANSLATION_MISSING).
385
+ */
386
+ export async function loadReleaseNotesSource({
387
+ unitRoot,
388
+ config,
389
+ version,
390
+ maxBytes,
391
+ seam,
392
+ backendFactory,
393
+ } = {}) {
394
+ if (typeof unitRoot !== 'string' || unitRoot.length === 0) {
395
+ invalid('INVALID_OPTIONS', 'unitRoot must be a non-empty string', {});
396
+ }
397
+ if (typeof version !== 'string' || version.length === 0) {
398
+ invalid('INVALID_OPTIONS', 'version must be a non-empty string', {});
399
+ }
400
+ const limit = maxBytes ?? DEFAULT_MAX_NOTES_BYTES;
401
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
402
+ invalid('INVALID_OPTIONS', 'maxBytes must be a positive integer', {});
403
+ }
404
+
405
+ // 1. Closed semantic validation of the config block first.
406
+ const normalized = normalizeReleaseDocumentsConfig(config);
407
+
408
+ // 2. Substitute {version} and re-canonicalize; choose format by suffix.
409
+ const { relativePath, format } = resolveNotesPath(normalized.notesSource, version);
410
+
411
+ // 3. Load the safe backend; fail closed when unavailable (no fallback).
412
+ const factory = backendFactory ?? loadSafeFs;
413
+ const backend = await factory();
414
+
415
+ // 4. Handle-based safe read inside the physical unit root.
416
+ const { bytes } = await readSafeFileThroughHandles(backend, unitRoot, relativePath, limit, seam);
417
+
418
+ // 5. Strict closed-model parse by format.
419
+ const notes = parseReleaseNotes(bytes, {
420
+ format,
421
+ expectedVersion: version,
422
+ locales: [...normalized.locales],
423
+ maxBytes: limit,
424
+ });
425
+
426
+ // 6. Frozen canonical result; no absolute paths, deterministic digest.
427
+ return Object.freeze({
428
+ relativePath,
429
+ bytesDigest: `sha256:${sha256Hex(bytes)}`,
430
+ notes,
431
+ });
432
+ }