forge-workflow 0.0.8 → 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 (135) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/premerge.md +2 -2
  4. package/.claude/commands/review.md +5 -2
  5. package/.claude/commands/ship.md +6 -5
  6. package/.claude/commands/status.md +4 -4
  7. package/.claude/rules/greptile-review-process.md +4 -4
  8. package/.cline/workflows/dev.md +2 -2
  9. package/.cline/workflows/plan.md +2 -2
  10. package/.cline/workflows/premerge.md +2 -2
  11. package/.cline/workflows/review.md +5 -2
  12. package/.cline/workflows/ship.md +6 -5
  13. package/.cline/workflows/status.md +4 -4
  14. package/.codex/skills/dev/SKILL.md +2 -2
  15. package/.codex/skills/plan/SKILL.md +2 -2
  16. package/.codex/skills/premerge/SKILL.md +2 -2
  17. package/.codex/skills/review/SKILL.md +5 -2
  18. package/.codex/skills/ship/SKILL.md +6 -5
  19. package/.codex/skills/status/SKILL.md +4 -4
  20. package/.cursor/commands/dev.md +2 -2
  21. package/.cursor/commands/plan.md +2 -2
  22. package/.cursor/commands/premerge.md +2 -2
  23. package/.cursor/commands/review.md +5 -2
  24. package/.cursor/commands/ship.md +6 -5
  25. package/.cursor/commands/status.md +4 -4
  26. package/.github/prompts/dev.prompt.md +2 -2
  27. package/.github/prompts/plan.prompt.md +2 -2
  28. package/.github/prompts/premerge.prompt.md +2 -2
  29. package/.github/prompts/review.prompt.md +5 -2
  30. package/.github/prompts/ship.prompt.md +6 -5
  31. package/.github/prompts/status.prompt.md +4 -4
  32. package/.github/workflows/beads-to-github.yml +44 -11
  33. package/.github/workflows/github-to-beads.yml +11 -8
  34. package/.kilocode/workflows/dev.md +2 -2
  35. package/.kilocode/workflows/plan.md +2 -2
  36. package/.kilocode/workflows/premerge.md +2 -2
  37. package/.kilocode/workflows/review.md +5 -2
  38. package/.kilocode/workflows/ship.md +6 -5
  39. package/.kilocode/workflows/status.md +4 -4
  40. package/.opencode/commands/dev.md +2 -2
  41. package/.opencode/commands/plan.md +2 -2
  42. package/.opencode/commands/premerge.md +2 -2
  43. package/.opencode/commands/review.md +5 -2
  44. package/.opencode/commands/ship.md +6 -5
  45. package/.opencode/commands/status.md +4 -4
  46. package/.roo/commands/dev.md +2 -2
  47. package/.roo/commands/plan.md +2 -2
  48. package/.roo/commands/premerge.md +2 -2
  49. package/.roo/commands/review.md +5 -2
  50. package/.roo/commands/ship.md +6 -5
  51. package/.roo/commands/status.md +4 -4
  52. package/AGENTS.md +10 -9
  53. package/CLAUDE.md +12 -0
  54. package/README.md +12 -6
  55. package/bin/forge.js +24 -8
  56. package/docs/BEADS_GITHUB_SYNC.md +32 -2
  57. package/docs/EXAMPLES.md +22 -22
  58. package/docs/ROADMAP.md +3 -3
  59. package/docs/TOOLCHAIN.md +150 -160
  60. package/lib/agents/codex.plugin.json +3 -0
  61. package/lib/agents-config.js +18 -12
  62. package/lib/beads-bootstrap.js +225 -0
  63. package/lib/beads-health-check.js +55 -10
  64. package/lib/beads-setup.js +104 -28
  65. package/lib/beads-sync-scaffold.js +11 -6
  66. package/lib/codex-skills.js +54 -1
  67. package/lib/commands/_issue.js +11 -1
  68. package/lib/commands/issues.js +49 -0
  69. package/lib/commands/plan.js +5 -2
  70. package/lib/commands/recommend.js +22 -1
  71. package/lib/commands/setup.js +247 -27
  72. package/lib/commands/ship.js +188 -5
  73. package/lib/commands/status.js +187 -19
  74. package/lib/commands/team.js +11 -1
  75. package/lib/commands/test.js +125 -25
  76. package/lib/commands/validate.js +232 -9
  77. package/lib/commands/worktree.js +27 -54
  78. package/lib/dep-guard/keyword-ripple.js +184 -0
  79. package/lib/detect-worktree.js +9 -10
  80. package/lib/forge-issues.js +326 -0
  81. package/lib/issue-sync/authority.js +100 -0
  82. package/lib/issue-sync/github-pull.js +184 -0
  83. package/lib/issue-sync/import-primitives.js +98 -0
  84. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  85. package/lib/issue-sync/link-store.js +292 -0
  86. package/lib/issue-sync/project-github.js +123 -0
  87. package/lib/issue-sync/reconcile.js +195 -0
  88. package/lib/issue-sync/schema.js +126 -0
  89. package/lib/lefthook-check.js +5 -2
  90. package/lib/project-memory.js +564 -0
  91. package/lib/runtime-health.js +100 -12
  92. package/lib/setup-action-log.js +2 -0
  93. package/lib/setup-summary-renderer.js +15 -11
  94. package/lib/smart-status/conflicts.js +205 -0
  95. package/lib/smart-status/scoring.js +177 -0
  96. package/lib/status/beads-snapshot.js +102 -0
  97. package/lib/status/presenter.js +65 -0
  98. package/lib/workflow/enforce-stage.js +14 -8
  99. package/lib/workflow/state-manager.js +349 -0
  100. package/package.json +12 -4
  101. package/scripts/beads-context.sh +124 -5
  102. package/scripts/beads-context.test.js +21 -4
  103. package/scripts/beads-migrate-to-dolt.sh +7 -0
  104. package/scripts/beads-upgrade-smoke.sh +263 -0
  105. package/scripts/behavioral-judge.sh +115 -11
  106. package/scripts/benchmark.js +349 -63
  107. package/scripts/dep-guard-analyze.js +52 -17
  108. package/scripts/dep-guard-keyword-ripple.js +29 -0
  109. package/scripts/dep-guard-render-review.js +86 -0
  110. package/scripts/dep-guard.sh +45 -222
  111. package/scripts/forge-team/lib/hooks.sh +1 -1
  112. package/scripts/forge-team/lib/sync-github.sh +160 -28
  113. package/scripts/forge-team/lib/verify.sh +1 -1
  114. package/scripts/forge-team/lib/workload.sh +56 -27
  115. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  116. package/scripts/forge-team/tests/workload.test.sh +35 -4
  117. package/scripts/github-beads-sync/index.mjs +122 -98
  118. package/scripts/github-beads-sync/mapping.mjs +54 -0
  119. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  120. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  121. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  122. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  123. package/scripts/preflight.sh +181 -0
  124. package/scripts/smart-status-score.js +31 -0
  125. package/scripts/smart-status-sessions.js +51 -0
  126. package/scripts/smart-status.sh +84 -330
  127. package/scripts/sync-agentic-workflow.js +48 -0
  128. package/scripts/sync-utils.sh +39 -0
  129. package/scripts/test-ci-shard.js +244 -0
  130. package/scripts/test-dashboard.js +188 -52
  131. package/scripts/test-full-suite.js +186 -0
  132. package/scripts/test-profile.js +278 -0
  133. package/scripts/test.js +335 -38
  134. package/scripts/validate.js +143 -0
  135. package/scripts/validate.sh +18 -1
