forge-workflow 0.1.0-beta.5 → 0.1.0-beta.7

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 (268) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/bin/forge.js +12 -1
  6. package/docs/guides/MIGRATION.md +3 -3
  7. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  8. package/docs/reference/INSTALL.md +4 -0
  9. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  10. package/docs/reference/RELEASE.md +4 -4
  11. package/docs/reference/github-accounts.md +134 -0
  12. package/docs/reference/shepherd.md +63 -13
  13. package/lib/adapters/pr-state-adapter.js +15 -2
  14. package/lib/base-remote.js +138 -0
  15. package/lib/beta5-compatibility-evidence.js +1093 -0
  16. package/lib/bun-lockfile-proof.js +413 -0
  17. package/lib/bun-workflow-pins.js +461 -0
  18. package/lib/capabilities/index.js +9 -0
  19. package/lib/capabilities/model.js +141 -0
  20. package/lib/capabilities/probes.js +347 -0
  21. package/lib/codex-skills.js +2 -2
  22. package/lib/commands/_manifest.js +1 -0
  23. package/lib/commands/_registry.js +48 -18
  24. package/lib/commands/clean.js +57 -1
  25. package/lib/commands/doctor.js +37 -6
  26. package/lib/commands/gate.js +197 -27
  27. package/lib/commands/github.js +215 -0
  28. package/lib/commands/hooks.js +54 -6
  29. package/lib/commands/memory.js +66 -2
  30. package/lib/commands/merge.js +720 -73
  31. package/lib/commands/plan.js +33 -2
  32. package/lib/commands/pr.js +2 -0
  33. package/lib/commands/preflight.js +10 -2
  34. package/lib/commands/push.js +120 -9
  35. package/lib/commands/recall.js +95 -61
  36. package/lib/commands/release.js +23 -2
  37. package/lib/commands/remember.js +28 -4
  38. package/lib/commands/serve.js +26 -9
  39. package/lib/commands/setup.js +132 -4
  40. package/lib/commands/shepherd.js +578 -72
  41. package/lib/commands/ship.js +15 -69
  42. package/lib/commands/skill.js +8 -0
  43. package/lib/commands/team.js +47 -8
  44. package/lib/commands/test.js +163 -4
  45. package/lib/commands/validate.js +78 -22
  46. package/lib/commands/worktree.js +155 -19
  47. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  48. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  49. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  50. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  51. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  52. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  53. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  54. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  55. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  56. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  57. package/lib/forge-issues.js +78 -0
  58. package/lib/gate-events.js +98 -10
  59. package/lib/github-context.js +308 -0
  60. package/lib/global-flags.js +1 -0
  61. package/lib/hook-renderer.js +29 -1
  62. package/lib/issue-render.js +19 -0
  63. package/lib/kernel/broker.js +723 -31
  64. package/lib/kernel/claim-reconciler.js +238 -0
  65. package/lib/kernel/lease-enforcer.js +9 -4
  66. package/lib/kernel/legacy-claim-repair.js +442 -0
  67. package/lib/kernel/live-claim-projection.js +26 -0
  68. package/lib/kernel/migrations.js +118 -3
  69. package/lib/kernel/readiness-model.js +184 -12
  70. package/lib/kernel/schema.js +49 -1
  71. package/lib/kernel/sqlite-driver.js +3322 -183
  72. package/lib/kernel/taxonomy-validator.js +4 -1
  73. package/lib/kernel/windows-private-acl.js +239 -0
  74. package/lib/memory/hygiene.js +191 -0
  75. package/lib/memory/router.js +94 -27
  76. package/lib/memory/usage-evidence.js +4 -0
  77. package/lib/memory-digest.js +59 -0
  78. package/lib/merge-rules.js +135 -17
  79. package/lib/npm-publish-workflow.js +233 -40
  80. package/lib/package-root.js +2 -0
  81. package/lib/pr-monitor/auto-actions.js +169 -28
  82. package/lib/pr-monitor/differ.js +110 -4
  83. package/lib/pr-monitor/events.js +0 -0
  84. package/lib/pr-monitor/flow-monitor.js +1424 -0
  85. package/lib/pr-monitor/gather.js +251 -44
  86. package/lib/pr-monitor/journal.js +0 -37
  87. package/lib/pr-monitor/monitor.js +117 -10
  88. package/lib/pr-monitor/process-identity.js +117 -0
  89. package/lib/pr-monitor/reconcile-executor.js +1101 -625
  90. package/lib/pr-monitor/reconcile.js +0 -0
  91. package/lib/pr-monitor/render-summary.js +121 -24
  92. package/lib/pr-monitor/review-preflight.js +269 -0
  93. package/lib/pr-monitor/shepherd-lease.js +28 -19
  94. package/lib/pr-monitor/verdict.js +438 -0
  95. package/lib/pr-monitor/watch-lifecycle.js +144 -38
  96. package/lib/pr-monitor/watch-owner.js +1414 -0
  97. package/lib/pr-monitor/watch.js +129 -58
  98. package/lib/pr-shepherd.js +17 -3
  99. package/lib/project-memory.js +145 -3
  100. package/lib/protected-state-authority.js +799 -4
  101. package/lib/protected-state-surfaces.js +181 -3
  102. package/lib/release-readiness.js +2 -3
  103. package/lib/review-adapter.js +65 -0
  104. package/lib/skills-sync.js +65 -32
  105. package/lib/validation/risk-manifest.js +339 -0
  106. package/lib/validation-receipt.js +190 -0
  107. package/lib/workflow/enforce-stage.js +44 -0
  108. package/lib/workflow/plan-authority.js +225 -0
  109. package/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  110. package/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  111. package/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  112. package/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  113. package/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  114. package/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  115. package/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  116. package/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  117. package/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  118. package/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  119. package/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  120. package/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  121. package/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  122. package/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  123. package/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  124. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  125. package/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  126. package/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  127. package/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  128. package/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  129. package/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  130. package/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  131. package/node_modules/@forge/contracts/index.js +32 -0
  132. package/node_modules/@forge/contracts/package.json +35 -0
  133. package/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  134. package/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  135. package/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  136. package/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  137. package/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  138. package/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  139. package/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  140. package/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  141. package/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  142. package/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  143. package/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  144. package/node_modules/@forge/contracts/src/baseline.js +23 -0
  145. package/node_modules/@forge/contracts/src/canonical.js +151 -0
  146. package/node_modules/@forge/contracts/src/definitions.js +176 -0
  147. package/node_modules/@forge/contracts/src/identity.js +42 -0
  148. package/node_modules/@forge/contracts/src/schema.js +72 -0
  149. package/node_modules/@forge/contracts/src/validate.js +305 -0
  150. package/node_modules/@forge/flow/index.js +114 -0
  151. package/node_modules/@forge/flow/package.json +34 -0
  152. package/node_modules/@forge/flow/src/bounded-loop.js +415 -0
  153. package/node_modules/@forge/flow/src/efficiency-supervisor.js +89 -0
  154. package/node_modules/@forge/flow/src/executor.js +279 -0
  155. package/node_modules/@forge/flow/src/monitor-durability.js +419 -0
  156. package/node_modules/@forge/flow/src/monitor-runtime.js +460 -0
  157. package/node_modules/@forge/flow/src/process-lifecycle.js +469 -0
  158. package/node_modules/@forge/flow/src/skill-runtime.js +343 -0
  159. package/node_modules/@forge/memory/index.js +331 -0
  160. package/node_modules/@forge/memory/package.json +34 -0
  161. package/node_modules/@forge/memory/src/authority-provider.js +67 -0
  162. package/node_modules/@forge/memory/src/backend-registry.js +166 -0
  163. package/node_modules/@forge/memory/src/feedback-intake.js +243 -0
  164. package/node_modules/@forge/memory/src/pr-lifecycle-authority.js +946 -0
  165. package/node_modules/@forge/memory/src/usage-evidence.js +205 -0
  166. package/package.json +16 -4
  167. package/packages/flow/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  168. package/packages/flow/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  169. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  170. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  171. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  172. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  173. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  174. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  175. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  176. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  177. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  178. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  179. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  180. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  181. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  182. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  183. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  184. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  185. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  186. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  187. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  188. package/packages/flow/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  189. package/packages/flow/node_modules/@forge/contracts/index.js +32 -0
  190. package/packages/flow/node_modules/@forge/contracts/package.json +35 -0
  191. package/packages/flow/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  192. package/packages/flow/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  193. package/packages/flow/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  194. package/packages/flow/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  195. package/packages/flow/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  196. package/packages/flow/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  197. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  198. package/packages/flow/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  199. package/packages/flow/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  200. package/packages/flow/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  201. package/packages/flow/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  202. package/packages/flow/node_modules/@forge/contracts/src/baseline.js +23 -0
  203. package/packages/flow/node_modules/@forge/contracts/src/canonical.js +151 -0
  204. package/packages/flow/node_modules/@forge/contracts/src/definitions.js +176 -0
  205. package/packages/flow/node_modules/@forge/contracts/src/identity.js +42 -0
  206. package/packages/flow/node_modules/@forge/contracts/src/schema.js +72 -0
  207. package/packages/flow/node_modules/@forge/contracts/src/validate.js +305 -0
  208. package/packages/memory/node_modules/@forge/contracts/compatibility-matrix.v1.json +1 -0
  209. package/packages/memory/node_modules/@forge/contracts/contract-baseline.v1.json +134 -0
  210. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/canonical-hash.json +1 -0
  211. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/contract-inputs.v1.json +71 -0
  212. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/feedback-secret-reject.json +1 -0
  213. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/identity-conflict.json +1 -0
  214. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-envelope-inputs.v1.json +16 -0
  215. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/malformed-missing-required.json +1 -0
  216. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-bounds-reject.json +1 -0
  217. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/monitor-receipt-privacy-reject.json +1 -0
  218. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/not-executed-receipt.json +1 -0
  219. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/privacy-redaction.json +1 -0
  220. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/retry-identical.json +1 -0
  221. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/stale-authority-reject.json +1 -0
  222. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-privacy-reject.json +1 -0
  223. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-live-reject.json +1 -0
  224. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/structured-error-stripe-test-reject.json +1 -0
  225. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-advisory-roundtrip.json +1 -0
  226. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/unknown-consequential-reject.json +1 -0
  227. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-full.json +1 -0
  228. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/valid-minimal.json +1 -0
  229. package/packages/memory/node_modules/@forge/contracts/fixtures/v1/wrong-capability-digest.json +1 -0
  230. package/packages/memory/node_modules/@forge/contracts/index.js +32 -0
  231. package/packages/memory/node_modules/@forge/contracts/package.json +35 -0
  232. package/packages/memory/node_modules/@forge/contracts/schemas/v1/capability-manifest.schema.json +1 -0
  233. package/packages/memory/node_modules/@forge/contracts/schemas/v1/claim-request.schema.json +1 -0
  234. package/packages/memory/node_modules/@forge/contracts/schemas/v1/context-packet.schema.json +1 -0
  235. package/packages/memory/node_modules/@forge/contracts/schemas/v1/delivery-receipt.schema.json +1 -0
  236. package/packages/memory/node_modules/@forge/contracts/schemas/v1/feedback-report.schema.json +1 -0
  237. package/packages/memory/node_modules/@forge/contracts/schemas/v1/lease-receipt.schema.json +1 -0
  238. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-event.schema.json +1 -0
  239. package/packages/memory/node_modules/@forge/contracts/schemas/v1/monitor-receipt.schema.json +1 -0
  240. package/packages/memory/node_modules/@forge/contracts/schemas/v1/run-receipt.schema.json +1 -0
  241. package/packages/memory/node_modules/@forge/contracts/schemas/v1/structured-error.schema.json +1 -0
  242. package/packages/memory/node_modules/@forge/contracts/schemas/v1/work-packet.schema.json +1 -0
  243. package/packages/memory/node_modules/@forge/contracts/src/baseline.js +23 -0
  244. package/packages/memory/node_modules/@forge/contracts/src/canonical.js +151 -0
  245. package/packages/memory/node_modules/@forge/contracts/src/definitions.js +176 -0
  246. package/packages/memory/node_modules/@forge/contracts/src/identity.js +42 -0
  247. package/packages/memory/node_modules/@forge/contracts/src/schema.js +72 -0
  248. package/packages/memory/node_modules/@forge/contracts/src/validate.js +305 -0
  249. package/scripts/commitlint.js +13 -15
  250. package/scripts/generate-risk-manifest.js +91 -0
  251. package/scripts/github-context-bridge.sh +10 -0
  252. package/scripts/legacy-claim-repair.js +145 -0
  253. package/scripts/lib/behavioral-eval-runtime.js +3 -2
  254. package/scripts/process-tree.js +14 -2
  255. package/scripts/protected-state-check.js +440 -17
  256. package/scripts/sync-agent-skills.js +333 -34
  257. package/scripts/test-full-suite.js +704 -18
  258. package/scripts/test-profile.js +13 -3
  259. package/scripts/test.js +96 -15
  260. package/skills/coverage.json +1 -0
  261. package/skills/review/SKILL.md +2 -0
  262. package/skills/review/evals/scorecard.json +2 -2
  263. package/skills/setup/SKILL.md +18 -0
  264. package/skills/setup/evals/scorecard.json +3 -3
  265. package/skills/shepherd/SKILL.md +19 -2
  266. package/skills/shepherd/evals/scorecard.json +3 -3
  267. package/skills/validate/SKILL.md +3 -0
  268. package/skills/validate/evals/scorecard.json +1 -1
