@opengsd/gsd-core 1.3.1 → 1.4.0-rc.2

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 (136) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/GEMINI.md +53 -0
  3. package/agents/gsd-advisor-researcher.md +1 -20
  4. package/agents/gsd-ai-researcher.md +2 -21
  5. package/agents/gsd-code-fixer.md +1 -1
  6. package/agents/gsd-code-reviewer.md +1 -1
  7. package/agents/gsd-domain-researcher.md +2 -21
  8. package/agents/gsd-eval-auditor.md +1 -1
  9. package/agents/gsd-eval-planner.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-framework-selector.md +1 -1
  12. package/agents/gsd-nyquist-auditor.md +1 -1
  13. package/agents/gsd-pattern-mapper.md +1 -1
  14. package/agents/gsd-phase-researcher.md +92 -166
  15. package/agents/gsd-planner.md +9 -36
  16. package/agents/gsd-project-researcher.md +62 -141
  17. package/agents/gsd-security-auditor.md +1 -1
  18. package/agents/gsd-ui-auditor.md +1 -1
  19. package/agents/gsd-ui-checker.md +1 -1
  20. package/agents/gsd-ui-researcher.md +3 -22
  21. package/agents/gsd-user-profiler.md +1 -1
  22. package/agents/gsd-verifier.md +8 -2
  23. package/bin/install.js +1977 -339
  24. package/commands/gsd/autonomous.md +2 -0
  25. package/commands/gsd/execute-phase.md +2 -0
  26. package/commands/gsd/graphify.md +11 -6
  27. package/commands/gsd/import.md +6 -2
  28. package/commands/gsd/plan-phase.md +4 -2
  29. package/commands/gsd/progress.md +1 -0
  30. package/commands/gsd/stats.md +1 -0
  31. package/commands/gsd/update.md +3 -2
  32. package/gemini-extension.json +6 -0
  33. package/gsd-core/bin/check-latest-version.cjs +61 -6
  34. package/gsd-core/bin/gsd-tools.cjs +238 -32
  35. package/gsd-core/bin/lib/check-command-router.cjs +1 -0
  36. package/gsd-core/bin/lib/cli-exit.cjs +42 -0
  37. package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
  38. package/gsd-core/bin/lib/commands.cjs +5 -4
  39. package/gsd-core/bin/lib/config.cjs +28 -4
  40. package/gsd-core/bin/lib/core.cjs +72 -28
  41. package/gsd-core/bin/lib/graphify.cjs +2 -2
  42. package/gsd-core/bin/lib/init-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/init.cjs +19 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +58 -0
  45. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  46. package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
  47. package/gsd-core/bin/lib/intel.cjs +3 -20
  48. package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
  49. package/gsd-core/bin/lib/phase.cjs +3 -3
  50. package/gsd-core/bin/lib/research-provider.cjs +137 -0
  51. package/gsd-core/bin/lib/research-store.cjs +167 -0
  52. package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
  53. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
  54. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
  55. package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
  56. package/gsd-core/bin/lib/security.cjs +73 -0
  57. package/gsd-core/bin/lib/shell-command-projection.cjs +9 -0
  58. package/gsd-core/bin/lib/surface.cjs +54 -11
  59. package/gsd-core/bin/lib/validate.cjs +2 -2
  60. package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
  61. package/gsd-core/bin/lib/verification.cjs +193 -0
  62. package/gsd-core/bin/lib/verify.cjs +2 -2
  63. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
  64. package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
  65. package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
  66. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  67. package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
  68. package/gsd-core/references/planner-load-graph-context.md +36 -0
  69. package/gsd-core/references/planning-config.md +3 -1
  70. package/gsd-core/references/research-documentation-lookup.md +29 -0
  71. package/gsd-core/references/research-philosophy.md +29 -0
  72. package/gsd-core/references/research-verification-protocol.md +27 -0
  73. package/gsd-core/workflows/execute-phase.md +19 -8
  74. package/gsd-core/workflows/help/modes/full.md +4 -3
  75. package/gsd-core/workflows/ingest-docs.md +3 -2
  76. package/gsd-core/workflows/plan-phase.md +14 -10
  77. package/gsd-core/workflows/plan-review-convergence.md +3 -3
  78. package/gsd-core/workflows/review.md +24 -7
  79. package/gsd-core/workflows/ship.md +5 -8
  80. package/gsd-core/workflows/spec-phase.md +2 -1
  81. package/gsd-core/workflows/update.md +34 -6
  82. package/hooks/dist/gsd-config-reload.js +133 -0
  83. package/hooks/dist/gsd-context-monitor.js +1 -1
  84. package/hooks/dist/gsd-cursor-post-tool.js +75 -0
  85. package/hooks/dist/gsd-cursor-session-start.js +52 -0
  86. package/hooks/dist/gsd-workflow-guard.js +1 -0
  87. package/hooks/dist/gsd-worktree-path-guard.js +1 -1
  88. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  89. package/hooks/gsd-config-reload.js +133 -0
  90. package/hooks/gsd-context-monitor.js +1 -1
  91. package/hooks/gsd-cursor-post-tool.js +75 -0
  92. package/hooks/gsd-cursor-session-start.js +52 -0
  93. package/hooks/gsd-workflow-guard.js +1 -0
  94. package/hooks/gsd-worktree-path-guard.js +1 -1
  95. package/hooks/hooks.json +69 -0
  96. package/hooks/managed-hooks-registry.cjs +3 -0
  97. package/package.json +8 -1
  98. package/scripts/affected-tests-lib.cjs +3 -2
  99. package/scripts/build-hooks.js +7 -0
  100. package/scripts/changeset/cli.cjs +226 -28
  101. package/scripts/changeset/lint.cjs +5 -4
  102. package/scripts/changeset/new.cjs +4 -4
  103. package/scripts/check-alias-drift.cjs +77 -71
  104. package/scripts/check-env.cjs +185 -179
  105. package/scripts/check-npm-integrity.cjs +115 -109
  106. package/scripts/ci-guard-runner.cjs +11 -5
  107. package/scripts/ci-prepare-test-scope.cjs +27 -22
  108. package/scripts/ci-rebase-check.cjs +46 -45
  109. package/scripts/ci-test-scope.cjs +126 -22
  110. package/scripts/diff-touches-shipped-paths.cjs +52 -44
  111. package/scripts/gen-inventory-manifest.cjs +38 -32
  112. package/scripts/gen-research-agents.cjs +276 -0
  113. package/scripts/issue-dedupe.cjs +278 -0
  114. package/scripts/lib/cli-exit.cjs +56 -0
  115. package/scripts/lint-command-contract.cjs +28 -22
  116. package/scripts/lint-descriptions.cjs +32 -28
  117. package/scripts/lint-docs-required.cjs +4 -4
  118. package/scripts/lint-legacy-dir-name.cjs +56 -52
  119. package/scripts/lint-pr-check-project-dir.cjs +3 -1
  120. package/scripts/lint-shell-command-projection-drift.cjs +27 -22
  121. package/scripts/lint-skill-deps.cjs +31 -26
  122. package/scripts/lint-test-file-count.allowlist.json +2 -0
  123. package/scripts/lint-test-file-count.cjs +5 -4
  124. package/scripts/mutation-matrix.cjs +6 -3
  125. package/scripts/prompt-injection-scan.sh +1 -1
  126. package/scripts/release-notes/discord-release-summary.cjs +373 -0
  127. package/scripts/release-notes/format-github-release-notes.cjs +8 -3
  128. package/scripts/release-tarball-smoke.cjs +6 -4
  129. package/scripts/research-profiles.cjs +149 -0
  130. package/scripts/run-affected-tests.cjs +2 -1
  131. package/scripts/run-cross-platform-tests.cjs +11 -7
  132. package/scripts/run-tests.cjs +8 -7
  133. package/scripts/strip-prose-atrefs.cjs +1 -1
  134. package/scripts/sync-manifest-versions.cjs +119 -0
  135. package/scripts/sync-runtime-launcher.cjs +0 -3
  136. package/scripts/verify-npm-publish.cjs +14 -26
