@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
@@ -16,6 +16,8 @@
16
16
  const fs = require('node:fs');
17
17
  const path = require('node:path');
18
18
 
19
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
20
+
19
21
  const ROOT = path.resolve(__dirname, '..');
20
22
  const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
21
23
 
@@ -70,40 +72,44 @@ function buildManifest() {
70
72
  return manifest;
71
73
  }
72
74
 
73
- const [, , flag] = process.argv;
75
+ function main() {
76
+ const [, , flag] = process.argv;
74
77
 
75
- if (flag === '--check') {
76
- const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
77
- const live = buildManifest();
78
- // Strip the generated date for comparison
79
- delete committed.generated;
80
- delete live.generated;
81
- const committedStr = JSON.stringify(committed, null, 2);
82
- const liveStr = JSON.stringify(live, null, 2);
83
- if (committedStr !== liveStr) {
84
- process.stderr.write(
85
- 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' +
86
- ' node scripts/gen-inventory-manifest.cjs --write\n' +
87
- 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n',
88
- );
89
- // Show diff-friendly output
90
- for (const family of Object.keys(live.families)) {
91
- const liveSet = new Set(live.families[family]);
92
- const committedSet = new Set((committed.families || {})[family] || []);
93
- for (const name of liveSet) {
94
- if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n');
95
- }
96
- for (const name of committedSet) {
97
- if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n');
78
+ if (flag === '--check') {
79
+ const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
80
+ const live = buildManifest();
81
+ // Strip the generated date for comparison
82
+ delete committed.generated;
83
+ delete live.generated;
84
+ const committedStr = JSON.stringify(committed, null, 2);
85
+ const liveStr = JSON.stringify(live, null, 2);
86
+ if (committedStr !== liveStr) {
87
+ process.stderr.write(
88
+ 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' +
89
+ ' node scripts/gen-inventory-manifest.cjs --write\n' +
90
+ 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n',
91
+ );
92
+ // Show diff-friendly output
93
+ for (const family of Object.keys(live.families)) {
94
+ const liveSet = new Set(live.families[family]);
95
+ const committedSet = new Set((committed.families || {})[family] || []);
96
+ for (const name of liveSet) {
97
+ if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n');
98
+ }
99
+ for (const name of committedSet) {
100
+ if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n');
101
+ }
98
102
  }
103
+ throw new ExitError(1);
99
104
  }
100
- process.exit(1);
105
+ process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n');
106
+ } else if (flag === '--write') {
107
+ const manifest = buildManifest();
108
+ fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
109
+ process.stdout.write('Wrote ' + MANIFEST_PATH + '\n');
110
+ } else {
111
+ process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n');
101
112
  }
102
- process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n');
103
- } else if (flag === '--write') {
104
- const manifest = buildManifest();
105
- fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
106
- process.stdout.write('Wrote ' + MANIFEST_PATH + '\n');
107
- } else {
108
- process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n');
109
113
  }
114
+
115
+ runMain(main);
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * gen-research-agents.cjs — profile-driven drift guard for the 7 researcher agents.
6
+ *
7
+ * Usage:
8
+ * node scripts/gen-research-agents.cjs # same as --check
9
+ * node scripts/gen-research-agents.cjs --check # assert every agent matches its profile
10
+ * node scripts/gen-research-agents.cjs --write # regenerate frontmatter from profiles
11
+ *
12
+ * --check assertions per agent:
13
+ * (a) frontmatter name/description/color/tools exactly match the profile
14
+ * (b) every requiredInclude string is present in the body
15
+ * (c) every requiredSeamCall string is present in the body
16
+ * (d) every outputContract marker string is present in the body
17
+ *
18
+ * --write regenerates ONLY the opening `---\n...\n---` frontmatter block from the
19
+ * profile, leaving the body byte-identical. After --write, --check must pass and
20
+ * `git diff` must be empty (profiles were derived from current state).
21
+ */
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { PROFILES } = require('./research-profiles.cjs');
27
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
28
+
29
+ const ROOT = path.resolve(__dirname, '..');
30
+ const AGENTS_DIR = path.join(ROOT, 'agents');
31
+
32
+ // ─── Frontmatter serialization ────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Build the frontmatter block for a profile.
36
+ *
37
+ * The agent files have two patterns for commented hooks:
38
+ * - Agents with Write in tools (file-writers): include the commented hooks block
39
+ * - The advisor-researcher (Read-only tools, no Write): no commented hooks
40
+ *
41
+ * We read the CURRENT commented-hooks block from the agent file and preserve it
42
+ * byte-for-byte; only name/description/tools/color are regenerated.
43
+ */
44
+ function buildFrontmatter(profile, existingFrontmatter) {
45
+ // Extract the commented hooks section from the existing frontmatter, if any.
46
+ // The hooks block starts at `# hooks:` and runs to (but not including) the
47
+ // closing `---`. In the committed files there is NO blank line between
48
+ // `color:` and `# hooks:`, so we append it directly after the color line's `\n`.
49
+ const hooksMatch = existingFrontmatter.match(/(# hooks:[\s\S]*?)(?=\n---)/);
50
+ // hooksSuffix: if present, the block followed by a newline so `---` is on its own line;
51
+ // if absent, empty string (the closing `---` follows directly after color's `\n`).
52
+ const hooksSuffix = hooksMatch ? hooksMatch[1] + '\n' : '';
53
+
54
+ return (
55
+ '---\n' +
56
+ 'name: ' + profile.name + '\n' +
57
+ 'description: ' + profile.description + '\n' +
58
+ 'tools: ' + profile.tools + '\n' +
59
+ 'color: ' + profile.color + '\n' +
60
+ hooksSuffix +
61
+ '---'
62
+ );
63
+ }
64
+
65
+ // ─── Parse agent file ─────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Parse a .md file and return { frontmatterRaw, body, frontmatterFields }.
69
+ *
70
+ * frontmatterRaw: the raw text between the first and second `---` delimiters (exclusive)
71
+ * body: everything after the closing `---\n`
72
+ * frontmatterFields: { name, description, color, tools }
73
+ */
74
+ function parseAgentFile(filePath) {
75
+ const raw = fs.readFileSync(filePath, 'utf8');
76
+
77
+ // The frontmatter is between the first `---` line and the next `---` line.
78
+ const lines = raw.split('\n');
79
+ let start = -1;
80
+ let end = -1;
81
+ for (let i = 0; i < lines.length; i++) {
82
+ if (lines[i].trim() === '---') {
83
+ if (start === -1) {
84
+ start = i;
85
+ } else {
86
+ end = i;
87
+ break;
88
+ }
89
+ }
90
+ }
91
+
92
+ if (start === -1 || end === -1) {
93
+ throw new Error('No valid frontmatter delimiters found in ' + filePath);
94
+ }
95
+
96
+ const frontmatterLines = lines.slice(start + 1, end);
97
+ const frontmatterRaw = frontmatterLines.join('\n');
98
+ // body includes the closing `---` line and everything after
99
+ const fullFrontmatter = lines.slice(start, end + 1).join('\n');
100
+ const body = lines.slice(end + 1).join('\n');
101
+
102
+ const fields = {};
103
+ // Parse simple key: value pairs (not nested YAML, no multi-line values here)
104
+ for (const line of frontmatterLines) {
105
+ // Skip comment lines
106
+ if (line.trimStart().startsWith('#')) continue;
107
+ const m = line.match(/^(\w+):\s*(.*)/);
108
+ if (m) {
109
+ fields[m[1]] = m[2].trim();
110
+ }
111
+ }
112
+
113
+ return { raw, frontmatterRaw, fullFrontmatter, body, fields };
114
+ }
115
+
116
+ // ─── Check ────────────────────────────────────────────────────────────────────
117
+
118
+ /**
119
+ * Check one profile against its agent file.
120
+ * Returns an array of failure strings (empty = pass).
121
+ */
122
+ function checkAgent(profile) {
123
+ // Validate required array fields — return a clear failure rather than throwing TypeError.
124
+ for (const field of ['requiredIncludes', 'requiredSeamCalls', 'outputContract']) {
125
+ if (!Array.isArray(profile[field])) {
126
+ return ['profile ' + profile.name + ': missing required array field ' + field];
127
+ }
128
+ }
129
+
130
+ const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
131
+ const failures = [];
132
+
133
+ if (!fs.existsSync(agentPath)) {
134
+ return ['agent file not found: ' + agentPath];
135
+ }
136
+
137
+ const { fields } = parseAgentFile(agentPath);
138
+ const fullContent = fs.readFileSync(agentPath, 'utf8');
139
+
140
+ // (a) frontmatter fields
141
+ if (fields.name !== profile.name) {
142
+ failures.push(
143
+ 'name mismatch: got "' + fields.name + '", want "' + profile.name + '"',
144
+ );
145
+ }
146
+ if (fields.description !== profile.description) {
147
+ failures.push(
148
+ 'description mismatch:\n got: "' + fields.description + '"\n want: "' + profile.description + '"',
149
+ );
150
+ }
151
+ if (fields.color !== profile.color) {
152
+ failures.push(
153
+ 'color mismatch: got "' + fields.color + '", want "' + profile.color + '"',
154
+ );
155
+ }
156
+ if (fields.tools !== profile.tools) {
157
+ failures.push(
158
+ 'tools mismatch:\n got: "' + fields.tools + '"\n want: "' + profile.tools + '"',
159
+ );
160
+ }
161
+
162
+ // (b) requiredIncludes
163
+ for (const include of profile.requiredIncludes) {
164
+ if (!fullContent.includes(include)) {
165
+ failures.push('missing required include: ' + include);
166
+ }
167
+ }
168
+
169
+ // (c) requiredSeamCalls
170
+ for (const seam of profile.requiredSeamCalls) {
171
+ if (!fullContent.includes(seam)) {
172
+ failures.push('missing required seam call: ' + seam);
173
+ }
174
+ }
175
+
176
+ // (d) outputContract
177
+ for (const marker of profile.outputContract) {
178
+ if (!fullContent.includes(marker)) {
179
+ failures.push('missing output contract marker: ' + marker);
180
+ }
181
+ }
182
+
183
+ return failures;
184
+ }
185
+
186
+ /**
187
+ * Run --check for all profiles. Prints pass/fail per agent.
188
+ * Returns true if all pass, false otherwise.
189
+ */
190
+ function runCheck() {
191
+ let allPassed = true;
192
+
193
+ for (const profile of PROFILES) {
194
+ const failures = checkAgent(profile);
195
+ if (failures.length === 0) {
196
+ process.stdout.write(' PASS ' + profile.name + '\n');
197
+ } else {
198
+ process.stdout.write(' FAIL ' + profile.name + '\n');
199
+ for (const f of failures) {
200
+ process.stdout.write(' ' + f.replace(/\n/g, '\n ') + '\n');
201
+ }
202
+ allPassed = false;
203
+ }
204
+ }
205
+
206
+ return allPassed;
207
+ }
208
+
209
+ // ─── Write ────────────────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Regenerate the frontmatter block of one agent file from its profile.
213
+ * The body (everything after the closing ---) is preserved byte-for-byte.
214
+ */
215
+ function writeAgent(profile) {
216
+ const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
217
+ const { fullFrontmatter, body } = parseAgentFile(agentPath);
218
+
219
+ const newFrontmatter = buildFrontmatter(profile, fullFrontmatter);
220
+ const newContent = newFrontmatter + '\n' + body;
221
+
222
+ fs.writeFileSync(agentPath, newContent, 'utf8');
223
+ }
224
+
225
+ function runWrite() {
226
+ for (const profile of PROFILES) {
227
+ const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
228
+ if (!fs.existsSync(agentPath)) {
229
+ throw new ExitError(1, 'ERROR: agent file not found: ' + agentPath);
230
+ }
231
+ writeAgent(profile);
232
+ process.stdout.write(' wrote ' + profile.name + '.md\n');
233
+ }
234
+ process.stdout.write('\nRun --check to verify:\n');
235
+ process.stdout.write(' node scripts/gen-research-agents.cjs --check\n');
236
+ }
237
+
238
+ // ─── Exports (for tests) ──────────────────────────────────────────────────────
239
+
240
+ module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile };
241
+
242
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
243
+
244
+ function main() {
245
+ const flag = process.argv[2] || '--check';
246
+
247
+ if (flag === '--write') {
248
+ process.stdout.write('Writing frontmatter from profiles...\n');
249
+ runWrite();
250
+ process.stdout.write('\nVerifying...\n');
251
+ const ok = runCheck();
252
+ if (!ok) {
253
+ process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n');
254
+ throw new ExitError(1);
255
+ }
256
+ process.stdout.write('\nAll agents match their profiles.\n');
257
+ } else if (flag === '--check') {
258
+ process.stdout.write('Checking research agent profiles...\n');
259
+ const ok = runCheck();
260
+ if (!ok) {
261
+ process.stderr.write('\nSome agents do not match their profiles.\n');
262
+ process.stdout.write(
263
+ '\nTo regenerate frontmatter from profiles:\n' +
264
+ ' node scripts/gen-research-agents.cjs --write\n',
265
+ );
266
+ throw new ExitError(1);
267
+ }
268
+ process.stdout.write('\nAll 7 agents match their profiles.\n');
269
+ } else {
270
+ throw new ExitError(1, 'Unknown flag: ' + flag + '\nUsage: node scripts/gen-research-agents.cjs [--check|--write]');
271
+ }
272
+ }
273
+
274
+ if (require.main === module) {
275
+ runMain(main);
276
+ }
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Error that carries a process exit code. CLI logic throws this instead of
5
+ * calling process.exit() (banned by n/no-process-exit); runMain() translates it
6
+ * into process.exitCode at the entrypoint.
7
+ *
8
+ * @param {number} code exit code (default 1)
9
+ * @param {string} [message] optional human message; when set and code != 0 it is
10
+ * written to stderr by runMain before the process exits.
11
+ */
12
+ class ExitError extends Error {
13
+ constructor(code = 1, message) {
14
+ super(message === undefined ? `process exit ${code}` : message);
15
+ this.name = 'ExitError';
16
+ this.code = code;
17
+ // Whether runMain should print this.message to stderr (only when a real
18
+ // message was provided, not the synthetic default).
19
+ this.hasUserMessage = message !== undefined;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Run a CLI main function and translate its outcome into process.exitCode
25
+ * (never process.exit(), so n/no-process-exit stays satisfied). Supports sync or
26
+ * async main.
27
+ * - main returns a number -> process.exitCode = that number
28
+ * - main throws/rejects ExitError -> process.exitCode = err.code, and if
29
+ * err.hasUserMessage && err.code !== 0, err.message is written to stderr
30
+ * - main throws/rejects anything else -> the stack is written to stderr and
31
+ * process.exitCode = 1
32
+ * Letting the event loop drain (vs process.exit) means buffered stdout/stderr is
33
+ * flushed and process.on('exit') cleanup handlers still fire.
34
+ *
35
+ * @param {() => (number|void|Promise<number|void>)} main
36
+ */
37
+ function runMain(main) {
38
+ Promise.resolve()
39
+ .then(() => main())
40
+ .then((code) => {
41
+ if (typeof code === 'number') process.exitCode = code;
42
+ })
43
+ .catch((err) => {
44
+ if (err instanceof ExitError) {
45
+ if (err.hasUserMessage && err.code !== 0) {
46
+ process.stderr.write(`${err.message}\n`);
47
+ }
48
+ process.exitCode = err.code;
49
+ return;
50
+ }
51
+ process.stderr.write(`${err && err.stack ? err.stack : String(err)}\n`);
52
+ process.exitCode = 1;
53
+ });
54
+ }
55
+
56
+ module.exports = { ExitError, runMain };
@@ -28,6 +28,8 @@ const {
28
28
  executionContextRefs: extractExecutionContextRefs,
29
29
  } = require('./command-contract-helpers.cjs');
30
30
 
31
+ const { runMain } = require('./lib/cli-exit.cjs');
32
+
31
33
  // ─── check one file ───────────────────────────────────────────────────────────
32
34
 
33
35
  function check(filePath) {
@@ -79,30 +81,34 @@ function check(filePath) {
79
81
 
80
82
  // ─── run ─────────────────────────────────────────────────────────────────────
81
83
 
82
- const commandFiles = fs
83
- .readdirSync(COMMANDS_DIR)
84
- .filter(f => f.endsWith('.md'))
85
- .map(f => path.join(COMMANDS_DIR, f));
84
+ function main() {
85
+ const commandFiles = fs
86
+ .readdirSync(COMMANDS_DIR)
87
+ .filter(f => f.endsWith('.md'))
88
+ .map(f => path.join(COMMANDS_DIR, f));
86
89
 
87
- const results = commandFiles.map(check).filter(Boolean);
90
+ const results = commandFiles.map(check).filter(Boolean);
88
91
 
89
- if (results.length === 0) {
90
- console.log(
91
- `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
92
- );
93
- process.exit(0);
94
- }
92
+ if (results.length === 0) {
93
+ console.log(
94
+ `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
95
+ );
96
+ return 0;
97
+ }
95
98
 
96
- const total = results.reduce((n, r) => n + r.violations.length, 0);
97
- process.stderr.write(
98
- `\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
99
- );
100
- for (const r of results) {
101
- process.stderr.write(` ${r.file}\n`);
102
- for (const v of r.violations) {
103
- process.stderr.write(` - ${v}\n`);
99
+ const total = results.reduce((n, r) => n + r.violations.length, 0);
100
+ process.stderr.write(
101
+ `\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
102
+ );
103
+ for (const r of results) {
104
+ process.stderr.write(` ${r.file}\n`);
105
+ for (const v of r.violations) {
106
+ process.stderr.write(` - ${v}\n`);
107
+ }
108
+ process.stderr.write('\n');
104
109
  }
105
- process.stderr.write('\n');
110
+ process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
111
+ return 1;
106
112
  }
107
- process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
108
- process.exit(1);
113
+
114
+ runMain(main);
@@ -15,6 +15,7 @@
15
15
 
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
18
19
 
19
20
  const MAX_LENGTH = 100;
20
21
  const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
@@ -46,38 +47,41 @@ function getFiles() {
46
47
  .map(f => path.join(COMMANDS_DIR, f));
47
48
  }
48
49
 
49
- const files = getFiles();
50
- const violations = [];
50
+ function main() {
51
+ const files = getFiles();
52
+ const violations = [];
51
53
 
52
- for (const filePath of files) {
53
- let content;
54
- try {
55
- content = fs.readFileSync(filePath, 'utf-8');
56
- } catch (err) {
57
- process.stderr.write(`ERROR: Cannot read file: ${filePath}\n ${err.message}\n`);
58
- process.exit(1);
59
- }
54
+ for (const filePath of files) {
55
+ let content;
56
+ try {
57
+ content = fs.readFileSync(filePath, 'utf-8');
58
+ } catch (err) {
59
+ throw new ExitError(1, `ERROR: Cannot read file: ${filePath}\n ${err.message}`);
60
+ }
60
61
 
61
- const description = parseDescription(content);
62
- if (description === null) continue;
62
+ const description = parseDescription(content);
63
+ if (description === null) continue;
63
64
 
64
- if (description.length > MAX_LENGTH) {
65
- violations.push({ filePath, length: description.length, description });
65
+ if (description.length > MAX_LENGTH) {
66
+ violations.push({ filePath, length: description.length, description });
67
+ }
66
68
  }
67
- }
68
69
 
69
- if (violations.length === 0) {
70
- const checked = files.length;
71
- process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`);
72
- process.exit(0);
73
- }
70
+ if (violations.length === 0) {
71
+ const checked = files.length;
72
+ process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`);
73
+ return 0;
74
+ }
74
75
 
75
- process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`);
76
- for (const v of violations) {
77
- const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description;
78
- process.stderr.write(` ${v.filePath}\n`);
79
- process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`);
80
- process.stderr.write(` Desc : ${preview}\n\n`);
76
+ process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`);
77
+ for (const v of violations) {
78
+ const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description;
79
+ process.stderr.write(` ${v.filePath}\n`);
80
+ process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`);
81
+ process.stderr.write(` Desc : ${preview}\n\n`);
82
+ }
83
+ process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`);
84
+ return 1;
81
85
  }
82
- process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`);
83
- process.exit(1);
86
+
87
+ runMain(main);
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
17
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
17
18
 
18
19
  const LINT_REASON = Object.freeze({
19
20
  OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
@@ -161,8 +162,7 @@ function main() {
161
162
  );
162
163
  changedFiles = out.split('\n').filter(Boolean);
163
164
  } catch (e) {
164
- process.stderr.write(`could not compute diff: ${e.message}\n`);
165
- process.exit(2);
165
+ throw new ExitError(2, `could not compute diff: ${e.message}`);
166
166
  }
167
167
 
168
168
  const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
@@ -204,10 +204,10 @@ function main() {
204
204
  `exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
205
205
  );
206
206
  }
207
- process.exit(verdict.ok ? 0 : 1);
207
+ return verdict.ok ? 0 : 1;
208
208
  }
209
209
 
210
- if (require.main === module) main();
210
+ if (require.main === module) runMain(main);
211
211
 
212
212
  module.exports = {
213
213
  evaluateLint,