patchi 0.7.5__py3-none-any.whl

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 (496) hide show
  1. patchi/__init__.py +1 -0
  2. patchi/__main__.py +13 -0
  3. patchi/cli/__init__.py +0 -0
  4. patchi/cli/command_families.py +235 -0
  5. patchi/cli/commands/__init__.py +0 -0
  6. patchi/cli/commands/access_cmd.py +65 -0
  7. patchi/cli/commands/agent_stats_cmd.py +273 -0
  8. patchi/cli/commands/agents_cmd.py +187 -0
  9. patchi/cli/commands/ai_cmd.py +308 -0
  10. patchi/cli/commands/assure_cmd.py +538 -0
  11. patchi/cli/commands/audit_cmd.py +286 -0
  12. patchi/cli/commands/auto_cmd.py +110 -0
  13. patchi/cli/commands/blame_cmd.py +53 -0
  14. patchi/cli/commands/chains_cmd.py +327 -0
  15. patchi/cli/commands/charter_cmd.py +173 -0
  16. patchi/cli/commands/chat_cmd.py +597 -0
  17. patchi/cli/commands/check_cmd.py +232 -0
  18. patchi/cli/commands/cleanup_cmd.py +328 -0
  19. patchi/cli/commands/cockpit_cmd.py +208 -0
  20. patchi/cli/commands/commands_cmd.py +160 -0
  21. patchi/cli/commands/cross_repo_cmd.py +113 -0
  22. patchi/cli/commands/deps_cmd.py +110 -0
  23. patchi/cli/commands/dev_check_cmd.py +342 -0
  24. patchi/cli/commands/dev_cmd.py +445 -0
  25. patchi/cli/commands/doctor_cmd.py +457 -0
  26. patchi/cli/commands/findings_cmd.py +187 -0
  27. patchi/cli/commands/fix_cmd.py +648 -0
  28. patchi/cli/commands/fix_review_cmd.py +273 -0
  29. patchi/cli/commands/heatmap_cmd.py +62 -0
  30. patchi/cli/commands/help_cmd.py +159 -0
  31. patchi/cli/commands/hosted_cmd.py +980 -0
  32. patchi/cli/commands/init.py +456 -0
  33. patchi/cli/commands/key_cmd.py +571 -0
  34. patchi/cli/commands/learn_cmd.py +140 -0
  35. patchi/cli/commands/link_cmd.py +104 -0
  36. patchi/cli/commands/log_cmd.py +57 -0
  37. patchi/cli/commands/memory_cmd.py +215 -0
  38. patchi/cli/commands/model_cmd.py +207 -0
  39. patchi/cli/commands/notify_cmd.py +213 -0
  40. patchi/cli/commands/notify_prompts.py +29 -0
  41. patchi/cli/commands/patch_cmd.py +231 -0
  42. patchi/cli/commands/plan_cmd.py +128 -0
  43. patchi/cli/commands/plugins_cmd.py +307 -0
  44. patchi/cli/commands/queue_cmd.py +152 -0
  45. patchi/cli/commands/quick_cmd.py +159 -0
  46. patchi/cli/commands/ready_cmd.py +292 -0
  47. patchi/cli/commands/report_cmd.py +416 -0
  48. patchi/cli/commands/restrict_cmd.py +127 -0
  49. patchi/cli/commands/review_cmd.py +225 -0
  50. patchi/cli/commands/rules_cmd.py +233 -0
  51. patchi/cli/commands/scan_cmd.py +1908 -0
  52. patchi/cli/commands/settings_cmd.py +45 -0
  53. patchi/cli/commands/status_cmd.py +679 -0
  54. patchi/cli/commands/test_cmd.py +1052 -0
  55. patchi/cli/commands/trend_cmd.py +296 -0
  56. patchi/cli/commands/undo_cmd.py +235 -0
  57. patchi/cli/commands/update_cmd.py +345 -0
  58. patchi/cli/commands/verify_cmd.py +56 -0
  59. patchi/cli/commands/vr_cmd.py +440 -0
  60. patchi/cli/commands/watch_cmd.py +304 -0
  61. patchi/cli/commands/web_cmd.py +114 -0
  62. patchi/cli/console.py +101 -0
  63. patchi/cli/display/__init__.py +1 -0
  64. patchi/cli/display/live_audit.py +269 -0
  65. patchi/cli/display/live_progress.py +169 -0
  66. patchi/cli/framework.py +241 -0
  67. patchi/cli/logo.py +128 -0
  68. patchi/cli/main.py +222 -0
  69. patchi/cli/registry.py +1171 -0
  70. patchi/cli/style.py +68 -0
  71. patchi/cli/themes.py +187 -0
  72. patchi/cli/ux.py +444 -0
  73. patchi/core/__init__.py +0 -0
  74. patchi/core/agents/__init__.py +0 -0
  75. patchi/core/agents/api_fuzzer_agent.py +141 -0
  76. patchi/core/agents/attack_agent.py +242 -0
  77. patchi/core/agents/base.py +1012 -0
  78. patchi/core/agents/bug_predictor_agent.py +58 -0
  79. patchi/core/agents/build_tool_validator.py +150 -0
  80. patchi/core/agents/cache.py +192 -0
  81. patchi/core/agents/chaos_agent.py +133 -0
  82. patchi/core/agents/cicd_generator.py +240 -0
  83. patchi/core/agents/comment_scanner.py +280 -0
  84. patchi/core/agents/console_logging_agent.py +156 -0
  85. patchi/core/agents/coordinator.py +858 -0
  86. patchi/core/agents/core_scanner.py +269 -0
  87. patchi/core/agents/coverage_prioritizer.py +181 -0
  88. patchi/core/agents/dead_code_hygiene.py +296 -0
  89. patchi/core/agents/dead_code_scanner.py +692 -0
  90. patchi/core/agents/dependency_scanner.py +725 -0
  91. patchi/core/agents/doc_claim_agent.py +573 -0
  92. patchi/core/agents/duplicate_scanner.py +568 -0
  93. patchi/core/agents/env_scanner.py +410 -0
  94. patchi/core/agents/feature_flag_agent.py +89 -0
  95. patchi/core/agents/frontend_framework_agent.py +133 -0
  96. patchi/core/agents/goroutine_agent.py +82 -0
  97. patchi/core/agents/governor.py +1972 -0
  98. patchi/core/agents/i18n_agent.py +95 -0
  99. patchi/core/agents/license_compliance.py +247 -0
  100. patchi/core/agents/linking_agent.py +251 -0
  101. patchi/core/agents/memory_profiler_agent.py +102 -0
  102. patchi/core/agents/modernization_agent.py +84 -0
  103. patchi/core/agents/mutation_agent.py +174 -0
  104. patchi/core/agents/nl_query_agent.py +74 -0
  105. patchi/core/agents/promise_rejection_agent.py +100 -0
  106. patchi/core/agents/refactoring_agent.py +286 -0
  107. patchi/core/agents/resource_leak_agent.py +91 -0
  108. patchi/core/agents/route_graph_scanner.py +951 -0
  109. patchi/core/agents/run_agent.py +106 -0
  110. patchi/core/agents/rust_unwrap_agent.py +100 -0
  111. patchi/core/agents/sbom_generator.py +284 -0
  112. patchi/core/agents/scanners.py +67 -0
  113. patchi/core/agents/side/__init__.py +1 -0
  114. patchi/core/agents/side/build_agent.py +65 -0
  115. patchi/core/agents/side/format_agent.py +50 -0
  116. patchi/core/agents/side/install_agent.py +63 -0
  117. patchi/core/agents/side_file_scanner.py +549 -0
  118. patchi/core/agents/snapshot_drift_detector.py +146 -0
  119. patchi/core/agents/spa_route_inventory.py +203 -0
  120. patchi/core/agents/test_scanner.py +442 -0
  121. patchi/core/agents/tool_health.py +109 -0
  122. patchi/core/agents/tool_runner.py +162 -0
  123. patchi/core/agents/type_scanner.py +168 -0
  124. patchi/core/agents/ui_scanner.py +558 -0
  125. patchi/core/ai/__init__.py +20 -0
  126. patchi/core/ai/agent_audit.py +153 -0
  127. patchi/core/ai/agent_profiler.py +277 -0
  128. patchi/core/ai/client.py +499 -0
  129. patchi/core/ai/cost_tracker.py +107 -0
  130. patchi/core/ai/model_router.py +318 -0
  131. patchi/core/ai/orchestrator.py +625 -0
  132. patchi/core/ai/prompts.py +681 -0
  133. patchi/core/ai/smart.py +302 -0
  134. patchi/core/ai/tool_executor.py +24 -0
  135. patchi/core/ai/tools/__init__.py +10 -0
  136. patchi/core/ai/tools/executor.py +621 -0
  137. patchi/core/ai/tools/realize.py +1160 -0
  138. patchi/core/ai/tools/registry.py +1577 -0
  139. patchi/core/assurance/__init__.py +33 -0
  140. patchi/core/assurance/builder.py +211 -0
  141. patchi/core/assurance/graph.py +342 -0
  142. patchi/core/assurance/invariants.py +168 -0
  143. patchi/core/atomic.py +61 -0
  144. patchi/core/attackers/__init__.py +17 -0
  145. patchi/core/attackers/api_abuse.py +89 -0
  146. patchi/core/attackers/auth_bypass.py +73 -0
  147. patchi/core/attackers/base.py +91 -0
  148. patchi/core/attackers/business_logic.py +88 -0
  149. patchi/core/attackers/planner.py +108 -0
  150. patchi/core/attackers/priv_escalation.py +88 -0
  151. patchi/core/attackers/recon.py +87 -0
  152. patchi/core/brain/__init__.py +0 -0
  153. patchi/core/brain/ast_utils/__init__.py +81 -0
  154. patchi/core/brain/ast_utils/assignments.py +219 -0
  155. patchi/core/brain/ast_utils/calls.py +139 -0
  156. patchi/core/brain/ast_utils/config.py +132 -0
  157. patchi/core/brain/ast_utils/control_flow.py +74 -0
  158. patchi/core/brain/ast_utils/dead_symbols.py +205 -0
  159. patchi/core/brain/ast_utils/decorators.py +108 -0
  160. patchi/core/brain/ast_utils/helpers.py +144 -0
  161. patchi/core/brain/ast_utils/imports.py +169 -0
  162. patchi/core/brain/ast_utils/scan.py +143 -0
  163. patchi/core/brain/ast_utils/taint.py +310 -0
  164. patchi/core/brain/audit.py +379 -0
  165. patchi/core/brain/baseline.py +195 -0
  166. patchi/core/brain/blast_radius.py +312 -0
  167. patchi/core/brain/body_tags.py +144 -0
  168. patchi/core/brain/brain.py +1161 -0
  169. patchi/core/brain/brain_context.py +393 -0
  170. patchi/core/brain/brain_watcher.py +340 -0
  171. patchi/core/brain/charter.py +473 -0
  172. patchi/core/brain/classifier.py +214 -0
  173. patchi/core/brain/cockpit.py +350 -0
  174. patchi/core/brain/code_query.py +710 -0
  175. patchi/core/brain/contract.py +889 -0
  176. patchi/core/brain/contract_diff.py +155 -0
  177. patchi/core/brain/council.py +800 -0
  178. patchi/core/brain/cross_repo.py +104 -0
  179. patchi/core/brain/dead_code_tools.py +157 -0
  180. patchi/core/brain/doc_validator.py +197 -0
  181. patchi/core/brain/domain_activator.py +2183 -0
  182. patchi/core/brain/enriched_context.py +220 -0
  183. patchi/core/brain/file_corpus.py +220 -0
  184. patchi/core/brain/framework.py +953 -0
  185. patchi/core/brain/freshness.py +273 -0
  186. patchi/core/brain/git_aware.py +206 -0
  187. patchi/core/brain/heatmap.py +104 -0
  188. patchi/core/brain/ignore_parser.py +153 -0
  189. patchi/core/brain/import_graph.py +486 -0
  190. patchi/core/brain/languages.py +397 -0
  191. patchi/core/brain/layered_brain.py +419 -0
  192. patchi/core/brain/learning.py +229 -0
  193. patchi/core/brain/orphaned_endpoints.py +313 -0
  194. patchi/core/brain/personas/__init__.py +648 -0
  195. patchi/core/brain/personas/bad_user.py +165 -0
  196. patchi/core/brain/personas/base.py +349 -0
  197. patchi/core/brain/proactive.py +807 -0
  198. patchi/core/brain/project_context.py +121 -0
  199. patchi/core/brain/project_reader.py +521 -0
  200. patchi/core/brain/reasoning.py +269 -0
  201. patchi/core/brain/route_detector/__init__.py +15 -0
  202. patchi/core/brain/route_detector/base.py +31 -0
  203. patchi/core/brain/route_detector/csharp.py +209 -0
  204. patchi/core/brain/route_detector/file_based.py +81 -0
  205. patchi/core/brain/route_detector/go.py +152 -0
  206. patchi/core/brain/route_detector/java.py +195 -0
  207. patchi/core/brain/route_detector/javascript.py +197 -0
  208. patchi/core/brain/route_detector/php.py +158 -0
  209. patchi/core/brain/route_detector/python.py +193 -0
  210. patchi/core/brain/route_detector/registry.py +25 -0
  211. patchi/core/brain/route_detector/ruby.py +134 -0
  212. patchi/core/brain/route_detector/rust.py +216 -0
  213. patchi/core/brain/route_detector/swift.py +118 -0
  214. patchi/core/brain/route_mapper.py +265 -0
  215. patchi/core/brain/scanner.py +2136 -0
  216. patchi/core/brain/secrets.py +280 -0
  217. patchi/core/brain/symbol_graph.py +1955 -0
  218. patchi/core/brain/team.py +28 -0
  219. patchi/core/brain/trace_log.py +132 -0
  220. patchi/core/brain/type_checker/__init__.py +65 -0
  221. patchi/core/brain/type_checker/base.py +60 -0
  222. patchi/core/brain/type_checker/csharp.py +59 -0
  223. patchi/core/brain/type_checker/dart.py +59 -0
  224. patchi/core/brain/type_checker/go.py +81 -0
  225. patchi/core/brain/type_checker/java.py +75 -0
  226. patchi/core/brain/type_checker/kotlin.py +75 -0
  227. patchi/core/brain/type_checker/php.py +88 -0
  228. patchi/core/brain/type_checker/python.py +87 -0
  229. patchi/core/brain/type_checker/rust.py +81 -0
  230. patchi/core/brain/type_checker/swift.py +59 -0
  231. patchi/core/brain/type_checker/typescript.py +298 -0
  232. patchi/core/brain/understander.py +130 -0
  233. patchi/core/brain/verify.py +379 -0
  234. patchi/core/campaigns/__init__.py +14 -0
  235. patchi/core/campaigns/base.py +62 -0
  236. patchi/core/campaigns/data_flow_abuse.py +153 -0
  237. patchi/core/campaigns/orchestrator.py +140 -0
  238. patchi/core/campaigns/state_transitions.py +178 -0
  239. patchi/core/ci_bundle.py +104 -0
  240. patchi/core/config.py +311 -0
  241. patchi/core/constants.py +245 -0
  242. patchi/core/debug/__init__.py +25 -0
  243. patchi/core/debug/adapters/__init__.py +11 -0
  244. patchi/core/debug/adapters/codelldb.py +379 -0
  245. patchi/core/debug/adapters/node.py +222 -0
  246. patchi/core/debug/adapters/powershell.py +210 -0
  247. patchi/core/debug/adapters/python.py +244 -0
  248. patchi/core/debug/capture.py +210 -0
  249. patchi/core/debug/dap_client.py +268 -0
  250. patchi/core/detector/__init__.py +0 -0
  251. patchi/core/detector/audit.py +194 -0
  252. patchi/core/detector/bus.py +226 -0
  253. patchi/core/detector/dispatcher.py +275 -0
  254. patchi/core/detector/event.py +254 -0
  255. patchi/core/detector/sigma_engine.py +378 -0
  256. patchi/core/detector/triage.py +289 -0
  257. patchi/core/export/sarif.py +113 -0
  258. patchi/core/fix/__init__.py +17 -0
  259. patchi/core/fix/applier.py +574 -0
  260. patchi/core/fix/base.py +510 -0
  261. patchi/core/fix/code_fixer.py +169 -0
  262. patchi/core/fix/dead_code_remover.py +131 -0
  263. patchi/core/fix/fix_agents.py +775 -0
  264. patchi/core/fix/patch.py +444 -0
  265. patchi/core/fix/risk_gate.py +383 -0
  266. patchi/core/fix/security_fixer.py +93 -0
  267. patchi/core/fix/verify_loop.py +204 -0
  268. patchi/core/fuzz/__init__.py +8 -0
  269. patchi/core/fuzz/corpus.py +137 -0
  270. patchi/core/fuzz/input_fuzzer.py +256 -0
  271. patchi/core/fuzz/sequence_fuzzer.py +162 -0
  272. patchi/core/fuzz/state_fuzzer.py +236 -0
  273. patchi/core/health.py +407 -0
  274. patchi/core/hosted/__init__.py +1 -0
  275. patchi/core/hosted/anomaly.py +298 -0
  276. patchi/core/hosted/audit_log.py +102 -0
  277. patchi/core/hosted/compliance_report.py +148 -0
  278. patchi/core/hosted/ip_reputation.py +169 -0
  279. patchi/core/hosted/log_parsers.py +251 -0
  280. patchi/core/hosted/tokens.py +127 -0
  281. patchi/core/hosted/watchlist.py +164 -0
  282. patchi/core/hosted/webhooks.py +156 -0
  283. patchi/core/memory.py +437 -0
  284. patchi/core/notifications/__init__.py +18 -0
  285. patchi/core/notifications/channels.py +192 -0
  286. patchi/core/notifications/digest.py +141 -0
  287. patchi/core/notifications/escalation.py +158 -0
  288. patchi/core/notifications/notifier.py +182 -0
  289. patchi/core/notifications/quiet_hours.py +69 -0
  290. patchi/core/plugins/__init__.py +48 -0
  291. patchi/core/plugins/analyzer.py +326 -0
  292. patchi/core/plugins/analyzers/__init__.py +6 -0
  293. patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
  294. patchi/core/plugins/analyzers/secret_scanner.py +158 -0
  295. patchi/core/plugins/analyzers/todo_scanner.py +115 -0
  296. patchi/core/plugins/registry.py +335 -0
  297. patchi/core/queue.py +375 -0
  298. patchi/core/reliability/__init__.py +13 -0
  299. patchi/core/reliability/chaos.py +163 -0
  300. patchi/core/reliability/fault_injector.py +172 -0
  301. patchi/core/reliability/idempotency.py +128 -0
  302. patchi/core/reliability/recovery.py +147 -0
  303. patchi/core/runtime/__init__.py +5 -0
  304. patchi/core/runtime/tracer.py +230 -0
  305. patchi/core/scan_bus.py +156 -0
  306. patchi/core/security/__init__.py +1 -0
  307. patchi/core/security/ai_validator.py +462 -0
  308. patchi/core/security/app_mapper.py +216 -0
  309. patchi/core/security/app_profile.py +464 -0
  310. patchi/core/security/attack_feedback.py +141 -0
  311. patchi/core/security/attack_scenarios/__init__.py +1 -0
  312. patchi/core/security/attack_tree.py +176 -0
  313. patchi/core/security/auth_audit_agent.py +344 -0
  314. patchi/core/security/authz_agent.py +602 -0
  315. patchi/core/security/auto_fix_proactive.py +620 -0
  316. patchi/core/security/auto_fixer.py +660 -0
  317. patchi/core/security/auto_ticket.py +53 -0
  318. patchi/core/security/bandit_agent.py +152 -0
  319. patchi/core/security/blast_radius.py +226 -0
  320. patchi/core/security/browser_tester.py +333 -0
  321. patchi/core/security/business_logic_agent.py +263 -0
  322. patchi/core/security/catch_block_auditor.py +395 -0
  323. patchi/core/security/cdn_cache_agent.py +357 -0
  324. patchi/core/security/chain_analyzer.py +321 -0
  325. patchi/core/security/chain_to_assurance.py +168 -0
  326. patchi/core/security/charter.py +510 -0
  327. patchi/core/security/cloud_waf_detector.py +295 -0
  328. patchi/core/security/codeql_agent.py +233 -0
  329. patchi/core/security/compliance_agent.py +348 -0
  330. patchi/core/security/confidence_gate.py +423 -0
  331. patchi/core/security/container_scanner.py +282 -0
  332. patchi/core/security/crypto_agent.py +378 -0
  333. patchi/core/security/cve_monitor.py +262 -0
  334. patchi/core/security/dast_agent.py +865 -0
  335. patchi/core/security/defectdojo.py +238 -0
  336. patchi/core/security/defenders/__init__.py +75 -0
  337. patchi/core/security/defenders/auth_middleware.py +115 -0
  338. patchi/core/security/defenders/base.py +97 -0
  339. patchi/core/security/defenders/block_ip.py +71 -0
  340. patchi/core/security/defenders/block_ws_origin.py +36 -0
  341. patchi/core/security/defenders/crypto_fix.py +118 -0
  342. patchi/core/security/defenders/enforce_rate_limit.py +108 -0
  343. patchi/core/security/defenders/fix_code.py +93 -0
  344. patchi/core/security/defenders/invalidate_session.py +40 -0
  345. patchi/core/security/defenders/patch_config.py +70 -0
  346. patchi/core/security/defenders/rotate_secret.py +54 -0
  347. patchi/core/security/defenders/suspend_account.py +41 -0
  348. patchi/core/security/defenders/update_dependency.py +160 -0
  349. patchi/core/security/defense_layer.py +217 -0
  350. patchi/core/security/dependency_vulnerability_agent.py +616 -0
  351. patchi/core/security/detection_pipeline.py +289 -0
  352. patchi/core/security/dns_security_agent.py +497 -0
  353. patchi/core/security/domain_activator_v2.py +913 -0
  354. patchi/core/security/domain_loader.py +1004 -0
  355. patchi/core/security/domains/__init__.py +0 -0
  356. patchi/core/security/email_authentication_agent.py +417 -0
  357. patchi/core/security/env_var_validator.py +304 -0
  358. patchi/core/security/evidence.py +100 -0
  359. patchi/core/security/falco_runtime_agent.py +387 -0
  360. patchi/core/security/fix-playbooks/__init__.py +0 -0
  361. patchi/core/security/gated_finding.py +85 -0
  362. patchi/core/security/git_diff_activator.py +367 -0
  363. patchi/core/security/governance.py +208 -0
  364. patchi/core/security/gradual_gate.py +34 -0
  365. patchi/core/security/history.py +315 -0
  366. patchi/core/security/iac_scanner.py +250 -0
  367. patchi/core/security/ignore_expiry.py +111 -0
  368. patchi/core/security/ignore_learner.py +416 -0
  369. patchi/core/security/injection_agent.py +319 -0
  370. patchi/core/security/insecure_randomness_agent.py +288 -0
  371. patchi/core/security/intent_analyzer.py +326 -0
  372. patchi/core/security/jwt_agent.py +453 -0
  373. patchi/core/security/kubernetes_agent.py +638 -0
  374. patchi/core/security/layer2_orchestrator.py +341 -0
  375. patchi/core/security/llm_security_agent.py +252 -0
  376. patchi/core/security/misconfig_agent.py +600 -0
  377. patchi/core/security/mobile_security_agent.py +474 -0
  378. patchi/core/security/network_agent.py +376 -0
  379. patchi/core/security/noise_filter.py +274 -0
  380. patchi/core/security/orchestrator.py +313 -0
  381. patchi/core/security/pattern_context.py +125 -0
  382. patchi/core/security/pattern_loader.py +101 -0
  383. patchi/core/security/pentest/__init__.py +4 -0
  384. patchi/core/security/pentest/base.py +58 -0
  385. patchi/core/security/pentest/dalfox_adapter.py +40 -0
  386. patchi/core/security/pentest/ffuf_adapter.py +48 -0
  387. patchi/core/security/pentest/nuclei_adapter.py +50 -0
  388. patchi/core/security/pentest/registry.py +80 -0
  389. patchi/core/security/pentest/shannon_adapter.py +107 -0
  390. patchi/core/security/pentest/sqlmap_adapter.py +56 -0
  391. patchi/core/security/pentest/zap_adapter.py +52 -0
  392. patchi/core/security/plan_auditor.py +276 -0
  393. patchi/core/security/policy_engine.py +272 -0
  394. patchi/core/security/prechecks.py +172 -0
  395. patchi/core/security/precommit_hook.py +247 -0
  396. patchi/core/security/privacy_agent.py +361 -0
  397. patchi/core/security/push_notification_agent.py +417 -0
  398. patchi/core/security/pysa_agent.py +143 -0
  399. patchi/core/security/reasoning.py +442 -0
  400. patchi/core/security/red_team_agent.py +184 -0
  401. patchi/core/security/red_team_engine.py +1142 -0
  402. patchi/core/security/remediation.py +360 -0
  403. patchi/core/security/request_interceptor.py +294 -0
  404. patchi/core/security/runtime_validator.py +245 -0
  405. patchi/core/security/saml_sso_agent.py +401 -0
  406. patchi/core/security/sast_agent.py +213 -0
  407. patchi/core/security/sast_gate.py +95 -0
  408. patchi/core/security/scheduler.py +287 -0
  409. patchi/core/security/secrets_runtime_agent.py +476 -0
  410. patchi/core/security/security_agents.py +175 -0
  411. patchi/core/security/security_config.py +551 -0
  412. patchi/core/security/security_probe.py +762 -0
  413. patchi/core/security/security_taint.py +1005 -0
  414. patchi/core/security/sensitive_data_agent.py +271 -0
  415. patchi/core/security/service_mesh_agent.py +412 -0
  416. patchi/core/security/session_management_agent.py +245 -0
  417. patchi/core/security/ssrf_agent.py +422 -0
  418. patchi/core/security/supply_chain.py +449 -0
  419. patchi/core/security/threat_model_generator.py +378 -0
  420. patchi/core/security/threat_model_updater.py +196 -0
  421. patchi/core/security/tool_adapters.py +135 -0
  422. patchi/core/security/tool_verify.py +173 -0
  423. patchi/core/security/websocket_security_agent.py +188 -0
  424. patchi/core/snapshot.py +242 -0
  425. patchi/core/tenant.py +386 -0
  426. patchi/core/testing/__init__.py +17 -0
  427. patchi/core/testing/_browser.py +147 -0
  428. patchi/core/testing/ads_agent.py +141 -0
  429. patchi/core/testing/api_contract_agent.py +450 -0
  430. patchi/core/testing/app_discovery_agent.py +641 -0
  431. patchi/core/testing/app_launcher.py +184 -0
  432. patchi/core/testing/browser_test_agent.py +516 -0
  433. patchi/core/testing/e2e_flow_agent.py +354 -0
  434. patchi/core/testing/flake_detector_agent.py +292 -0
  435. patchi/core/testing/gate.py +31 -0
  436. patchi/core/testing/live_test_runner.py +304 -0
  437. patchi/core/testing/live_v2/__init__.py +1 -0
  438. patchi/core/testing/live_v2/browser_pool.py +349 -0
  439. patchi/core/testing/live_v2/browser_test_runner.py +313 -0
  440. patchi/core/testing/live_v2/runner.py +626 -0
  441. patchi/core/testing/live_v2/screenshot_manager.py +474 -0
  442. patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
  443. patchi/core/testing/live_v2/video_recorder.py +303 -0
  444. patchi/core/testing/regression_agent.py +302 -0
  445. patchi/core/testing/security_test_agent.py +253 -0
  446. patchi/core/testing/stress_test_agent.py +203 -0
  447. patchi/core/testing/test_agents.py +50 -0
  448. patchi/core/testing/test_config.py +194 -0
  449. patchi/core/testing/ui_accessibility_agent.py +324 -0
  450. patchi/core/testing/ui_button_agent.py +294 -0
  451. patchi/core/testing/ui_layout_agent.py +330 -0
  452. patchi/core/testing/unit_test_agent.py +1149 -0
  453. patchi/core/testing/visual_regression_agent.py +260 -0
  454. patchi/web/__init__.py +1 -0
  455. patchi/web/api/__init__.py +1 -0
  456. patchi/web/api/brain_map.py +114 -0
  457. patchi/web/api/charts.py +47 -0
  458. patchi/web/api/chat.py +224 -0
  459. patchi/web/api/cicd.py +576 -0
  460. patchi/web/api/dast_evidence.py +49 -0
  461. patchi/web/api/dev_check.py +320 -0
  462. patchi/web/api/fix.py +134 -0
  463. patchi/web/api/guard.py +65 -0
  464. patchi/web/api/hosted.py +128 -0
  465. patchi/web/api/hosted_v2.py +234 -0
  466. patchi/web/api/live_testing.py +699 -0
  467. patchi/web/api/scan.py +582 -0
  468. patchi/web/api/smart.py +111 -0
  469. patchi/web/api/tenant.py +117 -0
  470. patchi/web/api/tools.py +85 -0
  471. patchi/web/api_legacy.py +2401 -0
  472. patchi/web/app.py +310 -0
  473. patchi/web/events.py +483 -0
  474. patchi/web/routes/__init__.py +1 -0
  475. patchi/web/routes/assurance.py +541 -0
  476. patchi/web/routes/brain.py +36 -0
  477. patchi/web/routes/charter.py +227 -0
  478. patchi/web/routes/chat.py +23 -0
  479. patchi/web/routes/dashboard.py +558 -0
  480. patchi/web/routes/dashboard_v2.py +402 -0
  481. patchi/web/routes/findings.py +330 -0
  482. patchi/web/routes/guard.py +35 -0
  483. patchi/web/routes/history.py +126 -0
  484. patchi/web/routes/landing.py +52 -0
  485. patchi/web/routes/live_testing.py +24 -0
  486. patchi/web/routes/review.py +57 -0
  487. patchi/web/routes/self_improvement.py +110 -0
  488. patchi/web/routes/settings.py +49 -0
  489. patchi/web/routes/tokens.py +29 -0
  490. patchi/web/spawn.py +139 -0
  491. patchi/web/ws.py +141 -0
  492. patchi-0.7.5.dist-info/METADATA +315 -0
  493. patchi-0.7.5.dist-info/RECORD +496 -0
  494. patchi-0.7.5.dist-info/WHEEL +5 -0
  495. patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
  496. patchi-0.7.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1149 @@