@@ -17,7 +17,8 @@
17
17
  const fs = require('node:fs');
18
18
  const path = require('node:path');
19
19
 
20
- const { parseFragment, FRAGMENT_ERROR } = require('./parse.cjs');
20
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
21
+ const { parseFragment } = require('./parse.cjs');
21
22
  const { renderChangelog } = require('./render.cjs');
22
23
  const { serializeChangelog, parseChangelog } = require('./serialize.cjs');
23
24
  const { renderGithubReleaseNotes } = require('./github-release-notes.cjs');
@@ -40,6 +41,8 @@ function parseArgs(argv) {
40
41
  repoSlug: defaultRepoSlug,
41
42
  installCommand: `npx ${packageName}@latest`,
42
43
  json: false,
44
+ allowEmpty: false,
45
+ preview: false,
43
46
  };
44
47
  if (argv.length === 0) return { ok: true, opts };
45
48
  opts.cmd = argv[0];
@@ -59,6 +62,8 @@ function parseArgs(argv) {
59
62
  for (let i = 1; i < argv.length; i++) {
60
63
  const a = argv[i];
61
64
  if (a === '--json') { opts.json = true; continue; }
65
+ if (a === '--allow-empty') { opts.allowEmpty = true; continue; }
66
+ if (a === '--preview') { opts.preview = true; continue; }
62
67
  if (
63
68
  a === '--repo' ||
64
69
  a === '--version' ||
@@ -118,6 +123,31 @@ function splitChangelog(text) {
118
123
  return { lead, prior };
119
124
  }
120
125
 
126
+ // FIX 2: tiny local helper so both render paths share identical assembly logic.
127
+ function assembleChangelog(lead, releaseBlock) {
128
+ return [
129
+ lead || '# Changelog',
130
+ '',
131
+ '## [Unreleased]',
132
+ '',
133
+ releaseBlock.replace(/\s+$/, ''),
134
+ '',
135
+ ].join('\n');
136
+ }
137
+
138
+ // Insert a "_No notable changes._" placeholder after the dated release heading
139
+ // of an otherwise-empty release block. serializeChangelog with no sections
140
+ // yields just "## [v] - d\n"; we expand the trailing newline into a blank line
141
+ // + placeholder + blank line so parseChangelog still sees the dated heading
142
+ // first and the output is human-readable. Shared by the --allow-empty and
143
+ // --preview zero-fragment paths so they can never drift.
144
+ function injectEmptyPlaceholder(headerOnlyBlock) {
145
+ return headerOnlyBlock.replace(
146
+ /^(##\s+\[[^\]]+\][^\n]*)\n+/,
147
+ '$1\n\n_No notable changes._\n\n',
148
+ );
149
+ }
150
+
121
151
  function cmdRender(opts) {
122
152
  const repo = path.resolve(opts.repo);
123
153
  const changesetDir = path.join(repo, '.changeset');
@@ -133,14 +163,107 @@ function cmdRender(opts) {
133
163
  else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null });
134
164
  }
135
165
 
166
+ // 1. parse-failure → exitCode 1 (unchanged).
136
167
  if (failures.length > 0) {
137
168
  return { exitCode: 1, report: { consumed: 0, failures } };
138
169
  }
170
+
171
+ // 2. Read priorText once; reuse in all subsequent branches.
172
+ const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
173
+
174
+ // Preview mode (#759): render the dated release section WITHOUT writing
175
+ // CHANGELOG.md and WITHOUT consuming .changeset fragments. Used by the rc
176
+ // release job to surface the curated notes for the version under test while
177
+ // leaving the fragment set intact for the eventual finalize render.
178
+ if (opts.preview) {
179
+ // priorChangelog is intentionally null: a preview shows ONLY the new dated
180
+ // section for the version under test, not the full file history.
181
+ // serializeChangelog appends priorChangelog verbatim, so passing the prior
182
+ // text here would dump every past release into the rc job summary.
183
+ const ir = renderChangelog({
184
+ fragments,
185
+ version: opts.version,
186
+ date: opts.date,
187
+ priorChangelog: null,
188
+ });
189
+ let releaseBlock = serializeChangelog(ir);
190
+ if (fragments.length === 0) {
191
+ // Mirror --allow-empty: a no-fragment release still shows a dated heading
192
+ // with a placeholder rather than an empty block.
193
+ releaseBlock = injectEmptyPlaceholder(releaseBlock);
194
+ }
195
+ return {
196
+ exitCode: 0,
197
+ report: {
198
+ consumed: 0,
199
+ failures: [],
200
+ preview: releaseBlock,
201
+ fragmentCount: fragments.length,
202
+ },
203
+ };
204
+ }
205
+
206
+ // 3. FIX 1: idempotency guard — if the version is already promoted (a dated
207
+ // release heading for this version already exists in CHANGELOG), split on
208
+ // whether fragments are still present:
209
+ // • alreadyPromoted + zero fragments → legitimate CI-retry no-op (the prior
210
+ // render commit already deleted fragments and wrote the heading).
211
+ // • alreadyPromoted + fragments present → inconsistent state: the heading
212
+ // was written out-of-band but fragments were never consumed. Fail loudly
213
+ // so the operator resolves it manually rather than silently leaving stale
214
+ // fragments to be re-consumed in a later release.
215
+ const version = stripV(opts.version);
216
+ const { releases: existingReleases } = parseChangelog(priorText);
217
+ const alreadyPromoted = existingReleases.some(
218
+ (rel) => rel.version === version && rel.date,
219
+ );
220
+ if (alreadyPromoted) {
221
+ if (fragments.length === 0) {
222
+ return { exitCode: 0, report: { consumed: 0, failures: [], alreadyPromoted: true } };
223
+ }
224
+ const errMsg =
225
+ `CHANGELOG.md already has a dated heading for ${version} but ` +
226
+ `${fragments.length} unconsumed fragment(s) remain in .changeset/ — ` +
227
+ `resolve manually (the version was likely promoted out-of-band).`;
228
+ return {
229
+ exitCode: 1,
230
+ report: { consumed: 0, failures: [], alreadyPromoted: true, error: errMsg },
231
+ };
232
+ }
233
+
234
+ // 4. Zero-fragment + !allowEmpty early-exit: write nothing.
139
235
  if (fragments.length === 0) {
140
- return { exitCode: 0, report: { consumed: 0, failures: [] } };
236
+ if (!opts.allowEmpty) {
237
+ return { exitCode: 0, report: { consumed: 0, failures: [] } };
238
+ }
239
+ // --allow-empty: emit a dated heading with a placeholder even though there
240
+ // are no fragments. This lets the render→verify CI chain succeed when a
241
+ // release contains no user-visible changes.
242
+ const { lead, prior } = splitChangelog(priorText);
243
+ // Build a header-only release block and inject the placeholder line.
244
+ const ir = renderChangelog({
245
+ fragments: [],
246
+ version: opts.version,
247
+ date: opts.date,
248
+ priorChangelog: prior || null,
249
+ });
250
+ const headerOnlyBlock = serializeChangelog(ir);
251
+ const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock);
252
+ // FIX 2: use shared assembleChangelog helper.
253
+ const out = assembleChangelog(lead, releaseBlock);
254
+ fs.writeFileSync(changelogPath, out);
255
+ return {
256
+ exitCode: 0,
257
+ report: {
258
+ consumed: 0,
259
+ failures: [],
260
+ written: true,
261
+ release: { version: opts.version, date: opts.date },
262
+ },
263
+ };
141
264
  }
142
265
 
143
- const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
266
+ // 5. Normal render path: fragments present — reuse priorText already read above.
144
267
  const { lead, prior } = splitChangelog(priorText);
145
268
 
146
269
  const ir = renderChangelog({
@@ -150,14 +273,8 @@ function cmdRender(opts) {
150
273
  priorChangelog: prior || null,
151
274
  });
152
275
  const releaseBlock = serializeChangelog(ir);
153
- const out = [
154
- lead || '# Changelog',
155
- '',
156
- '## [Unreleased]',
157
- '',
158
- releaseBlock.replace(/\s+$/, ''),
159
- '',
160
- ].join('\n');
276
+ // FIX 2: use shared assembleChangelog helper.
277
+ const out = assembleChangelog(lead, releaseBlock);
161
278
 
162
279
  fs.writeFileSync(changelogPath, out);
163
280
 
@@ -188,6 +305,14 @@ function cmdRender(opts) {
188
305
  };
189
306
  }
190
307
 
308
+ function stripV(v) { return typeof v === 'string' ? v.replace(/^v/, '') : v; }
309
+
310
+ function resolveChangelogPath(opts) {
311
+ return opts.changelog
312
+ ? path.resolve(opts.changelog)
313
+ : path.join(path.resolve(opts.repo), 'CHANGELOG.md');
314
+ }
315
+
191
316
  /**
192
317
  * extract subcommand: extracts all changelog release blocks strictly after
193
318
  * `--from` (exclusive) up to and including `--to` (inclusive). Both
@@ -203,7 +328,6 @@ function cmdRender(opts) {
203
328
  * vague/manual extraction that can silently skip intermediate versions.
204
329
  */
205
330
  function cmdExtract(opts) {
206
- const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v);
207
331
  const from = stripV(opts.fromRef);
208
332
  const to = stripV(opts.toRef);
209
333
 
@@ -225,9 +349,7 @@ function cmdExtract(opts) {
225
349
  };
226
350
  }
227
351
 
228
- const changelogPath = opts.changelog
229
- ? path.resolve(opts.changelog)
230
- : path.join(path.resolve(opts.repo), 'CHANGELOG.md');
352
+ const changelogPath = resolveChangelogPath(opts);
231
353
 
232
354
  if (!fs.existsSync(changelogPath)) {
233
355
  return {
@@ -282,6 +404,61 @@ function cmdExtract(opts) {
282
404
  };
283
405
  }
284
406
 
407
+ function cmdVerify(opts) {
408
+ const version = stripV(opts.version);
409
+
410
+ if (!isStableTripletSemver(version)) {
411
+ return {
412
+ exitCode: 1,
413
+ report: { error: `invalid semver for --version: "${version}" (expected N.N.N)`, ok: false },
414
+ textOutput: null,
415
+ };
416
+ }
417
+
418
+ const changelogPath = resolveChangelogPath(opts);
419
+
420
+ if (!fs.existsSync(changelogPath)) {
421
+ return {
422
+ exitCode: 1,
423
+ report: { error: `CHANGELOG not found: ${changelogPath}`, ok: false },
424
+ textOutput: null,
425
+ };
426
+ }
427
+
428
+ const text = fs.readFileSync(changelogPath, 'utf8');
429
+ const { releases } = parseChangelog(text);
430
+
431
+ const match = releases.find((r) => r.version === version);
432
+
433
+ if (!match) {
434
+ return {
435
+ exitCode: 1,
436
+ report: {
437
+ error: `CHANGELOG.md has no \`## [${version}]\` release heading — promote [Unreleased] into a dated section before releasing (see #690)`,
438
+ ok: false,
439
+ },
440
+ textOutput: null,
441
+ };
442
+ }
443
+
444
+ if (!match.date) {
445
+ return {
446
+ exitCode: 1,
447
+ report: {
448
+ error: `CHANGELOG.md heading \`## [${version}]\` has no date — expected \`## [${version}] - YYYY-MM-DD\``,
449
+ ok: false,
450
+ },
451
+ textOutput: null,
452
+ };
453
+ }
454
+
455
+ return {
456
+ exitCode: 0,
457
+ report: { ok: true, version, date: match.date },
458
+ textOutput: `CHANGELOG.md has a dated heading for ${version} (${match.date})`,
459
+ };
460
+ }
461
+
285
462
  function cmdGithubReleaseNotes(opts) {
286
463
  const repo = path.resolve(opts.repo);
287
464
  const report = renderGithubReleaseNotes({
@@ -322,12 +499,14 @@ function cmdGithubReleaseNotes(opts) {
322
499
  function usage() {
323
500
  return [
324
501
  'usage:',
325
- ' changeset/cli.cjs render --repo <dir> --version V --date D [--json]',
502
+ ' changeset/cli.cjs render --repo <dir> --version V --date D [--allow-empty] [--preview] [--json]',
503
+ ' --preview renders the dated section to stdout without writing CHANGELOG.md or consuming fragments.',
326
504
  ' changeset/cli.cjs github-release-notes --repo <dir> --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]',
327
505
  ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo <dir>] [--json]',
328
506
  ' Extracts changelog entries strictly after --from (exclusive) and up to',
329
507
  ' and including --to (inclusive). Accepts v-prefixed versions.',
330
508
  ' Exit 2 when no releases fall in range.',
509
+ ' changeset/cli.cjs verify --version <X.Y.Z> [--changelog <path>] Exit non-zero if CHANGELOG.md has no dated `## [X.Y.Z]` heading (release gate, #690)',
331
510
  '',
332
511
  ].join('\n');
333
512
  }
@@ -337,25 +516,26 @@ function main() {
337
516
  if (!parsed.ok) {
338
517
  process.stderr.write(`${parsed.error}\n`);
339
518
  process.stderr.write(usage());
340
- process.exit(2);
519
+ throw new ExitError(2);
341
520
  }
342
521
  const { opts } = parsed;
343
- if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract') {
522
+ if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') {
344
523
  process.stderr.write(usage());
345
- process.exit(1);
524
+ throw new ExitError(1);
346
525
  }
347
526
  if (opts.cmd === 'render' && (!opts.version || !opts.date)) {
348
- process.stderr.write('--version and --date are required for render\n');
349
- process.exit(2);
527
+ throw new ExitError(2, '--version and --date are required for render');
350
528
  }
351
529
  if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) {
352
- process.stderr.write('--from and --to are required for github-release-notes\n');
353
- process.exit(2);
530
+ throw new ExitError(2, '--from and --to are required for github-release-notes');
354
531
  }
355
532
  if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) {
356
533
  process.stderr.write('--from and --to are required for extract\n');
357
534
  process.stderr.write(usage());
358
- process.exit(1);
535
+ throw new ExitError(1);
536
+ }
537
+ if (opts.cmd === 'verify' && !opts.version) {
538
+ throw new ExitError(2, '--version is required for verify');
359
539
  }
360
540
 
361
541
  if (opts.cmd === 'extract') {
@@ -367,15 +547,33 @@ function main() {
367
547
  } else if (exitCode === 2) {
368
548
  process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`);
369
549
  }
370
- process.exit(exitCode);
550
+ return exitCode;
551
+ }
552
+
553
+ if (opts.cmd === 'verify') {
554
+ const { exitCode, report, textOutput } = cmdVerify(opts);
555
+ if (opts.json) {
556
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
557
+ } else if (textOutput) {
558
+ process.stdout.write(textOutput + '\n');
559
+ } else {
560
+ process.stderr.write(report.error + '\n');
561
+ }
562
+ return exitCode;
371
563
  }
372
564
 
373
565
  const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
374
566
  if (opts.json) {
375
567
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
568
+ } else if (opts.cmd === 'render' && opts.preview) {
569
+ // render --preview: emit the rendered section verbatim (no mutation occurred).
570
+ process.stdout.write(report.preview);
376
571
  } else if (opts.cmd === 'github-release-notes' && report.body) {
377
572
  process.stdout.write(report.body);
378
573
  } else {
574
+ if (report.error) {
575
+ process.stderr.write(`${report.error}\n`);
576
+ }
379
577
  process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`);
380
578
  if (report.failures.length > 0) {
381
579
  process.stdout.write(`Failures: ${report.failures.length}\n`);
@@ -384,9 +582,9 @@ function main() {
384
582
  }
385
583
  }
386
584
  }
387
- process.exit(exitCode);
585
+ return exitCode;
388
586
  }
389
587
 
390
- if (require.main === module) main();
588
+ if (require.main === module) runMain(main);
391
589
 
392
- module.exports = { cmdRender, cmdExtract, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };
590
+ module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage };
@@ -60,6 +60,8 @@ function evaluateLint({ changedFiles, labels }) {
60
60
  return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT };
61
61
  }
