forge-workflow 0.1.0-beta.4 → 0.1.0-beta.6

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 (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Wraps test execution with smart defaults:
7
7
  * - Auto-detects package manager from lockfiles
8
- * - Checks Beads (Dolt) connectivity, sets BEADS_SKIP_TESTS if unavailable
9
8
  * - Supports --affected flag to run only tests for changed files
10
9
  *
11
10
  * Security: Uses execFileSync for subprocess calls (OWASP A03)
@@ -17,6 +16,8 @@ const { execFileSync: defaultExecFileSync, spawnSync: defaultSpawnSync } = requi
17
16
  const defaultFs = require('node:fs');
18
17
  const path = require('node:path');
19
18
 
19
+ const { selectDocAssertingTests } = require('../doc-assertions');
20
+
20
21
  /** @type {Array<[string, string]>} Lockfile → package manager mapping (order matters) */
21
22
  const LOCKFILE_MAP = [
22
23
  ['bun.lockb', 'bun'],
@@ -26,13 +27,14 @@ const LOCKFILE_MAP = [
26
27
  ];
27
28
 
28
29
  const DEFAULT_TIMEOUT = 120000;
29
- const BEADS_CHECK_TIMEOUT = 3000;
30
30
  const DIRECT_TEST_CANDIDATES = Object.freeze({
31
31
  'bin/forge.js': [
32
32
  'test/cli-flags.test.js',
33
33
  'test/forge-cli-registry.test.js',
34
- 'test/setup-github-sync.test.js',
35
34
  'test/setup-runtime-flags.test.js',
35
+ 'test/github-launcher.test.js',
36
+ 'test/integration/github-account-context.test.js',
37
+ 'test/structural/github-account-public-surface.test.js',
36
38
  ],
37
39
  // The second CLI surface (command descriptions + shepherd wiring). Without an
38
40
  // entry here it resolves to zero tests and pushes fall back to the full suite.
@@ -41,15 +43,150 @@ const DIRECT_TEST_CANDIDATES = Object.freeze({
41
43
  'test/forge-cmd-shepherd.test.js',
42
44
  ],
43
45
  'bin/forge-preflight.js': ['test/bin/forge-preflight.test.js'],
46
+ // The CI entry point is covered by the doc-assertion regression suite; without
47
+ // this entry it resolves to zero tests and forces the full suite.
48
+ // (lib/doc-assertions.js resolves via the lib/ -> test/ convention below.)
49
+ 'scripts/doc-asserting-tests.js': [
50
+ 'test/doc-assertions.test.js',
51
+ 'test/scripts/doc-asserting-tests.test.js',
52
+ ],
53
+ // The suites that assert the repo .gitignore's actual content. The path is
54
+ // already known to the classifier, so without candidates here it resolves
55
+ // zero tests and a one-line ignore addition pays the full-suite lane.
56
+ '.gitignore': [
57
+ 'test/check-forge-token.test.js',
58
+ 'test/cleanup/dropped-agent-config.test.js',
59
+ 'test/coverage-config.test.js',
60
+ 'test/eval/eval-history.test.js',
61
+ 'test/freshness-token.test.js',
62
+ 'test/mutation-config.test.js',
63
+ 'test/test-dashboard.test.js',
64
+ ],
44
65
  'lib/lefthook-check.js': ['test/lefthook-check.test.js', 'test/runtime-health.test.js'],
45
66
  'lib/runtime-health.js': ['test/runtime-health.test.js'],
46
67
  // skill-eval hosts the accuracy-lint detectors (auditCommandDocumentation /
47
68
  // auditRouterPrecision), so edits there must also run their detector suite.
48
69
  'lib/skill-eval.js': ['test/skill-eval.test.js', 'test/skill-accuracy.test.js'],
70
+ // Schema is shared by every kernel authority; keep it on the full-suite lane.
71
+ 'lib/kernel/schema.js': [],
72
+ // Migrations have consumers across the kernel; an empty mapping deliberately
73
+ // selects the full-suite lane, matching the shared SQLite driver.
74
+ 'lib/kernel/migrations.js': [],
75
+ 'lib/commands/shepherd.js': [
76
+ 'test/commands/shepherd.test.js',
77
+ 'test/commands/github-route-matrix.test.js',
78
+ 'test/pr-monitor/arm-on-push.test.js',
79
+ 'test/pr-monitor/shepherd-watch.test.js',
80
+ 'test/pr-monitor/watch-owner-cutover.test.js',
81
+ 'test/pr-monitor/watch-owner-launch-contention.test.js',
82
+ 'test/pr-monitor/watch-owner-launch-gate.test.js',
83
+ ],
84
+ 'lib/commands/github.js': [
85
+ 'test/commands/github.test.js',
86
+ 'test/github-launcher.test.js',
87
+ 'test/integration/github-account-context.test.js',
88
+ ],
89
+ 'lib/github-context.js': ['test/github-context.test.js', 'test/integration/github-account-context.test.js'],
90
+ 'lib/commands/_registry.js': [
91
+ 'test/commands/_registry.test.js',
92
+ 'test/commands/_registry-github-context.test.js',
93
+ ],
94
+ 'lib/commands/pr.js': ['test/commands/pr.test.js', 'test/commands/github-route-matrix.test.js'],
95
+ 'lib/commands/ship.js': ['test/commands/ship.test.js', 'test/commands/github-route-matrix.test.js'],
96
+ 'lib/commands/merge.js': ['test/commands/merge.test.js', 'test/commands/github-route-matrix.test.js'],
97
+ 'lib/commands/team.js': ['test/commands/team.test.js', 'test/commands/github-route-matrix.test.js'],
98
+ 'lib/commands/clean.js': ['test/commands/clean.test.js', 'test/commands/github-route-matrix.test.js'],
99
+ 'scripts/github-context-bridge.sh': ['test/commands/github-route-matrix.test.js'],
100
+ 'lib/commands/push.js': ['test/commands/push.test.js', 'test/commands/github-indirect-routes.test.js'],
101
+ 'lib/commands/hooks.js': ['test/commands/hooks.test.js', 'test/hooks-session-start.test.js', 'test/commands/github-indirect-routes.test.js'],
102
+ 'lib/commands/serve.js': ['test/commands/serve.test.js', 'test/commands/github-indirect-routes.test.js'],
103
+ 'lib/commands/skill.js': ['test/commands/skill.test.js', 'test/commands/github-indirect-routes.test.js'],
104
+ 'scripts/lib/behavioral-eval-runtime.js': ['test/eval/behavioral-eval-runtime.test.js', 'test/commands/github-indirect-routes.test.js'],
105
+ 'web/dashboard/generate-snapshot.mjs': ['test/commands/github-indirect-routes.test.js'],
106
+ // The shared SQLite driver has more consumers than a bounded direct list can
107
+ // safely represent; its empty mapping deliberately selects the full-suite lane.
108
+ 'lib/kernel/sqlite-driver.js': [],
109
+ 'lib/pr-monitor/watch-owner.js': [
110
+ 'test/pr-monitor/watch-owner.test.js',
111
+ 'test/kernel/watch-owner-transaction.test.js',
112
+ ],
113
+ 'lib/pr-monitor/reconcile.js': [
114
+ 'test/pr-monitor/reconcile.test.js',
115
+ 'test/pr-monitor/reconcile-owner-authority.test.js',
116
+ ],
117
+ 'lib/pr-monitor/reconcile-executor.js': [
118
+ 'test/commands/github-indirect-routes.test.js',
119
+ 'test/pr-monitor/auto-trigger-containment.test.js',
120
+ 'test/pr-monitor/reconcile-executor-owner-authority.test.js',
121
+ 'test/pr-monitor/reconcile-executor.test.js',
122
+ 'test/pr-monitor/reconcile-daemon-owner-authority.test.js',
123
+ 'test/pr-monitor/reconcile-migration-gate.test.js',
124
+ 'test/pr-monitor/watch-owner-launch-contention.test.js',
125
+ 'test/windows-hide-background-spawns.test.js',
126
+ ],
127
+ 'lib/pr-monitor/watch.js': [
128
+ 'test/pr-monitor/watch-owner-cutover.test.js',
129
+ 'test/pr-monitor/watch.test.js',
130
+ ],
131
+ 'lib/pr-monitor/watch-lifecycle.js': [
132
+ 'test/commands/github-indirect-routes.test.js',
133
+ 'test/pr-monitor/watch-lifecycle.test.js',
134
+ 'test/pr-monitor/watch-owner-launch-contention.test.js',
135
+ 'test/pr-monitor/watch-owner-launch-gate.test.js',
136
+ ],
137
+ 'lib/pr-monitor/shepherd-lease.js': [
138
+ 'test/pr-monitor/shepherd-lease.test.js',
139
+ 'test/pr-monitor/shepherd-lease-election-only.test.js',
140
+ ],
141
+ 'lib/pr-monitor/monitor.js': [
142
+ 'test/pr-monitor/monitor.test.js',
143
+ 'test/pr-monitor/monitor-owner-signal.test.js',
144
+ ],
49
145
  'lib/upgrade-safety.js': ['test/commands/upgrade.test.js'],
50
146
  'scripts/test.js': ['test/scripts/test-runner.test.js'],
147
+ // `bun.lock` is deliberately absent: a dependency change can break anything,
148
+ // so it stays on the full-suite lane.
149
+ // lefthook.yml is the repository's hook wiring, so this must also cover the
150
+ // suites that assert the individual pre-commit/pre-push commands exist.
151
+ 'lefthook.yml': [
152
+ 'test/lefthook-check.test.js',
153
+ 'test/lefthook-wiring.test.js',
154
+ 'test/commands/lefthook-user-config.test.js',
155
+ 'test/branch-protection.test.js',
156
+ 'test/commitlint.test.js',
157
+ 'test/cross-platform-install.test.js',
158
+ 'test/sync-d20-audit-script.test.js',
159
+ ],
160
+ 'eslint.config.js': ['test/lint-script.test.js'],
161
+ '.coderabbit.yaml': ['test/coderabbit-config.test.js'],
162
+ '.claude-plugin/marketplace.json': [
163
+ 'test/activation/plugin-scaffold.test.js',
164
+ 'test/plugin-catalog.test.js',
165
+ ],
51
166
  });
52
167
 
168
+ // Shared authority modules must run the full suite even when another changed file
169
+ // resolves a targeted test. Keep this signal separate from test candidates: an
170
+ // empty candidate list is not enough once a mapped consumer is also changed.
171
+ const FULL_SUITE_REQUIRED_FILES = new Set([
172
+ 'lib/kernel/schema.js',
173
+ 'lib/kernel/migrations.js',
174
+ 'lib/kernel/sqlite-driver.js',
175
+ ]);
176
+
177
+ // Directory-level mappings for source trees that own a dedicated suite. Without
178
+ // these a change under any of them resolves to zero tests and forces the full
179
+ // suite (see hasZeroResolvedTests in scripts/test.js).
180
+ const PREFIX_TEST_TARGETS = Object.freeze([
181
+ ['validation/', ['test/validation/risk-manifest.test.js', 'test/validation/risk-manifest-generator.test.js']],
182
+ // `eval/corpus/` must come first: the corpus hash and manifest checks live in
183
+ // immutable-corpus, and eval-sets only reads `eval/commands/`.
184
+ ['eval/corpus/', ['test/eval/immutable-corpus.test.js']],
185
+ ['eval/', ['test/eval/eval-sets.test.js']],
186
+ ['rules/', ['test/rules-sync.test.js', 'test/skill-dispatch-parity.test.js']],
187
+ ['plugin/', ['test/activation/plugin-scaffold.test.js', 'test/plugin-catalog.test.js', 'test/release-readiness.test.js']],
188
+ ]);
189
+
53
190
  // Skill sources (canonical `skills/**` and the committed `.agents/skills/**` mirror)
54
191
  // map to the fast skill suite. This is what keeps a skills-only PR on the targeted
55
192
  // lane instead of the full ~1500-test suite. `skills-sync-drift` guards the mirror.
@@ -84,22 +221,6 @@ function detectPackageManager(projectRoot, fs) {
84
221
  return 'npm';
85
222
  }
86
223
 
87
- /**
88
- * Check if Beads (bd CLI) is reachable.
89
- *
90
- * @param {Function} execFileSync - Injected execFileSync
91
- * @returns {boolean} true if bd is available
92
- */
93
- function checkBeadsConnectivity(execFileSync) {
94
- try {
95
- execFileSync('bd', ['list', '--limit=1'], { timeout: BEADS_CHECK_TIMEOUT });
96
- return true;
97
- } catch (_e) { // NOSONAR S2486
98
- /* intentional: bd not installed or unreachable */
99
- return false;
100
- }
101
- }
102
-
103
224
  function resolveBaseBranch(execFileSync) {
104
225
  let baseBranch = 'main';
105
226
  try {
@@ -186,7 +307,9 @@ function getChangedFiles(execFileSync, options = {}) {
186
307
  * @returns {string[]} Array of test file paths (e.g. ['test/foo.test.js'])
187
308
  */
188
309
  function getAffectedTestFiles(projectRoot, execFileSync, fs = defaultFs, options = {}) {
189
- const changedFiles = getChangedFiles(execFileSync, options);
310
+ const changedFiles = Array.isArray(options.changedFiles)
311
+ ? options.changedFiles
312
+ : getChangedFiles(execFileSync, options);
190
313
  const testFiles = new Set();
191
314
  for (const file of changedFiles) {
192
315
  for (const candidate of getTestCandidatesForChangedFile(file)) {
@@ -196,6 +319,16 @@ function getAffectedTestFiles(projectRoot, execFileSync, fs = defaultFs, options
196
319
  }
197
320
  }
198
321
 
322
+ // Markdown changes must also run the suites that assert on that markdown's
323
+ // CONTENT (README size badge, AGENTS.md convention, docs consistency). The
324
+ // static mapping above only knows the docs-consistency suite, so a docs PR
325
+ // used to merge green and break master for the next code PR (issue 63556816).
326
+ // The set is derived from the test sources, so new markdown-reading suites are
327
+ // picked up without editing a list here.
328
+ for (const candidate of selectDocAssertingTests(changedFiles, projectRoot, fs)) {
329
+ testFiles.add(candidate);
330
+ }
331
+
199
332
  return Array.from(testFiles).sort((left, right) => left.localeCompare(right));
200
333
  }
201
334
 
@@ -215,10 +348,24 @@ function getTestCandidatesForChangedFile(file) {
215
348
  return [...SKILL_TEST_TARGETS];
216
349
  }
217
350
 
351
+ // Maintainer-only contributor skills. They document the AGENTS.md product /
352
+ // maintainer split, so the docs-bleed gate is the suite that can catch a
353
+ // contradiction between them.
354
+ if (file.startsWith('.forge/contributor-skills/')) {
355
+ return ['test/structural/agents-docs-bleed.test.js'];
356
+ }
357
+
358
+ for (const [prefix, targets] of PREFIX_TEST_TARGETS) {
359
+ if (file.startsWith(prefix)) {
360
+ return [...targets];
361
+ }
362
+ }
363
+
218
364
  if (file === 'README.md'
219
365
  || file === 'QUICKSTART.md'
220
366
  || file === 'CHANGELOG.md'
221
367
  || file === 'AGENTS.md'
368
+ || file === 'CODING_STANDARDS.md'
222
369
  || file === 'DEVELOPMENT.md'
223
370
  || file === 'docs/INDEX.md'
224
371
  || file === 'docs/PROJECT_DESIGN.md'
@@ -226,7 +373,9 @@ function getTestCandidatesForChangedFile(file) {
226
373
  || file.startsWith('docs/guides/')
227
374
  || file.startsWith('docs/reference/')
228
375
  || file.startsWith('docs/work/')) {
229
- return ['test/docs-consistency.test.js'];
376
+ return file === 'AGENTS.md'
377
+ ? ['test/docs-consistency.test.js', 'test/structural/agents-docs-bleed.test.js']
378
+ : ['test/docs-consistency.test.js'];
230
379
  }
231
380
 
232
381
  if (file.startsWith('lib/') && file.endsWith('.js')) {
@@ -270,10 +419,15 @@ function getTestCandidatesForChangedFile(file) {
270
419
  return [];
271
420
  }
272
421
 
422
+ function isFullSuiteRequiredFile(file) {
423
+ return FULL_SUITE_REQUIRED_FILES.has(file);
424
+ }
425
+
273
426
  module.exports = {
274
427
  getChangedFiles,
275
428
  getAffectedTestFiles,
276
429
  getTestCandidatesForChangedFile,
430
+ isFullSuiteRequiredFile,
277
431
  name: 'test',
278
432
  description: 'Run tests with smart defaults (timeout, affected-only)',
279
433
  usage: 'forge test [--affected]',
@@ -291,7 +445,7 @@ module.exports = {
291
445
  * @param {Object} [deps.fs] - fs module
292
446
  * @param {Function} [deps.execFileSync] - child_process.execFileSync
293
447
  * @param {Function} [deps.spawnSync] - child_process.spawnSync
294
- * @returns {Promise<{ success: boolean, exitCode: number, beadsSkipped: boolean }>}
448
+ * @returns {Promise<{ success: boolean, exitCode: number }>}
295
449
  */
296
450
  async handler(_args, flags, projectRoot, deps = {}) {
297
451
  const fs = deps.fs || defaultFs;
@@ -313,32 +467,28 @@ module.exports = {
313
467
  }
314
468
  } catch (_e) { /* intentional: package.json missing or unreadable, use default timeout */ } // NOSONAR S2486
315
469
 
316
- // 3. Check Beads connectivity
317
- const beadsAvailable = checkBeadsConnectivity(execFileSync);
318
- const beadsSkipped = !beadsAvailable;
319
-
320
- const extraEnv = {};
321
- if (beadsSkipped) {
322
- extraEnv.BEADS_SKIP_TESTS = '1';
323
- }
324
-
325
- // 4. Build test command args
470
+ // 3. Build test command args
326
471
  let testArgs = ['run', 'test'];
327
472
 
328
- // 5. --affected flag: find changed test files
473
+ // 4. --affected flag: find changed test files
329
474
  if (flags['--affected'] || flags.affected) {
330
- const affectedTests = getAffectedTestFiles(projectRoot, execFileSync, fs, {
475
+ const diffOptions = {
331
476
  sinceUpstream: flags.sinceUpstream || flags['--since-upstream'],
477
+ };
478
+ const changedFiles = getChangedFiles(execFileSync, diffOptions);
479
+ const affectedTests = getAffectedTestFiles(projectRoot, execFileSync, fs, {
480
+ ...diffOptions,
481
+ changedFiles,
332
482
  });
333
- if (affectedTests.length > 0) {
483
+ if (!changedFiles.some(isFullSuiteRequiredFile) && affectedTests.length > 0) {
334
484
  testArgs = ['run', 'test', ...affectedTests];
335
485
  }
336
486
  // If no affected tests found, fall back to running all tests
337
487
  }
338
488
 
339
- // 6. Run tests
489
+ // 5. Run tests
340
490
  const result = spawnSync(pkgManager, testArgs, {
341
- env: { ...process.env, ...extraEnv },
491
+ env: { ...process.env },
342
492
  timeout,
343
493
  stdio: 'inherit',
344
494
  shell: process.platform === 'win32',
@@ -349,7 +499,6 @@ module.exports = {
349
499
  return {
350
500
  success: exitCode === 0,
351
501
  exitCode,
352
- beadsSkipped,
353
502
  };
354
503
  },
355
504
  };
@@ -44,10 +44,12 @@ const CONFLICT_DIVIDER_PATTERN = /^=======$/;
44
44
  const CONFLICT_END_PATTERN = /^>>>>>>>.*$/;
45
45
  const GIT_CONFLICT_SCAN_MAX_BUFFER = 16 * 1024 * 1024;
46
46
  const VALIDATION_COMMAND_TIMEOUT_MS = 600000;
47
- const VALIDATION_COMMAND_TIMEOUT_MINUTES = Math.round(VALIDATION_COMMAND_TIMEOUT_MS / 60000);
47
+ // Match the full-suite runner's measured headroom; its verbose output exceeds 1 MiB.
48
+ const FULL_SUITE_TIMEOUT_MS = 25 * 60 * 1000;
49
+ const FULL_SUITE_MAX_BUFFER = 16 * 1024 * 1024;
48
50
 
49
- function validationTimeoutMessage(label) {
50
- return `${label} timed out after ${VALIDATION_COMMAND_TIMEOUT_MINUTES} minutes`;
51
+ function validationTimeoutMessage(label, timeoutMs = VALIDATION_COMMAND_TIMEOUT_MS) {
52
+ return `${label} timed out after ${Math.round(timeoutMs / 60000)} minutes`;
51
53
  }
52
54
 
53
55
  function listConflictScanFiles(rootDir) {
@@ -80,8 +82,15 @@ function shouldSkipConflictScanPath(relativePath) {
80
82
  ));
81
83
  }
82
84
 
83
- function getExecOptions() {
84
- return { encoding: 'utf8', cwd: process.cwd(), timeout: VALIDATION_COMMAND_TIMEOUT_MS };
85
+ /** Build bounded process options, with extra headroom only for Forge's full suite. */
86
+ function getExecOptions(rootDir = process.cwd(), fullSuite = false) {
87
+ const options = {
88
+ encoding: 'utf8',
89
+ cwd: rootDir,
90
+ timeout: fullSuite ? FULL_SUITE_TIMEOUT_MS : VALIDATION_COMMAND_TIMEOUT_MS,
91
+ };
92
+ if (fullSuite) options.maxBuffer = FULL_SUITE_MAX_BUFFER;
93
+ return options;
85
94
  }
86
95
 
87
96
  const ERROR_PATTERNS = {
@@ -130,7 +139,7 @@ function parseNumber(match, index = 1, defaultValue = 0) {
130
139
  */
131
140
  function getCheckStatus(check) {
132
141
  if (!check) return null;
133
- if (check.skipped) return 'SKIPPED';
142
+ if (check.skipped === true) return 'SKIPPED';
134
143
  return check.success ? 'PASS' : 'FAIL';
135
144
  }
136
145
 
@@ -343,17 +352,18 @@ async function runTypeCheck() {
343
352
  * Run ESLint
344
353
  * Executes eslint . to check code quality
345
354
  *
355
+ * @param {typeof execFileSync} exec - Synchronous process runner
346
356
  * @returns {Promise<{success: boolean, duration: number, warnings?: number, errors?: number, message?: string}>} Lint result
347
357
  * @example
348
358
  * const result = await runLint();
349
359
  * console.log(`Warnings: ${result.warnings}, Errors: ${result.errors}`);
350
360
  */
351
- async function runLint() {
361
+ async function runLint(exec = execFileSync) {
352
362
  const startTime = Date.now();
353
363
 
354
364
  try {
355
365
  // Run eslint with no output (exit code determines success)
356
- execFileSync('eslint', ['.'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
366
+ exec('eslint', ['.'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
357
367
 
358
368
  return {
359
369
  success: true,
@@ -556,7 +566,7 @@ async function runSecurityScan() { // NOSONAR S3776
556
566
 
557
567
  /**
558
568
  * Run all tests
559
- * Executes bun test to run the test suite
569
+ * Uses Forge's full-suite runner when present, with raw Bun as an external-repository fallback
560
570
  *
561
571
  * @returns {Promise<{success: boolean, duration: number, passed: number, failed: number, total: number, message?: string}>} Test execution result
562
572
  * @example
@@ -565,6 +575,22 @@ async function runSecurityScan() { // NOSONAR S3776
565
575
  */
566
576
  function parseTestCounts(output) {
567
577
  const text = String(output || '');
578
+ const aggregateMatches = [...text.matchAll(
579
+ /Full suite aggregate: status=(PASS|FAIL|INCOMPLETE) tests=(\d+) assertions=\d+ passed=(\d+) failed=(\d+) errors=(\d+) skipped=(\d+)/g,
580
+ )]; // NOSONAR S5852 - fixed tokens and bounded numeric fields
581
+ const aggregate = aggregateMatches.at(-1);
582
+ if (aggregate) {
583
+ const total = parseNumber(aggregate, 2);
584
+ return {
585
+ status: aggregate[1],
586
+ passed: parseNumber(aggregate, 3),
587
+ failed: parseNumber(aggregate, 4),
588
+ errors: parseNumber(aggregate, 5),
589
+ skipped: parseNumber(aggregate, 6),
590
+ total,
591
+ testsFound: total > 0,
592
+ };
593
+ }
568
594
  const passed = parseNumber(/(\d+) pass/.exec(text)); // NOSONAR S5852 - bounded \d+ pattern
569
595
  const failed = parseNumber(/(\d+) fail/.exec(text)); // NOSONAR S5852 - bounded \d+ pattern
570
596
  const skipped = parseNumber(/(\d+) skip/.exec(text)); // NOSONAR S5852 - bounded \d+ pattern
@@ -573,13 +599,31 @@ function parseTestCounts(output) {
573
599
  return { passed, failed, skipped, total, testsFound: total > 0 };
574
600
  }
575
601
 
576
- async function runAllTests(exec = execFileSync) {
602
+ function isForgeCheckout(rootDir) {
603
+ try {
604
+ const manifest = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
605
+ return manifest.name === 'forge-workflow'
606
+ && manifest.bin?.forge === 'bin/forge.js'
607
+ && manifest.scripts?.['test:full:parallel'] === 'node scripts/test-full-suite.js'
608
+ && fs.existsSync(path.join(rootDir, 'scripts', 'test-full-suite.js'));
609
+ } catch (error) {
610
+ if (error instanceof SyntaxError || error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
611
+ throw error;
612
+ }
613
+ }
614
+
615
+ async function runAllTests(exec = execFileSync, rootDir = process.cwd()) {
577
616
  const startTime = Date.now();
617
+ const useFullSuite = isForgeCheckout(rootDir);
618
+ const command = useFullSuite ? 'node' : 'bun';
619
+ const args = useFullSuite ? ['scripts/test-full-suite.js'] : ['test', '--timeout', '30000'];
620
+ const execOptions = getExecOptions(rootDir, useFullSuite);
578
621
 
579
622
  try {
580
- const result = exec('bun', ['test', '--timeout', '30000'], getExecOptions()); // NOSONAR S4036 - hardcoded CLI command, no user input, developer tool context
623
+ const result = exec(command, args, execOptions); // NOSONAR S4036 - fixed CLI commands, no user input, developer tool context
581
624
 
582
- const { passed, failed, skipped, total, testsFound } = parseTestCounts(result);
625
+ const { status, passed, failed, errors = 0, skipped, total, testsFound } = parseTestCounts(result);
626
+ const success = failed === 0 && errors === 0 && (status === undefined || status === 'PASS');
583
627
 
584
628
  // B2 (N1): a bun run that executes 0 tests must NEVER report PASS — that
585
629
  // manufactures false confidence. Surface it as an explicit SKIP instead.
@@ -597,27 +641,27 @@ async function runAllTests(exec = execFileSync) {
597
641
  }
598
642
 
599
643
  return {
600
- success: failed === 0,
644
+ success,
601
645
  testsFound: true,
602
646
  duration: Date.now() - startTime,
603
647
  passed,
604
648
  failed,
605
649
  skipped,
606
650
  total,
607
- message: failed === 0
651
+ message: success
608
652
  ? `All ${total} tests passed`
609
- : `${failed}/${total} tests failed`,
653
+ : `${failed + errors}/${total} tests failed`,
610
654
  };
611
655
  } catch (error) {
612
656
  // Check for timeout
613
- if (error.killed && error.signal === 'SIGTERM') {
657
+ if (error.code === 'ETIMEDOUT' || (error.killed && error.signal === 'SIGTERM')) {
614
658
  return {
615
659
  success: false,
616
660
  duration: Date.now() - startTime,
617
661
  passed: 0,
618
662
  failed: 0,
619
663
  total: 0,
620
- message: validationTimeoutMessage('Test execution'),
664
+ message: validationTimeoutMessage('Test execution', execOptions.timeout),
621
665
  };
622
666
  }
623
667
 
@@ -631,7 +675,7 @@ async function runAllTests(exec = execFileSync) {
631
675
  passed: 0,
632
676
  failed: 0,
633
677
  total: 0,
634
- message: 'Tests skipped: bun test runner not found. Install bun to run tests.',
678
+ message: 'Tests skipped: test runner not found. Install Node.js and Bun to run tests.',
635
679
  };
636
680
  }
637
681
 
@@ -641,7 +685,7 @@ async function runAllTests(exec = execFileSync) {
641
685
  const passed = counts.passed;
642
686
  // bun exited non-zero: if the output shows no explicit failure count, treat
643
687
  // it as at least one failure rather than swallowing the non-zero exit.
644
- const failed = counts.failed > 0 ? counts.failed : Math.max(1, counts.failed);
688
+ const failed = counts.failed > 0 ? counts.failed : Math.max(1, counts.errors || 0);
645
689
  const total = counts.total > 0 ? counts.total : passed + failed + counts.skipped;
646
690
 
647
691
  return {
@@ -753,8 +797,8 @@ async function executeValidate(options = {}) { // NOSONAR S3776
753
797
  // 4. Tests
754
798
  if (!skip.includes(CHECK_TYPES.TESTS)) {
755
799
  try {
756
- checks.tests = await runAllTests();
757
- if (!checks.tests.success && !checks.tests.skipped) {
800
+ checks.tests = await runAllTests(execFileSync, rootDir);
801
+ if (!checks.tests.success && checks.tests.skipped !== true) {
758
802
  failedChecks.push(CHECK_TYPES.TESTS);
759
803
  if (!continueOnError) {
760
804
  return buildResult(checks, failedChecks, errors, startTime);