package/scripts/test.js CHANGED
@@ -1,17 +1,70 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Cross-platform test runner for lefthook pre-push hook.
4
- * Replaces bash-only: bun/pnpm/yarn/npm test detection with if/elif.
5
- * Works on Windows CMD, PowerShell, macOS, Linux.
3
+ * Cross-platform test runner shared by the pre-push hook and local validation.
4
+ *
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.
6
7
  */
7
8
 
8
- const { spawnSync } = require('node:child_process');
9
+ const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = require('node:child_process');
9
10
  const fs = require('node:fs');
10
11
 
11
- // On Windows, package manager CLIs are .cmd files — shell: true resolves them
12
+ const {
13
+ getAffectedTestFiles,
14
+ getChangedFiles,
15
+ } = require('../lib/commands/test');
16
+
17
+ const PACKAGE_LEVEL_PATHS = new Set([
18
+ 'package.json',
19
+ 'bun.lockb',
20
+ 'pnpm-lock.yaml',
21
+ 'yarn.lock',
22
+ 'package-lock.json',
23
+ ]);
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
+
12
46
  const isWindows = process.platform === 'win32';
13
47
 
14
- // Detect package manager from lock files (same priority as forge.js)
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
+ */
15
68
  function detectPackageManager() {
16
69
  if (fs.existsSync('bun.lockb') || fs.existsSync('bun.lock')) return 'bun';
17
70
  if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
@@ -19,43 +72,287 @@ function detectPackageManager() {
19
72
  return 'npm';
20
73
  }
21
74
 
22
- const pkgManager = detectPackageManager();
23
- console.log(`🧪 Running test suite (${pkgManager} test)...`);
24
-
25
- // Strip git hook environment variables so child processes (especially tests
26
- // that create temp git repos) never accidentally operate on the real worktree.
27
- // During pre-push hooks, git sets GIT_DIR pointing to the repo — any test that
28
- // runs `git init` / `git commit` in a temp dir inherits this and silently
29
- // commits into the worktree instead, creating rogue "initial commit" that
30
- // deletes the entire codebase.
31
- const env = { ...process.env };
32
- for (const key of Object.keys(env)) {
33
- if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
34
- || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
35
- || key === 'GIT_QUARANTINE_PATH') {
36
- delete env[key];
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
+ */
81
+ function stripGitHookEnv(sourceEnv = process.env) {
82
+ const env = { ...sourceEnv };
83
+ for (const key of Object.keys(env)) {
84
+ if (key === 'GIT_DIR' || key === 'GIT_WORK_TREE' || key === 'GIT_INDEX_FILE'
85
+ || key === 'GIT_OBJECT_DIRECTORY' || key === 'GIT_ALTERNATE_OBJECT_DIRECTORIES'
86
+ || key === 'GIT_QUARANTINE_PATH') {
87
+ delete env[key];
88
+ }
89
+ }
90
+ return env;
91
+ }
92
+
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;
37
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)];
38
131
  }