@@ -5,24 +5,58 @@ const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
7
  const { spawn: defaultSpawn } = require('node:child_process');
8
+ const { stripVTControlCharacters } = require('node:util');
8
9
 
9
10
  const {
10
11
  createDurationMap,
11
12
  getShardPlan,
12
13
  getUnitTestRoots,
14
+ normalizePath,
13
15
  readNewestProfile,
14
16
  walkTests,
15
17
  } = require('./test-ci-shard');
18
+ const {
19
+ buildProfile,
20
+ parseJUnitFiles,
21
+ parseJUnitTestcases,
22
+ walk: walkProfileFiles,
23
+ } = require('./test-profile');
16
24
  const { createProcessTree, signalExitCode } = require('./process-tree');
17
25
  const { stripGitHookEnv } = require('./test');
26
+ const { redact } = require('../lib/audit-evidence');
18
27
 
19
28
  const rootDir = path.join(__dirname, '..');
20
29
  const reportDir = path.join(rootDir, 'test-results');
30
+ const RESOURCE_LANES = new Set(['unit', 'subprocess', 'exclusive']);
31
+ const RESOURCE_LANE_RANK = new Map([
32
+ ['unit', 0],
33
+ ['subprocess', 1],
34
+ ['exclusive', 2],
35
+ ]);
36
+ const DEFAULT_SHARD_TIMEOUT_MS = 30000;
37
+ const STDERR_TAIL_LIMIT = 4096;
38
+ const STDERR_OMITTED_LINE = '[stderr line omitted]\n';
39
+ const JS_FAMILY_EXTENSIONS = ['.js', '.cjs', '.mjs', '.jsx', '.ts', '.cts', '.mts', '.tsx'];
40
+ const FORGE_COORDINATION_ENV = new Set([
41
+ 'FORGE_ACTOR',
42
+ 'FORGE_SESSION_ID',
43
+ 'FORGE_WORKTREE_ID',
44
+ 'FORGE_LEASE_TTL_MS',
45
+ ]);
46
+
47
+ function stripFullSuiteChildEnv(env) {
48
+ const childEnv = stripGitHookEnv(env);
49
+ for (const key of Object.keys(childEnv)) {
50
+ if (FORGE_COORDINATION_ENV.has(key.toUpperCase())) delete childEnv[key];
51
+ }
52
+ return childEnv;
53
+ }
21
54
 
