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,553 @@
1
+ /**
2
+ * Pure parser for the structured release-notes source
3
+ * (2026-07-21-release-docs-refresh-protocol §3).
4
+ *
5
+ * `parseReleaseNotes(bytes, options)` maps YAML and JSON bytes to one closed
6
+ * canonical model. It is a pure function: no file reads, no network, no
7
+ * subprocesses. It fails closed on:
8
+ *
9
+ * - oversized input (`maxBytes`, default 1 MiB), empty input;
10
+ * - invalid UTF-8, NUL bytes, BOM;
11
+ * - JSON duplicate keys (strict scanner; escape-form aware) and YAML
12
+ * duplicate keys;
13
+ * - YAML anchors, aliases, merge keys (`<<`), explicit/custom tags,
14
+ * non-scalar keys, and multi-document streams;
15
+ * - unknown fields at every level of the closed data model;
16
+ * - version drift versus `expectedVersion`, malformed `YYYY-MM-DD` dates
17
+ * (including impossible calendar dates such as 2026-02-30);
18
+ * - extra locales, empty summaries, unknown change categories, empty or
19
+ * non-string change entries, no non-empty category, empty upgradeNotes.
20
+ *
21
+ * Missing configured locales throw the stable code
22
+ * `RELEASE_DOCS_TRANSLATION_MISSING` naming the missing locales precisely;
23
+ * content is never substituted from another locale. All other violations
24
+ * throw `RELEASE_DOCS_INVALID` with a machine-readable `details.reason`.
25
+ * Error details never carry note body text.
26
+ *
27
+ * @module src/docs/notes
28
+ */
29
+
30
+ import YAML from 'yaml';
31
+ import {
32
+ ReleaseError,
33
+ RELEASE_DOCS_INVALID,
34
+ RELEASE_DOCS_TRANSLATION_MISSING,
35
+ } from '../core/errors.mjs';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Public constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /** Fixed change-category set, in the stable output order (protocol §3). */
42
+ export const RELEASE_NOTES_CATEGORIES = Object.freeze([
43
+ 'security',
44
+ 'breaking',
45
+ 'added',
46
+ 'changed',
47
+ 'deprecated',
48
+ 'removed',
49
+ 'fixed',
50
+ ]);
51
+
52
+ /** Default notes-source size limit (1 MiB). */
53
+ export const DEFAULT_MAX_NOTES_BYTES = 1024 * 1024;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Internal constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const CATEGORY_SET = new Set(RELEASE_NOTES_CATEGORIES);
60
+ const ROOT_KEYS = new Set(['version', 'date', 'locales']);
61
+ const ENTRY_KEYS = new Set(['summary', 'changes', 'upgradeNotes']);
62
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
63
+ const SUPPORTED_FORMATS = new Set(['yaml', 'yml', 'json']);
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Error helpers (details must never include note body text)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function invalid(reason, message, details = {}) {
70
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, { reason, ...details });
71
+ }
72
+
73
+ function translationMissing(missingLocales) {
74
+ throw new ReleaseError(
75
+ RELEASE_DOCS_TRANSLATION_MISSING,
76
+ `release notes are missing configured locales: ${missingLocales.join(', ')}`,
77
+ { reason: 'MISSING_LOCALE', locales: missingLocales },
78
+ );
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Small utilities
83
+ // ---------------------------------------------------------------------------
84
+
85
+ function isPlainObject(value) {
86
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
87
+ }
88
+
89
+ function deepFreeze(value) {
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) deepFreeze(item);
92
+ return Object.freeze(value);
93
+ }
94
+ if (value !== null && typeof value === 'object') {
95
+ for (const item of Object.values(value)) deepFreeze(item);
96
+ return Object.freeze(value);
97
+ }
98
+ return value;
99
+ }
100
+
101
+ /**
102
+ * Validate a YYYY-MM-DD string against the real calendar
103
+ * (rejects e.g. 2026-02-30, which YAML/JS would silently roll over).
104
+ * @param {string} text
105
+ * @returns {boolean}
106
+ */
107
+ function isValidCalendarDate(text) {
108
+ if (!DATE_PATTERN.test(text)) return false;
109
+ const [year, month, day] = text.split('-').map(Number);
110
+ const date = new Date(Date.UTC(year, month - 1, day));
111
+ return (
112
+ date.getUTCFullYear() === year &&
113
+ date.getUTCMonth() === month - 1 &&
114
+ date.getUTCDate() === day
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Normalise a parsed `date` value to a strict YYYY-MM-DD string.
120
+ * @param {unknown} value
121
+ * @returns {string}
122
+ */
123
+ function normalizeDate(value) {
124
+ if (typeof value === 'string') {
125
+ if (isValidCalendarDate(value)) return value;
126
+ invalid('INVALID_DATE', 'release notes date must be a YYYY-MM-DD calendar date', { date: value });
127
+ }
128
+ if (value instanceof Date) {
129
+ // YAML 1.1-style timestamp resolution: accept only exact UTC midnight.
130
+ let iso;
131
+ try {
132
+ iso = value.toISOString();
133
+ } catch {
134
+ invalid('INVALID_DATE', 'release notes date is not a representable date', {});
135
+ }
136
+ if (!iso.endsWith('T00:00:00.000Z')) {
137
+ invalid('INVALID_DATE', 'release notes date must be a YYYY-MM-DD calendar date', {});
138
+ }
139
+ const text = iso.slice(0, 10);
140
+ if (!isValidCalendarDate(text)) {
141
+ invalid('INVALID_DATE', 'release notes date must be a YYYY-MM-DD calendar date', { date: text });
142
+ }
143
+ return text;
144
+ }
145
+ return invalid('INVALID_DATE', 'release notes date must be a YYYY-MM-DD string', {});
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // YAML strictness: AST walk
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Walk a YAML AST and collect security/strictness violations.
154
+ * Violation kinds: 'alias', 'merge', 'anchor', 'tag', 'complex-key'.
155
+ *
156
+ * @param {import('yaml').Node | null | undefined} node
157
+ * @param {Set<string>} found
158
+ */
159
+ function collectYamlViolations(node, found) {
160
+ if (node === null || node === undefined || typeof node !== 'object') return;
161
+
162
+ if (node.constructor?.name === 'Alias' || node.type === 'ALIAS') {
163
+ found.add('alias');
164
+ return;
165
+ }
166
+ if (node.anchor !== undefined) found.add('anchor');
167
+ if (node.tag !== undefined) found.add('tag');
168
+
169
+ if (node.items && Array.isArray(node.items)) {
170
+ for (const item of node.items) {
171
+ if (item && typeof item === 'object' && 'key' in item) {
172
+ // Pair inside a map.
173
+ const key = item.key;
174
+ if (key && typeof key === 'object') {
175
+ const isScalarKey = key.type === 'SCALAR' || key.constructor?.name === 'Scalar';
176
+ if (!isScalarKey) {
177
+ found.add('complex-key');
178
+ } else if (key.value === '<<') {
179
+ found.add('merge');
180
+ } else if (typeof key.value !== 'string') {
181
+ found.add('complex-key');
182
+ }
183
+ } else if (typeof key !== 'string') {
184
+ found.add('complex-key');
185
+ } else if (key === '<<') {
186
+ found.add('merge');
187
+ }
188
+ collectYamlViolations(key, found);
189
+ collectYamlViolations(item.value, found);
190
+ } else {
191
+ // Bare node inside a sequence.
192
+ collectYamlViolations(item, found);
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ const YAML_VIOLATION_ORDER = [
199
+ ['alias', 'ALIAS', 'YAML aliases are not allowed in release notes'],
200
+ ['merge', 'MERGE_KEY', 'YAML merge keys (<<) are not allowed in release notes'],
201
+ ['anchor', 'ANCHOR', 'YAML anchors are not allowed in release notes'],
202
+ ['tag', 'CUSTOM_TAG', 'explicit or custom YAML tags are not allowed in release notes'],
203
+ ['complex-key', 'COMPLEX_KEY', 'non-scalar or non-string YAML keys are not allowed in release notes'],
204
+ ];
205
+
206
+ /**
207
+ * Parse YAML text strictly into a plain object.
208
+ * @param {string} text
209
+ * @returns {unknown}
210
+ */
211
+ function parseStrictYaml(text) {
212
+ const docs = YAML.parseAllDocuments(text, { uniqueKeys: true, merge: false });
213
+ if (docs.length === 0) {
214
+ invalid('EMPTY_SOURCE', 'release notes source is empty', {});
215
+ }
216
+ if (docs.length > 1) {
217
+ invalid('MULTI_DOCUMENT', 'release notes must contain exactly one YAML document', { documents: docs.length });
218
+ }
219
+ const doc = docs[0];
220
+
221
+ const duplicate = doc.errors.find((err) => err.message && err.message.includes('unique'));
222
+ if (duplicate) {
223
+ invalid('DUPLICATE_KEY', 'duplicate keys are not allowed in release notes', {
224
+ line: duplicate.line ?? null,
225
+ });
226
+ }
227
+ if (doc.errors.length > 0) {
228
+ invalid('PARSE_FAILED', 'release notes YAML cannot be parsed', {
229
+ line: doc.errors[0].line ?? null,
230
+ });
231
+ }
232
+ if (!doc.contents) {
233
+ invalid('EMPTY_SOURCE', 'release notes source is empty', {});
234
+ }
235
+
236
+ const violations = new Set();
237
+ collectYamlViolations(doc.contents, violations);
238
+ for (const [kind, reason, message] of YAML_VIOLATION_ORDER) {
239
+ if (violations.has(kind)) invalid(reason, message, {});
240
+ }
241
+
242
+ return doc.toJS();
243
+ }
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // JSON strictness: syntax + duplicate-key scanner
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Scan valid JSON text for duplicate object keys.
251
+ *
252
+ * Assumes `text` has already passed `JSON.parse`, so the grammar is valid;
253
+ * the scanner only tracks container scopes and string tokens. Keys are
254
+ * decoded (escape-aware) before comparison so `"a"` and `"a"` collide.
255
+ *
256
+ * @param {string} text
257
+ */
258
+ function assertJsonNoDuplicateKeys(text) {
259
+ const scopes = []; // Set<string> for objects, null for arrays
260
+ const length = text.length;
261
+ let i = 0;
262
+ while (i < length) {
263
+ const c = text[i];
264
+ if (c === '"') {
265
+ const start = i;
266
+ i += 1;
267
+ while (i < length) {
268
+ if (text[i] === '\\') { i += 2; continue; }
269
+ if (text[i] === '"') break;
270
+ i += 1;
271
+ }
272
+ const raw = text.slice(start, i + 1);
273
+ i += 1;
274
+ // A string followed by ':' inside an object scope is a key.
275
+ let k = i;
276
+ while (k < length && (text[k] === ' ' || text[k] === '\t' || text[k] === '\n' || text[k] === '\r')) k += 1;
277
+ if (text[k] === ':' && scopes.length > 0 && scopes[scopes.length - 1] !== null) {
278
+ let key = raw;
279
+ try { key = JSON.parse(raw); } catch { /* valid JSON guarantees success */ }
280
+ const set = scopes[scopes.length - 1];
281
+ if (set.has(key)) {
282
+ invalid('DUPLICATE_KEY', 'duplicate keys are not allowed in release notes', { key });
283
+ }
284
+ set.add(key);
285
+ }
286
+ continue;
287
+ }
288
+ if (c === '{') { scopes.push(new Set()); i += 1; continue; }
289
+ if (c === '}') { scopes.pop(); i += 1; continue; }
290
+ if (c === '[') { scopes.push(null); i += 1; continue; }
291
+ if (c === ']') { scopes.pop(); i += 1; continue; }
292
+ i += 1;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Parse JSON text strictly into a plain object.
298
+ * @param {string} text
299
+ * @returns {unknown}
300
+ */
301
+ function parseStrictJson(text) {
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(text);
305
+ } catch {
306
+ invalid('PARSE_FAILED', 'release notes JSON cannot be parsed', {});
307
+ }
308
+ assertJsonNoDuplicateKeys(text);
309
+ return parsed;
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Closed data-model validation
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * Validate one locale entry and build its canonical form.
318
+ *
319
+ * @param {unknown} entry
320
+ * @param {string} locale
321
+ * @returns {{ summary: string, changes: object, upgradeNotes?: string }}
322
+ */
323
+ function normalizeLocaleEntry(entry, locale) {
324
+ if (!isPlainObject(entry)) {
325
+ invalid('ENTRY_NOT_OBJECT', `release notes locale entry must be a mapping`, { locale });
326
+ }
327
+ for (const key of Object.keys(entry)) {
328
+ if (!ENTRY_KEYS.has(key)) {
329
+ invalid('UNKNOWN_FIELD', `unknown field in release notes locale entry`, { locale, field: key });
330
+ }
331
+ }
332
+ for (const key of ['summary', 'changes']) {
333
+ if (!Object.hasOwn(entry, key)) {
334
+ invalid('MISSING_FIELD', `release notes locale entry missing required field`, { locale, field: key });
335
+ }
336
+ }
337
+
338
+ // summary
339
+ const { summary } = entry;
340
+ if (typeof summary !== 'string') {
341
+ invalid('INVALID_SUMMARY', 'release notes summary must be a string', { locale });
342
+ }
343
+ const trimmedSummary = summary.trim();
344
+ if (trimmedSummary.length === 0) {
345
+ invalid('EMPTY_SUMMARY', 'release notes summary must not be empty', { locale });
346
+ }
347
+
348
+ // changes
349
+ const { changes } = entry;
350
+ if (!isPlainObject(changes)) {
351
+ invalid('CHANGES_NOT_OBJECT', 'release notes changes must be a mapping of categories', { locale });
352
+ }
353
+ const canonicalChanges = {};
354
+ let totalEntries = 0;
355
+ for (const category of RELEASE_NOTES_CATEGORIES) {
356
+ if (!Object.hasOwn(changes, category)) continue;
357
+ const items = changes[category];
358
+ if (!Array.isArray(items)) {
359
+ invalid('CHANGES_CATEGORY_NOT_ARRAY', 'release notes change category must be an array', { locale, category });
360
+ }
361
+ const trimmedItems = [];
362
+ for (const item of items) {
363
+ if (typeof item !== 'string') {
364
+ invalid('INVALID_CHANGE_ENTRY', 'release notes change entries must be strings', { locale, category });
365
+ }
366
+ const trimmed = item.trim();
367
+ if (trimmed.length === 0) {
368
+ invalid('EMPTY_CHANGE_ENTRY', 'release notes change entries must not be empty', { locale, category });
369
+ }
370
+ trimmedItems.push(trimmed);
371
+ }
372
+ totalEntries += trimmedItems.length;
373
+ if (trimmedItems.length > 0) canonicalChanges[category] = trimmedItems;
374
+ }
375
+ for (const key of Object.keys(changes)) {
376
+ if (!CATEGORY_SET.has(key)) {
377
+ invalid('UNKNOWN_CATEGORY', 'release notes contain an unknown change category', { locale, category: key });
378
+ }
379
+ }
380
+ if (totalEntries === 0) {
381
+ invalid('EMPTY_CHANGES', 'release notes must contain at least one non-empty change category', { locale });
382
+ }
383
+
384
+ const canonical = { summary: trimmedSummary, changes: canonicalChanges };
385
+
386
+ // upgradeNotes (optional)
387
+ if (Object.hasOwn(entry, 'upgradeNotes')) {
388
+ const { upgradeNotes } = entry;
389
+ if (typeof upgradeNotes !== 'string') {
390
+ invalid('INVALID_UPGRADE_NOTES', 'release notes upgradeNotes must be a string', { locale });
391
+ }
392
+ const trimmedNotes = upgradeNotes.trim();
393
+ if (trimmedNotes.length === 0) {
394
+ invalid('EMPTY_UPGRADE_NOTES', 'release notes upgradeNotes must not be empty when present', { locale });
395
+ }
396
+ canonical.upgradeNotes = trimmedNotes;
397
+ }
398
+
399
+ return canonical;
400
+ }
401
+
402
+ /**
403
+ * Validate the parsed plain-object model and build the canonical form.
404
+ *
405
+ * @param {unknown} root
406
+ * @param {{ expectedVersion: string, locales: string[] }} options
407
+ * @returns {object} canonical notes object (not yet frozen)
408
+ */
409
+ function validateNotesModel(root, { expectedVersion, locales }) {
410
+ if (!isPlainObject(root)) {
411
+ invalid('ROOT_NOT_OBJECT', 'release notes root must be a mapping', {});
412
+ }
413
+ for (const key of Object.keys(root)) {
414
+ if (!ROOT_KEYS.has(key)) {
415
+ invalid('UNKNOWN_FIELD', 'unknown field in release notes root', { field: key });
416
+ }
417
+ }
418
+ for (const key of ROOT_KEYS) {
419
+ if (!Object.hasOwn(root, key)) {
420
+ invalid('MISSING_FIELD', 'release notes missing required field', { field: key });
421
+ }
422
+ }
423
+
424
+ // version: exact match against the expected version; never coerced.
425
+ const { version } = root;
426
+ if (typeof version !== 'string' || version.length === 0) {
427
+ invalid('INVALID_VERSION', 'release notes version must be a non-empty string', {});
428
+ }
429
+ if (version !== expectedVersion) {
430
+ invalid('VERSION_MISMATCH', 'release notes version does not match the expected version', {
431
+ expected: expectedVersion,
432
+ actual: version,
433
+ });
434
+ }
435
+
436
+ // date: strict YYYY-MM-DD.
437
+ const date = normalizeDate(root.date);
438
+
439
+ // locales map: exactly the configured set; missing locales are precise
440
+ // translation failures, never substituted.
441
+ const localesMap = root.locales;
442
+ if (!isPlainObject(localesMap)) {
443
+ invalid('LOCALES_NOT_OBJECT', 'release notes locales must be a mapping keyed by locale', {});
444
+ }
445
+ const expectedSet = new Set(locales);
446
+ for (const key of Object.keys(localesMap)) {
447
+ if (!expectedSet.has(key)) {
448
+ invalid('EXTRA_LOCALE', 'release notes contain a locale that is not configured', { locale: key });
449
+ }
450
+ }
451
+ const missing = locales.filter((locale) => !Object.hasOwn(localesMap, locale));
452
+ if (missing.length > 0) {
453
+ translationMissing(missing);
454
+ }
455
+
456
+ const canonicalLocales = {};
457
+ for (const locale of locales) {
458
+ canonicalLocales[locale] = normalizeLocaleEntry(localesMap[locale], locale);
459
+ }
460
+
461
+ return { version, date, locales: canonicalLocales };
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Public API
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /**
469
+ * Parse structured release-notes bytes into the closed canonical model.
470
+ *
471
+ * Pure function: no file reads, no network, no subprocesses.
472
+ *
473
+ * @param {Uint8Array} bytes Raw source bytes (YAML or JSON).
474
+ * @param {object} options
475
+ * @param {string} options.format One of 'yaml', 'yml', 'json'.
476
+ * @param {string} options.expectedVersion Exact version the notes must declare.
477
+ * @param {string[]} options.locales Configured locales; each must appear exactly once.
478
+ * @param {number} [options.maxBytes] Input size limit (default 1 MiB).
479
+ * @returns {Readonly<{
480
+ * version: string,
481
+ * date: string,
482
+ * locales: Record<string, {
483
+ * summary: string,
484
+ * changes: Record<string, readonly string[]>,
485
+ * upgradeNotes?: string,
486
+ * }>,
487
+ * }>} deeply frozen canonical notes object
488
+ * @throws {ReleaseError} RELEASE_DOCS_INVALID on structural/semantic violations;
489
+ * RELEASE_DOCS_TRANSLATION_MISSING when a configured locale is absent.
490
+ */
491
+ export function parseReleaseNotes(bytes, options = {}) {
492
+ // --- Options validation ---
493
+ const { format, expectedVersion, locales } = options;
494
+ if (!SUPPORTED_FORMATS.has(format)) {
495
+ invalid('UNSUPPORTED_FORMAT', 'release notes format must be one of: yaml, yml, json', { format: String(format) });
496
+ }
497
+ if (typeof expectedVersion !== 'string' || expectedVersion.length === 0) {
498
+ invalid('INVALID_OPTIONS', 'expectedVersion must be a non-empty string', {});
499
+ }
500
+ if (!Array.isArray(locales) || locales.length === 0) {
501
+ invalid('INVALID_OPTIONS', 'locales must be a non-empty array', {});
502
+ }
503
+ const localeSet = new Set();
504
+ for (const locale of locales) {
505
+ if (typeof locale !== 'string' || locale.length === 0) {
506
+ invalid('INVALID_OPTIONS', 'locale identifiers must be non-empty strings', {});
507
+ }
508
+ if (localeSet.has(locale)) {
509
+ invalid('INVALID_OPTIONS', 'locale identifiers must be unique', { locale });
510
+ }
511
+ localeSet.add(locale);
512
+ }
513
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_NOTES_BYTES;
514
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
515
+ invalid('INVALID_OPTIONS', 'maxBytes must be a positive integer', {});
516
+ }
517
+
518
+ // --- Byte-level checks ---
519
+ if (!(bytes instanceof Uint8Array)) {
520
+ invalid('INVALID_OPTIONS', 'bytes must be a Uint8Array/Buffer', {});
521
+ }
522
+ if (bytes.length === 0) {
523
+ invalid('EMPTY_SOURCE', 'release notes source is empty', {});
524
+ }
525
+ if (bytes.length > maxBytes) {
526
+ invalid('INPUT_TOO_LARGE', 'release notes source exceeds the size limit', {
527
+ size: bytes.length,
528
+ maxBytes,
529
+ });
530
+ }
531
+
532
+ let text;
533
+ try {
534
+ // ignoreBOM: true keeps a leading BOM in the decoded text so the strict
535
+ // BOM check below can reject it (the decoder would strip it by default).
536
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
537
+ } catch {
538
+ invalid('NOT_UTF8', 'release notes source is not valid UTF-8', {});
539
+ }
540
+ if (text.charCodeAt(0) === 0xfeff) {
541
+ invalid('BOM', 'release notes source must not start with a byte order mark', {});
542
+ }
543
+ if (text.includes('\0')) {
544
+ invalid('NUL_BYTE', 'release notes source must not contain NUL bytes', {});
545
+ }
546
+
547
+ // --- Format-specific strict parsing ---
548
+ const parsed = format === 'json' ? parseStrictJson(text) : parseStrictYaml(text);
549
+
550
+ // --- Closed data-model validation + canonicalization ---
551
+ const canonical = validateNotesModel(parsed, { expectedVersion, locales: [...localeSet] });
552
+ return deepFreeze(canonical);
553
+ }