forge-workflow 0.0.9 → 0.0.10

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 (102) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/ship.md +2 -2
  4. package/.claude/commands/status.md +4 -4
  5. package/.cline/workflows/dev.md +2 -2
  6. package/.cline/workflows/plan.md +2 -2
  7. package/.cline/workflows/ship.md +2 -2
  8. package/.cline/workflows/status.md +4 -4
  9. package/.codex/skills/dev/SKILL.md +2 -2
  10. package/.codex/skills/plan/SKILL.md +2 -2
  11. package/.codex/skills/ship/SKILL.md +2 -2
  12. package/.codex/skills/status/SKILL.md +4 -4
  13. package/.cursor/commands/dev.md +2 -2
  14. package/.cursor/commands/plan.md +2 -2
  15. package/.cursor/commands/ship.md +2 -2
  16. package/.cursor/commands/status.md +4 -4
  17. package/.github/prompts/dev.prompt.md +2 -2
  18. package/.github/prompts/plan.prompt.md +2 -2
  19. package/.github/prompts/ship.prompt.md +2 -2
  20. package/.github/prompts/status.prompt.md +4 -4
  21. package/.github/workflows/beads-to-github.yml +43 -10
  22. package/.github/workflows/github-to-beads.yml +10 -7
  23. package/.kilocode/workflows/dev.md +2 -2
  24. package/.kilocode/workflows/plan.md +2 -2
  25. package/.kilocode/workflows/ship.md +2 -2
  26. package/.kilocode/workflows/status.md +4 -4
  27. package/.opencode/commands/dev.md +2 -2
  28. package/.opencode/commands/plan.md +2 -2
  29. package/.opencode/commands/ship.md +2 -2
  30. package/.opencode/commands/status.md +4 -4
  31. package/.roo/commands/dev.md +2 -2
  32. package/.roo/commands/plan.md +2 -2
  33. package/.roo/commands/ship.md +2 -2
  34. package/.roo/commands/status.md +4 -4
  35. package/AGENTS.md +1 -0
  36. package/CLAUDE.md +12 -0
  37. package/bin/forge.js +10 -5
  38. package/docs/BEADS_GITHUB_SYNC.md +26 -0
  39. package/docs/TOOLCHAIN.md +130 -148
  40. package/lib/beads-bootstrap.js +225 -0
  41. package/lib/beads-health-check.js +55 -10
  42. package/lib/beads-setup.js +104 -28
  43. package/lib/beads-sync-scaffold.js +11 -6
  44. package/lib/commands/_issue.js +11 -1
  45. package/lib/commands/issues.js +49 -0
  46. package/lib/commands/recommend.js +22 -1
  47. package/lib/commands/setup.js +16 -10
  48. package/lib/commands/status.js +181 -0
  49. package/lib/commands/team.js +11 -1
  50. package/lib/commands/test.js +37 -2
  51. package/lib/commands/validate.js +14 -8
  52. package/lib/commands/worktree.js +27 -54
  53. package/lib/dep-guard/keyword-ripple.js +184 -0
  54. package/lib/detect-worktree.js +9 -10
  55. package/lib/forge-issues.js +326 -0
  56. package/lib/issue-sync/authority.js +100 -0
  57. package/lib/issue-sync/github-pull.js +184 -0
  58. package/lib/issue-sync/import-primitives.js +98 -0
  59. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  60. package/lib/issue-sync/link-store.js +292 -0
  61. package/lib/issue-sync/project-github.js +123 -0
  62. package/lib/issue-sync/reconcile.js +195 -0
  63. package/lib/issue-sync/schema.js +126 -0
  64. package/lib/lefthook-check.js +5 -2
  65. package/lib/project-memory.js +564 -0
  66. package/lib/runtime-health.js +100 -12
  67. package/lib/smart-status/conflicts.js +205 -0
  68. package/lib/smart-status/scoring.js +177 -0
  69. package/lib/status/beads-snapshot.js +102 -0
  70. package/lib/status/presenter.js +65 -0
  71. package/lib/workflow/enforce-stage.js +3 -1
  72. package/lib/workflow/state-manager.js +164 -8
  73. package/package.json +12 -4
  74. package/scripts/beads-context.sh +124 -5
  75. package/scripts/beads-context.test.js +21 -4
  76. package/scripts/beads-migrate-to-dolt.sh +7 -0
  77. package/scripts/beads-upgrade-smoke.sh +263 -0
  78. package/scripts/behavioral-judge.sh +115 -11
  79. package/scripts/benchmark.js +349 -63
  80. package/scripts/dep-guard-analyze.js +52 -17
  81. package/scripts/dep-guard-keyword-ripple.js +29 -0
  82. package/scripts/dep-guard-render-review.js +86 -0
  83. package/scripts/dep-guard.sh +45 -232
  84. package/scripts/forge-team/lib/sync-github.sh +160 -28
  85. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  86. package/scripts/github-beads-sync/index.mjs +122 -98
  87. package/scripts/github-beads-sync/mapping.mjs +54 -0
  88. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  89. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  90. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  91. package/scripts/preflight.sh +181 -0
  92. package/scripts/smart-status-score.js +31 -0
  93. package/scripts/smart-status-sessions.js +51 -0
  94. package/scripts/smart-status.sh +74 -329
  95. package/scripts/sync-agentic-workflow.js +48 -0
  96. package/scripts/test-ci-shard.js +244 -0
  97. package/scripts/test-dashboard.js +188 -52
  98. package/scripts/test-full-suite.js +186 -0
  99. package/scripts/test-profile.js +278 -0
  100. package/scripts/test.js +219 -28
  101. package/scripts/validate.js +143 -0
  102. package/scripts/validate.sh +18 -1