22
55
  function parseArgs(argv) {
23
56
  const args = {
24
57
  labelPrefix: 'local-full',
25
58
  shards: null,
59
+ timeoutMs: DEFAULT_SHARD_TIMEOUT_MS,
26
60
  };
27
61
 
28
62
  for (let index = 0; index < argv.length; index += 1) {
@@ -31,11 +65,20 @@ function parseArgs(argv) {
31
65
 
32
66
  if (current === '--label-prefix') args.labelPrefix = next;
33
67
  if (current === '--shards') args.shards = Number.parseInt(next, 10);
68
+ if (current === '--timeout') args.timeoutMs = parseTimeoutMs(next);
34
69
  }
35
70
 
36
71
  return args;
37
72
  }
38
73
 
74
+ function parseTimeoutMs(value) {
75
+ const timeoutMs = Number(value);
76
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
77
+ throw new Error('--timeout must be a positive integer');
78
+ }
79
+ return timeoutMs;
80
+ }
81
+
39
82
  function getDefaultShardCount(cpuCount = os.cpus().length) {
40
83
  if (!Number.isInteger(cpuCount) || cpuCount <= 1) return 1;
41
84
  return Math.max(2, Math.min(4, cpuCount - 1));
@@ -129,11 +172,440 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
129
172
  return specs;
130
173
  }
131
174
 
