@herbertgao/pi-extensions 2026.9.2 → 2026.9.4

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 (255) hide show
  1. package/README.md +4 -2
  2. package/THIRD_PARTY_NOTICES.md +49 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +3 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +16 -11
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +20 -6
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +12 -7
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +98 -0
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  9. package/node_modules/@herbertgao/sol-pi/LICENSE +19 -0
  10. package/node_modules/@herbertgao/sol-pi/README.md +159 -0
  11. package/node_modules/@herbertgao/sol-pi/SECURITY.md +26 -0
  12. package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +19 -0
  13. package/node_modules/@herbertgao/sol-pi/agents-install.md +150 -0
  14. package/node_modules/@herbertgao/sol-pi/assets/sol-pi-hero.png +0 -0
  15. package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +69 -0
  16. package/node_modules/@herbertgao/sol-pi/docs/configuration.md +75 -0
  17. package/node_modules/@herbertgao/sol-pi/package.json +76 -0
  18. package/node_modules/@herbertgao/sol-pi/scripts/check-pi-compat.mjs +32 -0
  19. package/node_modules/@herbertgao/sol-pi/scripts/check-sol-pi-config.mjs +120 -0
  20. package/node_modules/@herbertgao/sol-pi/sol-pi.example.json +10 -0
  21. package/node_modules/@herbertgao/sol-pi/src/sol-pi/config.ts +135 -0
  22. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/file-queue.ts +74 -0
  23. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/index.ts +185 -0
  24. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/action-fusion/then-run.ts +128 -0
  25. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/archive.ts +53 -0
  26. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/candidate.ts +101 -0
  27. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/config.ts +71 -0
  28. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/index.ts +220 -0
  29. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/journal.ts +25 -0
  30. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/provider.ts +164 -0
  31. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/evidence-preserving-reducer/receipt.ts +177 -0
  32. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/index.ts +227 -0
  33. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/ledger.ts +20 -0
  34. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/observation-pack/observation.ts +252 -0
  35. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/economics.ts +237 -0
  36. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/extension.ts +455 -0
  37. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/index.ts +49 -0
  38. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/plan.ts +79 -0
  39. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/state.ts +208 -0
  40. package/node_modules/@herbertgao/sol-pi/src/sol-pi/extensions/online-context-compact/tools.ts +100 -0
  41. package/node_modules/@herbertgao/sol-pi/src/sol-pi/index.ts +42 -0
  42. package/node_modules/@herbertgao/sol-pi/src/sol-pi/runtime-paths.ts +17 -0
  43. package/node_modules/@herbertgao/sol-pi/src/sol-pi/tui.ts +71 -0
  44. package/node_modules/@narumitw/pi-btw/dist/index.ts +12 -2
  45. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  46. package/node_modules/@narumitw/pi-btw/package.json +1 -1
  47. package/node_modules/@narumitw/pi-btw/src/btw.ts +13 -2
  48. package/node_modules/pi-antigravity/LICENSE +21 -0
  49. package/node_modules/pi-antigravity/README.md +194 -0
  50. package/node_modules/pi-antigravity/package.json +67 -0
  51. package/node_modules/pi-antigravity/src/auth/index.ts +14 -0
  52. package/node_modules/pi-antigravity/src/auth/oauth.ts +442 -0
  53. package/node_modules/pi-antigravity/src/client/client.ts +561 -0
  54. package/node_modules/pi-antigravity/src/client/index.ts +1 -0
  55. package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  56. package/node_modules/pi-antigravity/src/diagnostics/index.ts +1 -0
  57. package/node_modules/pi-antigravity/src/image/image.ts +336 -0
  58. package/node_modules/pi-antigravity/src/image/index.ts +1 -0
  59. package/node_modules/pi-antigravity/src/index.ts +280 -0
  60. package/node_modules/pi-antigravity/src/models/discovery.ts +154 -0
  61. package/node_modules/pi-antigravity/src/models/grouping.ts +424 -0
  62. package/node_modules/pi-antigravity/src/models/index.ts +3 -0
  63. package/node_modules/pi-antigravity/src/models/models.ts +500 -0
  64. package/node_modules/pi-antigravity/src/stream/index.ts +1 -0
  65. package/node_modules/pi-antigravity/src/stream/stream.ts +1460 -0
  66. package/node_modules/pi-antigravity/src/types/enums.ts +42 -0
  67. package/node_modules/pi-antigravity/src/types/index.ts +2 -0
  68. package/node_modules/pi-antigravity/src/types/types.ts +292 -0
  69. package/node_modules/pi-antigravity/src/usage/index.ts +1 -0
  70. package/node_modules/pi-antigravity/src/usage/usage.ts +371 -0
  71. package/node_modules/pi-antigravity/src/utils/http.ts +91 -0
  72. package/node_modules/pi-antigravity/src/utils/index.ts +3 -0
  73. package/node_modules/pi-antigravity/src/utils/security.ts +73 -0
  74. package/node_modules/pi-antigravity/src/utils/util.ts +132 -0
  75. package/node_modules/pi-antigravity/tsconfig.json +21 -0
  76. package/node_modules/pi-lens/CHANGELOG.md +176 -0
  77. package/node_modules/pi-lens/README.md +13 -8
  78. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +3 -1
  79. package/node_modules/pi-lens/dist/clients/analysed-root.js +1 -0
  80. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +1 -1
  81. package/node_modules/pi-lens/dist/clients/biome-client.js +13 -2
  82. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +2 -0
  83. package/node_modules/pi-lens/dist/clients/cache-observability.js +122 -1
  84. package/node_modules/pi-lens/dist/clients/complexity-client.js +5 -0
  85. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +2 -0
  86. package/node_modules/pi-lens/dist/clients/config-resolve.js +6 -3
  87. package/node_modules/pi-lens/dist/clients/config-schema.js +17 -0
  88. package/node_modules/pi-lens/dist/clients/config-warn.js +2 -2
  89. package/node_modules/pi-lens/dist/clients/dead-code-client.js +8 -1
  90. package/node_modules/pi-lens/dist/clients/dependency-checker.js +5 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +38 -15
  92. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +1 -1
  93. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +5 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +2 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +2 -3
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -3
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +2 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +2 -1
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +2 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +2 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +2 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +2 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +2 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +2 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +2 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +3 -1
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +2 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +2 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +2 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +2 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +19 -1
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +3 -2
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +2 -1
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +2 -1
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +2 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +2 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +2 -1
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +12 -12
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +2 -1
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +2 -1
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +2 -2
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +12 -11
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -1
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -1
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +2 -2
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +3 -2
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +3 -2
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +2 -1
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +2 -1
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +2 -1
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +2 -1
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +5 -0
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +2 -2
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +8 -2
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +2 -1
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +4 -3
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +2 -1
  143. package/node_modules/pi-lens/dist/clients/effective-config.js +12 -2
  144. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -0
  145. package/node_modules/pi-lens/dist/clients/file-role.js +18 -1
  146. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +4 -0
  147. package/node_modules/pi-lens/dist/clients/formatters.js +54 -188
  148. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -0
  149. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +42 -6
  150. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +48 -7
  151. package/node_modules/pi-lens/dist/clients/installer/index.js +196 -52
  152. package/node_modules/pi-lens/dist/clients/jscpd-client.js +9 -1
  153. package/node_modules/pi-lens/dist/clients/knip-client.js +9 -2
  154. package/node_modules/pi-lens/dist/clients/language-profile.js +12 -2
  155. package/node_modules/pi-lens/dist/clients/latency-logger.js +2 -0
  156. package/node_modules/pi-lens/dist/clients/lens-config.js +4 -0
  157. package/node_modules/pi-lens/dist/clients/lsp/config.js +36 -7
  158. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +24 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +78 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/index.js +281 -45
  161. package/node_modules/pi-lens/dist/clients/lsp/server.js +62 -15
  162. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +37 -4
  163. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +3 -0
  164. package/node_modules/pi-lens/dist/clients/mcp/session.js +2 -0
  165. package/node_modules/pi-lens/dist/clients/opengrep-client.js +2 -0
  166. package/node_modules/pi-lens/dist/clients/package-manager.js +2 -1
  167. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +8 -3
  168. package/node_modules/pi-lens/dist/clients/pipeline.js +3 -2
  169. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +38 -10
  170. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +10 -9
  171. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  172. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +2 -0
  173. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +2 -0
  174. package/node_modules/pi-lens/dist/clients/ruff-client.js +18 -9
  175. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +9 -0
  176. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +5 -0
  177. package/node_modules/pi-lens/dist/clients/runtime-session.js +13 -4
  178. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +4 -1
  179. package/node_modules/pi-lens/dist/clients/runtime-turn.js +48 -1
  180. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -2
  181. package/node_modules/pi-lens/dist/clients/session-event-guard.js +69 -1
  182. package/node_modules/pi-lens/dist/clients/sg-runner.js +2 -1
  183. package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +108 -0
  184. package/node_modules/pi-lens/dist/clients/test-runner-client.js +291 -76
  185. package/node_modules/pi-lens/dist/clients/tool-config.js +199 -0
  186. package/node_modules/pi-lens/dist/clients/tool-cwd.js +299 -0
  187. package/node_modules/pi-lens/dist/clients/tool-probe.js +53 -0
  188. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +42 -0
  189. package/node_modules/pi-lens/dist/clients/trivy-client.js +9 -1
  190. package/node_modules/pi-lens/dist/clients/turn-context.js +52 -0
  191. package/node_modules/pi-lens/dist/clients/widget-state.js +4 -3
  192. package/node_modules/pi-lens/dist/index.js +21969 -20005
  193. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -2
  194. package/node_modules/pi-lens/dist/mcp/server.js +135 -159
  195. package/node_modules/pi-lens/dist/tools/activate-tools.js +10 -7
  196. package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +2 -13
  197. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +8 -13
  198. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +83 -39
  199. package/node_modules/pi-lens/dist/tools/effective-config.js +4 -3
  200. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +1 -10
  201. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +353 -85
  202. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +21 -110
  203. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +20 -44
  204. package/node_modules/pi-lens/dist/tools/module-report.js +5 -9
  205. package/node_modules/pi-lens/dist/tools/project-report.js +9 -17
  206. package/node_modules/pi-lens/dist/tools/render-compact.js +280 -1
  207. package/node_modules/pi-lens/dist/tools/symbol-search.js +2 -2
  208. package/node_modules/pi-lens/docs/agent-guide.md +3 -4
  209. package/node_modules/pi-lens/docs/agent-tools.md +46 -22
  210. package/node_modules/pi-lens/docs/configuration.md +9 -0
  211. package/node_modules/pi-lens/docs/environment-variables.md +0 -5
  212. package/node_modules/pi-lens/docs/features.md +5 -5
  213. package/node_modules/pi-lens/docs/globalconfig.md +11 -1
  214. package/node_modules/pi-lens/docs/mcp.md +13 -1
  215. package/node_modules/pi-lens/docs/pi-lens-fixer.md +71 -0
  216. package/node_modules/pi-lens/docs/pi-lens-investigator.md +15 -0
  217. package/node_modules/pi-lens/docs/pi-lens-monitor.md +88 -0
  218. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +35 -0
  219. package/node_modules/pi-lens/docs/pi-lens-subagent.md +12 -4
  220. package/node_modules/pi-lens/docs/public-api-stability.md +1 -0
  221. package/node_modules/pi-lens/docs/real-harness.md +46 -0
  222. package/node_modules/pi-lens/docs/release-qa-baseline.md +5 -1
  223. package/node_modules/pi-lens/docs/servercapabilities.md +1 -6
  224. package/node_modules/pi-lens/docs/settings.md +37 -2
  225. package/node_modules/pi-lens/docs/tools_improvement2.md +4 -4
  226. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +1 -1
  227. package/node_modules/pi-lens/docs/usage.md +13 -3
  228. package/node_modules/pi-lens/package.json +10 -1
  229. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml +7 -3
  230. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +8 -6
  231. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +19 -9
  232. package/node_modules/pi-web-access/CHANGELOG.md +29 -0
  233. package/node_modules/pi-web-access/README.md +33 -22
  234. package/node_modules/pi-web-access/abortable.ts +17 -0
  235. package/node_modules/pi-web-access/crawl4ai.ts +204 -0
  236. package/node_modules/pi-web-access/credential-source.ts +1 -0
  237. package/node_modules/pi-web-access/curator-page.ts +20 -2
  238. package/node_modules/pi-web-access/curator-run.ts +44 -0
  239. package/node_modules/pi-web-access/curator-server.ts +3 -1
  240. package/node_modules/pi-web-access/duckduckgo.ts +1 -1
  241. package/node_modules/pi-web-access/extract.ts +57 -12
  242. package/node_modules/pi-web-access/fetch-params.ts +1 -1
  243. package/node_modules/pi-web-access/gemini-search.ts +9 -5
  244. package/node_modules/pi-web-access/index.ts +25 -18
  245. package/node_modules/pi-web-access/openai-search.ts +35 -13
  246. package/node_modules/pi-web-access/package.json +2 -2
  247. package/node_modules/pi-web-access/page-query.ts +5 -2
  248. package/node_modules/pi-web-access/query-rewrite.ts +5 -2
  249. package/node_modules/pi-web-access/serpapi.ts +220 -0
  250. package/node_modules/pi-web-access/ssrf-protection.ts +5 -1
  251. package/node_modules/pi-web-access/storage.ts +10 -0
  252. package/node_modules/pi-web-access/summary-review.ts +34 -21
  253. package/node_modules/pi-web-access/utils.ts +6 -4
  254. package/package.json +11 -5
  255. package/node_modules/pi-lens/dist/tools/ast-dump.js +0 -103
