release-skill 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +2 -2
  4. package/CHANGELOG.md +104 -0
  5. package/INSTALL.md +81 -1
  6. package/INSTALL.zh-CN.md +69 -1
  7. package/README.md +233 -8
  8. package/README.zh-CN.md +188 -8
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +14164 -9912
  12. package/adapters/claude/bin/release-skill.mjs +24 -4
  13. package/adapters/claude/native/safe-write/binding.gyp +2 -1
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +1 -1
  16. package/adapters/claude/schemas/.render-manifest.json +10 -10
  17. package/adapters/claude/schemas/release-project.schema.json +141 -0
  18. package/adapters/claude/skills/release-help/SKILL.md +21 -0
  19. package/adapters/claude/skills/release-prepare/SKILL.md +17 -6
  20. package/adapters/claude/skills/release-publish/SKILL.md +3 -1
  21. package/adapters/claude/skills/release-reconcile/SKILL.md +1 -1
  22. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  23. package/adapters/codex/bin/release-skill.bundle.mjs +14164 -9912
  24. package/adapters/codex/bin/release-skill.mjs +24 -4
  25. package/adapters/codex/native/safe-write/binding.gyp +2 -1
  26. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  27. package/adapters/codex/native/safe-write/prebuilds.json +1 -1
  28. package/adapters/codex/schemas/.render-manifest.json +10 -10
  29. package/adapters/codex/schemas/release-project.schema.json +141 -0
  30. package/adapters/codex/skills/release-help/SKILL.md +21 -0
  31. package/adapters/codex/skills/release-prepare/SKILL.md +17 -6
  32. package/adapters/codex/skills/release-publish/SKILL.md +3 -1
  33. package/adapters/codex/skills/release-reconcile/SKILL.md +1 -1
  34. package/bin/release-skill-cli.mjs +163 -4
  35. package/bin/release-skill.bundle.mjs +14164 -9912
  36. package/bin/release-skill.mjs +24 -4
  37. package/native/safe-write/binding.gyp +2 -1
  38. package/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  39. package/native/safe-write/prebuilds.json +1 -1
  40. package/package.json +2 -2
  41. package/references/.render-manifest.json +4 -4
  42. package/references/02-project-config.md +24 -0
  43. package/references/05-evidence-and-errors.md +5 -0
  44. package/schemas/.render-manifest.json +10 -10
  45. package/schemas/release-project.schema.json +141 -0
  46. package/scripts/build-bundle.mjs +15 -2
  47. package/skills/release-help/SKILL.md +21 -0
  48. package/skills/release-prepare/SKILL.md +17 -6
  49. package/skills/release-publish/SKILL.md +3 -1
  50. package/skills/release-reconcile/SKILL.md +1 -1
  51. package/skills-src/release-help/SKILL.md +21 -0
  52. package/skills-src/release-prepare/SKILL.md +17 -6
  53. package/skills-src/release-publish/SKILL.md +3 -1
  54. package/skills-src/release-reconcile/SKILL.md +1 -1
  55. package/src/adapters/plugin-marketplace.mjs +70 -3
  56. package/src/artifacts/transaction-journal.mjs +1126 -105
  57. package/src/artifacts/transaction.mjs +313 -130
  58. package/src/commands/docs.mjs +332 -0
  59. package/src/commands/prepare.mjs +324 -17
  60. package/src/commands/reconcile.mjs +4 -1
  61. package/src/commands/verify.mjs +4 -1
  62. package/src/core/errors.mjs +64 -2
  63. package/src/core/plan.mjs +59 -1
  64. package/src/core/redact.mjs +206 -0
  65. package/src/docs/changelog-renderer.mjs +853 -0
  66. package/src/docs/config.mjs +337 -0
  67. package/src/docs/notes-loader.mjs +432 -0
  68. package/src/docs/notes.mjs +553 -0
  69. package/src/docs/readme-renderer.mjs +647 -0
  70. package/src/docs/refresh-planner.mjs +542 -0
  71. package/src/docs/refresh-service.mjs +675 -0
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Deterministic pure planner for the two-phase release-document refresh
3
+ * protocol (2026-07-21-release-docs-refresh-protocol §5,
4
+ * 2026-07-21-release-docs-refresh-planner).
5
+ *
6
+ * `createReleaseDocsRefreshPlan({ unitId, version, config, notes,
7
+ * notesSourceDigest, oldFiles })` maps the canonicalized
8
+ * releaseDocuments configuration, canonical release notes, the notes-source
9
+ * byte digest, the release unit identifier, the canonical version, and the
10
+ * old bytes of every configured target to one deeply frozen plan. It
11
+ * renders each candidate through the existing README and CHANGELOG
12
+ * renderers; it never reads or writes files, never touches the network,
13
+ * and never spawns subprocesses.
14
+ *
15
+ * Target matching is exact and fail closed: every configured changelog and
16
+ * readme target must have exactly one input entry keyed by its canonical
17
+ * path with a matching kind and locale; missing, duplicate, extra, or
18
+ * kind/locale-mismatched entries fail closed with RELEASE_DOCS_INVALID.
19
+ *
20
+ * Digest binding contract:
21
+ * - `inputDigest` (`sha256:<64hex>`) binds the canonical notes object and
22
+ * the original notes-source byte digest.
23
+ * - `refreshDigest` (`sha256:<64hex>`) binds the protocol version, unitId,
24
+ * version, inputDigest, the canonical releaseDocuments configuration
25
+ * projection, and the sorted per-file path/kind/locale/oldDigest/
26
+ * newDigest/change. It never binds absolute paths, times, display text,
27
+ * candidate bodies, or input array order.
28
+ *
29
+ * The internal plan keeps share-protected copies of `oldBytes`/`newBytes`
30
+ * for the later write phase; mutating caller-held input buffers after
31
+ * planning cannot change plan candidates or digests.
32
+ *
33
+ * `projectReleaseDocsRefreshDisplay(plan)` derives the safe display
34
+ * projection: it never carries candidate bytes, note body text, or
35
+ * absolute paths, and it carries the exact `nextCommand.argv` string
36
+ * arrays (dry-run always; the write arguments — `--write`,
37
+ * `--confirm-refresh <refreshDigest>`, `--ack-local-document-write` —
38
+ * only when the plan has changes; never on a clean plan). No shell string
39
+ * is ever produced.
40
+ *
41
+ * Both the plan and the projection are deeply frozen; renderer failures
42
+ * (STRUCTURE_INVALID, RELEASE_DOCS_CONFLICT, RELEASE_DOCS_TRANSLATION_MISSING)
43
+ * propagate with their own stable codes, and malformed planner inputs fail
44
+ * closed with RELEASE_DOCS_INVALID. Error details never carry note body
45
+ * text, credentials, or absolute paths.
46
+ *
47
+ * @module src/docs/refresh-planner
48
+ */
49
+
50
+ import { canonicalJson, sha256Hex } from '../core/digest.mjs';
51
+ import { ReleaseError, RELEASE_DOCS_INVALID } from '../core/errors.mjs';
52
+ import { renderChangelogRelease } from './changelog-renderer.mjs';
53
+ import { renderReadmeRelease } from './readme-renderer.mjs';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Protocol constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /** Protocol version bound into every refreshDigest. */
60
+ export const RELEASE_DOCS_REFRESH_PROTOCOL_VERSION = 1;
61
+
62
+ /** Canonical digest form used for every digest in the refresh protocol. */
63
+ const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
64
+
65
+ /** Release unit identifier (mirrors the authoritative schema pattern). */
66
+ const UNIT_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9][A-Za-z0-9._-]*$/;
67
+
68
+ /** Allowed target kinds, one per renderer. */
69
+ const KINDS = new Set(['changelog', 'readme']);
70
+
71
+ /** Allowed plan statuses. */
72
+ const STATUSES = new Set(['changes', 'clean']);
73
+
74
+ /** Allowed per-file change kinds (union of both renderers' vocabularies). */
75
+ const CHANGES = new Set(['insert', 'update', 'none']);
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Small utilities
79
+ // ---------------------------------------------------------------------------
80
+
81
+ function invalid(message, details = {}) {
82
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, details);
83
+ }
84
+
85
+ function isPlainObject(value) {
86
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
87
+ }
88
+
89
+ /**
90
+ * Deeply freeze a plain object/array structure. Typed arrays (Buffer) are
91
+ * skipped: the JS specification forbids freezing non-empty ArrayBuffer
92
+ * views, so byte buffers are instead guaranteed immutable by always being
93
+ * freshly composed copies that share no mutable state with inputs.
94
+ */
95
+ function deepFreeze(value) {
96
+ if (Array.isArray(value)) {
97
+ for (const item of value) deepFreeze(item);
98
+ return Object.freeze(value);
99
+ }
100
+ if (value !== null && typeof value === 'object') {
101
+ if (ArrayBuffer.isView(value)) return value;
102
+ for (const item of Object.values(value)) deepFreeze(item);
103
+ return Object.freeze(value);
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function assertDigest(value, field) {
109
+ if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) {
110
+ invalid(`${field} must be a sha256:<64 lowercase hex> digest`, { field });
111
+ }
112
+ }
113
+
114
+ /** Canonical byte-order comparison of two paths. */
115
+ function comparePaths(a, b) {
116
+ return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Input validation (fail closed; never mutate inputs)
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Validate the canonicalized releaseDocuments configuration shape and build
125
+ * the exact target map keyed by canonical path. The config is expected to
126
+ * come from normalizeReleaseDocumentsConfig; every semantic violation here
127
+ * is a fail-closed RELEASE_DOCS_INVALID.
128
+ *
129
+ * @param {unknown} config
130
+ * @returns {{ targets: Map<string, { kind: string, locale: string, target: object }>, locales: string[] }}
131
+ */
132
+ function validateConfig(config) {
133
+ if (!isPlainObject(config)) {
134
+ invalid('releaseDocuments config must be an object', { field: 'config' });
135
+ }
136
+ if (typeof config.notesSource !== 'string' || config.notesSource.length === 0) {
137
+ invalid('releaseDocuments config notesSource must be a non-empty string', {
138
+ field: 'config.notesSource',
139
+ });
140
+ }
141
+ if (!Array.isArray(config.locales) || config.locales.length === 0) {
142
+ invalid('releaseDocuments config locales must be a non-empty array', { field: 'config.locales' });
143
+ }
144
+ for (const locale of config.locales) {
145
+ if (typeof locale !== 'string' || locale.length === 0) {
146
+ invalid('releaseDocuments config locale identifiers must be non-empty strings', {
147
+ field: 'config.locales',
148
+ });
149
+ }
150
+ }
151
+
152
+ /** @type {Map<string, { kind: string, locale: string, target: object }>} */
153
+ const targets = new Map();
154
+ const register = (kind, target, where) => {
155
+ if (!isPlainObject(target)) {
156
+ invalid(`releaseDocuments ${kind} target must be an object`, { where });
157
+ }
158
+ if (typeof target.path !== 'string' || target.path.length === 0) {
159
+ invalid(`releaseDocuments ${kind} target path must be a non-empty string`, { where });
160
+ }
161
+ if (typeof target.locale !== 'string' || target.locale.length === 0) {
162
+ invalid(`releaseDocuments ${kind} target locale must be a non-empty string`, { where });
163
+ }
164
+ if (targets.has(target.path)) {
165
+ invalid('releaseDocuments config has a duplicate target path', { where, path: target.path });
166
+ }
167
+ targets.set(target.path, { kind, locale: target.locale, target });
168
+ };
169
+
170
+ if (!Array.isArray(config.changelogs) || config.changelogs.length === 0) {
171
+ invalid('releaseDocuments config changelogs must be a non-empty array', {
172
+ field: 'config.changelogs',
173
+ });
174
+ }
175
+ config.changelogs.forEach((target, index) =>
176
+ register('changelog', target, `config.changelogs[${index}]`),
177
+ );
178
+
179
+ if (!Array.isArray(config.readmes) || config.readmes.length === 0) {
180
+ invalid('releaseDocuments config readmes must be a non-empty array', {
181
+ field: 'config.readmes',
182
+ });
183
+ }
184
+ config.readmes.forEach((target, index) => {
185
+ const where = `config.readmes[${index}]`;
186
+ if (!Array.isArray(target.regions) || target.regions.length === 0) {
187
+ invalid('releaseDocuments readme target regions must be a non-empty array', { where });
188
+ }
189
+ for (const region of target.regions) {
190
+ if (typeof region !== 'string' || region.length === 0) {
191
+ invalid('releaseDocuments readme region ids must be non-empty strings', { where });
192
+ }
193
+ }
194
+ if ('versionMarkers' in target) {
195
+ const { versionMarkers } = target;
196
+ if (!Array.isArray(versionMarkers) || versionMarkers.length === 0) {
197
+ invalid('releaseDocuments readme versionMarkers must be a non-empty array when present', {
198
+ where,
199
+ });
200
+ }
201
+ for (const marker of versionMarkers) {
202
+ if (
203
+ !isPlainObject(marker) ||
204
+ typeof marker.id !== 'string' ||
205
+ marker.id.length === 0 ||
206
+ typeof marker.pattern !== 'string' ||
207
+ marker.pattern.length === 0
208
+ ) {
209
+ invalid('releaseDocuments readme versionMarker must carry a non-empty id and pattern', {
210
+ where,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ register('readme', target, where);
216
+ });
217
+
218
+ return { targets, locales: [...config.locales] };
219
+ }
220
+
221
+ /**
222
+ * Validate the per-target old-byte inputs against the exact configured
223
+ * target set: every entry must match one configured path with matching
224
+ * kind and locale; duplicates, extras, and mismatches fail closed.
225
+ *
226
+ * @param {unknown} oldFiles
227
+ * @param {Map<string, { kind: string, locale: string, target: object }>} targets
228
+ * @returns {Map<string, Uint8Array>} old bytes keyed by canonical path
229
+ */
230
+ function validateOldFiles(oldFiles, targets) {
231
+ if (!Array.isArray(oldFiles)) {
232
+ invalid('oldFiles must be an array of per-target inputs', { field: 'oldFiles' });
233
+ }
234
+ /** @type {Map<string, Uint8Array>} */
235
+ const bytesByPath = new Map();
236
+ oldFiles.forEach((entry, index) => {
237
+ const where = `oldFiles[${index}]`;
238
+ if (!isPlainObject(entry)) {
239
+ invalid('oldFiles entries must be objects', { where });
240
+ }
241
+ if (typeof entry.path !== 'string' || entry.path.length === 0) {
242
+ invalid('oldFiles entry path must be a non-empty string', { where });
243
+ }
244
+ if (typeof entry.kind !== 'string' || !KINDS.has(entry.kind)) {
245
+ invalid('oldFiles entry kind must be "changelog" or "readme"', { where, kind: entry.kind });
246
+ }
247
+ if (typeof entry.locale !== 'string' || entry.locale.length === 0) {
248
+ invalid('oldFiles entry locale must be a non-empty string', { where });
249
+ }
250
+ if (!(entry.bytes instanceof Uint8Array)) {
251
+ invalid('oldFiles entry bytes must be a Uint8Array/Buffer', { where });
252
+ }
253
+ if (bytesByPath.has(entry.path)) {
254
+ invalid('oldFiles contains a duplicate target path', { where, path: entry.path });
255
+ }
256
+ const expected = targets.get(entry.path);
257
+ if (expected === undefined) {
258
+ invalid('oldFiles contains a target that is not configured', { where, path: entry.path });
259
+ }
260
+ if (expected.kind !== entry.kind) {
261
+ invalid('oldFiles entry kind does not match the configured target', {
262
+ where,
263
+ path: entry.path,
264
+ expected: expected.kind,
265
+ actual: entry.kind,
266
+ });
267
+ }
268
+ if (expected.locale !== entry.locale) {
269
+ invalid('oldFiles entry locale does not match the configured target', {
270
+ where,
271
+ path: entry.path,
272
+ expected: expected.locale,
273
+ actual: entry.locale,
274
+ });
275
+ }
276
+ bytesByPath.set(entry.path, entry.bytes);
277
+ });
278
+ for (const path of targets.keys()) {
279
+ if (!bytesByPath.has(path)) {
280
+ invalid('oldFiles is missing an input for a configured target', {
281
+ reason: 'MISSING_TARGET',
282
+ path,
283
+ });
284
+ }
285
+ }
286
+ return bytesByPath;
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // Public API
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Plan the release-document refresh for one release unit.
295
+ *
296
+ * Pure function: no file reads or writes, no network, no subprocesses.
297
+ * Inputs are never mutated; the returned plan (including every array and
298
+ * nested object) is deeply frozen, and the internal byte copies share no
299
+ * mutable state with caller-held buffers.
300
+ *
301
+ * @param {object} input
302
+ * @param {string} input.unitId Release unit identifier.
303
+ * @param {string} input.version Canonical release version; must equal
304
+ * `notes.version`.
305
+ * @param {object} input.config Canonicalized releaseDocuments
306
+ * configuration as produced by normalizeReleaseDocumentsConfig.
307
+ * @param {object} input.notes Canonical release notes as produced by
308
+ * parseReleaseNotes ({ version, date, locales }).
309
+ * @param {string} input.notesSourceDigest `sha256:<64hex>` digest of the
310
+ * original notes-source bytes.
311
+ * @param {Array<{ path: string, kind: 'changelog' | 'readme', locale: string, bytes: Uint8Array }>} input.oldFiles
312
+ * Old bytes for exactly the configured targets, keyed by canonical path.
313
+ * @returns {Readonly<{
314
+ * status: 'changes' | 'clean',
315
+ * unitId: string,
316
+ * version: string,
317
+ * locales: readonly string[],
318
+ * inputDigest: string,
319
+ * refreshDigest: string,
320
+ * files: ReadonlyArray<{
321
+ * path: string,
322
+ * kind: 'changelog' | 'readme',
323
+ * locale: string,
324
+ * oldDigest: string,
325
+ * newDigest: string,
326
+ * change: 'insert' | 'update' | 'none',
327
+ * changed: boolean,
328
+ * summary: { oldSize: number, newSize: number, delta: number },
329
+ * oldBytes: Buffer,
330
+ * newBytes: Buffer,
331
+ * }>,
332
+ * }>} deeply frozen plan; files are sorted by canonical path byte order
333
+ * @throws {ReleaseError} RELEASE_DOCS_INVALID on malformed inputs
334
+ * (missing/duplicate/extra/mismatched targets, malformed digests,
335
+ * version drift); STRUCTURE_INVALID / RELEASE_DOCS_CONFLICT /
336
+ * RELEASE_DOCS_TRANSLATION_MISSING propagate from the renderers.
337
+ */
338
+ export function createReleaseDocsRefreshPlan({
339
+ unitId,
340
+ version,
341
+ config,
342
+ notes,
343
+ notesSourceDigest,
344
+ oldFiles,
345
+ } = {}) {
346
+ if (typeof unitId !== 'string' || !UNIT_ID_PATTERN.test(unitId)) {
347
+ invalid('unitId must be a release unit identifier', { field: 'unitId' });
348
+ }
349
+ if (typeof version !== 'string' || version.length === 0) {
350
+ invalid('version must be a non-empty string', { field: 'version' });
351
+ }
352
+ if (!isPlainObject(notes)) {
353
+ invalid('release notes must be an object', { field: 'notes' });
354
+ }
355
+ if (typeof notes.version !== 'string' || notes.version !== version) {
356
+ invalid('release notes version does not match the release version', {
357
+ reason: 'VERSION_DRIFT',
358
+ field: 'notes.version',
359
+ });
360
+ }
361
+ assertDigest(notesSourceDigest, 'notesSourceDigest');
362
+
363
+ const { targets, locales } = validateConfig(config);
364
+ const bytesByPath = validateOldFiles(oldFiles, targets);
365
+
366
+ // Render every target through the existing deterministic renderers.
367
+ const files = [];
368
+ for (const [path, entry] of targets) {
369
+ const oldBytes = Buffer.from(bytesByPath.get(path));
370
+ const rendered =
371
+ entry.kind === 'changelog'
372
+ ? renderChangelogRelease({ bytes: oldBytes, target: entry.target, notes })
373
+ : renderReadmeRelease({ bytes: oldBytes, target: entry.target, notes });
374
+ const newBytes = Buffer.from(rendered.bytes);
375
+ const changed = !oldBytes.equals(newBytes);
376
+ const change = entry.kind === 'changelog' ? rendered.change : changed ? 'update' : 'none';
377
+ files.push({
378
+ path,
379
+ kind: entry.kind,
380
+ locale: entry.locale,
381
+ oldDigest: `sha256:${sha256Hex(oldBytes)}`,
382
+ newDigest: `sha256:${sha256Hex(newBytes)}`,
383
+ change,
384
+ changed,
385
+ summary: {
386
+ oldSize: oldBytes.length,
387
+ newSize: newBytes.length,
388
+ delta: newBytes.length - oldBytes.length,
389
+ },
390
+ oldBytes,
391
+ newBytes,
392
+ });
393
+ }
394
+ files.sort((a, b) => comparePaths(a.path, b.path));
395
+
396
+ const inputDigest = `sha256:${sha256Hex(canonicalJson({ notes, notesSourceDigest }))}`;
397
+ const refreshDigest = `sha256:${sha256Hex(
398
+ canonicalJson({
399
+ protocolVersion: RELEASE_DOCS_REFRESH_PROTOCOL_VERSION,
400
+ unitId,
401
+ version,
402
+ inputDigest,
403
+ config,
404
+ files: files.map((file) => ({
405
+ path: file.path,
406
+ kind: file.kind,
407
+ locale: file.locale,
408
+ oldDigest: file.oldDigest,
409
+ newDigest: file.newDigest,
410
+ change: file.change,
411
+ })),
412
+ }),
413
+ )}`;
414
+
415
+ const status = files.some((file) => file.changed) ? 'changes' : 'clean';
416
+ return deepFreeze({ status, unitId, version, locales, inputDigest, refreshDigest, files });
417
+ }
418
+
419
+ /**
420
+ * Project the safe, displayable view of a refresh plan.
421
+ *
422
+ * Pure function. The projection never carries candidate bytes, note body
423
+ * text, or absolute paths, and it carries the exact `nextCommand.argv`
424
+ * string arrays: the dry-run argv always; the write argv (dry-run argv
425
+ * plus `--write`, `--confirm-refresh <refreshDigest>`, and
426
+ * `--ack-local-document-write`) only when the plan has changes; `null`
427
+ * (never a write suggestion) on a clean plan. No shell string is ever
428
+ * produced.
429
+ *
430
+ * @param {object} plan A plan produced by createReleaseDocsRefreshPlan.
431
+ * @returns {Readonly<{
432
+ * status: 'changes' | 'clean',
433
+ * unitId: string,
434
+ * version: string,
435
+ * locales: readonly string[],
436
+ * inputDigest: string,
437
+ * refreshDigest: string,
438
+ * files: ReadonlyArray<{
439
+ * path: string,
440
+ * kind: 'changelog' | 'readme',
441
+ * locale: string,
442
+ * oldDigest: string,
443
+ * newDigest: string,
444
+ * change: 'insert' | 'update' | 'none',
445
+ * changed: boolean,
446
+ * summary: { oldSize: number, newSize: number, delta: number },
447
+ * }>,
448
+ * nextCommand: { argv: readonly string[], writeArgv: readonly string[] | null },
449
+ * }>} deeply frozen display projection
450
+ * @throws {ReleaseError} RELEASE_DOCS_INVALID on malformed plan shapes.
451
+ */
452
+ export function projectReleaseDocsRefreshDisplay(plan) {
453
+ if (!isPlainObject(plan)) {
454
+ invalid('plan must be an object', { field: 'plan' });
455
+ }
456
+ if (typeof plan.status !== 'string' || !STATUSES.has(plan.status)) {
457
+ invalid('plan status must be "changes" or "clean"', { field: 'plan.status' });
458
+ }
459
+ if (typeof plan.unitId !== 'string' || !UNIT_ID_PATTERN.test(plan.unitId)) {
460
+ invalid('plan unitId must be a release unit identifier', { field: 'plan.unitId' });
461
+ }
462
+ if (typeof plan.version !== 'string' || plan.version.length === 0) {
463
+ invalid('plan version must be a non-empty string', { field: 'plan.version' });
464
+ }
465
+ if (!Array.isArray(plan.locales)) {
466
+ invalid('plan locales must be an array', { field: 'plan.locales' });
467
+ }
468
+ for (const locale of plan.locales) {
469
+ if (typeof locale !== 'string' || locale.length === 0) {
470
+ invalid('plan locale identifiers must be non-empty strings', { field: 'plan.locales' });
471
+ }
472
+ }
473
+ assertDigest(plan.inputDigest, 'plan.inputDigest');
474
+ assertDigest(plan.refreshDigest, 'plan.refreshDigest');
475
+ if (!Array.isArray(plan.files)) {
476
+ invalid('plan files must be an array', { field: 'plan.files' });
477
+ }
478
+
479
+ const files = plan.files.map((file, index) => {
480
+ const where = `plan.files[${index}]`;
481
+ if (!isPlainObject(file)) {
482
+ invalid('plan file entries must be objects', { where });
483
+ }
484
+ if (typeof file.path !== 'string' || file.path.length === 0) {
485
+ invalid('plan file path must be a non-empty string', { where });
486
+ }
487
+ if (typeof file.kind !== 'string' || !KINDS.has(file.kind)) {
488
+ invalid('plan file kind must be "changelog" or "readme"', { where });
489
+ }
490
+ if (typeof file.locale !== 'string' || file.locale.length === 0) {
491
+ invalid('plan file locale must be a non-empty string', { where });
492
+ }
493
+ assertDigest(file.oldDigest, `${where}.oldDigest`);
494
+ assertDigest(file.newDigest, `${where}.newDigest`);
495
+ if (typeof file.change !== 'string' || !CHANGES.has(file.change)) {
496
+ invalid('plan file change must be "insert", "update", or "none"', { where });
497
+ }
498
+ if (typeof file.changed !== 'boolean') {
499
+ invalid('plan file changed must be a boolean', { where });
500
+ }
501
+ if (
502
+ !isPlainObject(file.summary) ||
503
+ typeof file.summary.oldSize !== 'number' ||
504
+ typeof file.summary.newSize !== 'number' ||
505
+ typeof file.summary.delta !== 'number'
506
+ ) {
507
+ invalid('plan file summary must carry numeric oldSize, newSize, and delta', { where });
508
+ }
509
+ // Whitelisted copy: candidate bytes never reach the projection.
510
+ return {
511
+ path: file.path,
512
+ kind: file.kind,
513
+ locale: file.locale,
514
+ oldDigest: file.oldDigest,
515
+ newDigest: file.newDigest,
516
+ change: file.change,
517
+ changed: file.changed,
518
+ summary: {
519
+ oldSize: file.summary.oldSize,
520
+ newSize: file.summary.newSize,
521
+ delta: file.summary.delta,
522
+ },
523
+ };
524
+ });
525
+
526
+ const argv = ['release-skill', 'docs', 'refresh', '--unit', plan.unitId];
527
+ const writeArgv =
528
+ plan.status === 'changes'
529
+ ? [...argv, '--write', '--confirm-refresh', plan.refreshDigest, '--ack-local-document-write']
530
+ : null;
531
+
532
+ return deepFreeze({
533
+ status: plan.status,
534
+ unitId: plan.unitId,
535
+ version: plan.version,
536
+ locales: [...plan.locales],
537
+ inputDigest: plan.inputDigest,
538
+ refreshDigest: plan.refreshDigest,
539
+ files,
540
+ nextCommand: { argv, writeArgv },
541
+ });
542
+ }