@opengsd/gsd-core 1.10.0 → 1.11.0

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 (328) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-debug-session-manager.md +11 -0
  4. package/agents/gsd-doc-synthesizer.md +2 -4
  5. package/agents/gsd-executor.md +5 -5
  6. package/agents/gsd-mempalace-curator.md +5 -2
  7. package/agents/gsd-phase-researcher.md +20 -1
  8. package/agents/gsd-plan-checker.md +37 -0
  9. package/agents/gsd-planner.md +44 -46
  10. package/agents/gsd-user-profiler.md +3 -0
  11. package/agents/gsd-verifier.md +12 -3
  12. package/bin/install.js +841 -971
  13. package/bin/lib/ui-safety-gate.cjs +2 -0
  14. package/commands/gsd/code-review.md +1 -1
  15. package/commands/gsd/execute-phase.md +1 -1
  16. package/commands/gsd/map-codebase.md +1 -1
  17. package/commands/gsd/mempalace-capture.md +1 -1
  18. package/commands/gsd/mempalace-recall.md +1 -1
  19. package/commands/gsd/new-milestone.md +1 -1
  20. package/commands/gsd/quick.md +1 -1
  21. package/commands/gsd/review-backlog.md +2 -1
  22. package/commands/gsd/verify-work.md +1 -1
  23. package/gsd-core/bin/gsd-tools.cjs +469 -88
  24. package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
  25. package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
  26. package/gsd-core/bin/lib/api-coverage.cjs +3 -5
  27. package/gsd-core/bin/lib/artifacts.cjs +3 -0
  28. package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
  29. package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
  30. package/gsd-core/bin/lib/audit.cjs +876 -240
  31. package/gsd-core/bin/lib/broken-windows.cjs +1 -1
  32. package/gsd-core/bin/lib/capability-consent.cjs +149 -15
  33. package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +575 -101
  35. package/gsd-core/bin/lib/capability-source.cjs +92 -0
  36. package/gsd-core/bin/lib/capability-trust.cjs +444 -25
  37. package/gsd-core/bin/lib/capability-validator.cjs +495 -22
  38. package/gsd-core/bin/lib/capability-writer.cjs +3 -2
  39. package/gsd-core/bin/lib/check-command-router.cjs +71 -37
  40. package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
  41. package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +22 -0
  43. package/gsd-core/bin/lib/command-roster.cjs +44 -1
  44. package/gsd-core/bin/lib/commands.cjs +651 -86
  45. package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
  46. package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
  47. package/gsd-core/bin/lib/config-loader.cjs +75 -0
  48. package/gsd-core/bin/lib/config.cjs +10 -1
  49. package/gsd-core/bin/lib/core-utils.cjs +127 -29
  50. package/gsd-core/bin/lib/decisions.cjs +23 -0
  51. package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
  52. package/gsd-core/bin/lib/frontmatter.cjs +155 -20
  53. package/gsd-core/bin/lib/gap-checker.cjs +68 -7
  54. package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
  55. package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
  56. package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
  57. package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
  58. package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
  59. package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
  60. package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
  61. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
  62. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
  63. package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
  64. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
  65. package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
  66. package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
  67. package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
  68. package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
  69. package/gsd-core/bin/lib/init.cjs +321 -129
  70. package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
  71. package/gsd-core/bin/lib/install-engine.cjs +745 -258
  72. package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
  73. package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
  74. package/gsd-core/bin/lib/install-profiles.cjs +134 -57
  75. package/gsd-core/bin/lib/install-scope.cjs +270 -0
  76. package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
  77. package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
  78. package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
  79. package/gsd-core/bin/lib/io.cjs +10 -0
  80. package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
  81. package/gsd-core/bin/lib/markdown-table.cjs +133 -20
  82. package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
  83. package/gsd-core/bin/lib/milestone.cjs +754 -70
  84. package/gsd-core/bin/lib/model-catalog.cjs +59 -1
  85. package/gsd-core/bin/lib/model-resolver.cjs +183 -40
  86. package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
  87. package/gsd-core/bin/lib/pattern.cjs +122 -0
  88. package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
  89. package/gsd-core/bin/lib/phase-id.cjs +444 -36
  90. package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
  91. package/gsd-core/bin/lib/phase-locator.cjs +125 -18
  92. package/gsd-core/bin/lib/phase.cjs +646 -143
  93. package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
  94. package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
  95. package/gsd-core/bin/lib/plan-scan.cjs +86 -2
  96. package/gsd-core/bin/lib/planning-scope.cjs +31 -0
  97. package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
  98. package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
  99. package/gsd-core/bin/lib/probe-core.cjs +1 -1
  100. package/gsd-core/bin/lib/profile-output.cjs +1 -1
  101. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
  102. package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
  103. package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
  104. package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
  105. package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
  106. package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
  107. package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
  108. package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
  109. package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
  110. package/gsd-core/bin/lib/roadmap.cjs +385 -94
  111. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
  112. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
  113. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
  114. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
  115. package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
  116. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
  117. package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
  118. package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
  119. package/gsd-core/bin/lib/security.cjs +104 -5
  120. package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
  121. package/gsd-core/bin/lib/smart-entry.cjs +142 -22
  122. package/gsd-core/bin/lib/state-command-router.cjs +5 -1
  123. package/gsd-core/bin/lib/state-document.cjs +152 -8
  124. package/gsd-core/bin/lib/state-transition.cjs +371 -117
  125. package/gsd-core/bin/lib/state.cjs +1794 -357
  126. package/gsd-core/bin/lib/surface.cjs +23 -9
  127. package/gsd-core/bin/lib/text-lines.cjs +80 -0
  128. package/gsd-core/bin/lib/token-scanner.cjs +76 -0
  129. package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
  130. package/gsd-core/bin/lib/uat.cjs +399 -56
  131. package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
  132. package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
  133. package/gsd-core/bin/lib/unusable-input.cjs +24 -0
  134. package/gsd-core/bin/lib/update-context.cjs +8 -2
  135. package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
  136. package/gsd-core/bin/lib/validate.cjs +20 -6
  137. package/gsd-core/bin/lib/vendor/README.md +37 -0
  138. package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
  139. package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
  140. package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
  141. package/gsd-core/bin/lib/verification.cjs +258 -8
  142. package/gsd-core/bin/lib/verify.cjs +368 -888
  143. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
  144. package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
  145. package/gsd-core/bin/lib/workstream.cjs +2 -2
  146. package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
  147. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  148. package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
  149. package/gsd-core/references/agent-contracts.md +43 -26
  150. package/gsd-core/references/checkpoints.md +2 -2
  151. package/gsd-core/references/context-budget.md +1 -1
  152. package/gsd-core/references/dispatch-isolation-gate.md +138 -0
  153. package/gsd-core/references/doc-conflict-engine.md +1 -1
  154. package/gsd-core/references/execute-mvp-tdd.md +3 -3
  155. package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
  156. package/gsd-core/references/execute-phase-context-guard.md +1 -1
  157. package/gsd-core/references/execute-phase-response-language.md +1 -1
  158. package/gsd-core/references/execute-phase-wave-guard.md +6 -2
  159. package/gsd-core/references/gate-prompts.md +1 -1
  160. package/gsd-core/references/git-planning-commit.md +2 -1
  161. package/gsd-core/references/loop-hook-dispatch.md +39 -2
  162. package/gsd-core/references/model-profiles.md +12 -4
  163. package/gsd-core/references/mvp-concepts.md +9 -9
  164. package/gsd-core/references/planner-guidance.md +3 -9
  165. package/gsd-core/references/planner-preconditions.md +1 -1
  166. package/gsd-core/references/planner-reviews.md +1 -1
  167. package/gsd-core/references/planning-config.md +8 -6
  168. package/gsd-core/references/revision-loop.md +1 -1
  169. package/gsd-core/references/specless-probe-fallback.md +1 -1
  170. package/gsd-core/references/universal-anti-patterns.md +3 -3
  171. package/gsd-core/references/verifier-phase-gates.md +192 -0
  172. package/gsd-core/references/verify-mvp-mode.md +1 -1
  173. package/gsd-core/references/workstream-flag.md +22 -6
  174. package/gsd-core/templates/discussion-log.md +1 -1
  175. package/gsd-core/templates/phase-prompt.md +2 -4
  176. package/gsd-core/templates/state.md +4 -4
  177. package/gsd-core/templates/verification-report.md +9 -1
  178. package/gsd-core/workflows/ai-integration-phase.md +9 -11
  179. package/gsd-core/workflows/autonomous.md +1 -1
  180. package/gsd-core/workflows/cleanup.md +62 -3
  181. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
  182. package/gsd-core/workflows/code-review-fix.md +37 -10
  183. package/gsd-core/workflows/code-review.md +38 -12
  184. package/gsd-core/workflows/complete-milestone.md +141 -18
  185. package/gsd-core/workflows/debug.md +7 -5
  186. package/gsd-core/workflows/diagnose-issues.md +35 -9
  187. package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
  188. package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
  189. package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
  190. package/gsd-core/workflows/edit-phase.md +26 -1
  191. package/gsd-core/workflows/eval-review.md +3 -5
  192. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
  193. package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
  194. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
  195. package/gsd-core/workflows/execute-phase.md +38 -50
  196. package/gsd-core/workflows/execute-plan.md +36 -4
  197. package/gsd-core/workflows/explore.md +131 -4
  198. package/gsd-core/workflows/fast.md +10 -2
  199. package/gsd-core/workflows/health.md +73 -4
  200. package/gsd-core/workflows/import.md +4 -4
  201. package/gsd-core/workflows/ingest-docs.md +5 -5
  202. package/gsd-core/workflows/mvp-phase.md +6 -3
  203. package/gsd-core/workflows/new-milestone.md +14 -9
  204. package/gsd-core/workflows/new-project.md +14 -14
  205. package/gsd-core/workflows/next.md +12 -0
  206. package/gsd-core/workflows/plan-phase.md +41 -17
  207. package/gsd-core/workflows/plan-review-convergence.md +50 -2
  208. package/gsd-core/workflows/progress.md +34 -6
  209. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
  210. package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
  211. package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
  212. package/gsd-core/workflows/quick.md +35 -15
  213. package/gsd-core/workflows/review.md +26 -5
  214. package/gsd-core/workflows/secure-phase.md +1 -1
  215. package/gsd-core/workflows/session-report.md +2 -1
  216. package/gsd-core/workflows/settings.md +66 -2
  217. package/gsd-core/workflows/ship.md +104 -44
  218. package/gsd-core/workflows/spec-phase.md +30 -12
  219. package/gsd-core/workflows/sync-skills.md +63 -8
  220. package/gsd-core/workflows/transition.md +46 -11
  221. package/gsd-core/workflows/ui-phase.md +5 -5
  222. package/gsd-core/workflows/ui-review.md +2 -2
  223. package/gsd-core/workflows/update.md +1 -1
  224. package/gsd-core/workflows/validate-phase.md +1 -1
  225. package/gsd-core/workflows/verify-work.md +9 -7
  226. package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
  227. package/hooks/dist/gsd-check-update-worker.js +56 -13
  228. package/hooks/dist/gsd-check-update.js +19 -1
  229. package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
  230. package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
  231. package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
  232. package/hooks/dist/gsd-prompt-guard.js +21 -20
  233. package/hooks/dist/gsd-read-injection-scanner.js +38 -24
  234. package/hooks/dist/gsd-statusline.js +18 -0
  235. package/hooks/dist/gsd-update-banner.js +22 -1
  236. package/hooks/dist/gsd-workflow-guard.js +134 -36
  237. package/hooks/dist/lib/git-cmd.js +92 -59
  238. package/hooks/dist/lib/injection-patterns.js +45 -0
  239. package/hooks/dist/lib/isolation-deny-reason.js +39 -0
  240. package/hooks/dist/lib/isolation-sentinel.js +9 -0
  241. package/hooks/gsd-agent-isolation-guard.js +103 -14
  242. package/hooks/gsd-check-update-worker.js +56 -13
  243. package/hooks/gsd-check-update.js +19 -1
  244. package/hooks/gsd-cursor-pre-tool.js +0 -3
  245. package/hooks/gsd-cursor-subagent-start.js +77 -2
  246. package/hooks/gsd-cursor-subagent-stop.js +3 -2
  247. package/hooks/gsd-prompt-guard.js +21 -20
  248. package/hooks/gsd-read-injection-scanner.js +38 -24
  249. package/hooks/gsd-statusline.js +18 -0
  250. package/hooks/gsd-update-banner.js +22 -1
  251. package/hooks/gsd-workflow-guard.js +134 -36
  252. package/hooks/lib/git-cmd.js +92 -59
  253. package/hooks/lib/injection-patterns.js +45 -0
  254. package/hooks/lib/isolation-deny-reason.js +39 -0
  255. package/hooks/lib/isolation-sentinel.js +9 -0
  256. package/package.json +21 -9
  257. package/pi/gsd.cjs +19 -5
  258. package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
  259. package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
  260. package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
  261. package/scripts/changeset/lint.cjs +60 -5
  262. package/scripts/check-alias-drift.cjs +7 -43
  263. package/scripts/check-contract-drift.cjs +297 -0
  264. package/scripts/ci-test-scope.cjs +19 -2
  265. package/scripts/command-contract-helpers.cjs +903 -1
  266. package/scripts/gen-adr-index.cjs +728 -38
  267. package/scripts/gen-capability-registry.cjs +3 -15
  268. package/scripts/gen-context-index.cjs +2 -11
  269. package/scripts/gen-health-docs.cjs +390 -0
  270. package/scripts/gen-inventory-manifest.cjs +50 -4
  271. package/scripts/gen-loop-host-contract.cjs +4 -24
  272. package/scripts/gen-registry.cjs +3 -14
  273. package/scripts/lib/alias-drift-families.cjs +46 -0
  274. package/scripts/lib/drift-scan.cjs +278 -0
  275. package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
  276. package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
  277. package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
  278. package/scripts/lint-canary-version-leak.cjs +73 -0
  279. package/scripts/lint-command-contract.cjs +96 -13
  280. package/scripts/lint-completion-predicate-drift.cjs +933 -0
  281. package/scripts/lint-completion-ratio-drift.cjs +214 -0
  282. package/scripts/lint-default-flip-documentation.cjs +193 -0
  283. package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
  284. package/scripts/lint-eslint-glob-coverage.cjs +340 -0
  285. package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
  286. package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
  287. package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
  288. package/scripts/lint-milestone-window-drift.cjs +468 -0
  289. package/scripts/lint-phase-enumeration-drift.cjs +479 -0
  290. package/scripts/lint-plan-count-drift.cjs +318 -0
  291. package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
  292. package/scripts/lint-planning-prompt-drift.cjs +434 -0
  293. package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
  294. package/scripts/lint-regression-test-names.cjs +15 -13
  295. package/scripts/lint-removed-but-needed.cjs +320 -0
  296. package/scripts/lint-state-field-drift.cjs +805 -0
  297. package/scripts/lint-state-write-path-drift.cjs +1045 -0
  298. package/scripts/lint-test-file-count.allowlist.json +21 -10
  299. package/scripts/lint-unreachable-guard-drift.cjs +843 -0
  300. package/scripts/lint-vendored-deps.cjs +124 -0
  301. package/scripts/pr-changed-files.cjs +63 -0
  302. package/scripts/pr-template-policy.cjs +14 -4
  303. package/scripts/prompt-injection-scan.sh +25 -0
  304. package/scripts/require-issue-link-policy.cjs +192 -0
  305. package/scripts/state-write-path-drift-baseline.json +19 -0
  306. package/scripts/sync-runtime-launcher.cjs +2 -4
  307. package/skills/gsd-autonomous/SKILL.md +0 -1
  308. package/skills/gsd-code-review/SKILL.md +1 -1
  309. package/skills/gsd-execute-phase/SKILL.md +1 -2
  310. package/skills/gsd-map-codebase/SKILL.md +1 -1
  311. package/skills/gsd-mempalace-capture/SKILL.md +1 -1
  312. package/skills/gsd-mempalace-recall/SKILL.md +1 -1
  313. package/skills/gsd-new-milestone/SKILL.md +1 -1
  314. package/skills/gsd-next/SKILL.md +0 -1
  315. package/skills/gsd-plan-phase/SKILL.md +0 -1
  316. package/skills/gsd-progress/SKILL.md +0 -1
  317. package/skills/gsd-quick/SKILL.md +1 -1
  318. package/skills/gsd-review-backlog/SKILL.md +2 -1
  319. package/skills/gsd-stats/SKILL.md +0 -1
  320. package/skills/gsd-verify-work/SKILL.md +1 -1
  321. package/vscode/package.json +1 -1
  322. package/gsd-core/workflows/discovery-phase.md +0 -298
  323. package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
  324. package/gsd-core/workflows/verify-phase.md +0 -574
  325. package/scripts/affected-tests-lib.cjs +0 -554
  326. package/scripts/lint-allow-test-rule-refs.cjs +0 -162
  327. package/scripts/run-affected-tests.cjs +0 -7
  328. package/scripts/run-tests.cjs +0 -1051