package/scripts/test.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cross-platform test runner for the lefthook pre-push hook.
3
+ * Cross-platform test runner shared by the pre-push hook and local validation.
4
4
  *
5
- * Runs only the tests affected by the changes being pushed when possible.
6
- * Falls back to the full suite only for package-level changes.
5
+ * It runs only the tests affected by known changes when possible and falls back
6
+ * to the full suite for package-level or unknown-file changes.
7
7
  */
8
8
 
9
9
  const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = require('node:child_process');
@@ -22,8 +22,49 @@ const PACKAGE_LEVEL_PATHS = new Set([
22
22
  'package-lock.json',
23
23
  ]);
24
24
 
25
+ const KNOWN_TARGETABLE_PREFIXES = [
26
+ '.claude/commands/',
27
+ '.cursor/',
28
+ '.cline/',
29
+ '.roo/',
30
+ '.kilocode/',
31
+ '.opencode/',
32
+ '.codex/skills/',
33
+ '.forge/',
34
+ '.github/prompts/',
35
+ '.github/agentic-workflows/',
36
+ '.github/workflows/',
37
+ 'test/',
38
+ ];
39
+
40
+ const ALWAYS_RUN_RISK_TEST_TARGETS = [
41
+ // Windows + concurrent filesystem locking has failed post-merge; keep this
42
+ // in the fast PR lane until enough full-matrix runs prove it stable.
43
+ 'test/project-memory.test.js',
44
+ ];
45
+
25
46
  const isWindows = process.platform === 'win32';
26
47
 
