@opengsd/gsd-core 1.3.0 → 1.4.0-rc.1

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 (98) hide show
  1. package/agents/gsd-advisor-researcher.md +1 -20
  2. package/agents/gsd-ai-researcher.md +1 -20
  3. package/agents/gsd-domain-researcher.md +1 -20
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-phase-researcher.md +92 -166
  6. package/agents/gsd-planner.md +9 -36
  7. package/agents/gsd-project-researcher.md +62 -141
  8. package/agents/gsd-ui-researcher.md +2 -21
  9. package/agents/gsd-verifier.md +8 -2
  10. package/bin/install.js +85 -4
  11. package/commands/gsd/graphify.md +11 -6
  12. package/commands/gsd/import.md +6 -2
  13. package/commands/gsd/plan-phase.md +2 -2
  14. package/gsd-core/bin/check-latest-version.cjs +3 -2
  15. package/gsd-core/bin/gsd-tools.cjs +238 -32
  16. package/gsd-core/bin/lib/check-command-router.cjs +1 -0
  17. package/gsd-core/bin/lib/cli-exit.cjs +42 -0
  18. package/gsd-core/bin/lib/command-routing-hub.cjs +1 -1
  19. package/gsd-core/bin/lib/commands.cjs +5 -4
  20. package/gsd-core/bin/lib/config.cjs +28 -4
  21. package/gsd-core/bin/lib/core.cjs +72 -28
  22. package/gsd-core/bin/lib/graphify.cjs +2 -2
  23. package/gsd-core/bin/lib/init-command-router.cjs +2 -2
  24. package/gsd-core/bin/lib/init.cjs +19 -3
  25. package/gsd-core/bin/lib/installer-migrations.cjs +61 -22
  26. package/gsd-core/bin/lib/intel.cjs +3 -20
  27. package/gsd-core/bin/lib/package-legitimacy.cjs +368 -0
  28. package/gsd-core/bin/lib/phase.cjs +3 -3
  29. package/gsd-core/bin/lib/research-provider.cjs +137 -0
  30. package/gsd-core/bin/lib/research-store.cjs +167 -0
  31. package/gsd-core/bin/lib/roadmap-upgrade.cjs +4 -19
  32. package/gsd-core/bin/lib/security.cjs +73 -0
  33. package/gsd-core/bin/lib/shell-command-projection.cjs +3 -0
  34. package/gsd-core/bin/lib/validate.cjs +2 -2
  35. package/gsd-core/bin/lib/verification-command-router.cjs +31 -0
  36. package/gsd-core/bin/lib/verification.cjs +193 -0
  37. package/gsd-core/bin/lib/verify.cjs +2 -2
  38. package/gsd-core/bin/lib/workstream-inventory.cjs +1 -1
  39. package/gsd-core/bin/lib/worktree-base-ref.cjs +325 -0
  40. package/gsd-core/bin/lib/worktree-safety.cjs +31 -0
  41. package/gsd-core/bin/shared/config-schema.manifest.json +2 -1
  42. package/gsd-core/bin/verify-reapply-patches.cjs +8 -11
  43. package/gsd-core/references/planner-load-graph-context.md +36 -0
  44. package/gsd-core/references/planning-config.md +3 -1
  45. package/gsd-core/references/research-documentation-lookup.md +29 -0
  46. package/gsd-core/references/research-philosophy.md +29 -0
  47. package/gsd-core/references/research-verification-protocol.md +27 -0
  48. package/gsd-core/workflows/execute-phase.md +19 -8
  49. package/gsd-core/workflows/help/modes/full.md +2 -2
  50. package/gsd-core/workflows/ingest-docs.md +3 -2
  51. package/gsd-core/workflows/plan-phase.md +14 -10
  52. package/gsd-core/workflows/plan-review-convergence.md +3 -3
  53. package/gsd-core/workflows/review.md +22 -5
  54. package/gsd-core/workflows/ship.md +5 -8
  55. package/gsd-core/workflows/spec-phase.md +2 -1
  56. package/gsd-core/workflows/update.md +2 -1
  57. package/hooks/dist/gsd-context-monitor.js +1 -1
  58. package/hooks/dist/gsd-workflow-guard.js +1 -0
  59. package/hooks/dist/gsd-worktree-path-guard.js +1 -1
  60. package/hooks/gsd-context-monitor.js +1 -1
  61. package/hooks/gsd-workflow-guard.js +1 -0
  62. package/hooks/gsd-worktree-path-guard.js +1 -1
  63. package/package.json +4 -1
  64. package/scripts/affected-tests-lib.cjs +3 -2
  65. package/scripts/changeset/cli.cjs +183 -28
  66. package/scripts/changeset/lint.cjs +5 -4
  67. package/scripts/changeset/new.cjs +4 -4
  68. package/scripts/check-alias-drift.cjs +77 -71
  69. package/scripts/check-env.cjs +185 -179
  70. package/scripts/check-npm-integrity.cjs +115 -109
  71. package/scripts/ci-guard-runner.cjs +11 -5
  72. package/scripts/ci-prepare-test-scope.cjs +27 -22
  73. package/scripts/ci-rebase-check.cjs +46 -45
  74. package/scripts/ci-test-scope.cjs +6 -4
  75. package/scripts/diff-touches-shipped-paths.cjs +52 -44
  76. package/scripts/gen-inventory-manifest.cjs +38 -32
  77. package/scripts/gen-research-agents.cjs +276 -0
  78. package/scripts/lib/cli-exit.cjs +56 -0
  79. package/scripts/lint-command-contract.cjs +28 -22
  80. package/scripts/lint-descriptions.cjs +32 -28
  81. package/scripts/lint-docs-required.cjs +4 -4
  82. package/scripts/lint-legacy-dir-name.cjs +56 -52
  83. package/scripts/lint-pr-check-project-dir.cjs +3 -1
  84. package/scripts/lint-shell-command-projection-drift.cjs +27 -22
  85. package/scripts/lint-skill-deps.cjs +31 -26
  86. package/scripts/lint-test-file-count.allowlist.json +1 -0
  87. package/scripts/lint-test-file-count.cjs +5 -4
  88. package/scripts/mutation-matrix.cjs +6 -3
  89. package/scripts/prompt-injection-scan.sh +1 -1
  90. package/scripts/release-notes/format-github-release-notes.cjs +8 -3
  91. package/scripts/release-tarball-smoke.cjs +6 -4
  92. package/scripts/research-profiles.cjs +149 -0
  93. package/scripts/run-affected-tests.cjs +2 -1
  94. package/scripts/run-cross-platform-tests.cjs +11 -7
  95. package/scripts/run-tests.cjs +8 -7
  96. package/scripts/strip-prose-atrefs.cjs +1 -1
  97. package/scripts/sync-runtime-launcher.cjs +0 -3
  98. 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,7 @@ function parseArgs(argv) {
40
41
  repoSlug: defaultRepoSlug,
41
42
  installCommand: `npx ${packageName}@latest`,
42
43
  json: false,
44
+ allowEmpty: false,
43
45
  };
