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,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
|
+
}
|