48
+ /**
49
+ * @typedef {Object} TestExecutionPlan
50
+ * @property {string[]} changedFiles
51
+ * @property {boolean} hasUnmappedFiles
52
+ * @property {boolean} hasUnknownChangedFiles
53
+ * @property {boolean} hasZeroResolvedTests
54
+ * @property {'targeted'|'full'} mode
55
+ * @property {string} reason
56
+ * @property {boolean} runE2E
57
+ * @property {boolean} runFullSuite
58
+ * @property {boolean} runTestEnv
59
+ * @property {boolean} runWorkflowTests
60
+ * @property {string[]} testTargets
61
+ */
62
+
63
+ /**
64
+ * Detects the package manager to use for running test commands in this checkout.
65
+ *
66
+ * @returns {'bun'|'pnpm'|'yarn'|'npm'} The package manager inferred from lockfiles.
67
+ */
27
68
  function detectPackageManager() {
28
69
  if (fs.existsSync('bun.lockb') || fs.existsSync('bun.lock')) return 'bun';
29
70
  if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
@@ -31,6 +72,12 @@ function detectPackageManager() {
31
72
  return 'npm';
32
73
  }
33
74
 
75
+ /**
76
+ * Removes Git hook-only environment variables before spawning nested Git-aware commands.
77
+ *
78
+ * @param {NodeJS.ProcessEnv} [sourceEnv=process.env] Environment variables to sanitize.
79
+ * @returns {NodeJS.ProcessEnv} A copy of the environment without hook-specific Git variables.
80
+ */
34
81
  function stripGitHookEnv(sourceEnv = process.env) {
35
82
  const env = { ...sourceEnv };
36
83
  for (const key of Object.keys(env)) {
@@ -43,21 +90,78 @@ function stripGitHookEnv(sourceEnv = process.env) {
43
90
  return env;
44
91
  }
45
92
 
46
- function classifyPushTests(projectRoot, execFileSync = defaultExecFileSync) {
47
- const changedFiles = getChangedFiles(execFileSync, { sinceUpstream: true });
48
- const testTargets = getAffectedTestFiles(projectRoot, execFileSync, fs, { sinceUpstream: true });
93
+ /**
94
+ * Checks whether a changed path belongs to a known test-targetable area.
95
+ *
96
+ * @param {string} file Repository-relative changed file path.
97
+ * @returns {boolean} True when the path can be handled by targeted test selection.
98
+ */
99
+ function isKnownTargetablePath(file) {
100
+ if (file === '.gitignore') {
101
+ return true;
102
+ }
103
+
104
+ if (file.startsWith('docs/plans/')) {
105
+ return true;
106
+ }
107
+
108
+ return KNOWN_TARGETABLE_PREFIXES.some((prefix) => file.startsWith(prefix));
109
+ }
110
+
111
+ /**
112
+ * Determines whether the resolved test targets require workflow-specific validation.
113
+ *
114
+ * @param {string[]} testTargets Repository-relative test file paths.
115
+ * @returns {boolean} True when workflow tests are part of the target set.
116
+ */
117
+ function includesWorkflowTarget(testTargets) {
118
+ return testTargets.some((target) => target === 'test/ci-workflow.test.js'
119
+ || target === 'test/structural/agentic-workflow-sync.test.js'
120
+ || target.startsWith('test/workflows/'));
121
+ }
122
+
123
+ /**
124
+ * Deduplicates test targets while preserving the original execution order.
125
+ *
126
+ * @param {string[]} testTargets Repository-relative test file paths.
127
+ * @returns {string[]} Unique test targets in first-seen order.
128
+ */
129
+ function uniqueTestTargets(testTargets) {
130
+ return [...new Set(testTargets)];
131
+ }
132
+
133
+ function isExtraLaneOnlyPath(file) {
134
+ return file.startsWith('test/e2e/') || file.startsWith('test-env/');
135
+ }
136
+
137
+ /**
138
+ * Builds the execution plan used by PR, pre-push, and local validation test lanes.
139
+ *
140
+ * @param {string} projectRoot Absolute or relative repository root.
141
+ * @param {typeof defaultExecFileSync} [execFileSync=defaultExecFileSync] Command runner used to inspect Git state.
142
+ * @param {{sinceUpstream?: boolean}} [options={}] Test selection options.
143
+ * @returns {TestExecutionPlan} The computed test execution plan.
144
+ */
145
+ function buildTestExecutionPlan(projectRoot, execFileSync = defaultExecFileSync, options = {}) {
146
+ const diffOptions = {
147
+ sinceUpstream: options.sinceUpstream !== false,
148
+ };
149
+ const changedFiles = getChangedFiles(execFileSync, diffOptions);
150
+ const affectedTestTargets = getAffectedTestFiles(projectRoot, execFileSync, fs, diffOptions);
49
151
 
50
152
  let runFullSuite = false;
51
153
  let runTestEnv = false;
52
154
  let runE2E = false;
155
+ let runWorkflowTests = includesWorkflowTarget(affectedTestTargets);
53
156
  let hasUnmappedFiles = false;
54
- const hasUnknownChangedFiles = changedFiles.length === 0 && testTargets.length === 0;
157
+ const hasUnknownChangedFiles = changedFiles.length === 0 && affectedTestTargets.length === 0;
55
158
 
56
159
  for (const file of changedFiles) {
57
160
  if (PACKAGE_LEVEL_PATHS.has(file) || file.startsWith('packages/')) {
58
161
  runFullSuite = true;
59
162
  runTestEnv = true;
60
163
  runE2E = true;
164
+ runWorkflowTests = true;
61
165
  break;
62
166
  }
63
167
 
@@ -71,29 +175,80 @@ function classifyPushTests(projectRoot, execFileSync = defaultExecFileSync) {
71
175
  continue;
72
176
  }
73
177
 
74
- if ((file.startsWith('lib/') || file.startsWith('scripts/')) && file.endsWith('.js')) {
178
+ if ((file.startsWith('lib/') || file.startsWith('scripts/')) && (file.endsWith('.js') || file.endsWith('.sh'))) {
75
179
  runTestEnv = true;
180
+ if (file === 'scripts/behavioral-judge.sh') {
181
+ runWorkflowTests = true;
182
+ }
76
183
  continue;
77
184
  }
78
185
 
79
- if (file.startsWith('test/') || file.startsWith('.github/workflows/') || file.startsWith('.claude/commands/')) {
186
+ if (file.startsWith('.github/workflows/') || file.startsWith('.github/agentic-workflows/')) {
187
+ runWorkflowTests = true;
188
+ }
189
+
190
+ if (isKnownTargetablePath(file)) {
80
191
  continue;
81
192
  }
82
193
 
83
194
  hasUnmappedFiles = true;
84
195
  }
85
196
 
197
+ const hasOnlyExtraLaneChanges = changedFiles.length > 0
198
+ && changedFiles.every(isExtraLaneOnlyPath);
199
+ const hasZeroResolvedTests = changedFiles.length > 0
200
+ && affectedTestTargets.length === 0
201
+ && !runFullSuite
202
+ && !hasOnlyExtraLaneChanges;
203
+ const shouldRunFullSuite = runFullSuite || hasUnmappedFiles || hasUnknownChangedFiles || hasZeroResolvedTests;
204
+
205
+ const reason = hasUnmappedFiles
206
+ ? 'unmapped pushed files require full unit coverage'
207
+ : hasUnknownChangedFiles
208
+ ? 'changed files could not be resolved safely'
209
+ : hasZeroResolvedTests
210
+ ? 'known changes did not resolve runnable tests'
211
+ : runFullSuite
212
+ ? 'package-level changes detected'
213
+ : 'known changes mapped to targeted tests';
214
+
86
215
  return {
87
216
  changedFiles,
88
217
  hasUnmappedFiles,
89
218
  hasUnknownChangedFiles,
219
+ hasZeroResolvedTests,
220
+ mode: shouldRunFullSuite ? 'full' : 'targeted',
221
+ reason,
90
222
  runE2E,
91
- runFullSuite: runFullSuite || hasUnmappedFiles || hasUnknownChangedFiles,
223
+ runFullSuite: shouldRunFullSuite,
92
224
  runTestEnv,
93
- testTargets,
225
+ runWorkflowTests,
226
+ testTargets: shouldRunFullSuite
227
+ ? affectedTestTargets
228
+ : uniqueTestTargets([...affectedTestTargets, ...ALWAYS_RUN_RISK_TEST_TARGETS]),
94
229
  };
95
230
  }
96
231
 
232
+ /**
233
+ * Classifies the pushed changes into the test plan enforced by the pre-push hook.
234
+ *
235
+ * @param {string} projectRoot Absolute or relative repository root.
236
+ * @param {typeof defaultExecFileSync} [execFileSync=defaultExecFileSync] Command runner used to inspect Git state.
237
+ * @returns {TestExecutionPlan} The pre-push test execution plan.
238
+ */
239
+ function classifyPushTests(projectRoot, execFileSync = defaultExecFileSync) {
240
+ return buildTestExecutionPlan(projectRoot, execFileSync, { sinceUpstream: true });
241
+ }
242
+
243
+ /**
244
+ * Runs a command and returns its exit status, throwing only when process spawning fails.
245
+ *
246
+ * @param {string} command Executable name.
247
+ * @param {string[]} args Command arguments.
248
+ * @param {import('node:child_process').SpawnSyncOptions} [options={}] Spawn options.
249
+ * @param {typeof defaultSpawnSync} [spawnSync=defaultSpawnSync] Process runner.
250
+ * @returns {number} Process exit status, or 1 when no status is reported.
251
+ */
97
252
  function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
98
253
  const result = spawnSync(command, args, {
99
254
  stdio: 'inherit',
@@ -108,40 +263,43 @@ function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
108
263
  return result.status ?? 1;
109
264
  }
110
265
 
111
- function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
266
+ /**
267
+ * Executes a computed test plan and any extra targeted validation lanes.
268
+ *
269
+ * @param {TestExecutionPlan} plan Test plan to execute.
270
+ * @param {Object} [deps={}] Runtime dependencies for tests.
271
+ * @returns {number} Exit status for the executed plan.
272
+ */
273
+ function runTestExecutionPlan(plan, deps = {}) {
112
274
  const spawnSync = deps.spawnSync || defaultSpawnSync;
113
- const execFileSync = deps.execFileSync || defaultExecFileSync;
114
275
  const pkgManager = deps.pkgManager || detectPackageManager();
115
276
  const env = deps.env || stripGitHookEnv(process.env);
116
- const plan = classifyPushTests(projectRoot, execFileSync);
277
+ const bunCommand = deps.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
278
+ const label = deps.label || 'tests';
117
279
 
118
- console.log(`Running pre-push tests (${pkgManager})...`);
280
+ console.log(`Running ${label} (${pkgManager})...`);
119
281
 
120
282
  try {
121
283
  if (plan.runFullSuite) {
122
- const reason = plan.hasUnmappedFiles
123
- ? 'unmapped pushed files require full unit coverage'
124
- : plan.hasUnknownChangedFiles
125
- ? 'changed files could not be resolved safely'
126
- : 'package-level changes detected';
127
- console.log(` Mode: full suite (${reason})`);
128
- const status = runCommand(pkgManager, ['run', 'test'], { env }, spawnSync);
284
+ console.log(` Mode: full suite (${plan.reason})`);
285
+ const status = runCommand('node', ['scripts/test-full-suite.js'], { env }, spawnSync);
129
286
  if (status !== 0) return status;
130
287
  } else if (plan.testTargets.length > 0) {
131
288
  console.log(` Mode: targeted (${plan.testTargets.length} test file${plan.testTargets.length === 1 ? '' : 's'})`);
132
- const status = runCommand(pkgManager, ['run', 'test', ...plan.testTargets], { env }, spawnSync);
289
+ const command = pkgManager === 'bun' ? bunCommand : pkgManager;
290
+ const status = runCommand(command, ['run', 'test', ...plan.testTargets], { env }, spawnSync);
133
291
  if (status !== 0) return status;
134
292
  }
135
293
 
136
294
  if (plan.runE2E) {
137
295
  console.log(' Extra: running affected e2e tests');
138
- const status = runCommand('bun', ['test', 'test/e2e/'], { env }, spawnSync);
296
+ const status = runCommand(bunCommand, ['test', 'test/e2e/'], { env }, spawnSync);
139
297
  if (status !== 0) return status;
140
298
  }
141
299
 
142
- if (plan.runTestEnv) {
300
+ if (!plan.runFullSuite && plan.runTestEnv) {
143
301
  console.log(' Extra: running affected edge-case tests');
144
- const status = runCommand('bun', ['test', 'test-env/'], { env }, spawnSync);
302
+ const status = runCommand(bunCommand, ['test', 'test-env/'], { env }, spawnSync);
145
303
  if (status !== 0) return status;
146
304
  }
147
305
 
@@ -149,19 +307,52 @@ function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
149
307
  return 0;
150
308
  } catch (error) {
151
309
  console.error('');
152
- console.error(`Failed to run tests: ${error.message}`);
310
+ console.error(`Failed to run ${label}: ${error.message}`);
153
311
  console.error('');
154
312
  return 1;
155
313
  }
156
314
  }
157
315
 
316
+ /**
317
+ * Runs the pre-push test plan for the current checkout.
318
+ *
319
+ * @param {string} [projectRoot=process.cwd()] Repository root.
320
+ * @param {Object} [deps={}] Runtime dependencies for tests.
321
+ * @returns {number} Exit status for pre-push tests.
322
+ */
323
+ function runPrePushTests(projectRoot = process.cwd(), deps = {}) {
324
+ const execFileSync = deps.execFileSync || defaultExecFileSync;
325
+ const plan = classifyPushTests(projectRoot, execFileSync);
326
+ return runTestExecutionPlan(plan, { ...deps, label: 'pre-push tests' });
327
+ }
328
+
329
+ /**
330
+ * Runs the local validation test plan for the current checkout.
331
+ *
332
+ * @param {string} [projectRoot=process.cwd()] Repository root.
333
+ * @param {Object} [deps={}] Runtime dependencies for tests.
334
+ * @returns {number} Exit status for local validation tests.
335
+ */
336
+ function runLocalValidationTests(projectRoot = process.cwd(), deps = {}) {
337
+ const execFileSync = deps.execFileSync || defaultExecFileSync;
338
+ const plan = buildTestExecutionPlan(projectRoot, execFileSync, { sinceUpstream: true });
339
+ return runTestExecutionPlan(plan, { ...deps, label: 'local validation tests' });
340
+ }
341
+
158
342
  if (require.main === module) {
159
- process.exit(runPrePushTests());
343
+ const exitCode = process.argv.includes('--validate')
344
+ ? runLocalValidationTests()
345
+ : runPrePushTests();
346
+ process.exit(exitCode);
160
347
  }
161
348
 
162
349
  module.exports = {
350
+ ALWAYS_RUN_RISK_TEST_TARGETS,
351
+ buildTestExecutionPlan,
163
352
  classifyPushTests,
164
353
  detectPackageManager,
354
+ runLocalValidationTests,
165
355
  runPrePushTests,
356
+ runTestExecutionPlan,
166
357
  stripGitHookEnv,
167
358
  };
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('node:child_process');
5
+
6
+ const bunCommand = process.env.BUN_EXE || 'bun';
7
+
8
+ function color(code, text) {
9
+ return process.stdout.isTTY ? `\u001b[${code}m${text}\u001b[0m` : text;
10
+ }
11
+
12
+ function printHeader(title) {
13
+ const line = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
14
+ console.log('');
15
+ console.log(color('0;34', line));
16
+ console.log(color('0;34', `▶ ${title}`));
17
+ console.log(color('0;34', line));
18
+ }
19
+
20
+ function printStatus(kind, message) {
21
+ const palette = {
22
+ error: ['0;31', '✗'],
23
+ success: ['0;32', '✓'],
24
+ warning: ['1;33', '⚠'],
25
+ };
26
+ const [code, prefix] = palette[kind];
27
+ console.log(color(code, `${prefix} ${message}`));
28
+ }
29
+
30
+ function run(command, args, options = {}) {
31
+ const result = spawnSync(command, args, {
32
+ cwd: process.cwd(),
33
+ encoding: 'utf8',
34
+ stdio: options.captureOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit',
35
+ shell: false,
36
+ env: options.env || process.env,
37
+ });
38
+
39
+ if (result.error) {
40
+ throw result.error;
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ function combinedOutput(result) {
47
+ return [result.stdout, result.stderr].filter(Boolean).join('');
48
+ }
49
+
50
+ function hasBlockingAuditSeverity(output = '') {
51
+ if (typeof output !== 'string' || output.length === 0) {
52
+ return false;
53
+ }
54
+
55
+ return [
56
+ /\b(?:critical|high)\s+severity\b/i,
57
+ /\bseverity\s*:?\s*(?:critical|high)\b/i,
58
+ ].some((pattern) => pattern.test(output));
59
+ }
60
+
61
+ function main(options = {}) {
62
+ const runCommand = options.runCommand ?? run;
63
+ const activeBunCommand = options.bunCommand ?? bunCommand;
64
+ console.log('');
65
+ console.log(color('0;34', '╔═══════════════════════════════════════════╗'));
66
+ console.log(color('0;34', '║ Forge Quality Gate - Running Checks ║'));
67
+ console.log(color('0;34', '╚═══════════════════════════════════════════╝'));
68
+
69
+ printHeader('1/4: Type Check');
70
+ const typecheck = runCommand(activeBunCommand, ['run', 'typecheck'], { captureOutput: true });
71
+ const typecheckOutput = combinedOutput(typecheck);
72
+ if (typecheckOutput) {
73
+ process.stdout.write(typecheckOutput);
74
+ if (!typecheckOutput.endsWith('\n')) {
75
+ process.stdout.write('\n');
76
+ }
77
+ }
78
+ if ((typecheck.status ?? 1) !== 0) {
79
+ printStatus('error', 'Type check failed');
80
+ return 1;
81
+ }
82
+ if (/no typescript in project|skipping type check/i.test(typecheckOutput)) {
83
+ printStatus('warning', 'SKIPPED (no TypeScript in project)');
84
+ } else {
85
+ printStatus('success', 'Type check passed');
86
+ }
87
+
88
+ printHeader('2/4: Lint');
89
+ const lint = runCommand(activeBunCommand, ['run', 'lint']);
90
+ if ((lint.status ?? 1) !== 0) {
91
+ printStatus('error', 'Lint failed');
92
+ return 1;
93
+ }
94
+ printStatus('success', 'Lint passed');
95
+
96
+ printHeader('3/4: Security Audit');
97
+ const audit = runCommand(activeBunCommand, ['audit'], { captureOutput: true });
98
+ const auditOutput = combinedOutput(audit);
99
+ if (auditOutput) {
100
+ process.stdout.write(auditOutput);
101
+ if (!auditOutput.endsWith('\n')) {
102
+ process.stdout.write('\n');
103
+ }
104
+ }
105
+ if (hasBlockingAuditSeverity(auditOutput)) {
106
+ printStatus('error', 'Security audit found critical/high vulnerabilities');
107
+ return 1;
108
+ }
109
+ if ((audit.status ?? 1) === 0) {
110
+ printStatus('success', 'Security audit passed');
111
+ } else {
112
+ printStatus('warning', 'Security audit found issues (moderate/low — non-blocking)');
113
+ }
114
+
115
+ printHeader('4/4: Tests');
116
+ const tests = runCommand('node', ['scripts/test.js', '--validate']);
117
+ if ((tests.status ?? 1) !== 0) {
118
+ printStatus('error', 'Tests failed');
119
+ return 1;
120
+ }
121
+ printStatus('success', 'All tests passed');
122
+
123
+ console.log('');
124
+ console.log(color('0;32', '╔═══════════════════════════════════════════╗'));
125
+ console.log(color('0;32', '║ ✓ All Checks Passed Successfully ║'));
126
+ console.log(color('0;32', '╚═══════════════════════════════════════════╝'));
127
+ console.log('');
128
+ return 0;
129
+ }
130
+
131
+ if (require.main === module) {
132
+ process.exit(main());
133
+ }
134
+
135
+ module.exports = {
136
+ color,
137
+ combinedOutput,
138
+ hasBlockingAuditSeverity,
139
+ main,
140
+ printHeader,
141
+ printStatus,
142
+ run,
143
+ };
@@ -44,6 +44,23 @@ print_warning() {
44
44
  echo -e "${YELLOW}⚠ $1${NC}"
45
45
  }
46
46
 
47
+ run_validate_tests() {
48
+ if [[ "${OS:-}" == "Windows_NT" && ( -n "${MSYSTEM:-}" || "${OSTYPE:-}" == msys* || "${OSTYPE:-}" == cygwin* ) ]]; then
49
+ local bun_exe
50
+ bun_exe="$(where.exe bun 2>/dev/null | tr -d '\r' | grep -i 'bun\.exe$' | head -n 1)"
51
+
52
+ if [[ -n "$bun_exe" ]]; then
53
+ cmd.exe /d /c "set \"BUN_EXE=$bun_exe\" && node scripts\\test.js --validate"
54
+ return $?
55
+ fi
56
+
57
+ cmd.exe /d /c "node scripts\\test.js --validate"
58
+ return $?
59
+ fi
60
+
61
+ node scripts/test.js --validate
62
+ }
63
+
47
64
  echo ""
48
65
  echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}"
49
66
  echo -e "${BLUE}║ Forge Quality Gate - Running Checks ║${NC}"
@@ -78,7 +95,7 @@ fi
78
95
 
79
96
  # Step 4: Tests
80
97
  print_header "4/4: Tests"
81
- if bun test; then
98
+ if run_validate_tests; then
82
99
  print_success "All tests passed"
83
100
  else
84
101
  print_error "Tests failed"