62
62
 
63
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
64
+
63
65
  function main() {
64
66
  const fs = require('node:fs');
65
67
  const cp = require('node:child_process');
@@ -87,8 +89,7 @@ function main() {
87
89
  );
88
90
  changedFiles = out.split('\n').filter(Boolean);
89
91
  } catch (e) {
90
- process.stderr.write(`could not compute diff: ${e.message}\n`);
91
- process.exit(2);
92
+ throw new ExitError(2, `could not compute diff: ${e.message}`);
92
93
  }
93
94
 
94
95
  const verdict = evaluateLint({ changedFiles, labels });
@@ -102,9 +103,9 @@ function main() {
102
103
  process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`);
103
104
  process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`);
104
105
  }
105
- process.exit(verdict.ok ? 0 : 1);
106
+ return verdict.ok ? 0 : 1;
106
107
  }
107
108
 
108
- if (require.main === module) main();
109
+ if (require.main === module) runMain(main);
109
110
 
110
111
  module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment };
@@ -13,6 +13,7 @@
13
13
 
14
14
  const fs = require('node:fs');
15
15
  const path = require('node:path');
16
+ const { ExitError, runMain } = require('../lib/cli-exit.cjs');
16
17
 
17
18
  // Small word lists — keep the function simple and dependency-free.