39
132
 
40
- // Use 'run test' to invoke the package.json script (which may include --timeout flags)
41
- // 'bun test' is a built-in that ignores package.json scripts
42
- const result = spawnSync(pkgManager, ['run', 'test'], { stdio: 'inherit', shell: isWindows, env });
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);
151
+
152
+ let runFullSuite = false;
153
+ let runTestEnv = false;
154
+ let runE2E = false;
155
+ let runWorkflowTests = includesWorkflowTarget(affectedTestTargets);
156
+ let hasUnmappedFiles = false;
157
+ const hasUnknownChangedFiles = changedFiles.length === 0 && affectedTestTargets.length === 0;
158
+
159
+ for (const file of changedFiles) {
160
+ if (PACKAGE_LEVEL_PATHS.has(file) || file.startsWith('packages/')) {
161
+ runFullSuite = true;
162
+ runTestEnv = true;
163
+ runE2E = true;
164
+ runWorkflowTests = true;
165
+ break;
166
+ }
167
+
168
+ if (file.startsWith('test-env/')) {
169
+ runTestEnv = true;
170
+ continue;
171
+ }
172
+
173
+ if (file.startsWith('test/e2e/')) {
174
+ runE2E = true;
175
+ continue;
176
+ }
177
+
178
+ if ((file.startsWith('lib/') || file.startsWith('scripts/')) && (file.endsWith('.js') || file.endsWith('.sh'))) {
179
+ runTestEnv = true;
180
+ if (file === 'scripts/behavioral-judge.sh') {
181
+ runWorkflowTests = true;
182
+ }
183
+ continue;
184
+ }
43
185
 
44
- if (result.error) {
45
- console.error('');
46
- console.error(`❌ Failed to run ${pkgManager} test: ${result.error.message}`);
47
- console.error(` Is '${pkgManager}' installed and on PATH?`);
48
- console.error('');
49
- process.exit(1);
186
+ if (file.startsWith('.github/workflows/') || file.startsWith('.github/agentic-workflows/')) {
187
+ runWorkflowTests = true;
188
+ }
189
+
190
+ if (isKnownTargetablePath(file)) {
191
+ continue;
192
+ }
193
+
194
+ hasUnmappedFiles = true;
195
+ }
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
+
215
+ return {
216
+ changedFiles,
217
+ hasUnmappedFiles,
218
+ hasUnknownChangedFiles,
219
+ hasZeroResolvedTests,
220
+ mode: shouldRunFullSuite ? 'full' : 'targeted',
221
+ reason,
222
+ runE2E,
223
+ runFullSuite: shouldRunFullSuite,
224
+ runTestEnv,
225
+ runWorkflowTests,
226
+ testTargets: shouldRunFullSuite
227
+ ? affectedTestTargets
228
+ : uniqueTestTargets([...affectedTestTargets, ...ALWAYS_RUN_RISK_TEST_TARGETS]),
229
+ };
230
+ }
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
+ */
252
+ function runCommand(command, args, options = {}, spawnSync = defaultSpawnSync) {
253
+ const result = spawnSync(command, args, {
254
+ stdio: 'inherit',
255
+ shell: isWindows,
256
+ ...options,
257
+ });
258
+
259
+ if (result.error) {
260
+ throw result.error;
261
+ }
262
+
263
+ return result.status ?? 1;
264
+ }
265
+
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 = {}) {
274
+ const spawnSync = deps.spawnSync || defaultSpawnSync;
275
+ const pkgManager = deps.pkgManager || detectPackageManager();
276
+ const env = deps.env || stripGitHookEnv(process.env);
277
+ const bunCommand = deps.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
278
+ const label = deps.label || 'tests';
279
+
280
+ console.log(`Running ${label} (${pkgManager})...`);
281
+
282
+ try {
283
+ if (plan.runFullSuite) {
284
+ console.log(` Mode: full suite (${plan.reason})`);
285
+ const status = runCommand('node', ['scripts/test-full-suite.js'], { env }, spawnSync);
286
+ if (status !== 0) return status;
287
+ } else if (plan.testTargets.length > 0) {
288
+ console.log(` Mode: targeted (${plan.testTargets.length} test file${plan.testTargets.length === 1 ? '' : 's'})`);
289
+ const command = pkgManager === 'bun' ? bunCommand : pkgManager;
290
+ const status = runCommand(command, ['run', 'test', ...plan.testTargets], { env }, spawnSync);
291
+ if (status !== 0) return status;
292
+ }
293
+
294
+ if (plan.runE2E) {
295
+ console.log(' Extra: running affected e2e tests');
296
+ const status = runCommand(bunCommand, ['test', 'test/e2e/'], { env }, spawnSync);
297
+ if (status !== 0) return status;
298
+ }
299
+
300
+ if (!plan.runFullSuite && plan.runTestEnv) {
301
+ console.log(' Extra: running affected edge-case tests');
302
+ const status = runCommand(bunCommand, ['test', 'test-env/'], { env }, spawnSync);
303
+ if (status !== 0) return status;
304
+ }
305
+
306
+ console.log('Relevant tests passed');
307
+ return 0;
308
+ } catch (error) {
309
+ console.error('');
310
+ console.error(`Failed to run ${label}: ${error.message}`);
311
+ console.error('');
312
+ return 1;
313
+ }
314
+ }
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' });
50
340
  }
51
341
 
52
- if (result.status !== 0) {
53
- console.error('');
54
- console.error('❌ Tests failed. Fix them before pushing.');
55
- console.error('');
56
- console.error('Fix the failing checks. Do not bypass hooks.');
57
- console.error('');
58
- process.exit(1);
342
+ if (require.main === module) {
343
+ const exitCode = process.argv.includes('--validate')
344
+ ? runLocalValidationTests()
345
+ : runPrePushTests();
346
+ process.exit(exitCode);
59
347
  }
60
348
 
61
- console.log('✅ All tests passed');
349
+ module.exports = {
350
+ ALWAYS_RUN_RISK_TEST_TARGETS,
351
+ buildTestExecutionPlan,
352
+ classifyPushTests,
353
+ detectPackageManager,
354
+ runLocalValidationTests,
355
+ runPrePushTests,
356
+ runTestExecutionPlan,
357
+ stripGitHookEnv,
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"