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