@herbertgao/pi-extensions 2026.10.0 → 2026.10.2

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 (769) hide show
  1. package/README.md +16 -12
  2. package/THIRD_PARTY_NOTICES.md +3 -3
  3. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +2 -2
  4. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +2 -2
  5. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
  6. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +1 -1
  7. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +2 -2
  8. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
  9. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/index.js +4 -0
  10. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +43 -3
  11. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +142 -59
  12. package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +21 -21
  13. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
  14. package/node_modules/@dietrichgebert/ponytail/AGENTS.md +21 -21
  15. package/node_modules/@dietrichgebert/ponytail/README.md +52 -242
  16. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +3 -3
  17. package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +4 -4
  18. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +70 -16
  19. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +8 -7
  20. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +30 -30
  21. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-map.js +103 -0
  22. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +46 -23
  23. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +39 -7
  24. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +20 -3
  25. package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +3 -3
  26. package/node_modules/@dietrichgebert/ponytail/package.json +3 -3
  27. package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +47 -17
  28. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +21 -7
  29. package/node_modules/@dietrichgebert/ponytail/skills/plugin.json +5 -0
  30. package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +33 -101
  31. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +89 -27
  32. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +13 -12
  33. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +21 -18
  34. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +12 -13
  35. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +96 -52
  36. package/node_modules/@herbertgao/pi-agency/LICENSE +21 -0
  37. package/node_modules/@herbertgao/pi-agency/README.md +40 -0
  38. package/node_modules/@herbertgao/pi-agency/package.json +53 -0
  39. package/node_modules/@herbertgao/pi-agency/src/index.ts +441 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +12 -12
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +30 -6
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +15 -9
  43. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +27 -9
  44. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -1
  45. package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
  46. package/node_modules/@narumitw/pi-caffeinate/package.json +4 -4
  47. package/node_modules/@tifan/pi-mermaid-open/package.json +5 -1
  48. package/node_modules/@tifan/pi-mermaid-open/src/index.ts +26 -9
  49. package/node_modules/@tifan/pi-recap/package.json +1 -1
  50. package/node_modules/@tifan/pi-recap/src/index.ts +104 -20
  51. package/node_modules/@tifan/pi-recap/src/models.ts +46 -4
  52. package/node_modules/pi-antigravity/README.md +23 -2
  53. package/node_modules/pi-antigravity/package.json +5 -3
  54. package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +3 -2
  55. package/node_modules/pi-antigravity/src/features/generate-image.ts +72 -0
  56. package/node_modules/pi-antigravity/src/features/google-search.ts +71 -0
  57. package/node_modules/pi-antigravity/src/image/image.ts +86 -47
  58. package/node_modules/pi-antigravity/src/image/native.ts +69 -0
  59. package/node_modules/pi-antigravity/src/index.ts +52 -137
  60. package/node_modules/pi-antigravity/src/search/search.ts +130 -31
  61. package/node_modules/pi-antigravity/src/stream/stream.ts +162 -113
  62. package/node_modules/pi-antigravity/src/types/enums.ts +1 -0
  63. package/node_modules/pi-antigravity/src/types/types.ts +1 -1
  64. package/node_modules/pi-antigravity/src/utils/security.ts +47 -1
  65. package/node_modules/pi-antigravity/src/utils/util.ts +38 -6
  66. package/node_modules/pi-lens/CHANGELOG.md +1064 -0
  67. package/node_modules/pi-lens/README.md +115 -25
  68. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +6 -2
  69. package/node_modules/pi-lens/dist/chunk-22AVIGXS.js +560 -0
  70. package/node_modules/pi-lens/dist/chunk-27BBHSZC.js +106 -0
  71. package/node_modules/pi-lens/dist/chunk-2BEMIWEW.js +51 -0
  72. package/node_modules/pi-lens/dist/chunk-2EECBNC5.js +89 -0
  73. package/node_modules/pi-lens/dist/chunk-34U7PLGE.js +23 -0
  74. package/node_modules/pi-lens/dist/chunk-3I5QFT3N.js +181 -0
  75. package/node_modules/pi-lens/dist/chunk-3MED75HT.js +37 -0
  76. package/node_modules/pi-lens/dist/chunk-47D2BO4S.js +87 -0
  77. package/node_modules/pi-lens/dist/chunk-4RERDEXR.js +348 -0
  78. package/node_modules/pi-lens/dist/chunk-4W2PLC4J.js +27 -0
  79. package/node_modules/pi-lens/dist/chunk-52H5B5TC.js +202 -0
  80. package/node_modules/pi-lens/dist/chunk-54B42YRX.js +27 -0
  81. package/node_modules/pi-lens/dist/chunk-5RXBNSW2.js +266 -0
  82. package/node_modules/pi-lens/dist/chunk-5THXD6X5.js +201 -0
  83. package/node_modules/pi-lens/dist/chunk-5W3R6LFT.js +24 -0
  84. package/node_modules/pi-lens/dist/chunk-5ZEJHV35.js +341 -0
  85. package/node_modules/pi-lens/dist/chunk-6P62QVEA.js +118 -0
  86. package/node_modules/pi-lens/dist/chunk-6QIUX4EP.js +459 -0
  87. package/node_modules/pi-lens/dist/chunk-6VCD4VKZ.js +78 -0
  88. package/node_modules/pi-lens/dist/chunk-7F72PXY2.js +326 -0
  89. package/node_modules/pi-lens/dist/chunk-7FBF7NPR.js +998 -0
  90. package/node_modules/pi-lens/dist/chunk-7U2IDNXN.js +37 -0
  91. package/node_modules/pi-lens/dist/chunk-ABBOA7UD.js +157 -0
  92. package/node_modules/pi-lens/dist/chunk-AHDW3GPE.js +50 -0
  93. package/node_modules/pi-lens/dist/chunk-AJ3QXTRQ.js +756 -0
  94. package/node_modules/pi-lens/dist/chunk-AJVLWNFN.js +1610 -0
  95. package/node_modules/pi-lens/dist/chunk-AJXODZY6.js +112 -0
  96. package/node_modules/pi-lens/dist/chunk-ARFCXG7S.js +84 -0
  97. package/node_modules/pi-lens/dist/chunk-AS44CFQ4.js +708 -0
  98. package/node_modules/pi-lens/dist/chunk-AWE5TMPO.js +38 -0
  99. package/node_modules/pi-lens/dist/chunk-B3I2UUT4.js +4692 -0
  100. package/node_modules/pi-lens/dist/chunk-BRJKOBZP.js +331 -0
  101. package/node_modules/pi-lens/dist/chunk-BW5KFIQC.js +59 -0
  102. package/node_modules/pi-lens/dist/chunk-C2AUYXQH.js +149 -0
  103. package/node_modules/pi-lens/dist/chunk-CGGAIP7J.js +29 -0
  104. package/node_modules/pi-lens/dist/chunk-D6QS3U6M.js +30 -0
  105. package/node_modules/pi-lens/dist/chunk-DFVMIKMQ.js +349 -0
  106. package/node_modules/pi-lens/dist/chunk-DIULIWKU.js +169 -0
  107. package/node_modules/pi-lens/dist/chunk-DQ2NZ7C4.js +2317 -0
  108. package/node_modules/pi-lens/dist/chunk-E2WFBRHM.js +213 -0
  109. package/node_modules/pi-lens/dist/chunk-E4MAEDPM.js +50 -0
  110. package/node_modules/pi-lens/dist/chunk-EOKRQ3SA.js +30 -0
  111. package/node_modules/pi-lens/dist/chunk-F3AU2TMM.js +2626 -0
  112. package/node_modules/pi-lens/dist/chunk-F3P7VQ7H.js +170 -0
  113. package/node_modules/pi-lens/dist/chunk-FC4RP4I2.js +12 -0
  114. package/node_modules/pi-lens/dist/chunk-FJNKANOS.js +32 -0
  115. package/node_modules/pi-lens/dist/chunk-GUOGVTCQ.js +146 -0
  116. package/node_modules/pi-lens/dist/chunk-GVXA6CCE.js +18 -0
  117. package/node_modules/pi-lens/dist/chunk-HHYAELLJ.js +2334 -0
  118. package/node_modules/pi-lens/dist/chunk-HMFXILPV.js +83 -0
  119. package/node_modules/pi-lens/dist/chunk-HVUZXSWK.js +421 -0
  120. package/node_modules/pi-lens/dist/chunk-HWBZIGMJ.js +62 -0
  121. package/node_modules/pi-lens/dist/chunk-I3R7GKDR.js +17 -0
  122. package/node_modules/pi-lens/dist/chunk-I7SPGOWI.js +55 -0
  123. package/node_modules/pi-lens/dist/chunk-IIXOAGHR.js +38 -0
  124. package/node_modules/pi-lens/dist/chunk-IVP5KAB7.js +2250 -0
  125. package/node_modules/pi-lens/dist/chunk-IXEBJSUE.js +22 -0
  126. package/node_modules/pi-lens/dist/chunk-JOLITGCC.js +27 -0
  127. package/node_modules/pi-lens/dist/chunk-K6VYLB2U.js +145 -0
  128. package/node_modules/pi-lens/dist/chunk-LJDODBBZ.js +78 -0
  129. package/node_modules/pi-lens/dist/chunk-LNAKEO63.js +78 -0
  130. package/node_modules/pi-lens/dist/chunk-LSMJ6KBE.js +4139 -0
  131. package/node_modules/pi-lens/dist/chunk-LW6XKDZQ.js +5565 -0
  132. package/node_modules/pi-lens/dist/chunk-N3YQJI6O.js +899 -0
  133. package/node_modules/pi-lens/dist/chunk-NABLC7W3.js +148 -0
  134. package/node_modules/pi-lens/dist/chunk-NKOUFPQF.js +659 -0
  135. package/node_modules/pi-lens/dist/chunk-NL5GLMFI.js +211 -0
  136. package/node_modules/pi-lens/dist/chunk-NMABWBZN.js +327 -0
  137. package/node_modules/pi-lens/dist/chunk-NTHH2J74.js +27 -0
  138. package/node_modules/pi-lens/dist/chunk-NTUJEGDZ.js +59 -0
  139. package/node_modules/pi-lens/dist/chunk-NXL4FFQQ.js +47 -0
  140. package/node_modules/pi-lens/dist/chunk-NXPS2L6U.js +466 -0
  141. package/node_modules/pi-lens/dist/chunk-O5WILGMI.js +42 -0
  142. package/node_modules/pi-lens/dist/chunk-O6TQT6RI.js +48 -0
  143. package/node_modules/pi-lens/dist/chunk-OAXNDZJ7.js +323 -0
  144. package/node_modules/pi-lens/dist/chunk-ODEEOXPH.js +44 -0
  145. package/node_modules/pi-lens/dist/chunk-OQGFCZJ4.js +4533 -0
  146. package/node_modules/pi-lens/dist/chunk-ORTMY4Q4.js +282 -0
  147. package/node_modules/pi-lens/dist/chunk-OS32RNR2.js +442 -0
  148. package/node_modules/pi-lens/dist/chunk-PAK6IDME.js +189 -0
  149. package/node_modules/pi-lens/dist/chunk-PWFHBTSF.js +406 -0
  150. package/node_modules/pi-lens/dist/chunk-Q5U6FMDI.js +2352 -0
  151. package/node_modules/pi-lens/dist/chunk-QOZXJ4NA.js +3364 -0
  152. package/node_modules/pi-lens/dist/chunk-QYF5S6P6.js +45 -0
  153. package/node_modules/pi-lens/dist/chunk-RIISMHKN.js +36 -0
  154. package/node_modules/pi-lens/dist/chunk-RPD4ZSB7.js +25 -0
  155. package/node_modules/pi-lens/dist/chunk-RPD5K5NC.js +79 -0
  156. package/node_modules/pi-lens/dist/chunk-RUOG5D2C.js +262 -0
  157. package/node_modules/pi-lens/dist/chunk-T4ERT73Y.js +599 -0
  158. package/node_modules/pi-lens/dist/chunk-T6D4Q4AQ.js +54 -0
  159. package/node_modules/pi-lens/dist/chunk-TGPXLY2J.js +391 -0
  160. package/node_modules/pi-lens/dist/chunk-TLGFBM3F.js +12116 -0
  161. package/node_modules/pi-lens/dist/chunk-TO6WL3GM.js +88 -0
  162. package/node_modules/pi-lens/dist/chunk-TT22N74J.js +517 -0
  163. package/node_modules/pi-lens/dist/chunk-TVN5N6GY.js +21216 -0
  164. package/node_modules/pi-lens/dist/chunk-U3VGR4VU.js +308 -0
  165. package/node_modules/pi-lens/dist/chunk-UK3CMEAL.js +1250 -0
  166. package/node_modules/pi-lens/dist/chunk-UOPVJQ4N.js +103 -0
  167. package/node_modules/pi-lens/dist/chunk-UXVNL3D7.js +23 -0
  168. package/node_modules/pi-lens/dist/chunk-UY752QSN.js +45 -0
  169. package/node_modules/pi-lens/dist/chunk-V5PXFSZK.js +188 -0
  170. package/node_modules/pi-lens/dist/chunk-V7R425XG.js +398 -0
  171. package/node_modules/pi-lens/dist/chunk-VCSRSYLF.js +81 -0
  172. package/node_modules/pi-lens/dist/chunk-VD4JQCQC.js +162 -0
  173. package/node_modules/pi-lens/dist/chunk-VDBPVIKD.js +62 -0
  174. package/node_modules/pi-lens/dist/chunk-VHQLPIRJ.js +114 -0
  175. package/node_modules/pi-lens/dist/chunk-VN2AXLEX.js +5414 -0
  176. package/node_modules/pi-lens/dist/chunk-VNMT7C64.js +478 -0
  177. package/node_modules/pi-lens/dist/chunk-WONERJTP.js +309 -0
  178. package/node_modules/pi-lens/dist/chunk-XECLWQZN.js +85 -0
  179. package/node_modules/pi-lens/dist/chunk-XIMYZ3JA.js +27 -0
  180. package/node_modules/pi-lens/dist/chunk-XK4CIILI.js +144 -0
  181. package/node_modules/pi-lens/dist/chunk-Y4T27COV.js +562 -0
  182. package/node_modules/pi-lens/dist/chunk-YGVYKNSN.js +1860 -0
  183. package/node_modules/pi-lens/dist/chunk-YLF3PPT5.js +308 -0
  184. package/node_modules/pi-lens/dist/chunk-YSNFMJKN.js +14 -0
  185. package/node_modules/pi-lens/dist/chunk-Z3CCVDUB.js +76 -0
  186. package/node_modules/pi-lens/dist/chunk-Z54ZSOBZ.js +61 -0
  187. package/node_modules/pi-lens/dist/chunk-ZAWW3AF6.js +97 -0
  188. package/node_modules/pi-lens/dist/chunk-ZMOL6RQC.js +167 -0
  189. package/node_modules/pi-lens/dist/index.js +16456 -8335
  190. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +186 -232
  191. package/node_modules/pi-lens/dist/mcp/cli.js +137 -98
  192. package/node_modules/pi-lens/dist/mcp/server.js +22589 -1573
  193. package/node_modules/pi-lens/dist/mcp/worker.js +97 -38
  194. package/node_modules/pi-lens/dist/probes/installer.js +135 -0
  195. package/node_modules/pi-lens/dist/workers/project-snapshot-persist-worker.js +22 -0
  196. package/node_modules/pi-lens/dist/workers/review-graph-persist-worker.js +15 -0
  197. package/node_modules/pi-lens/docs/agent-guide.md +13 -4
  198. package/node_modules/pi-lens/docs/agent-tools.md +42 -0
  199. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +3 -2
  200. package/node_modules/pi-lens/docs/configuration.md +20 -1
  201. package/node_modules/pi-lens/docs/environment-variables.md +52 -2
  202. package/node_modules/pi-lens/docs/features.md +8 -4
  203. package/node_modules/pi-lens/docs/globalconfig.md +1 -0
  204. package/node_modules/pi-lens/docs/io-bridge-v2-migration.md +94 -0
  205. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +36 -4
  206. package/node_modules/pi-lens/docs/lsp-idle-eviction.md +124 -0
  207. package/node_modules/pi-lens/docs/mcp.md +21 -5
  208. package/node_modules/pi-lens/docs/merge-queue-rollout.md +77 -0
  209. package/node_modules/pi-lens/docs/pi-lens-fixer.md +156 -88
  210. package/node_modules/pi-lens/docs/pi-lens-investigator.md +74 -26
  211. package/node_modules/pi-lens/docs/pi-lens-merge-policy.md +694 -0
  212. package/node_modules/pi-lens/docs/pi-lens-monitor.md +73 -7
  213. package/node_modules/pi-lens/docs/pi-lens-retro.md +6 -4
  214. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +188 -48
  215. package/node_modules/pi-lens/docs/pi-lens-subagent.md +246 -45
  216. package/node_modules/pi-lens/docs/pi-lens-warden.md +18 -7
  217. package/node_modules/pi-lens/docs/public-api-stability.md +55 -0
  218. package/node_modules/pi-lens/docs/release-qa-baseline.md +84 -17
  219. package/node_modules/pi-lens/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  220. package/node_modules/pi-lens/docs/servercapabilities.md +53 -49
  221. package/node_modules/pi-lens/docs/settings.md +1 -0
  222. package/node_modules/pi-lens/docs/usage.md +5 -1
  223. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm +0 -0
  224. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm.json +3 -3
  225. package/node_modules/pi-lens/package.json +18 -6
  226. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  227. package/node_modules/pi-lens/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  228. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +1275 -62
  229. package/node_modules/pi-lens/scripts/download-grammars.js +5 -0
  230. package/node_modules/pi-lens/scripts/grammars.lock.json +6 -1
  231. package/node_modules/pi-lens/scripts/install-selftest.mjs +33 -34
  232. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +13 -4
  233. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +18 -0
  234. package/node_modules/pi-typesafe/README.md +1 -1
  235. package/node_modules/pi-typesafe/dist/backends.d.ts +15 -5
  236. package/node_modules/pi-typesafe/dist/backends.js +18 -0
  237. package/node_modules/pi-typesafe/dist/client.d.ts +4 -3
  238. package/node_modules/pi-typesafe/dist/client.js +22 -7
  239. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  240. package/node_modules/pi-typesafe/dist/index.js +1 -1
  241. package/node_modules/pi-typesafe/dist/usage.d.ts +14 -4
  242. package/node_modules/pi-typesafe/dist/usage.js +147 -42
  243. package/node_modules/pi-typesafe/package.json +1 -1
  244. package/node_modules/pi-web-access/CHANGELOG.md +48 -0
  245. package/node_modules/pi-web-access/README.md +57 -11
  246. package/node_modules/pi-web-access/brave-rate-limit.ts +8 -2
  247. package/node_modules/pi-web-access/curator-page.ts +5 -1
  248. package/node_modules/pi-web-access/curator-server.ts +2 -0
  249. package/node_modules/pi-web-access/declared-web-links.ts +2 -2
  250. package/node_modules/pi-web-access/degoog.ts +323 -0
  251. package/node_modules/pi-web-access/dist/index.js +3483 -2745
  252. package/node_modules/pi-web-access/dist/mcp-cli.js +27443 -0
  253. package/node_modules/pi-web-access/exa.ts +5 -1
  254. package/node_modules/pi-web-access/extract.ts +41 -3
  255. package/node_modules/pi-web-access/firecrawl.ts +35 -4
  256. package/node_modules/pi-web-access/gemini-search.ts +34 -11
  257. package/node_modules/pi-web-access/gemini-web.ts +14 -9
  258. package/node_modules/pi-web-access/index.ts +153 -1202
  259. package/node_modules/pi-web-access/keenable.ts +221 -0
  260. package/node_modules/pi-web-access/mcp-cli.ts +40 -0
  261. package/node_modules/pi-web-access/mcp-server.ts +81 -0
  262. package/node_modules/pi-web-access/mcp-tool-definitions.ts +117 -0
  263. package/node_modules/pi-web-access/openai-search.ts +34 -1
  264. package/node_modules/pi-web-access/package.json +23 -5
  265. package/node_modules/pi-web-access/perplexity.ts +64 -56
  266. package/node_modules/pi-web-access/tool-arguments.ts +20 -0
  267. package/node_modules/pi-web-access/video-extract.ts +1 -1
  268. package/node_modules/pi-web-access/web-tool-contract.ts +86 -0
  269. package/node_modules/pi-web-access/web-tool-core.ts +1502 -0
  270. package/node_modules/pi-web-access/youtube-extract.ts +2 -5
  271. package/package.json +17 -12
  272. package/node_modules/@dietrichgebert/ponytail/README.es.md +0 -329
  273. package/node_modules/@dietrichgebert/ponytail/README.ko.md +0 -346
  274. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +0 -21
  275. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +0 -62
  276. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
  277. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +0 -115
  278. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +0 -27
  279. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +0 -27
  280. package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
  281. package/node_modules/@dietrichgebert/ponytail/assets/retriever-icon.png +0 -0
  282. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-dark.svg +0 -4
  283. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-light.svg +0 -4
  284. package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
  285. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
  286. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
  287. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
  288. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +0 -35
  289. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +0 -1610
  290. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +0 -593
  291. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +0 -147
  292. package/node_modules/pi-lens/dist/clients/agent-nudge.js +0 -325
  293. package/node_modules/pi-lens/dist/clients/analysed-root.js +0 -1
  294. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +0 -731
  295. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +0 -146
  296. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +0 -118
  297. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -10
  298. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +0 -154
  299. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +0 -75
  300. package/node_modules/pi-lens/dist/clients/atomic-write.js +0 -189
  301. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +0 -12
  302. package/node_modules/pi-lens/dist/clients/bash-file-access.js +0 -1056
  303. package/node_modules/pi-lens/dist/clients/biome-client.js +0 -357
  304. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +0 -837
  305. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +0 -129
  306. package/node_modules/pi-lens/dist/clients/bootstrap.js +0 -569
  307. package/node_modules/pi-lens/dist/clients/bounded-cache.js +0 -190
  308. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +0 -202
  309. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +0 -295
  310. package/node_modules/pi-lens/dist/clients/build-identity.js +0 -95
  311. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +0 -113
  312. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +0 -105
  313. package/node_modules/pi-lens/dist/clients/bus-publish.js +0 -171
  314. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +0 -190
  315. package/node_modules/pi-lens/dist/clients/cache-manager.js +0 -524
  316. package/node_modules/pi-lens/dist/clients/cache-observability.js +0 -1313
  317. package/node_modules/pi-lens/dist/clients/call-graph.js +0 -852
  318. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +0 -422
  319. package/node_modules/pi-lens/dist/clients/cascade-budget.js +0 -196
  320. package/node_modules/pi-lens/dist/clients/cascade-format.js +0 -315
  321. package/node_modules/pi-lens/dist/clients/cascade-logger.js +0 -31
  322. package/node_modules/pi-lens/dist/clients/cascade-types.js +0 -9
  323. package/node_modules/pi-lens/dist/clients/child-unref.js +0 -124
  324. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +0 -286
  325. package/node_modules/pi-lens/dist/clients/codebase-model.js +0 -173
  326. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +0 -40
  327. package/node_modules/pi-lens/dist/clients/complexity-client.js +0 -462
  328. package/node_modules/pi-lens/dist/clients/config-core/deny.js +0 -221
  329. package/node_modules/pi-lens/dist/clients/config-core/index.js +0 -50
  330. package/node_modules/pi-lens/dist/clients/config-core/merge.js +0 -357
  331. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +0 -340
  332. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +0 -248
  333. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +0 -164
  334. package/node_modules/pi-lens/dist/clients/config-core/records.js +0 -218
  335. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +0 -125
  336. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +0 -78
  337. package/node_modules/pi-lens/dist/clients/config-core/schema.js +0 -165
  338. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +0 -285
  339. package/node_modules/pi-lens/dist/clients/config-locations.js +0 -388
  340. package/node_modules/pi-lens/dist/clients/config-resolve.js +0 -798
  341. package/node_modules/pi-lens/dist/clients/config-schema.js +0 -251
  342. package/node_modules/pi-lens/dist/clients/config-warn.js +0 -407
  343. package/node_modules/pi-lens/dist/clients/console-guard-install.js +0 -26
  344. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +0 -42
  345. package/node_modules/pi-lens/dist/clients/dead-code-client.js +0 -510
  346. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +0 -30
  347. package/node_modules/pi-lens/dist/clients/deadline-utils.js +0 -280
  348. package/node_modules/pi-lens/dist/clients/debug-handles.js +0 -211
  349. package/node_modules/pi-lens/dist/clients/debug-heap.js +0 -174
  350. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +0 -106
  351. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +0 -426
  352. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +0 -169
  353. package/node_modules/pi-lens/dist/clients/dependency-checker.js +0 -885
  354. package/node_modules/pi-lens/dist/clients/deps/ast-grep-napi.js +0 -22
  355. package/node_modules/pi-lens/dist/clients/deps/js-yaml.js +0 -9
  356. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +0 -4
  357. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +0 -14
  358. package/node_modules/pi-lens/dist/clients/deps/typebox.js +0 -14
  359. package/node_modules/pi-lens/dist/clients/deps/vscode-jsonrpc.js +0 -1
  360. package/node_modules/pi-lens/dist/clients/deps/web-tree-sitter.js +0 -24
  361. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +0 -630
  362. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +0 -305
  363. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +0 -73
  364. package/node_modules/pi-lens/dist/clients/diagnostic-tracker.js +0 -105
  365. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +0 -230
  366. package/node_modules/pi-lens/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  367. package/node_modules/pi-lens/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  368. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -35
  369. package/node_modules/pi-lens/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  370. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +0 -1269
  371. package/node_modules/pi-lens/dist/clients/dispatch/fact-provider-types.js +0 -1
  372. package/node_modules/pi-lens/dist/clients/dispatch/fact-rule-runner.js +0 -17
  373. package/node_modules/pi-lens/dist/clients/dispatch/fact-runner.js +0 -30
  374. package/node_modules/pi-lens/dist/clients/dispatch/fact-scheduler.js +0 -68
  375. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +0 -377
  376. package/node_modules/pi-lens/dist/clients/dispatch/facts/comment-facts.js +0 -22
  377. package/node_modules/pi-lens/dist/clients/dispatch/facts/file-content.js +0 -19
  378. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +0 -348
  379. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +0 -204
  380. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  381. package/node_modules/pi-lens/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  382. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +0 -339
  383. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +0 -417
  384. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +0 -89
  385. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +0 -2377
  386. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -11
  387. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +0 -87
  388. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -278
  389. package/node_modules/pi-lens/dist/clients/dispatch/priorities.js +0 -20
  390. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +0 -50
  391. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +0 -69
  392. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +0 -135
  393. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-noise.js +0 -40
  394. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  395. package/node_modules/pi-lens/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  396. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  397. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  398. package/node_modules/pi-lens/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  399. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +0 -72
  400. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  401. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  402. package/node_modules/pi-lens/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  403. package/node_modules/pi-lens/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  404. package/node_modules/pi-lens/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  405. package/node_modules/pi-lens/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  406. package/node_modules/pi-lens/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  407. package/node_modules/pi-lens/dist/clients/dispatch/runner-context.js +0 -52
  408. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -111
  409. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  410. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -296
  411. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -219
  412. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -82
  413. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -416
  414. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -183
  415. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -176
  416. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -164
  417. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -195
  418. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -137
  419. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -29
  420. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -69
  421. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -167
  422. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +0 -104
  423. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -135
  424. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -91
  425. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -171
  426. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -940
  427. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -117
  428. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +0 -108
  429. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -99
  430. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -110
  431. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -379
  432. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -167
  433. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -102
  434. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +0 -414
  435. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -85
  436. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -120
  437. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  438. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  439. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +0 -169
  440. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -99
  441. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -88
  442. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +0 -256
  443. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -173
  444. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -102
  445. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -128
  446. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -259
  447. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -183
  448. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -166
  449. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -163
  450. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -174
  451. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -168
  452. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -125
  453. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -643
  454. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -253
  455. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  456. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  457. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
  458. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  459. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  460. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  461. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  462. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  463. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -15
  464. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -128
  465. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  466. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -95
  467. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -79
  468. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +0 -79
  469. package/node_modules/pi-lens/dist/clients/dispatch/tool-profile.js +0 -30
  470. package/node_modules/pi-lens/dist/clients/dispatch/types.js +0 -21
  471. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +0 -45
  472. package/node_modules/pi-lens/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  473. package/node_modules/pi-lens/dist/clients/disposition-logger.js +0 -32
  474. package/node_modules/pi-lens/dist/clients/disposition-publish.js +0 -124
  475. package/node_modules/pi-lens/dist/clients/durable-store.js +0 -97
  476. package/node_modules/pi-lens/dist/clients/effective-config.js +0 -415
  477. package/node_modules/pi-lens/dist/clients/env-utils.js +0 -83
  478. package/node_modules/pi-lens/dist/clients/error-class.js +0 -23
  479. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +0 -17
  480. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +0 -274
  481. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +0 -250
  482. package/node_modules/pi-lens/dist/clients/extension-log.js +0 -504
  483. package/node_modules/pi-lens/dist/clients/extension-mode.js +0 -73
  484. package/node_modules/pi-lens/dist/clients/feature-hints.js +0 -103
  485. package/node_modules/pi-lens/dist/clients/file-kinds.js +0 -412
  486. package/node_modules/pi-lens/dist/clients/file-role.js +0 -134
  487. package/node_modules/pi-lens/dist/clients/file-time.js +0 -160
  488. package/node_modules/pi-lens/dist/clients/file-utils.js +0 -1032
  489. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +0 -494
  490. package/node_modules/pi-lens/dist/clients/finding-identity.js +0 -88
  491. package/node_modules/pi-lens/dist/clients/fix-worklog.js +0 -103
  492. package/node_modules/pi-lens/dist/clients/format-events-publish.js +0 -343
  493. package/node_modules/pi-lens/dist/clients/format-service.js +0 -222
  494. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +0 -9
  495. package/node_modules/pi-lens/dist/clients/formatters.js +0 -2032
  496. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +0 -17
  497. package/node_modules/pi-lens/dist/clients/freshness.js +0 -38
  498. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +0 -394
  499. package/node_modules/pi-lens/dist/clients/generation-guard.js +0 -215
  500. package/node_modules/pi-lens/dist/clients/git-guard.js +0 -1136
  501. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +0 -243
  502. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +0 -509
  503. package/node_modules/pi-lens/dist/clients/go-client.js +0 -104
  504. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +0 -605
  505. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +0 -348
  506. package/node_modules/pi-lens/dist/clients/grammar-source.js +0 -465
  507. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +0 -135
  508. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +0 -424
  509. package/node_modules/pi-lens/dist/clients/hook-budgets.js +0 -76
  510. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +0 -140
  511. package/node_modules/pi-lens/dist/clients/host-ports.js +0 -39
  512. package/node_modules/pi-lens/dist/clients/indent-retarget.js +0 -154
  513. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +0 -186
  514. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +0 -156
  515. package/node_modules/pi-lens/dist/clients/installer/index.js +0 -5416
  516. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  517. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +0 -783
  518. package/node_modules/pi-lens/dist/clients/instance-reaper-state.js +0 -7
  519. package/node_modules/pi-lens/dist/clients/instance-reaper.js +0 -1137
  520. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +0 -145
  521. package/node_modules/pi-lens/dist/clients/instance-registry.js +0 -773
  522. package/node_modules/pi-lens/dist/clients/jscpd-client.js +0 -378
  523. package/node_modules/pi-lens/dist/clients/json-cache-read.js +0 -65
  524. package/node_modules/pi-lens/dist/clients/knip-client.js +0 -788
  525. package/node_modules/pi-lens/dist/clients/language-policy.js +0 -252
  526. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -361
  527. package/node_modules/pi-lens/dist/clients/language-registry.js +0 -603
  528. package/node_modules/pi-lens/dist/clients/latency-logger.js +0 -727
  529. package/node_modules/pi-lens/dist/clients/lazy-import.js +0 -21
  530. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +0 -54
  531. package/node_modules/pi-lens/dist/clients/lens-config.js +0 -404
  532. package/node_modules/pi-lens/dist/clients/lens-engine.js +0 -358
  533. package/node_modules/pi-lens/dist/clients/lens-events.js +0 -125
  534. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +0 -467
  535. package/node_modules/pi-lens/dist/clients/lens-map.js +0 -1156
  536. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +0 -74
  537. package/node_modules/pi-lens/dist/clients/log-cleanup.js +0 -222
  538. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +0 -178
  539. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +0 -421
  540. package/node_modules/pi-lens/dist/clients/lsp/client.js +0 -4593
  541. package/node_modules/pi-lens/dist/clients/lsp/config.js +0 -691
  542. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +0 -41
  543. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +0 -225
  544. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +0 -467
  545. package/node_modules/pi-lens/dist/clients/lsp/edits.js +0 -1219
  546. package/node_modules/pi-lens/dist/clients/lsp/index.js +0 -8305
  547. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +0 -216
  548. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +0 -298
  549. package/node_modules/pi-lens/dist/clients/lsp/language.js +0 -63
  550. package/node_modules/pi-lens/dist/clients/lsp/launch.js +0 -746
  551. package/node_modules/pi-lens/dist/clients/lsp/lombok.js +0 -158
  552. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +0 -5
  553. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +0 -251
  554. package/node_modules/pi-lens/dist/clients/lsp/position-encoding.js +0 -61
  555. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  556. package/node_modules/pi-lens/dist/clients/lsp/server.js +0 -3290
  557. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +0 -192
  558. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +0 -17
  559. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -56
  560. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +0 -332
  561. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  562. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +0 -85
  563. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/index.js +0 -10
  564. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +0 -392
  565. package/node_modules/pi-lens/dist/clients/lsp/watch-queue.js +0 -61
  566. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  567. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  568. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  569. package/node_modules/pi-lens/dist/clients/lsp-budget.js +0 -186
  570. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +0 -110
  571. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +0 -9
  572. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +0 -396
  573. package/node_modules/pi-lens/dist/clients/lsp-status.js +0 -59
  574. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +0 -40
  575. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +0 -420
  576. package/node_modules/pi-lens/dist/clients/mcp/host-shim.js +0 -31
  577. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +0 -523
  578. package/node_modules/pi-lens/dist/clients/mcp/review.js +0 -197
  579. package/node_modules/pi-lens/dist/clients/mcp/session.js +0 -412
  580. package/node_modules/pi-lens/dist/clients/memory-sampler.js +0 -208
  581. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +0 -30
  582. package/node_modules/pi-lens/dist/clients/metrics-client.js +0 -107
  583. package/node_modules/pi-lens/dist/clients/metrics-history.js +0 -327
  584. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +0 -263
  585. package/node_modules/pi-lens/dist/clients/model-provider.js +0 -53
  586. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +0 -189
  587. package/node_modules/pi-lens/dist/clients/module-report.js +0 -2150
  588. package/node_modules/pi-lens/dist/clients/mutating-tool.js +0 -651
  589. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +0 -368
  590. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +0 -240
  591. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +0 -734
  592. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +0 -101
  593. package/node_modules/pi-lens/dist/clients/observed-mutation.js +0 -1253
  594. package/node_modules/pi-lens/dist/clients/oldtext-autopatch.js +0 -128
  595. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +0 -496
  596. package/node_modules/pi-lens/dist/clients/opengrep-client.js +0 -333
  597. package/node_modules/pi-lens/dist/clients/opengrep-config.js +0 -76
  598. package/node_modules/pi-lens/dist/clients/package-manager.js +0 -607
  599. package/node_modules/pi-lens/dist/clients/package-root.js +0 -38
  600. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +0 -439
  601. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +0 -43
  602. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +0 -108
  603. package/node_modules/pi-lens/dist/clients/path-utils.js +0 -1244
  604. package/node_modules/pi-lens/dist/clients/performance-report.js +0 -331
  605. package/node_modules/pi-lens/dist/clients/persist-debounce.js +0 -70
  606. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +0 -271
  607. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +0 -119
  608. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -1480
  609. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +0 -37
  610. package/node_modules/pi-lens/dist/clients/print-mode.js +0 -21
  611. package/node_modules/pi-lens/dist/clients/probe-home-state.js +0 -228
  612. package/node_modules/pi-lens/dist/clients/process-bridge.js +0 -66
  613. package/node_modules/pi-lens/dist/clients/process-singletons.js +0 -210
  614. package/node_modules/pi-lens/dist/clients/process-snapshot.js +0 -68
  615. package/node_modules/pi-lens/dist/clients/project-changes.js +0 -181
  616. package/node_modules/pi-lens/dist/clients/project-conventions.js +0 -171
  617. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +0 -137
  618. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +0 -109
  619. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
  620. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  621. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  622. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  623. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  624. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  625. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  626. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  627. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  628. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  629. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  630. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +0 -520
  631. package/node_modules/pi-lens/dist/clients/project-diagnostics/types.js +0 -1
  632. package/node_modules/pi-lens/dist/clients/project-lens-config.js +0 -807
  633. package/node_modules/pi-lens/dist/clients/project-report.js +0 -701
  634. package/node_modules/pi-lens/dist/clients/project-scale.js +0 -237
  635. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +0 -11
  636. package/node_modules/pi-lens/dist/clients/project-snapshot-fingerprint.js +0 -48
  637. package/node_modules/pi-lens/dist/clients/project-snapshot-persist-worker.js +0 -20
  638. package/node_modules/pi-lens/dist/clients/project-snapshot.js +0 -1653
  639. package/node_modules/pi-lens/dist/clients/project-trust.js +0 -181
  640. package/node_modules/pi-lens/dist/clients/python-environment.js +0 -217
  641. package/node_modules/pi-lens/dist/clients/python-provenance.js +0 -639
  642. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +0 -47
  643. package/node_modules/pi-lens/dist/clients/quiet-window.js +0 -236
  644. package/node_modules/pi-lens/dist/clients/read-bridge.js +0 -139
  645. package/node_modules/pi-lens/dist/clients/read-expansion.js +0 -266
  646. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +0 -251
  647. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +0 -1306
  648. package/node_modules/pi-lens/dist/clients/read-guard.js +0 -1561
  649. package/node_modules/pi-lens/dist/clients/recent-touches.js +0 -240
  650. package/node_modules/pi-lens/dist/clients/redact/secrets.js +0 -449
  651. package/node_modules/pi-lens/dist/clients/resource-sampler.js +0 -628
  652. package/node_modules/pi-lens/dist/clients/reverse-deps.js +0 -270
  653. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +0 -5126
  654. package/node_modules/pi-lens/dist/clients/review-graph/format.js +0 -33
  655. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +0 -163
  656. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +0 -481
  657. package/node_modules/pi-lens/dist/clients/review-graph/persist-worker.js +0 -10
  658. package/node_modules/pi-lens/dist/clients/review-graph/query.js +0 -244
  659. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +0 -21
  660. package/node_modules/pi-lens/dist/clients/review-graph/service.js +0 -96
  661. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  662. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +0 -103
  663. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +0 -299
  664. package/node_modules/pi-lens/dist/clients/review-graph/types.js +0 -1
  665. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +0 -449
  666. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +0 -81
  667. package/node_modules/pi-lens/dist/clients/ruff-client.js +0 -179
  668. package/node_modules/pi-lens/dist/clients/rules-scanner.js +0 -131
  669. package/node_modules/pi-lens/dist/clients/run-duration.js +0 -55
  670. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +0 -746
  671. package/node_modules/pi-lens/dist/clients/runtime-config.js +0 -77
  672. package/node_modules/pi-lens/dist/clients/runtime-context.js +0 -211
  673. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +0 -1324
  674. package/node_modules/pi-lens/dist/clients/runtime-session.js +0 -2485
  675. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +0 -1295
  676. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +0 -2049
  677. package/node_modules/pi-lens/dist/clients/runtime-turn.js +0 -3767
  678. package/node_modules/pi-lens/dist/clients/rust-client.js +0 -97
  679. package/node_modules/pi-lens/dist/clients/safe-spawn.js +0 -2357
  680. package/node_modules/pi-lens/dist/clients/sanitize.js +0 -286
  681. package/node_modules/pi-lens/dist/clients/scan-utils.js +0 -25
  682. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +0 -165
  683. package/node_modules/pi-lens/dist/clients/search-read-registration.js +0 -94
  684. package/node_modules/pi-lens/dist/clients/secret-findings.js +0 -114
  685. package/node_modules/pi-lens/dist/clients/security-scan-client.js +0 -352
  686. package/node_modules/pi-lens/dist/clients/session-event-guard.js +0 -297
  687. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +0 -484
  688. package/node_modules/pi-lens/dist/clients/session-start-observability.js +0 -94
  689. package/node_modules/pi-lens/dist/clients/session-state-store.js +0 -101
  690. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -15
  691. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +0 -27
  692. package/node_modules/pi-lens/dist/clients/sg-runner.js +0 -876
  693. package/node_modules/pi-lens/dist/clients/sgconfig.js +0 -347
  694. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +0 -338
  695. package/node_modules/pi-lens/dist/clients/single-flight.js +0 -158
  696. package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +0 -108
  697. package/node_modules/pi-lens/dist/clients/skills-resolver.js +0 -141
  698. package/node_modules/pi-lens/dist/clients/slow-fs.js +0 -138
  699. package/node_modules/pi-lens/dist/clients/smells-rollup.js +0 -231
  700. package/node_modules/pi-lens/dist/clients/source-filter.js +0 -596
  701. package/node_modules/pi-lens/dist/clients/source-walker.js +0 -211
  702. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +0 -120
  703. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +0 -110
  704. package/node_modules/pi-lens/dist/clients/stale-marker.js +0 -17
  705. package/node_modules/pi-lens/dist/clients/startup-scan.js +0 -400
  706. package/node_modules/pi-lens/dist/clients/startup-timing.js +0 -130
  707. package/node_modules/pi-lens/dist/clients/string-utils.js +0 -33
  708. package/node_modules/pi-lens/dist/clients/subagent-mode.js +0 -111
  709. package/node_modules/pi-lens/dist/clients/symbol-containment.js +0 -24
  710. package/node_modules/pi-lens/dist/clients/symbol-types.js +0 -5
  711. package/node_modules/pi-lens/dist/clients/test-runner-client.js +0 -2507
  712. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +0 -287
  713. package/node_modules/pi-lens/dist/clients/todo-scanner.js +0 -229
  714. package/node_modules/pi-lens/dist/clients/tool-agreement.js +0 -423
  715. package/node_modules/pi-lens/dist/clients/tool-config.js +0 -249
  716. package/node_modules/pi-lens/dist/clients/tool-cwd.js +0 -270
  717. package/node_modules/pi-lens/dist/clients/tool-definition.js +0 -47
  718. package/node_modules/pi-lens/dist/clients/tool-event.js +0 -105
  719. package/node_modules/pi-lens/dist/clients/tool-policy.js +0 -2755
  720. package/node_modules/pi-lens/dist/clients/tool-probe.js +0 -53
  721. package/node_modules/pi-lens/dist/clients/tool-render.js +0 -156
  722. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +0 -118
  723. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +0 -364
  724. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +0 -4195
  725. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +0 -100
  726. package/node_modules/pi-lens/dist/clients/tree-sitter-navigator.js +0 -269
  727. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +0 -569
  728. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +0 -158
  729. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  730. package/node_modules/pi-lens/dist/clients/trivy-client.js +0 -467
  731. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -50
  732. package/node_modules/pi-lens/dist/clients/turn-context.js +0 -52
  733. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +0 -51
  734. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +0 -112
  735. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +0 -204
  736. package/node_modules/pi-lens/dist/clients/turn-summary-render.js +0 -72
  737. package/node_modules/pi-lens/dist/clients/turn-summary.js +0 -132
  738. package/node_modules/pi-lens/dist/clients/typos-config.js +0 -29
  739. package/node_modules/pi-lens/dist/clients/user-notify.js +0 -51
  740. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +0 -57
  741. package/node_modules/pi-lens/dist/clients/warm-attach.js +0 -305
  742. package/node_modules/pi-lens/dist/clients/widget-state.js +0 -1808
  743. package/node_modules/pi-lens/dist/clients/word-index-logger.js +0 -49
  744. package/node_modules/pi-lens/dist/clients/word-index-store.js +0 -459
  745. package/node_modules/pi-lens/dist/clients/word-index.js +0 -2080
  746. package/node_modules/pi-lens/dist/clients/workspace-topology.js +0 -443
  747. package/node_modules/pi-lens/dist/clients/write-ordering-guard.js +0 -65
  748. package/node_modules/pi-lens/dist/clients/zizmor-config.js +0 -340
  749. package/node_modules/pi-lens/dist/i18n.js +0 -69
  750. package/node_modules/pi-lens/dist/mcp/build-staleness.js +0 -124
  751. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +0 -583
  752. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +0 -129
  753. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
  754. package/node_modules/pi-lens/dist/tools/activate-tools.js +0 -87
  755. package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +0 -137
  756. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +0 -244
  757. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +0 -762
  758. package/node_modules/pi-lens/dist/tools/effective-config.js +0 -90
  759. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +0 -375
  760. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +0 -2571
  761. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +0 -1745
  762. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +0 -1367
  763. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +0 -294
  764. package/node_modules/pi-lens/dist/tools/module-report.js +0 -382
  765. package/node_modules/pi-lens/dist/tools/project-report.js +0 -86
  766. package/node_modules/pi-lens/dist/tools/render-compact.js +0 -374
  767. package/node_modules/pi-lens/dist/tools/scan-progress.js +0 -53
  768. package/node_modules/pi-lens/dist/tools/shared.js +0 -29
  769. package/node_modules/pi-lens/dist/tools/symbol-search.js +0 -155
