@opengsd/gsd-core 1.20.1 → 1.21.0

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 (135) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +234 -126
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/gsd-core/bin/gsd-tools.cjs +450 -753
  11. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  12. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  13. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  15. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  16. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  17. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  18. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  19. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  20. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  21. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  22. package/gsd-core/bin/lib/commands.cjs +80 -2
  23. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  24. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  25. package/gsd-core/bin/lib/config.cjs +139 -101
  26. package/gsd-core/bin/lib/configuration.cjs +41 -2
  27. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  28. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  29. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  30. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  33. package/gsd-core/bin/lib/graphify.cjs +19 -2
  34. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  35. package/gsd-core/bin/lib/init.cjs +13 -11
  36. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  37. package/gsd-core/bin/lib/install-engine.cjs +11 -19
  38. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  39. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  40. package/gsd-core/bin/lib/io.cjs +6 -0
  41. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  42. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  43. package/gsd-core/bin/lib/milestone.cjs +8 -6
  44. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  45. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  46. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  47. package/gsd-core/bin/lib/phase.cjs +107 -31
  48. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  49. package/gsd-core/bin/lib/planning-snapshot.cjs +8 -4
  50. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  51. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  52. package/gsd-core/bin/lib/project-context.cjs +439 -0
  53. package/gsd-core/bin/lib/project-root.cjs +9 -230
  54. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  55. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  56. package/gsd-core/bin/lib/roadmap-parser.cjs +328 -50
  57. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  58. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  59. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -4
  60. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  61. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  62. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  63. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  64. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  65. package/gsd-core/bin/lib/state-transition.cjs +91 -41
  66. package/gsd-core/bin/lib/state.cjs +446 -61
  67. package/gsd-core/bin/lib/surface.cjs +4 -7
  68. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  69. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  70. package/gsd-core/bin/lib/verify.cjs +49 -6
  71. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  73. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  74. package/gsd-core/references/checkpoints.md +1 -1
  75. package/gsd-core/references/planner-guidance.md +11 -0
  76. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  77. package/gsd-core/references/planner-reversibility.md +2 -0
  78. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  79. package/gsd-core/workflows/autonomous.md +4 -4
  80. package/gsd-core/workflows/code-review-fix.md +5 -7
  81. package/gsd-core/workflows/execute-phase.md +21 -7
  82. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  83. package/gsd-core/workflows/plan-phase.md +18 -19
  84. package/hooks/dist/gsd-agent-isolation-guard.js +3 -2
  85. package/hooks/dist/gsd-config-reload.js +2 -2
  86. package/hooks/dist/gsd-context-monitor.js +2 -1
  87. package/hooks/dist/gsd-cursor-subagent-start.js +8 -5
  88. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  89. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-workflow-guard.js +2 -1
  92. package/hooks/dist/lib/config-read.js +49 -0
  93. package/hooks/dist/lib/git-cmd.js +3 -1
  94. package/hooks/dist/lib/git-head-tree.js +474 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +97 -17
  96. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  97. package/hooks/gsd-agent-isolation-guard.js +3 -2
  98. package/hooks/gsd-config-reload.js +2 -2
  99. package/hooks/gsd-context-monitor.js +2 -1
  100. package/hooks/gsd-cursor-subagent-start.js +8 -5
  101. package/hooks/gsd-read-injection-scanner.js +2 -2
  102. package/hooks/gsd-secret-read-guard.js +141 -6
  103. package/hooks/gsd-statusline.js +3 -2
  104. package/hooks/gsd-workflow-guard.js +2 -1
  105. package/hooks/lib/config-read.js +49 -0
  106. package/hooks/lib/git-cmd.js +3 -1
  107. package/hooks/lib/git-head-tree.js +474 -0
  108. package/hooks/lib/isolation-sentinel.js +97 -17
  109. package/hooks/lib/secret-read-exemptions.js +161 -0
  110. package/package.json +7 -2
  111. package/pi/package-agents/gsd-executor.md +1 -1
  112. package/pi/package-agents/gsd-plan-checker.md +1 -1
  113. package/pi/package-agents/gsd-planner.md +3 -3
  114. package/pi/package-agents/gsd-verifier.md +2 -2
  115. package/pi/package-agents/manifest.json +4 -4
  116. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  117. package/scripts/changeset/cli.cjs +2 -2
  118. package/scripts/changeset/serialize.cjs +12 -2
  119. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  120. package/scripts/ci-fix-next-rerun.cjs +149 -0
  121. package/scripts/ci-next-health.cjs +52 -9
  122. package/scripts/ci-test-scope.cjs +77 -71
  123. package/scripts/command-contract-helpers.cjs +56 -7
  124. package/scripts/docs-guard-registry.cjs +3 -0
  125. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  126. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  127. package/scripts/lib/macos-conformance-tier.generated.cjs +55 -47
  128. package/scripts/lib/platform-conformance-tier.generated.cjs +19 -64
  129. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  130. package/scripts/lint-removed-but-needed.cjs +562 -50
  131. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  132. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  133. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  134. package/skills/gsd-autonomous/SKILL.md +1 -1
  135. package/vscode/package.json +1 -1
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Build a stable GitHub Release body from the release's CHANGELOG.md section
6
+ * (#5922).
7
+ *
8
+ * GitHub's generated notes list PRs whose merge commits lie in the tag range.
9
+ * A hotfix's fixes are cherry-picks with new shas, so that list is empty for
10
+ * them. The curated record is the `## [X.Y.Z] - DATE` section that the
11
+ * finalize job's "Promote CHANGELOG" step writes, so the body is built from it.
12
+ *
13
+ * Usage:
14
+ * node scripts/release-notes/changelog-release-notes.cjs --version <X.Y.Z> \
15
+ * --output <file> [--changelog <path>] [--package <name>] [--repo <owner/repo>] \
16
+ * [--previous <X.Y.Z>]
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const os = require('node:os');
21
+ const path = require('node:path');
22
+
23
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
24
+ const { parseChangelog, RELEASE_HEADING_RE, EMPTY_RELEASE_PLACEHOLDER } = require('../changeset/serialize.cjs');
25
+ const { isStableTripletSemver } = require('../../gsd-core/bin/lib/semver-compare.cjs');
26
+ const { packageName: defaultPackageName, repoSlug: defaultRepoSlug } = require('../../gsd-core/bin/lib/package-identity.cjs');
27
+
28
+ /** Typed failure reasons; every one maps to a non-zero CLI exit. */
29
+ const CHANGELOG_NOTES_ERROR = Object.freeze({
30
+ INVALID_VERSION: 'invalid-version',
31
+ INVALID_PREVIOUS: 'invalid-previous',
32
+ NO_SECTION: 'no-section',
33
+ UNDATED_SECTION: 'undated-section',
34
+ EMPTY_SECTION: 'empty-section',
35
+ NO_PREVIOUS: 'no-previous',
36
+ OUTPUT_NOT_ALLOWED: 'output-not-allowed',
37
+ BUDGET_TOO_SMALL: 'budget-too-small',
38
+ });
39
+
40
+ class ChangelogNotesError extends Error {
41
+ constructor(code, message) {
42
+ super(message);
43
+ this.name = 'ChangelogNotesError';
44
+ this.code = code;
45
+ }
46
+ }
47
+
48
+ // GitHub rejects release bodies over 125,000 characters; stay well inside.
49
+ const RELEASE_BODY_MAX_CHARS = 120000;
50
+
51
+ // Stricter than isStableTripletSemver (src/semver-compare.cts:49-51), which
52
+ // strips a leading `v` before testing: --previous must be a bare X.Y.Z, so a
53
+ // `v1.2.0` is rejected here rather than silently accepted.
54
+ const STRICT_TRIPLET_RE = /^\d+\.\d+\.\d+$/;
55
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
56
+
57
+ function sliceBody(lines, startIdx, endIdx) {
58
+ const body = lines.slice(startIdx, endIdx);
59
+ while (body.length > 0 && body[0].trim() === '') body.shift();
60
+ while (body.length > 0 && body[body.length - 1].trim() === '') body.pop();
61
+ return body.join('\n');
62
+ }
63
+
64
+ /**
65
+ * Split a section body into ordered units: `###` headings, top-level bullets
66
+ * (with their continuation lines) and any leading text. Raw lines are kept so
67
+ * concatenating all units reproduces the body exactly.
68
+ */
69
+ function splitUnits(body) {
70
+ const units = [];
71
+ for (const line of body.split('\n')) {
72
+ if (/^#{3,6}\s/.test(line)) units.push({ kind: 'heading', lines: [line] });
73
+ else if (/^[-*]\s/.test(line)) units.push({ kind: 'bullet', lines: [line] });
74
+ else if (units.length === 0) units.push({ kind: 'text', lines: [line] });
75
+ else units[units.length - 1].lines.push(line);
76
+ }
77
+ return units;
78
+ }
79
+
80
+ /**
81
+ * @param {object} opts
82
+ * @param {string} opts.changelog Full CHANGELOG.md text.
83
+ * @param {string} opts.version Version, with or without a leading "v".
84
+ * @param {string} [opts.packageName]
85
+ * @param {string} [opts.repoSlug]
86
+ * @param {string} [opts.previous] Explicit previous release (strict X.Y.Z).
87
+ * @param {number} [opts.maxChars] Body budget (default RELEASE_BODY_MAX_CHARS).
88
+ * @returns {string} Release body, trailing newline included.
89
+ * @throws {ChangelogNotesError}
90
+ */
91
+ function buildChangelogReleaseNotes({
92
+ changelog,
93
+ version,
94
+ packageName = defaultPackageName,
95
+ repoSlug = defaultRepoSlug,
96
+ previous = null,
97
+ maxChars = RELEASE_BODY_MAX_CHARS,
98
+ }) {
99
+ const ver = String(version || '').replace(/^v/, '');
100
+ if (!VERSION_RE.test(ver)) {
101
+ throw new ChangelogNotesError(
102
+ CHANGELOG_NOTES_ERROR.INVALID_VERSION,
103
+ `invalid version "${version}" (expected X.Y.Z or X.Y.Z-prerelease)`,
104
+ );
105
+ }
106
+ if (previous !== null && previous !== undefined && !STRICT_TRIPLET_RE.test(String(previous))) {
107
+ throw new ChangelogNotesError(
108
+ CHANGELOG_NOTES_ERROR.INVALID_PREVIOUS,
109
+ `invalid --previous "${previous}" (expected a strict X.Y.Z triplet)`,
110
+ );
111
+ }
112
+
113
+ const text = String(changelog || '');
114
+ const { releases } = parseChangelog(text);
115
+ const lines = text.split(/\r?\n/);
116
+ // parseChangelog pushes exactly one release per heading line matched by
117
+ // RELEASE_HEADING_RE, so heading line i belongs to releases[i].
118
+ const headingLines = [];
119
+ lines.forEach((line, idx) => {
120
+ if (RELEASE_HEADING_RE.test(line)) headingLines.push(idx);
121
+ });
122
+
123
+ const relIdx = releases.findIndex((r) => r.version === ver);
124
+ if (relIdx === -1) {
125
+ throw new ChangelogNotesError(
126
+ CHANGELOG_NOTES_ERROR.NO_SECTION,
127
+ `CHANGELOG.md has no \`## [${ver}]\` section`,
128
+ );
129
+ }
130
+ const release = releases[relIdx];
131
+ if (!release.date) {
132
+ throw new ChangelogNotesError(
133
+ CHANGELOG_NOTES_ERROR.UNDATED_SECTION,
134
+ `CHANGELOG.md heading \`## [${ver}]\` has no date (expected \`## [${ver}] - YYYY-MM-DD\`)`,
135
+ );
136
+ }
137
+ const bulletCount = release.sections.reduce((n, s) => n + s.bullets.length, 0);
138
+ const endIdx = relIdx + 1 < headingLines.length ? headingLines[relIdx + 1] : lines.length;
139
+ const body = sliceBody(lines, headingLines[relIdx] + 1, endIdx);
140
+ // `render --allow-empty` writes a dated heading plus a placeholder for a
141
+ // zero-fragment release; that is a valid (if sparse) section.
142
+ if (bulletCount === 0 && body !== EMPTY_RELEASE_PLACEHOLDER) {
143
+ throw new ChangelogNotesError(
144
+ CHANGELOG_NOTES_ERROR.EMPTY_SECTION,
145
+ `CHANGELOG.md section \`## [${ver}]\` has no bullets`,
146
+ );
147
+ }
148
+
149
+ let prev = previous === null || previous === undefined ? null : String(previous);
150
+ if (prev === null) {
151
+ const earlier = releases
152
+ .slice(relIdx + 1)
153
+ .find((r) => r.date && isStableTripletSemver(r.version));
154
+ if (!earlier) {
155
+ throw new ChangelogNotesError(
156
+ CHANGELOG_NOTES_ERROR.NO_PREVIOUS,
157
+ `CHANGELOG.md has no dated X.Y.Z section below \`## [${ver}]\`; pass --previous <X.Y.Z>`,
158
+ );
159
+ }
160
+ prev = earlier.version;
161
+ }
162
+
163
+ const install = [
164
+ '## Install',
165
+ '',
166
+ '```bash',
167
+ `npm i ${packageName}@${ver}`,
168
+ '# or',
169
+ `npm i ${packageName}@latest`,
170
+ '```',
171
+ ].join('\n');
172
+ const footer = `**Full Changelog**: https://github.com/${repoSlug}/compare/v${prev}...v${ver}`;
173
+ const changelogUrl = `https://github.com/${repoSlug}/blob/v${ver}/CHANGELOG.md`;
174
+ const assemble = (bodyText, dropped) => {
175
+ // An empty body (nothing fit) leaves just the heading, never a blank run.
176
+ const parts = [install, bodyText ? `## What's Changed\n\n${bodyText}` : "## What's Changed"];
177
+ if (dropped > 0) {
178
+ const noun = dropped === 1 ? 'entry' : 'entries';
179
+ parts.push(`_\u2026and ${dropped} more ${noun}. See the full [CHANGELOG](${changelogUrl})._`);
180
+ }
181
+ parts.push(footer);
182
+ return parts.join('\n\n') + '\n';
183
+ };
184
+
185
+ const full = assemble(body, 0);
186
+ if (full.length <= maxChars) return full;
187
+
188
+ // Over budget: keep whole units (headings, bullets with continuations) in
189
+ // order until the next one would not fit alongside the notice and footer.
190
+ const units = splitUnits(body);
191
+ let remaining = units.filter((u) => u.kind === 'bullet').length;
192
+ // The smallest possible body carries no entries at all; if even that is over
193
+ // budget no truncation can help, so fail instead of returning an oversized body.
194
+ const floor = assemble('', remaining).length;
195
+ if (floor > maxChars) {
196
+ throw new ChangelogNotesError(
197
+ CHANGELOG_NOTES_ERROR.BUDGET_TOO_SMALL,
198
+ `release body budget ${maxChars} is below the ${floor}-character floor (install block, notice and footer)`,
199
+ );
200
+ }
201
+ const kept = [];
202
+ for (const unit of units) {
203
+ const after = remaining - (unit.kind === 'bullet' ? 1 : 0);
204
+ const candidate = [...kept, unit].map((u) => u.lines.join('\n')).join('\n').replace(/\s+$/, '');
205
+ if (assemble(candidate, after).length > maxChars) break;
206
+ kept.push(unit);
207
+ remaining = after;
208
+ }
209
+ // Never leave a heading with no entries under it.
210
+ while (kept.length > 0 && kept[kept.length - 1].kind === 'heading') kept.pop();
211
+ const keptBullets = kept.filter((u) => u.kind === 'bullet').length;
212
+ const dropped = units.filter((u) => u.kind === 'bullet').length - keptBullets;
213
+ const keptBody = kept.map((u) => u.lines.join('\n')).join('\n').replace(/\s+$/, '');
214
+ return assemble(keptBody, dropped);
215
+ }
216
+
217
+ /**
218
+ * Resolve `--output` and require it to sit under the working directory, the OS
219
+ * temp directory or RUNNER_TEMP (symlinks resolved on the parent directory).
220
+ * An existing symlink as the final component is refused outright.
221
+ * @returns {string} Absolute path to write.
222
+ * @throws {ChangelogNotesError}
223
+ */
224
+ function resolveOutputPath(output, { cwd = process.cwd(), tmpdir = os.tmpdir(), runnerTemp = process.env.RUNNER_TEMP } = {}) {
225
+ const abs = path.resolve(cwd, output);
226
+ let realParent;
227
+ try {
228
+ realParent = fs.realpathSync(path.dirname(abs));
229
+ } catch (_e) {
230
+ throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output directory does not exist: ${path.dirname(abs)}`);
231
+ }
232
+ const target = path.join(realParent, path.basename(abs));
233
+ for (const root of [cwd, tmpdir, runnerTemp]) {
234
+ if (!root) continue;
235
+ let realRoot;
236
+ try {
237
+ realRoot = fs.realpathSync(root);
238
+ } catch (_e) {
239
+ continue;
240
+ }
241
+ if (target.startsWith(realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep)) {
242
+ let st = null;
243
+ try {
244
+ st = fs.lstatSync(target);
245
+ } catch (_e) {
246
+ st = null; // does not exist yet
247
+ }
248
+ if (st && st.isSymbolicLink()) {
249
+ throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output ${output} is a symbolic link`);
250
+ }
251
+ return target;
252
+ }
253
+ }
254
+ throw new ChangelogNotesError(
255
+ CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED,
256
+ `--output ${output} is outside the working directory, the temp directory and RUNNER_TEMP`,
257
+ );
258
+ }
259
+
260
+ /**
261
+ * Write the body without following a symlink at the final path component.
262
+ * O_NOFOLLOW (where defined) closes the window between resolveOutputPath's
263
+ * lstat and the open; ELOOP is mapped to a typed error.
264
+ * @throws {ChangelogNotesError}
265
+ */
266
+ function writeOutput(target, body) {
267
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
268
+ let fd;
269
+ try {
270
+ fd = fs.openSync(target, flags, 0o644);
271
+ } catch (err) {
272
+ if (err && err.code === 'ELOOP') {
273
+ throw new ChangelogNotesError(CHANGELOG_NOTES_ERROR.OUTPUT_NOT_ALLOWED, `--output ${target} is a symbolic link`);
274
+ }
275
+ throw err;
276
+ }
277
+ try {
278
+ fs.writeFileSync(fd, body, 'utf8');
279
+ } finally {
280
+ fs.closeSync(fd);
281
+ }
282
+ }
283
+
284
+ const FLAGS_WITH_VALUE = new Set([
285
+ '--version', '--tag', '--changelog', '--output', '--package', '--repo', '--previous',
286
+ ]);
287
+
288
+ function parseArgs(argv) {
289
+ const opts = {};
290
+ for (let i = 0; i < argv.length; i++) {
291
+ const arg = argv[i];
292
+ if (!FLAGS_WITH_VALUE.has(arg)) throw new ExitError(2, `error: unknown argument ${arg}`);
293
+ const value = argv[++i];
294
+ if (value === undefined || value === '' || value.startsWith('--')) {
295
+ throw new ExitError(2, `error: ${arg} requires a value`);
296
+ }
297
+ opts[arg.slice(2)] = value;
298
+ }
299
+ return opts;
300
+ }
301
+
302
+ function main() {
303
+ const opts = parseArgs(process.argv.slice(2));
304
+ const version = opts.version || opts.tag;
305
+ if (!version) throw new ExitError(2, 'error: --version <X.Y.Z> is required');
306
+ if (!opts.output) throw new ExitError(2, 'error: --output <file> is required');
307
+
308
+ const changelogPath = path.resolve(opts.changelog || path.join(__dirname, '..', '..', 'CHANGELOG.md'));
309
+ if (!fs.existsSync(changelogPath)) {
310
+ throw new ExitError(1, `error: CHANGELOG not found: ${changelogPath}`);
311
+ }
312
+
313
+ let body;
314
+ let outputPath;
315
+ try {
316
+ outputPath = resolveOutputPath(opts.output);
317
+ body = buildChangelogReleaseNotes({
318
+ changelog: fs.readFileSync(changelogPath, 'utf8'),
319
+ version,
320
+ packageName: opts.package || defaultPackageName,
321
+ repoSlug: opts.repo || defaultRepoSlug,
322
+ previous: opts.previous === undefined ? null : opts.previous,
323
+ });
324
+ writeOutput(outputPath, body);
325
+ } catch (err) {
326
+ if (err instanceof ChangelogNotesError) {
327
+ throw new ExitError(1, `error[${err.code}]: ${err.message}`);
328
+ }
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ if (require.main === module) {
334
+ runMain(main);
335
+ }
336
+
337
+ module.exports = {
338
+ buildChangelogReleaseNotes,
339
+ ChangelogNotesError,
340
+ CHANGELOG_NOTES_ERROR,
341
+ RELEASE_BODY_MAX_CHARS,
342
+ resolveOutputPath,
343
+ writeOutput,
344
+ parseArgs,
345
+ };
@@ -11,6 +11,8 @@ const DEFAULT_MAX_CONTENT = 1850;
11
11
  const SECTION_LIMITS = Object.freeze([
12
12
  ['Breaking', 4],
13
13
  ['Security', 4],
14
+ ['Removed', 3],
15
+ ['Deprecated', 3],
14
16
  ['Feature', 4],
15
17
  ['Fix', 4],
16
18
  ['Enhancement', 3],
@@ -49,6 +51,14 @@ function normalizeHeading(value) {
49
51
 
50
52
  if (lower.includes('breaking')) return 'Breaking';
51
53
  if (lower.includes('security')) return 'Security';
54
+ // Keep-a-Changelog headings (#5922): the release body is built from the
55
+ // CHANGELOG section, whose subsections use these names.
56
+ if (lower === 'added') return 'Feature';
57
+ if (lower === 'fixed') return 'Fix';
58
+ if (lower === 'changed') return 'Enhancement';
59
+ if (lower === 'deprecated') return 'Deprecated';
60
+ // Note: an old-format body with a `## Removed` heading now buckets under Removed (#5922).
61
+ if (lower === 'removed') return 'Removed';
52
62
  if (lower === 'feature' || lower === 'features') return 'Feature';
53
63
  if (lower === 'fix' || lower === 'fixes' || lower === 'bug fixes') return 'Fix';
54
64
  if (lower === 'enhancement' || lower === 'enhancements' || lower === 'improvements') return 'Enhancement';
@@ -58,11 +68,21 @@ function normalizeHeading(value) {
58
68
  return heading;
59
69
  }
60
70
 
71
+ // A body built from a CHANGELOG section (#5922) carries Keep-a-Changelog
72
+ // subsection headings. GitHub-generated and formatter output never does, so
73
+ // its bullets keep the original (unchanged) handling.
74
+ const CHANGELOG_HEADING_RE = /^###\s+(?:Added|Changed|Deprecated|Removed|Fixed|Security)\s*$/m;
75
+
61
76
  function collectSections(body) {
77
+ const text = normalizeNewlines(body);
78
+ return CHANGELOG_HEADING_RE.test(text) ? collectChangelogSections(text) : collectLegacySections(text);
79
+ }
80
+
81
+ function collectLegacySections(text) {
62
82
  const sections = new Map();
63
83
  let current = null;
64
84
 
65
- for (const line of normalizeNewlines(body).split('\n')) {
85
+ for (const line of text.split('\n')) {
66
86
  const trimmed = line.trim();
67
87
  const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
68
88
 
@@ -86,6 +106,67 @@ function collectSections(body) {
86
106
  return sections;
87
107
  }
88
108
 
109
+ function collectChangelogSections(text) {
110
+ const sections = new Map();
111
+ let current = null;
112
+ // Bullet in flight: CHANGELOG bullets wrap onto indented continuation lines
113
+ // that carry the trailing (#issue) (#pr) references.
114
+ let pending = null;
115
+
116
+ function flush() {
117
+ if (!pending) return;
118
+ if (!sections.has(pending.section)) sections.set(pending.section, []);
119
+ sections.get(pending.section).push(summarizeChangelogBullet(pending.text) || cleanBullet(pending.text));
120
+ pending = null;
121
+ }
122
+
123
+ for (const line of text.split('\n')) {
124
+ const trimmed = line.trim();
125
+ const heading = trimmed.match(/^#{2,3}\s+(.+)$/);
126
+
127
+ if (heading) {
128
+ flush();
129
+ current = normalizeHeading(heading[1]);
130
+ continue;
131
+ }
132
+
133
+ // Indented bullets are nested detail of a CHANGELOG bullet, not entries.
134
+ if (/^\s+[*-]\s/.test(line)) {
135
+ flush();
136
+ continue;
137
+ }
138
+
139
+ const bullet = trimmed.match(/^([*-])\s+(.+)$/);
140
+ if (!bullet || !current) {
141
+ if (pending && /^\s+\S/.test(line)) pending.text += ` ${trimmed}`;
142
+ else flush();
143
+ continue;
144
+ }
145
+
146
+ flush();
147
+ const section = current === "What's Changed" ? classifyTitle(trimmed) : current;
148
+ pending = { section, text: trimmed };
149
+ }
150
+ flush();
151
+
152
+ return sections;
153
+ }
154
+
155
+ /**
156
+ * CHANGELOG bullets read `- **Headline** — long explanation (#issue) (#pr)`.
157
+ * The bold headline is the user-visible summary; keep it and the trailing
158
+ * issue/PR references. Returns null for any other bullet shape.
159
+ * @param {string} bulletLine
160
+ * @returns {string|null}
161
+ */
162
+ function summarizeChangelogBullet(bulletLine) {
163
+ const m = bulletLine.match(/^[*-]\s+\*\*(.+?)\*\*\s+[—–-]\s/);
164
+ if (!m) return null;
165
+ const refs = bulletLine.match(/(?:\s\(#\d+\))+\s*$/);
166
+ const headline = cleanBullet(`- ${m[1]}`);
167
+ return refs ? `${headline}${refs[0].replace(/\s+/g, ' ').replace(/\s+$/, '')}` : headline;
168
+ }
169
+
89
170
  function cleanBullet(value) {
90
171
  let text = String(value)
91
172
  .replace(/^[*-]\s+/, '')