44
46
  if (argv.length === 0) return { ok: true, opts };
45
47
  opts.cmd = argv[0];
@@ -59,6 +61,7 @@ function parseArgs(argv) {
59
61
  for (let i = 1; i < argv.length; i++) {
60
62
  const a = argv[i];
61
63
  if (a === '--json') { opts.json = true; continue; }
64
+ if (a === '--allow-empty') { opts.allowEmpty = true; continue; }
62
65
  if (
63
66
  a === '--repo' ||
64
67
  a === '--version' ||
@@ -118,6 +121,18 @@ function splitChangelog(text) {
118
121
  return { lead, prior };
119
122
  }
120
123
 
124
+ // FIX 2: tiny local helper so both render paths share identical assembly logic.
125
+ function assembleChangelog(lead, releaseBlock) {
126
+ return [
127
+ lead || '# Changelog',
128
+ '',
129
+ '## [Unreleased]',
130
+ '',
131
+ releaseBlock.replace(/\s+$/, ''),
132
+ '',
133
+ ].join('\n');
134
+ }
135
+
121
136
  function cmdRender(opts) {
122
137
  const repo = path.resolve(opts.repo);
123
138
  const changesetDir = path.join(repo, '.changeset');
@@ -133,14 +148,83 @@ function cmdRender(opts) {
133
148
  else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null });
134
149
  }
135
150
 
151
+ // 1. parse-failure → exitCode 1 (unchanged).
136
152
  if (failures.length > 0) {
137
153
  return { exitCode: 1, report: { consumed: 0, failures } };
138
154
  }
155
+
156
+ // 2. Read priorText once; reuse in all subsequent branches.
157
+ const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
158
+
159
+ // 3. FIX 1: idempotency guard — if the version is already promoted (a dated
160
+ // release heading for this version already exists in CHANGELOG), split on
161
+ // whether fragments are still present:
162
+ // • alreadyPromoted + zero fragments → legitimate CI-retry no-op (the prior
163
+ // render commit already deleted fragments and wrote the heading).
164
+ // • alreadyPromoted + fragments present → inconsistent state: the heading
165
+ // was written out-of-band but fragments were never consumed. Fail loudly
166
+ // so the operator resolves it manually rather than silently leaving stale
167
+ // fragments to be re-consumed in a later release.
168
+ const version = stripV(opts.version);
169
+ const { releases: existingReleases } = parseChangelog(priorText);
170
+ const alreadyPromoted = existingReleases.some(
171
+ (rel) => rel.version === version && rel.date,
172
+ );
173
+ if (alreadyPromoted) {
174
+ if (fragments.length === 0) {
175
+ return { exitCode: 0, report: { consumed: 0, failures: [], alreadyPromoted: true } };
176
+ }
177
+ const errMsg =
178
+ `CHANGELOG.md already has a dated heading for ${version} but ` +
179
+ `${fragments.length} unconsumed fragment(s) remain in .changeset/ — ` +
180
+ `resolve manually (the version was likely promoted out-of-band).`;
181
+ return {
182
+ exitCode: 1,
183
+ report: { consumed: 0, failures: [], alreadyPromoted: true, error: errMsg },
184
+ };
185
+ }
186
+
187
+ // 4. Zero-fragment + !allowEmpty early-exit: write nothing.
139
188
  if (fragments.length === 0) {
140
- return { exitCode: 0, report: { consumed: 0, failures: [] } };
189
+ if (!opts.allowEmpty) {
190
+ return { exitCode: 0, report: { consumed: 0, failures: [] } };
191
+ }
192
+ // --allow-empty: emit a dated heading with a placeholder even though there
193
+ // are no fragments. This lets the render→verify CI chain succeed when a
194
+ // release contains no user-visible changes.
195
+ const { lead, prior } = splitChangelog(priorText);
196
+ // Build a header-only release block and inject the placeholder line.
197
+ const ir = renderChangelog({
198
+ fragments: [],
199
+ version: opts.version,
200
+ date: opts.date,
201
+ priorChangelog: prior || null,
202
+ });
203
+ const headerOnlyBlock = serializeChangelog(ir);
204
+ // Insert placeholder after the release header line.
205
+ // serializeChangelog with no sections yields just "## [v] - d\n" (single
206
+ // trailing newline, no blank line). We replace that trailing newline with
207
+ // a blank line + placeholder + blank line so parseChangelog still sees the
208
+ // dated heading first and the file is human-readable.
209
+ const releaseBlock = headerOnlyBlock.replace(
210
+ /^(##\s+\[[^\]]+\][^\n]*)\n+/,
211
+ '$1\n\n_No notable changes._\n\n',
212
+ );
213
+ // FIX 2: use shared assembleChangelog helper.
214
+ const out = assembleChangelog(lead, releaseBlock);
215
+ fs.writeFileSync(changelogPath, out);
216
+ return {
217
+ exitCode: 0,
218
+ report: {
219
+ consumed: 0,
220
+ failures: [],
221
+ written: true,
222
+ release: { version: opts.version, date: opts.date },
223
+ },
224
+ };
141
225
  }
142
226
 
143
- const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
227
+ // 5. Normal render path: fragments present — reuse priorText already read above.
144
228
  const { lead, prior } = splitChangelog(priorText);
145
229
 
146
230
  const ir = renderChangelog({
@@ -150,14 +234,8 @@ function cmdRender(opts) {
150
234
  priorChangelog: prior || null,
151
235
  });
152
236
  const releaseBlock = serializeChangelog(ir);
153
- const out = [
154
- lead || '# Changelog',
155
- '',
156
- '## [Unreleased]',
157
- '',
158
- releaseBlock.replace(/\s+$/, ''),
159
- '',
160
- ].join('\n');
237
+ // FIX 2: use shared assembleChangelog helper.
238
+ const out = assembleChangelog(lead, releaseBlock);
161
239
 
162
240
  fs.writeFileSync(changelogPath, out);
163
241
 
@@ -188,6 +266,14 @@ function cmdRender(opts) {
188
266
  };
189
267
  }
190
268
 
269
+ function stripV(v) { return typeof v === 'string' ? v.replace(/^v/, '') : v; }
270
+
271
+ function resolveChangelogPath(opts) {
272
+ return opts.changelog
273
+ ? path.resolve(opts.changelog)
274
+ : path.join(path.resolve(opts.repo), 'CHANGELOG.md');
275
+ }
276
+
191
277
  /**
192
278
  * extract subcommand: extracts all changelog release blocks strictly after
193
279
  * `--from` (exclusive) up to and including `--to` (inclusive). Both
@@ -203,7 +289,6 @@ function cmdRender(opts) {
203
289
  * vague/manual extraction that can silently skip intermediate versions.
204
290
  */
205
291
  function cmdExtract(opts) {
206
- const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v);
207
292
  const from = stripV(opts.fromRef);
208
293
  const to = stripV(opts.toRef);
209
294
 
@@ -225,9 +310,7 @@ function cmdExtract(opts) {
225
310
  };
226
311
  }
227
312
 
228
- const changelogPath = opts.changelog
229
- ? path.resolve(opts.changelog)
230
- : path.join(path.resolve(opts.repo), 'CHANGELOG.md');
313
+ const changelogPath = resolveChangelogPath(opts);
231
314
 
232
315
  if (!fs.existsSync(changelogPath)) {
233
316
  return {
@@ -282,6 +365,61 @@ function cmdExtract(opts) {
282
365
  };
283
366
  }
284
367
 
368
+ function cmdVerify(opts) {
369
+ const version = stripV(opts.version);
370
+
371
+ if (!isStableTripletSemver(version)) {
372
+ return {
373
+ exitCode: 1,
374
+ report: { error: `invalid semver for --version: "${version}" (expected N.N.N)`, ok: false },
375
+ textOutput: null,
376
+ };
377
+ }
378
+
379
+ const changelogPath = resolveChangelogPath(opts);
380
+
381
+ if (!fs.existsSync(changelogPath)) {
382
+ return {
383
+ exitCode: 1,
384
+ report: { error: `CHANGELOG not found: ${changelogPath}`, ok: false },
385
+ textOutput: null,
386
+ };
387
+ }
388
+
389
+ const text = fs.readFileSync(changelogPath, 'utf8');
390
+ const { releases } = parseChangelog(text);
391
+
392
+ const match = releases.find((r) => r.version === version);
393
+
394
+ if (!match) {
395
+ return {
396
+ exitCode: 1,
397
+ report: {
398
+ error: `CHANGELOG.md has no \`## [${version}]\` release heading — promote [Unreleased] into a dated section before releasing (see #690)`,
399
+ ok: false,
400
+ },
401
+ textOutput: null,
402
+ };
403
+ }
404
+
405
+ if (!match.date) {
406
+ return {
407
+ exitCode: 1,
408
+ report: {
409
+ error: `CHANGELOG.md heading \`## [${version}]\` has no date — expected \`## [${version}] - YYYY-MM-DD\``,
410
+ ok: false,
411
+ },
412
+ textOutput: null,
413
+ };
414
+ }
415
+
416
+ return {
417
+ exitCode: 0,
418
+ report: { ok: true, version, date: match.date },
419
+ textOutput: `CHANGELOG.md has a dated heading for ${version} (${match.date})`,
420
+ };
421
+ }
422
+
285
423
  function cmdGithubReleaseNotes(opts) {
286
424
  const repo = path.resolve(opts.repo);
287
425
  const report = renderGithubReleaseNotes({
@@ -322,12 +460,13 @@ function cmdGithubReleaseNotes(opts) {
322
460
  function usage() {
323
461
  return [
324
462
  'usage:',
325
- ' changeset/cli.cjs render --repo <dir> --version V --date D [--json]',
463
+ ' changeset/cli.cjs render --repo <dir> --version V --date D [--allow-empty] [--json]',
326
464
  ' changeset/cli.cjs github-release-notes --repo <dir> --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]',
327
465
  ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo <dir>] [--json]',
328
466
  ' Extracts changelog entries strictly after --from (exclusive) and up to',
329
467
  ' and including --to (inclusive). Accepts v-prefixed versions.',
330
468
  ' Exit 2 when no releases fall in range.',
469
+ ' 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
470
  '',
332
471
  ].join('\n');
333
472
  }
@@ -337,25 +476,26 @@ function main() {
337
476
  if (!parsed.ok) {
338
477
  process.stderr.write(`${parsed.error}\n`);
339
478
  process.stderr.write(usage());
340
- process.exit(2);
479
+ throw new ExitError(2);
341
480
  }
342
481
  const { opts } = parsed;
343
- if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract') {
482
+ if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') {
344
483
  process.stderr.write(usage());
345
- process.exit(1);
484
+ throw new ExitError(1);
346
485
  }
347
486
  if (opts.cmd === 'render' && (!opts.version || !opts.date)) {
348
- process.stderr.write('--version and --date are required for render\n');
349
- process.exit(2);
487
+ throw new ExitError(2, '--version and --date are required for render');
350
488
  }
351
489
  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);
490
+ throw new ExitError(2, '--from and --to are required for github-release-notes');
354
491
  }
355
492
  if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) {
356
493
  process.stderr.write('--from and --to are required for extract\n');
357
494
  process.stderr.write(usage());
358
- process.exit(1);
495
+ throw new ExitError(1);
496
+ }
497
+ if (opts.cmd === 'verify' && !opts.version) {
498
+ throw new ExitError(2, '--version is required for verify');
359
499
  }
360
500
 
361
501
  if (opts.cmd === 'extract') {
@@ -367,7 +507,19 @@ function main() {
367
507
  } else if (exitCode === 2) {
368
508
  process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`);
369
509
  }
370
- process.exit(exitCode);
510
+ return exitCode;
511
+ }
512
+
513
+ if (opts.cmd === 'verify') {
514
+ const { exitCode, report, textOutput } = cmdVerify(opts);
515
+ if (opts.json) {
516
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
517
+ } else if (textOutput) {
518
+ process.stdout.write(textOutput + '\n');
519
+ } else {
520
+ process.stderr.write(report.error + '\n');
521
+ }
522
+ return exitCode;
371
523
  }
372
524
 
373
525
  const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
@@ -376,6 +528,9 @@ function main() {
376
528
  } else if (opts.cmd === 'github-release-notes' && report.body) {
377
529
  process.stdout.write(report.body);
378
530
  } else {
531
+ if (report.error) {
532
+ process.stderr.write(`${report.error}\n`);
533
+ }
379
534
  process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`);
380
535
  if (report.failures.length > 0) {
381
536
  process.stdout.write(`Failures: ${report.failures.length}\n`);
@@ -384,9 +539,9 @@ function main() {
384
539
  }
385
540
  }
386
541
  }
387
- process.exit(exitCode);
542
+ return exitCode;
388
543
  }
389
544
 
390
- if (require.main === module) main();
545
+ if (require.main === module) runMain(main);
391
546
 
392
- module.exports = { cmdRender, cmdExtract, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };
547
+ 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);