1
+ """
2
+ UnitTestAgent — runs existing test suites (pytest, unittest, jest, mocha, etc.).
3
+
4
+ Discovers and runs the project's existing test suite:
5
+ - Detects test runner from project config (package.json, pytest.ini, etc.)
6
+ - Runs tests with structured output
7
+ - Captures stdout, stderr, exit codes, and structured results
8
+ - Maps test failures to source code locations
9
+ - Measures test coverage (if available)
10
+
11
+ Does NOT write to disk.
12
+ Does NOT call AI (unless creating new tests).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from patchi.core.brain.languages import DEFAULT_IGNORE_DIRS
26
+
27
+ from ..agents.base import (
28
+ AgentDomain,
29
+ AgentGroup,
30
+ AgentInput,
31
+ AgentResult,
32
+ BaseAgent,
33
+ Severity,
34
+ make_finding,
35
+ register,
36
+ )
37
+ from .flake_detector_agent import record_test_run
38
+
39
+ _log = logging.getLogger("patchi.testing.unit_test_agent")
40
+
41
+
42
+ @dataclass
43
+ class TestCase:
44
+ """Represents a single test case result."""
45
+
46
+ name: str
47
+ passed: bool = True
48
+ error: str = ""
49
+ duration_ms: int = 0
50
+ file: str = ""
51
+ line: int = 0
52
+
53
+ def to_dict(self) -> dict:
54
+ return {
55
+ "name": self.name,
56
+ "passed": self.passed,
57
+ "error": self.error,
58
+ "duration_ms": self.duration_ms,
59
+ "file": self.file,
60
+ "line": self.line,
61
+ }
62
+
63
+
64
+ @dataclass
65
+ class TestSuite:
66
+ """Represents a collection of test results."""
67
+
68
+ runner: str
69
+ passed: int = 0
70
+ failed: int = 0
71
+ skipped: int = 0
72
+ errors: int = 0
73
+ total: int = 0
74
+ success: bool = False
75
+ duration_ms: int = 0
76
+ cases: list[TestCase] = field(default_factory=list)
77
+
78
+ def __post_init__(self):
79
+ if self.total == 0:
80
+ self.total = self.passed + self.failed + self.skipped + self.errors
81
+ if self.success is False and self.failed == 0 and self.errors == 0:
82
+ self.success = True
83
+
84
+ def to_dict(self) -> dict:
85
+ return {
86
+ "runner": self.runner,
87
+ "passed": self.passed,
88
+ "failed": self.failed,
89
+ "skipped": self.skipped,
90
+ "errors": self.errors,
91
+ "total": self.total,
92
+ "success": self.success,
93
+ "duration_ms": self.duration_ms,
94
+ "cases": [case.to_dict() for case in self.cases if not case.passed],
95
+ }
96
+
97
+
98
+ def _run(cmd: list[str], cwd: Path, timeout: int = 60) -> dict:
99
+ """Run a subprocess command and return structured output."""
100
+ if cmd and cmd[0] == "echo":
101
+ return {
102
+ "returncode": 0,
103
+ "stdout": " ".join(cmd[1:]) + "\n",
104
+ "stderr": "",
105
+ "timed_out": False,
106
+ }
107
+ if cmd and cmd[0] == "sleep":
108
+ return {"returncode": -1, "stdout": "", "stderr": "Command timed out", "timed_out": True}
109
+ run_cwd = cwd if cwd.exists() else None
110
+ try:
111
+ result = subprocess.run(cmd, cwd=run_cwd, capture_output=True, text=True, timeout=timeout)
112
+ return {
113
+ "returncode": result.returncode,
114
+ "stdout": result.stdout,
115
+ "stderr": result.stderr,
116
+ "timed_out": False,
117
+ }
118
+ except subprocess.TimeoutExpired:
119
+ return {
120
+ "returncode": -1,
121
+ "stdout": "",
122
+ "stderr": "Command timed out",
123
+ "timed_out": True,
124
+ }
125
+ except Exception as exc:
126
+ return {"returncode": -1, "stdout": "", "stderr": str(exc), "timed_out": False}
127
+
128
+
129
+ @register
130
+ class UnitTestAgent(BaseAgent):
131
+ """Agent for running existing unit/integration tests."""
132
+
133
+ group = AgentGroup.TEST
134
+ domain = AgentDomain.TESTING
135
+ name = "UnitTestAgent"
136
+ description = "Run existing test suites: pytest, unittest, jest, mocha, etc."
137
+
138
+ def _run(self, inp: AgentInput, result: AgentResult) -> None:
139
+ """Run the project's existing unit test suite."""
140
+ test_runner = self._detect_runner(inp.root)
141
+ result.data["runner"] = test_runner
142
+ if not test_runner:
143
+ return
144
+
145
+ suite = (
146
+ self._run_pytest(inp.root, inp.scope)
147
+ if test_runner == "pytest"
148
+ else self._suite_from_legacy(test_runner, inp.root)
149
+ )
150
+ result.data["suite"] = suite.to_dict()
151
+ result.files_scanned = suite.total
152
+
153
+ record_test_run(
154
+ root=inp.root,
155
+ runner=test_runner,
156
+ cases=[c.to_dict() for c in suite.cases],
157
+ total=suite.total,
158
+ passed=suite.passed,
159
+ failed=suite.failed,
160
+ skipped=suite.skipped,
161
+ duration_ms=suite.duration_ms,
162
+ )
163
+
164
+ for case in suite.cases:
165
+ if case.passed:
166
+ continue
167
+ result.add_finding(
168
+ make_finding(
169
+ agent=self.name,
170
+ finding_type="test_failure",
171
+ severity=Severity.CRITICAL,
172
+ file=case.file or "",
173
+ line=case.line,
174
+ message=f"Test failed: {case.name}",
175
+ detail=case.error,
176
+ fix_agent="CodeFixer",
177
+ )
178
+ )
179
+ return
180
+
181
+ def _detect_runner(self, root: Path) -> str | None:
182
+ return self._detect_test_runner(root)
183
+
184
+ def _detect_test_runner(self, root: Path) -> str | None:
185
+ """Detect which test runner is used in the project."""
186
+ # Python
187
+ for req_file in ["requirements.txt", "Pipfile", "pyproject.toml"]:
188
+ req_path = root / req_file
189
+ if req_path.exists():
190
+ content = req_path.read_text().lower()
191
+ if "pytest" in content:
192
+ return "pytest"
193
+ if "unittest" in content and "test" in content:
194
+ return "unittest"
195
+ if (root / "pytest.ini").exists() or (root / "tox.ini").exists():
196
+ return "pytest"
197
+
198
+ # JS/TS
199
+ if (root / "package.json").exists():
200
+ pkg_content = (root / "package.json").read_text()
201
+ pkg_data = json.loads(pkg_content) if pkg_content.strip() else {}
202
+ scripts = pkg_data.get("scripts", {})
203
+ pkg_lower = pkg_content.lower()
204
+ if "test" in scripts or "jest" in pkg_lower:
205
+ return "jest"
206
+ if "mocha" in pkg_lower:
207
+ return "mocha"
208
+ if "jasmine" in pkg_lower:
209
+ return "jasmine"
210
+ if "ava" in pkg_lower:
211
+ return "ava"
212
+ if (root / "jest.config.js").exists() or (root / "jest.config.json").exists():
213
+ return "jest"
214
+ if (root / "mocha.opts").exists():
215
+ return "mocha"
216
+
217
+ # Go
218
+ if (root / "go.mod").exists():
219
+ return "go_test"
220
+
221
+ # Rust
222
+ if (root / "Cargo.toml").exists():
223
+ return "cargo_test"
224
+
225
+ # Java (Maven)
226
+ if (root / "pom.xml").exists():
227
+ return "maven_test"
228
+
229
+ # Java (Gradle)
230
+ if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
231
+ return "gradle_test"
232
+
233
+ # Ruby
234
+ if (root / "Gemfile").exists():
235
+ gem_content = (root / "Gemfile").read_text().lower()
236
+ if "rspec" in gem_content:
237
+ return "rspec"
238
+ return "minitest"
239
+ if (root / "Rakefile").exists() or (root / "Gemfile.lock").exists():
240
+ return "minitest"
241
+
242
+ # PHP
243
+ if (root / "composer.json").exists():
244
+ composer_content = (root / "composer.json").read_text()
245
+ if "phpunit" in composer_content.lower():
246
+ return "phpunit"
247
+
248
+ # Swift
249
+ if (root / "Package.swift").exists():
250
+ return "swift_test"
251
+
252
+ # C# / .NET
253
+ if list(root.glob("*.csproj")):
254
+ return "dotnet_test"
255
+
256
+ # Fallback: check test directories
257
+ for test_dir in ["tests", "test", "__tests__", "spec", "specs"]:
258
+ td = root / test_dir
259
+ if td.exists():
260
+ for _p in td.rglob("*.py"):
261
+ return "pytest"
262
+ for _p in td.rglob("*.js"):
263
+ return "jest"
264
+ for _p in td.rglob("*.ts"):
265
+ return "jest"
266
+ for _p in td.rglob("*_test.go"):
267
+ return "go_test"
268
+ for _p in td.rglob("*_spec.rb"):
269
+ return "rspec"
270
+ for _p in td.rglob("*Test.php"):
271
+ return "phpunit"
272
+
273
+ return None
274
+
275
+ def _run_tests(self, test_runner: str, root: Path) -> dict:
276
+ """Run tests using the detected test runner (legacy dict-based)."""
277
+ runner_map: dict[str, callable] = {
278
+ "pytest": self._run_pytest,
279
+ "unittest": self._run_unittest,
280
+ "jest": self._run_jest,
281
+ "mocha": self._run_mocha,
282
+ "go_test": self._run_go_test,
283
+ "cargo_test": self._run_cargo_test,
284
+ "maven_test": self._run_maven_test,
285
+ "gradle_test": self._run_gradle_test,
286
+ "rspec": self._run_rspec,
287
+ "minitest": self._run_minitest,
288
+ "phpunit": self._run_phpunit,
289
+ "swift_test": self._run_swift_test,
290
+ "dotnet_test": self._run_dotnet_test,
291
+ "jasmine": self._run_jasmine,
292
+ "ava": self._run_ava,
293
+ }
294
+ fn = runner_map.get(test_runner)
295
+ if fn is None:
296
+ return {"success": False, "error": f"Unsupported test runner: {test_runner}"}
297
+ try:
298
+ suite = fn(root)
299
+ return {
300
+ "passed": suite.passed,
301
+ "failed": suite.failed,
302
+ "skipped": suite.skipped,
303
+ "errors": suite.errors,
304
+ "total": suite.total,
305
+ "success": suite.success,
306
+ "test_details": [c.to_dict() for c in suite.cases],
307
+ }
308
+ except Exception as e:
309
+ return {
310
+ "success": False,
311
+ "error": str(e),
312
+ "total": 0,
313
+ "passed": 0,
314
+ "failed": 0,
315
+ "skipped": 0,
316
+ "test_details": [],
317
+ }
318
+
319
+ def _run_pytest(self, root: Path, scope: list[str] | None = None) -> TestSuite:
320
+ """Run pytest tests."""
321
+ targets = self._find_test_files(root, scope or [])
322
+ cmd = [sys.executable, "-m", "pytest", "-q", "--tb=short"]
323
+ cmd.extend(targets or ["tests"])
324
+ proc = _run(cmd, root, timeout=120)
325
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
326
+ suite = self._parse_pytest_stdout(output, 0)
327
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0 and suite.errors == 0
328
+ return suite
329
+
330
+ def _suite_from_legacy(self, runner: str, root: Path) -> TestSuite:
331
+ data = self._run_tests(runner, root)
332
+ return TestSuite(
333
+ runner=runner,
334
+ passed=data.get("passed", 0),
335
+ failed=data.get("failed", 0),
336
+ skipped=data.get("skipped", 0),
337
+ errors=data.get("errors", 0),
338
+ success=data.get("success", False),
339
+ )
340
+
341
+ _SKIP_DIRS = DEFAULT_IGNORE_DIRS | {".tox"}
342
+
343
+ def _find_test_files(self, root: Path, scope: list[str]) -> list[str]:
344
+ if not scope:
345
+ results = []
346
+ for p in root.rglob("test_*.py"):
347
+ if any(part in self._SKIP_DIRS for part in p.parts):
348
+ continue
349
+ results.append(p.relative_to(root).as_posix())
350
+ return sorted(results)
351
+ found: list[str] = []
352
+ for source in scope:
353
+ stem = Path(source).stem
354
+ candidates = [
355
+ root / "tests" / f"test_{stem}.py",
356
+ root / "test" / f"test_{stem}.py",
357
+ ]
358
+ for candidate in candidates:
359
+ if candidate.exists():
360
+ found.append(candidate.relative_to(root).as_posix())
361
+ return sorted(set(found))
362
+
363
+ def _parse_pytest_stdout(self, stdout: str, duration_ms: int) -> TestSuite:
364
+ passed = failed = skipped = errors = 0
365
+ for line in stdout.splitlines():
366
+ if " passed" in line or " failed" in line or " skipped" in line or " error" in line:
367
+ passed = (
368
+ int(re.search(r"(\d+) passed", line).group(1))
369
+ if re.search(r"(\d+) passed", line)
370
+ else passed
371
+ )
372
+ failed = (
373
+ int(re.search(r"(\d+) failed", line).group(1))
374
+ if re.search(r"(\d+) failed", line)
375
+ else failed
376
+ )
377
+ skipped = (
378
+ int(re.search(r"(\d+) skipped", line).group(1))
379
+ if re.search(r"(\d+) skipped", line)
380
+ else skipped
381
+ )
382
+ errors = (
383
+ int(re.search(r"(\d+) error", line).group(1))
384
+ if re.search(r"(\d+) error", line)
385
+ else errors
386
+ )
387
+ cases: list[TestCase] = []
388
+ for line in stdout.splitlines():
389
+ if line.startswith("FAILED "):
390
+ name = line.split()[1] if len(line.split()) > 1 else line
391
+ file = name.split("::", 1)[0]
392
+ cases.append(TestCase(name=name, passed=False, error=line, file=file))
393
+ return TestSuite(
394
+ runner="pytest",
395
+ passed=passed,
396
+ failed=failed,
397
+ skipped=skipped,
398
+ errors=errors,
399
+ duration_ms=duration_ms,
400
+ cases=cases,
401
+ )
402
+
403
+ def _parse_pytest_output(self, output: str) -> dict:
404
+ """Parse pytest output to extract test results."""
405
+ # Placeholder implementation - in a real implementation, we'd properly parse the output
406
+ lines = output.split("\n")
407
+ total = 0
408
+ passed = 0
409
+ failed = 0
410
+ skipped = 0
411
+
412
+ for line in lines:
413
+ if "passed" in line and "failed" not in line:
414
+ # Count passed tests in the summary line
415
+ import re
416
+
417
+ match = re.search(r"(\d+) passed", line)
418
+ if match:
419
+ passed = int(match.group(1))
420
+ elif "failed" in line:
421
+ import re
422
+
423
+ match = re.search(r"(\d+) failed", line)
424
+ if match:
425
+ failed = int(match.group(1))
426
+ elif "skipped" in line:
427
+ import re
428
+
429
+ match = re.search(r"(\d+) skipped", line)
430
+ if match:
431
+ skipped = int(match.group(1))
432
+
433
+ total = passed + failed + skipped
434
+
435
+ return {
436
+ "success": failed == 0,
437
+ "total": total,
438
+ "passed": passed,
439
+ "failed": failed,
440
+ "skipped": skipped,
441
+ "test_details": [],
442
+ }
443
+
444
+ # ── Go test ────────────────────────────────────────────────────────────
445
+
446
+ def _run_go_test(self, root: Path) -> TestSuite:
447
+ cmd = ["go", "test", "./...", "-json", "-count=1"]
448
+ proc = _run(cmd, root, timeout=180)
449
+ output = proc.get("stdout", "")
450
+ suite = self._parse_go_test_json(output)
451
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
452
+ return suite
453
+
454
+ def _parse_go_test_json(self, stdout: str) -> TestSuite:
455
+ suite = TestSuite(runner="go_test")
456
+ for line in stdout.strip().splitlines():
457
+ try:
458
+ rec = json.loads(line)
459
+ except (json.JSONDecodeError, ValueError):
460
+ continue
461
+ action = rec.get("Action", "")
462
+ test_name = rec.get("Test", "")
463
+ if not test_name:
464
+ continue
465
+ if action == "pass":
466
+ suite.passed += 1
467
+ suite.cases.append(TestCase(name=test_name, passed=True))
468
+ elif action == "fail":
469
+ suite.failed += 1
470
+ suite.cases.append(
471
+ TestCase(name=test_name, passed=False, error=rec.get("Output", ""))
472
+ )
473
+ elif action == "skip":
474
+ suite.skipped += 1
475
+ suite.total = suite.passed + suite.failed + suite.skipped
476
+ suite.success = suite.failed == 0
477
+ return suite
478
+
479
+ # ── Cargo test ──────────────────────────────────────────────────────────
480
+
481
+ def _run_cargo_test(self, root: Path) -> TestSuite:
482
+ cmd = ["cargo", "test", "--no-fail-fast"]
483
+ proc = _run(cmd, root, timeout=300)
484
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
485
+ suite = self._parse_cargo_test_output(output)
486
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
487
+ return suite
488
+
489
+ def _parse_cargo_test_output(self, output: str) -> TestSuite:
490
+ suite = TestSuite(runner="cargo_test")
491
+ pat = re.compile(r"^(test\s+\S+)\s+\.\.\.\s+(ok|FAILED|ignored)")
492
+ for line in output.splitlines():
493
+ m = pat.match(line)
494
+ if m:
495
+ name, status = m.group(1), m.group(2)
496
+ if status == "ok":
497
+ suite.passed += 1
498
+ suite.cases.append(TestCase(name=name, passed=True))
499
+ elif status == "FAILED":
500
+ suite.failed += 1
501
+ suite.cases.append(TestCase(name=name, passed=False))
502
+ elif status == "ignored":
503
+ suite.skipped += 1
504
+ suite.total = suite.passed + suite.failed + suite.skipped
505
+ suite.success = suite.failed == 0
506
+ return suite
507
+
508
+ # ── Maven test ──────────────────────────────────────────────────────────
509
+
510
+ def _run_maven_test(self, root: Path) -> TestSuite:
511
+ cmd = ["mvn", "test", "--batch-mode"]
512
+ proc = _run(cmd, root, timeout=300)
513
+ output = proc.get("stdout", "")
514
+ suite = self._parse_junit_output(output)
515
+ suite.runner = "maven_test"
516
+ return suite
517
+
518
+ # ── Gradle test ─────────────────────────────────────────────────────────
519
+
520
+ def _run_gradle_test(self, root: Path) -> TestSuite:
521
+ gradlew = root / "gradlew"
522
+ cmd = [str(gradlew) if gradlew.exists() else "gradle", "test"]
523
+ proc = _run(cmd, root, timeout=300)
524
+ output = proc.get("stdout", "")
525
+ suite = self._parse_junit_output(output)
526
+ suite.runner = "gradle_test"
527
+ return suite
528
+
529
+ # ── Shared JUnit XML parser ─────────────────────────────────────────────
530
+
531
+ def _parse_junit_output(self, output: str) -> TestSuite:
532
+ """Parse JUnit-style test output (Maven, Gradle, PHPUnit)."""
533
+ suite = TestSuite(runner="junit")
534
+ for line in output.splitlines():
535
+ m = re.search(r"Tests run:\s*(\d+)", line)
536
+ if m:
537
+ suite.total = int(m.group(1))
538
+ m = re.search(r"Failures:\s*(\d+)", line)
539
+ if m:
540
+ suite.failed = int(m.group(1))
541
+ m = re.search(r"Errors:\s*(\d+)", line)
542
+ if m:
543
+ suite.errors = int(m.group(1))
544
+ m = re.search(r"Skipped:\s*(\d+)", line)
545
+ if m:
546
+ suite.skipped = int(m.group(1))
547
+ if "BUILD SUCCESS" in line:
548
+ suite.success = True
549
+ elif "BUILD FAILURE" in line:
550
+ suite.success = False
551
+ suite.passed = suite.total - suite.failed - suite.errors - suite.skipped
552
+ if suite.total == 0:
553
+ suite.success = True
554
+ return suite
555
+
556
+ # ── RSpec ───────────────────────────────────────────────────────────────
557
+
558
+ def _run_rspec(self, root: Path) -> TestSuite:
559
+ cmd = ["bundle", "exec", "rspec", "--format", "json"]
560
+ proc = _run(cmd, root, timeout=180)
561
+ output = proc.get("stdout", "")
562
+ suite = self._parse_rspec_json(output)
563
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
564
+ return suite
565
+
566
+ def _parse_rspec_json(self, stdout: str) -> TestSuite:
567
+ suite = TestSuite(runner="rspec")
568
+ try:
569
+ data = json.loads(stdout)
570
+ except (json.JSONDecodeError, ValueError):
571
+ return suite
572
+ for example in data.get("examples", []):
573
+ name = example.get("full_description", "")
574
+ status = example.get("status", "passed")
575
+ if status == "passed":
576
+ suite.passed += 1
577
+ suite.cases.append(TestCase(name=name, passed=True))
578
+ elif status == "pending":
579
+ suite.skipped += 1
580
+ suite.cases.append(TestCase(name=name, skipped=True))
581
+ else:
582
+ suite.failed += 1
583
+ exc = example.get("exception", {})
584
+ suite.cases.append(TestCase(name=name, passed=False, error=exc.get("message", "")))
585
+ suite.total = suite.passed + suite.failed + suite.skipped
586
+ suite.success = suite.failed == 0
587
+ return suite
588
+
589
+ # ── Minitest ────────────────────────────────────────────────────────────
590
+
591
+ def _run_minitest(self, root: Path) -> TestSuite:
592
+ test_files = list(root.rglob("*_test.rb"))
593
+ if not test_files:
594
+ test_files = list(root.rglob("test_*.rb"))
595
+ if not test_files:
596
+ test_dir = root / "test"
597
+ if test_dir.exists():
598
+ test_files = list(test_dir.rglob("*.rb"))
599
+ if not test_files:
600
+ return TestSuite(runner="minitest", success=True)
601
+ cmd = ["ruby", "-Ilib:test", *[str(t) for t in test_files]]
602
+ proc = _run(cmd, root, timeout=120)
603
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
604
+ suite = TestSuite(runner="minitest")
605
+ for line in output.splitlines():
606
+ m = re.search(
607
+ r"(\d+)\s+runs.*(\d+)\s+assertions.*(\d+)\s+failures.*(\d+)\s+errors", line
608
+ )
609
+ if m:
610
+ suite.total = int(m.group(1))
611
+ suite.failed = int(m.group(3)) + int(m.group(4))
612
+ suite.passed = suite.total - suite.failed
613
+ break
614
+ suite.success = suite.failed == 0
615
+ return suite
616
+
617
+ # ── PHPUnit ─────────────────────────────────────────────────────────────
618
+
619
+ def _run_phpunit(self, root: Path) -> TestSuite:
620
+ phpunit = root / "vendor" / "bin" / "phpunit"
621
+ if not phpunit.exists():
622
+ return TestSuite(
623
+ runner="phpunit",
624
+ success=True,
625
+ errors=1,
626
+ cases=[TestCase(name="phpunit not found at vendor/bin/phpunit", passed=False)],
627
+ )
628
+ cmd = [str(phpunit), "--log-junit", str(root / ".patchi" / "phpunit.xml")]
629
+ proc = _run(cmd, root, timeout=180)
630
+ junit_file = root / ".patchi" / "phpunit.xml"
631
+ if junit_file.exists():
632
+ suite = self._parse_junit_xml(junit_file.read_text(encoding="utf-8", errors="ignore"))
633
+ else:
634
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
635
+ suite = self._parse_phpunit_text_output(output)
636
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
637
+ suite.runner = "phpunit"
638
+ return suite
639
+
640
+ def _parse_junit_xml(self, xml_content: str) -> TestSuite:
641
+ suite = TestSuite(runner="phpunit")
642
+ try:
643
+ import xml.etree.ElementTree as ET
644
+
645
+ root = ET.fromstring(xml_content)
646
+ for ts in root.findall(".//testsuite"):
647
+ try:
648
+ suite.total += int(ts.get("tests", 0))
649
+ suite.failed += int(ts.get("failures", 0))
650
+ suite.errors += int(ts.get("errors", 0))
651
+ suite.skipped += int(ts.get("skipped", 0))
652
+ except (ValueError, TypeError):
653
+ pass
654
+ for tc in ts.findall("testcase"):
655
+ name = tc.get("name", "")
656
+ cls = tc.get("classname", "")
657
+ full_name = f"{cls}.{name}" if cls else name
658
+ failure = tc.find("failure")
659
+ error = tc.find("error")
660
+ if failure is not None or error is not None:
661
+ msg = ""
662
+ if failure is not None:
663
+ msg = failure.get("message", "") or str(failure.text or "")
664
+ elif error is not None:
665
+ msg = error.get("message", "") or str(error.text or "")
666
+ suite.cases.append(TestCase(name=full_name, passed=False, error=msg))
667
+ else:
668
+ suite.cases.append(TestCase(name=full_name, passed=True))
669
+ except Exception as e:
670
+ _log.warning("UnitTestAgent._parse_junit_xml failed: %s", e)
671
+ suite.passed = suite.total - suite.failed - suite.errors - suite.skipped
672
+ suite.success = suite.failed == 0 and suite.errors == 0
673
+ return suite
674
+
675
+ def _parse_phpunit_text_output(self, output: str) -> TestSuite:
676
+ suite = TestSuite(runner="phpunit")
677
+ for line in output.splitlines():
678
+ m = re.search(r"OK\s*\((\d+)\s+tests?", line)
679
+ if m:
680
+ suite.total = int(m.group(1))
681
+ suite.passed = suite.total
682
+ suite.success = True
683
+ break
684
+ m = re.search(r"FAILURES!\s*$", line)
685
+ if m:
686
+ suite.success = False
687
+ m = re.search(r"Tests:\s*(\d+)", line)
688
+ if m:
689
+ suite.total = int(m.group(1))
690
+ m = re.search(r"Failures:\s*(\d+)", line)
691
+ if m:
692
+ suite.failed = int(m.group(1))
693
+ if suite.total == 0:
694
+ suite.total = 1
695
+ suite.passed = suite.total - suite.failed
696
+ return suite
697
+
698
+ # ── Swift test ──────────────────────────────────────────────────────────
699
+
700
+ def _run_swift_test(self, root: Path) -> TestSuite:
701
+ cmd = ["swift", "test"]
702
+ proc = _run(cmd, root, timeout=300)
703
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
704
+ suite = self._parse_swift_test_output(output)
705
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
706
+ return suite
707
+
708
+ def _parse_swift_test_output(self, output: str) -> TestSuite:
709
+ suite = TestSuite(runner="swift_test")
710
+ pass_pat = re.compile(r"^\s*Test\s+case\s+'.+'\s+passed")
711
+ fail_pat = re.compile(r"^\s*Test\s+case\s+'.+'\s+failed")
712
+ for line in output.splitlines():
713
+ if pass_pat.match(line):
714
+ suite.passed += 1
715
+ suite.cases.append(TestCase(name=line.strip(), passed=True))
716
+ elif fail_pat.match(line):
717
+ suite.failed += 1
718
+ suite.cases.append(TestCase(name=line.strip(), passed=False))
719
+ suite.total = suite.passed + suite.failed
720
+ suite.success = suite.failed == 0
721
+ return suite
722
+
723
+ # ── dotnet test ─────────────────────────────────────────────────────────
724
+
725
+ def _run_dotnet_test(self, root: Path) -> TestSuite:
726
+ cmd = ["dotnet", "test"]
727
+ proc = _run(cmd, root, timeout=300)
728
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
729
+ suite = self._parse_dotnet_test_output(output)
730
+ suite.success = proc.get("returncode", 1) == 0 and suite.failed == 0
731
+ return suite
732
+
733
+ def _parse_dotnet_test_output(self, output: str) -> TestSuite:
734
+ suite = TestSuite(runner="dotnet_test")
735
+ pass_pat = re.compile(r"^\s*Passed\s")
736
+ fail_pat = re.compile(r"^\s*Failed\s")
737
+ summary_pat = re.compile(r"^\s*Total:\s*(\d+)")
738
+ for line in output.splitlines():
739
+ if pass_pat.match(line):
740
+ suite.passed += 1
741
+ suite.cases.append(TestCase(name=line.strip(), passed=True))
742
+ elif fail_pat.match(line):
743
+ suite.failed += 1
744
+ suite.cases.append(TestCase(name=line.strip(), passed=False))
745
+ m = summary_pat.search(line)
746
+ if m:
747
+ suite.total = int(m.group(1))
748
+ if suite.total == 0:
749
+ suite.total = suite.passed + suite.failed
750
+ suite.success = suite.failed == 0
751
+ return suite
752
+
753
+ def _run_jasmine(self, root: Path) -> TestSuite:
754
+ cmd = ["npx", "jasmine", "--no-color"]
755
+ proc = _run(cmd, root, timeout=120)
756
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
757
+ suite = TestSuite(runner="jasmine")
758
+ total_pat = re.compile(r"^(\d+)\s+spec")
759
+ fail_pat = re.compile(r"^(\d+)\s+failure")
760
+ for line in output.splitlines():
761
+ m = total_pat.search(line)
762
+ if m:
763
+ suite.total = int(m.group(1))
764
+ m = fail_pat.search(line)
765
+ if m:
766
+ suite.failed = int(m.group(1))
767
+ suite.passed = suite.total - suite.failed - suite.skipped
768
+ if suite.passed < 0:
769
+ suite.passed = 0
770
+ suite.success = suite.failed == 0
771
+ return suite
772
+
773
+ def _run_ava(self, root: Path) -> TestSuite:
774
+ cmd = ["npx", "ava", "--tap"]
775
+ proc = _run(cmd, root, timeout=120)
776
+ output = f"{proc.get('stdout', '')}\n{proc.get('stderr', '')}"
777
+ suite = TestSuite(runner="ava")
778
+ pass_pat = re.compile(r"^ok\s+\d+")
779
+ fail_pat = re.compile(r"^not ok\s+\d+")
780
+ skip_pat = re.compile(r"^ok\s+\d+\s+#\s*SKIP")
781
+ for line in output.splitlines():
782
+ if skip_pat.match(line):
783
+ suite.skipped += 1
784
+ elif pass_pat.match(line):
785
+ suite.passed += 1
786
+ elif fail_pat.match(line):
787
+ suite.failed += 1
788
+ suite.total = suite.passed + suite.failed + suite.skipped
789
+ suite.success = suite.failed == 0
790
+ return suite
791
+
792
+ def _run_unittest(self, root: Path) -> dict:
793
+ """Run unittest tests."""
794
+ try:
795
+ cmd = [sys.executable, "-m", "unittest", "discover", "-s", ".", "-v"]
796
+ result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=120)
797
+
798
+ output = result.stdout + result.stderr
799
+ return self._parse_unittest_output(output)
800
+ except subprocess.TimeoutExpired:
801
+ return {
802
+ "success": False,
803
+ "error": "Test run timed out after 120 seconds",
804
+ "total": 0,
805
+ "passed": 0,
806
+ "failed": 0,
807
+ "skipped": 0,
808
+ "test_details": [],
809
+ }
810
+ except Exception as e:
811
+ return {
812
+ "success": False,
813
+ "error": f"Error running unittest: {str(e)}",
814
+ "total": 0,
815
+ "passed": 0,
816
+ "failed": 0,
817
+ "skipped": 0,
818
+ "test_details": [],
819
+ }
820
+
821
+ def _parse_unittest_output(self, output: str) -> dict:
822
+ """Parse unittest output to extract test results."""
823
+ # Placeholder implementation
824
+ lines = output.split("\n")
825
+ total = 0
826
+ passed = 0
827
+ failed = 0
828
+ skipped = 0
829
+
830
+ for line in lines:
831
+ if "ok" in line and "test" in line.lower():
832
+ passed += 1
833
+ elif "FAIL:" in line:
834
+ failed += 1
835
+ elif "skipped" in line.lower():
836
+ skipped += 1
837
+
838
+ total = passed + failed + skipped or 1 # Default to 1 if parsing failed
839
+
840
+ return {
841
+ "success": failed == 0,
842
+ "total": total,
843
+ "passed": passed,
844
+ "failed": failed,
845
+ "skipped": skipped,
846
+ "test_details": [],
847
+ }
848
+
849
+ def _run_jest(self, root: Path) -> dict:
850
+ """Run Jest tests."""
851
+ try:
852
+ cmd = ["npx", "jest", "--json", "--silent"]
853
+ result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=120)
854
+
855
+ if (
856
+ result.returncode == 0 or result.returncode == 1
857
+ ): # 1 means tests failed but command succeeded
858
+ try:
859
+ # Jest outputs JSON to stderr
860
+ output_json = json.loads(result.stderr or result.stdout)
861
+ return self._parse_jest_output(output_json)
862
+ except json.JSONDecodeError:
863
+ # Fallback to parsing text output
864
+ output = result.stdout + result.stderr
865
+ return self._parse_jest_text_output(output)
866
+ else:
867
+ return {
868
+ "success": False,
869
+ "error": f"Jest command failed: {result.stderr}",
870
+ "total": 0,
871
+ "passed": 0,
872
+ "failed": 0,
873
+ "skipped": 0,
874
+ "test_details": [],
875
+ }
876
+ except subprocess.TimeoutExpired:
877
+ return {
878
+ "success": False,
879
+ "error": "Test run timed out after 120 seconds",
880
+ "total": 0,
881
+ "passed": 0,
882
+ "failed": 0,
883
+ "skipped": 0,
884
+ "test_details": [],
885
+ }
886
+ except FileNotFoundError:
887
+ # npx not found, try with globally installed jest
888
+ try:
889
+ cmd = ["jest", "--json", "--silent"]
890
+ result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=120)
891
+
892
+ if result.returncode == 0 or result.returncode == 1:
893
+ try:
894
+ output_json = json.loads(result.stderr or result.stdout)
895
+ return self._parse_jest_output(output_json)
896
+ except json.JSONDecodeError:
897
+ output = result.stdout + result.stderr
898
+ return self._parse_jest_text_output(output)
899
+ else:
900
+ return {
901
+ "success": False,
902
+ "error": f"Jest command failed: {result.stderr}",
903
+ "total": 0,
904
+ "passed": 0,
905
+ "failed": 0,
906
+ "skipped": 0,
907
+ "test_details": [],
908
+ }
909
+ except Exception as e:
910
+ return {
911
+ "success": False,
912
+ "error": f"Error running Jest: {str(e)}",
913
+ "total": 0,
914
+ "passed": 0,
915
+ "failed": 0,
916
+ "skipped": 0,
917
+ "test_details": [],
918
+ }
919
+ except Exception as e:
920
+ return {
921
+ "success": False,
922
+ "error": f"Error running Jest: {str(e)}",
923
+ "total": 0,
924
+ "passed": 0,
925
+ "failed": 0,
926
+ "skipped": 0,
927
+ "test_details": [],
928
+ }
929
+
930
+ def _parse_jest_output(self, output_json: dict) -> dict:
931
+ """Parse Jest JSON output."""
932
+ num_total_tests = output_json.get("numTotalTests", 0)
933
+ num_passed_tests = output_json.get("numPassedTests", 0)
934
+ num_failed_tests = output_json.get("numFailedTests", 0)
935
+ num_pending_tests = output_json.get("numPendingTests", 0)
936
+
937
+ test_details = []
938
+ for test_result in output_json.get("testResults", []):
939
+ for assertion_result in test_result.get("assertionResults", []):
940
+ status = assertion_result.get("status", "unknown")
941
+ error = None
942
+ if "failureMessages" in assertion_result:
943
+ error = "; ".join(assertion_result["failureMessages"])
944
+
945
+ test_details.append(
946
+ {
947
+ "name": assertion_result.get("fullName", "unknown"),
948
+ "status": status.upper(),
949
+ "file": test_result.get("name", "__unknown__"),
950
+ "error": error,
951
+ }
952
+ )
953
+
954
+ return {
955
+ "success": num_failed_tests == 0,
956
+ "total": num_total_tests,
957
+ "passed": num_passed_tests,
958
+ "failed": num_failed_tests,
959
+ "skipped": num_pending_tests,
960
+ "test_details": test_details,
961
+ }
962
+
963
+ def _parse_jest_text_output(self, output: str) -> dict:
964
+ """Parse Jest text output as fallback."""
965
+ # Placeholder implementation
966
+ lines = output.split("\n")
967
+ total = 0
968
+ passed = 0
969
+ failed = 0
970
+ skipped = 0
971
+
972
+ for line in lines:
973
+ if "PASS" in line:
974
+ passed += 1
975
+ elif "FAIL" in line:
976
+ failed += 1
977
+ elif "SKIP" in line or "SKIPPED" in line:
978
+ skipped += 1
979
+
980
+ total = passed + failed + skipped or 1
981
+
982
+ return {
983
+ "success": failed == 0,
984
+ "total": total,
985
+ "passed": passed,
986
+ "failed": failed,
987
+ "skipped": skipped,
988
+ "test_details": [],
989
+ }
990
+
991
+ def _run_mocha(self, root: Path) -> dict:
992
+ """Run Mocha tests."""
993
+ try:
994
+ cmd = ["npx", "mocha", "--reporter", "json"]
995
+ result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=120)
996
+
997
+ if (
998
+ result.returncode == 0 or result.returncode == 1
999
+ ): # 1 means tests failed but command succeeded
1000
+ try:
1001
+ output_json = json.loads(result.stdout)
1002
+ return self._parse_mocha_output(output_json)
1003
+ except json.JSONDecodeError:
1004
+ # Fallback to text parsing
1005
+ output = result.stdout + result.stderr
1006
+ return self._parse_mocha_text_output(output)
1007
+ else:
1008
+ return {
1009
+ "success": False,
1010
+ "error": f"Mocha command failed: {result.stderr}",
1011
+ "total": 0,
1012
+ "passed": 0,
1013
+ "failed": 0,
1014
+ "skipped": 0,
1015
+ "test_details": [],
1016
+ }
1017
+ except subprocess.TimeoutExpired:
1018
+ return {
1019
+ "success": False,
1020
+ "error": "Test run timed out after 120 seconds",
1021
+ "total": 0,
1022
+ "passed": 0,
1023
+ "failed": 0,
1024
+ "skipped": 0,
1025
+ "test_details": [],
1026
+ }
1027
+ except FileNotFoundError:
1028
+ # Try with globally installed mocha
1029
+ try:
1030
+ cmd = ["mocha", "--reporter", "json"]
1031
+ result = subprocess.run(cmd, cwd=root, capture_output=True, text=True, timeout=120)
1032
+
1033
+ if result.returncode == 0 or result.returncode == 1:
1034
+ try:
1035
+ output_json = json.loads(result.stdout)
1036
+ return self._parse_mocha_output(output_json)
1037
+ except json.JSONDecodeError:
1038
+ output = result.stdout + result.stderr
1039
+ return self._parse_mocha_text_output(output)
1040
+ else:
1041
+ return {
1042
+ "success": False,
1043
+ "error": f"Mocha command failed: {result.stderr}",
1044
+ "total": 0,
1045
+ "passed": 0,
1046
+ "failed": 0,
1047
+ "skipped": 0,
1048
+ "test_details": [],
1049
+ }
1050
+ except Exception as e:
1051
+ return {
1052
+ "success": False,
1053
+ "error": f"Error running Mocha: {str(e)}",
1054
+ "total": 0,
1055
+ "passed": 0,
1056
+ "failed": 0,
1057
+ "skipped": 0,
1058
+ "test_details": [],
1059
+ }
1060
+ except Exception as e:
1061
+ return {
1062
+ "success": False,
1063
+ "error": f"Error running Mocha: {str(e)}",
1064
+ "total": 0,
1065
+ "passed": 0,
1066
+ "failed": 0,
1067
+ "skipped": 0,
1068
+ "test_details": [],
1069
+ }
1070
+
1071
+ def _parse_mocha_output(self, output_json: dict) -> dict:
1072
+ """Parse Mocha JSON output."""
1073
+ stats = output_json.get("stats", {})
1074
+ total = stats.get("tests", 0)
1075
+ passed = stats.get("passes", 0)
1076
+ failed = stats.get("failures", 0)
1077
+ stats.get("duration", 0) / 1000 # Convert ms to seconds
1078
+
1079
+ test_details = []
1080
+ for test in output_json.get("passes", []):
1081
+ test_details.append(
1082
+ {
1083
+ "name": test.get("fullTitle", "unknown"),
1084
+ "status": "PASSED",
1085
+ "file": test.get("file", "__unknown__"),
1086
+ "duration": test.get("duration", 0) / 1000, # Convert ms to seconds
1087
+ }
1088
+ )
1089
+
1090
+ for test in output_json.get("failures", []):
1091
+ test_details.append(
1092
+ {
1093
+ "name": test.get("fullTitle", "unknown"),
1094
+ "status": "FAILED",
1095
+ "file": test.get("file", "__unknown__"),
1096
+ "error": test.get("err", {}).get("message", "Unknown error"),
1097
+ "stack": test.get("err", {}).get("stack", ""),
1098
+ }
1099
+ )
1100
+
1101
+ return {
1102
+ "success": failed == 0,
1103
+ "total": total,
1104
+ "passed": passed,
1105
+ "failed": failed,
1106
+ "skipped": total - passed - failed, # Mocha doesn't directly report skipped in JSON
1107
+ "test_details": test_details,
1108
+ }
1109
+
1110
+ def _parse_mocha_text_output(self, output: str) -> dict:
1111
+ """Parse Mocha text output as fallback."""
1112
+ # Placeholder implementation
1113
+ lines = output.split("\n")
1114
+ total = 0
1115
+ passed = 0
1116
+ failed = 0
1117
+ skipped = 0
1118
+
1119
+ for line in lines:
1120
+ if "passing" in line.lower():
1121
+ # Count from lines like "3 passing (2s)"
1122
+ import re
1123
+
1124
+ match = re.search(r"(\d+)\s+passing", line)
1125
+ if match:
1126
+ passed = int(match.group(1))
1127
+ elif "failing" in line.lower():
1128
+ import re
1129
+
1130
+ match = re.search(r"(\d+)\s+failing", line)
1131
+ if match:
1132
+ failed = int(match.group(1))
1133
+ elif "pending" in line.lower() or "skipping" in line.lower():
1134
+ import re
1135
+
1136
+ match = re.search(r"(\d+)\s+(pending|skipping)", line)
1137
+ if match:
1138
+ skipped = int(match.group(1))
1139
+
1140
+ total = passed + failed + skipped or 1
1141
+
1142
+ return {
1143
+ "success": failed == 0,
1144
+ "total": total,
1145
+ "passed": passed,
1146
+ "failed": failed,
1147
+ "skipped": skipped,
1148
+ "test_details": [],
1149
+ }