@@ -18,14 +18,18 @@ import { emitBounded } from "./bounded-telemetry.js";
18
18
  import { LEDGER_FIELD_MAX } from "./degradation-ledger.js";
19
19
  import { minimatch } from "./deps/minimatch.js";
20
20
  import { createSubsystemLogger } from "./extension-log.js";
21
+ import { detectFileKind } from "./file-kinds.js";
21
22
  import { detectFileRole } from "./file-role.js";
23
+ import { resolveLanguageRootForFile } from "./language-profile.js";
22
24
  import { findGlobalBinary } from "./package-manager.js";
23
25
  import { PathKeyedMap } from "./path-keyed-map.js";
24
26
  import { augmentPythonEnvironment, detectPythonEnvironment, } from "./python-environment.js";
25
- import { normalizeEphemeralMapKey, normalizeMapKey, toPosix, } from "./path-utils.js";
27
+ import { isUnderDir, normalizeEphemeralMapKey, normalizeMapKey, toPosix, } from "./path-utils.js";
28
+ import { findNearestDirWithAnyBasename } from "./workspace-topology.js";
26
29
  import { isMeasuredDuration, toMeasuredDurationMs } from "./run-duration.js";
27
30
  import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
28
31
  import { stripAnsi } from "./sanitize.js";
