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,22 +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');
24
+ const { createProcessTree, signalExitCode } = require('./process-tree');
25
+ const { stripGitHookEnv } = require('./test');
26
+ const { redact } = require('../lib/audit-evidence');
16
27
 
17
28
  const rootDir = path.join(__dirname, '..');
18
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
+ }
19
54
 
20
55
  function parseArgs(argv) {
21
56
  const args = {
22
57
  labelPrefix: 'local-full',
23
58
  shards: null,
59
+ timeoutMs: DEFAULT_SHARD_TIMEOUT_MS,
24
60
  };
25
61
 
26
62
  for (let index = 0; index < argv.length; index += 1) {
@@ -29,11 +65,20 @@ function parseArgs(argv) {
29
65
 
30
66
  if (current === '--label-prefix') args.labelPrefix = next;
31
67
  if (current === '--shards') args.shards = Number.parseInt(next, 10);
68
+ if (current === '--timeout') args.timeoutMs = parseTimeoutMs(next);
32
69
  }
33
70
 
34
71
  return args;
35
72
  }
36
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
+
37
82
  function getDefaultShardCount(cpuCount = os.cpus().length) {
38
83
  if (!Number.isInteger(cpuCount) || cpuCount <= 1) return 1;
39
84
  return Math.max(2, Math.min(4, cpuCount - 1));
@@ -81,6 +126,29 @@ function walkAllTests(dir) {
81
126
  return results;
82
127
  }
83
128
 
129
+ function assertExactShardAssignment(allTests, shardSpecs) {
130
+ const expectedFiles = new Set(allTests);
131
+ const assignedFiles = new Set();
132
+
133
+ for (const shard of shardSpecs) {
134
+ for (const file of shard.files) {
135
+ if (!expectedFiles.has(file)) {
136
+ throw new Error(`Test file ${file} is not part of the full suite`);
137
+ }
138
+ if (assignedFiles.has(file)) {
139
+ throw new Error(`Test file ${file} belongs to more than exactly one shard`);
140
+ }
141
+ assignedFiles.add(file);
142
+ }
143
+ }
144
+
145
+ for (const file of expectedFiles) {
146
+ if (!assignedFiles.has(file)) {
147
+ throw new Error(`Test file ${file} was omitted from the shard assignment`);
148
+ }
149
+ }
150
+ }
151
+
84
152
  function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
85
153
  const specs = [];
86
154
  for (let shardIndex = 0; shardIndex < shardTotal; shardIndex += 1) {
@@ -100,63 +168,881 @@ function buildShardSpecs(allTests, shardTotal, durationMap = new Map()) {
100
168
  source: plan.source,
101
169
  });
102
170
  }
171
+ assertExactShardAssignment(allTests, specs);
103
172
  return specs;
104
173
  }