132
- function buildShardTestArgs({ junitPath, files, root = rootDir }) {
175
+ function strongestResourceLane(left, right) {
176
+ return RESOURCE_LANE_RANK.get(left) >= RESOURCE_LANE_RANK.get(right) ? left : right;
177
+ }
178
+
179
+ function tokenizeResourceSyntax(source) {
180
+ const tokens = [];
181
+ let index = 0;
182
+ while (index < source.length) {
183
+ const current = source[index];
184
+ if (/\s/.test(current)) {
185
+ index += 1;
186
+ continue;
187
+ }
188
+ if (current === '/' && source[index + 1] === '/') {
189
+ index = source.indexOf('\n', index + 2);
190
+ if (index === -1) break;
191
+ continue;
192
+ }
193
+ if (current === '/' && source[index + 1] === '*') {
194
+ const end = source.indexOf('*/', index + 2);
195
+ index = end === -1 ? source.length : end + 2;
196
+ continue;
197
+ }
198
+ if (current === '"' || current === "'") {
199
+ const quote = current;
200
+ let value = '';
201
+ index += 1;
202
+ while (index < source.length && source[index] !== quote) {
203
+ if (source[index] === '\\' && index + 1 < source.length) index += 1;
204
+ value += source[index];
205
+ index += 1;
206
+ }
207
+ index += 1;
208
+ tokens.push({ type: 'string', value });
209
+ continue;
210
+ }
211
+ if (current === '`') {
212
+ let dynamic = false;
213
+ let value = '';
214
+ index += 1;
215
+ while (index < source.length && source[index] !== '`') {
216
+ if (source[index] === '\\' && index + 1 < source.length) {
217
+ index += 1;
218
+ } else if (source[index] === '$' && source[index + 1] === '{') {
219
+ dynamic = true;
220
+ }
221
+ value += source[index];
222
+ index += 1;
223
+ }
224
+ index += 1;
225
+ tokens.push({ type: dynamic ? 'dynamic-string' : 'string', value });
226
+ continue;
227
+ }
228
+ if (/[A-Za-z_$]/.test(current)) {
229
+ const start = index;
230
+ index += 1;
231
+ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index])) index += 1;
232
+ tokens.push({ type: 'identifier', value: source.slice(start, index) });
233
+ continue;
234
+ }
235
+ tokens.push({ type: 'punctuator', value: current });
236
+ index += 1;
237
+ }
238
+ return tokens;
239
+ }
240
+
241
+ function inspectTestResourceSource(source, file, classifySource) {
242
+ const marker = source.match(/^\s*\/\/\s*forge-test-resource:\s*([^\s]+)\s*$/m);
243
+ let resource = 'unit';
244
+ if (marker) {
245
+ if (!RESOURCE_LANES.has(marker[1])) {
246
+ throw new Error(`Unknown full-suite resource lane ${marker[1]} in ${file}`);
247
+ }
248
+ resource = marker[1];
249
+ }
250
+ if (classifySource) {
251
+ const classified = classifySource(source, file);
252
+ if (!RESOURCE_LANES.has(classified)) {
253
+ throw new Error(`Unknown full-suite resource lane ${classified} in ${file}`);
254
+ }
255
+ resource = strongestResourceLane(resource, classified);
256
+ }
257
+
258
+ const imports = [];
259
+ const recordImport = (specifier) => {
260
+ if (specifier === 'child_process' || specifier === 'node:child_process') {
261
+ resource = strongestResourceLane(resource, 'subprocess');
262
+ } else if (specifier.startsWith('.')) {
263
+ imports.push(specifier);
264
+ }
265
+ };
266
+ const tokens = tokenizeResourceSyntax(source);
267
+ for (let index = 0; index < tokens.length; index += 1) {
268
+ const token = tokens[index];
269
+ const previous = tokens[index - 1];
270
+ const next = tokens[index + 1];
271
+ if (token.value === 'Bun'
272
+ && next?.value === '.'
273
+ && (tokens[index + 2]?.value === 'spawn' || tokens[index + 2]?.value === 'spawnSync')
274
+ && tokens[index + 3]?.value === '(') {
275
+ resource = strongestResourceLane(resource, 'subprocess');
276
+ continue;
277
+ }
278
+ if ((token.value === 'require' || token.value === 'import')
279
+ && previous?.value !== '.'
280
+ && next?.value === '(') {
281
+ const argument = tokens[index + 2];
282
+ if (argument?.type === 'string' && tokens[index + 3]?.value === ')') {
283
+ recordImport(argument.value);
284
+ } else {
285
+ resource = strongestResourceLane(resource, 'subprocess');
286
+ }
287
+ continue;
288
+ }
289
+ if (token.value !== 'import' && token.value !== 'export') continue;
290
+ if (next?.type === 'string') {
291
+ recordImport(next.value);
292
+ continue;
293
+ }
294
+ for (let cursor = index + 1; cursor < tokens.length && tokens[cursor].value !== ';'; cursor += 1) {
295
+ if (tokens[cursor].value === 'from' && tokens[cursor + 1]?.type === 'string') {
296
+ recordImport(tokens[cursor + 1].value);
297
+ break;
298
+ }
299
+ }
300
+ }
301
+ return { imports, resource };
302
+ }
303
+
304
+ function isWithinRoot(root, target) {
305
+ const relative = path.relative(root, target);
306
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
307
+ }
308
+
309
+ function createTestResourceClassifier(options = {}) {
310
+ const root = path.resolve(options.root || rootDir);
311
+ const realRoot = fs.realpathSync(root);
312
+ const readFile = options.readFile || ((target) => fs.readFileSync(target, 'utf8'));
313
+ const moduleCache = new Map();
314
+ const resolutionCache = new Map();
315
+ const resultCache = new Map();
316
+
317
+ const resolveLocalImport = (fromFile, specifier) => {
318
+ const cacheKey = `${fromFile}\0${specifier}`;
319
+ if (resolutionCache.has(cacheKey)) return resolutionCache.get(cacheKey);
320
+ const base = path.resolve(path.dirname(fromFile), specifier);
321
+ if (!isWithinRoot(realRoot, base)) {
322
+ resolutionCache.set(cacheKey, null);
323
+ return null;
324
+ }
325
+ const candidates = [
326
+ base,
327
+ ...JS_FAMILY_EXTENSIONS.map((extension) => base + extension),
328
+ ...JS_FAMILY_EXTENSIONS.map((extension) => path.join(base, `index${extension}`)),
329
+ ];
330
+ for (const candidate of candidates) {
331
+ let stats;
332
+ try {
333
+ stats = fs.statSync(candidate);
334
+ } catch {
335
+ continue;
336
+ }
337
+ if (!stats.isFile()) continue;
338
+ const resolved = fs.realpathSync(candidate);
339
+ if (!isWithinRoot(realRoot, resolved)) {
340
+ resolutionCache.set(cacheKey, null);
341
+ return null;
342
+ }
343
+ resolutionCache.set(cacheKey, resolved);
344
+ return resolved;
345
+ }
346
+ resolutionCache.set(cacheKey, null);
347
+ return null;
348
+ };
349
+
350
+ const inspectModule = (absoluteFile) => {
351
+ if (moduleCache.has(absoluteFile)) return moduleCache.get(absoluteFile);
352
+ let inspected;
353
+ try {
354
+ const source = readFile(absoluteFile);
355
+ if (typeof source !== 'string') throw new TypeError('resource source reader must return a string');
356
+ inspected = inspectTestResourceSource(source, path.relative(root, absoluteFile), options.classifySource);
357
+ } catch (error) {
358
+ if (error && /Unknown full-suite resource lane/.test(error.message)) throw error;
359
+ inspected = { imports: [], resource: 'subprocess' };
360
+ }
361
+ moduleCache.set(absoluteFile, inspected);
362
+ return inspected;
363
+ };
364
+
365
+ const classifyModule = (absoluteFile, visiting) => {
366
+ if (resultCache.has(absoluteFile)) {
367
+ return { complete: true, resource: resultCache.get(absoluteFile) };
368
+ }
369
+ if (visiting.has(absoluteFile)) return { complete: false, resource: 'unit' };
370
+
371
+ visiting.add(absoluteFile);
372
+ const inspected = inspectModule(absoluteFile);
373
+ let resource = inspected.resource;
374
+ let complete = true;
375
+ if (resource !== 'exclusive') {
376
+ for (const specifier of inspected.imports) {
377
+ const resolved = resolveLocalImport(absoluteFile, specifier);
378
+ if (!resolved) {
379
+ resource = strongestResourceLane(resource, 'subprocess');
380
+ continue;
381
+ }
382
+ const dependency = classifyModule(resolved, visiting);
383
+ resource = strongestResourceLane(resource, dependency.resource);
384
+ complete = complete && dependency.complete;
385
+ if (resource === 'exclusive') {
386
+ complete = true;
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ visiting.delete(absoluteFile);
392
+ if (complete) resultCache.set(absoluteFile, resource);
393
+ return { complete, resource };
394
+ };
395
+
396
+ return (file) => {
397
+ const absoluteFile = path.resolve(root, file);
398
+ if (!isWithinRoot(root, absoluteFile)) return 'subprocess';
399
+ let realFile;
400
+ try {
401
+ realFile = fs.realpathSync(absoluteFile);
402
+ } catch {
403
+ return 'subprocess';
404
+ }
405
+ if (!isWithinRoot(realRoot, realFile)) return 'subprocess';
406
+ return classifyModule(realFile, new Set()).resource;
407
+ };
408
+ }
409
+
410
+ function classifyTestResource(file, options = {}) {
411
+ return createTestResourceClassifier(options)(file);
412
+ }
413
+
414
+ async function loadTestResourceMap(allTests, options = {}) {
415
+ const classify = createTestResourceClassifier(options);
416
+ const uniqueFiles = [...new Set(allTests)];
417
+ const entries = uniqueFiles.map((file) => [file, classify(file)]);
418
+ return new Map(entries);
419
+ }
420
+
421
+ function buildResourceLanePlan(allTests, shardTotal, durationMap = new Map(), options = {}) {
422
+ const classify = options.classify || ((file) => classifyTestResource(file, options));
423
+ const subprocessShardTotal = Number.isInteger(options.subprocessShardTotal)
424
+ && options.subprocessShardTotal > 0
425
+ ? options.subprocessShardTotal
426
+ : shardTotal;
427
+ const buckets = {
428
+ exclusive: [],
429
+ subprocess: [],
430
+ unit: [],
431
+ };
432
+ for (const file of allTests) {
433
+ const resource = classify(file);
434
+ if (!RESOURCE_LANES.has(resource)) {
435
+ throw new Error(`Unknown full-suite resource lane ${resource} for ${file}`);
436
+ }
437
+ buckets[resource].push(file);
438
+ }
439
+
440
+ const lanes = [];
441
+ const addShardedLane = (name, shardCap, concurrencyCap = shardCap) => {
442
+ if (buckets[name].length === 0) return;
443
+ const shardCount = Math.min(shardCap, shardTotal, buckets[name].length);
444
+ lanes.push({
445
+ concurrency: Math.min(concurrencyCap, shardCount),
446
+ name,
447
+ shards: buildShardSpecs(buckets[name], shardCount, durationMap),
448
+ });
449
+ };
450
+ addShardedLane('unit', 4);
451
+ if (buckets.subprocess.length > 0) {
452
+ const shardCount = Math.min(subprocessShardTotal, buckets.subprocess.length);
453
+ lanes.push({
454
+ concurrency: Math.min(3, shardCount),
455
+ name: 'subprocess',
456
+ // Extra shards improve tail balancing without increasing the worker budget.
457
+ shards: buildShardSpecs(buckets.subprocess, shardCount, durationMap),
458
+ });
459
+ }
460
+ if (buckets.exclusive.length > 0) {
461
+ lanes.push({
462
+ concurrency: 1,
463
+ name: 'exclusive',
464
+ shards: buckets.exclusive.map((file) => ({ files: [file], source: 'exclusive' })),
465
+ });
466
+ }
467
+
468
+ let nextIndex = 0;
469
+ for (const lane of lanes) {
470
+ for (const shard of lane.shards) {
471
+ shard.index = nextIndex;
472
+ nextIndex += 1;
473
+ }
474
+ }
475
+ assertExactShardAssignment(allTests, lanes.flatMap((lane) => lane.shards));
476
+ return lanes;
477
+ }
478
+
479
+ // A subprocess-lane worker owns two OS processes on Windows: the shard's own bun
480
+ // runtime plus the bun.exe grandchild its tests spawn. Counting such a worker as
481
+ // one budget unit oversubscribes small runners (3 workers is ~6 processes on 4
482
+ // vCPU), so weight the grant by real process cost instead of by worker count.
483
+ function laneWorkerCost(laneName, platform = process.platform) {
484
+ if (platform !== 'win32') return 1;
485
+ return laneName === 'unit' ? 1 : 2;
486
+ }
487
+
488
+ function computeLaneGrants(lanes, options = {}) {
489
+ const platform = options.platform || process.platform;
490
+ const workerBudget = Number.isInteger(options.workerBudget) && options.workerBudget > 0
491
+ ? options.workerBudget
492
+ : null;
493
+ const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
494
+ const overlapsSubprocess = sharedLanes.some((lane) => lane.name === 'subprocess');
495
+ const grants = new Map();
496
+ for (const lane of lanes) {
497
+ grants.set(lane, {
498
+ cost: laneWorkerCost(lane.name, platform),
499
+ deferred: false,
500
+ granted: lane.concurrency,
501
+ workerBudget,
502
+ });
503
+ }
504
+ if (workerBudget === null) {
505
+ for (const lane of sharedLanes) {
506
+ // One unit worker fills otherwise-idle CPU without recreating broad process pressure.
507
+ grants.get(lane).granted = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
508
+ }
509
+ return grants;
510
+ }
511
+ // An explicit shard count is an operator-imposed cap on total concurrent
512
+ // children; reserve the budget for heavier subprocess workers first and
513
+ // defer leftover lanes until capacity frees instead of exceeding it.
514
+ const ordered = [...sharedLanes].sort(
515
+ (left, right) => (left.name === 'subprocess' ? 0 : 1) - (right.name === 'subprocess' ? 0 : 1),
516
+ );
517
+ let reservedCost = 0;
518
+ for (const [position, lane] of ordered.entries()) {
519
+ const entry = grants.get(lane);
520
+ const want = overlapsSubprocess && lane.name === 'unit' ? 1 : lane.concurrency;
521
+ const affordable = Math.floor(Math.max(0, workerBudget - reservedCost) / entry.cost);
522
+ // The strongest lane always keeps one worker so the suite can never stall at zero.
523
+ const granted = position === 0
524
+ ? Math.max(1, Math.min(want, affordable))
525
+ : Math.min(want, affordable);
526
+ entry.granted = granted;
527
+ entry.deferred = granted === 0;
528
+ if (entry.deferred) {
529
+ entry.deferredConcurrency = Math.max(
530
+ 1,
531
+ Math.min(lane.concurrency, Math.floor(workerBudget / entry.cost)),
532
+ );
533
+ }
534
+ reservedCost += granted * entry.cost;
535
+ }
536
+ return grants;
537
+ }
538
+
539
+ async function runLaneSchedule(lanes, execute, cancel = () => {}, options = {}) {
540
+ const runLane = async (lane, concurrency = lane.concurrency, schedule = { stopped: false }) => {
541
+ const laneResults = new Array(lane.shards.length);
542
+ let nextIndex = 0;
543
+ let stopped = false;
544
+ const worker = async () => {
545
+ while (!stopped && !schedule.stopped) {
546
+ const index = nextIndex;
547
+ nextIndex += 1;
548
+ if (index >= lane.shards.length) return;
549
+ try {
550
+ laneResults[index] = await execute(lane.shards[index], lane);
551
+ } catch (error) {
552
+ stopped = true;
553
+ if (!schedule.stopped) {
554
+ schedule.stopped = true;
555
+ cancel(error);
556
+ }
557
+ throw error;
558
+ }
559
+ }
560
+ };
561
+ const workers = Array.from(
562
+ { length: Math.min(concurrency, lane.shards.length) },
563
+ () => worker(),
564
+ );
565
+ const settled = await Promise.allSettled(workers);
566
+ const failure = settled.find((result) => result.status === 'rejected');
567
+ if (failure) throw failure.reason;
568
+ return laneResults;
569
+ };
570
+
571
+ const resultsByLane = new Map();
572
+ const sharedLanes = lanes.filter((lane) => lane.name !== 'exclusive');
573
+ const sharedSchedule = { stopped: false };
574
+ const grants = options.grants || computeLaneGrants(lanes, options);
575
+ const deferredLanes = sharedLanes.filter((lane) => grants.get(lane).deferred);
576
+ const immediateLanes = sharedLanes.filter((lane) => !grants.get(lane).deferred);
577
+ const settledSharedLanes = await Promise.allSettled(immediateLanes.map(async (lane) => {
578
+ resultsByLane.set(lane, await runLane(lane, grants.get(lane).granted, sharedSchedule));
579
+ }));
580
+ const sharedFailure = settledSharedLanes.find((result) => result.status === 'rejected');
581
+ if (sharedFailure) throw sharedFailure.reason;
582
+ // Budget-exhausted lanes start only after reserved capacity has been released.
583
+ const settledDeferredLanes = await Promise.allSettled(deferredLanes.map(async (lane) => {
584
+ resultsByLane.set(lane, await runLane(lane, grants.get(lane).deferredConcurrency, sharedSchedule));
585
+ }));
586
+ const deferredFailure = settledDeferredLanes.find((result) => result.status === 'rejected');
587
+ if (deferredFailure) throw deferredFailure.reason;
588
+
589
+ for (const lane of lanes.filter((candidate) => candidate.name === 'exclusive')) {
590
+ resultsByLane.set(lane, await runLane(lane));
591
+ }
592
+ return lanes.flatMap((lane) => {
593
+ const laneResults = resultsByLane.get(lane);
594
+ if (!laneResults) throw new Error(`Missing results for resource lane ${lane.name}`);
595
+ return laneResults;
596
+ });
597
+ }
598
+
599
+ function buildShardTestArgs({
600
+ junitPath,
601
+ files,
602
+ root = rootDir,
603
+ timeoutMs = DEFAULT_SHARD_TIMEOUT_MS,
604
+ }) {
133
605
  return [
134
606
  'test',
135
607
  '--timeout',
136
- '30000',
608
+ String(parseTimeoutMs(timeoutMs)),
137
609
  '--reporter=junit',
138
610
  '--reporter-outfile',
139
611
  junitPath,
@@ -171,6 +643,106 @@ function parseShardReceipt(output) {
171
643
  return { assertions, errors, failed, passed, skipped, tests };
172
644
  }
173
645
 
646
+ function extractFailedTestCases(output, limit = 20) {
647
+ const failures = [];
648
+ for (const { attrs, body } of parseJUnitTestcases(output)) {
649
+ const type = body.match(/<(failure|error)\b/)?.[1];
650
+ if (!type) continue;
651
+ failures.push({
652
+ file: attrs.file || attrs.classname || 'unknown',
653
+ line: attrs.line || '',
654
+ name: attrs.name || 'unknown',
655
+ type,
656
+ });
657
+ if (failures.length >= limit) break;
658
+ }
659
+ return failures;
660
+ }
661
+
662
+ function classifyShardFailure(result) {
663
+ if (result?.signal) return 'signal';
664
+ const output = typeof result?.output === 'string' ? result.output : '';
665
+ const stderrTail = typeof result?.stderrTail === 'string' ? result.stderrTail : '';
666
+ const parsed = parseShardReceipt(output);
667
+ const failedReceipt = parsed && (parsed.failed > 0 || parsed.errors > 0);
668
+ const hasTimeoutDiagnostic = (value) => /\b(?:etimedout|timed?\s*out|timeout\s*(?:error|exception))\b/i.test(value);
669
+ const failedOutput = failedReceipt
670
+ ? parseJUnitTestcases(output)
671
+ .map(({ body }) => body)
672
+ .filter(body => /<(?:failure|error)\b/.test(body))
673
+ .join('\n')
674
+ : '';
675
+ const stderrDiagnostics = stripVTControlCharacters(stderrTail).split(/\r?\n/)
676
+ .filter(line => !/^\s*\((?:pass|fail|skip|todo)\)(?:\s|$)/i.test(line))
677
+ .join('\n');
678
+ if (hasTimeoutDiagnostic(failedOutput) || hasTimeoutDiagnostic(stderrDiagnostics)) return 'test-timeout';
679
+ if (failedReceipt) return 'test-failure';
680
+ if (parsed) return 'post-junit-exit';
681
+ return 'incomplete-receipt';
682
+ }
683
+
684
+ function createStderrTailCollector() {
685
+ let tail = '';
686
+ let pending = '';
687
+ let discardingLine = false;
688
+ const append = (value) => {
689
+ tail = (tail + value).slice(-STDERR_TAIL_LIMIT);
690
+ };
691
+
692
+ return {
693
+ write(chunk) {
694
+ let text = String(chunk);
695
+ if (discardingLine) {
696
+ const newline = text.indexOf('\n');
697
+ if (newline === -1) return;
698
+ text = text.slice(newline + 1);
699
+ discardingLine = false;
700
+ }
701
+ pending += text;
702
+ while (pending) {
703
+ const newline = pending.indexOf('\n');
704
+ if (newline !== -1) {
705
+ const line = pending.slice(0, newline + 1);
706
+ pending = pending.slice(newline + 1);
707
+ append(line.length > STDERR_TAIL_LIMIT ? STDERR_OMITTED_LINE : redact(line));
708
+ continue;
709
+ }
710
+ if (pending.length > STDERR_TAIL_LIMIT) {
711
+ pending = '';
712
+ discardingLine = true;
713
+ append(STDERR_OMITTED_LINE);
714
+ }
715
+ break;
716
+ }
717
+ },
718
+ value() {
719
+ if (!discardingLine && pending) append(redact(pending));
720
+ pending = '';
721
+ return tail;
722
+ },
723
+ };
724
+ }
725
+
726
+ function writeDurationProfile({ allTests, label, outputPath, runReportDir }) {
727
+ const files = walkProfileFiles(runReportDir, '.xml');
728
+ if (files.length === 0) return false;
729
+ const metrics = parseJUnitFiles(files);
730
+ const measured = new Set(metrics.allFileDurations.map((entry) => normalizePath(entry.file)));
731
+ if (!allTests.every((file) => measured.has(normalizePath(file)))) return false;
732
+
733
+ const profile = buildProfile({ integrationSkipped: false, label: label || 'local-full' }, metrics);
734
+ const target = path.resolve(outputPath);
735
+ const temporary = `${target}.tmp-${process.pid}`;
736
+ fs.mkdirSync(path.dirname(target), { recursive: true });
737
+ try {
738
+ fs.writeFileSync(temporary, JSON.stringify(profile, null, 2));
739
+ fs.renameSync(temporary, target);
740
+ } finally {
741
+ fs.rmSync(temporary, { force: true });
742
+ }
743
+ return true;
744
+ }
745
+
174
746
  function aggregateShardReceipts(receipts, expectedCount) {
175
747
  const totals = {
176
748
  assertions: 0,
@@ -223,6 +795,7 @@ function spawnShard(shard, options = {}) {
223
795
  const targetReportDir = options.reportDirectory || reportDir;
224
796
  const platform = options.platform || process.platform;
225
797
  const processTree = options.processTree || createProcessTree({ env, platform });
798
+ const stderrStream = options.stderrStream || process.stderr;
226
799
  const resolvedReportDir = path.resolve(targetReportDir);
227
800
  const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
228
801
  if (path.dirname(junitPath) !== resolvedReportDir) {
@@ -243,28 +816,44 @@ function spawnShard(shard, options = {}) {
243
816
 
244
817
  let child;
245
818
  let settled = false;
246
- const finish = (code, output) => {
819
+ let stderrDrainListener = null;
820
+ const stderrTail = createStderrTailCollector();
821
+ const clearStderrDrainListener = () => {
822
+ if (!stderrDrainListener) return;
823
+ stderrStream.off('drain', stderrDrainListener);
824
+ stderrDrainListener = null;
825
+ };
826
+ const finish = (code, output, signal) => {
247
827
  if (settled) return;
248
828
  settled = true;
829
+ clearStderrDrainListener();
249
830
  processTree.unregisterChild(reservation);
250
- resolve({ code, index: shard.index, output });
831
+ const result = { code, index: shard.index, output };
832
+ if (code !== 0) {
833
+ if (signal) result.signal = signal;
834
+ const retainedStderr = stderrTail.value();
835
+ if (retainedStderr) result.stderrTail = retainedStderr;
836
+ }
837
+ resolve(result);
251
838
  };
252
839
  try {
253
840
  fs.rmSync(junitPath, { force: true });
254
841
  child = spawn(bunCommand, buildShardTestArgs({
255
842
  junitPath,
256
843
  files: shard.files,
844
+ timeoutMs: options.timeoutMs,
257
845
  }), {
258
846
  cwd: rootDir,
259
847
  env,
260
848
  shell: false,
261
- stdio: 'inherit',
849
+ stdio: ['inherit', 'inherit', 'pipe'],
262
850
  detached: platform !== 'win32',
263
851
  windowsHide: true,
264
852
  });
265
853
  child.on('error', (error) => {
266
854
  if (settled) return;
267
855
  settled = true;
856
+ clearStderrDrainListener();
268
857
  processTree.unregisterChild(reservation);
269
858
  reject(error);
270
859
  });
@@ -283,18 +872,37 @@ function spawnShard(shard, options = {}) {
283
872
  reject(new Error('test shard process could not be registered'));
284
873
  return;
285
874
  }
875
+ child.stderr?.on('data', (chunk) => {
876
+ const canContinue = stderrStream.write(chunk);
877
+ stderrTail.write(chunk);
878
+ if (
879
+ canContinue === false
880
+ && !stderrDrainListener
881
+ && typeof child.stderr.pause === 'function'
882
+ && typeof child.stderr.resume === 'function'
883
+ && typeof stderrStream.once === 'function'
884
+ && typeof stderrStream.off === 'function'
885
+ ) {
886
+ child.stderr.pause();
887
+ stderrDrainListener = () => {
888
+ stderrDrainListener = null;
889
+ if (!settled) child.stderr.resume();
890
+ };
891
+ stderrStream.once('drain', stderrDrainListener);
892
+ }
893
+ });
286
894
  } catch (error) {
287
895
  if (!settled) processTree.unregisterChild(reservation);
288
896
  reject(error);
289
897
  return;
290
898
  }
291
899
 
292
- child.on('close', (code) => {
900
+ child.on('close', (code, signal) => {
293
901
  let output = null;
294
902
  try {
295
903
  output = fs.readFileSync(junitPath, 'utf8');
296
904
  } catch {}
297
- finish(code ?? 1, output);
905
+ finish(code ?? 1, output, signal);
298
906
  });
299
907
  });
300
908
  }
@@ -305,6 +913,7 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
305
913
  const processTree = deps.processTree || createProcessTree({ env, platform });
306
914
  let signal = null;
307
915
  let completed = false;
916
+ let scheduleCancelled = false;
308
917
  const removeSignalHandlers = processTree.installSignalHandlers((received) => {
309
918
  signal = received;
310
919
  });
@@ -314,9 +923,23 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
314
923
  const shardTotal = Number.isInteger(args.shards) && args.shards > 0
315
924
  ? args.shards
316
925
  : getDefaultShardCount(deps.cpuCount);
926
+ const subprocessShardTotal = Number.isInteger(args.shards) && args.shards > 0
927
+ ? shardTotal
928
+ : Math.max(6, shardTotal);
317
929
  const profile = deps.profile || readNewestProfile(reportDir);
318
930
  const durationMap = deps.durationMap || createDurationMap(profile);
319
- const shardSpecs = buildShardSpecs(allTests, shardTotal, durationMap);
931
+ const resourceMap = deps.classify
932
+ ? null
933
+ : await loadTestResourceMap(allTests, {
934
+ classifySource: deps.classifySource,
935
+ readFile: deps.readFile,
936
+ root: deps.root || rootDir,
937
+ });
938
+ const lanePlan = buildResourceLanePlan(allTests, shardTotal, durationMap, {
939
+ classify: deps.classify || ((file) => resourceMap.get(file)),
940
+ subprocessShardTotal,
941
+ });
942
+ const shardSpecs = lanePlan.flatMap((lane) => lane.shards);
320
943
 
321
944
  if (shardSpecs.length === 0) {
322
945
  const exitCode = signal ? signalExitCode(signal) : 1;
@@ -330,20 +953,47 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
330
953
  const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
331
954
 
332
955
  console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
333
- const childEnv = stripGitHookEnv(
956
+ const laneGrants = computeLaneGrants(lanePlan, { platform, workerBudget: shardTotal });
957
+ for (const lane of lanePlan) {
958
+ const grant = laneGrants.get(lane);
959
+ const granted = grant.deferred ? grant.deferredConcurrency : grant.granted;
960
+ const files = lane.shards.reduce((total, shard) => total + shard.files.length, 0);
961
+ console.log(`Resource lane ${lane.name}: files=${files} shards=${lane.shards.length} concurrency=${granted} (nominal=${lane.concurrency} cost=${grant.cost} budget=${shardTotal}${grant.deferred ? ' deferred' : ''})`);
962
+ }
963
+ const childEnv = stripFullSuiteChildEnv(
334
964
  typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
335
965
  );
336
966
  let results;
337
967
  try {
338
- results = await Promise.all(shardSpecs.map((shard) => spawnShard(shard, {
339
- bunCommand: deps.bunCommand,
340
- env: childEnv,
341
- labelPrefix: args.labelPrefix,
342
- reportDirectory: runReportDir,
343
- spawn: deps.spawn,
968
+ const nodeExecutable = deps.nodeExecutable ?? (
969
+ process.versions.bun ? globalThis.Bun?.which?.('node') : process.execPath
970
+ );
971
+ if (typeof nodeExecutable !== 'string' || !path.isAbsolute(nodeExecutable)) {
972
+ throw new Error('Full suite requires an absolute Node executable');
973
+ }
974
+ childEnv.FORGE_TEST_NODE_EXECUTABLE = nodeExecutable;
975
+ results = await runLaneSchedule(lanePlan, async (shard, lane) => ({
976
+ ...await spawnShard(shard, {
977
+ bunCommand: deps.bunCommand,
978
+ env: childEnv,
979
+ labelPrefix: args.labelPrefix,
980
+ reportDirectory: runReportDir,
981
+ spawn: deps.spawn,
982
+ platform,
983
+ processTree,
984
+ stderrStream: deps.stderrStream,
985
+ timeoutMs: args.timeoutMs,
986
+ }),
987
+ resource: lane.name,
988
+ }), () => {
989
+ if (scheduleCancelled) return;
990
+ scheduleCancelled = true;
991
+ processTree.cleanup('SIGKILL');
992
+ }, {
993
+ grants: laneGrants,
344
994
  platform,
345
- processTree,
346
- })));
995
+ workerBudget: shardTotal,
996
+ });
347
997
  } catch (error) {
348
998
  console.error('Full suite shard execution failed:', error);
349
999
  const exitCode = signal ? signalExitCode(signal) : 1;
@@ -352,9 +1002,36 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
352
1002
  return exitCode;
353
1003
  }
354
1004
 
1005
+ const nonzeroShards = results.filter((result) => result.code !== 0);
1006
+ if (nonzeroShards.length > 0) {
1007
+ console.error(`Full suite non-zero shards: ${nonzeroShards.map((result) => {
1008
+ let diagnostic = `${result.index}:${result.resource}:exit=${result.code} cause=${classifyShardFailure(result)}`;
1009
+ if (result.signal) diagnostic += ` signal=${result.signal}`;
1010
+ const stderrTail = result.stderrTail?.trim();
1011
+ if (stderrTail) diagnostic += ` stderr=${JSON.stringify(stderrTail)}`;
1012
+ return diagnostic;
1013
+ }).join(', ')}`);
1014
+ const failedTests = nonzeroShards.flatMap((result) => extractFailedTestCases(result.output));
1015
+ if (failedTests.length > 0) {
1016
+ console.error(`Full suite failing tests: ${failedTests.map((failure) => `${failure.file}${failure.line ? `:${failure.line}` : ''} (${failure.name})`).join(', ')}`);
1017
+ }
1018
+ }
355
1019
  const aggregate = aggregateShardReceipts(results, shardSpecs.length);
356
1020
  const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
357
1021
  if (signal) aggregate.status = 'INCOMPLETE';
1022
+ if (aggregate.status !== 'INCOMPLETE') {
1023
+ const labelPrefix = args.labelPrefix || 'local-full';
1024
+ try {
1025
+ (deps.writeDurationProfile || writeDurationProfile)({
1026
+ allTests,
1027
+ label: labelPrefix,
1028
+ outputPath: deps.profileOutputPath || path.join(reportDir, `${labelPrefix}.profile.json`),
1029
+ runReportDir,
1030
+ });
1031
+ } catch (error) {
1032
+ console.warn(`Full suite profile was not updated: ${error.message}`);
1033
+ }
1034
+ }
358
1035
  if (aggregate.status === 'PASS' && exitCode === 0) {
359
1036
  fs.rmSync(runReportDir, { force: true, recursive: true });
360
1037
  }
@@ -364,7 +1041,7 @@ async function runFullSuiteInParallel(args = {}, deps = {}) {
364
1041
  return exitCode;
365
1042
  } finally {
366
1043
  removeSignalHandlers();
367
- processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
1044
+ if (!scheduleCancelled) processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
368
1045
  }
369
1046
  }
370
1047
 
@@ -386,13 +1063,22 @@ if (require.main === module) {
386
1063
  module.exports = {
387
1064
  aggregateShardReceipts,
388
1065
  assertExactShardAssignment,
1066
+ buildResourceLanePlan,
389
1067
  buildShardTestArgs,
390
1068
  buildShardSpecs,
1069
+ classifyShardFailure,
1070
+ classifyTestResource,
1071
+ computeLaneGrants,
1072
+ extractFailedTestCases,
1073
+ laneWorkerCost,
391
1074
  getDefaultShardCount,
392
1075
  listAllFullSuiteTests,
1076
+ loadTestResourceMap,
393
1077
  main,
394
1078
  parseArgs,
1079
+ runLaneSchedule,
395
1080
  runFullSuiteInParallel,
396
1081
  spawnShard,
397
1082
  walkAllTests,
1083
+ writeDurationProfile,
398
1084
  };