32
+ import { resolveToolCwd } from "./tool-cwd.js";
29
33
  /**
30
34
  * #2532: the ONE classification seam for "this `TestResult` carries NO
31
35
  * counted failure the agent needs to fix, even though the runner also
@@ -99,10 +103,26 @@ const SOURCE_TO_TEST_PATTERNS = [
99
103
  // basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
100
104
  { ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] },
101
105
  ];
102
- // Bound for walking up parent directories to find a hoisted node_modules
103
- // (monorepo workspaces) deep enough for realistic nesting
104
- // (repo/packages/scope/pkg-name), never unbounded to the filesystem root.
105
- const MAX_NODE_MODULES_WALK_UP = 5;
106
+ /**
107
+ * Nearest `node_modules/<packageName>` at or above `cwd` — monorepo workspace
108
+ * hoisting (npm/yarn/pnpm), where a workspace package's own `node_modules` may
109
+ * not exist at all and the runner lives in the workspace root's.
110
+ *
111
+ * #2870 net-count: this was a private bounded climb loop of its own (the
112
+ * fourth walker in this file). It is now the shared workspace-topology
113
+ * walker every other per-directory marker walk in the codebase goes through
114
+ * — same `walkUpDirs` primitive, the `$HOME` ceiling the private loop
115
+ * lacked, the `MAX_WALK_DEPTH` cap with its cap-trip latency record, and the
116
+ * per-directory marker cache, so a chain already walked for a language root
117
+ * is not re-`readdir`'d here. Returns the directory CONTAINING
118
+ * `node_modules` (the private loop returned the `node_modules` path itself;
119
+ * only the log line reads it).
120
+ */
121
+ function findHoistedNodeModulesPackage(cwd, packageName) {
122
+ return findNearestDirWithAnyBasename(path.resolve(cwd), [
123
+ `node_modules/${packageName}`,
124
+ ]);
125
+ }
106
126
  // Bound for recursive descent into a Python test directory when the exact
107
127
  // same-relative-subdir mirror doesn't match (e.g. tests/unit/ grouping by
108
128
  // test type rather than mirroring source layout) — capped depth, never an