105
174
 
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
+ }) {
605
+ return [
606
+ 'test',
607
+ '--timeout',
608
+ String(parseTimeoutMs(timeoutMs)),
609
+ '--reporter=junit',
610
+ '--reporter-outfile',
611
+ junitPath,
612
+ ...files.map((file) => path.resolve(root, file)),
613
+ ];
614
+ }
615
+
616
+ function parseShardReceipt(output) {
617
+ if (typeof output !== 'string') return null;
618
+ const root = output.match(/^\s*(?:<\?xml\b[^?]*\?>\s*)?<testsuites\b([^>]*)>[\s\S]*<\/testsuites\s*>\s*$/);
619
+ const openingTags = output.match(/<testsuites\b/g) || [];
620
+ const closingTags = output.match(/<\/testsuites\s*>/g) || [];
621
+ if (!root || openingTags.length !== 1 || closingTags.length !== 1) return null;
622
+
623
+ const readAttribute = (name) => root[1].match(new RegExp('\\b' + name + '="(\\d+)"'));
624
+ const requiredAttributes = ['tests', 'assertions', 'failures', 'skipped'];
625
+ const values = requiredAttributes.map(readAttribute);
626
+ const hasInvalidRequiredAttribute = requiredAttributes.some((name, index) => {
627
+ const occurrences = root[1].match(new RegExp('\\b' + name + '\\s*=', 'g')) || [];
628
+ return occurrences.length !== 1 || !values[index];
629
+ });
630
+ const errorsOccurrences = root[1].match(/\berrors\s*=/g) || [];
631
+ const errorsAttribute = readAttribute('errors');
632
+ if (hasInvalidRequiredAttribute
633
+ || errorsOccurrences.length > 1
634
+ || (errorsOccurrences.length === 1 && !errorsAttribute)) return null;
635
+
636
+ const tests = Number.parseInt(values[0][1], 10);
637
+ const assertions = Number.parseInt(values[1][1], 10);
638
+ const failed = Number.parseInt(values[2][1], 10);
639
+ const errors = Number.parseInt(errorsAttribute?.[1] || '0', 10);
640
+ const skipped = Number.parseInt(values[3][1], 10);
641
+ const passed = tests - failed - errors - skipped;
642
+ if (tests === 0 || passed < 0) return null;
643
+ return { assertions, errors, failed, passed, skipped, tests };
644
+ }
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
+
746
+ function aggregateShardReceipts(receipts, expectedCount) {
747
+ const totals = {
748
+ assertions: 0,
749
+ errors: 0,
750
+ failed: 0,
751
+ passed: 0,
752
+ skipped: 0,
753
+ tests: 0,
754
+ };
755
+ const seen = new Set();
756
+ let incomplete = receipts.length !== expectedCount;
757
+ let failedProcess = false;
758
+
759
+ for (const receipt of receipts) {
760
+ if (receipt === null || typeof receipt !== 'object'
761
+ || !Number.isInteger(receipt.index)
762
+ || receipt.index < 0
763
+ || receipt.index >= expectedCount
764
+ || seen.has(receipt.index)) {
765
+ incomplete = true;
766
+ continue;
767
+ }
768
+ seen.add(receipt.index);
769
+ if (!Number.isInteger(receipt.code)) {
770
+ incomplete = true;
771
+ } else {
772
+ failedProcess ||= receipt.code !== 0;
773
+ }
774
+
775
+ const parsed = parseShardReceipt(receipt.output);
776
+ if (!parsed) {
777
+ incomplete = true;
778
+ continue;
779
+ }
780
+ for (const key of Object.keys(totals)) totals[key] += parsed[key];
781
+ }
782
+
783
+ incomplete ||= seen.size !== expectedCount;
784
+ const status = incomplete
785
+ ? 'INCOMPLETE'
786
+ : (failedProcess || totals.failed > 0 || totals.errors > 0 ? 'FAIL' : 'PASS');
787
+ return { ...totals, exitCode: status === 'PASS' ? 0 : 1, status };
788
+ }
789
+
106
790
  function spawnShard(shard, options = {}) {
107
791
  const spawn = options.spawn || defaultSpawn;
108
792
  const env = options.env || process.env;
109
793
  const bunCommand = options.bunCommand || env.BUN_EXE || process.env.BUN_EXE || 'bun';
110
794
  const labelPrefix = options.labelPrefix || 'local-full';
111
- fs.mkdirSync(reportDir, { recursive: true });
112
- const junitPath = path.join(reportDir, `${labelPrefix}-shard-${shard.index}.xml`);
795
+ const targetReportDir = options.reportDirectory || reportDir;
796
+ const platform = options.platform || process.platform;
797
+ const processTree = options.processTree || createProcessTree({ env, platform });
798
+ const stderrStream = options.stderrStream || process.stderr;
799
+ const resolvedReportDir = path.resolve(targetReportDir);
800
+ const junitPath = path.resolve(resolvedReportDir, `${labelPrefix}-shard-${shard.index}.xml`);
801
+ if (path.dirname(junitPath) !== resolvedReportDir) {
802
+ return Promise.reject(new Error('label prefix must produce a receipt directly inside test-results'));
803
+ }
804
+ fs.mkdirSync(resolvedReportDir, { recursive: true });
113
805
 
114
806
  return new Promise((resolve, reject) => {
115
- const child = spawn(bunCommand, [
116
- 'test',
117
- '--timeout',
118
- '30000',
119
- '--reporter=junit',
120
- '--reporter-outfile',
121
- junitPath,
122
- ...shard.files,
123
- ], {
124
- cwd: rootDir,
125
- env,
126
- shell: false,
127
- stdio: 'inherit',
807
+ const reservation = processTree.reserveChild({
808
+ command: bunCommand,
809
+ kind: 'test-shard',
810
+ label: `${labelPrefix}-shard-${shard.index}`,
128
811
  });
812
+ if (!reservation) {
813
+ reject(new Error('test shard ownership manifest is unavailable'));
814
+ return;
815
+ }
129
816
 
130
- child.on('error', reject);
131
- child.on('close', (code) => {
132
- resolve(code ?? 1);
817
+ let child;
818
+ let settled = false;
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) => {
827
+ if (settled) return;
828
+ settled = true;
829
+ clearStderrDrainListener();
830
+ processTree.unregisterChild(reservation);
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);
838
+ };
839
+ try {
840
+ fs.rmSync(junitPath, { force: true });
841
+ child = spawn(bunCommand, buildShardTestArgs({
842
+ junitPath,
843
+ files: shard.files,
844
+ timeoutMs: options.timeoutMs,
845
+ }), {
846
+ cwd: rootDir,
847
+ env,
848
+ shell: false,
849
+ stdio: ['inherit', 'inherit', 'pipe'],
850
+ detached: platform !== 'win32',
851
+ windowsHide: true,
852
+ });
853
+ child.on('error', (error) => {
854
+ if (settled) return;
855
+ settled = true;
856
+ clearStderrDrainListener();
857
+ processTree.unregisterChild(reservation);
858
+ reject(error);
859
+ });
860
+ if (!processTree.registerChild(reservation, child)) {
861
+ settled = true;
862
+ if (typeof processTree.abortChild === 'function') {
863
+ processTree.abortChild(reservation, child);
864
+ } else {
865
+ try {
866
+ child.kill?.('SIGKILL');
867
+ } finally {
868
+ processTree.cleanup?.('SIGKILL');
869
+ processTree.unregisterChild(reservation);
870
+ }
871
+ }
872
+ reject(new Error('test shard process could not be registered'));
873
+ return;
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
+ });
894
+ } catch (error) {
895
+ if (!settled) processTree.unregisterChild(reservation);
896
+ reject(error);
897
+ return;
898
+ }
899
+
900
+ child.on('close', (code, signal) => {
901
+ let output = null;
902
+ try {
903
+ output = fs.readFileSync(junitPath, 'utf8');
904
+ } catch {}
905
+ finish(code ?? 1, output, signal);
133
906
  });
134
907
  });
135
908
  }
136
909
 
137
910
  async function runFullSuiteInParallel(args = {}, deps = {}) {
138
- const allTests = deps.allTests || listAllFullSuiteTests();
139
- const shardTotal = Number.isInteger(args.shards) && args.shards > 0
140
- ? args.shards
141
- : getDefaultShardCount(deps.cpuCount);
142
- const profile = deps.profile || readNewestProfile(reportDir);
143
- const durationMap = deps.durationMap || createDurationMap(profile);
144
- const shardSpecs = buildShardSpecs(allTests, shardTotal, durationMap);
911
+ const env = deps.env || process.env;
912
+ const platform = deps.platform || process.platform;
913
+ const processTree = deps.processTree || createProcessTree({ env, platform });
914
+ let signal = null;
915
+ let completed = false;
916
+ let scheduleCancelled = false;
917
+ const removeSignalHandlers = processTree.installSignalHandlers((received) => {
918
+ signal = received;
919
+ });
145
920
 
146
- if (shardSpecs.length === 0) {
147
- console.log('No unit test files discovered for local full-suite run');
148
- return 0;
149
- }
921
+ try {
922
+ const allTests = deps.allTests || listAllFullSuiteTests();
923
+ const shardTotal = Number.isInteger(args.shards) && args.shards > 0
924
+ ? args.shards
925
+ : getDefaultShardCount(deps.cpuCount);
926
+ const subprocessShardTotal = Number.isInteger(args.shards) && args.shards > 0
927
+ ? shardTotal
928
+ : Math.max(6, shardTotal);
929
+ const profile = deps.profile || readNewestProfile(reportDir);
930
+ const durationMap = deps.durationMap || createDurationMap(profile);
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);
943
+
944
+ if (shardSpecs.length === 0) {
945
+ const exitCode = signal ? signalExitCode(signal) : 1;
946
+ console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
947
+ console.log('Full suite exit: ' + exitCode);
948
+ completed = true;
949
+ return exitCode;
950
+ }
951
+
952
+ fs.mkdirSync(reportDir, { recursive: true });
953
+ const runReportDir = fs.mkdtempSync(path.join(reportDir, 'full-suite-'));
150
954
 
151
- console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
152
- const results = await Promise.all(shardSpecs.map((shard) => spawnShard(shard, {
153
- bunCommand: deps.bunCommand,
154
- env: deps.env,
155
- labelPrefix: args.labelPrefix,
156
- spawn: deps.spawn,
157
- })));
955
+ console.log(`Running local full suite in ${shardSpecs.length} shard(s)`);
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(
964
+ typeof processTree.envFor === 'function' ? processTree.envFor(env) : env,
965
+ );
966
+ let results;
967
+ try {
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,
994
+ platform,
995
+ workerBudget: shardTotal,
996
+ });
997
+ } catch (error) {
998
+ console.error('Full suite shard execution failed:', error);
999
+ const exitCode = signal ? signalExitCode(signal) : 1;
1000
+ console.log('Full suite aggregate: status=INCOMPLETE tests=0 assertions=0 passed=0 failed=0 errors=0 skipped=0');
1001
+ console.log('Full suite exit: ' + exitCode);
1002
+ return exitCode;
1003
+ }
158
1004
 
