@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,62 +16,7 @@
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
 
19
- // ---- Argument parsing -------------------------------------------------------
20
-
21
- let ignoreExtraneous = false;
22
-
23
- for (const arg of process.argv.slice(2)) {
24
- if (arg === '--ignore-extraneous') {
25
- ignoreExtraneous = true;
26
- } else if (arg === '--help' || arg === '-h') {
27
- process.stdout.write(
28
- 'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
29
- );
30
- process.exit(0);
31
- } else {
32
- process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
33
- process.exit(2);
34
- }
35
- }
36
-
37
- // ---- Locate lockfile --------------------------------------------------------
38
-
39
- const lockfilePath = path.join(process.cwd(), 'package-lock.json');
40
-
41
- if (!fs.existsSync(lockfilePath)) {
42
- process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
43
- process.exit(2);
44
- }
45
-
46
- // ---- Parse lockfile ---------------------------------------------------------
47
-
48
- let lock;
49
- try {
50
- lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
51
- } catch (e) {
52
- process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
53
- process.exit(2);
54
- }
55
-
56
- const lockVersion = lock.lockfileVersion || 1;
57
- if (lockVersion < 2) {
58
- process.stderr.write(
59
- `ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
60
- 'Run `npm install` to upgrade to v3.\n'
61
- );
62
- process.exit(2);
63
- }
64
-
65
- const packages = lock.packages || {};
66
- const rootEntry = packages[''] || {};
67
-
68
- // Collect declared dependency ranges from root entry.
69
- const declaredRanges = {};
70
- for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
71
- for (const [name, range] of Object.entries(rootEntry[field] || {})) {
72
- if (!declaredRanges[name]) declaredRanges[name] = range;
73
- }
74
- }
19
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
75
20
 
76
21
  // ---- Minimal semver satisfies -----------------------------------------------
77
22
 
@@ -132,78 +77,139 @@ function satisfies(installed, range) {
132
77
  return installed === range;
133
78
  }
134
79
 
135
- // ---- Walk packages map ------------------------------------------------------
80
+ function main() {
81
+ // ---- Argument parsing -------------------------------------------------------
82
+
83
+ let ignoreExtraneous = false;
84
+
85
+ for (const arg of process.argv.slice(2)) {
86
+ if (arg === '--ignore-extraneous') {
87
+ ignoreExtraneous = true;
88
+ } else if (arg === '--help' || arg === '-h') {
89
+ process.stdout.write(
90
+ 'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
91
+ );
92
+ return 0;
93
+ } else {
94
+ process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
95
+ throw new ExitError(2);
96
+ }
97
+ }
136
98
 
137
- const invalids = [];
138
- const missings = [];
139
- const extraneousFound = [];
99
+ // ---- Locate lockfile --------------------------------------------------------
140
100
 
141
- for (const [key, entry] of Object.entries(packages)) {
142
- if (!key.startsWith('node_modules/')) continue;
143
- const rest = key.slice('node_modules/'.length);
144
- const isScoped = rest[0] === '@';
145
- const slashCount = (rest.match(/\//g) || []).length;
146
- if (isScoped && slashCount > 1) continue;
147
- if (!isScoped && slashCount > 0) continue;
101
+ const lockfilePath = path.join(process.cwd(), 'package-lock.json');
148
102
 
149
- const pkgName = rest;
150
- const installedVersion = entry.version || '';
103
+ if (!fs.existsSync(lockfilePath)) {
104
+ process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
105
+ throw new ExitError(2);
106
+ }
151
107
 
152
- if (entry.extraneous) {
153
- extraneousFound.push({ name: pkgName, version: installedVersion });
154
- continue;
108
+ // ---- Parse lockfile ---------------------------------------------------------
109
+
110
+ let lock;
111
+ try {
112
+ lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
113
+ } catch (e) {
114
+ process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
115
+ throw new ExitError(2);
155
116
  }
156
117
 
157
- if (!declaredRanges[pkgName]) continue;
118
+ const lockVersion = lock.lockfileVersion || 1;
119
+ if (lockVersion < 2) {
120
+ process.stderr.write(
121
+ `ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
122
+ 'Run `npm install` to upgrade to v3.\n'
123
+ );
124
+ throw new ExitError(2);
125
+ }
158
126
 