18
19
  // Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint
@@ -121,17 +122,16 @@ function main() {
121
122
  if (!parsed.ok) {
122
123
  process.stderr.write(`${parsed.error}\n`);
123
124
  process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n');
124
- process.exit(2);
125
+ throw new ExitError(2);
125
126
  }
126
127
  const { opts } = parsed;
127
128
  if (!opts.type || !opts.pr || !opts.body) {
128
- process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n');
129
- process.exit(2);
129
+ throw new ExitError(2, 'usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."');
130
130
  }
131
131
  const file = scaffoldFragment(opts);
132
132
  process.stdout.write(`${path.relative(process.cwd(), file)}\n`);
133
133
  }
134
134
 
135
- if (require.main === module) main();
135
+ if (require.main === module) runMain(main);
136
136
 
137
137
  module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES };
@@ -3,12 +3,14 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
7
+
6
8
  const ROOT = path.resolve(__dirname, '..');
7
9
  const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs');
8
10
 
9
11
  function fail(message) {
10
12
  process.stderr.write(`${message}\n`);
11
- process.exit(1);
13
+ throw new ExitError(1);
12
14
  }
13
15
 
14
16
  function ensureArray(value, name) {
@@ -27,82 +29,86 @@ function assertNoDuplicates(values, label) {
27
29
  }
28
30
  }
