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,337 @@
1
+ /**
2
+ * Runtime semantic validation for the releaseDocuments configuration block
3
+ * (2026-07-21-release-docs-refresh-protocol §2).
4
+ *
5
+ * The JSON Schema layer provides the first lexical protection; this module
6
+ * performs the closed-world semantic checks that cannot be expressed there:
7
+ *
8
+ * - every changelog/readme target locale is declared in `locales`;
9
+ * - locale identifiers, region ids, and versionMarker ids are unique under
10
+ * exact, case-fold, and Unicode NFC comparison;
11
+ * - changelog/readme target paths share one canonical namespace: each path
12
+ * is canonicalized through the existing `canonicalArtifactPath` helper
13
+ * (NFC, no traversal/absolute/backslash/NUL/empty segments) and duplicate
14
+ * or case/Unicode-colliding targets are rejected;
15
+ * - every versionMarker pattern contains exactly one `{version}` placeholder
16
+ * with non-empty fixed bytes on both sides and no CR/LF/NUL bytes
17
+ * (2026-07-21-readme-release-renderer §2);
18
+ * - every object level is closed: unknown fields fail closed;
19
+ * - the input object is never mutated; a deeply frozen canonical object is
20
+ * returned on success.
21
+ *
22
+ * Pure and independently callable: no file reads, no YAML parsing, no
23
+ * network, no subprocesses. All violations throw `ReleaseError` with the
24
+ * stable code `RELEASE_DOCS_INVALID`; unsafe paths record the original
25
+ * `PATH_UNSAFE` cause in `details.cause`.
26
+ *
27
+ * @module src/docs/config
28
+ */
29
+
30
+ import { canonicalArtifactPath } from '../artifacts/path-key.mjs';
31
+ import { ReleaseError, RELEASE_DOCS_INVALID } from '../core/errors.mjs';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Closed key sets (mirror the authoritative release-project schema)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const ROOT_KEYS = Object.freeze(['notesSource', 'locales', 'changelogs', 'readmes']);
38
+ const CHANGELOG_KEYS = Object.freeze(['path', 'locale']);
39
+ const README_KEYS = Object.freeze(['path', 'locale', 'regions', 'versionMarkers']);
40
+ const MARKER_KEYS = Object.freeze(['id', 'pattern']);
41
+
42
+ /** BCP 47-style stable locale identifier (mirrors the schema pattern). */
43
+ const LOCALE_PATTERN = /^[a-zA-Z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
44
+
45
+ /** Managed-region id and versionMarker id (mirrors the schema pattern). */
46
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
47
+
48
+ /** Version placeholder required inside every versionMarker pattern. */
49
+ const VERSION_PLACEHOLDER = '{version}';
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Internal helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ function invalid(message, details = {}) {
56
+ throw new ReleaseError(RELEASE_DOCS_INVALID, message, details);
57
+ }
58
+
59
+ function isPlainObject(value) {
60
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
61
+ }
62
+
63
+ /** Stable collision key: NFC + en-US case-fold (same semantics as canonicalArtifactPath). */
64
+ function foldKey(value) {
65
+ return value.normalize('NFC').toLocaleLowerCase('en-US');
66
+ }
67
+
68
+ /**
69
+ * Reject unknown keys on a closed object level.
70
+ * @param {object} obj
71
+ * @param {readonly string[]} allowed
72
+ * @param {string} where Human-readable location for error details.
73
+ */
74
+ function assertClosed(obj, allowed, where) {
75
+ const allowedSet = new Set(allowed);
76
+ for (const key of Object.keys(obj)) {
77
+ if (!allowedSet.has(key)) {
78
+ invalid(`releaseDocuments: unknown field "${key}" in ${where}`, { where, key });
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Track exact and folded keys for one namespace; fail closed on duplicates
85
+ * or case/Unicode collisions.
86
+ */
87
+ class UniqueNamespace {
88
+ constructor(kind, where) {
89
+ this.kind = kind;
90
+ this.where = where;
91
+ this.exact = new Set();
92
+ this.folded = new Map();
93
+ }
94
+
95
+ /**
96
+ * @param {string} canonical Already-canonicalized (NFC) value.
97
+ * @returns {string} the canonical value.
98
+ */
99
+ register(canonical) {
100
+ if (this.exact.has(canonical)) {
101
+ invalid(`releaseDocuments: duplicate ${this.kind} "${canonical}" in ${this.where}`, {
102
+ where: this.where,
103
+ kind: this.kind,
104
+ value: canonical,
105
+ });
106
+ }
107
+ this.exact.add(canonical);
108
+ const key = foldKey(canonical);
109
+ const existing = this.folded.get(key);
110
+ if (existing !== undefined && existing !== canonical) {
111
+ invalid(
112
+ `releaseDocuments: case/Unicode collision in ${this.kind}: "${canonical}" and "${existing}" in ${this.where}`,
113
+ { where: this.where, kind: this.kind, value: canonical, existing },
114
+ );
115
+ }
116
+ this.folded.set(key, canonical);
117
+ return canonical;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Canonicalize one configured path through the shared artifact path helper,
123
+ * converting PATH_UNSAFE into RELEASE_DOCS_INVALID with the original cause.
124
+ *
125
+ * @param {unknown} raw
126
+ * @param {string} where
127
+ * @returns {string} canonical NFC path
128
+ */
129
+ function canonicalPath(raw, where) {
130
+ if (typeof raw !== 'string' || raw.length === 0) {
131
+ invalid(`releaseDocuments: ${where} must be a non-empty string`, { where, path: raw });
132
+ }
133
+ try {
134
+ return canonicalArtifactPath(raw).path;
135
+ } catch (err) {
136
+ invalid(`releaseDocuments: unsafe path in ${where}: ${err.message}`, {
137
+ where,
138
+ path: raw,
139
+ cause: err.code ?? 'PATH_UNSAFE',
140
+ });
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Validate a versionMarker pattern (2026-07-21-readme-release-renderer §2):
146
+ * exactly one `{version}` placeholder with non-empty fixed bytes on both
147
+ * sides, and no CR, LF, or NUL byte anywhere.
148
+ *
149
+ * @param {string} pattern Already checked to be a non-empty string.
150
+ * @param {string} where Human-readable location for error details.
151
+ */
152
+ function assertVersionPattern(pattern, where) {
153
+ if (/[\r\n\0]/.test(pattern)) {
154
+ invalid('releaseDocuments: versionMarker pattern must not contain CR, LF, or NUL', { where });
155
+ }
156
+ const start = pattern.indexOf(VERSION_PLACEHOLDER);
157
+ const next = start === -1 ? -1 : pattern.indexOf(VERSION_PLACEHOLDER, start + VERSION_PLACEHOLDER.length);
158
+ if (start === -1 || next !== -1) {
159
+ invalid('releaseDocuments: versionMarker pattern must contain exactly one {version} placeholder', { where });
160
+ }
161
+ if (start === 0) {
162
+ invalid('releaseDocuments: versionMarker pattern must have non-empty bytes before {version}', { where });
163
+ }
164
+ if (start + VERSION_PLACEHOLDER.length === pattern.length) {
165
+ invalid('releaseDocuments: versionMarker pattern must have non-empty bytes after {version}', { where });
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Recursively freeze a plain object/array structure built from primitives.
171
+ * @template T
172
+ * @param {T} value
173
+ * @returns {T}
174
+ */
175
+ function deepFreeze(value) {
176
+ if (Array.isArray(value)) {
177
+ for (const item of value) deepFreeze(item);
178
+ return Object.freeze(value);
179
+ }
180
+ if (value !== null && typeof value === 'object') {
181
+ for (const item of Object.values(value)) deepFreeze(item);
182
+ return Object.freeze(value);
183
+ }
184
+ return value;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Public API
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /**
192
+ * Validate a parsed `releaseDocuments` configuration block and return its
193
+ * canonical, deeply frozen form.
194
+ *
195
+ * The input object is never mutated; all returned containers are fresh
196
+ * copies. On any semantic violation a `ReleaseError` with code
197
+ * `RELEASE_DOCS_INVALID` is thrown.
198
+ *
199
+ * @param {unknown} config Parsed releaseDocuments block (schema-checked or not).
200
+ * @returns {Readonly<{
201
+ * notesSource: string,
202
+ * locales: readonly string[],
203
+ * changelogs: ReadonlyArray<{ path: string, locale: string }>,
204
+ * readmes: ReadonlyArray<{
205
+ * path: string,
206
+ * locale: string,
207
+ * regions: readonly string[],
208
+ * versionMarkers?: ReadonlyArray<{ id: string, pattern: string }>,
209
+ * }>,
210
+ * }>}
211
+ * @throws {ReleaseError} RELEASE_DOCS_INVALID on any semantic violation.
212
+ */
213
+ export function normalizeReleaseDocumentsConfig(config) {
214
+ if (!isPlainObject(config)) {
215
+ invalid('releaseDocuments must be an object', { actual: Array.isArray(config) ? 'array' : typeof config });
216
+ }
217
+
218
+ assertClosed(config, ROOT_KEYS, 'releaseDocuments');
219
+ for (const key of ROOT_KEYS) {
220
+ if (!(key in config)) {
221
+ invalid(`releaseDocuments: missing required field "${key}"`, { field: key });
222
+ }
223
+ }
224
+
225
+ const { notesSource, locales, changelogs, readmes } = config;
226
+
227
+ // --- notesSource: canonical path (placeholder/suffix checked at load time) ---
228
+ const canonicalNotesSource = canonicalPath(notesSource, 'releaseDocuments.notesSource');
229
+
230
+ // --- locales: non-empty, well-formed, unique (exact + case-fold + NFC) ---
231
+ if (!Array.isArray(locales) || locales.length === 0) {
232
+ invalid('releaseDocuments: locales must be a non-empty array', { field: 'locales' });
233
+ }
234
+ const localeNamespace = new UniqueNamespace('locale', 'releaseDocuments.locales');
235
+ const declaredLocales = new Set();
236
+ for (const locale of locales) {
237
+ if (typeof locale !== 'string' || !LOCALE_PATTERN.test(locale)) {
238
+ invalid('releaseDocuments: invalid locale identifier', { field: 'locales', locale });
239
+ }
240
+ localeNamespace.register(locale);
241
+ declaredLocales.add(locale);
242
+ }
243
+
244
+ // --- shared target-path namespace across changelogs and readmes ---
245
+ const targetNamespace = new UniqueNamespace('target path', 'releaseDocuments');
246
+
247
+ // --- changelogs ---
248
+ if (!Array.isArray(changelogs) || changelogs.length === 0) {
249
+ invalid('releaseDocuments: changelogs must be a non-empty array', { field: 'changelogs' });
250
+ }
251
+ const canonicalChangelogs = changelogs.map((item, index) => {
252
+ const where = `releaseDocuments.changelogs[${index}]`;
253
+ if (!isPlainObject(item)) invalid(`releaseDocuments: changelog item must be an object`, { where });
254
+ assertClosed(item, CHANGELOG_KEYS, where);
255
+ for (const key of CHANGELOG_KEYS) {
256
+ if (!(key in item)) invalid(`releaseDocuments: changelog item missing "${key}"`, { where, field: key });
257
+ }
258
+ const path = targetNamespace.register(canonicalPath(item.path, `${where}.path`));
259
+ const { locale } = item;
260
+ if (typeof locale !== 'string' || !declaredLocales.has(locale)) {
261
+ invalid(`releaseDocuments: changelog locale is not declared in locales`, { where, locale });
262
+ }
263
+ return { path, locale };
264
+ });
265
+
266
+ // --- readmes ---
267
+ if (!Array.isArray(readmes) || readmes.length === 0) {
268
+ invalid('releaseDocuments: readmes must be a non-empty array', { field: 'readmes' });
269
+ }
270
+ const canonicalReadmes = readmes.map((item, index) => {
271
+ const where = `releaseDocuments.readmes[${index}]`;
272
+ if (!isPlainObject(item)) invalid('releaseDocuments: readme item must be an object', { where });
273
+ assertClosed(item, README_KEYS, where);
274
+ for (const key of ['path', 'locale', 'regions']) {
275
+ if (!(key in item)) invalid(`releaseDocuments: readme item missing "${key}"`, { where, field: key });
276
+ }
277
+ const path = targetNamespace.register(canonicalPath(item.path, `${where}.path`));
278
+ const { locale } = item;
279
+ if (typeof locale !== 'string' || !declaredLocales.has(locale)) {
280
+ invalid('releaseDocuments: readme locale is not declared in locales', { where, locale });
281
+ }
282
+
283
+ // regions: non-empty, well-formed, unique (exact + case-fold + NFC)
284
+ const { regions } = item;
285
+ if (!Array.isArray(regions) || regions.length === 0) {
286
+ invalid('releaseDocuments: regions must be a non-empty array', { where });
287
+ }
288
+ const regionNamespace = new UniqueNamespace('region', where);
289
+ const canonicalRegions = regions.map((region) => {
290
+ if (typeof region !== 'string' || !ID_PATTERN.test(region)) {
291
+ invalid('releaseDocuments: invalid region id', { where, region });
292
+ }
293
+ return regionNamespace.register(region);
294
+ });
295
+
296
+ const canonical = { path, locale, regions: canonicalRegions };
297
+
298
+ // versionMarkers: optional; when present non-empty with unique ids
299
+ if ('versionMarkers' in item) {
300
+ const { versionMarkers } = item;
301
+ if (!Array.isArray(versionMarkers) || versionMarkers.length === 0) {
302
+ invalid('releaseDocuments: versionMarkers must be a non-empty array when present', { where });
303
+ }
304
+ const markerNamespace = new UniqueNamespace('versionMarker id', where);
305
+ canonical.versionMarkers = versionMarkers.map((marker, markerIndex) => {
306
+ const markerWhere = `${where}.versionMarkers[${markerIndex}]`;
307
+ if (!isPlainObject(marker)) {
308
+ invalid('releaseDocuments: versionMarker item must be an object', { where: markerWhere });
309
+ }
310
+ assertClosed(marker, MARKER_KEYS, markerWhere);
311
+ for (const key of MARKER_KEYS) {
312
+ if (!(key in marker)) {
313
+ invalid(`releaseDocuments: versionMarker item missing "${key}"`, { where: markerWhere, field: key });
314
+ }
315
+ }
316
+ const { id, pattern } = marker;
317
+ if (typeof id !== 'string' || !ID_PATTERN.test(id)) {
318
+ invalid('releaseDocuments: invalid versionMarker id', { where: markerWhere, id });
319
+ }
320
+ if (typeof pattern !== 'string' || pattern.length === 0) {
321
+ invalid('releaseDocuments: versionMarker pattern must be a non-empty string', { where: markerWhere });
322
+ }
323
+ assertVersionPattern(pattern, markerWhere);
324
+ return { id: markerNamespace.register(id), pattern };
325
+ });
326
+ }
327
+
328
+ return canonical;
329
+ });
330
+
331
+ return deepFreeze({
332
+ notesSource: canonicalNotesSource,
333
+ locales: [...locales],
334
+ changelogs: canonicalChangelogs,
335
+ readmes: canonicalReadmes,
336
+ });
337
+ }