159
- if (!satisfies(installedVersion, declaredRanges[pkgName])) {
160
- invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
127
+ const packages = lock.packages || {};
128
+ const rootEntry = packages[''] || {};
129
+
130
+ // Collect declared dependency ranges from root entry.
131
+ const declaredRanges = {};
132
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
133
+ for (const [name, range] of Object.entries(rootEntry[field] || {})) {
134
+ if (!declaredRanges[name]) declaredRanges[name] = range;
135
+ }
161
136
  }
162
- }
163
137
 
164
- for (const name of Object.keys(declaredRanges)) {
165
- if (!packages[`node_modules/${name}`]) {
166
- missings.push({ name, required: declaredRanges[name] });
138
+ // ---- Walk packages map ------------------------------------------------------
139
+
140
+ const invalids = [];
141
+ const missings = [];
142
+ const extraneousFound = [];
143
+
144
+ for (const [key, entry] of Object.entries(packages)) {
145
+ if (!key.startsWith('node_modules/')) continue;
146
+ const rest = key.slice('node_modules/'.length);
147
+ const isScoped = rest[0] === '@';
148
+ const slashCount = (rest.match(/\//g) || []).length;
149
+ if (isScoped && slashCount > 1) continue;
150
+ if (!isScoped && slashCount > 0) continue;
151
+
152
+ const pkgName = rest;
153
+ const installedVersion = entry.version || '';
154
+
155
+ if (entry.extraneous) {
156
+ extraneousFound.push({ name: pkgName, version: installedVersion });
157
+ continue;
158
+ }
159
+
160
+ if (!declaredRanges[pkgName]) continue;
161
+
162
+ if (!satisfies(installedVersion, declaredRanges[pkgName])) {
163
+ invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
164
+ }
167
165
  }
168
- }
169
166
 
170
- // ---- Verdict ----------------------------------------------------------------
167
+ for (const name of Object.keys(declaredRanges)) {
168
+ if (!packages[`node_modules/${name}`]) {
169
+ missings.push({ name, required: declaredRanges[name] });
170
+ }
171
+ }
171
172
 
172
- const failInvalid = invalids.length > 0;
173
- const failMissing = missings.length > 0;
174
- const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
173
+ // ---- Verdict ----------------------------------------------------------------
175
174
 
176
- if (!failInvalid && !failMissing && !failExtra) {
177
- process.stderr.write('check-npm-integrity.cjs: clean\n');
178
- process.exit(0);
179
- }
175
+ const failInvalid = invalids.length > 0;
176
+ const failMissing = missings.length > 0;
177
+ const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
180
178
 
181
- const lines = ['FAIL: dependency integrity drift detected', ''];
179
+ if (!failInvalid && !failMissing && !failExtra) {
180
+ process.stderr.write('check-npm-integrity.cjs: clean\n');
181
+ return 0;
182
+ }
182
183
 
183
- if (failInvalid) {
184
- lines.push(' INVALID (installed version does not satisfy declared range):');
185
- for (const { name, declared, version } of invalids) {
186
- lines.push(` ${name}: declared=${declared} installed=${version}`);
184
+ const lines = ['FAIL: dependency integrity drift detected', ''];
185
+
186
+ if (failInvalid) {
187
+ lines.push(' INVALID (installed version does not satisfy declared range):');
188
+ for (const { name, declared, version } of invalids) {
189
+ lines.push(` ${name}: declared=${declared} installed=${version}`);
190
+ }
191
+ lines.push('');
187
192
  }
188
- lines.push('');
189
- }
190
193
 
191
- if (failMissing) {
192
- lines.push(' MISSING (declared but absent from lockfile packages map):');
193
- for (const { name, required } of missings) {
194
- lines.push(` ${name}@${required}`);
194
+ if (failMissing) {
195
+ lines.push(' MISSING (declared but absent from lockfile packages map):');
196
+ for (const { name, required } of missings) {
197
+ lines.push(` ${name}@${required}`);
198
+ }
199
+ lines.push('');
195
200
  }
196
- lines.push('');
197
- }
198
201
 
199
- if (failExtra) {
200
- lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
201
- for (const { name, version } of extraneousFound) {
202
- lines.push(` ${name}@${version}`);
202
+ if (failExtra) {
203
+ lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
204
+ for (const { name, version } of extraneousFound) {
205
+ lines.push(` ${name}@${version}`);
206
+ }
207
+ lines.push('');
203
208
  }
204
- lines.push('');
209
+
210
+ lines.push('Remediation: rm -rf node_modules && npm ci');
211
+ process.stderr.write(lines.join('\n') + '\n');
212
+ throw new ExitError(1);
205
213
  }
206
214
 
207
- lines.push('Remediation: rm -rf node_modules && npm ci');
208
- process.stderr.write(lines.join('\n') + '\n');
209
- process.exit(1);
215
+ runMain(main);
@@ -6,11 +6,17 @@
6
6
  // Exit 0 = github-hosted runner confirmed.
7
7
  // Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation).
8
8
 
9
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
10
+
9
11
  const env = process.env.RUNNER_ENVIRONMENT || '';
10
12
 
11
- if (env !== 'github-hosted') {
12
- process.stderr.write(
13
- `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n`
14
- );
15
- process.exit(1);
13
+ function main() {
14
+ if (env !== 'github-hosted') {
15
+ throw new ExitError(
16
+ 1,
17
+ `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}`
18
+ );
19
+ }
16
20
  }
21
+
22
+ runMain(main);
@@ -14,33 +14,38 @@
14
14
  const fs = require('fs');
15
15
  const path = require('path');
16
16
 
17
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
18
+
17
19
  const scope = process.env.TEST_SCOPE || '';
18
20
  const targeted = process.env.TARGETED_TESTS || '';
19
21
  const windows = process.env.WINDOWS_TESTS || '';
20
22
 
21
23
  const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
22
24
 
23
- let selected;
24
- if (scope === 'windows') {
25
- selected = windows;
26
- } else if (scope === 'targeted') {
27
- selected = targeted;
28
- } else {
29
- process.stderr.write(`::error::Unknown test scope: ${scope}\n`);
30
- process.exit(1);
31
- }
32
-
33
- // Trim and fall back to default set if empty.
34
- if (!selected.trim()) {
35
- selected = FALLBACK;
25
+ function main() {
26
+ let selected;
27
+ if (scope === 'windows') {
28
+ selected = windows;
29
+ } else if (scope === 'targeted') {
30
+ selected = targeted;
31
+ } else {
32
+ throw new ExitError(1, `::error::Unknown test scope: ${scope}`);
33
+ }
34
+
35
+ // Trim and fall back to default set if empty.
36
+ if (!selected.trim()) {
37
+ selected = FALLBACK;
38
+ }
39
+
40
+ // Split on whitespace, filter blanks, join with newlines.
41
+ const lines = selected.split(/\s+/).filter(Boolean);
42
+ const content = lines.join('\n') + '\n';
43
+
44
+ const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
45
+ fs.writeFileSync(outPath, content, 'utf-8');
46
+
47
+ process.stdout.write('Scoped tests:\n');
48
+ process.stdout.write(content);
36
49
  }
37
50
 
38
- // Split on whitespace, filter blanks, join with newlines.
39
- const lines = selected.split(/\s+/).filter(Boolean);
40
- const content = lines.join('\n') + '\n';
41
-
42
- const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
43
- fs.writeFileSync(outPath, content, 'utf-8');
44
-
45
- process.stdout.write('Scoped tests:\n');
46
- process.stdout.write(content);
51
+ runMain(main);
@@ -11,8 +11,9 @@
11
11
  // Exit 0 = merged cleanly (or merge was a no-op).
12
12
  // Exit 1 = merge conflict or fetch failure.
13
13
 
14
- const { execFileSync, execSync } = require('child_process');
15
- const path = require('path');
14
+ const { execFileSync } = require('child_process');
15
+
16
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
16
17
 
17
18
  function run(cmd, args, opts) {
18
19
  try {
@@ -27,8 +28,7 @@ function runOrThrow(cmd, args, label) {
27
28
  try {
28
29
  execFileSync(cmd, args, { stdio: 'inherit' });
29
30
  } catch (e) {
30
- process.stderr.write(`::error::${label} failed\n`);
31
- process.exit(1);
31
+ throw new ExitError(1, `::error::${label} failed`);
32
32
  }
33
33
  }
34
34
 
@@ -36,50 +36,51 @@ const token = process.env.GITHUB_TOKEN || '';
36
36
  const baseBranch = process.env.GITHUB_BASE_REF || 'main';
37
37
  const repo = process.env.GITHUB_REPOSITORY || '';
38
38
 
39
- // Configure git identity (needed for merge commit).
40
- runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
41
- runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
42
-
43
- // Set authenticated remote URL.
44
- if (token && repo) {
45
- runOrThrow(
46
- 'git',
47
- ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
48
- 'git remote set-url'
49
- );
50
- }
39
+ function main() {
40
+ // Configure git identity (needed for merge commit).
41
+ runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
42
+ runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
51
43
 
52
- // Fetch base branch with retry.
53
- let fetched = false;
54
- for (let attempt = 1; attempt <= 3; attempt++) {
55
- const result = run('git', ['fetch', 'origin', baseBranch]);
56
- if (result) {
57
- fetched = true;
58
- break;
44
+ // Set authenticated remote URL.
45
+ if (token && repo) {
46
+ runOrThrow(
47
+ 'git',
48
+ ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
49
+ 'git remote set-url'
50
+ );
59
51
  }
60
- if (attempt === 3) {
61
- process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`);
62
- process.exit(1);
52
+
53
+ // Fetch base branch with retry.
54
+ for (let attempt = 1; attempt <= 3; attempt++) {
55
+ const result = run('git', ['fetch', 'origin', baseBranch]);
56
+ if (result) {
57
+ break;
58
+ }
59
+ if (attempt === 3) {
60
+ throw new ExitError(1, `::error::git fetch origin ${baseBranch} failed after 3 attempts.`);
61
+ }
62
+ // Wait before retry: attempt * 4 seconds.
63
+ const waitMs = attempt * 4000;
64
+ const deadline = Date.now() + waitMs;
65
+ while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
63
66
  }
64
- // Wait before retry: attempt * 4 seconds.
65
- const waitMs = attempt * 4000;
66
- const deadline = Date.now() + waitMs;
67
- while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
68
- }
69
67
 
70
- // Attempt merge.
71
- try {
72
- execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
73
- } catch (e) {
74
- process.stderr.write(
75
- `::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
76
- );
77
- process.stderr.write('::error::Conflicting files:\n');
68
+ // Attempt merge.
78
69
  try {
79
- execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
80
- } catch (_) { /* ignore */ }
81
- try {
82
- execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
83
- } catch (_) { /* ignore */ }
84
- process.exit(1);
70
+ execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
71
+ } catch (e) {
72
+ process.stderr.write(
73
+ `::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
74
+ );
75
+ process.stderr.write('::error::Conflicting files:\n');
76
+ try {
77
+ execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
78
+ } catch (_) { /* ignore */ }
79
+ try {
80
+ execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
81
+ } catch (_) { /* ignore */ }
82
+ throw new ExitError(1);
83
+ }
85
84
  }
85
+
86
+ runMain(main);
@@ -3,7 +3,8 @@
3
3
 
4
4
  const { execFileSync } = require('child_process');
5
5
  const { existsSync, readdirSync, appendFileSync } = require('fs');
6
- const { join } = require('path');
6
+
7
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
7
8
 
8
9
  const RULES = [
9
10
  {
@@ -198,7 +199,7 @@ function parseArgs(argv) {
198
199
  if (!out.files) throw new Error('--files requires a value');
199
200
  } else if (arg === '--help' || arg === '-h') {
200
201
  console.log(usage());
201
- process.exit(0);
202
+ throw new ExitError(0);
202
203
  } else {
203
204
  throw new Error(`unknown argument: ${arg}`);
204
205
  }
@@ -318,10 +319,11 @@ function main() {
318
319
  writeOutputs(result);
319
320
  console.log(JSON.stringify(result, null, 2));
320
321
  } catch (error) {
322
+ if (error instanceof ExitError) throw error;
321
323
  console.error(`ci-test-scope: ${error.message}`);
322
324
  console.error(usage());
323
- process.exit(2);
325
+ throw new ExitError(2);
324
326
  }
325
327
  }
326
328
 
327
- main();
329
+ runMain(main);
@@ -43,6 +43,7 @@
43
43
 
44
44
  const fs = require('node:fs');
45
45
  const path = require('node:path');
46
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
46
47
 
47
48
  const EXIT_SHIPPED = 0;
48
49
  const EXIT_NOT_SHIPPED = 1;
@@ -88,60 +89,67 @@ function isPushBlocking(diffPath) {
88
89
  return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/');
89
90
  }
90
91
 
91
- function fail(message, err) {
92
- process.stderr.write(`diff-touches-shipped-paths: ${message}\n`);
93
- if (err && err.stack) process.stderr.write(`${err.stack}\n`);
94
- process.exit(EXIT_ERROR);
92
+ function readStdin() {
93
+ return new Promise((resolve, reject) => {
94
+ let data = '';
95
+ process.stdin.setEncoding('utf8');
96
+ process.stdin.on('data', (chunk) => { data += chunk; });
97
+ process.stdin.on('end', () => resolve(data));
98
+ process.stdin.on('error', reject);
99
+ });
95
100
  }
96
101
 
97
- function main() {
98
- // Surface ANY uncaught failure as exit 2 (classifier error) rather
99
- // than letting Node's default-1 shadow the legitimate
100
- // "no shipped paths" result. Bug #2983.
101
- process.on('uncaughtException', (err) => fail('uncaught exception', err));
102
- process.on('unhandledRejection', (err) => fail('unhandled rejection', err));
103
-
104
- let shipPrefixes;
102
+ async function main() {
105
103
  try {
106
- const pkgPath = path.resolve(process.cwd(), 'package.json');
107
- shipPrefixes = loadShipPrefixes(pkgPath);
108
- } catch (err) {
109
- return fail(`failed to read package.json from ${process.cwd()}`, err);
110
- }
104
+ let shipPrefixes;
105
+ try {
106
+ const pkgPath = path.resolve(process.cwd(), 'package.json');
107
+ shipPrefixes = loadShipPrefixes(pkgPath);
108
+ } catch (err) {
109
+ process.stderr.write(`diff-touches-shipped-paths: failed to read package.json from ${process.cwd()}\n`);
110
+ if (err && err.stack) process.stderr.write(`${err.stack}\n`);
111
+ throw new ExitError(EXIT_ERROR);
112
+ }
111
113
 
112
- let buf = '';
113
- process.stdin.setEncoding('utf8');
114
- process.stdin.on('error', (err) => fail('stdin read error', err));
115
- process.stdin.on('data', (chunk) => {
116
- buf += chunk;
117
- });
118
- process.stdin.on('end', () => {
114
+ let buf;
119
115
  try {
120
- const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
121
- // #2980 still wins over #3621: any commit touching .github/workflows/*
122
- // is unpickable regardless of other content because the push step
123
- // fails on workflow scope rejection. Check this first.
124
- if (paths.some(isPushBlocking)) {
125
- process.exit(EXIT_NOT_SHIPPED);
126
- }
127
- if (paths.some((p) => isShipped(p, shipPrefixes))) {
128
- process.exit(EXIT_SHIPPED);
129
- }
130
- // #3621: a commit whose only relevant paths are CI-gating tests is
131
- // still pickable — it can change whether the hotfix CI passes even
132
- // though it doesn't change what the npm tarball ships.
133
- if (paths.some(isCiGating)) {
134
- process.exit(EXIT_SHIPPED);
135
- }
136
- process.exit(EXIT_NOT_SHIPPED);
116
+ buf = await readStdin();
137
117
  } catch (err) {
138
- fail('classification failed', err);
118
+ process.stderr.write(`diff-touches-shipped-paths: stdin read error\n`);
119
+ if (err && err.stack) process.stderr.write(`${err.stack}\n`);
120
+ throw new ExitError(EXIT_ERROR);
139
121
  }
140
- });
122
+
123
+ const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
124
+ // #2980 still wins over #3621: any commit touching .github/workflows/*
125
+ // is unpickable regardless of other content because the push step
126
+ // fails on workflow scope rejection. Check this first.
127
+ if (paths.some(isPushBlocking)) {
128
+ return EXIT_NOT_SHIPPED;
129
+ }
130
+ if (paths.some((p) => isShipped(p, shipPrefixes))) {
131
+ return EXIT_SHIPPED;
132
+ }
133
+ // #3621: a commit whose only relevant paths are CI-gating tests is
134
+ // still pickable — it can change whether the hotfix CI passes even
135
+ // though it doesn't change what the npm tarball ships.
136
+ if (paths.some(isCiGating)) {
137
+ return EXIT_SHIPPED;
138
+ }
139
+ return EXIT_NOT_SHIPPED;
140
+ } catch (e) {
141
+ // Re-throw ExitError unchanged; map any unexpected error to EXIT_ERROR=2
142
+ // (Node's default uncaught-exception code is 1, which is indistinguishable
143
+ // from the legitimate EXIT_NOT_SHIPPED result — bug #2983).
144
+ if (e instanceof ExitError) throw e;
145
+ process.stderr.write(`diff-touches-shipped-paths: classification failed\n`);
146
+ if (e && e.stack) process.stderr.write(`${e.stack}\n`);
147
+ throw new ExitError(EXIT_ERROR);
148
+ }
141
149
  }
142
150
 
143
151
  if (require.main === module) {
144
- main();
152
+ runMain(main);
145
153
  }
146
154
 
147
155
  module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR };