release-skill 0.1.5 → 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 (67) 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 +77 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +223 -8
  8. package/README.zh-CN.md +180 -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 +14182 -10017
  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 +118 -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 +14182 -10017
  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 +118 -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 +14182 -10017
  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 +118 -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/artifacts/transaction-journal.mjs +1126 -105
  56. package/src/artifacts/transaction.mjs +313 -130
  57. package/src/commands/docs.mjs +332 -0
  58. package/src/commands/prepare.mjs +316 -17
  59. package/src/core/errors.mjs +64 -2
  60. package/src/core/redact.mjs +206 -0
  61. package/src/docs/changelog-renderer.mjs +853 -0
  62. package/src/docs/config.mjs +337 -0
  63. package/src/docs/notes-loader.mjs +432 -0
  64. package/src/docs/notes.mjs +553 -0
  65. package/src/docs/readme-renderer.mjs +647 -0
  66. package/src/docs/refresh-planner.mjs +542 -0
  67. package/src/docs/refresh-service.mjs +675 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Deterministic pure renderer refreshing the current-version managed entry
3
+ * in a multilingual CHANGELOG from canonical release notes
4
+ * (2026-07-21-release-docs-refresh-protocol §4.2,
5
+ * 2026-07-21-changelog-release-renderer).
6
+ *
7
+ * `renderChangelogRelease({ bytes, target, notes })` maps the raw CHANGELOG
8
+ * bytes, one canonicalized changelog target ({ path, locale }), and
9
+ * canonical release notes to a deeply frozen projection carrying the
10
+ * candidate bytes. It is a pure function: no file reads or writes, no
11
+ * network, no subprocesses.
12
+ *
13
+ * Managed-entry protocol:
14
+ * - Entries are enclosed by
15
+ * `<!-- release-skill:changelog:start version=V locale=L baseline=sha256:<64hex> -->`
16
+ * and `<!-- release-skill:changelog:end version=V locale=L -->`.
17
+ * `baseline` is the SHA-256 of the canonical body bytes between the two
18
+ * comments (comments excluded; the single leading and trailing file-EOL
19
+ * adjacent to the comments are structural and excluded as well). Body
20
+ * newlines use the file's unique existing newline style.
21
+ * - When the current version has no managed entry and no same-version
22
+ * heading, one blank line, the canonical managed entry, and one blank
23
+ * line are inserted right after the single H1 line; every byte outside
24
+ * the insertion is preserved verbatim.
25
+ * - When a managed current entry exists, its markers must be unique,
26
+ * same-version, same-locale, complete, and neither nested nor crossed,
27
+ * and the current body digest must equal the recorded baseline; only
28
+ * then is the whole entry replaced. Human edits fail closed without
29
+ * being overwritten.
30
+ * - Unmanaged same-version headings, multiple same-version headings, and
31
+ * corrupt or mismatched current-version markers fail closed as
32
+ * conflicts; the renderer never degrades a corrupt state into an insert.
33
+ *
34
+ * Byte-protection contract:
35
+ * - Only the current-version managed entry is inserted or replaced; other
36
+ * versions' managed entries, old unmanaged entries, preamble, link
37
+ * definitions, and human notes are preserved verbatim.
38
+ * - Generated content uses the file's unique existing newline style; mixed
39
+ * CRLF/bare-LF files and bare CR fail closed with STRUCTURE_INVALID.
40
+ * Files without any newline default to LF.
41
+ * - The file must have exactly one H1; zero or multiple H1s fail closed
42
+ * with STRUCTURE_INVALID.
43
+ * - Rendering the same input twice is byte-idempotent.
44
+ * - Any corrupt, nested, crossed, or metadata-mismatched release-skill
45
+ * changelog marker fails closed without returning candidate bytes.
46
+ * Global (non-current) marker corruption fails with STRUCTURE_INVALID;
47
+ * current-version marker corruption or conflict fails with
48
+ * RELEASE_DOCS_CONFLICT.
49
+ * - A missing target locale fails closed with
50
+ * RELEASE_DOCS_TRANSLATION_MISSING naming the locale precisely; content
51
+ * is never substituted from another locale. `en` and `zh-CN` use fixed
52
+ * built-in labels; every other locale uses the English canonical labels
53
+ * (no translation is ever invoked).
54
+ * - Malformed target/notes shapes fail closed with RELEASE_DOCS_INVALID.
55
+ * Error details never carry note body text, credentials, or paths; the
56
+ * projection carries no path at all.
57
+ *
58
+ * Human/same-version conflicts (unmanaged same-version headings, corrupt or
59
+ * mismatched current-version markers, baseline mismatches after hand edits)
60
+ * fail closed with the stable RELEASE_DOCS_CONFLICT code (protocol §7) and a
61
+ * machine-readable `details.reason`.
62
+ *
63
+ * @module src/docs/changelog-renderer
64
+ */
65
+
66
+ import { RELEASE_NOTES_CATEGORIES } from './notes.mjs';
67
+ import { sha256Hex } from '../core/digest.mjs';
68
+ import {
69
+ ReleaseError,
70
+ RELEASE_DOCS_CONFLICT,
71
+ RELEASE_DOCS_INVALID,
72
+ RELEASE_DOCS_TRANSLATION_MISSING,
73
+ STRUCTURE_INVALID,
74
+ } from '../core/errors.mjs';
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Protocol constants
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** Generic byte prefix shared by every changelog managed-entry marker. */
81
+ const MARKER_PREFIX = '<!-- release-skill:changelog:';
82
+
83
+ /**
84
+ * Well-formed marker grammar (single line):
85
+ * - start: `<!-- release-skill:changelog:start version=V locale=L baseline=sha256:<64 lowercase hex> -->`
86
+ * - end: `<!-- release-skill:changelog:end version=V locale=L -->`
87
+ * Version/locale tokens carry no whitespace or angle brackets; a start
88
+ * marker without baseline or an end marker with baseline is corrupt.
89
+ */
90
+ const WELL_FORMED_MARKER =
91
+ /^<!-- release-skill:changelog:(start|end) version=([^\s<>]+) locale=([^\s<>]+)( baseline=sha256:([0-9a-f]{64}))? -->$/;
92
+
93
+ /** Max bytes scanned for a marker's closing `-->` before declaring corrupt. */
94
+ const MARKER_MAX_BYTES = 512;
95
+
96
+ /**
97
+ * Byte prefix no rendered body value may contain: it would inject managed
98
+ * marker structure and break byte-idempotent re-rendering.
99
+ */
100
+ const RESERVED_STRUCTURE_PREFIX = '<!-- release-skill:';
101
+
102
+ /** Fixed built-in category labels for locales with first-class support. */
103
+ const CATEGORY_LABELS = Object.freeze({
104
+ en: Object.freeze({
105
+ security: 'Security',
106
+ breaking: 'Breaking Changes',
107
+ added: 'Added',
108
+ changed: 'Changed',
109
+ deprecated: 'Deprecated',
110
+ removed: 'Removed',
111
+ fixed: 'Fixed',
112
+ }),
113
+ 'zh-CN': Object.freeze({
114
+ security: '安全',
115
+ breaking: '破坏性变更',
116
+ added: '新增',
117
+ changed: '变更',
118
+ deprecated: '弃用',
119
+ removed: '移除',
120
+ fixed: '修复',
121
+ }),
122
+ });
123
+
124
+ /** Fixed built-in upgrade-notes labels for locales with first-class support. */
125
+ const UPGRADE_NOTES_LABELS = Object.freeze({
126
+ en: 'Upgrade Notes',
127
+ 'zh-CN': '升级说明',
128
+ });
129
+
130
+ /**
131
+ * Stable label fallback for every other locale: the English canonical
132
+ * labels. Body content always comes from the requested locale and is never
133
+ * substituted; only labels fall back, and translation is never invoked.
134
+ */
135
+ const FALLBACK_LABEL_LOCALE = 'en';
136
+
137
+ const CATEGORY_SET = new Set(RELEASE_NOTES_CATEGORIES);
138
+
139
+ /**
140
+ * Characters a version/date may not contain: they would corrupt the
141
+ * `## [version] - date` heading or the space-separated marker metadata.
142
+ */
143
+ const UNSAFE_TOKEN_CHARS = /[\s[\]<>\0]/;
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Error helpers (details must never include note body text or paths)
147
+ // ---------------------------------------------------------------------------
148
+
149
+ function structureError(message, details = {}) {
150
+ throw new ReleaseError(STRUCTURE_INVALID, message, details);
151
+ }
152
+
153
+ function docsError(message, details = {}) {
154
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, details);
155
+ }
156
+
157
+ function translationMissing(locale) {
158
+ throw new ReleaseError(
159
+ RELEASE_DOCS_TRANSLATION_MISSING,
160
+ `release notes are missing locale: ${locale}`,
161
+ { reason: 'MISSING_LOCALE', locales: [locale] },
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Human/same-version conflict failure (protocol §7): unmanaged same-version
167
+ * content, corrupt/mismatched current-version markers, or hand-edited
168
+ * baselines. Details carry a machine-readable `reason`, never body text.
169
+ */
170
+ function conflictError(message, details = {}) {
171
+ throw new ReleaseError(RELEASE_DOCS_CONFLICT, message, details);
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Small utilities
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function isPlainObject(value) {
179
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
180
+ }
181
+
182
+ /**
183
+ * Deeply freeze a plain object/array structure. Typed arrays (Buffer) are
184
+ * skipped: the JS specification forbids freezing non-empty ArrayBuffer
185
+ * views, so candidate bytes are instead guaranteed immutable by always
186
+ * being a freshly composed copy that shares no mutable state with inputs.
187
+ */
188
+ function deepFreeze(value) {
189
+ if (Array.isArray(value)) {
190
+ for (const item of value) deepFreeze(item);
191
+ return Object.freeze(value);
192
+ }
193
+ if (value !== null && typeof value === 'object') {
194
+ if (ArrayBuffer.isView(value)) return value;
195
+ for (const item of Object.values(value)) deepFreeze(item);
196
+ return Object.freeze(value);
197
+ }
198
+ return value;
199
+ }
200
+
201
+ /** Split one canonical body value into logical lines at any CR/LF style. */
202
+ function valueLines(value) {
203
+ return value.split(/\r\n|\r|\n/);
204
+ }
205
+
206
+ function escapeRegExp(text) {
207
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Input validation (fail closed; never mutate inputs)
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Validate a canonical changelog target ({ path, locale }).
216
+ *
217
+ * @param {unknown} target
218
+ * @returns {{ locale: string }}
219
+ */
220
+ function validateTarget(target) {
221
+ if (!isPlainObject(target)) {
222
+ docsError('changelog target must be an object', { field: 'target' });
223
+ }
224
+ if (typeof target.path !== 'string' || target.path.length === 0) {
225
+ docsError('changelog target path must be a non-empty string', { field: 'target.path' });
226
+ }
227
+ if (typeof target.locale !== 'string' || target.locale.length === 0) {
228
+ docsError('changelog target locale must be a non-empty string', { field: 'target.locale' });
229
+ }
230
+ return { locale: target.locale };
231
+ }
232
+
233
+ /**
234
+ * Validate canonical release notes and extract the target locale's closed
235
+ * entry. A missing locale is a precise translation failure; content is
236
+ * never substituted from another locale.
237
+ *
238
+ * @param {unknown} notes
239
+ * @param {string} locale
240
+ * @returns {{
241
+ * version: string,
242
+ * date: string,
243
+ * summary: string,
244
+ * changes: Record<string, string[]>,
245
+ * upgradeNotes: string | undefined,
246
+ * categories: string[],
247
+ * }}
248
+ */
249
+ function validateNotes(notes, locale) {
250
+ if (!isPlainObject(notes)) {
251
+ docsError('release notes must be an object', { field: 'notes' });
252
+ }
253
+ const { version, date, locales } = notes;
254
+ if (typeof version !== 'string' || version.length === 0) {
255
+ docsError('release notes version must be a non-empty string', { field: 'notes.version' });
256
+ }
257
+ if (UNSAFE_TOKEN_CHARS.test(version)) {
258
+ docsError('release notes version must not contain whitespace, brackets, angle brackets, or NUL', {
259
+ field: 'notes.version',
260
+ });
261
+ }
262
+ if (typeof date !== 'string' || date.length === 0) {
263
+ docsError('release notes date must be a non-empty string', { field: 'notes.date' });
264
+ }
265
+ if (UNSAFE_TOKEN_CHARS.test(date)) {
266
+ docsError('release notes date must not contain whitespace, brackets, angle brackets, or NUL', {
267
+ field: 'notes.date',
268
+ });
269
+ }
270
+ if (!isPlainObject(locales)) {
271
+ docsError('release notes locales must be an object', { field: 'notes.locales' });
272
+ }
273
+ if (!Object.hasOwn(locales, locale)) {
274
+ translationMissing(locale);
275
+ }
276
+ const entry = locales[locale];
277
+ if (!isPlainObject(entry)) {
278
+ docsError('release notes locale entry must be an object', { locale });
279
+ }
280
+
281
+ if (typeof entry.summary !== 'string' || entry.summary.trim().length === 0) {
282
+ docsError('release notes summary must be a non-empty string', { locale });
283
+ }
284
+ const summary = entry.summary.trim();
285
+
286
+ if (!isPlainObject(entry.changes)) {
287
+ docsError('release notes changes must be a mapping of categories', { locale });
288
+ }
289
+ const changes = {};
290
+ let totalEntries = 0;
291
+ for (const category of RELEASE_NOTES_CATEGORIES) {
292
+ if (!Object.hasOwn(entry.changes, category)) continue;
293
+ const items = entry.changes[category];
294
+ if (!Array.isArray(items)) {
295
+ docsError('release notes change category must be an array', { locale, category });
296
+ }
297
+ const clean = [];
298
+ for (const item of items) {
299
+ if (typeof item !== 'string' || item.trim().length === 0) {
300
+ docsError('release notes change entries must be non-empty strings', { locale, category });
301
+ }
302
+ clean.push(item.trim());
303
+ }
304
+ if (clean.length > 0) {
305
+ changes[category] = clean;
306
+ totalEntries += clean.length;
307
+ }
308
+ }
309
+ for (const key of Object.keys(entry.changes)) {
310
+ if (!CATEGORY_SET.has(key)) {
311
+ docsError('release notes contain an unknown change category', { locale, category: key });
312
+ }
313
+ }
314
+ if (totalEntries === 0) {
315
+ docsError('release notes must contain at least one non-empty change category', { locale });
316
+ }
317
+
318
+ let upgradeNotes;
319
+ if (Object.hasOwn(entry, 'upgradeNotes')) {
320
+ if (typeof entry.upgradeNotes !== 'string' || entry.upgradeNotes.trim().length === 0) {
321
+ docsError('release notes upgradeNotes must be a non-empty string when present', { locale });
322
+ }
323
+ upgradeNotes = entry.upgradeNotes.trim();
324
+ }
325
+
326
+ const categories = RELEASE_NOTES_CATEGORIES.filter(
327
+ (category) => (changes[category]?.length ?? 0) > 0,
328
+ );
329
+
330
+ return { version, date, summary, changes, upgradeNotes, categories };
331
+ }
332
+
333
+ /**
334
+ * Fail closed if any rendered body value could inject managed markers or
335
+ * heading structure; preserving byte-idempotent re-rendering and the
336
+ * single-H1 invariant requires it.
337
+ *
338
+ * @param {{ summary: string, changes: Record<string, string[]>, upgradeNotes?: string }} entry
339
+ */
340
+ function assertBodySafe(entry) {
341
+ const allValues = [entry.summary, entry.upgradeNotes ?? '', ...Object.values(entry.changes).flat()];
342
+ for (const value of allValues) {
343
+ if (value.includes(RESERVED_STRUCTURE_PREFIX)) {
344
+ structureError('release notes body must not contain managed structure markers', {
345
+ reason: 'BODY_INJECTS_STRUCTURE',
346
+ });
347
+ }
348
+ }
349
+ // Summary and upgrade-notes lines render unindented; a '#' lead would
350
+ // inject a heading (item lines are prefixed with '- ' or ' ').
351
+ const unindented = [entry.summary, entry.upgradeNotes ?? ''];
352
+ for (const value of unindented) {
353
+ for (const line of valueLines(value)) {
354
+ if (line.startsWith('#')) {
355
+ structureError('release notes body must not contain heading lines', {
356
+ reason: 'BODY_INJECTS_HEADING',
357
+ });
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Byte-level structure detection
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Detect the file's unique existing newline style.
369
+ *
370
+ * @param {Buffer} bytes
371
+ * @returns {string} '\n' or '\r\n'
372
+ * @throws {ReleaseError} STRUCTURE_INVALID on bare CR or mixed CRLF/LF.
373
+ */
374
+ function detectEol(bytes) {
375
+ let crlf = 0;
376
+ let lf = 0;
377
+ let cr = 0;
378
+ for (let i = 0; i < bytes.length; i += 1) {
379
+ const byte = bytes[i];
380
+ if (byte === 0x0a) {
381
+ lf += 1;
382
+ } else if (byte === 0x0d) {
383
+ cr += 1;
384
+ if (bytes[i + 1] === 0x0a) crlf += 1;
385
+ }
386
+ }
387
+ const bareLf = lf - crlf;
388
+ const bareCr = cr - crlf;
389
+ if (bareCr > 0) {
390
+ structureError('CHANGELOG contains bare CR line endings', { reason: 'MIXED_LINE_ENDINGS' });
391
+ }
392
+ if (crlf > 0 && bareLf > 0) {
393
+ structureError('CHANGELOG mixes CRLF and LF line endings', { reason: 'MIXED_LINE_ENDINGS' });
394
+ }
395
+ return crlf > 0 ? '\r\n' : '\n';
396
+ }
397
+
398
+ /** A line is an ATX H1 at column zero: '#', '# ' or '#\t' lead. */
399
+ function isH1Line(text) {
400
+ return (
401
+ text.length >= 1 &&
402
+ text.charCodeAt(0) === 0x23 &&
403
+ (text.length === 1 || text[1] === ' ' || text[1] === '\t')
404
+ );
405
+ }
406
+
407
+ /**
408
+ * Split decoded text at the uniform EOL and record each line's byte span.
409
+ *
410
+ * @param {string} text
411
+ * @param {string} eol
412
+ * @returns {Array<{ text: string, byteStart: number, byteLen: number, terminated: boolean }>}
413
+ */
414
+ function splitLines(text, eol) {
415
+ const lines = text.split(eol);
416
+ const eolByteLen = Buffer.byteLength(eol, 'utf8');
417
+ const infos = [];
418
+ let cursor = 0;
419
+ for (let i = 0; i < lines.length; i += 1) {
420
+ const byteLen = Buffer.byteLength(lines[i], 'utf8');
421
+ infos.push({ text: lines[i], byteStart: cursor, byteLen, terminated: i < lines.length - 1 });
422
+ cursor += byteLen + eolByteLen;
423
+ }
424
+ return infos;
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Marker scanning and pairing
429
+ // ---------------------------------------------------------------------------
430
+
431
+ /**
432
+ * Scan every release-skill changelog marker occurrence.
433
+ *
434
+ * A prefix occurrence that does not form an exact well-formed start/end
435
+ * marker is corrupt. Corruption that names the current version fails
436
+ * closed as a conflict (corrupt current-version marker); any other
437
+ * corruption fails closed with STRUCTURE_INVALID. A corrupt state is never
438
+ * treated as "current version absent".
439
+ *
440
+ * @param {Buffer} bytes
441
+ * @param {string} version Canonical target version (notes).
442
+ * @returns {Array<{ kind: 'start' | 'end', version: string, locale: string, baseline?: string, start: number, end: number }>}
443
+ */
444
+ function scanMarkers(bytes, version) {
445
+ const prefixLen = Buffer.byteLength(MARKER_PREFIX, 'utf8');
446
+ const currentVersionPattern = new RegExp(`version=${escapeRegExp(version)}(?=[\\s]|$)`);
447
+ const markers = [];
448
+ let offset = 0;
449
+ while (offset <= bytes.length) {
450
+ const idx = bytes.indexOf(MARKER_PREFIX, offset, 'utf8');
451
+ if (idx < 0) break;
452
+ const closeIdx = bytes.indexOf('-->', idx + prefixLen, 'utf8');
453
+ const regionEnd =
454
+ closeIdx < 0 || closeIdx - idx > MARKER_MAX_BYTES
455
+ ? Math.min(idx + MARKER_MAX_BYTES, bytes.length)
456
+ : closeIdx + 3;
457
+ const regionText = bytes.toString('utf8', idx, regionEnd);
458
+ const match = closeIdx >= 0 && closeIdx - idx <= MARKER_MAX_BYTES
459
+ ? WELL_FORMED_MARKER.exec(regionText)
460
+ : null;
461
+ const wellFormed =
462
+ match !== null &&
463
+ ((match[1] === 'start' && match[5] !== undefined) ||
464
+ (match[1] === 'end' && match[5] === undefined));
465
+ if (!wellFormed) {
466
+ if (currentVersionPattern.test(regionText)) {
467
+ conflictError('CHANGELOG contains a corrupt current-version marker', {
468
+ reason: 'CORRUPT_CURRENT_MARKER',
469
+ offset: idx,
470
+ });
471
+ }
472
+ structureError('CHANGELOG contains a corrupt managed-entry marker', {
473
+ reason: 'CORRUPT_MARKER',
474
+ offset: idx,
475
+ });
476
+ }
477
+ markers.push({
478
+ kind: match[1],
479
+ version: match[2],
480
+ locale: match[3],
481
+ baseline: match[1] === 'start' ? match[5] : undefined,
482
+ start: idx,
483
+ end: idx + Buffer.byteLength(match[0], 'utf8'),
484
+ });
485
+ offset = idx + 1;
486
+ }
487
+ return markers;
488
+ }
489
+
490
+ /**
491
+ * Pair markers into complete entries, failing closed on nesting, crossing,
492
+ * metadata mismatch, and orphan markers.
493
+ *
494
+ * Pairing is two-phase so nested and crossed sequences are distinguished
495
+ * precisely: each end marker binds to the nearest preceding unmatched start
496
+ * with identical version+locale (a near-miss sharing version or locale is
497
+ * a metadata-mismatch failure, not an orphan), then the resulting spans
498
+ * must be strictly sequential — overlapping spans are nested when the
499
+ * inner span closes first, crossed otherwise. Violations involving the
500
+ * current version fail as conflicts; all others fail with STRUCTURE_INVALID.
501
+ *
502
+ * @param {Array<{ kind: string, version: string, locale: string, baseline?: string, start: number, end: number }>} markers
503
+ * @param {string} version
504
+ * @returns {Array<{ version: string, locale: string, baseline: string, start: number, bodyStart: number, bodyEnd: number, end: number }>}
505
+ */
506
+ function pairMarkers(markers, version) {
507
+ /** @type {Array<{ kind: string, version: string, locale: string, baseline?: string, start: number, end: number }>} */
508
+ const openStarts = [];
509
+ /** @type {Array<{ version: string, locale: string, baseline: string, start: number, bodyStart: number, bodyEnd: number, end: number }>} */
510
+ const entries = [];
511
+
512
+ for (const marker of markers) {
513
+ if (marker.kind === 'start') {
514
+ openStarts.push(marker);
515
+ continue;
516
+ }
517
+ // Nearest preceding unmatched start with identical metadata.
518
+ let foundIdx = -1;
519
+ for (let i = openStarts.length - 1; i >= 0; i -= 1) {
520
+ if (openStarts[i].version === marker.version && openStarts[i].locale === marker.locale) {
521
+ foundIdx = i;
522
+ break;
523
+ }
524
+ }
525
+ if (foundIdx === -1) {
526
+ // Near-miss: an open start shares version or locale -> mismatch.
527
+ let nearIdx = -1;
528
+ for (let i = openStarts.length - 1; i >= 0; i -= 1) {
529
+ if (openStarts[i].version === marker.version || openStarts[i].locale === marker.locale) {
530
+ nearIdx = i;
531
+ break;
532
+ }
533
+ }
534
+ if (nearIdx !== -1) {
535
+ if (openStarts[nearIdx].version === version || marker.version === version) {
536
+ conflictError('CHANGELOG current-version markers are crossed or metadata-mismatched', {
537
+ reason: 'CURRENT_ENTRY_CROSSED',
538
+ offset: marker.start,
539
+ });
540
+ }
541
+ structureError('CHANGELOG managed-entry markers are crossed or metadata-mismatched', {
542
+ reason: 'MARKER_CROSSED',
543
+ offset: marker.start,
544
+ });
545
+ }
546
+ // Orphan end marker.
547
+ if (marker.version === version) {
548
+ conflictError('CHANGELOG current-version end marker has no start marker', {
549
+ reason: 'INCOMPLETE_CURRENT_ENTRY',
550
+ offset: marker.start,
551
+ });
552
+ }
553
+ structureError('CHANGELOG end marker has no start marker', {
554
+ reason: 'INCOMPLETE_ENTRY',
555
+ offset: marker.start,
556
+ });
557
+ }
558
+ const open = openStarts[foundIdx];
559
+ openStarts.splice(foundIdx, 1);
560
+ entries.push({
561
+ version: open.version,
562
+ locale: open.locale,
563
+ baseline: open.baseline,
564
+ start: open.start,
565
+ bodyStart: open.end,
566
+ bodyEnd: marker.start,
567
+ end: marker.end,
568
+ });
569
+ }
570
+
571
+ // Orphan start markers.
572
+ for (const open of openStarts) {
573
+ if (open.version === version) {
574
+ conflictError('CHANGELOG current-version start marker has no end marker', {
575
+ reason: 'INCOMPLETE_CURRENT_ENTRY',
576
+ offset: open.start,
577
+ });
578
+ }
579
+ structureError('CHANGELOG start marker has no end marker', {
580
+ reason: 'INCOMPLETE_ENTRY',
581
+ offset: open.start,
582
+ });
583
+ }
584
+
585
+ // Entries must be strictly sequential: overlapping spans are nested when
586
+ // the inner span closes first, crossed otherwise.
587
+ const sorted = [...entries].sort((a, b) => a.start - b.start || a.end - b.end);
588
+ for (let i = 1; i < sorted.length; i += 1) {
589
+ const outer = sorted[i - 1];
590
+ const inner = sorted[i];
591
+ if (inner.start >= outer.end) continue;
592
+ const involvesCurrent = outer.version === version || inner.version === version;
593
+ if (inner.end > outer.end) {
594
+ if (involvesCurrent) {
595
+ conflictError('CHANGELOG current-version managed entries are crossed', {
596
+ reason: 'CURRENT_ENTRY_CROSSED',
597
+ offset: inner.start,
598
+ });
599
+ }
600
+ structureError('CHANGELOG managed entries are crossed', {
601
+ reason: 'MARKER_CROSSED',
602
+ offset: inner.start,
603
+ });
604
+ }
605
+ if (involvesCurrent) {
606
+ conflictError('CHANGELOG current-version managed entries are nested', {
607
+ reason: 'CURRENT_ENTRY_NESTED',
608
+ offset: inner.start,
609
+ });
610
+ }
611
+ structureError('CHANGELOG managed entries are nested', {
612
+ reason: 'MARKER_NESTED',
613
+ offset: inner.start,
614
+ });
615
+ }
616
+
617
+ return entries;
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Body rendering
622
+ // ---------------------------------------------------------------------------
623
+
624
+ /**
625
+ * Render the canonical entry body as logical lines (newline-style
626
+ * agnostic). Category order follows RELEASE_NOTES_CATEGORIES; labels come
627
+ * from the built-in locale tables with the English canonical fallback.
628
+ *
629
+ * @param {{ summary: string, changes: Record<string, string[]>, upgradeNotes?: string }} entry
630
+ * @param {string} locale
631
+ * @param {string} version
632
+ * @param {string} date
633
+ * @returns {string[]}
634
+ */
635
+ function renderBodyLines(entry, locale, version, date) {
636
+ const labels = CATEGORY_LABELS[locale] ?? CATEGORY_LABELS[FALLBACK_LABEL_LOCALE];
637
+ const upgradeLabel = UPGRADE_NOTES_LABELS[locale] ?? UPGRADE_NOTES_LABELS[FALLBACK_LABEL_LOCALE];
638
+
639
+ const lines = [];
640
+ lines.push(`## [${version}] - ${date}`);
641
+ lines.push('');
642
+ lines.push(...valueLines(entry.summary));
643
+ for (const category of RELEASE_NOTES_CATEGORIES) {
644
+ const items = entry.changes[category];
645
+ if (!items || items.length === 0) continue;
646
+ lines.push('');
647
+ lines.push(`### ${labels[category]}`);
648
+ lines.push('');
649
+ for (const item of items) {
650
+ const itemLines = valueLines(item);
651
+ lines.push(`- ${itemLines[0]}`);
652
+ for (let i = 1; i < itemLines.length; i += 1) {
653
+ lines.push(` ${itemLines[i]}`);
654
+ }
655
+ }
656
+ }
657
+ if (entry.upgradeNotes !== undefined) {
658
+ lines.push('');
659
+ lines.push(`### ${upgradeLabel}`);
660
+ lines.push('');
661
+ lines.push(...valueLines(entry.upgradeNotes));
662
+ }
663
+ return lines;
664
+ }
665
+
666
+ // ---------------------------------------------------------------------------
667
+ // Public API
668
+ // ---------------------------------------------------------------------------
669
+
670
+ /**
671
+ * Render the refreshed CHANGELOG candidate bytes from raw bytes, one
672
+ * canonical changelog target, and canonical release notes.
673
+ *
674
+ * Pure function: no file reads or writes, no network, no subprocesses.
675
+ * Inputs are never mutated; the returned projection (including the
676
+ * candidate bytes and every array) is deeply frozen.
677
+ *
678
+ * @param {object} input
679
+ * @param {Uint8Array} input.bytes Raw CHANGELOG bytes.
680
+ * @param {object} input.target Canonical changelog target ({ path, locale })
681
+ * as produced by normalizeReleaseDocumentsConfig.
682
+ * @param {object} input.notes Canonical release notes as produced by
683
+ * parseReleaseNotes ({ version, date, locales }).
684
+ * @returns {Readonly<{
685
+ * kind: 'changelog',
686
+ * locale: string,
687
+ * version: string,
688
+ * date: string,
689
+ * categories: readonly string[],
690
+ * changed: boolean,
691
+ * change: 'insert' | 'update' | 'none',
692
+ * bytes: Buffer,
693
+ * }>} deeply frozen projection with candidate bytes
694
+ * @throws {ReleaseError} STRUCTURE_INVALID on any byte-structure or global
695
+ * marker violation (no candidate bytes are ever returned on failure);
696
+ * RELEASE_DOCS_CONFLICT on human/same-version conflicts;
697
+ * RELEASE_DOCS_TRANSLATION_MISSING when the target locale is absent;
698
+ * RELEASE_DOCS_INVALID on malformed target/notes shapes.
699
+ */
700
+ export function renderChangelogRelease({ bytes, target, notes } = {}) {
701
+ if (!(bytes instanceof Uint8Array)) {
702
+ structureError('changelog bytes must be a Uint8Array/Buffer', { reason: 'INVALID_BYTES' });
703
+ }
704
+ const input = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
705
+
706
+ const { locale } = validateTarget(target);
707
+ const { version, date, summary, changes, upgradeNotes, categories } = validateNotes(notes, locale);
708
+
709
+ assertBodySafe({ summary, changes, upgradeNotes });
710
+
711
+ const eol = detectEol(input);
712
+
713
+ let text;
714
+ try {
715
+ text = new TextDecoder('utf-8', { fatal: true }).decode(input);
716
+ } catch {
717
+ structureError('CHANGELOG is not valid UTF-8', { reason: 'NOT_UTF8' });
718
+ }
719
+
720
+ const lines = splitLines(text, eol);
721
+ const eolByteLen = Buffer.byteLength(eol, 'utf8');
722
+
723
+ // Exactly one ATX H1 at column zero.
724
+ const h1Lines = lines.filter((line) => isH1Line(line.text));
725
+ if (h1Lines.length === 0) {
726
+ structureError('CHANGELOG must contain exactly one level-1 heading', { reason: 'NO_H1' });
727
+ }
728
+ if (h1Lines.length > 1) {
729
+ structureError('CHANGELOG must contain exactly one level-1 heading', {
730
+ reason: 'MULTIPLE_H1',
731
+ count: h1Lines.length,
732
+ });
733
+ }
734
+ const h1 = h1Lines[0];
735
+
736
+ // Global marker integrity, then current-version selection.
737
+ const markers = scanMarkers(input, version);
738
+ const entries = pairMarkers(markers, version);
739
+
740
+ const sameVersionEntries = entries.filter((entry) => entry.version === version);
741
+ if (sameVersionEntries.some((entry) => entry.locale !== locale)) {
742
+ conflictError('CHANGELOG current-version entry has the wrong locale for this target', {
743
+ reason: 'CURRENT_ENTRY_WRONG_LOCALE',
744
+ version,
745
+ locale,
746
+ });
747
+ }
748
+ const exactEntries = sameVersionEntries.filter((entry) => entry.locale === locale);
749
+ if (exactEntries.length > 1) {
750
+ conflictError('CHANGELOG contains duplicate current-version managed entries', {
751
+ reason: 'DUPLICATE_CURRENT_ENTRY',
752
+ version,
753
+ locale,
754
+ count: exactEntries.length,
755
+ });
756
+ }
757
+ const current = exactEntries.length === 1 ? exactEntries[0] : null;
758
+
759
+ // Same-version heading scan: every `## [version]` line outside the
760
+ // current managed entry span indicates a human or ambiguous conflict.
761
+ const headingPrefix = `## [${version}]`;
762
+ let outsideHeadings = 0;
763
+ for (const line of lines) {
764
+ if (!line.text.startsWith(headingPrefix)) continue;
765
+ const insideCurrent =
766
+ current !== null && line.byteStart >= current.start && line.byteStart < current.end;
767
+ if (!insideCurrent) outsideHeadings += 1;
768
+ }
769
+
770
+ // Canonical entry bytes (baseline binds the body between the comments).
771
+ const bodyLines = renderBodyLines({ summary, changes, upgradeNotes }, locale, version, date);
772
+ const canonicalBody = bodyLines.join(eol);
773
+ const baseline = sha256Hex(canonicalBody);
774
+ const startMarker =
775
+ `<!-- release-skill:changelog:start version=${version} locale=${locale} baseline=sha256:${baseline} -->`;
776
+ const endMarker = `<!-- release-skill:changelog:end version=${version} locale=${locale} -->`;
777
+ const entryBytes = Buffer.from(`${startMarker}${eol}${canonicalBody}${eol}${endMarker}`, 'utf8');
778
+
779
+ let out;
780
+ let change;
781
+ if (current === null) {
782
+ if (outsideHeadings === 1) {
783
+ conflictError('CHANGELOG has an unmanaged heading for the current version', {
784
+ reason: 'UNMANAGED_CURRENT_HEADING',
785
+ version,
786
+ locale,
787
+ });
788
+ }
789
+ if (outsideHeadings >= 2) {
790
+ conflictError('CHANGELOG has multiple headings for the current version', {
791
+ reason: 'MULTIPLE_CURRENT_HEADINGS',
792
+ version,
793
+ locale,
794
+ count: outsideHeadings,
795
+ });
796
+ }
797
+ // Insert: one blank line, the managed entry, one blank line — right
798
+ // after the H1 line. Every other byte is preserved by slicing.
799
+ const insertAt = h1.terminated ? h1.byteStart + h1.byteLen + eolByteLen : input.length;
800
+ const terminator = h1.terminated ? '' : eol;
801
+ const insertion = Buffer.from(
802
+ `${terminator}${eol}${startMarker}${eol}${canonicalBody}${eol}${endMarker}${eol}${eol}`,
803
+ 'utf8',
804
+ );
805
+ out = Buffer.concat([input.subarray(0, insertAt), insertion, input.subarray(insertAt)]);
806
+ change = 'insert';
807
+ } else {
808
+ if (outsideHeadings >= 1) {
809
+ conflictError('CHANGELOG has multiple headings for the current version', {
810
+ reason: 'MULTIPLE_CURRENT_HEADINGS',
811
+ version,
812
+ locale,
813
+ count: outsideHeadings + 1,
814
+ });
815
+ }
816
+ // Baseline verification: bytes between the comments are exactly
817
+ // EOL + canonical body + EOL; the body digest must match the marker.
818
+ const between = input.subarray(current.bodyStart, current.bodyEnd);
819
+ const eolBuf = Buffer.from(eol, 'utf8');
820
+ let candidate = null;
821
+ if (
822
+ between.length >= 2 * eolBuf.length &&
823
+ between.subarray(0, eolBuf.length).equals(eolBuf) &&
824
+ between.subarray(between.length - eolBuf.length).equals(eolBuf)
825
+ ) {
826
+ candidate = between.subarray(eolBuf.length, between.length - eolBuf.length);
827
+ }
828
+ const digest = candidate === null ? null : sha256Hex(candidate);
829
+ if (digest !== current.baseline) {
830
+ conflictError('CHANGELOG current-version entry was modified by hand (baseline mismatch)', {
831
+ reason: 'BASELINE_MISMATCH',
832
+ version,
833
+ locale,
834
+ });
835
+ }
836
+ const oldSpan = input.subarray(current.start, current.end);
837
+ change = oldSpan.equals(entryBytes) ? 'none' : 'update';
838
+ out = Buffer.concat([input.subarray(0, current.start), entryBytes, input.subarray(current.end)]);
839
+ }
840
+
841
+ const changed = !input.equals(out);
842
+
843
+ return deepFreeze({
844
+ kind: 'changelog',
845
+ locale,
846
+ version,
847
+ date,
848
+ categories,
849
+ changed,
850
+ change,
851
+ bytes: out,
852
+ });
853
+ }