@@ -1,1051 +0,0 @@
1
- #!/usr/bin/env node
2
- // Cross-platform test runner — resolves test file globs via Node
3
- // instead of relying on shell expansion (which fails on Windows PowerShell/cmd).
4
- // Propagates NODE_V8_COVERAGE so c8 collects coverage from the child process.
5
- //
6
- // Suite filtering (issue #3597):
7
- // node scripts/run-tests.cjs # default — runs ALL tests (backcompat)
8
- // node scripts/run-tests.cjs --suite all # explicit "everything"
9
- // node scripts/run-tests.cjs --suite unit # only files with no other suite marker
10
- // node scripts/run-tests.cjs --suite security # *.security.test.cjs
11
- // node scripts/run-tests.cjs --suite integration # *.integration.test.cjs
12
- // node scripts/run-tests.cjs --suite install # *.install.test.cjs
13
- // node scripts/run-tests.cjs --suite slow # *.slow.test.cjs
14
- // node scripts/run-tests.cjs --suite qa # *.qa.test.cjs
15
- // node scripts/run-tests.cjs --files "a.test.cjs b.test.cjs"
16
- // node scripts/run-tests.cjs --files-from /tmp/selected-tests.txt
17
- // node scripts/run-tests.cjs --suite unit --shard 1/3 # shard 1 of 3 (#1212)
18
- //
19
- // Sharding (issue #1212, reweighted #2472): --shard <i>/<n> runs a
20
- // deterministic, COST-balanced slice of the SORTED selected file list. Files
21
- // are partitioned by measured duration (tests/test-timings.json) using LPT —
22
- // the same packing the chunker uses one level down — because equal file COUNTS
23
- // are not equal file COST: the index-based split this replaced ran 12.4m /
24
- // 19.2m / 15.2m against a 20-minute job cap. With no timing data every file
25
- // weighs the same and the partition degenerates to the original k % n
26
- // round-robin. i is 1-based (1..n); n >= 1; n=1 is a pure no-op (all files). The
27
- // CI windows full-test lane shards across N parallel runners so per-job
28
- // wall-clock scales as O(total/N) and stops hitting the job time cap. Sharding
29
- // composes with --suite (it slices the post-filter selection) and preserves
30
- // the existing 28K argv chunking WITHIN each shard.
31
- //
32
- // Suite grouping convention: filename suffix marker before `.test.cjs`.
33
- // A file named `foo.security.test.cjs` belongs to the `security` suite.
34
- // A file named `foo.test.cjs` (no marker) belongs to the `unit` suite.
35
- // See docs/TESTING-SUITES.md for full grouping policy.
36
- 'use strict';
37
-
38
- const { readdirSync, readFileSync } = require('fs');
39
- const { join, basename } = require('path');
40
- const { execFileSync } = require('child_process');
41
- const { ExitError, runMain } = require('./lib/cli-exit.cjs');
42
-
43
- const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow', 'qa'];
44
-
45
- // ADR-457 build-at-publish: gsd-core/bin/lib/*.cjs is generated from
46
- // src/*.cts and gitignored, so on a clean checkout (fresh CI, before any build)
47
- // the artifact is absent — yet test files require it. This is the universal
48
- // chokepoint every test path funnels through (test:unit, --files-from, direct
49
- // invocation), so build the artifact here.
50
- //
51
- // Strategy (incremental + re-emit-on-missing, closes both #969 failure modes):
52
- // 1. Run tsc incrementally (fast ~380ms no-op when sources unchanged).
53
- // 2. Verify every src/*.cts (non-.d.cts) maps to a non-empty gsd-core/bin/lib/*.cjs.
54
- // 3. If any expected .cjs is missing or zero-bytes (persistent-mirror scenario:
55
- // tsc no-ops because tsbuildinfo looks current even though the file was deleted),
56
- // delete the tsbuildinfo and run tsc ONCE MORE (clean re-emit), then re-verify.
57
- //
58
- // Common case: fast incremental no-op. Stale/deleted-output case: detected by
59
- // the cheap existsSync loop and force-rebuilt. Paths resolve from __dirname so
60
- // it works regardless of GSD_TEST_DIR / temp-dir cwd.
61
- function ensureBuiltArtifacts(overrides = {}) {
62
- const { existsSync, readdirSync, statSync, unlinkSync } = require('fs');
63
- const root = overrides.root || join(__dirname, '..');
64
- const srcDir = overrides.srcDir || join(root, 'src');
65
- const outDir = overrides.outDir || join(root, 'gsd-core', 'bin', 'lib');
66
- const tsBuildInfoPath = overrides.tsBuildInfoPath || join(root, 'tsconfig.build.tsbuildinfo');
67
- const tsconfigPath = overrides.tsconfigPath || join(root, 'tsconfig.build.json');
68
- const tscBin = require.resolve('typescript/bin/tsc');
69
- const tscArgs = [tscBin, '-p', tsconfigPath];
70
-
71
- // Build the 1:1 map of expected output paths from src/*.cts sources.
72
- // Excludes *.d.cts (declaration-only files that produce no output).
73
- // Handles subdirectories (e.g. src/installer-migrations/*.cts → gsd-core/bin/lib/installer-migrations/*.cjs).
74
- function gatherExpectedOutputs() {
75
- const expected = [];
76
- function scan(dir, relBase) {
77
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
78
- if (entry.isDirectory()) {
79
- scan(join(dir, entry.name), relBase ? `${relBase}/${entry.name}` : entry.name);
80
- } else if (entry.name.endsWith('.cts') && !entry.name.endsWith('.d.cts')) {
81
- const stem = entry.name.slice(0, -'.cts'.length);
82
- const rel = relBase ? `${relBase}/${stem}.cjs` : `${stem}.cjs`;
83
- expected.push(join(outDir, rel));
84
- }
85
- }
86
- }
87
- scan(srcDir, '');
88
- return expected;
89
- }
90
-
91
- function checkMissingOutputs(expectedPaths) {
92
- return expectedPaths.filter(p => !existsSync(p) || statSync(p).size === 0);
93
- }
94
-
95
- // #996 placed the tsbuildinfo inside gsd-core/bin/ (a copied/shipped tree), which
96
- // raced install-test copies. It now lives at the repo root. Best-effort purge any
97
- // stale bin-local copy so persistent workspaces/mirrors self-heal (no-op on a temp
98
- // override root or a clean checkout).
99
- const legacyTsBuildInfo = join(root, 'gsd-core', 'bin', 'tsconfig.build.tsbuildinfo');
100
- try { if (existsSync(legacyTsBuildInfo)) unlinkSync(legacyTsBuildInfo); } catch { /* best-effort */ }
101
-
102
- // Step 1: incremental build (fast no-op when sources unchanged).
103
- execFileSync(process.execPath, tscArgs, { cwd: root, stdio: 'inherit' });
104
-
105
- // Step 2: verify expected outputs.
106
- const expected = gatherExpectedOutputs();
107
- const missing = checkMissingOutputs(expected);
108
-
109
- // Step 3: if any output is missing/zero-bytes, force a clean re-emit.
110
- // This handles the persistent-mirror case where tsc's incremental no-op left
111
- // a deleted .cjs unregenerated (tsbuildinfo recorded it as up-to-date).
112
- if (missing.length > 0) {
113
- if (existsSync(tsBuildInfoPath)) {
114
- unlinkSync(tsBuildInfoPath);
115
- }
116
- execFileSync(process.execPath, tscArgs, { cwd: root, stdio: 'inherit' });
117
- // Re-verify after clean re-emit; surface any remaining gaps loudly.
118
- const stillMissing = checkMissingOutputs(expected);
119
- if (stillMissing.length > 0) {
120
- const names = stillMissing.map(p => require('path').basename(p)).join(', ');
121
- throw new Error(
122
- `ensureBuiltArtifacts: tsc clean re-emit still missing outputs: ${names}. ` +
123
- `Check src/ for compilation errors.`
124
- );
125
- }
126
- }
127
- }
128
-
129
- // hooks/dist/ is gitignored (.gitignore) and NOT built by `prepare`
130
- // (npm run build:lib only) — only the full `build`/`prepublishOnly` scripts run
131
- // build:hooks. So on a clean checkout + `npm ci` (fresh CI, incl. the scoped
132
- // test lane) hooks/dist starts absent. Install tests (e.g.
133
- // bug-3683-workflow-colon-namespace-leak) spawn `install.js --<runtime> --local`
134
- // which copies hooks from hooks/dist/ and then verifyInstalled() hard-fails if
135
- // the target hooks dir is empty. build-hooks.js `build()` creates DIST_DIR
136
- // empty and fills it file-by-file, so the FIRST on-demand build (triggered by
137
- // whichever concurrent install test's before() hook runs first) exposes a
138
- // window where hooks/dist exists but is empty/partial. A concurrently-spawned
139
- // install reader observes zero hooks -> "Failed to install hooks: directory is
140
- // empty" -> intermittent scoped-lane failure (full lanes dodge it only by luck
141
- // of a hooks-builder finishing early). Building hooks/dist ONCE here — the same
142
- // upfront chokepoint as ensureBuiltArtifacts, single-process with no concurrent
143
- // readers — fully populates dist before any test runs, closing the first-build
144
- // empty window everywhere (CI scoped/unit shards + local). Subsequent on-demand
145
- // rebuilds only atomically replace individual files (per-file rename in
146
- // build-hooks.js) and never re-empty the dir, so they stay safe.
147
- function ensureBuiltHooks(overrides = {}) {
148
- const { existsSync, statSync } = require('fs');
149
- const root = overrides.root || join(__dirname, '..');
150
- const distDir = overrides.distDir || join(root, 'hooks', 'dist');
151
- const hookNames = overrides.hookNames || require('./build-hooks.js').HOOKS_TO_COPY;
152
- const runBuild = overrides.runBuild || (() => {
153
- execFileSync(process.execPath, [join(root, 'scripts', 'build-hooks.js')], {
154
- cwd: root,
155
- stdio: 'inherit',
156
- });
157
- });
158
-
159
- // dist is "complete" only if every expected hook exists as a non-empty file.
160
- // Absent dir, empty dir, or a missing/zero-byte hook all trigger a rebuild.
161
- const complete = existsSync(distDir) && hookNames.every((hook) => {
162
- const p = join(distDir, hook);
163
- try {
164
- return existsSync(p) && statSync(p).size > 0;
165
- } catch {
166
- return false;
167
- }
168
- });
169
- if (!complete) {
170
- runBuild();
171
- }
172
- }
173
- const MARKED_SUITES = ['integration', 'install', 'security', 'slow', 'qa'];
174
-
175
- // Recursively collect *.test.cjs files under dir, returning paths relative to dir.
176
- // Skips node_modules to avoid accidentally picking up decoy files.
177
- function walkTestFiles(dir, relBase) {
178
- const results = [];
179
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
180
- if (entry.isDirectory()) {
181
- if (entry.name === 'node_modules') continue;
182
- results.push(...walkTestFiles(join(dir, entry.name), relBase ? `${relBase}/${entry.name}` : entry.name));
183
- } else if (entry.name.endsWith('.test.cjs')) {
184
- results.push(relBase ? `${relBase}/${entry.name}` : entry.name);
185
- }
186
- }
187
- return results;
188
- }
189
-
190
- // Parse a `--shard i/n` value into { index, total } or { error }.
191
- // i is 1-based and must satisfy 1 <= i <= n; n must be >= 1. Both parts must be
192
- // plain non-negative integers (no decimals, signs, or surrounding whitespace).
193
- // `n=1` is the pure no-op (every file). This is the strict-input boundary
194
- // (Postel's Law: be strict in what a CLI flag accepts so a typo fails loudly
195
- // rather than silently running the wrong slice of the suite).
196
- function parseShardArg(value) {
197
- if (typeof value !== 'string') {
198
- return { error: `--shard requires a value of the form i/n` };
199
- }
200
- const m = /^(\d+)\/(\d+)$/.exec(value);
201
- if (!m) {
202
- return { error: `--shard value "${value}" must be of the form i/n (e.g. 1/3)` };
203
- }
204
- const index = Number(m[1]);
205
- const total = Number(m[2]);
206
- if (!Number.isInteger(total) || total < 1) {
207
- return { error: `--shard total n must be an integer >= 1, got "${m[2]}"` };
208
- }
209
- if (!Number.isInteger(index) || index < 1 || index > total) {
210
- return { error: `--shard index i must be an integer in 1..${total}, got "${m[1]}"` };
211
- }
212
- return { index, total };
213
- }
214
-
215
- // Deterministic partition of an ALREADY-SORTED file list. Without a weigher
216
- // this is the original round-robin (#1212):
217
- // Shard `index` (1-based) receives every file whose position k in the sorted
218
- // list satisfies k % total === index - 1. Round-robin (not contiguous blocks)
219
- // spreads duration variance across shards and guarantees shard sizes differ by
220
- // at most 1. Selection keys off array INDEX, never off the path string, so the
221
- // partition is byte-identical across Windows/macOS/Linux as long as the caller
222
- // sorts the list with the same (locale-independent) comparator. `total=1`
223
- // returns the input unchanged (pure no-op). A shard with no files (total >
224
- // file count) returns [] and is a legitimate result, not an error.
225
- // `weightOf` (optional, #2472) switches the partition from equal COUNTS to
226
- // equal COST. Equal counts were only ever a proxy for equal duration, and on a
227
- // right-skewed suite the proxy fails: the real unit suite partitioned 12.4m /
228
- // 19.2m / 15.2m by index against a 20-minute job cap, and because assignment
229
- // keyed off array POSITION, inserting one test file re-indexed every file after
230
- // it and could tip the heaviest shard over. Weighting by measured cost fixes
231
- // both: LPT bounds the heaviest shard at 4/3 of optimal, and placement follows
232
- // a file's cost rather than its neighbours' names.
233
- //
234
- // This is the same algorithm packChunks uses one level down (#2456/#2463), so
235
- // both layers now share one cost model. Omitting `weightOf` keeps the legacy
236
- // round-robin byte-identical — callers with no timing data lose nothing.
237
- function selectShard(sortedFiles, { index, total }, weightOf) {
238
- if (total === 1) return sortedFiles;
239
- if (typeof weightOf !== 'function') {
240
- return sortedFiles.filter((_, k) => k % total === index - 1);
241
- }
242
- // A non-finite or negative weight must not poison bin arithmetic — one NaN
243
- // would make every subsequent comparison false and pile the rest of the suite
244
- // into bin 0. Mirrors packChunks' safeWeight for the same reason.
245
- const safeWeight = (file) => {
246
- const w = weightOf(file);
247
- return Number.isFinite(w) && w >= 0 ? w : 0;
248
- };
249
- const bins = Array.from({ length: total }, () => ({ weight: 0, picks: [] }));
250
- // LPT: heaviest first, each into the currently-lightest bin. Ties break on
251
- // the caller's sort position, and the lightest-bin scan takes the FIRST
252
- // minimum, so the partition is byte-identical across Windows/macOS/Linux —
253
- // the same determinism guarantee the round-robin path carries.
254
- const order = sortedFiles
255
- .map((file, k) => ({ k, weight: safeWeight(file) }))
256
- .sort((a, b) => b.weight - a.weight || a.k - b.k);
257
- for (const entry of order) {
258
- let lightest = 0;
259
- for (let i = 1; i < total; i += 1) {
260
- const bin = bins[i];
261
- const best = bins[lightest];
262
- // Weight first, then FILE COUNT. The count tiebreak is load-bearing, not
263
- // cosmetic: adding a zero-weight file leaves its bin's weight unchanged,
264
- // so on weight alone bin 0 stays tied-minimum forever and every
265
- // zero-weight file lands on it — all-zero weights put the whole suite on
266
- // shard 1 and leave the other runners idle. Zero weights are reachable
267
- // via safeWeight's clamp (a NaN/negative/Infinity entry in a hand-edited
268
- // or corrupted timings table) and via any genuinely 0ms measurement, so
269
- // the clamp above would otherwise reproduce the exact pile-onto-bin-0
270
- // failure it exists to prevent. Counting picks makes ties rotate.
271
- if (bin.weight < best.weight
272
- || (bin.weight === best.weight && bin.picks.length < best.picks.length)) {
273
- lightest = i;
274
- }
275
- }
276
- bins[lightest].weight += entry.weight;
277
- bins[lightest].picks.push(entry.k);
278
- }
279
- // Restore the caller's order within the shard: downstream chunking and argv
280
- // batching assume the list arrives sorted as the caller sorted it.
281
- return bins[index - 1].picks.sort((a, b) => a - b).map((k) => sortedFiles[k]);
282
- }
283
-
284
- // Read an operator-supplied numeric env knob, falling back to the default for
285
- // anything that is not a positive finite number.
286
- //
287
- // This is a strict-input boundary (Postel's Law: a typo must fail SAFE, not
288
- // silently poison arithmetic downstream). `Number('abc')` is NaN and
289
- // `Number('')` is 0, and both are load-bearing here: a NaN chunk budget makes
290
- // the chunk-count computation NaN, which spins packChunks' retry loop forever
291
- // (a hung CI job with no output); a zero budget makes it Infinity, which throws
292
- // `RangeError: Invalid array length`. Neither is an acceptable response to a
293
- // mistyped environment variable.
294
- function positiveNumberEnv(raw, fallback) {
295
- if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
296
- const n = Number(raw);
297
- return Number.isFinite(n) && n > 0 ? n : fallback;
298
- }
299
-
300
- // Per-file measured durations, regenerated by scripts/gen-test-timings.cjs from
301
- // gsd-test reporter event streams. Overridable so tests can inject a synthetic
302
- // table instead of depending on the real suite's cost profile.
303
- const DEFAULT_TIMINGS_PATH = join(__dirname, '..', 'tests', 'test-timings.json');
304
- // Must track SCHEMA_VERSION in scripts/gen-test-timings.cjs.
305
- const SUPPORTED_TIMINGS_SCHEMA = 1;
306
-
307
- // Load the timing table and reduce it to what the packer needs.
308
- //
309
- // Weights are normalized by the table's MEAN duration, so an average-cost file
310
- // weighs exactly 1 and `MAX_FILES_PER_CHUNK` keeps its original meaning ("about
311
- // N average files per chunk"). When every file costs the same, total weight
312
- // equals file count, so the chunk COUNT matches count-based packing exactly.
313
- // The chunk COMPOSITION still differs — LPT balances where first-fit filled
314
- // greedily, so 7 uniform files at budget 3 pack {3,2,2} rather than {3,3,1}.
315
- //
316
- // `medianWeight` is the fallback for a file absent from the table (a new test,
317
- // or a table that has drifted). The median — not the mean — because the cost
318
- // distribution is heavily right-skewed (median 0.28s vs mean 4.6s across the
319
- // suite), so the median is the honest estimate for an unknown file.
320
- //
321
- // Returns null when the table is missing or unusable; the caller then treats
322
- // every file as weight 1, which reproduces the pre-#2456 count-based balance.
323
- function loadTestTimings(timingsPath) {
324
- let parsed;
325
- try {
326
- parsed = JSON.parse(readFileSync(timingsPath, 'utf8'));
327
- } catch {
328
- return null;
329
- }
330
- if (!parsed || typeof parsed !== 'object') return null;
331
- // Refuse a table written by a future generator: a v2 schema could change the
332
- // unit or the key format, and consuming it under v1 semantics would silently
333
- // mis-weight every file. Returning null falls back to uniform weight, which
334
- // is the same graceful degradation as a missing table.
335
- if (parsed.schema_version !== undefined && parsed.schema_version !== SUPPORTED_TIMINGS_SCHEMA) {
336
- return null;
337
- }
338
- const timings = parsed.timings;
339
- // Array.isArray guard: `typeof [] === 'object'`, so a hand-edit that turned
340
- // the map into a list would pass a bare typeof check and be accepted as a
341
- // valid table. It degrades harmlessly (no basename ever matches an array
342
- // index, so every file takes medianWeight), but silently accepting a
343
- // malformed table is worse than rejecting it — reject, and fall back to
344
- // uniform weight the same way a missing file does.
345
- if (!timings || typeof timings !== 'object' || Array.isArray(timings)) return null;
346
- const values = Object.values(timings).filter(
347
- (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
348
- );
349
- if (values.length === 0) return null;
350
- const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
351
- if (!(mean > 0)) return null;
352
- const sorted = [...values].sort((a, b) => a - b);
353
- const mid = sorted.length >> 1;
354
- const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
355
- return { timings, mean, medianWeight: median / mean };
356
- }
357
-
358
- // Build the packer's weight function from a loaded timing table.
359
- //
360
- // A file present in the table weighs its measured duration relative to the
361
- // table mean. A file ABSENT from it weighs the table's median — this is the
362
- // "advisory, not gated" contract: a new test or a drifted table costs chunk
363
- // balance, never a red build. A null table (missing or unparseable file) makes
364
- // every file weigh 1, reproducing the pre-#2456 count-based balance exactly.
365
- function makeFileWeigher(timings) {
366
- if (!timings) return () => 1;
367
- return (f) => {
368
- const key = basename(f);
369
- // Own-property check before the lookup. This is defense-in-depth, NOT a
370
- // behavior change: the table is JSON-parsed, so a bare `timings[key]` would
371
- // walk the prototype chain, but the only keys that resolve there are
372
- // Object.prototype members (`constructor`, `toString`, …) and every real
373
- // selection is a `*.test.cjs` basename, which can never equal one. Even if
374
- // it could, the `typeof ms === 'number'` guard below already rejects the
375
- // function it would return. `Object.hasOwn` makes the intent explicit and
376
- // keeps the lookup correct for arbitrary input, since this function is
377
- // exported and does not control its caller's strings.
378
- const ms = Object.hasOwn(timings.timings, key) ? timings.timings[key] : undefined;
379
- return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0
380
- ? ms / timings.mean
381
- : timings.medianWeight;
382
- };
383
- }
384
-
385
- // Pack `files` into chunks using LPT (longest-processing-time-first): sort by
386
- // weight descending, then place each file into the currently-LIGHTEST chunk.
387
- //
388
- // #2456: the previous packer was a sequential first-fit that appended files in
389
- // selection order and closed a chunk once its weight budget was hit. Because
390
- // sorted-adjacent files land together, the two heaviest files in a shard packed
391
- // into the SAME chunk, leaving the slowest chunk ~3.9x the lightest and sitting
392
- // near the 600s per-chunk timeout while other chunks idled. LPT is the standard
393
- // greedy approximation for exactly this makespan problem and balanced the same
394
- // real shard to ~1.0x.
395
- //
396
- // Chunk COUNT is fixed before placement so LPT has bins to balance across:
397
- // ceil(totalWeight / maxWeight) — the weighted budget, and
398
- // ceil(fileCount / maxWeight) — a floor that pins the count at what the
399
- // old count-based packing would produce.
400
- // The floor is what makes a stale or missing timings table safe: unknown files
401
- // fall back to a small median weight, which on its own would collapse many files
402
- // into few fat chunks. With the floor, a degraded table can only ever reproduce
403
- // today's chunking, never something coarser.
404
- //
405
- // `maxChars` still bounds each chunk's argv (Windows CreateProcess caps
406
- // lpCommandLine at 32,767). A chunk that cannot fit the next file is skipped for
407
- // that file; when no chunk has room, the chunk count grows and packing restarts.
408
- // A single file longer than the budget lands alone rather than looping forever.
409
- //
410
- // Ordering is fully deterministic — ties break on the separator-normalized file
411
- // path, and each chunk's files are emitted in their original selection order —
412
- // so the packing is byte-identical across Windows/macOS/Linux.
413
- function packChunks(files, { weightOf, maxWeight, maxChars, fixedOverhead }) {
414
- if (files.length === 0) return [];
415
- // packChunks is exported, so it cannot assume its caller normalized these.
416
- // A non-finite or non-positive budget makes the chunk-count arithmetic
417
- // non-finite, which spins the retry loop below forever or throws from
418
- // Array.from; a non-finite weight propagates into the same computation.
419
- // Degrade to a safe bound instead.
420
- const weightBudget = Number.isFinite(maxWeight) && maxWeight > 0 ? maxWeight : files.length;
421
- const charBudget = Number.isFinite(maxChars) && maxChars > 0 ? maxChars : Number.MAX_SAFE_INTEGER;
422
- const overhead = Number.isFinite(fixedOverhead) && fixedOverhead >= 0 ? fixedOverhead : 0;
423
- const safeWeight = (file) => {
424
- const w = weightOf(file);
425
- return Number.isFinite(w) && w >= 0 ? w : 0;
426
- };
427
- const entries = files.map((file, index) => ({
428
- file,
429
- index,
430
- weight: safeWeight(file),
431
- chars: file.length + 1, // +1 for the inter-arg separator
432
- }));
433
- const totalWeight = entries.reduce((sum, e) => sum + e.weight, 0);
434
- // Ties break on a SEPARATOR-NORMALIZED path so a subdir file orders the same
435
- // on Windows as on POSIX: '/' is 0x2F and '\' is 0x5C, which straddle the
436
- // uppercase range, so comparing raw paths can order `sub/x.test.cjs` against
437
- // `subZ.test.cjs` differently per platform and silently produce a different
438
- // (still valid, but non-reproducible) packing.
439
- const sortKey = (f) => f.replace(/\\/g, '/');
440
- const heaviestFirst = [...entries].sort((a, b) => {
441
- if (b.weight !== a.weight) return b.weight - a.weight;
442
- const ka = sortKey(a.file);
443
- const kb = sortKey(b.file);
444
- return ka < kb ? -1 : ka > kb ? 1 : 0;
445
- });
446
-
447
- // Termination: the empty-bin rule below guarantees every file is placeable
448
- // once chunkCount reaches files.length, so the retry loop cannot run forever.
449
- // The upper clamp matters as much as the lower bound: a legitimate but tiny
450
- // budget (RUN_TESTS_MAX_FILES_PER_CHUNK=1e-9) would otherwise ask for
451
- // 637,000,000,000 bins and throw `RangeError: Invalid array length`. More
452
- // chunks than files is never useful — one file per chunk is the finest
453
- // possible packing.
454
- let chunkCount = Math.min(
455
- files.length,
456
- Math.max(1, Math.ceil(totalWeight / weightBudget), Math.ceil(files.length / weightBudget)),
457
- );
458
- for (;;) {
459
- const bins = Array.from({ length: chunkCount }, () => ({
460
- entries: [],
461
- weight: 0,
462
- chars: overhead,
463
- }));
464
- let overflowed = false;
465
- for (const entry of heaviestFirst) {
466
- let target = null;
467
- for (const bin of bins) {
468
- // An empty bin always accepts, so an over-long single file lands alone
469
- // instead of growing the chunk count forever.
470
- if (bin.entries.length > 0 && bin.chars + entry.chars > charBudget) continue;
471
- if (target === null || bin.weight < target.weight) target = bin;
472
- }
473
- if (target === null) {
474
- overflowed = true;
475
- break;
476
- }
477
- target.entries.push(entry);
478
- target.weight += entry.weight;
479
- target.chars += entry.chars;
480
- }
481
- if (!overflowed) {
482
- return bins
483
- .filter((bin) => bin.entries.length > 0)
484
- .map((bin) => bin.entries.sort((a, b) => a.index - b.index).map((e) => e.file));
485
- }
486
- chunkCount++;
487
- }
488
- }
489
-
490
- function parseArgs(argv) {
491
- let suite = null;
492
- let seen = false;
493
- let files = null;
494
- let filesFrom = null;
495
- let shard = null;
496
- let shardSeen = false;
497
- for (let i = 0; i < argv.length; i++) {
498
- const a = argv[i];
499
- if (a === '--shard' || a.startsWith('--shard=')) {
500
- if (shardSeen) {
501
- return { error: 'duplicate --shard flag' };
502
- }
503
- shardSeen = true;
504
- let v;
505
- if (a === '--shard') {
506
- v = argv[i + 1];
507
- if (v === undefined || (typeof v === 'string' && v.startsWith('--'))) {
508
- return { error: '--shard requires a value of the form i/n' };
509
- }
510
- i++;
511
- } else {
512
- v = a.slice('--shard='.length);
513
- }
514
- const parsed = parseShardArg(v);
515
- if (parsed.error) {
516
- return { error: parsed.error };
517
- }
518
- shard = parsed;
519
- } else if (a === '--suite') {
520
- if (seen) {
521
- return { error: 'duplicate --suite flag' };
522
- }
523
- seen = true;
524
- const v = argv[i + 1];
525
- if (!v || v.startsWith('--')) {
526
- return { error: '--suite requires a value' };
527
- }
528
- suite = v;
529
- i++;
530
- } else if (a.startsWith('--suite=')) {
531
- if (seen) {
532
- return { error: 'duplicate --suite flag' };
533
- }
534
- seen = true;
535
- suite = a.slice('--suite='.length);
536
- if (!suite) {
537
- return { error: '--suite requires a value' };
538
- }
539
- } else if (a === '--files') {
540
- if (files !== null) {
541
- return { error: 'duplicate --files flag' };
542
- }
543
- const v = argv[i + 1];
544
- if (!v || v.startsWith('--')) {
545
- return { error: '--files requires a value' };
546
- }
547
- files = v;
548
- i++;
549
- } else if (a.startsWith('--files=')) {
550
- if (files !== null) {
551
- return { error: 'duplicate --files flag' };
552
- }
553
- files = a.slice('--files='.length);
554
- if (!files) {
555
- return { error: '--files requires a value' };
556
- }
557
- } else if (a === '--files-from') {
558
- if (filesFrom !== null) {
559
- return { error: 'duplicate --files-from flag' };
560
- }
561
- const v = argv[i + 1];
562
- if (!v || v.startsWith('--')) {
563
- return { error: '--files-from requires a value' };
564
- }
565
- filesFrom = v;
566
- i++;
567
- } else if (a.startsWith('--files-from=')) {
568
- if (filesFrom !== null) {
569
- return { error: 'duplicate --files-from flag' };
570
- }
571
- filesFrom = a.slice('--files-from='.length);
572
- if (!filesFrom) {
573
- return { error: '--files-from requires a value' };
574
- }
575
- } else {
576
- return { error: `unknown argument: ${a}` };
577
- }
578
- }
579
- if (files !== null && filesFrom !== null) {
580
- return { error: '--files and --files-from cannot be combined' };
581
- }
582
- return { suite, files, filesFrom, shard };
583
- }
584
-
585
- // Return the marked suite name embedded in a filename, or null if it's unmarked.
586
- // foo.security.test.cjs -> "security"
587
- // foo.test.cjs -> null (unit)
588
- // Accepts either a bare filename or a relative subdir path; classification is
589
- // based on the basename only so subdir paths classify identically to root files.
590
- function suiteOf(filename) {
591
- const name = basename(filename);
592
- if (!name.endsWith('.test.cjs')) return null;
593
- const base = name.slice(0, -'.test.cjs'.length);
594
- const lastDot = base.lastIndexOf('.');
595
- if (lastDot === -1) return null;
596
- const marker = base.slice(lastDot + 1);
597
- return MARKED_SUITES.includes(marker) ? marker : null;
598
- }
599
-
600
- function selectFiles(allFiles, suite) {
601
- if (suite === null || suite === 'all') {
602
- return allFiles;
603
- }
604
- if (suite === 'unit') {
605
- return allFiles.filter(f => suiteOf(f) === null);
606
- }
607
- return allFiles.filter(f => suiteOf(f) === suite);
608
- }
609
-
610
- function splitFileList(value) {
611
- if (!value) return [];
612
- return value
613
- .split(/[,\s]+/)
614
- .map(v => v.trim())
615
- .filter(Boolean)
616
- .map(v => v.replace(/\\/g, '/')) // normalize Windows backslashes
617
- .map(v => v.replace(/^tests\//, ''));
618
- }
619
-
620
- function selectExplicitFiles(allFiles, filesValue, filesFrom) {
621
- const fs = require('fs');
622
- const requested = filesFrom
623
- ? splitFileList(fs.readFileSync(filesFrom, 'utf8'))
624
- : splitFileList(filesValue);
625
- const available = new Set(allFiles);
626
-
627
- // Build a basename -> [relpath, ...] index for bare-basename resolution.
628
- // A bare basename (no directory separator) may match exactly one subdir file.
629
- const basenameIndex = new Map();
630
- for (const f of allFiles) {
631
- const b = basename(f);
632
- if (!basenameIndex.has(b)) basenameIndex.set(b, []);
633
- basenameIndex.get(b).push(f);
634
- }
635
-
636
- const selected = [];
637
- const missing = [];
638
- const errors = [];
639
- for (const file of requested) {
640
- // If the token is a bare suite name (e.g. "unit" written by ci-test-scope
641
- // as the #408 fallback sentinel), delegate to the existing suite resolver
642
- // rather than treating it as a filename. This prevents the
643
- // "requested test file(s) not found: unit" crash (#641).
644
- if (SUITES.includes(file)) {
645
- for (const f of selectFiles(allFiles, file)) {
646
- selected.push(f);
647
- }
648
- } else if (available.has(file)) {
649
- // Exact relpath match (e.g. "installer-migrations/001-legacy-orphan-files.test.cjs").
650
- selected.push(file);
651
- } else if (!file.includes('/')) {
652
- // Bare basename (no directory separator): resolve via index.
653
- const candidates = basenameIndex.get(file);
654
- if (!candidates || candidates.length === 0) {
655
- missing.push(file);
656
- } else if (candidates.length > 1) {
657
- errors.push(
658
- `ambiguous basename "${file}" matches multiple files: ${candidates.join(', ')} — pass the subdir path instead`,
659
- );
660
- } else {
661
- selected.push(candidates[0]);
662
- }
663
- } else {
664
- missing.push(file);
665
- }
666
- }
667
- if (errors.length > 0) {
668
- return { error: errors.join('; ') };
669
- }
670
- if (missing.length > 0) {
671
- return {
672
- error: `requested test file(s) not found: ${missing.join(', ')}`,
673
- };
674
- }
675
- return { files: [...new Set(selected)] };
676
- }
677
-
678
- function main() {
679
- const args = process.argv.slice(2);
680
- const parsed = parseArgs(args);
681
- if (parsed.error) {
682
- console.error(`run-tests: ${parsed.error}`);
683
- console.error(`Valid suites: ${SUITES.join(', ')}`);
684
- throw new ExitError(2);
685
- }
686
- const suite = parsed.suite;
687
- if (suite !== null && !SUITES.includes(suite)) {
688
- console.error(`run-tests: unknown suite "${suite}"`);
689
- console.error(`Valid suites: ${SUITES.join(', ')}`);
690
- throw new ExitError(2);
691
- }
692
-
693
- const testDir = process.env.GSD_TEST_DIR
694
- ? process.env.GSD_TEST_DIR
695
- : join(__dirname, '..', 'tests');
696
-
697
- const allFiles = walkTestFiles(testDir, '').sort();
698
-
699
- if (allFiles.length === 0) {
700
- console.error(`No test files found in ${testDir}`);
701
- throw new ExitError(1);
702
- }
703
-
704
- const usingExplicitFiles = parsed.files !== null || parsed.filesFrom !== null;
705
- let selectedNames;
706
- if (usingExplicitFiles) {
707
- const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom);
708
- if (explicit.error) {
709
- console.error(`run-tests: ${explicit.error}`);
710
- throw new ExitError(2);
711
- }
712
- selectedNames = explicit.files;
713
- } else {
714
- selectedNames = selectFiles(allFiles, suite);
715
- }
716
-
717
- // Shard partitioning (#1212): when --shard i/n is given, keep only this
718
- // shard's deterministic cost-balanced slice of the selected list. Applied
719
- // AFTER suite/explicit selection so it composes with --suite (each shard
720
- // runs i/n of the post-filter selection).
721
- //
722
- // The partition keys off array index, so the slice is only reproducible if
723
- // the input is in a stable order. --suite/default selections are already
724
- // sorted (allFiles came from walkTestFiles(...).sort() and selectFiles
725
- // preserves that order), but --files/--files-from preserve REQUEST order.
726
- // Sort here so --shard is deterministic regardless of how the selection was
727
- // produced — the runner's documented contract is a sorted partition.
728
- //
729
- // emptyBeforeShard distinguishes "this shard legitimately got zero files
730
- // from a non-empty list" (total > file count — a valid no-op) from "the
731
- // selection was already empty before sharding" (a genuinely empty suite,
732
- // which must still hit the discovery hard-error below — Codex #1212 review).
733
- // Loaded before sharding because BOTH layers weigh by it now (#2472): the
734
- // shard partition below and the chunk packer further down share this one cost
735
- // model. Advisory in both places — a missing table yields uniform weight 1,
736
- // which makes the shard partition degenerate to the legacy equal-count split.
737
- // Lazily memoized: BOTH layers weigh by it now (#2472) — the shard partition
738
- // just below and the chunk packer further down share this one cost model —
739
- // but neither should charge a readFileSync + JSON.parse to an invocation that
740
- // exits before it needs one (an empty selection, or `--files` with nothing
741
- // matched). Memoized so the two consumers still read the table at most once.
742
- // Advisory in both places: a missing table yields uniform weight 1, under
743
- // which the shard partition degenerates to the legacy equal-count split.
744
- let weigherMemo = null;
745
- const fileWeightOf = () => {
746
- if (weigherMemo === null) {
747
- const timingsPath = process.env.RUN_TESTS_TIMINGS_FILE || DEFAULT_TIMINGS_PATH;
748
- weigherMemo = makeFileWeigher(loadTestTimings(timingsPath));
749
- }
750
- return weigherMemo;
751
- };
752
-
753
- const usingShard = parsed.shard !== null;
754
- let emptyBeforeShard = false;
755
- // The full pre-partition input, kept for the cross-job fingerprint below.
756
- // It must be the list every shard job sees, not this job's slice.
757
- let shardInput = null;
758
- if (usingShard) {
759
- emptyBeforeShard = selectedNames.length === 0;
760
- shardInput = [...selectedNames].sort();
761
- selectedNames = selectShard(shardInput, parsed.shard, fileWeightOf());
762
- }
763
-
764
- const selected = selectedNames.map(f => join(testDir, f));
765
-
766
- if (selected.length === 0) {
767
- // A legitimately-empty shard: --shard was given, the pre-shard selection
768
- // had files, but this shard index drew zero (total > file count). Exit 0.
769
- const legitimatelyEmptyShard = usingShard && !emptyBeforeShard;
770
- if (usingExplicitFiles || legitimatelyEmptyShard) {
771
- // Empty file list from --files/--files-from (e.g. CI passes an empty
772
- // .ci-selected-tests.txt on docs-only/inert PRs) OR a legitimately-empty
773
- // shard: both are expected. Exit 0 silently rather than taking the
774
- // "discovery broken" hard-error path below. An EMPTY suite that was
775
- // empty BEFORE sharding falls through to the hard error so a broken
776
- // --suite filter is still caught even with --shard present.
777
- console.error(`run-tests: no tests in suite "${suite || 'all'}"`);
778
- return 0;
779
- }
780
- // Empty suite/default run: this means discovery or the suite filter is broken.
781
- // Allow GSD_ALLOW_EMPTY_SUITE=1 as an escape hatch (downgrades to a warning).
782
- if (process.env.GSD_ALLOW_EMPTY_SUITE === '1') {
783
- console.error(`run-tests: WARNING: 0 test files selected for suite "${suite || 'all'}" — discovery or suite filter may be broken (GSD_ALLOW_EMPTY_SUITE=1 suppressed the error)`);
784
- return 0;
785
- }
786
- console.error(`run-tests: ERROR: 0 test files selected for suite "${suite || 'all'}" — discovery or suite filter is broken`);
787
- throw new ExitError(1);
788
- }
789
-
790
- // Build the gitignored bin/lib artifact if absent, before any test requires it.
791
- ensureBuiltArtifacts();
792
-
793
- // Build the gitignored hooks/dist artifact once, before any concurrent install
794
- // test spawns install.js and reads it — closes the first-build empty-dir race
795
- // that intermittently failed the scoped CI lane (see ensureBuiltHooks above).
796
- ensureBuiltHooks();
797
-
798
- // Hermeticity: in-process tests resolve `.planning` via planningDir(cwd), which
799
- // honours GSD_PROJECT/GSD_WORKSTREAM. A developer shell inside a GSD workstream
800
- // exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from
801
- // each <tmp>/.planning and silently diverge from the clean CI/Docker env. Strip
802
- // them so the local runner matches CI; tests that need them set them explicitly.
803
- delete process.env.GSD_PROJECT;
804
- delete process.env.GSD_WORKSTREAM;
805
- delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
806
- // Sandbox the overlay home so the loader's global scan ($GSD_HOME/.gsd/capabilities)
807
- // cannot read a developer's real installed capabilities during tests (ADR-1244 D2).
808
- // IDEMPOTENT: a nested run-tests spawn (e.g. tests/run-tests-harness.test.cjs)
809
- // inherits this sandbox via env — it must REUSE it, never mkdtemp a fresh dir per
810
- // invocation (that churned ~20+ temp dirs per harness run and amplified Docker load).
811
- {
812
- const { mkdtempSync } = require('fs');
813
- const { join: _join, basename: _basename } = require('path');
814
- const { tmpdir } = require('os');
815
- const _gh = process.env.GSD_HOME;
816
- if (!_gh || !_basename(_gh).startsWith('gsd-test-home-')) {
817
- process.env.GSD_HOME = mkdtempSync(_join(tmpdir(), 'gsd-test-home-'));
818
- }
819
- }
820
-
821
- // Log selected files to stderr for CI / harness-test visibility.
822
- // node:test default reporter doesn't echo filenames, so this gives
823
- // operators a single stable line they can grep.
824
- console.error(
825
- `run-tests: suite="${suite || 'all'}" files=${selected.length}: ${selected
826
- .map(f => f.split(/[\\/]/).pop())
827
- .join(' ')}`,
828
- );
829
-
830
- // Shard diagnostics (#2472). File COUNT stopped being a balance signal the
831
- // moment the partition started weighing by cost — two shards can now hold
832
- // very different counts by design — so the count line above can no longer be
833
- // eyeballed to spot a bad split. Worse, each shard job computes its partition
834
- // independently on its own runner: if the inputs differ between jobs (the
835
- // file list, or this table), two jobs can place the same file in different
836
- // shards, or in none, and every job still looks internally consistent. That
837
- // failure is silent — a test simply never runs and CI stays green.
838
- //
839
- // `sig` is the defense: a cheap fingerprint of the exact inputs the partition
840
- // consumed. Every shard job of a given run must print the SAME sig; a
841
- // mismatch across jobs is proof the runners disagreed about the input and
842
- // therefore about the partition. `weighed` reports how many of this shard's
843
- // files matched a real measurement — a table that silently failed to parse
844
- // shows weighed=0 instead of being indistinguishable from a healthy load.
845
- if (usingShard) {
846
- const weigher = fileWeightOf();
847
- const table = loadTestTimings(process.env.RUN_TESTS_TIMINGS_FILE || DEFAULT_TIMINGS_PATH);
848
- const mine = selectedNames.map(f => f.split(/[\\/]/).pop());
849
- const weighed = table
850
- ? mine.filter(n => Object.hasOwn(table.timings, n)).length
851
- : 0;
852
- const myWeight = mine.reduce((sum, n) => sum + weigher(n), 0);
853
- // Fingerprint the FULL pre-partition input — the file list and the weight
854
- // each file was assigned — NOT this shard's slice. Every shard job of one
855
- // run must print an identical sig; a mismatch is proof the runners
856
- // disagreed about the input, which is the only way the union of shards can
857
- // silently drop or duplicate a file. Order-independent sum of per-file
858
- // (name, weight) hashes: stable across platforms, cheap for ~600 files.
859
- let sig = 0;
860
- for (const n of shardInput.map(f => f.split(/[\\/]/).pop())) {
861
- let h = 2166136261;
862
- for (let i = 0; i < n.length; i += 1) {
863
- h = Math.imul(h ^ n.charCodeAt(i), 16777619);
864
- }
865
- sig = (sig + (h >>> 0) + Math.round(weigher(n) * 1000)) % 0xffffffff;
866
- }
867
- console.error(
868
- `run-tests: shard=${parsed.shard.index}/${parsed.shard.total} `
869
- + `files=${mine.length}/${shardInput.length} weighed=${weighed} `
870
- + `weight=${myWeight.toFixed(2)} table=${table ? 'loaded' : 'absent'} `
871
- + `sig=${sig.toString(16)}`,
872
- );
873
- }
874
-
875
- // Default concurrency: 4 on Linux/macOS, 2 on Windows.
876
- //
877
- // Windows has significantly higher per-subprocess overhead than Linux/macOS:
878
- // - Windows Defender scans each spawned process on first execution, adding
879
- // latency proportional to the number of concurrent spawns.
880
- // - NTFS has higher file-system latency under concurrent access compared to
881
- // ext4/APFS, which amplifies contention when multiple test chunks run in
882
- // parallel and all read/write the same fixture directories.
883
- // Reducing to 2 halves the peak concurrent subprocess count on Windows and
884
- // keeps per-chunk wall-clock time well within the 20m CI job cap.
885
- //
886
- // Operator override via TEST_CONCURRENCY env var for local debugging.
887
- const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
888
- const concurrency = process.env.TEST_CONCURRENCY
889
- ? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
890
- : `--test-concurrency=${defaultConcurrency}`;
891
-
892
- // Windows `CreateProcess` caps the full command line at 32,767 chars
893
- // (lpCommandLine). With 500+ test paths the spawn fails instantly with no
894
- // test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
895
- // fine there. Split into chunks sized for the tightest target so behavior
896
- // is identical across platforms. (#3597)
897
- // Operator override (also used by tests to force chunking with short paths).
898
- const MAX_CMDLINE_CHARS = positiveNumberEnv(
899
- process.env.RUN_TESTS_MAX_CMDLINE_CHARS,
900
- 28000, // headroom below the 32,767 Windows ceiling
901
- );
902
- // A full-lane shard (~171 files) fit in ONE chunk at the old cap of 180, so the
903
- // entire shard's wall-clock ran against a single per-chunk timeout. On the slow
904
- // Windows runner the install-heavy files in a shard (e.g. install-minimal-hooks
905
- // .test.cjs alone runs ~250 cases doing dozens of real installs) push that single
906
- // chunk past the 600s per-chunk backstop — killed mid-run while still making slow
907
- // progress (verified: no leaked handle / hang; --test-force-exit exits leaks
908
- // cleanly, so the timeout was pure slowness, NOT the leak the kill message guesses).
909
- // The per-chunk timeout is sized for a "healthy chunk (~4-5 min)"; keep chunks at
910
- // roughly a third of a shard so each gets its own fresh 600s budget and a fresh
911
- // node process (also relieving per-process memory pressure from 170+ files at once).
912
- // Lowered from 90 to 60 after #1575 — macOS Node 22 shard 2/3 chunk 2 (~80 files
913
- // including state.test.cjs, perf-*, worktree-cleanup) exceeded 600s with 90.
914
- const MAX_FILES_PER_CHUNK = positiveNumberEnv(process.env.RUN_TESTS_MAX_FILES_PER_CHUNK, 60);
915
- // #2088 established that file COUNT is a poor proxy for a chunk's wall-clock:
916
- // install-heavy files (real installs) cost ~10x a unit file, and when several
917
- // land in the SAME chunk it blows the 600s backstop while unit-only chunks
918
- // finish in seconds. #2088 approximated cost from the filename — basename
919
- // matching /^(?:install|codex-)/ scored 12, everything else 1.
920
- //
921
- // #2456: that approximation is miscalibrated in BOTH directions, so chunks were
922
- // still balanced by file count rather than by cost. Measured durations show
923
- // installer-migration-authoring.test.cjs scoring 12 while running ~0.1s, and the
924
- // two heaviest files in the whole suite scoring 1 — run-tests-harness.test.cjs
925
- // (never matched the prefix) and release-tarball-smoke.install.test.cjs (the
926
- // regex is anchored to the START of the basename, so a mid-name "install" never
927
- // matches). Both landed in the same chunk, leaving the slowest chunk ~3.9x the
928
- // lightest and sitting near the timeout.
929
- //
930
- // Weight each file by its MEASURED duration instead. `MAX_FILES_PER_CHUNK`
931
- // remains the per-chunk weight budget and keeps its scale — weights are
932
- // normalized so an average-cost file weighs 1 — so an all-uniform suite chunks
933
- // exactly as it did before. Timings are ADVISORY, never gated: an unknown file
934
- // falls back to the table's median weight and a missing table falls back to
935
- // uniform weight 1, so staleness degrades chunk BALANCE gracefully instead of
936
- // failing CI. Regenerate via `node scripts/gen-test-timings.cjs <events.jsonl>`.
937
- // The cost table is loaded lazily above and memoized; both the shard
938
- // partition and this packer consume the same weigher (#2472).
939
-
940
- // node:test does not exit until the event loop drains. A unit test that leaks
941
- // an open handle (un-terminated Worker, un-killed child_process, ref'd timer)
942
- // makes a chunk's `node --test` child hang ~150s on Windows AFTER its last test
943
- // prints; two such stalls push the windows full lane past its 20m cap and the
944
- // job is CANCELLED with no failed step — a false-negative gate (#1051, recurrence
945
- // of #869). --test-force-exit (Node >=22; engines requires >=22.0.0) exits the
946
- // runner once all tests finish regardless of lingering handles. The leaking
947
- // tests are also fixed at the source; this is the defensive backstop.
948
- // RUN_TESTS_NO_FORCE_EXIT=1 disables it (used by the harness regression test to
949
- // observe the pre-fix hang).
950
- const nodeMajor = Number(process.versions.node.split('.')[0]);
951
- const forceExit = nodeMajor >= 22 && !process.env.RUN_TESTS_NO_FORCE_EXIT;
952
-
953
- const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + (forceExit ? '--test-force-exit'.length + 1 : 0) + 8;
954
- const chunks = packChunks(selected, {
955
- weightOf: fileWeightOf(),
956
- maxWeight: MAX_FILES_PER_CHUNK,
957
- maxChars: MAX_CMDLINE_CHARS,
958
- fixedOverhead: FIXED_OVERHEAD,
959
- });
960
-
961
- // A chunk that still hangs (a leak the backstop somehow misses, or a wedged
962
- // subprocess) must fail loudly rather than silently burn the job's wall-clock
963
- // budget until the CI runner cancels the whole job. Default 10 min per chunk:
964
- // well above a healthy chunk (~4-5 min on the windows lane) but below the 20m
965
- // job cap. Operator/test override via RUN_TESTS_CHUNK_TIMEOUT_MS.
966
- const chunkTimeoutMs = positiveNumberEnv(process.env.RUN_TESTS_CHUNK_TIMEOUT_MS, 600000);
967
-
968
- let firstFailureExit = 0;
969
- for (let i = 0; i < chunks.length; i++) {
970
- if (chunks.length > 1) {
971
- console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
972
- }
973
- try {
974
- execFileSync(
975
- process.execPath,
976
- ['--test', ...(forceExit ? ['--test-force-exit'] : []), concurrency, ...chunks[i]],
977
- {
978
- stdio: 'inherit',
979
- env: { ...process.env },
980
- timeout: chunkTimeoutMs,
981
- },
982
- );
983
- } catch (err) {
984
- // When the per-chunk timeout fires, execFileSync kills the child and
985
- // surfaces it as err.code === 'ETIMEDOUT' (POSIX) and/or err.killed === true
986
- // (platform-dependent). Check both so detection holds on Windows and POSIX.
987
- const timedOut = err.killed === true || err.code === 'ETIMEDOUT';
988
- if (timedOut) {
989
- console.error(
990
- `run-tests: chunk ${i + 1}/${chunks.length} exceeded the per-chunk timeout ` +
991
- `of ${chunkTimeoutMs}ms and was killed. Two possible causes: (1) a test leaks ` +
992
- `an open handle (un-terminated Worker, un-killed child process, or ref'd timer) ` +
993
- `so node --test never exits — but --test-force-exit already guards that, so if it ` +
994
- `is enabled suspect (2) the chunk is legitimately too slow for the budget (too ` +
995
- `many/too-heavy files packed together). Check whether output kept flowing until ` +
996
- `the kill (slow) vs stopped early (hang) before assuming a leak. Files: ${chunks[i]
997
- .map(f => f.split(/[\\/]/).pop())
998
- .join(' ')}`,
999
- );
1000
- }
1001
- const code = err.status || 1;
1002
- if (firstFailureExit === 0) firstFailureExit = code;
1003
- if (timedOut) {
1004
- // A timeout has already burned a large share of the job's budget
1005
- // (chunkTimeoutMs defaults to 600000ms, i.e. half the 20m CI job
1006
- // cap), so — unlike an ordinary test failure — letting the loop
1007
- // fall through to the remaining chunks risks the CI runner
1008
- // cancelling the whole job before they finish. That cancellation
1009
- // replaces the loud, specific diagnostic printed above with an
1010
- // opaque "The operation was canceled." buried at the very end of
1011
- // the log, thousands of lines past the real cause (observed live on
1012
- // CI run 29749380190: chunk 1/5 timed out, the loop pressed on
1013
- // through chunks 2-4, and the job was cancelled mid-chunk-5 — the
1014
- // timeout message was ~38,000 log lines from the end and
1015
- // `gh run view --log-failed` returned nothing). Abort the remaining
1016
- // chunks instead so the operator actually sees this message.
1017
- const skipped = chunks.length - (i + 1);
1018
- if (skipped > 0) {
1019
- console.error(
1020
- `run-tests: aborting — skipping the remaining ${skipped} chunk${skipped === 1 ? '' : 's'} ` +
1021
- `after the chunk ${i + 1}/${chunks.length} timeout rather than risk the CI runner ` +
1022
- `cancelling the job (and burying this diagnostic) before they finish.`,
1023
- );
1024
- }
1025
- break;
1026
- }
1027
- // A non-timeout failure is cheap in wall-clock terms (the child exits
1028
- // promptly on its own), so — unlike the timeout case above — run every
1029
- // remaining chunk anyway: the operator sees all failures in one pass,
1030
- // and the first non-zero exit is reported at the end.
1031
- }
1032
- }
1033
- if (firstFailureExit !== 0) return firstFailureExit;
1034
- }
1035
-
1036
- if (require.main === module) {
1037
- runMain(main);
1038
- }
1039
-
1040
- module.exports = {
1041
- suiteOf,
1042
- ensureBuiltArtifacts,
1043
- ensureBuiltHooks,
1044
- parseShardArg,
1045
- selectShard,
1046
- positiveNumberEnv,
1047
- loadTestTimings,
1048
- makeFileWeigher,
1049
- packChunks,
1050
- DEFAULT_TIMINGS_PATH,
1051
- };