@@ -162,6 +182,7 @@ export function isExcludedTestTarget(testFilePath, cwd) {
162
182
  // --- Runner Detection ---
163
183
  export const RUNNERS = {
164
184
  vitest: {
185
+ kinds: ["jsts"],
165
186
  configFiles: ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs"],
166
187
  command: "npx",
167
188
  binName: "vitest",
@@ -175,6 +196,7 @@ export const RUNNERS = {
175
196
  parseJson: true,
176
197
  },
177
198
  jest: {
199
+ kinds: ["jsts"],
178
200
  configFiles: [
179
201
  "jest.config.ts",
180
202
  "jest.config.js",
@@ -193,12 +215,26 @@ export const RUNNERS = {
193
215
  parseJson: true,
194
216
  },
195
217
  pytest: {
218
+ kinds: ["python"],
196
219
  configFiles: ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"],
220
+ // #2879 review round 3, F7: `pyproject.toml` is CONTENT-conditional in
221
+ // detection — the Priority-1 loop accepts it only when it carries
222
+ // `[tool.pytest.ini_options]` — but the cwd seam walks basenames, so
223
+ // handing `configFiles` over verbatim anchored the child on the very
224
+ // file detection had refused. Omitting it here makes the spawn walk
225
+ // stop only on evidence the detector itself would accept; a project
226
+ // whose ONLY pytest config is a real `[tool.pytest.ini_options]`
227
+ // section therefore falls back to the dispatch root, which is exactly
228
+ // where master ran it. (The proper fix is to carry detection's
229
+ // accepted evidence path into the spawn resolution instead of
230
+ // re-deriving it from a basename table — filed as a follow-up.)
231
+ spawnCwdMarkers: ["pytest.ini", "tox.ini", "setup.cfg"],
197
232
  command: "python",
198
233
  args: (testFile, _cwd) => ["-m", "pytest", testFile, "--tb=short", "-q"],
199
234
  parseJson: false, // pytest JSON requires plugin, use text parsing
200
235
  },
201
236
  go: {
237
+ kinds: ["go"],
202
238
  configFiles: ["go.mod"],
203
239
  command: "go",
204
240
  args: (testFile, cwd) => {
@@ -210,30 +246,47 @@ export const RUNNERS = {
210
246
  parseJson: false, // Go test output is text-based
211
247
  },
212
248
  cargo: {
249
+ kinds: ["rust"],
213
250
  configFiles: ["Cargo.toml"],
214
251
  command: "cargo",
215
252
  args: (_testFile, _cwd) => ["test", "--no-fail-fast"],
216
253
  parseJson: false, // cargo test output is text-based
217
254
  },
218
255
  dotnet: {
256
+ kinds: ["csharp", "fsharp"],
219
257
  configFiles: ["*.csproj", "*.sln"],
220
258
  command: "dotnet",
221
259
  args: (_testFile, _cwd) => ["test", "--no-build"],
222
260
  parseJson: false,
223
261
  },
224
262
  gradle: {
225
- configFiles: ["build.gradle", "build.gradle.kts", "settings.gradle"],
263
+ kinds: ["java", "kotlin"],
264
+ // #2870: `settings.gradle.kts` alongside its Groovy sibling — a
265
+ // Kotlin-DSL build whose root carries only the settings script was
266
+ // invisible to detection, the same one-spelling gap the java root
267
+ // markers had.
268
+ configFiles: [
269
+ "build.gradle",
270
+ "build.gradle.kts",
271
+ "settings.gradle",
272
+ "settings.gradle.kts",
273
+ ],
226
274
  command: process.platform === "win32" ? "gradlew.bat" : "./gradlew",
275
+ // The child's cwd must be a directory the wrapper actually lives in.
276
+ spawnCwdMarkers: ["gradlew", "gradlew.bat"],
227
277
  args: (_testFile, _cwd) => ["test", "--no-daemon"],
228
278
  parseJson: false,
229
279
  },
230
280
  maven: {
281
+ kinds: ["java", "kotlin"],
231
282
  configFiles: ["pom.xml"],
232
283
  command: "mvn",
284
+ spawnCwdMarkers: ["mvnw", "mvnw.cmd"],
233
285
  args: (_testFile, _cwd) => ["test", "-q"],
234
286
  parseJson: false,
235
287
  },
236
288
  rspec: {
289
+ kinds: ["ruby"],
237
290
  configFiles: [".rspec", "spec/spec_helper.rb"],
238
291
  command: "bundle",
239
292
  // The real binary is "bundle" (the command runs `bundle exec rspec
@@ -245,22 +298,33 @@ export const RUNNERS = {
245
298
  parseJson: false,
246
299
  },
247
300
  minitest: {
301
+ kinds: ["ruby"],
248
302
  configFiles: ["Gemfile"],
249
303
  command: "ruby",
250
304
  args: (testFile, _cwd) => ["-Itest", testFile],
251
305
  parseJson: false,
252
306
  },
253
307
  phpunit: {
308
+ kinds: ["php"],
254
309
  // phpunit.xml(.dist) is the strong signal; composer.json is checked for
255
310
  // a require-dev dependency on phpunit/phpunit (see the special case in
256
311
  // detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
257
312
  // handling above).
258
313
  configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
314
+ // #2879 review round 3, F7: same shape as pytest's `pyproject.toml`,
315
+ // and this one is load-bearing — phpunit reads `phpunit.xml` from its
316
+ // CWD only, so a child launched in a directory whose `composer.json`
317
+ // carries no `phpunit/phpunit` dependency runs with no bootstrap and
318
+ // no autoloader, and the resulting fatal error reaches the agent as a
319
+ // test failure (measured end to end with a fake phpunit recording its
320
+ // own cwd). `composer.json` is an anchor, never phpunit evidence.
321
+ spawnCwdMarkers: ["phpunit.xml", "phpunit.xml.dist"],
259
322
  command: "phpunit",
260
323
  args: (testFile, _cwd) => [testFile],
261
324
  parseJson: false, // PHPUnit's default CLI output is text-based
262
325
  },
263
326
  mix: {
327
+ kinds: ["elixir"],
264
328
  configFiles: ["mix.exs"],
265
329
  command: "mix",
266
330
  args: (testFile, _cwd) => ["test", testFile],
@@ -441,14 +505,127 @@ export class TestRunnerClient {
441
505
  return;
442
506
  byRunner.set(runner, { available, evidencePath });
443
507
  }
508
+ /**
509
+ * Which runners may be handed `sourceFilePath`, and the directory their
510
+ * config files are probed in (#2870).
511
+ *
512
+ * `eligible === null` means "no file was named" — the session-level
513
+ * caller (`runtime-session.ts`'s summary) asks "what runner does this
514
+ * project have" and has no file to scope to, so every runner stays a
515
+ * candidate and the probe root is the dispatch root, exactly as before.
516
+ *
517
+ * A named file resolves TWO independent facts, and the order matters:
518
+ * - its KIND decides which runners may claim it at all (the gate), and
519
+ * - `resolveLanguageRootForFile` decides WHERE that kind's config files
520
+ * are looked for (the anchor) — the same per-kind, cached,
521
+ * workspace-clamped walk the dispatch runners already resolve their
522
+ * roots with, not a private walker.
523
+ *
524
+ * Returning `null` means no runner can own this file (a kind no entry
525
+ * claims: markdown, yaml, an unknown extension), so no directory is
526
+ * probed at all.
527
+ */
528
+ resolveDetectionScope(cwd, sourceFilePath) {
529
+ if (sourceFilePath === undefined)
530
+ return { root: cwd, eligible: null };
531
+ const absoluteFile = path.resolve(sourceFilePath);
532
+ // #2522 (pinned by "fails closed through the REAL getTestRunTarget when
533
+ // the resolved target is out of tree"): a file OUTSIDE the dispatch root
534
+ // keeps the pre-#2870 resolution — every runner, probed at the dispatch
535
+ // root — so `getTestRunTarget` still hands the exclusion layer a target
536
+ // to fail closed on instead of quietly answering "no runner" for a file
537
+ // the turn should refuse loudly. Per-file anchoring has nothing to
538
+ // anchor to out there: the language-root walk is clamped to the
539
+ // workspace, so every out-of-tree file resolves to the dispatch root
540
+ // anyway.
541
+ if (!isUnderDir(absoluteFile, path.resolve(cwd))) {
542
+ return { root: cwd, eligible: null };
543
+ }
544
+ const kind = detectFileKind(absoluteFile);
545
+ if (!kind)
546
+ return null;
547
+ const eligible = new Set(Object.entries(RUNNERS)
548
+ .filter(([, config]) => config.kinds.includes(kind))
549
+ .map(([name]) => name));
550
+ if (eligible.size === 0)
551
+ return null;
552
+ return {
553
+ root: resolveLanguageRootForFile(absoluteFile, cwd),
554
+ eligible,
555
+ };
556
+ }
444
557
  /**
445
558
  * Check if a test runner is available in the project
446
559
  * Detection order:
447
560
  * 1. Config files (vitest.config.ts, jest.config.js, etc.)
448
561
  * 2. package.json dependencies
449
562
  * 3. node_modules presence
563
+ *
564
+ * #2870: when `sourceFilePath` is given, the answer is scoped to that
565
+ * file's kind and resolved language root — see `resolveDetectionScope`.
450
566
  */
451
- detectRunner(cwd, sourceFilePath) {
567
+ detectRunner(dispatchRoot, sourceFilePath) {
568
+ const scope = this.resolveDetectionScope(dispatchRoot, sourceFilePath);
569
+ if (!scope)
570
+ return null;
571
+ const { root, eligible } = scope;
572
+ const anchored = this.probeRunnersAt(root, eligible);
573
+ if (anchored)
574
+ return anchored;
575
+ // #2879 review round 2, F1: the anchor is a LANGUAGE root, and
576
+ // `ROOT_MARKERS_BY_KIND` is deliberately BROADER than any runner's
577
+ // `configFiles` — `requirements.txt`, `setup.py`, `Pipfile`,
578
+ // `Rakefile`, `composer.lock`, `.classpath` all anchor a language
579
+ // without configuring a test runner. Probing the anchored directory
580
+ // ALONE therefore let any such intermediate marker shadow the
581
+ // project root's real runner config, and pytest/rspec/minitest/
582
+ // phpunit have no later priority to rescue them: four single-language
583
+ // repos went from a working runner to no target at all (measured, the
584
+ // review's `anchor.mjs` probe), which is exactly what #2870's amended
585
+ // criterion 4 protects.
586
+ //
587
+ // So the anchored probe is a PREFERENCE, not a restriction: on a miss,
588
+ // re-probe the dispatch root under the SAME kind gate. The gate is
589
+ // what keeps #2870 fixed — a `.java` file re-probing a polyglot root
590
+ // still cannot reach `go`, because `go` never claims the `java` kind.
591
+ const dispatch = path.resolve(dispatchRoot);
592
+ if (path.resolve(root) !== dispatch) {
593
+ const atDispatch = this.probeRunnersAt(dispatch, eligible);
594
+ if (atDispatch)
595
+ return atDispatch;
596
+ }
597
+ // Priority 5: Check if pytest is available globally (Python files only)
598
+ const isPythonSource = typeof sourceFilePath === "string" && sourceFilePath.endsWith(".py");
599
+ if (!isPythonSource)
600
+ return null;
601
+ try {
602
+ const whichCmd = process.platform === "win32" ? "where" : "which";
603
+ const result = safeSpawn(whichCmd, ["pytest"], {
604
+ timeout: 2000,
605
+ });
606
+ if (result.status === 0) {
607
+ this.log("Detected pytest globally");
608
+ return { runner: "pytest", config: RUNNERS.pytest };
609
+ }
610
+ }
611
+ catch (err) {
612
+ void err;
613
+ }
614
+ return null;
615
+ }
616
+ /**
617
+ * Priorities 1-4 of the detection ladder, against ONE directory: config
618
+ * files, `package.json` dependencies, a hoisted `node_modules`, and the
619
+ * glob-capable whole-project runners. `eligible` is the kind gate
620
+ * (#2870); `null` means no file was named and every runner is a
621
+ * candidate. Returns `null` when this directory configures no eligible
622
+ * runner, which is what lets `detectRunner` try the dispatch root next.
623
+ */
624
+ probeRunnersAt(cwd, eligible) {
625
+ // Keyed on the probed directory, not the dispatch root: two modules of
626
+ // one polyglot repo have different runners available, and a memo keyed
627
+ // on the shared dispatch root would serve the first module's verdict to
628
+ // every other module (#2870).
452
629
  const rootKey = this.getCanonicalProjectRoot(cwd);
453
630
  let byRunner = this.availableRunners.get(rootKey);
454
631
  if (!byRunner) {
@@ -457,6 +634,8 @@ export class TestRunnerClient {
457
634
  }
458
635
  // Priority 1: Config files
459
636
  for (const [name, config] of Object.entries(RUNNERS)) {
637
+ if (eligible && !eligible.has(name))
638
+ continue;
460
639
  const cached = this.getRunnerAvailability(byRunner, name);
461
640
  if (cached !== undefined) {
462
641
  if (cached) {
@@ -520,17 +699,18 @@ export class TestRunnerClient {
520
699
  ...pkg.devDependencies,
521
700
  };
522
701
  // Check for vitest first (more specific than jest)
523
- if (allDeps.vitest) {
702
+ if (allDeps.vitest && (!eligible || eligible.has("vitest"))) {
524
703
  this.log("Detected vitest in package.json");
525
704
  this.setRunnerAvailability(byRunner, "vitest", true, packageJsonPath);
526
705
  return { runner: "vitest", config: RUNNERS.vitest };
527
706
  }
528
- if (allDeps.jest) {
707
+ if (allDeps.jest && (!eligible || eligible.has("jest"))) {
529
708
  this.log("Detected jest in package.json");
530
709
  this.setRunnerAvailability(byRunner, "jest", true, packageJsonPath);
531
710
  return { runner: "jest", config: RUNNERS.jest };
532
711
  }
533
- if (allDeps.pytest || allDeps["pytest-cov"]) {
712
+ if ((allDeps.pytest || allDeps["pytest-cov"]) &&
713
+ (!eligible || eligible.has("pytest"))) {
534
714
  this.log("Detected pytest in package.json (unusual)");
535
715
  this.setRunnerAvailability(byRunner, "pytest", true, packageJsonPath);
536
716
  return { runner: "pytest", config: RUNNERS.pytest };
@@ -543,21 +723,19 @@ export class TestRunnerClient {
543
723
  // Priority 3: Check node_modules for installed packages, including a
544
724
  // hoisted monorepo layout where cwd is a workspace package (e.g.
545
725
  // packages/foo) but the runner only lives in node_modules at the
546
- // workspace root (npm/yarn/pnpm workspace hoisting). Walk up a
547
- // bounded number of parent directories looking for a node_modules
548
- // containing the package — never an unbounded walk to the
549
- // filesystem root.
550
- const hoistedVitest = this.findHoistedNodeModulesPackage(cwd, "vitest");
551
- if (hoistedVitest) {
552
- this.log(`Detected vitest in node_modules (${hoistedVitest})`);
553
- return { runner: "vitest", config: RUNNERS.vitest };
554
- }
555
- const hoistedJest = this.findHoistedNodeModulesPackage(cwd, "jest");
556
- if (hoistedJest) {
557
- this.log(`Detected jest in node_modules (${hoistedJest})`);
558
- return { runner: "jest", config: RUNNERS.jest };
726
+ // workspace root (npm/yarn/pnpm workspace hoisting).
727
+ for (const name of ["vitest", "jest"]) {
728
+ if (eligible && !eligible.has(name))
729
+ continue;
730
+ const hoisted = findHoistedNodeModulesPackage(cwd, name);
731
+ if (hoisted) {
732
+ this.log(`Detected ${name} in node_modules (${hoisted})`);
733
+ return { runner: name, config: RUNNERS[name] };
734
+ }
559
735
  }
560
736
  for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
737
+ if (eligible && !eligible.has(name))
738
+ continue;
561
739
  const config = RUNNERS[name];
562
740
  const found = config.configFiles.some((cf) => {
563
741
  // Handle glob patterns like *.csproj
@@ -577,47 +755,6 @@ export class TestRunnerClient {
577
755
  return { runner: name, config };
578
756
  }
579
757
  }
580
- // Priority 5: Check if pytest is available globally (Python files only)
581
- const isPythonSource = typeof sourceFilePath === "string" && sourceFilePath.endsWith(".py");
582
- if (!isPythonSource)
583
- return null;
584
- try {
585
- const whichCmd = process.platform === "win32" ? "where" : "which";
586
- const result = safeSpawn(whichCmd, ["pytest"], {
587
- timeout: 2000,
588
- });
589
- if (result.status === 0) {
590
- this.log("Detected pytest globally");
591
- return { runner: "pytest", config: RUNNERS.pytest };
592
- }
593
- }
594
- catch (err) {
595
- void err;
596
- }
597
- return null;
598
- }
599
- /**
600
- * Walk up from `cwd` through parent directories looking for a
601
- * `node_modules/<packageName>` — handles monorepo workspace hoisting
602
- * (npm/yarn/pnpm), where a workspace package's own `node_modules` may
603
- * not exist at all, with dependencies hoisted to the workspace root
604
- * several directories up. Bounded by `MAX_NODE_MODULES_WALK_UP` levels
605
- * and stops at the filesystem root — never an unbounded walk.
606
- * Returns the `node_modules` directory where the package was found, or
607
- * null if not found within the bound.
608
- */
609
- findHoistedNodeModulesPackage(cwd, packageName) {
610
- let dir = path.resolve(cwd);
611
- for (let level = 0; level <= MAX_NODE_MODULES_WALK_UP; level++) {
612
- const nodeModulesPath = path.join(dir, "node_modules");
613
- if (fs.existsSync(path.join(nodeModulesPath, packageName))) {
614
- return nodeModulesPath;
615
- }
616
- const parent = path.dirname(dir);
617
- if (parent === dir)
618
- break; // reached filesystem root
619
- dir = parent;
620
- }
621
758
  return null;
622
759
  }
623
760
  /**
@@ -790,7 +927,8 @@ export class TestRunnerClient {
790
927
  * file whose *related* test file needs to be discovered).
791
928
  *
792
929
  * Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
793
- * basenames, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
930
+ * basenames, `_test.`/`_spec.` suffix infixes, `*Test(s).<ext>` CamelCase
931
+ * suffixes, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
794
932
  * directories — shared with the rest of the codebase, not a second
795
933
  * parallel detector).
796
934
  *
@@ -1024,6 +1162,43 @@ export class TestRunnerClient {
1024
1162
  strategy: "related",
1025
1163
  };
1026
1164
  }
1165
+ /**
1166
+ * The working directory the test-runner CHILD is spawned in (#2871).
1167
+ *
1168
+ * AGENTS.md defect shape 40: `resolveToolCwd` is the one seam for a child
1169
+ * process's cwd, and this file resolved its own — it handed
1170
+ * `safeSpawnAsync` the dispatch root, so `RUNNERS.go.args` built
1171
+ * `./tools/tapctl/internal/lightning` relative to a root module that does
1172
+ * not own that package. Marker discovery, the `.git` fallback, the
1173
+ * dispatch-root fallback, the `$HOME` ceiling, the once-per-key `tool-cwd`
1174
+ * log line and the bounded `tool-cwd-resolution` degradation now all come
1175
+ * from the seam, with the runner's own table as the markers.
1176
+ *
1177
+ * This is a SECOND value, never a reassignment of `cwd`: the failed-target
1178
+ * ledger (`recordResult` → `getFailedTargets`, read back by
1179
+ * `getTestRunTarget`) is keyed by the DISPATCH root, and re-keying it per
1180
+ * module would make a recorded failure unfindable from the turn that
1181
+ * selects targets.
1182
+ *
1183
+ * Review round 2, F6: there is deliberately NO clamp on a resolution that
1184
+ * lands outside the dispatch root. The seam only answers outside it when
1185
+ * the FILE is outside it (`tool-cwd.ts`: every in-tree branch is
1186
+ * `isUnderDir`-checked), and the one production caller —
1187
+ * `runtime-turn.ts`'s turn-end batch, on both the fresh and the deferred
1188
+ * path — filters every target through `isExcludedTestTarget`, which fails
1189
+ * CLOSED out of tree (#2522). Round 1 shipped that clamp plus a
1190
+ * `tool-cwd-resolution` ledger row for it; neither could fire in a live
1191
+ * session, so the row was a record nothing could observe and the guard was
1192
+ * defence against a caller that does not exist. Both are gone. If a second
1193
+ * caller is ever added that can pass an out-of-tree file, it needs this
1194
+ * decision made where that caller is, with a test that reaches it.
1195
+ */
1196
+ resolveSpawnCwd(runner, config, testFile, dispatchRoot) {
1197
+ return resolveToolCwd("runner", runner, testFile, {
1198
+ cwd: path.resolve(dispatchRoot),
1199
+ rootMarkers: config.spawnCwdMarkers ?? config.configFiles,
1200
+ });
1201
+ }
1027
1202
  async runTestFileAsync(testFile, cwd, runnerOrRequest, legacyConfig) {
1028
1203
  const absoluteTestFile = path.resolve(testFile);
1029
1204
  let request;
@@ -1041,10 +1216,11 @@ export class TestRunnerClient {
1041
1216
  return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
1042
1217
  }
1043
1218
  try {
1044
- const { command, args, env } = await this.resolveExec(runner, config, absoluteTestFile, cwd);
1045
- this.log(`Running (async): ${command} ${args.join(" ")}`);
1219
+ const spawnCwd = this.resolveSpawnCwd(runner, config, absoluteTestFile, cwd);
1220
+ const { command, args, env } = await this.resolveExec(runner, config, absoluteTestFile, cwd, spawnCwd);
1221
+ this.log(`Running (async): ${command} ${args.join(" ")} (cwd ${spawnCwd})`);
1046
1222
  const result = await safeSpawnAsync(command, args, {
1047
- cwd,
1223
+ cwd: spawnCwd,
1048
1224
  timeout: 60000,
1049
1225
  env,
1050
1226
  // #2522 R2 F1. `safeSpawnAsync` resolves `options.signal ?? ambient`,
@@ -1942,7 +2118,26 @@ export class TestRunnerClient {
1942
2118
  const slash = name.indexOf("/");
1943
2119
  return slash === -1 || !goFailNameSet.has(name.slice(0, slash));
1944
2120
  }).length;
2121
+ // #2870: go's own `[setup failed]` verdict line — "there was no package
2122
+ // to test" — the positive twin of the `(?:build|setup) failed`
2123
+ // lookahead below. #1524-r4 stopped it from being counted as a test
2124
+ // failure but left it with no classification of its own, so whether the
2125
+ // agent saw "could not run tests" or a fabricated `✗ 1/1 failed ✗ go
2126
+ // failure` came down to whether go's output happened to contain the
2127
+ // substring "error" (the runner-error condition below): `FAIL <pkg>
2128
+ // [setup failed]` + `error: no packages to test` was advisory, the same
2129
+ // line on its own was a blocking failure the agent could not tell from
2130
+ // a real one (#2870, measured).
2131
+ //
2132
+ // `[build failed]` is deliberately NOT included (review round 2, F5).
2133
+ // The maintainer's amendment on #2870 names `[setup failed]`; a go
2134
+ // COMPILE error is usually one the agent just introduced, and
2135
+ // downgrading it to advisory is a signal change on master's behaviour
2136
+ // that belongs on the issue, not in this fix. Its classification is
2137
+ // still the coin flip described above — see the PR body's follow-ups.
2138
+ let goInfraVerdict = false;
1945
2139
  if (runner === "go") {
2140
+ goInfraVerdict = /^FAIL[^\S\n]+\S+[^\S\n]+\[setup failed\]/m.test(output);
1946
2141
  // #1524-r4: `(?![^\n]*\[(?:build|setup) failed\])` rejects go's
1947
2142
  // INFRASTRUCTURE verdict lines — `FAIL <pkg> [build failed]` (a
1948
2143
  // compile error) and `FAIL <pkg> [setup failed]` (no packages to
@@ -2018,10 +2213,19 @@ export class TestRunnerClient {
2018
2213
  // runner error they actually are). They still label a name onto
2019
2214
  // `failures` above when `matched` or `goFailNames` already settled
2020
2215
  // the question some other way, but they no longer settle it alone.
2216
+ //
2217
+ // #2870: `goInfraVerdict` is a SECOND sufficient condition, not a
2218
+ // widening of the first. go's `[build failed]`/`[setup failed]` line is
2219
+ // the runner itself saying no test ran, which is exactly what this
2220
+ // branch reports — so it no longer has to also print the word "error"
2221
+ // to be classified as one. The `!matched`/`goFailNames` vetoes still
2222
+ // apply: a run that produced real go counts or a real `--- FAIL:` is a
2223
+ // verdict, never a runner error, even if a later package failed to
2224
+ // build.
2021
2225
  const runnerError = exitCode !== 0 &&
2022
2226
  !matched &&
2023
2227
  goFailNames.length === 0 &&
2024
- lower.includes("error")
2228
+ (lower.includes("error") || goInfraVerdict)
2025
2229
  ? `Runner ${runner} exited with ${exitCode}`
2026
2230
  : undefined;
2027
2231
  // #1524-r4 (tidy): a runner-error result reports through
@@ -2217,7 +2421,18 @@ export class TestRunnerClient {
2217
2421
  * with an npx-style runner-name arg, which only holds for wrapper-style
2218
2422
  * runners like vitest/jest/pytest).
2219
2423
  */
2220
- async resolveExec(runner, config, testFile, cwd) {
2424
+ async resolveExec(runner, config, testFile, cwd,
2425
+ /**
2426
+ * #2871: the directory the child will RUN in, which is the only cwd
2427
+ * `args()` may be built against — go's package path is relative to it.
2428
+ * Where the binary and the Python environment are INSTALLED is a
2429
+ * different question with a different answer: they stay resolved from
2430
+ * the dispatch root, exactly as before, so a workspace package whose
2431
+ * dependencies are hoisted to the repo root still finds
2432
+ * `node_modules/.bin/<runner>` instead of falling through to `npx`.
2433
+ * Defaults to `cwd`, which is every call where the two are the same.
2434
+ */
2435
+ spawnCwd = cwd) {
2221
2436
  // Run pytest through the project interpreter itself, not a generic `python`
2222
2437
  // resolved from the host PATH. The child-only environment also keeps tools
2223
2438
  // spawned by tests inside the same project environment.
@@ -2226,7 +2441,7 @@ export class TestRunnerClient {
2226
2441
  if (pythonEnvironment) {
2227
2442
  return {
2228
2443
  command: pythonEnvironment.pythonPath,
2229
- args: config.args(testFile, cwd),
2444
+ args: config.args(testFile, spawnCwd),
2230
2445
  env: augmentPythonEnvironment(process.env, pythonEnvironment),
2231
2446
  };
2232
2447
  }
@@ -2238,9 +2453,9 @@ export class TestRunnerClient {
2238
2453
  const suffix = process.platform === "win32" ? ".bat" : "";
2239
2454
  const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix}`);
2240
2455
  if (fs.existsSync(vendorBin)) {
2241
- return { command: vendorBin, args: config.args(testFile, cwd) };
2456
+ return { command: vendorBin, args: config.args(testFile, spawnCwd) };
2242
2457
  }
2243
- return { command: "phpunit", args: config.args(testFile, cwd) };
2458
+ return { command: "phpunit", args: config.args(testFile, spawnCwd) };
2244
2459
  }
2245
2460
  const binName = config.binName ?? runner;
2246
2461
  const suffix = process.platform === "win32" ? ".cmd" : "";
@@ -2251,7 +2466,7 @@ export class TestRunnerClient {
2251
2466
  if (fs.existsSync(localBin)) {
2252
2467
  return {
2253
2468
  command: localBin,
2254
- args: stripWrapperArgs(binName, config.args(testFile, cwd)),
2469
+ args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
2255
2470
  };
2256
2471
  }
2257
2472
  // Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx (#375).
@@ -2259,10 +2474,10 @@ export class TestRunnerClient {
2259
2474
  if (globalBin) {
2260
2475
  return {
2261
2476
  command: globalBin,
2262
- args: stripWrapperArgs(binName, config.args(testFile, cwd)),
2477
+ args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
2263
2478
  };
2264
2479
  }
2265
- return { command: config.command, args: config.args(testFile, cwd) };
2480
+ return { command: config.command, args: config.args(testFile, spawnCwd) };
2266
2481
  }
2267
2482
  emptyResult(testFile, sourceFile, runner, error) {
2268
2483
  return {