29
31
 
30
- if (!fs.existsSync(aliasesPath)) {
31
- fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`);
32
- }
33
-
34
- const aliases = require(aliasesPath);
35
-
36
- const families = [
37
- {
38
- commandAliases: 'STATE_COMMAND_ALIASES',
39
- subcommands: 'STATE_SUBCOMMANDS',
40
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'),
41
- },
42
- {
43
- commandAliases: 'VERIFY_COMMAND_ALIASES',
44
- subcommands: 'VERIFY_SUBCOMMANDS',
45
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'),
46
- },
47
- {
48
- commandAliases: 'INIT_COMMAND_ALIASES',
49
- subcommands: 'INIT_SUBCOMMANDS',
50
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'),
51
- },
52
- {
53
- commandAliases: 'PHASE_COMMAND_ALIASES',
54
- subcommands: 'PHASE_SUBCOMMANDS',
55
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'),
56
- },
57
- {
58
- commandAliases: 'PHASES_COMMAND_ALIASES',
59
- subcommands: 'PHASES_SUBCOMMANDS',
60
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'),
61
- },
62
- {
63
- commandAliases: 'VALIDATE_COMMAND_ALIASES',
64
- subcommands: 'VALIDATE_SUBCOMMANDS',
65
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'),
66
- },
67
- {
68
- commandAliases: 'ROADMAP_COMMAND_ALIASES',
69
- subcommands: 'ROADMAP_SUBCOMMANDS',
70
- routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'),
71
- },
72
- ];
73
-
74
- for (const family of families) {
75
- const commandAliases = aliases[family.commandAliases];
76
- const subcommands = aliases[family.subcommands];
77
-
78
- ensureArray(commandAliases, family.commandAliases);
79
- ensureArray(subcommands, family.subcommands);
80
-
81
- const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand);
82
- assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`);
83
-
84
- if (derivedSubcommands.length !== subcommands.length) {
85
- fail(
86
- `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` +
87
- `${family.commandAliases} length ${derivedSubcommands.length}`,
88
- );
32
+ function main() {
33
+ if (!fs.existsSync(aliasesPath)) {
34
+ fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`);
89
35
  }
90
36
 
91
- for (let i = 0; i < derivedSubcommands.length; i++) {
92
- if (derivedSubcommands[i] !== subcommands[i]) {
37
+ const aliases = require(aliasesPath);
38
+
39
+ const families = [
40
+ {
41
+ commandAliases: 'STATE_COMMAND_ALIASES',
42
+ subcommands: 'STATE_SUBCOMMANDS',
43
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'),
44
+ },
45
+ {
46
+ commandAliases: 'VERIFY_COMMAND_ALIASES',
47
+ subcommands: 'VERIFY_SUBCOMMANDS',
48
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'),
49
+ },
50
+ {
51
+ commandAliases: 'INIT_COMMAND_ALIASES',
52
+ subcommands: 'INIT_SUBCOMMANDS',
53
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'),
54
+ },
55
+ {
56
+ commandAliases: 'PHASE_COMMAND_ALIASES',
57
+ subcommands: 'PHASE_SUBCOMMANDS',
58
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'),
59
+ },
60
+ {
61
+ commandAliases: 'PHASES_COMMAND_ALIASES',
62
+ subcommands: 'PHASES_SUBCOMMANDS',
63
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'),
64
+ },
65
+ {
66
+ commandAliases: 'VALIDATE_COMMAND_ALIASES',
67
+ subcommands: 'VALIDATE_SUBCOMMANDS',
68
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'),
69
+ },
70
+ {
71
+ commandAliases: 'ROADMAP_COMMAND_ALIASES',
72
+ subcommands: 'ROADMAP_SUBCOMMANDS',
73
+ routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'),
74
+ },
75
+ ];
76
+
77
+ for (const family of families) {
78
+ const commandAliases = aliases[family.commandAliases];
79
+ const subcommands = aliases[family.subcommands];
80
+
81
+ ensureArray(commandAliases, family.commandAliases);
82
+ ensureArray(subcommands, family.subcommands);
83
+
84
+ const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand);
85
+ assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`);
86
+
87
+ if (derivedSubcommands.length !== subcommands.length) {
93
88
  fail(
94
- `check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` +
95
- `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`,
89
+ `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` +
90
+ `${family.commandAliases} length ${derivedSubcommands.length}`,
96
91
  );
97
92
  }
98
- }
99
93
 
100
- const routerSource = fs.readFileSync(family.routerPath, 'utf8');
101
- if (!routerSource.includes(family.subcommands)) {
102
- fail(
103
- `check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`,
104
- );
94
+ for (let i = 0; i < derivedSubcommands.length; i++) {
95
+ if (derivedSubcommands[i] !== subcommands[i]) {
96
+ fail(
97
+ `check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` +
98
+ `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`,
99
+ );
100
+ }
101
+ }
102
+
103
+ const routerSource = fs.readFileSync(family.routerPath, 'utf8');
104
+ if (!routerSource.includes(family.subcommands)) {
105
+ fail(
106
+ `check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`,
107
+ );
108
+ }
105
109
  }
110
+
111
+ process.stdout.write('check:alias-drift ok\n');
106
112
  }
107
113
 
108
- process.stdout.write('check:alias-drift ok\n');
114
+ runMain(main);