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,647 @@
1
+ /**
2
+ * Deterministic pure renderer refreshing managed regions and version
3
+ * markers in a multilingual README from canonical release notes
4
+ * (2026-07-21-release-docs-refresh-protocol §4.1,
5
+ * 2026-07-21-readme-release-renderer).
6
+ *
7
+ * `renderReadmeRelease({ bytes, target, notes })` maps the raw README
8
+ * bytes, one canonicalized README target, and canonical release notes to a
9
+ * deeply frozen projection carrying the candidate bytes. It is a pure
10
+ * function: no file reads or writes, no network, no subprocesses.
11
+ *
12
+ * Byte-protection contract:
13
+ * - Only the bytes between each declared managed start/end marker pair and
14
+ * the machine version values are replaced; the markers themselves and
15
+ * every byte outside the declared ranges are preserved verbatim.
16
+ * - Generated content uses the file's unique existing newline style; mixed
17
+ * CRLF/bare-LF files and bare CR fail closed with STRUCTURE_INVALID.
18
+ * Files without any newline default to LF.
19
+ * - Rendering the same input twice is byte-idempotent.
20
+ * - Missing, duplicate, nested, crossed, reversed, corrupt, or undeclared
21
+ * managed markers; version-marker matches that are absent, ambiguous,
22
+ * empty, multiline, NUL-bearing, region-overlapping, or mutually
23
+ * overlapping; and body values that would inject managed structure all
24
+ * fail closed with STRUCTURE_INVALID without returning candidate bytes.
25
+ * - A missing target locale fails closed with
26
+ * RELEASE_DOCS_TRANSLATION_MISSING naming the locale precisely; content
27
+ * is never substituted from another locale. `en` and `zh-CN` use fixed
28
+ * built-in labels; every other locale uses the English canonical labels
29
+ * (no translation is ever invoked).
30
+ * - Malformed target/notes shapes fail closed with RELEASE_DOCS_INVALID.
31
+ * Error details never carry note body text, credentials, or paths; the
32
+ * projection carries no path at all.
33
+ *
34
+ * @module src/docs/readme-renderer
35
+ */
36
+
37
+ import { parseManagedRegions } from '../artifacts/merge/regions.mjs';
38
+ import { RELEASE_NOTES_CATEGORIES } from './notes.mjs';
39
+ import {
40
+ ReleaseError,
41
+ RELEASE_DOCS_INVALID,
42
+ RELEASE_DOCS_TRANSLATION_MISSING,
43
+ STRUCTURE_INVALID,
44
+ } from '../core/errors.mjs';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Protocol constants
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Version placeholder required inside every versionMarker pattern. */
51
+ const VERSION_PLACEHOLDER = '{version}';
52
+
53
+ /** Generic prefixes shared by all managed-region markers. */
54
+ const MANAGED_START_PREFIX = '<!-- release-skill:managed:start id=';
55
+ const MANAGED_END_PREFIX = '<!-- release-skill:managed:end id=';
56
+
57
+ /**
58
+ * Byte prefix no rendered body value may contain: it would inject managed
59
+ * or version-marker structure and break byte-idempotent re-rendering.
60
+ */
61
+ const RESERVED_STRUCTURE_PREFIX = '<!-- release-skill:';
62
+
63
+ /** Fixed built-in category labels for locales with first-class support. */
64
+ const CATEGORY_LABELS = Object.freeze({
65
+ en: Object.freeze({
66
+ security: 'Security',
67
+ breaking: 'Breaking Changes',
68
+ added: 'Added',
69
+ changed: 'Changed',
70
+ deprecated: 'Deprecated',
71
+ removed: 'Removed',
72
+ fixed: 'Fixed',
73
+ }),
74
+ 'zh-CN': Object.freeze({
75
+ security: '安全',
76
+ breaking: '破坏性变更',
77
+ added: '新增',
78
+ changed: '变更',
79
+ deprecated: '弃用',
80
+ removed: '移除',
81
+ fixed: '修复',
82
+ }),
83
+ });
84
+
85
+ /** Fixed built-in upgrade-notes labels for locales with first-class support. */
86
+ const UPGRADE_NOTES_LABELS = Object.freeze({
87
+ en: 'Upgrade Notes',
88
+ 'zh-CN': '升级说明',
89
+ });
90
+
91
+ /**
92
+ * Stable label fallback for every other locale: the English canonical
93
+ * labels. Body content always comes from the requested locale and is never
94
+ * substituted; only labels fall back, and translation is never invoked.
95
+ */
96
+ const FALLBACK_LABEL_LOCALE = 'en';
97
+
98
+ const CATEGORY_SET = new Set(RELEASE_NOTES_CATEGORIES);
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Error helpers (details must never include note body text or paths)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ function structureError(message, details = {}) {
105
+ throw new ReleaseError(STRUCTURE_INVALID, message, details);
106
+ }
107
+
108
+ function docsError(message, details = {}) {
109
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, details);
110
+ }
111
+
112
+ function translationMissing(locale) {
113
+ throw new ReleaseError(
114
+ RELEASE_DOCS_TRANSLATION_MISSING,
115
+ `release notes are missing locale: ${locale}`,
116
+ { reason: 'MISSING_LOCALE', locales: [locale] },
117
+ );
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Small utilities
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function isPlainObject(value) {
125
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
126
+ }
127
+
128
+ /**
129
+ * Deeply freeze a plain object/array structure. Typed arrays (Buffer) are
130
+ * skipped: the JS specification forbids freezing non-empty ArrayBuffer
131
+ * views, so candidate bytes are instead guaranteed immutable by always
132
+ * being a freshly composed copy that shares no mutable state with inputs.
133
+ */
134
+ function deepFreeze(value) {
135
+ if (Array.isArray(value)) {
136
+ for (const item of value) deepFreeze(item);
137
+ return Object.freeze(value);
138
+ }
139
+ if (value !== null && typeof value === 'object') {
140
+ if (ArrayBuffer.isView(value)) return value;
141
+ for (const item of Object.values(value)) deepFreeze(item);
142
+ return Object.freeze(value);
143
+ }
144
+ return value;
145
+ }
146
+
147
+ /** Split one canonical body value into logical lines at any CR/LF style. */
148
+ function valueLines(value) {
149
+ return value.split(/\r\n|\r|\n/);
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Input validation (fail closed; never mutate inputs)
154
+ // ---------------------------------------------------------------------------
155
+
156
+ /**
157
+ * Validate a canonical README target and derive its marker split form.
158
+ *
159
+ * @param {unknown} target
160
+ * @returns {{ locale: string, regions: string[], markers: Array<{ id: string, prefix: string, suffix: string }> }}
161
+ */
162
+ function validateTarget(target) {
163
+ if (!isPlainObject(target)) {
164
+ docsError('readme target must be an object', { field: 'target' });
165
+ }
166
+ if (typeof target.path !== 'string' || target.path.length === 0) {
167
+ docsError('readme target path must be a non-empty string', { field: 'target.path' });
168
+ }
169
+ if (typeof target.locale !== 'string' || target.locale.length === 0) {
170
+ docsError('readme target locale must be a non-empty string', { field: 'target.locale' });
171
+ }
172
+ if (!Array.isArray(target.regions) || target.regions.length === 0) {
173
+ docsError('readme target regions must be a non-empty array', { field: 'target.regions' });
174
+ }
175
+ for (const region of target.regions) {
176
+ if (typeof region !== 'string' || region.length === 0) {
177
+ docsError('readme target region ids must be non-empty strings', { field: 'target.regions' });
178
+ }
179
+ }
180
+
181
+ const markers = [];
182
+ if ('versionMarkers' in target) {
183
+ const { versionMarkers } = target;
184
+ if (!Array.isArray(versionMarkers) || versionMarkers.length === 0) {
185
+ docsError('readme target versionMarkers must be a non-empty array when present', {
186
+ field: 'target.versionMarkers',
187
+ });
188
+ }
189
+ for (const marker of versionMarkers) {
190
+ if (!isPlainObject(marker)) {
191
+ docsError('readme versionMarker must be an object', { field: 'target.versionMarkers' });
192
+ }
193
+ if (typeof marker.id !== 'string' || marker.id.length === 0) {
194
+ docsError('readme versionMarker id must be a non-empty string', { field: 'target.versionMarkers' });
195
+ }
196
+ const { pattern } = marker;
197
+ if (typeof pattern !== 'string' || pattern.length === 0) {
198
+ docsError('readme versionMarker pattern must be a non-empty string', {
199
+ markerId: marker.id,
200
+ });
201
+ }
202
+ if (/[\r\n\0]/.test(pattern)) {
203
+ docsError('readme versionMarker pattern must not contain CR, LF, or NUL', {
204
+ markerId: marker.id,
205
+ });
206
+ }
207
+ const start = pattern.indexOf(VERSION_PLACEHOLDER);
208
+ const next =
209
+ start === -1 ? -1 : pattern.indexOf(VERSION_PLACEHOLDER, start + VERSION_PLACEHOLDER.length);
210
+ if (
211
+ start === -1 ||
212
+ next !== -1 ||
213
+ start === 0 ||
214
+ start + VERSION_PLACEHOLDER.length === pattern.length
215
+ ) {
216
+ docsError(
217
+ 'readme versionMarker pattern must contain exactly one {version} placeholder with non-empty fixed bytes on both sides',
218
+ { markerId: marker.id },
219
+ );
220
+ }
221
+ markers.push({
222
+ id: marker.id,
223
+ prefix: pattern.slice(0, start),
224
+ suffix: pattern.slice(start + VERSION_PLACEHOLDER.length),
225
+ });
226
+ }
227
+ }
228
+
229
+ return { locale: target.locale, regions: [...target.regions], markers };
230
+ }
231
+
232
+ /**
233
+ * Validate canonical release notes and extract the target locale's closed
234
+ * entry. A missing locale is a precise translation failure; content is
235
+ * never substituted from another locale.
236
+ *
237
+ * @param {unknown} notes
238
+ * @param {string} locale
239
+ * @returns {{
240
+ * version: string,
241
+ * date: string,
242
+ * summary: string,
243
+ * changes: Record<string, string[]>,
244
+ * upgradeNotes: string | undefined,
245
+ * categories: string[],
246
+ * }}
247
+ */
248
+ function validateNotes(notes, locale) {
249
+ if (!isPlainObject(notes)) {
250
+ docsError('release notes must be an object', { field: 'notes' });
251
+ }
252
+ const { version, date, locales } = notes;
253
+ if (typeof version !== 'string' || version.length === 0) {
254
+ docsError('release notes version must be a non-empty string', { field: 'notes.version' });
255
+ }
256
+ if (/[\r\n\0]/.test(version)) {
257
+ docsError('release notes version must not contain CR, LF, or NUL', { field: 'notes.version' });
258
+ }
259
+ if (typeof date !== 'string' || date.length === 0) {
260
+ docsError('release notes date must be a non-empty string', { field: 'notes.date' });
261
+ }
262
+ if (!isPlainObject(locales)) {
263
+ docsError('release notes locales must be an object', { field: 'notes.locales' });
264
+ }
265
+ if (!Object.hasOwn(locales, locale)) {
266
+ translationMissing(locale);
267
+ }
268
+ const entry = locales[locale];
269
+ if (!isPlainObject(entry)) {
270
+ docsError('release notes locale entry must be an object', { locale });
271
+ }
272
+
273
+ if (typeof entry.summary !== 'string' || entry.summary.trim().length === 0) {
274
+ docsError('release notes summary must be a non-empty string', { locale });
275
+ }
276
+ const summary = entry.summary.trim();
277
+
278
+ if (!isPlainObject(entry.changes)) {
279
+ docsError('release notes changes must be a mapping of categories', { locale });
280
+ }
281
+ const changes = {};
282
+ let totalEntries = 0;
283
+ for (const category of RELEASE_NOTES_CATEGORIES) {
284
+ if (!Object.hasOwn(entry.changes, category)) continue;
285
+ const items = entry.changes[category];
286
+ if (!Array.isArray(items)) {
287
+ docsError('release notes change category must be an array', { locale, category });
288
+ }
289
+ const clean = [];
290
+ for (const item of items) {
291
+ if (typeof item !== 'string' || item.trim().length === 0) {
292
+ docsError('release notes change entries must be non-empty strings', { locale, category });
293
+ }
294
+ clean.push(item.trim());
295
+ }
296
+ if (clean.length > 0) {
297
+ changes[category] = clean;
298
+ totalEntries += clean.length;
299
+ }
300
+ }
301
+ for (const key of Object.keys(entry.changes)) {
302
+ if (!CATEGORY_SET.has(key)) {
303
+ docsError('release notes contain an unknown change category', { locale, category: key });
304
+ }
305
+ }
306
+ if (totalEntries === 0) {
307
+ docsError('release notes must contain at least one non-empty change category', { locale });
308
+ }
309
+
310
+ let upgradeNotes;
311
+ if (Object.hasOwn(entry, 'upgradeNotes')) {
312
+ if (typeof entry.upgradeNotes !== 'string' || entry.upgradeNotes.trim().length === 0) {
313
+ docsError('release notes upgradeNotes must be a non-empty string when present', { locale });
314
+ }
315
+ upgradeNotes = entry.upgradeNotes.trim();
316
+ }
317
+
318
+ const categories = RELEASE_NOTES_CATEGORIES.filter(
319
+ (category) => (changes[category]?.length ?? 0) > 0,
320
+ );
321
+
322
+ return { version, date, summary, changes, upgradeNotes, categories };
323
+ }
324
+
325
+ /**
326
+ * Fail closed if any rendered body value could inject managed structure or
327
+ * marker bytes; preserving byte-idempotent re-rendering requires it.
328
+ *
329
+ * @param {string[]} values
330
+ */
331
+ function assertBodySafe(values) {
332
+ for (const value of values) {
333
+ if (value.includes(RESERVED_STRUCTURE_PREFIX)) {
334
+ structureError('release notes body must not contain managed structure markers', {
335
+ reason: 'BODY_INJECTS_STRUCTURE',
336
+ });
337
+ }
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Byte-level structure detection
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /**
346
+ * Detect the file's unique existing newline style.
347
+ *
348
+ * @param {Buffer} bytes
349
+ * @returns {string} '\n' or '\r\n'
350
+ * @throws {ReleaseError} STRUCTURE_INVALID on bare CR or mixed CRLF/LF.
351
+ */
352
+ function detectEol(bytes) {
353
+ let crlf = 0;
354
+ let lf = 0;
355
+ let cr = 0;
356
+ for (let i = 0; i < bytes.length; i += 1) {
357
+ const byte = bytes[i];
358
+ if (byte === 0x0a) {
359
+ lf += 1;
360
+ } else if (byte === 0x0d) {
361
+ cr += 1;
362
+ if (bytes[i + 1] === 0x0a) crlf += 1;
363
+ }
364
+ }
365
+ const bareLf = lf - crlf;
366
+ const bareCr = cr - crlf;
367
+ if (bareCr > 0) {
368
+ structureError('README contains bare CR line endings', { reason: 'MIXED_LINE_ENDINGS' });
369
+ }
370
+ if (crlf > 0 && bareLf > 0) {
371
+ structureError('README mixes CRLF and LF line endings', { reason: 'MIXED_LINE_ENDINGS' });
372
+ }
373
+ return crlf > 0 ? '\r\n' : '\n';
374
+ }
375
+
376
+ /**
377
+ * Build managed-region declarations for one target.
378
+ *
379
+ * @param {string[]} regions
380
+ * @returns {Array<{ id: string, start: string, end: string }>}
381
+ */
382
+ function managedDeclarations(regions) {
383
+ return regions.map((id) => ({
384
+ id,
385
+ start: `${MANAGED_START_PREFIX}${id} -->`,
386
+ end: `${MANAGED_END_PREFIX}${id} -->`,
387
+ }));
388
+ }
389
+
390
+ /**
391
+ * Fail closed on corrupt or undeclared managed-region markers: every
392
+ * generic marker prefix occurrence must begin an exact declared marker.
393
+ *
394
+ * @param {Buffer} bytes
395
+ * @param {Array<{ id: string, start: string, end: string }>} declarations
396
+ */
397
+ function assertNoUnknownManagedMarkers(bytes, declarations) {
398
+ for (const kind of ['start', 'end']) {
399
+ const prefix = kind === 'start' ? MANAGED_START_PREFIX : MANAGED_END_PREFIX;
400
+ let offset = 0;
401
+ while (offset <= bytes.length) {
402
+ const idx = bytes.indexOf(prefix, offset, 'utf8');
403
+ if (idx < 0) break;
404
+ const known = declarations.some((decl) => {
405
+ const marker = kind === 'start' ? decl.start : decl.end;
406
+ return bytes.indexOf(marker, idx, 'utf8') === idx;
407
+ });
408
+ if (!known) {
409
+ structureError('README contains a corrupt or undeclared managed region marker', {
410
+ reason: 'UNKNOWN_MANAGED_MARKER',
411
+ offset: idx,
412
+ });
413
+ }
414
+ offset = idx + 1;
415
+ }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Find every version-marker match: a prefix occurrence followed by the
421
+ * suffix, with the machine value in between.
422
+ *
423
+ * @param {Buffer} bytes
424
+ * @param {string} prefix
425
+ * @param {string} suffix
426
+ * @returns {Array<{ prefixStart: number, valueStart: number, valueEnd: number, suffixEnd: number }>}
427
+ */
428
+ function findVersionMarkerMatches(bytes, prefix, suffix) {
429
+ const matches = [];
430
+ const prefixLength = Buffer.byteLength(prefix, 'utf8');
431
+ const suffixLength = Buffer.byteLength(suffix, 'utf8');
432
+ let offset = 0;
433
+ while (offset <= bytes.length) {
434
+ const prefixStart = bytes.indexOf(prefix, offset, 'utf8');
435
+ if (prefixStart < 0) break;
436
+ const suffixStart = bytes.indexOf(suffix, prefixStart + prefixLength, 'utf8');
437
+ if (suffixStart >= 0) {
438
+ matches.push({
439
+ prefixStart,
440
+ valueStart: prefixStart + prefixLength,
441
+ valueEnd: suffixStart,
442
+ suffixEnd: suffixStart + suffixLength,
443
+ });
444
+ }
445
+ offset = prefixStart + 1;
446
+ }
447
+ return matches;
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Body rendering
452
+ // ---------------------------------------------------------------------------
453
+
454
+ /**
455
+ * Render the region body as logical lines (newline-style agnostic).
456
+ * Category order follows RELEASE_NOTES_CATEGORIES; labels come from the
457
+ * built-in locale tables with the English canonical fallback.
458
+ *
459
+ * @param {{ summary: string, changes: Record<string, string[]>, upgradeNotes?: string }} entry
460
+ * @param {string} locale
461
+ * @param {string} version
462
+ * @param {string} date
463
+ * @returns {string[]}
464
+ */
465
+ function renderBodyLines(entry, locale, version, date) {
466
+ const labels = CATEGORY_LABELS[locale] ?? CATEGORY_LABELS[FALLBACK_LABEL_LOCALE];
467
+ const upgradeLabel = UPGRADE_NOTES_LABELS[locale] ?? UPGRADE_NOTES_LABELS[FALLBACK_LABEL_LOCALE];
468
+
469
+ const lines = [];
470
+ lines.push(`**${version}** (${date})`);
471
+ lines.push('');
472
+ lines.push(...valueLines(entry.summary));
473
+ for (const category of RELEASE_NOTES_CATEGORIES) {
474
+ const items = entry.changes[category];
475
+ if (!items || items.length === 0) continue;
476
+ lines.push('');
477
+ lines.push(`**${labels[category]}**`);
478
+ lines.push('');
479
+ for (const item of items) {
480
+ const itemLines = valueLines(item);
481
+ lines.push(`- ${itemLines[0]}`);
482
+ for (let i = 1; i < itemLines.length; i += 1) {
483
+ lines.push(` ${itemLines[i]}`);
484
+ }
485
+ }
486
+ }
487
+ if (entry.upgradeNotes !== undefined) {
488
+ lines.push('');
489
+ lines.push(`**${upgradeLabel}**`);
490
+ lines.push('');
491
+ lines.push(...valueLines(entry.upgradeNotes));
492
+ }
493
+ return lines;
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Public API
498
+ // ---------------------------------------------------------------------------
499
+
500
+ /**
501
+ * Render the refreshed README candidate bytes from raw bytes, one
502
+ * canonical README target, and canonical release notes.
503
+ *
504
+ * Pure function: no file reads or writes, no network, no subprocesses.
505
+ * Inputs are never mutated; the returned projection (including the
506
+ * candidate bytes and every array) is deeply frozen.
507
+ *
508
+ * @param {object} input
509
+ * @param {Uint8Array} input.bytes Raw README bytes.
510
+ * @param {object} input.target Canonical README target
511
+ * ({ path, locale, regions, versionMarkers? }) as produced by
512
+ * normalizeReleaseDocumentsConfig.
513
+ * @param {object} input.notes Canonical release notes as produced by
514
+ * parseReleaseNotes ({ version, date, locales }).
515
+ * @returns {Readonly<{
516
+ * kind: 'readme',
517
+ * locale: string,
518
+ * version: string,
519
+ * date: string,
520
+ * categories: readonly string[],
521
+ * regions: readonly string[],
522
+ * changed: boolean,
523
+ * bytes: Buffer,
524
+ * }>} deeply frozen projection with candidate bytes
525
+ * @throws {ReleaseError} STRUCTURE_INVALID on any byte-structure violation
526
+ * (no candidate bytes are ever returned on failure);
527
+ * RELEASE_DOCS_TRANSLATION_MISSING when the target locale is absent;
528
+ * RELEASE_DOCS_INVALID on malformed target/notes shapes.
529
+ */
530
+ export function renderReadmeRelease({ bytes, target, notes } = {}) {
531
+ if (!(bytes instanceof Uint8Array)) {
532
+ structureError('readme bytes must be a Uint8Array/Buffer', { reason: 'INVALID_BYTES' });
533
+ }
534
+ const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
535
+
536
+ const { locale, regions, markers } = validateTarget(target);
537
+ const { version, date, summary, changes, upgradeNotes, categories } = validateNotes(notes, locale);
538
+
539
+ assertBodySafe([summary, upgradeNotes ?? '', ...Object.values(changes).flat()]);
540
+
541
+ const eol = detectEol(input);
542
+
543
+ // Managed regions: parse via the shared byte-level parser after rejecting
544
+ // corrupt or undeclared markers the parser cannot see.
545
+ const declarations = managedDeclarations(regions);
546
+ assertNoUnknownManagedMarkers(input, declarations);
547
+ const ranges = parseManagedRegions(input, declarations);
548
+
549
+ // One shared body for every declared region, in the file's newline style.
550
+ const bodyLines = renderBodyLines({ summary, changes, upgradeNotes }, locale, version, date);
551
+ const innerBytes = Buffer.concat([
552
+ Buffer.from(eol, 'utf8'),
553
+ Buffer.from(bodyLines.join(eol), 'utf8'),
554
+ Buffer.from(eol, 'utf8'),
555
+ ]);
556
+
557
+ /** @type {Array<{ id: string, start: number, end: number }>} */
558
+ const protectedSpans = [];
559
+ /** @type {Array<{ start: number, end: number, replacement: Buffer }>} */
560
+ const edits = [];
561
+
562
+ for (const decl of declarations) {
563
+ const range = ranges.get(decl.id);
564
+ protectedSpans.push({ id: `region:${decl.id}`, start: range.start, end: range.end });
565
+ edits.push({
566
+ start: range.start + Buffer.byteLength(decl.start, 'utf8'),
567
+ end: range.end - Buffer.byteLength(decl.end, 'utf8'),
568
+ replacement: innerBytes,
569
+ });
570
+ }
571
+
572
+ // Version markers: exactly one match, non-empty single-line value, no
573
+ // overlap with managed regions or other markers.
574
+ const versionBytes = Buffer.from(version, 'utf8');
575
+ for (const marker of markers) {
576
+ const matches = findVersionMarkerMatches(input, marker.prefix, marker.suffix);
577
+ if (matches.length === 0) {
578
+ structureError(`version marker '${marker.id}' has no match in README`, {
579
+ reason: 'VERSION_MARKER_NO_MATCH',
580
+ markerId: marker.id,
581
+ });
582
+ }
583
+ if (matches.length > 1) {
584
+ structureError(`version marker '${marker.id}' matches more than once in README`, {
585
+ reason: 'VERSION_MARKER_AMBIGUOUS',
586
+ markerId: marker.id,
587
+ matches: matches.length,
588
+ });
589
+ }
590
+ const match = matches[0];
591
+ if (match.valueStart === match.valueEnd) {
592
+ structureError(`version marker '${marker.id}' has an empty machine value`, {
593
+ reason: 'VERSION_MARKER_EMPTY_VALUE',
594
+ markerId: marker.id,
595
+ });
596
+ }
597
+ const value = input.subarray(match.valueStart, match.valueEnd);
598
+ if (value.includes(0x0d) || value.includes(0x0a) || value.includes(0x00)) {
599
+ structureError(`version marker '${marker.id}' machine value spans lines or contains NUL`, {
600
+ reason: 'VERSION_MARKER_MULTILINE_VALUE',
601
+ markerId: marker.id,
602
+ });
603
+ }
604
+ protectedSpans.push({
605
+ id: `versionMarker:${marker.id}`,
606
+ start: match.prefixStart,
607
+ end: match.suffixEnd,
608
+ });
609
+ edits.push({
610
+ start: match.valueStart,
611
+ end: match.valueEnd,
612
+ replacement: versionBytes,
613
+ });
614
+ }
615
+
616
+ // No two protected spans may overlap: regions never do (parser-guaranteed),
617
+ // so this catches marker/region and marker/marker intersections.
618
+ const sortedSpans = [...protectedSpans].sort((a, b) => a.start - b.start || a.end - b.end);
619
+ for (let i = 1; i < sortedSpans.length; i += 1) {
620
+ if (sortedSpans[i].start < sortedSpans[i - 1].end) {
621
+ structureError('managed regions and version markers must not overlap', {
622
+ reason: 'OVERLAPPING_RANGES',
623
+ spans: [sortedSpans[i - 1].id, sortedSpans[i].id],
624
+ });
625
+ }
626
+ }
627
+
628
+ // Apply all edits in descending byte order so earlier offsets stay valid.
629
+ let out = input;
630
+ const sortedEdits = [...edits].sort((a, b) => b.start - a.start);
631
+ for (const edit of sortedEdits) {
632
+ out = Buffer.concat([out.subarray(0, edit.start), edit.replacement, out.subarray(edit.end)]);
633
+ }
634
+
635
+ const changed = !input.equals(out);
636
+
637
+ return deepFreeze({
638
+ kind: 'readme',
639
+ locale,
640
+ version,
641
+ date,
642
+ categories,
643
+ regions: [...regions],
644
+ changed,
645
+ bytes: out,
646
+ });
647
+ }