@@ -1,2507 +0,0 @@
1
- /**
2
- * Test Runner Client for pi-lens
3
- *
4
- * Detects test files and runs them on write/edit to provide
5
- * immediate test feedback to the AI agent.
6
- *
7
- * Supports: vitest, jest, pytest, go, cargo, dotnet, gradle, maven, rspec,
8
- * minitest, phpunit, mix (extensible to more)
9
- *
10
- * Design: File-level targeted testing — only runs tests for the
11
- * specific file being edited, not the entire suite.
12
- */
13
- import { createHash } from "node:crypto";
14
- import * as fs from "node:fs";
15
- import * as path from "node:path";
16
- import { BoundedFifoMap } from "./bounded-cache.js";
17
- import { emitBounded } from "./bounded-telemetry.js";
18
- import { LEDGER_FIELD_MAX } from "./degradation-ledger.js";
19
- import { minimatch } from "./deps/minimatch.js";
20
- import { createSubsystemLogger } from "./extension-log.js";
21
- import { detectFileKind } from "./file-kinds.js";
22
- import { detectFileRole } from "./file-role.js";
23
- import { resolveLanguageRootForFile } from "./language-profile.js";
24
- import { findGlobalBinary } from "./package-manager.js";
25
- import { PathKeyedMap } from "./path-keyed-map.js";
26
- import { augmentPythonEnvironment, detectPythonEnvironment, } from "./python-environment.js";
27
- import { isUnderDir, normalizeEphemeralMapKey, normalizeMapKey, toPosix, } from "./path-utils.js";
28
- import { findNearestDirWithAnyBasename } from "./workspace-topology.js";
29
- import { isMeasuredDuration, toMeasuredDurationMs } from "./run-duration.js";
30
- import { safeSpawn, safeSpawnAsync } from "./safe-spawn.js";
31
- import { stripAnsi } from "./sanitize.js";
32
- import { resolveToolCwd } from "./tool-cwd.js";
33
- /**
34
- * #2532: the ONE classification seam for "this `TestResult` carries NO
35
- * counted failure the agent needs to fix, even though the runner also
36
- * reported an `error`" — i.e. `failed === 0 && !!error`. This is NOT "the
37
- * suite never started": a runner can report BOTH counted failures and an
38
- * error (pytest exit 2 "Interrupted" after `2 failed, 1 passed` already
39
- * printed — `parsePytestOutput` sets `error` from the exit code
40
- * independently of the parsed counts, review round 1 S2). The rule is
41
- * exactly `failed === 0`, nothing about `error`'s presence or cause: a
42
- * result with counted failures stays blocking even when `error` is also
43
- * set (`testResultToProjectDiagnostics`/`formatResult` still mention the
44
- * error in that case, they just don't let it downgrade the verdict).
45
- *
46
- * `hasRealFailure`/`runnerErrorOnly` (`runtime-turn.ts`, #2522) key off the
47
- * exact same fact for the turn-end delivery framing — a batch made
48
- * entirely of `isRunnerErrorResult` results is advisory, one containing
49
- * even one counted failure keeps "fix before continuing". Three more
50
- * surfaces route through this SAME predicate instead of re-deriving it
51
- * (review round 1 S1's class sweep), so the identical `TestResult` cannot
52
- * classify differently across them: `testResultToProjectDiagnostics`
53
- * (`lens_diagnostics mode=full`), the `--lens-guard` merge call in
54
- * `handleTurnEnd`, and `TestRunnerClient.formatResult`/the turn-end dbg
55
- * summary (both in this file / `runtime-turn.ts`).
56
- *
57
- * Two call sites intentionally test the COMPLEMENT (a truly clean result,
58
- * no counted failure AND no error) rather than this predicate, and are not
59
- * a missed fourth spelling: `runtime-turn.ts`'s `cleanFiles` filter
60
- * (`--lens-guard`'s clear-blocker list) and `testResultToProjectDiagnostics`'s
61
- * own early "nothing to report" return. Both need "genuinely clean", which
62
- * `!isRunnerErrorResult(result)` cannot express (it is also true for a
63
- * counted failure).
64
- */
65
- export function isRunnerErrorResult(result) {
66
- return result.failed === 0 && !!result.error;
67
- }
68
- // Discovery table; exported so the #2928 parity test enumerates its conventions.
69
- export const SOURCE_TO_TEST_PATTERNS = [
70
- {
71
- ext: ".ts",
72
- testExts: [".test.ts", ".spec.ts"],
73
- dirs: ["__tests__", "tests", ".", "__tests__"],
74
- },
75
- {
76
- ext: ".tsx",
77
- testExts: [".test.tsx", ".spec.tsx"],
78
- dirs: ["__tests__", "tests", ".", "__tests__"],
79
- },
80
- {
81
- ext: ".js",
82
- testExts: [".test.js", ".spec.js"],
83
- dirs: ["__tests__", "tests", ".", "__tests__"],
84
- },
85
- {
86
- ext: ".jsx",
87
- testExts: [".test.jsx", ".spec.jsx"],
88
- dirs: ["__tests__", "tests", ".", "__tests__"],
89
- },
90
- {
91
- ext: ".py",
92
- testExts: ["test_*.py", "*_test.py"],
93
- dirs: ["tests", "test", ".", "."],
94
- },
95
- { ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] }, // Go tests are co-located
96
- { ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] }, // Rust: tests/ or #[test] in src
97
- // PHPUnit convention: tests/ mirrors src/ with ClassNameTest.php naming
98
- // (e.g. src/Foo/Bar.php -> tests/Foo/BarTest.php). Basename is already the
99
- // class name (PHP files are named after their class), so no case transform
100
- // is needed — the mirrored-directory search below handles the tests/ root.
101
- { ext: ".php", testExts: ["Test.php"], dirs: ["tests"] },
102
- // ExUnit convention: test/ mirrors lib/ with a _test.exs suffix on the same
103
- // basename (e.g. lib/accounts/user.ex -> test/accounts/user_test.exs).
104
- { ext: ".ex", testExts: ["_test.exs"], dirs: ["test"] },
105
- ];
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
- }
126
- // Bound for recursive descent into a Python test directory when the exact
127
- // same-relative-subdir mirror doesn't match (e.g. tests/unit/ grouping by
128
- // test type rather than mirroring source layout) — capped depth, never an
129
- // unbounded walk of the whole tests tree.
130
- const MAX_PYTEST_RECURSE_DEPTH = 3;
131
- /**
132
- * #2522: built-in exclusion list for the turn-end auto-fired test selection.
133
- *
134
- * `getTestRunTarget`'s three strategies (failed-first / related / self) will
135
- * happily resolve to ANY test file on disk, including integration/e2e suites
136
- * that spawn external processes. A plegma dogfooding turn resolved
137
- * `tests/integration/opencode-delegate.test.ts` this way — it spawns
138
- * `opencode` and needs a configured provider, so on a box without one it took
139
- * 17s to fail and was reported to the agent as "3/3 failed, fix before
140
- * proceeding" on an unrelated model-switch turn (#2522, refs #2504/#2509).
141
- *
142
- * One hard-coded list, no per-project config knob (maintainer decision
143
- * 2026-09-03) — this is a safety bound on what turn_end may auto-fire, not a
144
- * project preference. Documented in AGENTS.md and docs/. Matched against the
145
- * resolved test file's project-relative, POSIX-folded path so it applies
146
- * uniformly to whichever strategy produced the target.
147
- */
148
- export const TURN_END_EXCLUDED_TEST_GLOBS = [
149
- "**/integration/**",
150
- "**/e2e/**",
151
- "**/*.integration.*",
152
- "**/*.e2e.*",
153
- ];
154
- /**
155
- * Whether a resolved test target falls under the built-in turn-end
156
- * exclusion list (#2522). `testFilePath` may be absolute or relative, and
157
- * may use either path-separator form — folded through `toPosix` after being
158
- * made cwd-relative so `\`- and `/`-separated inputs match identically
159
- * (AGENTS.md cross-form-path screen).
160
- */
161
- export function isExcludedTestTarget(testFilePath, cwd) {
162
- const rel = toPosix(path.relative(cwd, path.resolve(cwd, testFilePath)));
163
- // #2522 review round 2, F6: a target that resolves OUTSIDE the project root
164
- // yields a `..`-leading relative path (or, across Windows drives, a still
165
- // absolute one), which matches none of the globs — so the bare `some()`
166
- // below reported it as "not excluded" and turn_end would have auto-spawned a
167
- // runner against a file outside the project the turn is running in. An
168
- // out-of-tree target, and the project root itself, fail CLOSED.
169
- if (rel === "" ||
170
- rel === ".." ||
171
- rel.startsWith("../") ||
172
- path.isAbsolute(rel))
173
- return true;
174
- // #2522 review round 2, F5: case-INSENSITIVE. `tests/Integration/`,
175
- // `tests/E2E/` and `foo.E2E.test.ts` are the same hazard as their lowercase
176
- // spellings, and on the case-insensitive filesystems Windows and macOS ship
177
- // by default they are literally the same files — so a case-sensitive match
178
- // made the SAME repo excluded on one box and unbounded on another. A safety
179
- // bound a capital letter defeats is not a bound.
180
- return TURN_END_EXCLUDED_TEST_GLOBS.some((glob) => minimatch(rel, glob, { dot: true, nocase: true }));
181
- }
182
- // --- Runner Detection ---
183
- export const RUNNERS = {
184
- vitest: {
185
- kinds: ["jsts"],
186
- configFiles: ["vitest.config.ts", "vitest.config.js", "vitest.config.mjs"],
187
- command: "npx",
188
- binName: "vitest",
189
- args: (testFile, _cwd) => [
190
- "vitest",
191
- "run",
192
- testFile,
193
- "--reporter=json",
194
- "--passWithNoTests",
195
- ],
196
- parseJson: true,
197
- },
198
- jest: {
199
- kinds: ["jsts"],
200
- configFiles: [
201
- "jest.config.ts",
202
- "jest.config.js",
203
- "jest.config.json",
204
- ".jestrc.js",
205
- ],
206
- command: "npx",
207
- binName: "jest",
208
- args: (testFile, _cwd) => [
209
- "jest",
210
- testFile,
211
- "--json",
212
- "--passWithNoTests",
213
- "--forceExit",
214
- ],
215
- parseJson: true,
216
- },
217
- pytest: {
218
- kinds: ["python"],
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"],
232
- command: "python",
233
- args: (testFile, _cwd) => ["-m", "pytest", testFile, "--tb=short", "-q"],
234
- parseJson: false, // pytest JSON requires plugin, use text parsing
235
- },
236
- go: {
237
- kinds: ["go"],
238
- configFiles: ["go.mod"],
239
- command: "go",
240
- args: (testFile, cwd) => {
241
- // Convert file path to package path
242
- const relPath = path.relative(cwd, testFile);
243
- const pkgDir = path.dirname(relPath);
244
- return ["test", `-run`, ".", `./${pkgDir === "." ? "." : pkgDir}`];
245
- },
246
- parseJson: false, // Go test output is text-based
247
- },
248
- cargo: {
249
- kinds: ["rust"],
250
- configFiles: ["Cargo.toml"],
251
- command: "cargo",
252
- args: (_testFile, _cwd) => ["test", "--no-fail-fast"],
253
- parseJson: false, // cargo test output is text-based
254
- },
255
- dotnet: {
256
- kinds: ["csharp", "fsharp"],
257
- configFiles: ["*.csproj", "*.sln"],
258
- command: "dotnet",
259
- args: (_testFile, _cwd) => ["test", "--no-build"],
260
- parseJson: false,
261
- },
262
- 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
- ],
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"],
277
- args: (_testFile, _cwd) => ["test", "--no-daemon"],
278
- parseJson: false,
279
- },
280
- maven: {
281
- kinds: ["java", "kotlin"],
282
- configFiles: ["pom.xml"],
283
- command: "mvn",
284
- args: (_testFile, _cwd) => ["test", "-q"],
285
- parseJson: false,
286
- },
287
- rspec: {
288
- kinds: ["ruby"],
289
- configFiles: [".rspec", "spec/spec_helper.rb"],
290
- command: "bundle",
291
- // The real binary is "bundle" (the command runs `bundle exec rspec
292
- // <file>`), NOT "rspec" — without this, binName defaulted to the
293
- // runner key "rspec" and local/global resolution looked for the wrong
294
- // binary name (#1098).
295
- binName: "bundle",
296
- args: (testFile, _cwd) => ["exec", "rspec", testFile],
297
- parseJson: false,
298
- },
299
- minitest: {
300
- kinds: ["ruby"],
301
- configFiles: ["Gemfile"],
302
- command: "ruby",
303
- args: (testFile, _cwd) => ["-Itest", testFile],
304
- parseJson: false,
305
- },
306
- phpunit: {
307
- kinds: ["php"],
308
- // phpunit.xml(.dist) is the strong signal; composer.json is checked for
309
- // a require-dev dependency on phpunit/phpunit (see the special case in
310
- // detectRunner's Priority-1 loop, mirroring the pytest/pyproject.toml
311
- // handling above).
312
- configFiles: ["phpunit.xml", "phpunit.xml.dist", "composer.json"],
313
- // #2879 review round 3, F7: same shape as pytest's `pyproject.toml`,
314
- // and this one is load-bearing — phpunit reads `phpunit.xml` from its
315
- // CWD only, so a child launched in a directory whose `composer.json`
316
- // carries no `phpunit/phpunit` dependency runs with no bootstrap and
317
- // no autoloader, and the resulting fatal error reaches the agent as a
318
- // test failure (measured end to end with a fake phpunit recording its
319
- // own cwd). `composer.json` is an anchor, never phpunit evidence.
320
- spawnCwdMarkers: ["phpunit.xml", "phpunit.xml.dist"],
321
- command: "phpunit",
322
- args: (testFile, _cwd) => [testFile],
323
- parseJson: false, // PHPUnit's default CLI output is text-based
324
- },
325
- mix: {
326
- kinds: ["elixir"],
327
- configFiles: ["mix.exs"],
328
- command: "mix",
329
- args: (testFile, _cwd) => ["test", testFile],
330
- parseJson: false, // mix test's default output is text-based
331
- },
332
- };
333
- /**
334
- * Drop the leading arg(s) of a runner's args() that merely NAME the binary
335
- * being invoked (the npx-wrapper convention: `npx vitest run …` → once
336
- * `vitest` becomes the resolved command itself, the leading "vitest" arg is
337
- * redundant). This must NOT strip a real subcommand.
338
- *
339
- * Two wrapper shapes are recognized:
340
- * - `[binName, ...rest]` (vitest/jest-style: `npx <bin> ...`) → drop 1.
341
- * - `["-m", binName, ...rest]` (pytest-style: `python -m <bin> ...`) → drop 2.
342
- * Anything else (cargo's `["test", "--no-fail-fast"]`, go's
343
- * `["test", "-run", …]`, rspec's `["exec", "rspec", file]` once binName is
344
- * "bundle", etc.) is a real subcommand/argv and is returned unchanged (#1098).
345
- */
346
- export function stripWrapperArgs(binName, args) {
347
- if (args[0] === binName)
348
- return args.slice(1);
349
- if (args[0] === "-m" && args[1] === binName)
350
- return args.slice(2);
351
- return args;
352
- }
353
- // --- Client ---
354
- const MAX_FAILED_TARGETS_PER_RUNNER = 32;
355
- const MAX_FAILED_TARGET_CHECKS_PER_SELECTION = 8;
356
- const FAILED_TARGET_DETAIL_CAP_PER_TURN = 8;
357
- function canonicalFailedPath(filePath) {
358
- const absolute = path.resolve(filePath);
359
- try {
360
- return normalizeMapKey(fs.realpathSync.native(absolute));
361
- }
362
- catch {
363
- return normalizeMapKey(absolute);
364
- }
365
- }
366
- function canonicalProjectRoot(cwd) {
367
- const absolute = path.resolve(cwd);
368
- try {
369
- return {
370
- key: normalizeEphemeralMapKey(fs.realpathSync.native(absolute)),
371
- resolved: true,
372
- };
373
- }
374
- catch {
375
- return { key: normalizeEphemeralMapKey(absolute), resolved: false };
376
- }
377
- }
378
- export const MAX_CANONICAL_ROOT_MEMO_ENTRIES = 512;
379
- function filesystemErrorCode(error) {
380
- if (error !== null &&
381
- typeof error === "object" &&
382
- "code" in error &&
383
- typeof error.code === "string") {
384
- return error.code;
385
- }
386
- return undefined;
387
- }
388
- const PYTEST_SUMMARY_OUTCOME = "(?:passed|failed|skipped|reruns?|errors?|warnings?|deselected|xfailed|xpassed)";
389
- const PYTEST_SUMMARY_LINE = new RegExp(`^\\s*(?:=+\\s*)?\\d+\\s+${PYTEST_SUMMARY_OUTCOME}` +
390
- `(?:\\s*,\\s*\\d+\\s+${PYTEST_SUMMARY_OUTCOME})*` +
391
- `\\s+in\\s+[\\d.]+s(?:\\s*=+)?\\s*$`, "i");
392
- /** Extract aggregate values from pytest's final outcome line only. */
393
- function parsePytestSummary(output) {
394
- // Pytest may color the whole summary or individual tokens. Strip ANSI before
395
- // selecting and extracting so formatting cannot turn the first count into 0.
396
- const normalizedOutput = stripAnsi(output);
397
- const summaryLine = normalizedOutput
398
- .split(/\r?\n/)
399
- .reverse()
400
- .find((line) => PYTEST_SUMMARY_LINE.test(line));
401
- let passed = 0;
402
- let failed = 0;
403
- let skipped = 0;
404
- if (!summaryLine)
405
- return { passed, failed, skipped };
406
- const summaryBody = summaryLine.replace(/^=+\s*|\s*=+\s*$/g, "");
407
- for (const field of summaryBody.split(",")) {
408
- const [countText, outcome] = field.trim().split(/\s+/, 2);
409
- const count = Number.parseInt(countText, 10);
410
- if (!Number.isFinite(count))
411
- continue;
412
- switch (outcome) {
413
- case "passed":
414
- passed = count;
415
- break;
416
- case "failed":
417
- failed = count;
418
- break;
419
- case "skipped":
420
- skipped = count;
421
- break;
422
- }
423
- }
424
- const durationMatch = /in\s+([\d.]+)s/.exec(summaryLine);
425
- // Rounded, like `jsonRunDurationMs` and PHPUnit's legacy path:
426
- // `in 2.01s` is 2009.9999999999998 in binary floating point.
427
- const duration = durationMatch
428
- ? Math.round(Number.parseFloat(durationMatch[1]) * 1000)
429
- : undefined;
430
- return { passed, failed, skipped, duration };
431
- }
432
- export class TestRunnerClient {
433
- log;
434
- // This is an instance-lifetime memo of RESOLVED spellings only, which leaves
435
- // two temporal edges. A symlink retargeted mid-session keeps its old
436
- // resolution until a new client instance — acceptable because round 2's
437
- // evidence re-validation already handles verdict-level staleness (positive
438
- // verdicts re-stat their config file). A spelling that did NOT resolve is
439
- // never memoized (#2077): the fallback key is a guess about a path that does
440
- // not exist yet, so memoizing it would pin an alias probed before its
441
- // symlink was created to a stale verdict for the instance's life. Re-probing
442
- // costs one failing realpath per call, and only for a cwd that does not
443
- // resolve — a state where `detectRunner` already walks node_modules on every
444
- // call. Keep the memo bounded so pathological spelling churn cannot grow it
445
- // without limit.
446
- canonicalRootMemo = new BoundedFifoMap(MAX_CANONICAL_ROOT_MEMO_ENTRIES);
447
- availableRunners = new PathKeyedMap(normalizeEphemeralMapKey);
448
- failedTestsByRunner = new Map();
449
- failedTargetSequence = 0;
450
- statFailedTarget;
451
- // Best-effort vitest config `test.include`/`test.exclude` globs, scraped as
452
- // plain text (never executed) and cached per cwd so the config file is
453
- // only read/parsed once, not on every edit. `null` means "no config found
454
- // or it couldn't be parsed in the simple shape we look for" — callers
455
- // treat that as "no additional signal" and fall back to naming-convention
456
- // detection only.
457
- vitestTestGlobsCache = new PathKeyedMap(normalizeEphemeralMapKey);
458
- constructor(verbose = false, options = {}) {
459
- this.log = verbose ? createSubsystemLogger("test-runner") : () => { };
460
- this.statFailedTarget =
461
- options.statFailedTarget ?? ((filePath) => void fs.statSync(filePath));
462
- }
463
- getCanonicalProjectRoot(cwd) {
464
- const cached = this.canonicalRootMemo.get(cwd);
465
- if (cached !== undefined)
466
- return cached;
467
- const { key, resolved } = canonicalProjectRoot(cwd);
468
- if (!resolved)
469
- return key;
470
- this.canonicalRootMemo.set(cwd, key);
471
- return key;
472
- }
473
- /** #2442 test-only: exercise canonicalRootMemo's bounded eviction directly. */
474
- _getCanonicalProjectRootForTests(cwd) {
475
- return this.getCanonicalProjectRoot(cwd);
476
- }
477
- getRunnerAvailability(byRunner, runner) {
478
- const cached = byRunner?.get(runner);
479
- if (!cached)
480
- return undefined;
481
- if (cached.available &&
482
- cached.evidencePath !== undefined &&
483
- !fs.existsSync(cached.evidencePath)) {
484
- byRunner?.delete(runner);
485
- return undefined;
486
- }
487
- return cached.available;
488
- }
489
- /**
490
- * #2252: only a POSITIVE verdict is memoized. A negative one has no
491
- * `evidencePath` to re-stat, so `getRunnerAvailability` had no way to tell
492
- * "still absent" from "a config file just appeared" and served the first
493
- * miss for the client's whole process lifetime — measured live: probe an
494
- * empty directory, add `vitest.config.ts`, and the SAME client kept
495
- * answering "no runner". Same precedent as #2242's alias-canonicalization
496
- * fix (`clients/test-runner-client.ts`'s `getCanonicalProjectRoot`): drop
497
- * the memo write on the failure branch rather than adding a TTL or a
498
- * re-arm signal. The cost is bounded and already paid today — a cache miss
499
- * re-runs the exact same `configFiles.some(fs.existsSync)` walk this
500
- * method's caller already does on every FIRST probe of a runner.
501
- */
502
- setRunnerAvailability(byRunner, runner, available, evidencePath) {
503
- if (!available)
504
- return;
505
- byRunner.set(runner, { available, evidencePath });
506
- }
507
- /**
508
- * Which runners may be handed `sourceFilePath`, and the directory their
509
- * config files are probed in (#2870).
510
- *
511
- * `eligible === null` means "no file was named" — the session-level
512
- * caller (`runtime-session.ts`'s summary) asks "what runner does this
513
- * project have" and has no file to scope to, so every runner stays a
514
- * candidate and the probe root is the dispatch root, exactly as before.
515
- *
516
- * A named file resolves TWO independent facts, and the order matters:
517
- * - its KIND decides which runners may claim it at all (the gate), and
518
- * - `resolveLanguageRootForFile` decides WHERE that kind's config files
519
- * are looked for (the anchor) — the same per-kind, cached,
520
- * workspace-clamped walk the dispatch runners already resolve their
521
- * roots with, not a private walker.
522
- *
523
- * Returning `null` means no runner can own this file (a kind no entry
524
- * claims: markdown, yaml, an unknown extension), so no directory is
525
- * probed at all.
526
- */
527
- resolveDetectionScope(cwd, sourceFilePath) {
528
- if (sourceFilePath === undefined)
529
- return { root: cwd, eligible: null };
530
- const absoluteFile = path.resolve(sourceFilePath);
531
- // #2522 (pinned by "fails closed through the REAL getTestRunTarget when
532
- // the resolved target is out of tree"): a file OUTSIDE the dispatch root
533
- // keeps the pre-#2870 resolution — every runner, probed at the dispatch
534
- // root — so `getTestRunTarget` still hands the exclusion layer a target
535
- // to fail closed on instead of quietly answering "no runner" for a file
536
- // the turn should refuse loudly. Per-file anchoring has nothing to
537
- // anchor to out there: the language-root walk is clamped to the
538
- // workspace, so every out-of-tree file resolves to the dispatch root
539
- // anyway.
540
- if (!isUnderDir(absoluteFile, path.resolve(cwd))) {
541
- return { root: cwd, eligible: null };
542
- }
543
- const kind = detectFileKind(absoluteFile);
544
- if (!kind)
545
- return null;
546
- const eligible = new Set(Object.entries(RUNNERS)
547
- .filter(([, config]) => config.kinds.includes(kind))
548
- .map(([name]) => name));
549
- if (eligible.size === 0)
550
- return null;
551
- return {
552
- root: resolveLanguageRootForFile(absoluteFile, cwd),
553
- eligible,
554
- };
555
- }
556
- /**
557
- * Check if a test runner is available in the project
558
- * Detection order:
559
- * 1. Config files (vitest.config.ts, jest.config.js, etc.)
560
- * 2. package.json dependencies
561
- * 3. node_modules presence
562
- *
563
- * #2870: when `sourceFilePath` is given, the answer is scoped to that
564
- * file's kind and resolved language root — see `resolveDetectionScope`.
565
- */
566
- detectRunner(dispatchRoot, sourceFilePath) {
567
- const scope = this.resolveDetectionScope(dispatchRoot, sourceFilePath);
568
- if (!scope)
569
- return null;
570
- const { root, eligible } = scope;
571
- const anchored = this.probeRunnersAt(root, eligible);
572
- if (anchored)
573
- return anchored;
574
- // #2879 review round 2, F1: the anchor is a LANGUAGE root, and
575
- // `ROOT_MARKERS_BY_KIND` is deliberately BROADER than any runner's
576
- // `configFiles` — `requirements.txt`, `setup.py`, `Pipfile`,
577
- // `Rakefile`, `composer.lock`, `.classpath` all anchor a language
578
- // without configuring a test runner. Probing the anchored directory
579
- // ALONE therefore let any such intermediate marker shadow the
580
- // project root's real runner config, and pytest/rspec/minitest/
581
- // phpunit have no later priority to rescue them: four single-language
582
- // repos went from a working runner to no target at all (measured, the
583
- // review's `anchor.mjs` probe), which is exactly what #2870's amended
584
- // criterion 4 protects.
585
- //
586
- // So the anchored probe is a PREFERENCE, not a restriction: on a miss,
587
- // re-probe the dispatch root under the SAME kind gate. The gate is
588
- // what keeps #2870 fixed — a `.java` file re-probing a polyglot root
589
- // still cannot reach `go`, because `go` never claims the `java` kind.
590
- const dispatch = path.resolve(dispatchRoot);
591
- if (path.resolve(root) !== dispatch) {
592
- const atDispatch = this.probeRunnersAt(dispatch, eligible);
593
- if (atDispatch)
594
- return atDispatch;
595
- }
596
- // Priority 5: Check if pytest is available globally (Python files only)
597
- const isPythonSource = typeof sourceFilePath === "string" && sourceFilePath.endsWith(".py");
598
- if (!isPythonSource)
599
- return null;
600
- try {
601
- const whichCmd = process.platform === "win32" ? "where" : "which";
602
- const result = safeSpawn(whichCmd, ["pytest"], {
603
- timeout: 2000,
604
- });
605
- if (result.status === 0) {
606
- this.log("Detected pytest globally");
607
- return { runner: "pytest", config: RUNNERS.pytest };
608
- }
609
- }
610
- catch (err) {
611
- void err;
612
- }
613
- return null;
614
- }
615
- /**
616
- * Priorities 1-4 of the detection ladder, against ONE directory: config
617
- * files, `package.json` dependencies, a hoisted `node_modules`, and the
618
- * glob-capable whole-project runners. `eligible` is the kind gate
619
- * (#2870); `null` means no file was named and every runner is a
620
- * candidate. Returns `null` when this directory configures no eligible
621
- * runner, which is what lets `detectRunner` try the dispatch root next.
622
- */
623
- probeRunnersAt(cwd, eligible) {
624
- // Keyed on the probed directory, not the dispatch root: two modules of
625
- // one polyglot repo have different runners available, and a memo keyed
626
- // on the shared dispatch root would serve the first module's verdict to
627
- // every other module (#2870).
628
- const rootKey = this.getCanonicalProjectRoot(cwd);
629
- let byRunner = this.availableRunners.get(rootKey);
630
- if (!byRunner) {
631
- byRunner = new Map();
632
- this.availableRunners.set(rootKey, byRunner);
633
- }
634
- // Priority 1: Config files
635
- for (const [name, config] of Object.entries(RUNNERS)) {
636
- if (eligible && !eligible.has(name))
637
- continue;
638
- const cached = this.getRunnerAvailability(byRunner, name);
639
- if (cached !== undefined) {
640
- if (cached) {
641
- return { runner: name, config };
642
- }
643
- continue;
644
- }
645
- let configEvidencePath;
646
- const found = config.configFiles.some((cf) => {
647
- if (name === "pytest" && cf === "pyproject.toml") {
648
- const pyprojectPath = path.join(cwd, cf);
649
- if (!fs.existsSync(pyprojectPath))
650
- return false;
651
- try {
652
- const pyproject = fs.readFileSync(pyprojectPath, "utf-8");
653
- const matches = pyproject.includes("[tool.pytest.ini_options]");
654
- if (matches)
655
- configEvidencePath = pyprojectPath;
656
- return matches;
657
- }
658
- catch {
659
- return false;
660
- }
661
- }
662
- if (name === "phpunit" && cf === "composer.json") {
663
- const composerPath = path.join(cwd, cf);
664
- if (!fs.existsSync(composerPath))
665
- return false;
666
- try {
667
- const composer = JSON.parse(fs.readFileSync(composerPath, "utf-8"));
668
- const allDeps = {
669
- ...composer.require,
670
- ...composer["require-dev"],
671
- };
672
- const matches = Boolean(allDeps["phpunit/phpunit"]);
673
- if (matches)
674
- configEvidencePath = composerPath;
675
- return matches;
676
- }
677
- catch {
678
- return false;
679
- }
680
- }
681
- const candidate = path.join(cwd, cf);
682
- const matches = fs.existsSync(candidate);
683
- if (matches)
684
- configEvidencePath = candidate;
685
- return matches;
686
- });
687
- this.setRunnerAvailability(byRunner, name, found, configEvidencePath);
688
- if (found) {
689
- this.log(`Detected runner via config: ${name}`);
690
- return { runner: name, config };
691
- }
692
- }
693
- const packageJsonPath = path.join(cwd, "package.json");
694
- try {
695
- const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
696
- const allDeps = {
697
- ...pkg.dependencies,
698
- ...pkg.devDependencies,
699
- };
700
- // Check for vitest first (more specific than jest)
701
- if (allDeps.vitest && (!eligible || eligible.has("vitest"))) {
702
- this.log("Detected vitest in package.json");
703
- this.setRunnerAvailability(byRunner, "vitest", true, packageJsonPath);
704
- return { runner: "vitest", config: RUNNERS.vitest };
705
- }
706
- if (allDeps.jest && (!eligible || eligible.has("jest"))) {
707
- this.log("Detected jest in package.json");
708
- this.setRunnerAvailability(byRunner, "jest", true, packageJsonPath);
709
- return { runner: "jest", config: RUNNERS.jest };
710
- }
711
- if ((allDeps.pytest || allDeps["pytest-cov"]) &&
712
- (!eligible || eligible.has("pytest"))) {
713
- this.log("Detected pytest in package.json (unusual)");
714
- this.setRunnerAvailability(byRunner, "pytest", true, packageJsonPath);
715
- return { runner: "pytest", config: RUNNERS.pytest };
716
- }
717
- }
718
- catch (err) {
719
- void err;
720
- // package.json parse error or file not found
721
- }
722
- // Priority 3: Check node_modules for installed packages, including a
723
- // hoisted monorepo layout where cwd is a workspace package (e.g.
724
- // packages/foo) but the runner only lives in node_modules at the
725
- // workspace root (npm/yarn/pnpm workspace hoisting).
726
- for (const name of ["vitest", "jest"]) {
727
- if (eligible && !eligible.has(name))
728
- continue;
729
- const hoisted = findHoistedNodeModulesPackage(cwd, name);
730
- if (hoisted) {
731
- this.log(`Detected ${name} in node_modules (${hoisted})`);
732
- return { runner: name, config: RUNNERS[name] };
733
- }
734
- }
735
- for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
736
- if (eligible && !eligible.has(name))
737
- continue;
738
- const config = RUNNERS[name];
739
- const found = config.configFiles.some((cf) => {
740
- // Handle glob patterns like *.csproj
741
- if (cf.includes("*")) {
742
- try {
743
- const files = fs.readdirSync(cwd);
744
- return files.some((f) => new RegExp(cf.replace(/\*/g, ".*")).test(f));
745
- }
746
- catch {
747
- return false;
748
- }
749
- }
750
- return fs.existsSync(path.join(cwd, cf));
751
- });
752
- if (found) {
753
- this.log(`Detected ${name} from config file`);
754
- return { runner: name, config };
755
- }
756
- }
757
- return null;
758
- }
759
- /**
760
- * Depth-bounded breadth-first search under `rootDir` for a pytest-style
761
- * test file matching `pattern` (exact, e.g. `test_foo.py`) or the
762
- * looser `test_*<basename>*.py` convention. Used as a last-resort
763
- * fallback when a Python test suite groups tests by kind
764
- * (`tests/unit/`, `tests/integration/`) instead of mirroring the
765
- * source directory layout, so the exact-mirror candidates in
766
- * `findTestFile` don't match. Bounded by `maxDepth` levels below
767
- * `rootDir` and skips hidden directories and `__pycache__` — never an
768
- * unbounded walk of the whole tests tree.
769
- */
770
- findPytestMatchRecursive(rootDir, pattern, basename, maxDepth) {
771
- const queue = [
772
- { dir: rootDir, depth: 0 },
773
- ];
774
- while (queue.length > 0) {
775
- const next = queue.shift();
776
- if (!next)
777
- break;
778
- const { dir, depth } = next;
779
- let entries;
780
- try {
781
- entries = fs.readdirSync(dir, { withFileTypes: true });
782
- }
783
- catch {
784
- continue;
785
- }
786
- for (const entry of entries) {
787
- const fullPath = path.join(dir, entry.name);
788
- if (entry.isFile()) {
789
- if (entry.name === pattern ||
790
- (entry.name.startsWith("test_") &&
791
- entry.name.endsWith(".py") &&
792
- entry.name.includes(basename))) {
793
- return fullPath;
794
- }
795
- }
796
- else if (entry.isDirectory() && depth < maxDepth) {
797
- if (entry.name === "__pycache__" || entry.name.startsWith("."))
798
- continue;
799
- queue.push({ dir: fullPath, depth: depth + 1 });
800
- }
801
- }
802
- }
803
- return null;
804
- }
805
- /**
806
- * Path of `dir` relative to `cwd`, using forward slashes, or null if `dir`
807
- * is not inside `cwd` (e.g. resolves to `..` or an absolute path).
808
- * Used to compute a mirrored test-tree subdirectory (e.g. `clients` for
809
- * `clients/knip-client.ts`, so `tests/clients/knip-client.test.ts` is
810
- * checked alongside the flat `tests/knip-client.test.ts` candidate).
811
- */
812
- relativeSourceDir(sourceFilePath, cwd) {
813
- const dir = path.dirname(sourceFilePath);
814
- const relDir = path.relative(cwd, path.resolve(cwd, dir));
815
- if (!relDir ||
816
- relDir === "." ||
817
- relDir.startsWith("..") ||
818
- path.isAbsolute(relDir)) {
819
- return null;
820
- }
821
- return relDir;
822
- }
823
- /**
824
- * Best-effort, text-only scrape of a vitest config's `test.include` /
825
- * `test.exclude` arrays. This deliberately does NOT execute the config
826
- * file (that would mean loading arbitrary ESM/TS via Vite's config
827
- * loader — too heavy for a per-edit hot path). It just looks for a
828
- * simple `include: [ ... ]` / `exclude: [ ... ]` shape with string
829
- * literals inside and pulls those out with a regex.
830
- *
831
- * Returns `null` (never throws) when there's no vitest config file, it
832
- * can't be read, or the include/exclude shape isn't a plain array of
833
- * string literals (e.g. it's built from a function call, spread, or
834
- * template expression) — anything more dynamic than that is out of
835
- * scope for this heuristic.
836
- *
837
- * Cached per `cwd`, including a `null` result — #2252 F2: a project with
838
- * no vitest config, or one this heuristic can't scrape, is a COMMON shape
839
- * (every non-vitest project pays this on every edit otherwise; measured
840
- * ~1500x — 0.4µs cached vs. 598.7µs re-reading the candidate list every
841
- * call). The cache entry is revalidated on every read, the same shape
842
- * `getRunnerAvailability` uses for a positive verdict: bounded
843
- * `fs.existsSync` checks against the config file the result was derived
844
- * from (or, when none existed, against the candidate list itself), never
845
- * a full re-read/re-parse on a cache hit. So a config file appearing (or
846
- * a broken one being fixed) still converges — only the FULL parse is
847
- * paid once, not the existence check.
848
- */
849
- parseVitestTestGlobs(cwd) {
850
- const rootKey = this.getCanonicalProjectRoot(cwd);
851
- // .mts isn't in RUNNERS.vitest.configFiles (that list drives runner
852
- // *detection* priority) but is a legal vitest config extension, so it's
853
- // included here for the scrape even though detectRunner doesn't check it.
854
- const candidates = [...RUNNERS.vitest.configFiles, "vitest.config.mts"];
855
- const cached = this.vitestTestGlobsCache.get(rootKey);
856
- if (cached !== undefined) {
857
- const stillValid = cached.evidencePath !== undefined
858
- ? fs.existsSync(cached.evidencePath)
859
- : !candidates.some((cf) => fs.existsSync(path.join(cwd, cf)));
860
- if (stillValid)
861
- return cached.result;
862
- this.vitestTestGlobsCache.delete(rootKey);
863
- }
864
- let content = null;
865
- let foundPath;
866
- for (const cf of candidates) {
867
- const candidatePath = path.join(cwd, cf);
868
- try {
869
- content = fs.readFileSync(candidatePath, "utf-8");
870
- foundPath = candidatePath;
871
- break;
872
- }
873
- catch {
874
- continue;
875
- }
876
- }
877
- let result = null;
878
- if (content !== null) {
879
- const include = this.extractGlobArrayLiteral(content, "include");
880
- const exclude = this.extractGlobArrayLiteral(content, "exclude");
881
- if (include || exclude) {
882
- result = {};
883
- if (include)
884
- result.include = include;
885
- if (exclude)
886
- result.exclude = exclude;
887
- }
888
- }
889
- this.vitestTestGlobsCache.set(rootKey, { result, evidencePath: foundPath });
890
- return result;
891
- }
892
- /**
893
- * Extract `<key>: [ 'a', "b", `c` ]` as a plain string array from raw
894
- * config text. Returns undefined if the key isn't present, or if the
895
- * array body contains anything besides string literals and commas/
896
- * whitespace (a function call, spread, variable reference, etc.) —
897
- * that's a sign the value is dynamic and this best-effort scrape can't
898
- * safely interpret it.
899
- */
900
- extractGlobArrayLiteral(content, key) {
901
- const arrayMatch = content.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`));
902
- if (!arrayMatch)
903
- return undefined;
904
- const body = arrayMatch[1];
905
- const literalPattern = /'([^'\\]*)'|"([^"\\]*)"|`([^`\\]*)`/g;
906
- const literals = [];
907
- let lastEnd = 0;
908
- let match;
909
- while ((match = literalPattern.exec(body)) !== null) {
910
- const between = body.slice(lastEnd, match.index).trim();
911
- // Only whitespace/commas may appear between literals — anything
912
- // else (identifiers, parens, spreads) means the array isn't a
913
- // plain list of string literals.
914
- if (between !== "" && !/^,$/.test(between))
915
- return undefined;
916
- literals.push(match[1] ?? match[2] ?? match[3] ?? "");
917
- lastEnd = literalPattern.lastIndex;
918
- }
919
- const trailing = body.slice(lastEnd).trim();
920
- if (trailing !== "" && trailing !== ",")
921
- return undefined;
922
- return literals.length > 0 ? literals : undefined;
923
- }
924
- /**
925
- * Whether `sourceFilePath` is itself a test file (as opposed to a source
926
- * file whose *related* test file needs to be discovered).
927
- *
928
- * Primary signal: `detectFileRole` (naming convention: `.test.`/`.spec.`
929
- * basenames, `_test.`/`_spec.` suffix infixes, `*Test(s).<ext>` CamelCase
930
- * suffixes, `test_`/`spec_` prefixes, `__tests__/`/`tests/`/`spec/`
931
- * directories — shared with the rest of the codebase, not a second
932
- * parallel detector).
933
- *
934
- * Secondary signal (vitest only): the project's own `test.include` /
935
- * `test.exclude` globs, best-effort scraped by `parseVitestTestGlobs`.
936
- * This can correct the naming-convention answer in both directions —
937
- * an `exclude` glob can rule out a path that looks like a test by name,
938
- * and an `include` glob can catch a project that puts tests somewhere
939
- * unconventional. When no config is found or it can't be parsed, this
940
- * is a no-op and behavior is unchanged.
941
- *
942
- * #628: a positive `include` override is only trusted when the glob is a
943
- * *narrow* test signal (see `isNarrowTestGlob`) — a bare "any file with
944
- * this extension" include (e.g. `src/**\/*.ts`) is common in real vitest
945
- * configs and matches ordinary source files, so treating any match as
946
- * "this is a test" produced vacuous `0p/0f` self-runs on plain source
947
- * files (background-review.ts, index.ts, …). The `exclude` direction is
948
- * left as a plain match: over-excluding only causes discovery to run on a
949
- * file that's actually a test (falls back to `findTestFile`, not a false
950
- * "self" positive), which is the safe failure mode.
951
- */
952
- isTestFile(sourceFilePath, cwd, runner) {
953
- let result = detectFileRole(sourceFilePath) === "test";
954
- if (runner === "vitest") {
955
- const globs = this.parseVitestTestGlobs(cwd);
956
- if (globs) {
957
- const rel = path
958
- .relative(cwd, path.resolve(cwd, sourceFilePath))
959
- .replace(/\\/g, "/");
960
- const matches = (globs_, filter) => !!globs_?.some((g) => (!filter || filter(g)) && minimatch(rel, g, { dot: true }));
961
- if (matches(globs.exclude)) {
962
- result = false;
963
- }
964
- else if (!result &&
965
- matches(globs.include, (g) => this.isNarrowTestGlob(g))) {
966
- result = true;
967
- }
968
- }
969
- }
970
- return result;
971
- }
972
- /**
973
- * Whether an `include` glob is a specific enough signal to override a
974
- * plain "this is source, not a test" naming-convention verdict (#628).
975
- *
976
- * Trusted when either:
977
- * - a literal (non-wildcard) path segment before the first wildcard
978
- * names a conventional test location (`tests/`, `test/`, `spec/`,
979
- * `specs/`, `__tests__/`) — the real case this override exists for:
980
- * a project whose test files live in such a directory without a
981
- * `.test.`/`.spec.` name (e.g. `tests/**\/*.ts`).
982
- * - the static suffix after the last wildcard encodes more than the
983
- * bare language extension (e.g. `.check.ts`, `.flow.ts`) — an explicit
984
- * project-specific naming convention, not "any file with this
985
- * extension" (e.g. `**\/*.check.ts`).
986
- *
987
- * Rejected for a bare extension glob with no test-ish directory (e.g.
988
- * `src/**\/*.ts`, `**\/*.ts`) — that shape matches every source file in
989
- * the tree and is exactly what produced vacuous self-runs in practice.
990
- */
991
- isNarrowTestGlob(glob) {
992
- const testDirPattern = /^(tests?|specs?|__tests__)$/i;
993
- for (const segment of glob.split("/")) {
994
- if (segment.includes("*") || segment.includes("?"))
995
- break;
996
- if (testDirPattern.test(segment))
997
- return true;
998
- }
999
- const lastWildcard = Math.max(glob.lastIndexOf("*"), glob.lastIndexOf("?"));
1000
- const suffix = lastWildcard >= 0 ? glob.slice(lastWildcard + 1) : glob;
1001
- const dotSegments = suffix.split(".").filter(Boolean);
1002
- return dotSegments.length >= 2;
1003
- }
1004
- /**
1005
- * Find test file for a given source file
1006
- * Returns the test file path if it exists, null otherwise
1007
- */
1008
- findTestFile(sourceFilePath, cwd, runnerOverride) {
1009
- const ext = path.extname(sourceFilePath);
1010
- const basename = path.basename(sourceFilePath, ext);
1011
- const dir = path.dirname(sourceFilePath);
1012
- const patterns = SOURCE_TO_TEST_PATTERNS.find((p) => p.ext === ext);
1013
- if (!patterns)
1014
- return null;
1015
- const detected = runnerOverride
1016
- ? { runner: runnerOverride, config: RUNNERS[runnerOverride] }
1017
- : this.detectRunner(cwd, sourceFilePath);
1018
- if (!detected)
1019
- return null;
1020
- // Relative subdirectory of the source file, used to check a mirrored
1021
- // test-tree layout (tests/<same-subdir>/<basename><testExt>), on top of
1022
- // the flat tests/<basename><testExt> layout already checked below.
1023
- // Null when the source file sits at the project root (dir === ".") or
1024
- // falls outside cwd — in that case there is no subdir to mirror.
1025
- const relDir = this.relativeSourceDir(sourceFilePath, cwd);
1026
- // Check each potential test file location
1027
- for (let i = 0; i < patterns.testExts.length; i++) {
1028
- const testExt = patterns.testExts[i];
1029
- const testDir = patterns.dirs[i];
1030
- // Handle glob patterns (pytest style: test_*.py)
1031
- if (testExt.includes("*")) {
1032
- const pattern = testExt.replace(/\*/g, basename);
1033
- const searchDirs = testDir === "."
1034
- ? [dir]
1035
- : relDir
1036
- ? [path.join(cwd, testDir, relDir), path.join(cwd, testDir)]
1037
- : [path.join(cwd, testDir)];
1038
- for (const searchDir of searchDirs) {
1039
- let files;
1040
- try {
1041
- files = fs.readdirSync(searchDir);
1042
- }
1043
- catch (err) {
1044
- void err;
1045
- continue;
1046
- }
1047
- const match = files.find((f) => f === pattern ||
1048
- (f.startsWith("test_") &&
1049
- f.endsWith(".py") &&
1050
- f.includes(basename)));
1051
- if (match) {
1052
- const testPath = path.join(searchDir, match);
1053
- this.log(`Found test file: ${testPath}`);
1054
- return { testFile: testPath, runner: detected.runner };
1055
- }
1056
- }
1057
- // None of the exact-mirror candidates matched. Python test
1058
- // suites commonly group tests by kind (tests/unit/,
1059
- // tests/integration/) rather than mirroring the source tree,
1060
- // so do a depth-bounded recursive search under the test
1061
- // root as a last resort before falling back to import
1062
- // scanning — bounded so a large repo can't turn this into
1063
- // an unbounded directory walk.
1064
- if (testDir !== ".") {
1065
- const recursiveMatch = this.findPytestMatchRecursive(path.join(cwd, testDir), pattern, basename, MAX_PYTEST_RECURSE_DEPTH);
1066
- if (recursiveMatch) {
1067
- this.log(`Found test file (recursive): ${recursiveMatch}`);
1068
- return { testFile: recursiveMatch, runner: detected.runner };
1069
- }
1070
- }
1071
- }
1072
- else {
1073
- // Exact pattern match (jest/vitest style)
1074
- const testFilename = basename + testExt;
1075
- const searchPaths = [
1076
- path.join(dir, testFilename), // same directory
1077
- path.join(dir, "__tests__", testFilename), // __tests__ subdirectory
1078
- ...(relDir
1079
- ? [
1080
- path.join(cwd, "tests", relDir, testFilename), // mirrored tests/<subdir>/
1081
- path.join(cwd, "__tests__", relDir, testFilename), // mirrored __tests__/<subdir>/
1082
- ]
1083
- : []),
1084
- path.join(cwd, "tests", testFilename), // top-level tests/
1085
- path.join(cwd, "__tests__", testFilename), // top-level __tests__/
1086
- // PHP/Elixir-style source-root mirroring (e.g. src/Foo/Bar.php ->
1087
- // tests/Foo/BarTest.php, lib/accounts/user.ex ->
1088
- // test/accounts/user_test.exs): strips a conventional source-root
1089
- // segment and mirrors under this pattern's OWN configured test
1090
- // root (testDir), not the hardcoded "tests"/"__tests__" above —
1091
- // ExUnit's root is "test" (singular), which those don't cover.
1092
- ...this.sourceRootMirroredCandidates(dir, cwd, testDir, testFilename),
1093
- ];
1094
- for (const testPath of searchPaths) {
1095
- if (fs.existsSync(testPath)) {
1096
- this.log(`Found test file: ${testPath}`);
1097
- return { testFile: testPath, runner: detected.runner };
1098
- }
1099
- }
1100
- }
1101
- }
1102
- // Basename lookup found nothing — try import scanning as a fallback.
1103
- const importMatch = this.findTestFileByImport(sourceFilePath, cwd);
1104
- if (importMatch) {
1105
- return { testFile: importMatch, runner: detected.runner };
1106
- }
1107
- return null;
1108
- }
1109
- /**
1110
- * Select the most useful test target for this edit.
1111
- *
1112
- * Strategy:
1113
- * 1) If there are known failing tests, rerun those first (fast feedback loop).
1114
- * 2) Otherwise run related tests for the edited file.
1115
- */
1116
- getTestRunTarget(sourceFilePath, cwd, turnIndex) {
1117
- const detected = this.detectRunner(cwd, sourceFilePath);
1118
- if (!detected)
1119
- return null;
1120
- const failedSet = this.getFailedTargets(cwd, detected.runner);
1121
- // If the edited file is itself a test file, there's no "related test"
1122
- // to discover — running findTestFile on it would strip its own
1123
- // extension and search for nonsense like foo.test.test.ts. Skip
1124
- // discovery entirely and treat the file as its own target.
1125
- const selfIsTest = this.isTestFile(sourceFilePath, cwd, detected.runner);
1126
- const related = selfIsTest
1127
- ? null
1128
- : this.findTestFile(sourceFilePath, cwd, detected.runner);
1129
- if (failedSet && failedSet.size > 0) {
1130
- const failedFirst = this.retireMissingFailedTargets({
1131
- cwd,
1132
- runner: detected.runner,
1133
- failedTargets: failedSet,
1134
- relatedAbs: related ? path.resolve(related.testFile) : undefined,
1135
- selfAbs: selfIsTest ? path.resolve(sourceFilePath) : undefined,
1136
- turnIndex,
1137
- });
1138
- if (failedFirst) {
1139
- return {
1140
- testFile: failedFirst,
1141
- runner: detected.runner,
1142
- config: detected.config,
1143
- strategy: "failed-first",
1144
- };
1145
- }
1146
- }
1147
- if (selfIsTest) {
1148
- return {
1149
- testFile: path.resolve(sourceFilePath),
1150
- runner: detected.runner,
1151
- config: detected.config,
1152
- strategy: "self",
1153
- };
1154
- }
1155
- if (!related)
1156
- return null;
1157
- return {
1158
- testFile: path.resolve(related.testFile),
1159
- runner: detected.runner,
1160
- config: detected.config,
1161
- strategy: "related",
1162
- };
1163
- }
1164
- async runTestFileAsync(testFile, cwd, runnerOrRequest, legacyConfig) {
1165
- const absoluteTestFile = path.resolve(testFile);
1166
- let request;
1167
- if (typeof runnerOrRequest === "string") {
1168
- if (!legacyConfig) {
1169
- return this.emptyResult(absoluteTestFile, "", runnerOrRequest, "Runner configuration missing");
1170
- }
1171
- request = { runner: runnerOrRequest, config: legacyConfig };
1172
- }
1173
- else {
1174
- request = runnerOrRequest;
1175
- }
1176
- const { runner, config, turnIndex, signal } = request;
1177
- if (!fs.existsSync(absoluteTestFile)) {
1178
- return this.emptyResult(absoluteTestFile, "", runner, "Test file not found");
1179
- }
1180
- try {
1181
- const spawnCwd = resolveToolCwd("runner", runner, absoluteTestFile, {
1182
- cwd: path.resolve(cwd),
1183
- rootMarkers: config.spawnCwdMarkers ?? config.configFiles,
1184
- }).cwd;
1185
- const { command, args, env } = await this.resolveExec(runner, config, absoluteTestFile, cwd, spawnCwd);
1186
- this.log(`Running (async): ${command} ${args.join(" ")} (cwd ${spawnCwd})`);
1187
- const result = await safeSpawnAsync(command, args, {
1188
- cwd: spawnCwd,
1189
- timeout: 60000,
1190
- env,
1191
- // #2522 R2 F1. `safeSpawnAsync` resolves `options.signal ?? ambient`,
1192
- // so an absent batch signal keeps the pre-#2522 ambient behaviour.
1193
- signal,
1194
- });
1195
- const stdout = result.stdout || "";
1196
- const stderr = result.stderr || "";
1197
- if (result.error) {
1198
- this.log(`Runner error: ${result.error.message}`);
1199
- return this.emptyResult(absoluteTestFile, "", runner, `Runner error: ${result.error.message}`);
1200
- }
1201
- let parsed;
1202
- switch (runner) {
1203
- case "vitest":
1204
- parsed = this.parseVitestOutput(stdout, stderr, absoluteTestFile, cwd, runner);
1205
- break;
1206
- case "jest":
1207
- parsed = this.parseJestOutput(stdout, stderr, absoluteTestFile, cwd, runner);
1208
- break;
1209
- case "pytest":
1210
- parsed = this.parsePytestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, cwd, runner);
1211
- break;
1212
- case "phpunit":
1213
- parsed = this.parsePhpunitOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
1214
- break;
1215
- case "mix":
1216
- parsed = this.parseMixTestOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
1217
- break;
1218
- default:
1219
- parsed = this.parseGenericRunnerOutput(stdout, stderr, result.status ?? 0, absoluteTestFile, runner);
1220
- break;
1221
- }
1222
- this.recordResult({
1223
- cwd,
1224
- runner,
1225
- testFile: absoluteTestFile,
1226
- result: parsed,
1227
- turnIndex,
1228
- });
1229
- return parsed;
1230
- }
1231
- catch (err) {
1232
- this.log(`Run error: ${err.message}`);
1233
- return this.emptyResult(absoluteTestFile, "", runner, err.message);
1234
- }
1235
- }
1236
- getFailedTargets(cwd, runner, create = false) {
1237
- let roots = this.failedTestsByRunner.get(runner);
1238
- if (!roots && create) {
1239
- roots = new PathKeyedMap(canonicalFailedPath);
1240
- this.failedTestsByRunner.set(runner, roots);
1241
- }
1242
- if (!roots)
1243
- return undefined;
1244
- const root = canonicalFailedPath(cwd);
1245
- let targets = roots.get(root);
1246
- if (!targets && create) {
1247
- targets = new PathKeyedMap(canonicalFailedPath);
1248
- roots.set(root, targets);
1249
- }
1250
- return targets;
1251
- }
1252
- deleteFailedRoot(cwd, runner) {
1253
- const roots = this.failedTestsByRunner.get(runner);
1254
- if (!roots)
1255
- return;
1256
- roots.delete(canonicalFailedPath(cwd));
1257
- if (roots.size === 0)
1258
- this.failedTestsByRunner.delete(runner);
1259
- }
1260
- failedTargetCount(runner) {
1261
- const roots = this.failedTestsByRunner.get(runner);
1262
- if (!roots)
1263
- return 0;
1264
- let count = 0;
1265
- for (const targets of roots.values())
1266
- count += targets.size;
1267
- return count;
1268
- }
1269
- evictOldestFailedTarget(runner) {
1270
- const roots = this.failedTestsByRunner.get(runner);
1271
- if (!roots)
1272
- return undefined;
1273
- let oldest;
1274
- for (const [root, targets] of roots) {
1275
- for (const [identity, entry] of targets) {
1276
- if (!oldest || entry.sequence < oldest.entry.sequence) {
1277
- oldest = { root, identity, entry };
1278
- }
1279
- }
1280
- }
1281
- if (!oldest)
1282
- return undefined;
1283
- const targets = roots.get(oldest.root);
1284
- targets?.delete(oldest.identity);
1285
- if (targets?.size === 0)
1286
- roots.delete(oldest.root);
1287
- if (roots.size === 0)
1288
- this.failedTestsByRunner.delete(runner);
1289
- return oldest.entry.displayPath;
1290
- }
1291
- classifyFailedTarget(candidate) {
1292
- try {
1293
- this.statFailedTarget(candidate);
1294
- return { status: "present" };
1295
- }
1296
- catch (error) {
1297
- const errorCode = filesystemErrorCode(error);
1298
- if (errorCode === "ENOENT" || errorCode === "ENOTDIR") {
1299
- return { status: "missing", errorCode };
1300
- }
1301
- return { status: "indeterminate", errorCode };
1302
- }
1303
- }
1304
- recordFailedTargetState(record) {
1305
- const { outcome, runner, candidate, errorCode, turnIndex } = record;
1306
- const boundedTarget = candidate.length <= LEDGER_FIELD_MAX
1307
- ? candidate
1308
- : `…${candidate.slice(1 - LEDGER_FIELD_MAX)}`;
1309
- const targetIdentity = createHash("sha256")
1310
- .update(runner)
1311
- .update("\0")
1312
- .update(candidate)
1313
- .digest("hex");
1314
- const identity = `${outcome}:${targetIdentity}`;
1315
- const payload = {
1316
- durationMs: 0,
1317
- filePath: boundedTarget,
1318
- metadata: {
1319
- outcome,
1320
- runner,
1321
- errorCode: errorCode ?? "unknown",
1322
- },
1323
- };
1324
- const options = {
1325
- ledgerKind: "test-runner-failed-target-state",
1326
- risingEdgePer: "identity",
1327
- reason: `${outcome}: ${boundedTarget}`,
1328
- };
1329
- emitBounded("test_runner_failed_target_state", identity, payload, turnIndex === undefined
1330
- ? options
1331
- : {
1332
- ...options,
1333
- capPerTurn: {
1334
- limit: FAILED_TARGET_DETAIL_CAP_PER_TURN,
1335
- turnIndex,
1336
- },
1337
- });
1338
- }
1339
- /**
1340
- * Retire only confirmed-missing failed-first paths, then return one usable
1341
- * target. A bounded prefix is checked per selection; remaining candidates
1342
- * carry over to the next selection instead of adding unbounded synchronous
1343
- * filesystem work to turn_end. Related/self targets keep priority.
1344
- */
1345
- retireMissingFailedTargets(selection) {
1346
- const { cwd, runner, failedTargets, relatedAbs, selfAbs, turnIndex } = selection;
1347
- let checked = 0;
1348
- const inspected = new Set();
1349
- const inspect = (identity, entry) => {
1350
- if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
1351
- return undefined;
1352
- checked += 1;
1353
- inspected.add(identity);
1354
- const candidate = entry.displayPath;
1355
- const verdict = this.classifyFailedTarget(candidate);
1356
- if (verdict.status === "missing") {
1357
- failedTargets.delete(identity);
1358
- this.recordFailedTargetState({
1359
- outcome: "retired-missing",
1360
- runner,
1361
- candidate,
1362
- errorCode: verdict.errorCode,
1363
- turnIndex,
1364
- });
1365
- return undefined;
1366
- }
1367
- if (verdict.status === "indeterminate") {
1368
- this.recordFailedTargetState({
1369
- outcome: "retained-indeterminate",
1370
- runner,
1371
- candidate,
1372
- errorCode: verdict.errorCode,
1373
- turnIndex,
1374
- });
1375
- }
1376
- return candidate;
1377
- };
1378
- for (const preferred of [relatedAbs, selfAbs]) {
1379
- if (!preferred)
1380
- continue;
1381
- const identity = canonicalFailedPath(preferred);
1382
- const entry = failedTargets.get(identity);
1383
- if (!entry)
1384
- continue;
1385
- const selected = inspect(identity, entry);
1386
- if (selected)
1387
- return selected;
1388
- }
1389
- for (const [identity, entry] of failedTargets) {
1390
- if (inspected.has(identity))
1391
- continue;
1392
- if (checked >= MAX_FAILED_TARGET_CHECKS_PER_SELECTION)
1393
- break;
1394
- const selected = inspect(identity, entry);
1395
- if (selected)
1396
- return selected;
1397
- }
1398
- if (failedTargets.size === 0)
1399
- this.deleteFailedRoot(cwd, runner);
1400
- return undefined;
1401
- }
1402
- /**
1403
- * Check if a source file has corresponding tests (without running them)
1404
- */
1405
- hasTestFile(sourceFilePath, cwd) {
1406
- return this.findTestFile(sourceFilePath, cwd) !== null;
1407
- }
1408
- /**
1409
- * Suggest test files for a list of source files.
1410
- * Returns deduplicated test file paths with their corresponding source file.
1411
- */
1412
- suggestTestFiles(sourceFiles, cwd) {
1413
- const seen = new Set();
1414
- const results = [];
1415
- for (const sourceFile of sourceFiles) {
1416
- const found = this.findTestFile(sourceFile, cwd);
1417
- if (!found)
1418
- continue;
1419
- const abs = path.resolve(found.testFile);
1420
- if (seen.has(abs))
1421
- continue;
1422
- seen.add(abs);
1423
- results.push({ testFile: abs, sourceFile, runner: found.runner });
1424
- }
1425
- return results;
1426
- }
1427
- // --- Shared JSON test output parser (Vitest + Jest share the same structure) ---
1428
- parseJsonTestOutput(stdout, stderr, testFile, cwd, runner) {
1429
- try {
1430
- const json = JSON.parse(stdout);
1431
- const failures = [];
1432
- for (const suite of json.testResults || []) {
1433
- if (suite.status === "failed" && suite.assertionResults) {
1434
- for (const test of suite.assertionResults) {
1435
- if (test.status === "failed") {
1436
- failures.push({
1437
- name: test.title,
1438
- message: test.failureMessages?.[0] || suite.message || "Test failed",
1439
- location: test.location
1440
- ? `${path.relative(cwd, testFile)}:${test.location.line}`
1441
- : undefined,
1442
- stack: this.truncateStack(test.failureMessages?.join("\n")),
1443
- });
1444
- }
1445
- }
1446
- }
1447
- }
1448
- return {
1449
- file: testFile,
1450
- sourceFile: "",
1451
- runner,
1452
- passed: json.numPassedTests || 0,
1453
- failed: json.numFailedTests || 0,
1454
- // #1452: `numSkippedTests` is absent from both reporters' JSON, so
1455
- // this read was always 0. `numPendingTests` is where a `test.skip`
1456
- // actually lands; `numTodoTests` is counted with it because the
1457
- // text parsers (pytest `N skipped`, mix `N excluded` + `N skipped`)
1458
- // also fold every not-run test into one `skipped` figure.
1459
- // `??` would accept a present 0, so a reporter that emits
1460
- // `numSkippedTests: 0` beside a real `numPendingTests` would
1461
- // reproduce the very defect this removes. Take the larger reading.
1462
- skipped: Math.max(json.numSkippedTests ?? 0, (json.numPendingTests || 0) + (json.numTodoTests || 0)),
1463
- failures,
1464
- duration: this.jsonRunDurationMs(json.testResults),
1465
- };
1466
- }
1467
- catch (err) {
1468
- void err;
1469
- const failed = stdout.includes("FAIL") || stderr.includes("FAIL");
1470
- return this.emptyResult(testFile, "", runner, failed ? "Tests failed (could not parse output)" : undefined);
1471
- }
1472
- }
1473
- /**
1474
- * #1452: real run duration in ms from a vitest/jest `--json` payload.
1475
- *
1476
- * NOT `testResults[].perfStats`. That field exists on jest's INTERNAL
1477
- * `TestResult`, but the JSON reporter's `formatTestResults` projects it to
1478
- * per-suite `startTime`/`endTime` and drops it — measured absent from both
1479
- * vitest 4.1.10 and jest 30.4.2 output, so reading it would have left this
1480
- * at 0. The per-suite epoch pair is what both reporters actually emit.
1481
- *
1482
- * Wall-clock SPAN across suites (max end - min start), not a sum: suites in
1483
- * one payload may have run in parallel workers, and summing would report
1484
- * more elapsed time than the run took. With the single suite pi-lens
1485
- * actually produces (one test file per invocation) the two agree.
1486
- *
1487
- * The span excludes the runner's own startup: the top-level `startTime` is
1488
- * ~330ms earlier than the first suite's on this repo. What the per-suite
1489
- * pair then measures is NOT the same quantity across runners. On vitest it
1490
- * tracks test time closely (135ms span against 134ms of summed assertions),
1491
- * but jest stamps a suite's `startTime` before transform and module load,
1492
- * so the same fields give 5595ms against 128ms of assertions. Both are
1493
- * honest suite wall clock; neither is comparable to the other, and only the
1494
- * vitest figure is close to what pytest's `in 0.05s` or ExUnit's
1495
- * `Finished in 0.05 seconds` report.
1496
- *
1497
- * Falls back to the summed per-assertion `duration` when a reporter omits
1498
- * the suite pair. Never returns a negative or non-finite value — a garbled
1499
- * payload must degrade to "unmeasured", not to a wrong number.
1500
- *
1501
- * #1479: that degradation is now literal. This used to return 0 for a
1502
- * payload it could not read, which is the figure a sub-millisecond suite
1503
- * also produces, so the caller could not tell them apart. It returns
1504
- * `undefined` instead. A readable pair whose span is 0 still returns 0,
1505
- * because that is a measurement.
1506
- */
1507
- jsonRunDurationMs(suites) {
1508
- let minStart = Number.POSITIVE_INFINITY;
1509
- let maxEnd = Number.NEGATIVE_INFINITY;
1510
- let assertionTotal = 0;
1511
- for (const suite of suites || []) {
1512
- if (typeof suite.startTime === "number" &&
1513
- Number.isFinite(suite.startTime) &&
1514
- typeof suite.endTime === "number" &&
1515
- Number.isFinite(suite.endTime)) {
1516
- minStart = Math.min(minStart, suite.startTime);
1517
- maxEnd = Math.max(maxEnd, suite.endTime);
1518
- }
1519
- for (const assertion of suite.assertionResults || []) {
1520
- if (typeof assertion.duration === "number" &&
1521
- Number.isFinite(assertion.duration) &&
1522
- assertion.duration > 0) {
1523
- assertionTotal += assertion.duration;
1524
- }
1525
- }
1526
- }
1527
- const span = maxEnd - minStart;
1528
- if (Number.isFinite(span) && span > 0)
1529
- return Math.round(span);
1530
- if (assertionTotal > 0)
1531
- return Math.round(assertionTotal);
1532
- // Ordering above is unchanged from #1452 on purpose: a positive span
1533
- // still beats the assertion sum, and the sum still beats a suite pair
1534
- // that read as zero. Only the terminal case moved. A pair we could
1535
- // read whose span is 0 is a run that took under a millisecond — report
1536
- // it. Everything else was never measured.
1537
- if (Number.isFinite(span) && span === 0)
1538
- return 0;
1539
- return undefined;
1540
- }
1541
- // --- Vitest Parser ---
1542
- parseVitestOutput(stdout, stderr, testFile, cwd, runner) {
1543
- return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
1544
- }
1545
- // --- Jest Parser ---
1546
- parseJestOutput(stdout, stderr, testFile, cwd, runner) {
1547
- return this.parseJsonTestOutput(stdout, stderr, testFile, cwd, runner);
1548
- }
1549
- // --- Pytest Parser (text-based, no JSON dependency) ---
1550
- parsePytestOutput(stdout, stderr, exitCode, testFile, _cwd, runner) {
1551
- const failures = [];
1552
- const output = `${stdout}\n${stderr}`;
1553
- // #1479: `duration` stays undefined unless pytest emits its own `in N.NNs`
1554
- // summary. A measured `in 0.00s` remains a real zero.
1555
- const { passed, failed, skipped, duration } = parsePytestSummary(output);
1556
- // Parse individual failures: "FAILED tests/test_foo.py::test_something - AssertionError: ..."
1557
- const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
1558
- let match;
1559
- while ((match = failureRegex.exec(output)) !== null) {
1560
- failures.push({
1561
- name: match[1],
1562
- message: match[2].trim().slice(0, 500),
1563
- location: match[1].replace("::", ":"),
1564
- });
1565
- }
1566
- // Also look for assertion errors with traceback
1567
- const tracebackRegex = /_{10,}\s*\n\s*(\w+Error:\s*.+?)(?:\n|$)/gs;
1568
- while ((match = tracebackRegex.exec(output)) !== null) {
1569
- // Add to last failure if exists, or create generic
1570
- if (failures.length > 0 && !failures[failures.length - 1].stack) {
1571
- failures[failures.length - 1].stack = match[1].trim().slice(0, 1000);
1572
- }
1573
- }
1574
- return {
1575
- file: testFile,
1576
- sourceFile: "",
1577
- runner,
1578
- passed,
1579
- failed,
1580
- skipped,
1581
- failures,
1582
- duration,
1583
- error: exitCode === 4
1584
- ? "Pytest configuration error"
1585
- : exitCode === 2
1586
- ? "Pytest interrupted"
1587
- : undefined,
1588
- };
1589
- }
1590
- // --- PHPUnit Parser (text-based, default CLI output) ---
1591
- parsePhpunitOutput(stdout, stderr, exitCode, testFile, runner) {
1592
- const output = `${stdout}\n${stderr}`;
1593
- let passed = 0;
1594
- let failed = 0;
1595
- let skipped = 0;
1596
- // Success (or success-with-incomplete/skipped): "OK (12 tests, 34 assertions)"
1597
- const okMatch = output.match(/OK\s*\((\d+)\s+tests?,\s*\d+\s+assertions?\)/i);
1598
- if (okMatch) {
1599
- passed = Number.parseInt(okMatch[1], 10);
1600
- }
1601
- else {
1602
- // Failure summary: "Tests: 12, Assertions: 34, Errors: 1, Failures: 2, Skipped: 1."
1603
- const testsMatch = output.match(/Tests:\s*(\d+)/i);
1604
- const failuresMatch = output.match(/Failures:\s*(\d+)/i);
1605
- const errorsMatch = output.match(/Errors:\s*(\d+)/i);
1606
- const skippedMatch = output.match(/Skipped:\s*(\d+)/i);
1607
- const total = testsMatch ? Number.parseInt(testsMatch[1], 10) : 0;
1608
- const failures = failuresMatch
1609
- ? Number.parseInt(failuresMatch[1], 10)
1610
- : 0;
1611
- const errors = errorsMatch ? Number.parseInt(errorsMatch[1], 10) : 0;
1612
- skipped = skippedMatch ? Number.parseInt(skippedMatch[1], 10) : 0;
1613
- failed = failures + errors;
1614
- passed = Math.max(0, total - failed - skipped);
1615
- }
1616
- // Individual failures: "1) Foo\BarTest::testSomething"
1617
- const failures = [];
1618
- const failureRegex = /^\d+\)\s+(\S+)/gm;
1619
- let match;
1620
- while ((match = failureRegex.exec(output)) !== null) {
1621
- failures.push({ name: match[1], message: match[1] });
1622
- }
1623
- // #1452: PHPUnit prints its own elapsed time and this parser dropped it,
1624
- // so every PHPUnit run reported 0ms. Two shapes are accepted because the
1625
- // summary changed across supported majors:
1626
- // PHPUnit >= 9.3 "Time: 00:00.123, Memory: 8.00 MB" (HH:)MM:SS.mmm
1627
- // PHPUnit <= 9.2 "Time: 1.23 seconds, Memory: 10.00MB" | "Time: 123 ms"
1628
- // NOT VERIFIED AGAINST A LIVE PHPUnit — there is no PHP toolchain on the
1629
- // box this was written on. Both shapes are covered by unit tests against
1630
- // literal summary lines taken from the PHPUnit printers, and the parser
1631
- // leaves duration UNMEASURED when neither matches (#1479 — it used to
1632
- // leave 0, which the turn-end log printed as a measurement), so an
1633
- // unrecognised summary degrades to "we do not know" rather than to a
1634
- // wrong figure.
1635
- let duration;
1636
- const clockMatch = output.match(/^Time:\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d{1,3}))?/im);
1637
- if (clockMatch) {
1638
- const hours = clockMatch[1] ? Number.parseInt(clockMatch[1], 10) : 0;
1639
- const minutes = Number.parseInt(clockMatch[2], 10);
1640
- const seconds = Number.parseInt(clockMatch[3], 10);
1641
- // ".1" is a tenth, ".12" hundredths — pad rather than parseInt, or
1642
- // "Time: 00:00.1" would read as 1ms instead of 100ms.
1643
- const millis = clockMatch[4]
1644
- ? Number.parseInt(clockMatch[4].padEnd(3, "0"), 10)
1645
- : 0;
1646
- duration = ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis;
1647
- }
1648
- else {
1649
- const legacyMatch = output.match(/^Time:\s*([\d.]+)\s*(seconds?|s|ms|milliseconds?|minutes?)\b/im);
1650
- if (legacyMatch) {
1651
- const value = Number.parseFloat(legacyMatch[1]);
1652
- const unit = legacyMatch[2].toLowerCase();
1653
- const scale = unit.startsWith("ms") || unit.startsWith("milli")
1654
- ? 1
1655
- : unit.startsWith("min")
1656
- ? 60_000
1657
- : 1000;
1658
- if (Number.isFinite(value) && value > 0) {
1659
- duration = Math.round(value * scale);
1660
- }
1661
- }
1662
- }
1663
- return {
1664
- file: testFile,
1665
- sourceFile: "",
1666
- runner,
1667
- passed,
1668
- failed,
1669
- skipped,
1670
- failures,
1671
- duration,
1672
- error: exitCode !== 0 && passed === 0 && failed === 0
1673
- ? "PHPUnit runner error"
1674
- : undefined,
1675
- };
1676
- }
1677
- // --- mix test Parser (ExUnit, text-based, default CLI output) ---
1678
- parseMixTestOutput(stdout, stderr, exitCode, testFile, runner) {
1679
- const output = `${stdout}\n${stderr}`;
1680
- let passed = 0;
1681
- let failed = 0;
1682
- let skipped = 0;
1683
- // #1479: undefined until ExUnit's own `Finished in N seconds` is read.
1684
- let duration;
1685
- // Summary: "3 tests, 1 failure" (optionally ", N excluded" / ", N skipped")
1686
- const summaryMatch = output.match(/(\d+)\s+tests?,\s*(\d+)\s+failures?(?:,\s*(\d+)\s+excluded)?(?:,\s*(\d+)\s+skipped)?/i);
1687
- if (summaryMatch) {
1688
- const total = Number.parseInt(summaryMatch[1], 10);
1689
- failed = Number.parseInt(summaryMatch[2], 10);
1690
- const excluded = summaryMatch[3]
1691
- ? Number.parseInt(summaryMatch[3], 10)
1692
- : 0;
1693
- const skippedCount = summaryMatch[4]
1694
- ? Number.parseInt(summaryMatch[4], 10)
1695
- : 0;
1696
- skipped = excluded + skippedCount;
1697
- passed = Math.max(0, total - failed - skipped);
1698
- }
1699
- const durationMatch = output.match(/Finished in\s+([\d.]+)\s+seconds?/i);
1700
- if (durationMatch) {
1701
- // Rounded for the same reason pytest's is: `2.01` seconds is
1702
- // 2009.9999999999998 ms unrounded, and that reaches the log.
1703
- // (Routing this through `toMeasuredDurationMs` is #1484.)
1704
- duration = Math.round(Number.parseFloat(durationMatch[1]) * 1000);
1705
- }
1706
- // Individual failures: " 1) test some behavior (MyModuleTest)"
1707
- const failures = [];
1708
- const failureRegex = /^\s*\d+\)\s+(.+?)\s*\(([^)]+)\)\s*$/gm;
1709
- let match;
1710
- while ((match = failureRegex.exec(output)) !== null) {
1711
- failures.push({
1712
- name: match[1].trim(),
1713
- message: match[1].trim(),
1714
- location: match[2].trim(),
1715
- });
1716
- }
1717
- return {
1718
- file: testFile,
1719
- sourceFile: "",
1720
- runner,
1721
- passed,
1722
- failed,
1723
- skipped,
1724
- failures,
1725
- duration,
1726
- error: exitCode !== 0 && passed === 0 && failed === 0
1727
- ? "mix test runner error"
1728
- : undefined,
1729
- };
1730
- }
1731
- // --- Generic text parser for non-JSON runners ---
1732
- /**
1733
- * #1480: elapsed time for the runners `parseGenericRunnerOutput` handles.
1734
- *
1735
- * Before this, only go's `ok pkg 0.25s` was read and every other runner
1736
- * reported a hardcoded 0. #1479 made the log tell "measured" from
1737
- * "unmeasured", but this parser is the `default:` arm behind cargo, dotnet,
1738
- * maven, gradle, rspec, minitest and every unrecognised runner, so all of
1739
- * them still reported a number nobody measured. Each runner below prints
1740
- * its elapsed time in the same summary block this parser already regexes
1741
- * for pass/fail counts.
1742
- *
1743
- * Absent, not 0, is the answer when nothing is found — see
1744
- * `TestResult.duration` and `run-duration.ts`. A probe that returned 0 here
1745
- * would be claiming a measurement.
1746
- *
1747
- * One parser serves all runners, so the probe is selected BY RUNNER NAME.
1748
- * Running every probe over every runner's output was the original shape of
1749
- * this code, and it let gradle borrow a number: `BUILD SUCCESSFUL in 3s`
1750
- * plus a preceding `... ok` line satisfied go's `ok <pkg> <n>s` probe, so
1751
- * the whole-build wall clock got reported as test time — the exact wrong
1752
- * number this function refuses to print. Gating on the runner makes that
1753
- * structurally impossible rather than merely unlikely, and it matters most
1754
- * for the `default:` arm of the switch, which is where an unrecognised or
1755
- * custom runner's arbitrary output lands.
1756
- *
1757
- * Within a runner the patterns are still anchored where an anchor helps,
1758
- * for the same reason #1452's PHPUnit `Time:` pattern is anchored: an
1759
- * unanchored /m match takes the FIRST hit over stdout+stderr, and a failure
1760
- * diff quoting "Finished in ..." would beat the real summary. Note what the
1761
- * `^` in `^Finished in` does and does not buy. It rejects a decoy that is
1762
- * INDENTED, which is what a quoted expectation or an assertion diff is; it
1763
- * does NOT rank two column-0 matches, so an unindented decoy printed by the
1764
- * suite itself would still win. It is a cheap filter for the common shape,
1765
- * not a proof of uniqueness. And it is not an anchor to the counts line for
1766
- * rspec or minitest: both print their elapsed time on a `Finished in ...`
1767
- * line and their counts (`3 examples, 0 failures`, `1 runs, 1 assertions,
1768
- * ...`) on a different line.
1769
- *
1770
- * KNOWN LIMIT — first summary only. cargo across multiple crates, `dotnet
1771
- * test` across multiple assemblies, and `go test ./...` across multiple
1772
- * packages each print one summary per unit, and these probes take the
1773
- * first. A multi-unit run therefore UNDER-REPORTS its duration. That is
1774
- * left as-is deliberately: the count parsers below have the same first-match
1775
- * shape for those runners, so duration and counts describe the same scope.
1776
- * Fixing one without the other would trade an under-report for an
1777
- * inconsistency. Pinned by test so it stays a known limit, not an accident.
1778
- *
1779
- * Formats and how each was verified:
1780
- *
1781
- * - go — `ok example.com/pkg 0.253s`. Pre-existing pattern, unchanged
1782
- * apart from the shared finite/non-negative guard.
1783
- *
1784
- * - cargo — `test result: ok. 3 passed; 0 failed; 1 ignored; 0 measured;
1785
- * 0 filtered out; finished in 0.253s`. NOT VERIFIED AGAINST A LIVE CARGO
1786
- * RUN — this box has no MSVC linker, so `cargo test` cannot link. Format
1787
- * read out of the libtest printer shipped with the local rustc 1.94.1:
1788
- * `library/test/src/formatters/pretty.rs` builds `"; finished in
1789
- * {exec_time}"` and `library/test/src/time.rs` renders `TestSuiteExecTime`
1790
- * as `{:.2}s`. Older rustc omits the suffix entirely; that degrades to
1791
- * unmeasured.
1792
- *
1793
- * - dotnet/vstest — `Failed: 1, Passed: 2, Skipped: 0, Total: 3, Duration:
1794
- * 1 m 30 s - t.dll (net8.0)`. NOT VERIFIED AGAINST A LIVE `dotnet test` —
1795
- * NuGet restore has no network here. Format read out of the
1796
- * vstest.console.dll shipped with the local .NET SDK 8.0.423, which holds
1797
- * the literal `{0} - Failed: {1}, Passed: {2}, Skipped: {3}, Total: {4},
1798
- * Duration: {5}` next to the unit literals `" h"`, `" m"`, `" s"`,
1799
- * `" ms"`, `"< 1 ms"`. The duration is a space-joined token list, so it
1800
- * is summed rather than read as one number.
1801
- *
1802
- * - maven/surefire — `Tests run: 4, Failures: 0, Errors: 0, Skipped: 0,
1803
- * Time elapsed: 0.05 s -- in com.example.AppTest`. NOT VERIFIED AGAINST A
1804
- * LIVE MAVEN — no mvn on this box. Summed across the per-class lines,
1805
- * because surefire prints `Time elapsed` per test class and its final
1806
- * `Results:` total carries no time. `[INFO] Total time: 3.4 s` is
1807
- * deliberately NOT used: that is whole-build wall clock including compile,
1808
- * which would report a wrong number rather than none. Surefire 2.x wrote
1809
- * `sec` where 3.x writes `s`; both are accepted.
1810
- *
1811
- * EXPECT THIS TO BE ABSENT IN PRACTICE. pi-lens invokes `mvn test -q`
1812
- * (see RUNNERS.maven above), and surefire logs its per-class `Tests run:
1813
- * ..., Time elapsed: ...` lines at INFO, which `-q` suppresses. Only the
1814
- * ERROR-level lines of a FAILING class survive, so a green maven run
1815
- * typically reports unmeasured and a red one reports the failing classes'
1816
- * time alone. REASONED, NOT RUN — there is no mvn on this box to confirm
1817
- * it. Left in rather than dropped: it costs nothing, it is correct when
1818
- * the output does carry the lines (a repo that sets `-Dsurefire.useFile`
1819
- * or drops `-q` via `.mvn/maven.config`), and `unmeasured` is an honest
1820
- * report of the quiet case.
1821
- *
1822
- * - rspec — `Finished in 0.32394 seconds (files took 0.49427 seconds to
1823
- * load)`. VERIFIED against a live rspec-core 3.13.6 run on ruby 3.4.10.
1824
- * The minutes form (`Finished in 2 minutes 15.14 seconds`) comes from
1825
- * `RSpec::Core::Formatters::Helpers.format_duration` in the same
1826
- * installed gem; rspec never prints hours. Load time trails the run time
1827
- * on the same line and must not be read instead of it.
1828
- *
1829
- * - minitest — `Finished in 0.254594s, 7.8557 runs/s, 7.8557 assertions/s.`
1830
- * VERIFIED against a live minitest 5.25.4 run on ruby 3.4.10. The format
1831
- * string is `"Finished in %.6fs, ..."` in minitest.rb, always seconds.
1832
- *
1833
- * - gradle — deliberately left unmeasured, and now UNREACHABLE by any other
1834
- * runner's probe rather than merely unmatched by it. Gradle's console
1835
- * summary (`4 tests completed, 1 failed`) carries no elapsed time, and
1836
- * `BUILD SUCCESSFUL in 3s` is whole-build wall clock including compile
1837
- * and dependency resolution. Reporting that as test time would be a wrong
1838
- * number; #1479 makes the absence legible in the log instead.
1839
- */
1840
- parseGenericRunnerDuration(output, runner) {
1841
- switch (runner) {
1842
- case "go":
1843
- return this.parseGoDuration(output);
1844
- case "cargo":
1845
- return this.parseCargoDuration(output);
1846
- case "dotnet":
1847
- return this.parseDotnetDuration(output);
1848
- case "maven":
1849
- return this.parseMavenDuration(output);
1850
- case "rspec":
1851
- return this.parseRspecDuration(output);
1852
- case "minitest":
1853
- return this.parseMinitestDuration(output);
1854
- default:
1855
- // gradle and anything unrecognised: unmeasured, never
1856
- // zero-as-measurement and never another runner's number.
1857
- return undefined;
1858
- }
1859
- }
1860
- /** go: `ok example.com/pkg 0.253s`. First package summary only. */
1861
- parseGoDuration(output) {
1862
- const goSummary = output.match(/ok\s+\S+\s+([\d.]+)s/m);
1863
- if (!goSummary)
1864
- return undefined;
1865
- return toMeasuredDurationMs(Number.parseFloat(goSummary[1]) * 1000);
1866
- }
1867
- /** cargo: `...; 0 filtered out; finished in 0.25s`. First crate only. */
1868
- parseCargoDuration(output) {
1869
- const cargoTime = output.match(/^test result:.*?;\s*finished in\s+([\d.]+)\s*s\b/im);
1870
- if (!cargoTime)
1871
- return undefined;
1872
- return toMeasuredDurationMs(Number.parseFloat(cargoTime[1]) * 1000);
1873
- }
1874
- /**
1875
- * dotnet/vstest: `..., Total: 3, Duration: 1 m 30 s - t.dll (net8.0)`.
1876
- *
1877
- * Anchored to the counts line, and the tail stops at the ` - <dll>`
1878
- * separator: without that stop an assembly name is scanned for unit tokens,
1879
- * and a name like `Timeouts.30s.Tests.dll` adds 30 seconds of nothing.
1880
- * First assembly only.
1881
- */
1882
- parseDotnetDuration(output) {
1883
- const dotnetTime = output.match(/Failed:\s*\d+,\s*Passed:\s*\d+,\s*Skipped:\s*\d+,\s*Total:\s*\d+,\s*Duration:\s*([^\r\n-]+)/i);
1884
- if (!dotnetTime)
1885
- return undefined;
1886
- // `< 1 ms` is vstest's "too fast to name a number", and under the
1887
- // optional-duration contract 0 is exactly the right thing to say: the
1888
- // run WAS measured and it rounds to 0 ms. The token scan below would
1889
- // reach the same 0 by finding no tokens, but only by accident, and the
1890
- // accident is indistinguishable from an unparseable tail — so the case
1891
- // is spelled out.
1892
- if (/^\s*</.test(dotnetTime[1]))
1893
- return 0;
1894
- let total = 0;
1895
- let tokens = 0;
1896
- // "ms" before "m", or "250 ms" scores as 250 minutes.
1897
- const units = {
1898
- ms: 1,
1899
- s: 1000,
1900
- m: 60_000,
1901
- h: 3_600_000,
1902
- };
1903
- for (const token of dotnetTime[1].matchAll(/([\d.]+)\s*(ms|h|m|s)\b/gi)) {
1904
- total += Number.parseFloat(token[1]) * units[token[2].toLowerCase()];
1905
- tokens++;
1906
- }
1907
- // A tail we matched but could not read a single token out of is not a
1908
- // zero-length run, it is an unrecognised format.
1909
- if (tokens === 0)
1910
- return undefined;
1911
- return toMeasuredDurationMs(total);
1912
- }
1913
- /**
1914
- * maven/surefire: summed across per-class `Time elapsed` lines.
1915
- *
1916
- * The guard is "did any line match", NOT "is the sum positive". Surefire
1917
- * prints `Time elapsed: 0.00 s` for a trivial test class, and that is a
1918
- * measurement of zero, not a failure to measure.
1919
- */
1920
- parseMavenDuration(output) {
1921
- let surefireTotal = 0;
1922
- let matched = false;
1923
- for (const line of output.matchAll(/^.*Tests run:\s*\d+,.*?Time elapsed:\s*([\d.]+)\s*(?:s|sec|secs|seconds)\b.*$/gim)) {
1924
- const seconds = Number.parseFloat(line[1]);
1925
- if (!Number.isFinite(seconds) || seconds < 0)
1926
- continue;
1927
- surefireTotal += seconds;
1928
- matched = true;
1929
- }
1930
- if (!matched)
1931
- return undefined;
1932
- return toMeasuredDurationMs(surefireTotal * 1000);
1933
- }
1934
- /** rspec: `Finished in 2 minutes 15.14 seconds (files took 0.5 ...)`. */
1935
- parseRspecDuration(output) {
1936
- const rspecTime = output.match(/^Finished in\s+(?:([\d.]+)\s+minutes?\s+)?([\d.]+)\s+seconds?/im);
1937
- if (!rspecTime)
1938
- return undefined;
1939
- const minutes = rspecTime[1] ? Number.parseFloat(rspecTime[1]) : 0;
1940
- return toMeasuredDurationMs(minutes * 60_000 + Number.parseFloat(rspecTime[2]) * 1000);
1941
- }
1942
- /**
1943
- * minitest: `Finished in 0.254594s, 7.8557 runs/s, ...`.
1944
- *
1945
- * The trailing `,` is load-bearing, not decoration: it is what separates
1946
- * minitest's own line from a bare `Finished in 99s` the suite under test
1947
- * printed at column 0, which the `^` alone does not rank.
1948
- */
1949
- parseMinitestDuration(output) {
1950
- const minitestTime = output.match(/^Finished in\s+([\d.]+)s\s*,/im);
1951
- if (!minitestTime)
1952
- return undefined;
1953
- return toMeasuredDurationMs(Number.parseFloat(minitestTime[1]) * 1000);
1954
- }
1955
- parseGenericRunnerOutput(stdout, stderr, exitCode, testFile, runner) {
1956
- const output = `${stdout}\n${stderr}`;
1957
- const lower = output.toLowerCase();
1958
- let passed = 0;
1959
- // #1487: NOT `exitCode === 0 ? 0 : 1`. Pre-seeding `failed` to 1 on a
1960
- // non-zero exit made the runner-error branch below unreachable — it
1961
- // requires `failed === 0`, which a spawn/config/load failure (no
1962
- // counts to parse) could never reach once this had already claimed
1963
- // the slot. `matched` tracks whether a count parser actually found
1964
- // real counts; the exit-code-distrust fallback further down uses it
1965
- // to tell "a runner that never ran" from "a runner whose summary
1966
- // legitimately parsed to zero failures".
1967
- let failed = 0;
1968
- let skipped = 0;
1969
- let matched = false;
1970
- // #1480: `number | undefined`, and sourced per runner. This used to be
1971
- // `let duration = 0` with only go's probe able to move it, so every
1972
- // other runner reported a zero it never measured.
1973
- const duration = this.parseGenericRunnerDuration(output, runner);
1974
- const cargoSummary = output.match(/test result:\s+\w+\.\s+(\d+)\s+passed;\s+(\d+)\s+failed;\s+(\d+)\s+ignored;/i);
1975
- if (cargoSummary) {
1976
- passed = Number.parseInt(cargoSummary[1], 10);
1977
- failed = Number.parseInt(cargoSummary[2], 10);
1978
- skipped = Number.parseInt(cargoSummary[3], 10);
1979
- matched = true;
1980
- }
1981
- const dotnetSummary = output.match(/Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)/i);
1982
- if (dotnetSummary) {
1983
- failed = Number.parseInt(dotnetSummary[1], 10);
1984
- passed = Number.parseInt(dotnetSummary[2], 10);
1985
- skipped = Number.parseInt(dotnetSummary[3], 10);
1986
- matched = true;
1987
- }
1988
- // #1480 (adjacent, duration-independent): surefire prints one
1989
- // `Tests run:` line PER TEST CLASS (those carry `Time elapsed:`) and
1990
- // then a per-MODULE aggregate under `Results:` (which does not). Taking
1991
- // the FIRST match scored a run by its first class alone — a two-class
1992
- // run with a failure in the second class reported 0 failures.
1993
- //
1994
- // Taking the LAST match is just as wrong, in a worse direction. A
1995
- // multi-module reactor run prints one `Results:` aggregate per module,
1996
- // and the last is the last module: a `--fail-at-end` build whose first
1997
- // module had 3 failures and whose second module was green would report
1998
- // 0 failures, turning a red build into `PASS` in the turn-end log. That
1999
- // is reachable without pi-lens passing the flag, because maven also
2000
- // reads `.mvn/maven.config` and `MAVEN_ARGS`.
2001
- //
2002
- // So: SUM the aggregates. That makes counts reactor-wide, the same
2003
- // scope `parseMavenDuration` sums its per-class times over. When no
2004
- // aggregate is present (output truncated, or `Results:` suppressed) the
2005
- // per-class lines sum to the same totals, so they are the fallback.
2006
- const mavenLines = [
2007
- ...output.matchAll(/^.*?Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+).*$/gim),
2008
- ];
2009
- const mavenAggregates = mavenLines.filter((line) => !/Time elapsed:/i.test(line[0]));
2010
- const mavenScored = mavenAggregates.length > 0 ? mavenAggregates : mavenLines;
2011
- if (mavenScored.length > 0) {
2012
- let total = 0;
2013
- let mavenFailed = 0;
2014
- let mavenSkipped = 0;
2015
- for (const line of mavenScored) {
2016
- total += Number.parseInt(line[1], 10);
2017
- mavenFailed +=
2018
- Number.parseInt(line[2], 10) + Number.parseInt(line[3], 10);
2019
- mavenSkipped += Number.parseInt(line[4], 10);
2020
- }
2021
- failed = mavenFailed;
2022
- skipped = mavenSkipped;
2023
- passed = Math.max(0, total - failed - skipped);
2024
- matched = true;
2025
- }
2026
- const rspecSummary = output.match(/(\d+)\s+examples?,\s+(\d+)\s+failures?/i);
2027
- if (rspecSummary) {
2028
- const total = Number.parseInt(rspecSummary[1], 10);
2029
- failed = Number.parseInt(rspecSummary[2], 10);
2030
- passed = Math.max(0, total - failed);
2031
- matched = true;
2032
- }
2033
- const minitestSummary = output.match(/(\d+)\s+runs?,\s+\d+\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/i);
2034
- if (minitestSummary) {
2035
- const total = Number.parseInt(minitestSummary[1], 10);
2036
- const failures = Number.parseInt(minitestSummary[2], 10);
2037
- const errors = Number.parseInt(minitestSummary[3], 10);
2038
- failed = failures + errors;
2039
- passed = Math.max(0, total - failed);
2040
- matched = true;
2041
- }
2042
- const gradleSummary = output.match(/(\d+)\s+tests? completed,\s+(\d+)\s+failed/i);
2043
- if (gradleSummary) {
2044
- const total = Number.parseInt(gradleSummary[1], 10);
2045
- failed = Number.parseInt(gradleSummary[2], 10);
2046
- passed = Math.max(0, total - failed);
2047
- matched = true;
2048
- }
2049
- // #1487/#1524/#1524-r3: `--- FAIL: TestName` is extracted first and
2050
- // separately from the other two name patterns, because it is the
2051
- // ONLY one of the three that is unambiguous evidence a real go test
2052
- // ran — see the `runnerError` comment below for why the other two
2053
- // may no longer veto on their own. go otherwise has no count parser
2054
- // at all (only a duration probe), so without this go's `matched`
2055
- // stayed false even on a real failure.
2056
- //
2057
- // #1524-r3: go ALSO gets a real count parser here, not just a name
2058
- // extractor. A panic inside `TestMain`/package `init` (exit 2) never
2059
- // prints a `--- FAIL:` line — there is no test to name, the process
2060
- // died before any test ran — so `goFailNames` alone left `matched`
2061
- // false and such a panic hit the same runner-error branch as a
2062
- // spawn failure, even though `go test` DID run and DID fail. `FAIL
2063
- // \t<pkg>` is go's own per-package verdict line and is printed for
2064
- // exactly this case; `ok <pkg> <n>s` is its pass counterpart.
2065
- // Package-line counts, like the go duration probe above, take the
2066
- // FIRST package summary only — the same known limit already
2067
- // documented on `parseGenericRunnerDuration`.
2068
- const goFailNames = [...output.matchAll(/--- FAIL:[^\S\n]+([^\s(]+)/g)];
2069
- // #1524-r4: only COUNT unparented failures. go prints a `--- FAIL:`
2070
- // line for every level of a failing subtest tree — `TestA` AND
2071
- // `TestA/sub` each get their own line for what is really one
2072
- // underlying failure — so counting every line inflated `failed`
2073
- // (two lines here read as two failures for one broken test). A name
2074
- // containing "/" whose prefix up to that "/" is itself in the list
2075
- // is a subtest of an already-counted parent; only names that are
2076
- // NOT such a subtest count. `goFailNames` itself (all lines) is
2077
- // still what decides `matched`/the runner-error veto below and
2078
- // what's listed in `failures` — subtest names are still useful
2079
- // detail there, just not double-counted.
2080
- const goFailNameSet = new Set(goFailNames.map((m) => m[1].trim()));
2081
- const goTopLevelFailCount = goFailNames.filter((m) => {
2082
- const name = m[1].trim();
2083
- const slash = name.indexOf("/");
2084
- return slash === -1 || !goFailNameSet.has(name.slice(0, slash));
2085
- }).length;
2086
- // #2870: go's own `[setup failed]` verdict line — "there was no package
2087
- // to test" — the positive twin of the `(?:build|setup) failed`
2088
- // lookahead below. #1524-r4 stopped it from being counted as a test
2089
- // failure but left it with no classification of its own, so whether the
2090
- // agent saw "could not run tests" or a fabricated `✗ 1/1 failed ✗ go
2091
- // failure` came down to whether go's output happened to contain the
2092
- // substring "error" (the runner-error condition below): `FAIL <pkg>
2093
- // [setup failed]` + `error: no packages to test` was advisory, the same
2094
- // line on its own was a blocking failure the agent could not tell from
2095
- // a real one (#2870, measured).
2096
- //
2097
- // `[build failed]` is deliberately NOT included (review round 2, F5).
2098
- // The maintainer's amendment on #2870 names `[setup failed]`; a go
2099
- // COMPILE error is usually one the agent just introduced, and
2100
- // downgrading it to advisory is a signal change on master's behaviour
2101
- // that belongs on the issue, not in this fix. Its classification is
2102
- // still the coin flip described above — see the PR body's follow-ups.
2103
- let goInfraVerdict = false;
2104
- if (runner === "go") {
2105
- goInfraVerdict = /^FAIL[^\S\n]+\S+[^\S\n]+\[setup failed\]/m.test(output);
2106
- // #1524-r4: `(?![^\n]*\[(?:build|setup) failed\])` rejects go's
2107
- // INFRASTRUCTURE verdict lines — `FAIL <pkg> [build failed]` (a
2108
- // compile error) and `FAIL <pkg> [setup failed]` (no packages to
2109
- // test) — which matched the same shape as a real `FAIL <pkg>
2110
- // <duration>` test verdict line. Neither ran a single test; both
2111
- // were rendering as `✗ 1/1 failed ✗ go failure` instead of the
2112
- // runner error they are.
2113
- const goFailPackage = /^FAIL(?![^\n]*\[(?:build|setup) failed\])[^\S\n]+\S+/m.test(output);
2114
- const goOkPackages = [
2115
- ...output.matchAll(/^ok[^\S\n]+\S+[^\S\n]+[\d.]+s/gm),
2116
- ];
2117
- if (goFailNames.length > 0 || goFailPackage) {
2118
- failed = Math.max(failed, goTopLevelFailCount || 1);
2119
- matched = true;
2120
- }
2121
- // #1524-r4: unconditional, not `else if`. A multi-package run
2122
- // can have BOTH a real failure and packages that passed clean —
2123
- // `ok a` / `--- FAIL: TestB` / `ok c` is 2 passed AND 1 failed,
2124
- // not "1 failed, 0 passed" with the green packages silently
2125
- // dropped because the fail branch above already ran.
2126
- if (goOkPackages.length > 0) {
2127
- passed = Math.max(passed, goOkPackages.length);
2128
- matched = true;
2129
- }
2130
- }
2131
- // #1524-r3: anchored to line START (`^`), and `[^\S\n]` (horizontal
2132
- // whitespace only) in place of `\s` between the keyword and the
2133
- // name. The prior `\bFAILED\s+([^\n]+)` and `Failure:\s+([^\n]+)`
2134
- // let their OWN `\s+` gap cross the newline: a bare `FAILED` at the
2135
- // end of a line has nothing after it on that line, so `\s+` ate the
2136
- // newline itself and `([^\n]+)` captured the FIRST TOKEN OF THE
2137
- // NEXT LINE as the "test name" — proven on a gradle compile failure
2138
- // (`> Task :compileJava FAILED` followed by
2139
- // `FAILURE: Build failed with an exception.`, which then rendered
2140
- // as the invented failure name) and the newline-spanning shape in
2141
- // general (`FAILED\n\nsome trailing note` → name "some trailing
2142
- // note"). Requiring `(\S.*)$` on the SAME line makes a keyword with
2143
- // nothing following it on that line simply not match, rather than
2144
- // reaching across for content that was never the name.
2145
- const failures = [];
2146
- for (const m of goFailNames.slice(0, 5)) {
2147
- failures.push({ name: m[1].trim(), message: m[1].trim() });
2148
- }
2149
- const otherNames = [
2150
- ...output.matchAll(/^[^\S\n]*FAILED[^\S\n]+(\S.*)$/gm),
2151
- ...output.matchAll(/^[^\S\n]*Failure:[^\S\n]+(\S.*)$/gm),
2152
- ];
2153
- for (const m of otherNames) {
2154
- if (failures.length >= 5)
2155
- break;
2156
- failures.push({ name: m[1].trim(), message: m[1].trim() });
2157
- }
2158
- // #1487: gated on `!matched`, not on `failed === 0`. A non-zero exit
2159
- // with NO count parser match — a spawn/config/load failure, nothing
2160
- // ran — is infrastructure, not a test verdict: report it as a runner
2161
- // error. A non-zero exit a count parser DID match (even to a
2162
- // legitimate `failed === 0`, e.g. a green last module of a red
2163
- // reactor) is a real run that produced real counts, so it is never a
2164
- // runner error, and the exit-code-distrust guard below still forces
2165
- // at least one failure so it can't render as PASS.
2166
- //
2167
- // #1524-r3: the veto is `goFailNames.length === 0`, NOT
2168
- // `failures.length === 0`. Only `--- FAIL:` is a marker a test
2169
- // runner emits SPECIFICALLY for a failed test; `FAILED`/`Failure:`
2170
- // are generic words a build tool prints for reasons that have
2171
- // nothing to do with a test — a gradle task failing to compile, a
2172
- // maven goal failing before surefire ever runs, an rspec file that
2173
- // never loaded. Letting those two veto the runner-error branch on
2174
- // their own reintroduced #1487's exact symptom for the commonest
2175
- // gradle/maven/rspec failure shapes (proven: `[ERROR] Failed to
2176
- // execute goal ... FAILED` and rspec's `Failure: cannot load such
2177
- // file` both rendered as a fabricated test failure instead of the
2178
- // runner error they actually are). They still label a name onto
2179
- // `failures` above when `matched` or `goFailNames` already settled
2180
- // the question some other way, but they no longer settle it alone.
2181
- //
2182
- // #2870: `goInfraVerdict` is a SECOND sufficient condition, not a
2183
- // widening of the first. go's `[build failed]`/`[setup failed]` line is
2184
- // the runner itself saying no test ran, which is exactly what this
2185
- // branch reports — so it no longer has to also print the word "error"
2186
- // to be classified as one. The `!matched`/`goFailNames` vetoes still
2187
- // apply: a run that produced real go counts or a real `--- FAIL:` is a
2188
- // verdict, never a runner error, even if a later package failed to
2189
- // build.
2190
- const runnerError = exitCode !== 0 &&
2191
- !matched &&
2192
- goFailNames.length === 0 &&
2193
- (lower.includes("error") || goInfraVerdict)
2194
- ? `Runner ${runner} exited with ${exitCode}`
2195
- : undefined;
2196
- // #1524-r4 (tidy): a runner-error result reports through
2197
- // `formatResult`'s runner-error branch, not the failed-tests
2198
- // branch, so any name `otherNames` picked up before this was
2199
- // decided (e.g. a same-line `Failure: cannot load such file`)
2200
- // would sit in `failures` unused but visible to anything reading
2201
- // the raw `TestResult` — an error result with a non-empty
2202
- // `failures` list is a self-contradiction. Clear it here so the
2203
- // result is one or the other, never both.
2204
- if (runnerError) {
2205
- failures.length = 0;
2206
- }
2207
- // #1480 (adjacent): a non-zero exit is the runner saying the run
2208
- // failed. Every count parser above can legitimately arrive at
2209
- // `failed === 0` — a summary that only covers part of the run, a green
2210
- // module of a red reactor build, a failure outside any test — and the
2211
- // turn-end log would then print PASS over a build the runner rejected.
2212
- // Trust the exit code: no parse of the text may talk it out of at least
2213
- // one failure. Skipped when `runnerError` is set — that case already
2214
- // reports through `formatResult`'s runner-error branch, which requires
2215
- // `passed === 0 && failed === 0` to stay reachable, and forcing
2216
- // `failed` to 1 here would make it unreachable again (#1487).
2217
- if (exitCode !== 0 && failed === 0 && !runnerError) {
2218
- failed = 1;
2219
- }
2220
- if (passed === 0 && failed === 0 && skipped === 0 && exitCode === 0) {
2221
- passed = 1;
2222
- }
2223
- if (failures.length === 0 && failed > 0) {
2224
- const firstLine = output
2225
- .split("\n")
2226
- .find((l) => /fail|error|exception/i.test(l))
2227
- ?.trim()
2228
- .slice(0, 300) || `Tests failed for runner ${runner}`;
2229
- failures.push({ name: `${runner} failure`, message: firstLine });
2230
- }
2231
- this.log(runnerError
2232
- ? `Generic runner ${runner}: never started (${runnerError})`
2233
- : `Generic runner ${runner}: ran (matched=${matched}, passed=${passed}, failed=${failed}, failures=${failures.length})`);
2234
- return {
2235
- file: testFile,
2236
- sourceFile: "",
2237
- runner,
2238
- passed,
2239
- failed,
2240
- skipped,
2241
- failures,
2242
- duration,
2243
- error: runnerError,
2244
- };
2245
- }
2246
- // --- Formatting ---
2247
- /**
2248
- * Format test result for LLM consumption
2249
- */
2250
- formatResult(result) {
2251
- // #2532 review S1: folded onto `isRunnerErrorResult` instead of the old
2252
- // local `error && passed === 0 && failed === 0` spelling — that missed a
2253
- // runner error reported alongside partial passes (pytest `Interrupted`
2254
- // after some tests already ran clean), which fell through to the normal
2255
- // "N/N passed" branch below and silently dropped the interruption.
2256
- if (isRunnerErrorResult(result)) {
2257
- return result.passed > 0
2258
- ? `[Tests] ⚠ Could not complete tests: ${result.error} (${result.passed} passed before)`
2259
- : `[Tests] ⚠ Could not run tests: ${result.error}`;
2260
- }
2261
- const total = result.passed + result.failed + result.skipped;
2262
- if (total === 0) {
2263
- return ""; // No tests to report
2264
- }
2265
- // #1479 deliberately does NOT change this surface. The agent-facing
2266
- // string already suppressed the suffix for a 0, so an unmeasured run
2267
- // and a zero-length one look the same here and always did. The issue
2268
- // scopes the unmeasured/zero distinction to the turn-end log line;
2269
- // widening it to the LLM prompt is a separate call about prompt noise.
2270
- // #1480: the "is this a measurement at all" half of the test comes from
2271
- // `run-duration.ts` so this surface cannot drift from the log's answer.
2272
- // The `> 0` half is the scope decision above and stays local to it — it
2273
- // is what suppresses the suffix for a measured zero, which is a choice
2274
- // about prompt noise rather than about the duration contract. Routing
2275
- // the first half through the shared predicate also stops a non-finite
2276
- // duration rendering as ` (Infinitys)`.
2277
- const durationStr = isMeasuredDuration(result.duration) && result.duration > 0
2278
- ? ` (${(result.duration / 1000).toFixed(2)}s)`
2279
- : "";
2280
- if (result.failed === 0) {
2281
- return `[Tests] ✓ ${result.passed}/${total} passed${durationStr} — ${result.runner}`;
2282
- }
2283
- // Has failures
2284
- let output = `[Tests] ✗ ${result.failed}/${total} failed, ${result.passed} passed${durationStr} — ${result.runner}\n`;
2285
- for (const failure of result.failures.slice(0, 5)) {
2286
- output += ` ✗ ${failure.name}\n`;
2287
- const msg = failure.message.split("\n")[0].slice(0, 200); // First line, truncated
2288
- output += ` ${msg}\n`;
2289
- if (failure.location) {
2290
- output += ` at ${failure.location}\n`;
2291
- }
2292
- }
2293
- if (result.failures.length > 5) {
2294
- output += ` ... and ${result.failures.length - 5} more failure(s)\n`;
2295
- }
2296
- output += ` → Fix failing tests before proceeding\n`;
2297
- return output.trimEnd();
2298
- }
2299
- // --- Helpers ---
2300
- /**
2301
- * Additional mirrored-directory candidate for source trees whose test
2302
- * tree mirrors the source tree under a *different*, conventional
2303
- * source-root segment rather than the source file's full relative
2304
- * directory — e.g. PHPUnit's `src/Foo/Bar.php` -> `tests/Foo/BarTest.php`
2305
- * (strips `src`) or ExUnit's `lib/accounts/user.ex` ->
2306
- * `test/accounts/user_test.exs` (strips `lib`).
2307
- *
2308
- * Unlike the `relDir`-based candidates above (which mirror under the
2309
- * hardcoded "tests"/"__tests__" roots), this uses `testDir` — the
2310
- * pattern's own configured test root from `SOURCE_TO_TEST_PATTERNS`
2311
- * (e.g. "tests" for PHP, "test" for Elixir) — since ExUnit's root is
2312
- * singular and wouldn't otherwise be checked.
2313
- *
2314
- * Returns an empty array when the source directory doesn't start with a
2315
- * known source-root segment (src/lib/app) followed by at least one more
2316
- * path segment — i.e. this is a no-op for languages/layouts that don't
2317
- * use this convention.
2318
- */
2319
- sourceRootMirroredCandidates(dir, cwd, testDir, testFilename) {
2320
- const knownSourceRoots = new Set(["src", "lib", "app"]);
2321
- const relDir = path.relative(cwd, dir);
2322
- const segments = relDir.split(path.sep).filter(Boolean);
2323
- if (segments.length > 1 && knownSourceRoots.has(segments[0])) {
2324
- return [path.join(cwd, testDir, ...segments.slice(1), testFilename)];
2325
- }
2326
- return [];
2327
- }
2328
- /**
2329
- * Fallback discovery: scan known test directories for a file that imports
2330
- * the source module. Catches cases where the test file name doesn't match
2331
- * the source basename (e.g. cline.test.ts testing cline-auth.ts).
2332
- *
2333
- * Checks for the basename appearing in a quoted import/require path:
2334
- * from "../providers/cline/cline-auth" → /cline-auth" ✓
2335
- * from "./cline-auth.js" → /cline-auth. ✓
2336
- * import("cline-auth") → "cline-auth" ✓
2337
- */
2338
- findTestFileByImport(sourceFilePath, cwd) {
2339
- const ext = path.extname(sourceFilePath);
2340
- const basename = path.basename(sourceFilePath, ext);
2341
- const testPattern = /\.(test|spec)\.(ts|tsx|js|jsx|mjs)$/;
2342
- const searchDirs = [
2343
- path.join(cwd, "tests"),
2344
- path.join(cwd, "__tests__"),
2345
- path.dirname(sourceFilePath),
2346
- ];
2347
- for (const dir of searchDirs) {
2348
- let entries;
2349
- try {
2350
- entries = fs.readdirSync(dir);
2351
- }
2352
- catch {
2353
- continue;
2354
- }
2355
- for (const entry of entries) {
2356
- if (!testPattern.test(entry))
2357
- continue;
2358
- const testPath = path.join(dir, entry);
2359
- let content;
2360
- try {
2361
- content = fs.readFileSync(testPath, "utf-8");
2362
- }
2363
- catch {
2364
- continue;
2365
- }
2366
- if (content.includes(`/${basename}"`) ||
2367
- content.includes(`/${basename}'`) ||
2368
- content.includes(`/${basename}.`) ||
2369
- content.includes(`"${basename}"`) ||
2370
- content.includes(`'${basename}'`)) {
2371
- this.log(`Found test file via import scan: ${testPath}`);
2372
- return testPath;
2373
- }
2374
- }
2375
- }
2376
- return null;
2377
- }
2378
- /**
2379
- * Resolve the executable and args for a runner, preferring a local
2380
- * node_modules/.bin binary over npx to avoid the ~150ms npx startup cost.
2381
- *
2382
- * When a resolved binary becomes the command itself, `stripWrapperArgs`
2383
- * drops ONLY the leading arg(s) that named the wrapped binary — never a
2384
- * real subcommand (#1098: `cargo test --no-fail-fast` unconditionally lost
2385
- * `test` here because the old code assumed every runner's args() started
2386
- * with an npx-style runner-name arg, which only holds for wrapper-style
2387
- * runners like vitest/jest/pytest).
2388
- */
2389
- async resolveExec(runner, config, testFile, cwd,
2390
- /**
2391
- * #2871: the directory the child will RUN in, which is the only cwd
2392
- * `args()` may be built against — go's package path is relative to it.
2393
- * Where the binary and the Python environment are INSTALLED is a
2394
- * different question with a different answer: they stay resolved from
2395
- * the dispatch root, exactly as before, so a workspace package whose
2396
- * dependencies are hoisted to the repo root still finds
2397
- * `node_modules/.bin/<runner>` instead of falling through to `npx`.
2398
- * Defaults to `cwd`, which is every call where the two are the same.
2399
- */
2400
- spawnCwd = cwd) {
2401
- // Run pytest through the project interpreter itself, not a generic `python`
2402
- // resolved from the host PATH. The child-only environment also keeps tools
2403
- // spawned by tests inside the same project environment.
2404
- if (runner === "pytest") {
2405
- const pythonEnvironment = await detectPythonEnvironment(cwd);
2406
- if (pythonEnvironment) {
2407
- return {
2408
- command: pythonEnvironment.pythonPath,
2409
- args: config.args(testFile, spawnCwd),
2410
- env: augmentPythonEnvironment(process.env, pythonEnvironment),
2411
- };
2412
- }
2413
- }
2414
- // PHPUnit has no npx-style automatic local-binary resolution — Composer's
2415
- // standard local-install location is vendor/bin/phpunit, so check that
2416
- // explicitly before falling back to a global `phpunit` on PATH.
2417
- if (runner === "phpunit") {
2418
- const suffix = process.platform === "win32" ? ".bat" : "";
2419
- const vendorBin = path.join(cwd, "vendor", "bin", `phpunit${suffix}`);
2420
- if (fs.existsSync(vendorBin)) {
2421
- return { command: vendorBin, args: config.args(testFile, spawnCwd) };
2422
- }
2423
- return { command: "phpunit", args: config.args(testFile, spawnCwd) };
2424
- }
2425
- const binName = config.binName ?? runner;
2426
- const suffix = process.platform === "win32" ? ".cmd" : "";
2427
- const localBin = path.join(cwd, "node_modules", ".bin", binName + suffix);
2428
- // A resolved binary (local, or any manager's global bin) becomes the command
2429
- // itself, so the leading wrapper-name arg(s) that named it (e.g. "vitest",
2430
- // or "-m pytest") are stripped from args() — see stripWrapperArgs.
2431
- if (fs.existsSync(localBin)) {
2432
- return {
2433
- command: localBin,
2434
- args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
2435
- };
2436
- }
2437
- // Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx (#375).
2438
- const globalBin = await findGlobalBinary(binName);
2439
- if (globalBin) {
2440
- return {
2441
- command: globalBin,
2442
- args: stripWrapperArgs(binName, config.args(testFile, spawnCwd)),
2443
- };
2444
- }
2445
- return { command: config.command, args: config.args(testFile, spawnCwd) };
2446
- }
2447
- emptyResult(testFile, sourceFile, runner, error) {
2448
- return {
2449
- file: testFile,
2450
- sourceFile,
2451
- runner,
2452
- passed: 0,
2453
- failed: 0,
2454
- skipped: 0,
2455
- failures: [],
2456
- // #1479: no duration key at all. Nothing ran, so there is nothing
2457
- // to report — this used to say 0, which reads as "ran, instantly".
2458
- error,
2459
- };
2460
- }
2461
- truncateStack(stack) {
2462
- if (!stack)
2463
- return undefined;
2464
- // Keep first 3 lines of stack trace
2465
- const lines = stack.split("\n").slice(0, 3);
2466
- return lines.join("\n").slice(0, 500);
2467
- }
2468
- recordResult(record) {
2469
- const { cwd, runner, testFile, result, turnIndex } = record;
2470
- const targetPath = path.resolve(testFile);
2471
- const target = canonicalFailedPath(targetPath);
2472
- let failedTargets = this.getFailedTargets(cwd, runner, result.failed > 0);
2473
- if (!failedTargets)
2474
- return;
2475
- if (result.failed > 0) {
2476
- const alreadyRecorded = failedTargets.has(target);
2477
- if (!alreadyRecorded &&
2478
- this.failedTargetCount(runner) >= MAX_FAILED_TARGETS_PER_RUNNER) {
2479
- const evicted = this.evictOldestFailedTarget(runner);
2480
- if (evicted) {
2481
- this.recordFailedTargetState({
2482
- outcome: "capacity-evicted",
2483
- runner,
2484
- candidate: evicted,
2485
- turnIndex,
2486
- });
2487
- }
2488
- // Eviction can remove this root's final prior target. Reacquire the
2489
- // root map before inserting so the newest failure never lands in a
2490
- // detached PathKeyedMap.
2491
- failedTargets = this.getFailedTargets(cwd, runner, true);
2492
- if (!failedTargets)
2493
- return;
2494
- }
2495
- // Sequence is global across roots, so a refreshed target becomes the
2496
- // newest failure without relying on one root map's insertion order.
2497
- failedTargets.set(target, {
2498
- displayPath: targetPath,
2499
- sequence: ++this.failedTargetSequence,
2500
- });
2501
- return;
2502
- }
2503
- if (failedTargets.delete(target) && failedTargets.size === 0) {
2504
- this.deleteFailedRoot(cwd, runner);
2505
- }
2506
- }
2507
- }