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