159
- return results.some((code) => code !== 0) ? 1 : 0;
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
+ }
1019
+ const aggregate = aggregateShardReceipts(results, shardSpecs.length);
1020
+ const exitCode = signal ? signalExitCode(signal) : aggregate.exitCode;
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
+ }
1035
+ if (aggregate.status === 'PASS' && exitCode === 0) {
1036
+ fs.rmSync(runReportDir, { force: true, recursive: true });
1037
+ }
1038
+ console.log(`Full suite aggregate: status=${aggregate.status} tests=${aggregate.tests} assertions=${aggregate.assertions} passed=${aggregate.passed} failed=${aggregate.failed} errors=${aggregate.errors} skipped=${aggregate.skipped}`);
1039
+ console.log(`Full suite exit: ${exitCode}`);
1040
+ completed = true;
1041
+ return exitCode;
1042
+ } finally {
1043
+ removeSignalHandlers();
1044
+ if (!scheduleCancelled) processTree.cleanup(signal || !completed ? 'SIGKILL' : 'SIGTERM');
1045
+ }
160
1046
  }
161
1047
 
162
1048
  async function main(argv = process.argv.slice(2), deps = {}) {
@@ -175,12 +1061,24 @@ if (require.main === module) {
175
1061
  }
176
1062
 
177
1063
  module.exports = {
1064
+ aggregateShardReceipts,
1065
+ assertExactShardAssignment,
1066
+ buildResourceLanePlan,
1067
+ buildShardTestArgs,
178
1068
  buildShardSpecs,
1069
+ classifyShardFailure,
1070
+ classifyTestResource,
1071
+ computeLaneGrants,
1072
+ extractFailedTestCases,
1073
+ laneWorkerCost,
179
1074
  getDefaultShardCount,
180
1075
  listAllFullSuiteTests,
1076
+ loadTestResourceMap,
181
1077
  main,
182
1078
  parseArgs,
1079
+ runLaneSchedule,
183
1080
  runFullSuiteInParallel,
184
1081
  spawnShard,
185
1082
  walkAllTests,
1083
+ writeDurationProfile,
186
1084
  };