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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +77 -0
- package/INSTALL.md +81 -1
- package/INSTALL.zh-CN.md +69 -1
- package/README.md +223 -8
- package/README.zh-CN.md +180 -8
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +14182 -10017
- package/adapters/claude/bin/release-skill.mjs +24 -4
- package/adapters/claude/native/safe-write/binding.gyp +2 -1
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -10
- package/adapters/claude/schemas/release-project.schema.json +118 -0
- package/adapters/claude/skills/release-help/SKILL.md +21 -0
- package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
- package/adapters/claude/skills/release-publish/SKILL.md +3 -1
- package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +14182 -10017
- package/adapters/codex/bin/release-skill.mjs +24 -4
- package/adapters/codex/native/safe-write/binding.gyp +2 -1
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -10
- package/adapters/codex/schemas/release-project.schema.json +118 -0
- package/adapters/codex/skills/release-help/SKILL.md +21 -0
- package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
- package/adapters/codex/skills/release-publish/SKILL.md +3 -1
- package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
- package/bin/release-skill-cli.mjs +163 -4
- package/bin/release-skill.bundle.mjs +14182 -10017
- package/bin/release-skill.mjs +24 -4
- package/native/safe-write/binding.gyp +2 -1
- package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/native/safe-write/prebuilds.json +1 -1
- package/package.json +2 -2
- package/references/.render-manifest.json +4 -4
- package/references/02-project-config.md +24 -0
- package/references/05-evidence-and-errors.md +5 -0
- package/schemas/.render-manifest.json +10 -10
- package/schemas/release-project.schema.json +118 -0
- package/scripts/build-bundle.mjs +15 -2
- package/skills/release-help/SKILL.md +21 -0
- package/skills/release-prepare/SKILL.md +17 -6
- package/skills/release-publish/SKILL.md +3 -1
- package/skills/release-reconcile/SKILL.md +1 -1
- package/skills-src/release-help/SKILL.md +21 -0
- package/skills-src/release-prepare/SKILL.md +17 -6
- package/skills-src/release-publish/SKILL.md +3 -1
- package/skills-src/release-reconcile/SKILL.md +1 -1
- package/src/artifacts/transaction-journal.mjs +1126 -105
- package/src/artifacts/transaction.mjs +313 -130
- package/src/commands/docs.mjs +332 -0
- package/src/commands/prepare.mjs +316 -17
- package/src/core/errors.mjs +64 -2
- package/src/core/redact.mjs +206 -0
- package/src/docs/changelog-renderer.mjs +853 -0
- package/src/docs/config.mjs +337 -0
- package/src/docs/notes-loader.mjs +432 -0
- package/src/docs/notes.mjs +553 -0
- package/src/docs/readme-renderer.mjs +647 -0
- package/src/docs/refresh-planner.mjs +542 -0
- 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
|
+
}
|