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