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,1052 @@
1
+ """
2
+ `p test` — Run tests, generate suites, view history, manage config.
3
+
4
+ Usage:
5
+ p test — run default tests (unit + regression)
6
+ p test unit — unit tests only
7
+ p test smoke — smoke tests (buttons + layout + a11y)
8
+ p test full — all test agents
9
+ p test <type> [area] — specific test type, optional scope
10
+
11
+ p test generate — AI generates a full test suite
12
+ p test generate unit — AI generates unit tests only
13
+ p test generate e2e — AI generates E2E tests only
14
+
15
+ p test report — show test run history
16
+ p test report --last 10 — show last 10 runs
17
+
18
+ p test config show — show current test configuration
19
+ p test config set <key> <value> — update a config value
20
+ p test config flows add — add a custom test flow (interactive)
21
+ p test config flows remove <n> — remove a custom test flow
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import logging
28
+ import time
29
+ from pathlib import Path
30
+
31
+ from rich.panel import Panel
32
+ from rich.table import Table
33
+ from rich.text import Text
34
+
35
+ from patchi.cli.console import con
36
+ from patchi.cli.display.live_progress import LiveProgress
37
+ from patchi.core import memory as mem
38
+ from patchi.core.config import require_project_root
39
+
40
+ _MAPS_DIR = Path(__file__).resolve().parent
41
+
42
+
43
+ _log = logging.getLogger("patchi.cli.test_cmd")
44
+
45
+
46
+ def _load_test_type_map() -> dict[str, list[str]]:
47
+ """Load test type mapping from external JSON config."""
48
+ maps_file = _MAPS_DIR / "agent_maps.json"
49
+ try:
50
+ data = json.loads(maps_file.read_text(encoding="utf-8"))
51
+ return data["test"]["test_types"]
52
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
53
+ con.print(f"[yellow]Warning: could not load agent_maps.json: {e}[/yellow]")
54
+ return {}
55
+
56
+
57
+ TEST_TYPE_MAP = _load_test_type_map()
58
+ VALID_TYPES = sorted(TEST_TYPE_MAP.keys())
59
+
60
+
61
+ def run(
62
+ test_type: str | None = None,
63
+ area: str | None = None,
64
+ root: Path | None = None,
65
+ action: str | None = None,
66
+ config_action: str | None = None,
67
+ key: str | None = None,
68
+ value: str | None = None,
69
+ last_n: int = 20,
70
+ attack: bool = False,
71
+ ) -> None:
72
+ """Entry point for `p test [type] [area]` and subcommands."""
73
+ # Route to subcommands
74
+ if action == "generate":
75
+ run_generate(test_type=test_type, root=root)
76
+ return
77
+ if action == "report":
78
+ run_report(last_n=last_n, root=root)
79
+ return
80
+ if action == "config":
81
+ run_config(action=config_action, key=key, value=value, root=root)
82
+ return
83
+
84
+ # ── Attack mode ─────────────────────────────────────────────────────────
85
+ if attack:
86
+ _run_attack(root)
87
+ return
88
+
89
+ try:
90
+ r = root or require_project_root()
91
+ except RuntimeError as e:
92
+ con.print(f"[red]{e}[/red]")
93
+ return
94
+
95
+ # Import all test agents (triggers registration)
96
+ _import_all_agents()
97
+
98
+ from patchi.core import config as cfg
99
+ from patchi.core.agents.base import AgentGroup, AgentInput, list_agents
100
+
101
+ try:
102
+ config = cfg.load(r)
103
+ except Exception as e:
104
+ _log.warning("run failed: %s", e)
105
+ config = {}
106
+
107
+ brain = mem.get_brain(r)
108
+ scope = _scope_from_area(r, area)
109
+
110
+ # Determine which agents to run
111
+ all_test_agents = list_agents(AgentGroup.TEST)
112
+ agent_map = {a.name: a for a in all_test_agents}
113
+
114
+ if test_type is None:
115
+ # Default: unit + regression
116
+ to_run_names = ["UnitTestAgent", "RegressionAgent"]
117
+ else:
118
+ to_run_names = TEST_TYPE_MAP.get(test_type.lower(), [])
119
+ if not to_run_names:
120
+ # Try direct agent name match
121
+ if test_type in agent_map:
122
+ to_run_names = [test_type]
123
+ else:
124
+ con.print(f"[red]Unknown test type: {test_type!r}[/red]")
125
+ con.print(f"[dim]Valid types: {' · '.join(VALID_TYPES)}[/dim]")
126
+ con.print(f"[dim]Or an agent name: {' · '.join(sorted(agent_map))}[/dim]")
127
+ return
128
+
129
+ to_run = [agent_map[n] for n in to_run_names if n in agent_map]
130
+ if not to_run:
131
+ con.print("[red]No matching test agents found.[/red]")
132
+ return
133
+
134
+ # ConsoleLoggingAgent rides along on browser-family runs (§11: auto when
135
+ # p test is active). Fail-open inside the agent if no server/playwright.
136
+ _browser_family = {
137
+ "BrowserTestAgent", "UIButtonAgent", "UILayoutAgent", "UIAccessibilityAgent",
138
+ "VisualRegressionAgent", "E2EFlowAgent", "ConsoleLoggingAgent",
139
+ }
140
+ if "ConsoleLoggingAgent" in agent_map and any(n in _browser_family for n in to_run_names):
141
+ if all(a.name != "ConsoleLoggingAgent" for a in to_run):
142
+ to_run.append(agent_map["ConsoleLoggingAgent"])
143
+ to_run_names = [*to_run_names, "ConsoleLoggingAgent"]
144
+
145
+ # Chain: Check → Run → Test/Attack (Run relies on Check green, Test relies on Run)
146
+ _launcher_url = None
147
+ needs_launcher = any(n in ("BrowserTestAgent", "UIButtonAgent", "UILayoutAgent", "UIAccessibilityAgent", "VisualRegressionAgent", "E2EFlowAgent", "ConsoleLoggingAgent") for n in to_run_names) or test_type in ("browser", "e2e", "visual", "full")
148
+ needs_run = needs_launcher or any(n in ("RedTeamAgent", "DastAgent", "ApiFuzzerAgent") for n in to_run_names)
149
+ if needs_run:
150
+ # 1. Check must be green — gate via .patchi/p_check_status.json
151
+ try:
152
+ from patchi.core.testing.gate import require_ready
153
+
154
+ ready, url, st = require_ready(r)
155
+ if not ready:
156
+ con.print(f"[yellow]P-Check not READY_TO_SERVE — running p check first[/yellow] [dim]({st.get('status') if st else 'no status'})[/dim]")
157
+ # Run P-Check side agents inline (install/build/format)
158
+ try:
159
+ from patchi.cli.commands.check_cmd import run as check_run
160
+
161
+ check_run(root=r)
162
+ ready, url, st = require_ready(r)
163
+ if not ready:
164
+ con.print(f"[red]P-Check still BLOCKED: {st.get('error') if st else 'unknown'} — aborting test (fix via p check --fix)[/red]")
165
+ return
166
+ except Exception as exc: # noqa: BLE001
167
+ _log.debug("p check inline failed: %s", exc)
168
+ except Exception as exc: # noqa: BLE001
169
+ _log.debug("gate check failed: %s", exc)
170
+ # 2. Run — start app after Check green
171
+ try:
172
+ from patchi.core.agents.base import AgentInput as _AI
173
+ from patchi.core.agents.run_agent import RunAgent
174
+
175
+ run_inp = _AI(root=r, scope=[], brain=brain, config=config, extra={})
176
+ run_res = RunAgent().run(run_inp)
177
+ if run_res.status.value == "done" and run_res.data.get("base_url"):
178
+ _launcher_url = run_res.data["base_url"]
179
+ con.print(f"[dim]RunAgent: app at {_launcher_url} — P-Check was green[/dim]")
180
+ else:
181
+ _log.debug("RunAgent %s: %s", run_res.status, run_res.data.get("gate_reason") or run_res.errors)
182
+ if run_res.data.get("gate_blocked"):
183
+ con.print(f"[red]Run blocked: {run_res.data.get('gate_reason')}[/red]")
184
+ return
185
+ # Fallback to direct launcher if RunAgent failed but Check was green
186
+ from patchi.core.testing.app_launcher import ensure_running
187
+
188
+ _launcher_url = ensure_running(r, config, {})
189
+ if _launcher_url:
190
+ con.print(f"[dim]App running at {_launcher_url} — launcher fallback[/dim]")
191
+ except Exception as exc: # noqa: BLE001
192
+ _log.debug("RunAgent failed: %s", exc)
193
+ # Fallback direct
194
+ try:
195
+ from patchi.core.testing.app_launcher import ensure_running
196
+
197
+ _launcher_url = ensure_running(r, config, {})
198
+ except Exception as _exc:
199
+ _log.debug('suppressed: %s', _exc)
200
+
201
+ con.print()
202
+ label = f" [dim]→ {area}[/dim]" if area else ""
203
+ con.print(
204
+ f"[bold #C8621A]Testing{label}[/bold #C8621A] "
205
+ f"[dim]{', '.join(a.name.replace('Agent', '') for a in to_run)}[/dim]"
206
+ )
207
+ con.print()
208
+
209
+ # Chain progress display
210
+ from patchi.cli.ux import format_step, status_icon, status_style
211
+
212
+ total_steps = len(to_run) + (2 if needs_run else 0) # +2 for check and run if needed
213
+ current_step = 0
214
+
215
+ def show_step(step_name: str, status: str = "running"):
216
+ nonlocal current_step
217
+ current_step += 1
218
+ icon = status_icon(status)
219
+ style = status_style(status)
220
+ con.print(format_step(current_step, total_steps, f"[{style}]{icon} {step_name}[/{style}]"))
221
+
222
+ # Show chain steps
223
+ if needs_run:
224
+ show_step("P-Check gate verification", "done")
225
+ show_step("RunAgent — starting app", "done")
226
+
227
+ # Run via coordinator (sequential for tests — order matters)
228
+ results = []
229
+ agent_names = ", ".join(a.name.replace("Agent", "") for a in to_run)
230
+ lp = LiveProgress(con, title=f"Tests ({agent_names})")
231
+ lp.start()
232
+ for agent_cls in to_run:
233
+ short_name = agent_cls.name.replace("Agent", "")
234
+ show_step(f"Running {short_name}")
235
+ lp.set_progress(len(results) + 1, len(to_run), short_name)
236
+ lp.log(f" {short_name}…")
237
+ lp.update()
238
+
239
+ extra = {}
240
+ if _launcher_url:
241
+ extra["base_url"] = _launcher_url
242
+ extra["live_probe"] = True
243
+ inp = AgentInput(
244
+ root=r,
245
+ scope=scope,
246
+ brain=brain,
247
+ config=config,
248
+ extra=extra,
249
+ on_message=lambda n, msg, s: (lp.log(f" {msg}", s), lp.update()),
250
+ )
251
+ result = agent_cls().run(inp)
252
+ results.append(result)
253
+
254
+ # Per-test result lines
255
+ suite = result.data.get("suite", {})
256
+ for t in suite.get("tests", []):
257
+ label = t.get("name", t.get("label", "?"))
258
+ passed = t.get("passed", t.get("status")) in (True, "passed", "ok")
259
+ icon = "[#4ADE80]v[/#4ADE80]" if passed else "[#FF4D6D]x[/#FF4D6D]"
260
+ lp.log(f" {icon} {label}", "green" if passed else "red")
261
+
262
+ lp.update()
263
+
264
+ # Save to scan results so memory tracks test runs
265
+ try:
266
+ mem.save_scan_result(
267
+ agent_cls.name,
268
+ {
269
+ "status": result.status.value,
270
+ "duration_ms": result.duration_ms,
271
+ "finding_count": result.finding_count,
272
+ **result.data,
273
+ },
274
+ r,
275
+ )
276
+ except Exception as e:
277
+ _log.warning("run failed: %s", e)
278
+
279
+ total_tests = sum(len(r.data.get("suite", {}).get("tests", [])) for r in results)
280
+ total_passed = sum(r.data.get("suite", {}).get("passed", 0) for r in results)
281
+ total_failed = sum(r.data.get("suite", {}).get("failed", 0) for r in results)
282
+ lp.stop(summary=f"{total_tests} tests - {total_passed} passed, {total_failed} failed")
283
+ # Launcher teardown
284
+ if _launcher_url:
285
+ try:
286
+ from patchi.core.testing.app_launcher import stop
287
+
288
+ stop()
289
+ con.print(f"[dim]Launcher: app at {_launcher_url} stopped[/dim]")
290
+ except Exception as exc: # noqa: BLE001
291
+ _log.debug("launcher stop failed: %s", exc)
292
+ _show_results(results)
293
+
294
+ # Merge TEST findings through the UI verification gate before memory —
295
+ # only findings whose file/line check out against the codebase are saved.
296
+ try:
297
+ from patchi.core.brain.verify import verify_ui_findings
298
+
299
+ ui_dicts: list[dict] = []
300
+ for res in results:
301
+ for f in res.findings:
302
+ ui_dicts.append(f.to_dict() if hasattr(f, "to_dict") else dict(f))
303
+ kept, dropped = verify_ui_findings(r, ui_dicts)
304
+ mem.save_scan_result(
305
+ "UI",
306
+ {
307
+ "status": "ok",
308
+ "finding_count": len(kept),
309
+ "dropped_unverifiable": len(dropped),
310
+ "findings": kept,
311
+ },
312
+ r,
313
+ )
314
+ if dropped:
315
+ con.print(
316
+ f"[dim]UI gate: kept {len(kept)}, dropped {len(dropped)} unverifiable[/dim]"
317
+ )
318
+ except Exception as e:
319
+ _log.warning("UI merge failed: %s", e)
320
+
321
+ # Save to test history
322
+ _save_to_history(results, test_type or "default", area, r)
323
+
324
+
325
+ def run_test(args) -> None:
326
+ """Namespace-shaped entry for the CLI registry (self-routing).
327
+
328
+ The framework passes the whole parsed Namespace; this replicates the exact
329
+ routing the legacy main.py ladder did before the migration, so `p test`
330
+ keeps the same behavior for every `type` value.
331
+ """
332
+ test_type = getattr(args, "type", None)
333
+ if test_type == "generate":
334
+ run(action="generate", test_type=getattr(args, "area", None))
335
+ elif test_type == "report":
336
+ run(action="report", last_n=getattr(args, "last", 20))
337
+ elif test_type == "config":
338
+ run(
339
+ action="config",
340
+ config_action=getattr(args, "area", None),
341
+ key=getattr(args, "config_key", None),
342
+ value=getattr(args, "config_value", None),
343
+ )
344
+ else:
345
+ run(
346
+ test_type=test_type,
347
+ area=getattr(args, "area", None),
348
+ attack=getattr(args, "attack", False),
349
+ )
350
+
351
+
352
+ def run_generate(test_type: str | None = None, root: Path | None = None) -> None:
353
+ """Entry point for `p test generate [type]`."""
354
+ try:
355
+ r = root or require_project_root()
356
+ except RuntimeError as e:
357
+ con.print(f"[red]{e}[/red]")
358
+ return
359
+
360
+ from patchi.core import config as cfg
361
+ from patchi.core.ai.client import call_ai
362
+
363
+ try:
364
+ config = cfg.load(r)
365
+ except Exception as e:
366
+ _log.warning("run_generate failed: %s", e)
367
+ config = {}
368
+
369
+ # Quick availability check
370
+ test_response = call_ai(config, "Reply with OK", "test", max_tokens=5)
371
+ if not test_response:
372
+ con.print("[yellow]No AI configured. Set up a key with `p key add` or use Ollama.[/yellow]")
373
+ con.print("[dim]Tip: `p ai status` to see what's available.[/dim]")
374
+ return
375
+
376
+ con.print()
377
+ con.print("[bold #C8621A]Generating tests with AI…[/bold #C8621A]")
378
+ con.print()
379
+
380
+ # Patchi decides — not user configured: Understander + coverage gap
381
+ try:
382
+ from patchi.core.ai.tools.realize import generate_tests as _realize_gen
383
+
384
+ # Let Patchi auto-pick untested core files (Understander + body_tags)
385
+ auto_res = _realize_gen(r, ["auto"], test_type or "unit")
386
+ # auto_res already wrote .patchi/generated_tests/test_*.py
387
+ # Now also do AI skeleton for deeper tests on same auto-picked files
388
+ auto_files = [c.get("source", "") for c in auto_res.get("created", []) if c.get("status") == "created"]
389
+ if auto_files:
390
+ project_files = [p.replace(str(r) + "/", "").replace("\\", "/") for p in auto_files]
391
+ else:
392
+ # fallback to Understander core
393
+ from patchi.core.brain.body_tags import load_body_tags
394
+ from patchi.core.brain.file_corpus import FileCorpus
395
+ from patchi.core.brain.understander import Understander
396
+
397
+ tags = load_body_tags(r)
398
+ corpus = FileCorpus(r)
399
+ class _FI:
400
+ def __init__(self, p: str):
401
+ self.path = p
402
+
403
+ fis = [_FI(e.path) for e in corpus.files()]
404
+ if tags:
405
+ u = Understander(r, fis, tags, {}, [])
406
+ core = u.core_files(limit=8)
407
+ project_files = [c["path"] for c in core if not (r / f"tests/test_{Path(c['path']).stem}.py").exists()]
408
+ if not project_files:
409
+ project_files = [c["path"] for c in core[:3]]
410
+ else:
411
+ project_files = _discover_project_files(r)[:8]
412
+ except Exception as e:
413
+ _log.debug("Patchi auto-pick failed, fallback: %s", e)
414
+ project_files = _discover_project_files(r)[:8]
415
+ top_files = project_files
416
+ if not project_files:
417
+ con.print("[yellow]Patchi found no untested core files to generate for.[/yellow]")
418
+ con.print("[dim]Patchi decides coverage gap: all core files already have tests or no core detected[/dim]")
419
+ return
420
+ con.print(f"[dim]Patchi auto-picked {len(project_files)} untested core files: {', '.join(project_files[:3])}{' …' if len(project_files)>3 else ''}[/dim]")
421
+ scope_msg = f" for {test_type} tests" if test_type else " — Patchi decided"
422
+ top_files = project_files # Patchi decides, not user
423
+
424
+ # Extract AST skeletons — guaranteed real names, no hallucination
425
+ skeletons = []
426
+ for fp in top_files:
427
+ full = r / fp
428
+ try:
429
+ skeleton = _extract_skeleton(str(full))
430
+ if skeleton.strip():
431
+ skeletons.append(f"### {fp}\n{skeleton}")
432
+ except Exception as e:
433
+ _log.warning("run_generate failed: %s", e)
434
+ continue
435
+
436
+ if not skeletons:
437
+ con.print("[yellow]Could not extract skeletons from any files.[/yellow]")
438
+ return
439
+
440
+ skeletons_text = "\n\n".join(skeletons)
441
+
442
+ # Smarter context: mutation survived, branch gaps, coverage, fuzz boundaries
443
+ extra_context = []
444
+ try:
445
+ from patchi.core import memory as _mem2
446
+
447
+ scans = _mem2.get_scan_results(r) or {}
448
+ mut = scans.get("MutationAgent", {}).get("findings", [])
449
+ if mut:
450
+ mut_lines = [f"- {f.get('file')}:{f.get('line')} {f.get('message','')[:80]}" for f in mut[:5]]
451
+ extra_context.append("MUTATION SURVIVED (must kill):\n" + "\n".join(mut_lines))
452
+ except Exception as _exc:
453
+ _log.debug('suppressed: %s', _exc)
454
+ try:
455
+ # Branch gaps: heuristic from FileCorpus
456
+ from patchi.core.brain.file_corpus import FileCorpus
457
+
458
+ corpus = FileCorpus(r)
459
+ branch_gaps = []
460
+ for fp in top_files:
461
+ try:
462
+ txt = (r / fp).read_text(encoding="utf-8", errors="replace")
463
+ branches = txt.count(" if ") + txt.count(" else") + txt.count(" ? ")
464
+ if branches > 6:
465
+ branch_gaps.append(f"- {fp}: {branches} branches")
466
+ except OSError:
467
+ pass
468
+ if branch_gaps:
469
+ extra_context.append("BRANCH GAPS (cover each if/else):\n" + "\n".join(branch_gaps[:5]))
470
+ except Exception as _exc:
471
+ _log.debug('suppressed: %s', _exc)
472
+ try:
473
+ from patchi.core.fuzz.input_fuzzer import InputFuzzer
474
+
475
+ fz = InputFuzzer(seed=42)
476
+ fuzz_samples = [f.to_dict() for f in fz.fuzz_string("test", count=5)]
477
+ extra_context.append("FUZZ BOUNDARIES (test these):\n" + "\n".join(f"- {s['label']}: {s['value']!r} ({s['strategy']})" for s in fuzz_samples[:4]))
478
+ except Exception as _exc:
479
+ _log.debug('suppressed: %s', _exc)
480
+ extra_block = "\n\n".join(extra_context) if extra_context else "No extra gap data — cover happy path + one edge per function."
481
+ if extra_block:
482
+ extra_block = f"\n\nSMART CONTEXT — Patchi decided what to catch:\n{extra_block}\n"
483
+
484
+ prompt = f"""Generate a pytest test file{scope_msg} for these Python modules.
485
+
486
+ MODULE SKELETONS (these are real — use ONLY these names):
487
+ {skeletons_text}
488
+ {extra_block}
489
+ Rules:
490
+ 1. Import from the exact paths shown (e.g. from patchi.core.agents.base import ...)
491
+ 2. Use ONLY the function/class names listed above — do not invent new ones
492
+ 3. Mock call_ai, HTTP, and file I/O
493
+ 4. Keep under 250 lines
494
+ 5. Return ONE test file in a ```python block
495
+ 6. NEVER use module-level `with patch(...)` blocks — use @patch decorators on test methods only
496
+ 7. NEVER import the module under test at the top level — import inside each test method
497
+ 8. Keep all imports inside test functions/methods to avoid side effects at collection time
498
+ 9. SMART: For each function, add one happy path + one boundary/branch test + one mutation-killing test (use extra context above). For security-sensitive fns, add injection/encoding payload test from FUZZ BOUNDARIES.
499
+ 10. Prioritize covering MUTATION SURVIVED and BRANCH GAPS listed — those are Patchi's priority catch list."""
500
+
501
+ from patchi.core.ai.prompts import SYSTEM_PROMPTS, Skill
502
+
503
+ system_prompt = SYSTEM_PROMPTS.get(Skill.TEST_GENERATE, "You are a test generator.")
504
+
505
+ lp = LiveProgress(con, title="Generate tests")
506
+ lp.start()
507
+ lp.log(" Generating tests via AI…")
508
+ lp.update()
509
+ try:
510
+ response_text = call_ai(config, system_prompt, prompt, max_tokens=4000)
511
+
512
+ if response_text:
513
+ code = response_text
514
+ if "```python" in code:
515
+ code = code.split("```python")[1].split("```")[0]
516
+ elif "```" in code:
517
+ code = code.split("```")[1].split("```")[0]
518
+
519
+ # Determine output path
520
+ if test_type:
521
+ out_path = r / "tests" / f"test_{test_type}_generated.py"
522
+ else:
523
+ out_path = r / "tests" / "test_generated_suite.py"
524
+
525
+ out_path.parent.mkdir(parents=True, exist_ok=True)
526
+ out_path.write_text(code.strip(), encoding="utf-8")
527
+
528
+ # Quick verify — no second AI call, just check if it compiles
529
+ err = _verify_test(code, r)
530
+ if err:
531
+ lp.log(f" [yellow]Tests written but may have issues: {err[:100]}[/yellow]")
532
+ lp.stop(summary="Tests generated with warnings")
533
+ con.print(f"[yellow]⚠ Tests written but may have issues: {err[:100]}[/yellow]")
534
+ else:
535
+ lp.log(" Tests verified and written")
536
+ lp.stop(summary="Tests generated and verified")
537
+ con.print(
538
+ f"[#4ADE80]✓[/#4ADE80] Tests verified and written to [bold]{out_path.relative_to(r)}[/bold]"
539
+ )
540
+ con.print("[dim]Run with: p test unit[/dim]")
541
+ else:
542
+ lp.stop(summary="AI returned empty response")
543
+ con.print("[yellow]AI returned empty response. Try again or check your key.[/yellow]")
544
+
545
+ except Exception as e:
546
+ lp.stop(summary=f"Failed: {e}")
547
+ con.print(f"[red]Generation failed: {e}[/red]")
548
+
549
+
550
+ def run_report(last_n: int = 20, root: Path | None = None) -> None:
551
+ """Entry point for `p test report`."""
552
+ try:
553
+ r = root or require_project_root()
554
+ except RuntimeError as e:
555
+ con.print(f"[red]{e}[/red]")
556
+ return
557
+
558
+ from patchi.core.testing.test_config import get_test_history
559
+
560
+ history = get_test_history(r)
561
+
562
+ if not history:
563
+ con.print("[dim]No test runs recorded yet. Run `p test` first.[/dim]")
564
+ return
565
+
566
+ runs = history[-last_n:]
567
+
568
+ table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
569
+ table.add_column("Time", style="dim", width=19)
570
+ table.add_column("Type", style="bold #F2EDD6", width=12)
571
+ table.add_column("Status", width=8)
572
+ table.add_column("Passed", justify="right", width=7)
573
+ table.add_column("Failed", justify="right", width=7)
574
+ table.add_column("Duration", justify="right", width=10)
575
+ table.add_column("Agents", style="dim", width=30)
576
+
577
+ for entry in reversed(runs):
578
+ ts = entry.get("timestamp", "")
579
+ try:
580
+ ts_fmt = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "—"
581
+ except Exception as e:
582
+ _log.warning("run_report failed: %s", e)
583
+ ts_fmt = str(ts)[:16]
584
+
585
+ status = entry.get("status", "unknown")
586
+ passed = entry.get("passed", 0)
587
+ failed = entry.get("failed", 0)
588
+ dur = entry.get("duration_ms", 0)
589
+ agents = entry.get("agents", [])
590
+ test_type = entry.get("test_type", "—")
591
+
592
+ if status == "passed" or (passed > 0 and failed == 0):
593
+ status_text = Text("PASS", style="#4ADE80 bold")
594
+ elif status == "failed" or failed > 0:
595
+ status_text = Text("FAIL", style="#FF4D6D bold")
596
+ else:
597
+ status_text = Text(status, style="dim")
598
+
599
+ table.add_row(
600
+ ts_fmt,
601
+ test_type,
602
+ status_text,
603
+ str(passed) if passed else "—",
604
+ Text(str(failed), style="#FF4D6D") if failed else "—",
605
+ f"{dur}ms" if dur else "—",
606
+ ", ".join(a.replace("Agent", "") for a in agents[:4])
607
+ + ("…" if len(agents) > 4 else ""),
608
+ )
609
+
610
+ con.print()
611
+ con.print(Panel(table, title=f"Test History (last {len(runs)} runs)", border_style="#C8621A"))
612
+ con.print()
613
+
614
+ # Coverage summary
615
+ from patchi.core.testing.test_config import get_coverage
616
+
617
+ cov = get_coverage(r)
618
+ total_runs = cov.get("total_runs", 0)
619
+ agents_run = cov.get("agents_run", {})
620
+ if total_runs:
621
+ con.print(f"[dim]Total runs: {total_runs}[/dim]")
622
+ if agents_run:
623
+ con.print(
624
+ f"[dim]Agents exercised: {', '.join(f'{k}({v})' for k, v in sorted(agents_run.items(), key=lambda x: -x[1]))}[/dim]"
625
+ )
626
+ con.print()
627
+
628
+
629
+ def run_config(
630
+ action: str | None = None,
631
+ key: str | None = None,
632
+ value: str | None = None,
633
+ root: Path | None = None,
634
+ ) -> None:
635
+ """Entry point for `p test config`."""
636
+ try:
637
+ r = root or require_project_root()
638
+ except RuntimeError as e:
639
+ con.print(f"[red]{e}[/red]")
640
+ return
641
+
642
+ from patchi.core.testing import test_config as tc
643
+
644
+ if action is None or action == "show":
645
+ config = tc.load(r)
646
+ con.print()
647
+ con.print(
648
+ Panel(
649
+ json.dumps(config, indent=2),
650
+ title="Test Configuration",
651
+ border_style="#C8621A",
652
+ subtitle=f"{tc.TESTS_CONFIG}",
653
+ )
654
+ )
655
+ con.print()
656
+ return
657
+
658
+ if action == "set":
659
+ if not key:
660
+ con.print("[red]Usage: p test config set <key> <value>[/red]")
661
+ return
662
+ # Try to parse as JSON for non-string values
663
+ parsed_value = value
664
+ if value:
665
+ try:
666
+ parsed_value = json.loads(value)
667
+ except (json.JSONDecodeError, ValueError):
668
+ pass
669
+ tc.set_value(key, parsed_value, r)
670
+ con.print(f"[#4ADE80]✓[/#4ADE80] [bold]{key}[/bold] = {parsed_value}")
671
+ return
672
+
673
+ if action == "flows":
674
+ _manage_flows(key, value, r)
675
+ return
676
+
677
+ con.print(f"[yellow]Unknown config action: {action!r}[/yellow]")
678
+ con.print("[dim]Valid: show, set, flows[/dim]")
679
+
680
+
681
+ def _manage_flows(sub_action: str | None, name: str | None, root: Path) -> None:
682
+ """Manage custom test flows."""
683
+ from patchi.core.testing import test_config as tc
684
+
685
+ if sub_action == "add":
686
+ con.print("[dim]Add a custom test flow:[/dim]")
687
+ flow_name = name or input(" Flow name: ").strip()
688
+ if not flow_name:
689
+ con.print("[red]Name required.[/red]")
690
+ return
691
+ url = input(" URL path (e.g. /login): ").strip()
692
+ steps_raw = input(" Steps (comma-separated): ").strip()
693
+ steps = [s.strip() for s in steps_raw.split(",") if s.strip()]
694
+ tc.add_custom_flow(flow_name, url, steps, root)
695
+ con.print(f"[#4ADE80]✓[/#4ADE80] Flow [bold]{flow_name}[/bold] added.")
696
+
697
+ elif sub_action == "remove":
698
+ if not name:
699
+ con.print("[red]Usage: p test config flows remove <name>[/red]")
700
+ return
701
+ removed = tc.remove_custom_flow(name, root)
702
+ if removed:
703
+ con.print(f"[#4ADE80]✓[/#4ADE80] Flow [bold]{name}[/bold] removed.")
704
+ else:
705
+ con.print(f"[yellow]Flow {name!r} not found.[/yellow]")
706
+
707
+ elif sub_action == "list" or sub_action is None:
708
+ config = tc.load(root)
709
+ flows = config.get("custom_flows", [])
710
+ if not flows:
711
+ con.print("[dim]No custom flows configured.[/dim]")
712
+ con.print("[dim]Add one with: p test config flows add[/dim]")
713
+ return
714
+ for f in flows:
715
+ con.print(f" [bold]{f['name']}[/bold] [dim]{f['url']}[/dim]")
716
+ if f.get("steps"):
717
+ con.print(f" [dim]Steps: {' → '.join(f['steps'])}[/dim]")
718
+
719
+ else:
720
+ con.print(f"[yellow]Unknown flows action: {sub_action!r}[/yellow]")
721
+ con.print("[dim]Valid: add, remove, list[/dim]")
722
+
723
+
724
+ # ── Helpers ────────────────────────────────────────────────────────────────────
725
+
726
+
727
+ def _import_all_agents():
728
+ """Import all agent modules to trigger registration (never hand-list)."""
729
+ from patchi.core.agents.base import discover_agent_modules
730
+
731
+ failures = discover_agent_modules()
732
+ for failure in failures:
733
+ _log.debug("agent discovery: %s", failure)
734
+
735
+
736
+ def _extract_skeleton(filepath: str) -> str:
737
+ """Extract function/class signatures and imports — no bodies, no hallucination."""
738
+ import ast
739
+
740
+ try:
741
+ with open(filepath, encoding="utf-8", errors="ignore") as f:
742
+ tree = ast.parse(f.read())
743
+ except (SyntaxError, OSError):
744
+ return ""
745
+
746
+ lines = []
747
+ for node in ast.iter_child_nodes(tree):
748
+ if isinstance(node, ast.FunctionDef):
749
+ args = [a.arg for a in node.args.args if a.arg != "self"]
750
+ lines.append(f"def {node.name}({', '.join(args)}): ...")
751
+ elif isinstance(node, ast.AsyncFunctionDef):
752
+ args = [a.arg for a in node.args.args if a.arg != "self"]
753
+ lines.append(f"async def {node.name}({', '.join(args)}): ...")
754
+ elif isinstance(node, ast.ClassDef):
755
+ bases = [ast.unparse(b) for b in node.bases]
756
+ base_str = f"({', '.join(bases)})" if bases else ""
757
+ lines.append(f"class {node.name}{base_str}:")
758
+ for item in ast.iter_child_nodes(node):
759
+ if isinstance(item, ast.FunctionDef):
760
+ margs = [a.arg for a in item.args.args if a.arg != "self"]
761
+ lines.append(f" def {item.name}({', '.join(margs)}): ...")
762
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
763
+ lines.append(ast.unparse(node))
764
+
765
+ return "\n".join(lines)
766
+
767
+
768
+ def _verify_test(test_code: str, root: Path) -> str | None:
769
+ """Try to compile+import the test. Returns error string or None if OK."""
770
+ import importlib.util
771
+ import tempfile
772
+
773
+ try:
774
+ tmp = Path(tempfile.mktemp(suffix=".py"))
775
+ tmp.write_text(test_code, encoding="utf-8")
776
+ spec = importlib.util.spec_from_file_location("_test_check", str(tmp))
777
+ module = importlib.util.module_from_spec(spec)
778
+ spec.loader.exec_module(module)
779
+ tmp.unlink(missing_ok=True)
780
+ return None
781
+ except (ImportError, AttributeError, SyntaxError) as e:
782
+ try:
783
+ tmp.unlink(missing_ok=True)
784
+ except Exception as e:
785
+ _log.warning("_verify_test failed: %s", e)
786
+ return str(e)
787
+
788
+
789
+ def _discover_project_files(root: Path) -> list[str]:
790
+ """Discover Python source files in the project."""
791
+ from patchi.core.agents.base import safe_rglob
792
+
793
+ files = []
794
+ for p in safe_rglob(root, "*.py"):
795
+ rel = str(p.relative_to(root))
796
+ if rel.startswith("tests/") or rel.startswith("test_"):
797
+ continue
798
+ if "__pycache__" in rel:
799
+ continue
800
+ files.append(rel)
801
+ return sorted(files)[:50]
802
+
803
+
804
+ def _save_to_history(results: list, test_type: str, area: str | None, root: Path) -> None:
805
+ """Save test run to history for reporting."""
806
+ from patchi.core.agents.base import AgentStatus
807
+ from patchi.core.testing.test_config import append_test_history, update_coverage
808
+
809
+ passed = sum(1 for r in results if r.finding_count == 0 and r.status != AgentStatus.SKIPPED)
810
+ failed = sum(1 for r in results if r.finding_count > 0 or r.status == AgentStatus.FAILED)
811
+ total_dur = sum(r.duration_ms for r in results)
812
+ agent_names = [r.agent_name for r in results]
813
+
814
+ entry = {
815
+ "timestamp": time.time(),
816
+ "test_type": test_type,
817
+ "area": area,
818
+ "status": "passed" if failed == 0 else "failed",
819
+ "passed": passed,
820
+ "failed": failed,
821
+ "duration_ms": total_dur,
822
+ "agents": agent_names,
823
+ }
824
+
825
+ append_test_history(entry, root)
826
+ update_coverage(entry, root)
827
+
828
+
829
+ # ── Results display ────────────────────────────────────────────────────────────
830
+
831
+
832
+ def _show_results(results: list) -> None:
833
+ from patchi.core.agents.base import AgentStatus
834
+
835
+ all_passed = all(r.finding_count == 0 for r in results if r.status != AgentStatus.SKIPPED)
836
+
837
+ con.print()
838
+
839
+ if all_passed and results:
840
+ con.print(
841
+ Panel(
842
+ "[bold #4ADE80]All tests passed.[/bold #4ADE80]",
843
+ border_style="#4ADE80",
844
+ padding=(0, 1),
845
+ )
846
+ )
847
+ for r in results:
848
+ suite = r.data.get("suite", {})
849
+ if suite:
850
+ con.print(
851
+ f" [dim]✓ {r.agent_name.replace('Agent', '')}:[/dim] "
852
+ f"[dim]{suite.get('passed', 0)} passed in "
853
+ f"{suite.get('duration_ms', r.duration_ms)}ms[/dim]"
854
+ )
855
+ con.print()
856
+ return
857
+
858
+ # Show per-agent results
859
+ table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
860
+ table.add_column("Agent", style="bold #F2EDD6", width=20)
861
+ table.add_column("Status", width=10)
862
+ table.add_column("Passed", justify="right", width=8)
863
+ table.add_column("Failed", justify="right", width=8)
864
+ table.add_column("Duration", justify="right", width=10)
865
+ table.add_column("Note", style="dim", width=30)
866
+
867
+ for r in results:
868
+ suite = r.data.get("suite", {})
869
+ passed = suite.get("passed", r.data.get("passed", 0))
870
+ failed = suite.get("failed", r.data.get("failed", r.finding_count))
871
+ dur_ms = suite.get("duration_ms", r.duration_ms)
872
+
873
+ if r.status == AgentStatus.SKIPPED:
874
+ status_text = Text("skipped", style="dim")
875
+ elif failed > 0 or r.finding_count > 0:
876
+ status_text = Text("failed", style="#FF4D6D bold")
877
+ else:
878
+ status_text = Text("passed", style="#4ADE80 bold")
879
+
880
+ note = r.data.get("message", "")[:30]
881
+
882
+ table.add_row(
883
+ r.agent_name.replace("Agent", ""),
884
+ status_text,
885
+ str(passed) if passed else "—",
886
+ Text(str(failed), style="#FF4D6D") if failed else "—",
887
+ f"{dur_ms}ms",
888
+ note,
889
+ )
890
+
891
+ con.print(table)
892
+
893
+ # Show individual failures
894
+ sev_colors = {
895
+ "critical": "#FF4D6D",
896
+ "high": "#FF8C42",
897
+ "medium": "#FACC15",
898
+ "low": "#4ADE80",
899
+ }
900
+ for r in results:
901
+ if r.findings:
902
+ con.print()
903
+ con.print(f"[bold #F2EDD6]{r.agent_name.replace('Agent', '')} failures:[/bold #F2EDD6]")
904
+ for finding in r.findings[:8]:
905
+ sev = finding.severity.value
906
+ color = sev_colors.get(sev, "white")
907
+ con.print(f" [{color}]●[/{color}] {finding.message[:80]}")
908
+ if finding.detail:
909
+ for line in finding.detail.splitlines()[:3]:
910
+ con.print(f" [dim]{line[:80]}[/dim]")
911
+
912
+ con.print()
913
+
914
+
915
+ # ── Attack mode ────────────────────────────────────────────────────────────────
916
+
917
+
918
+ def _run_attack(root: Path | None) -> None:
919
+ """Run the AttackAgent (Metasploit auxiliary/scanner probe)."""
920
+ try:
921
+ r = root or require_project_root()
922
+ except RuntimeError as e:
923
+ con.print(f"[red]{e}[/red]")
924
+ return
925
+
926
+ from patchi.core import config as cfg
927
+ from patchi.core import memory as mem
928
+ from patchi.core.agents.attack_agent import AttackAgent
929
+ from patchi.core.agents.base import AgentInput
930
+
931
+ try:
932
+ config = cfg.load(r)
933
+ except Exception as e:
934
+ _log.warning("_run_attack failed: %s", e)
935
+ config = {}
936
+
937
+ brain = mem.get_brain(r)
938
+
939
+ con.print()
940
+ con.print(
941
+ "[bold #C8621A]Attack Scan[/bold #C8621A] [dim]Metasploit auxiliary/scanner probes against localhost[/dim]"
942
+ )
943
+ con.print()
944
+
945
+ inp = AgentInput(
946
+ root=r,
947
+ scope=[],
948
+ brain=brain,
949
+ config=config,
950
+ extra={"msf_password": config.get("msf", {}).get("password", "")},
951
+ )
952
+
953
+ msf_target = config.get("msf", {}).get("target", "127.0.0.1:8000")
954
+ inp.extra["msf_target"] = msf_target
955
+
956
+ lp = LiveProgress(con, title="Attack probes")
957
+ lp.start()
958
+ lp.log(" Running attack probes…")
959
+ lp.update()
960
+ result = AttackAgent().run(inp)
961
+ lp.stop(summary=f"{result.finding_count} findings")
962
+ _show_attack_results(result, msf_target)
963
+
964
+
965
+ def _show_attack_results(result, default_target: str = "127.0.0.1:8000") -> None:
966
+ """Display Metasploit attack scan results."""
967
+ from patchi.core.agents.base import AgentStatus
968
+
969
+ con.print()
970
+ if result.status == AgentStatus.SKIPPED or result.status == AgentStatus.FAILED:
971
+ for err in result.errors:
972
+ con.print(f" [yellow]![/yellow] [dim]{err}[/dim]")
973
+ con.print()
974
+ return
975
+
976
+ target = result.data.get("target", default_target)
977
+ frameworks = result.data.get("frameworks", [])
978
+ modules_run = result.data.get("modules_selected", [])
979
+
980
+ # Info panel
981
+ con.print(
982
+ Panel(
983
+ f"[bold #F2EDD6]Target:[/bold #F2EDD6] [dim]{target}[/dim]\n"
984
+ + (
985
+ f"[bold #F2EDD6]Framework:[/bold #F2EDD6] [dim]{', '.join(frameworks)}[/dim]\n"
986
+ if frameworks
987
+ else ""
988
+ )
989
+ + f"[bold #F2EDD6]Modules:[/bold #F2EDD6] [dim]{len(modules_run)} probe(s)[/dim]\n"
990
+ + f"[bold #F2EDD6]Findings:[/bold #F2EDD6] [dim]{result.finding_count}[/dim]",
991
+ border_style="#C8621A",
992
+ )
993
+ )
994
+ con.print()
995
+
996
+ if not result.findings and not result.errors:
997
+ con.print("[dim]No findings from Metasploit probes.[/dim]")
998
+ con.print()
999
+ return
1000
+
1001
+ # Module results table
1002
+ table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
1003
+ table.add_column("Module", style="bold #F2EDD6", width=32)
1004
+ table.add_column("Severity", width=10)
1005
+ table.add_column("Detail", style="dim", width=60)
1006
+
1007
+ for f in result.findings:
1008
+ sev_colors = {
1009
+ "critical": "#FF4D6D",
1010
+ "high": "#FF8C42",
1011
+ "medium": "#FACC15",
1012
+ "low": "#4ADE80",
1013
+ "info": "#B8A898",
1014
+ }
1015
+ sev = f.severity.value
1016
+ color = sev_colors.get(sev, "white")
1017
+ mod_name = f.extra.get("module", f.type)
1018
+ detail_preview = f.detail[:60].replace("\n", " ") if f.detail else "—"
1019
+ table.add_row(
1020
+ mod_name,
1021
+ Text(sev, style=color),
1022
+ detail_preview,
1023
+ )
1024
+
1025
+ con.print(table)
1026
+ con.print()
1027
+
1028
+ # Show any errors
1029
+ if result.errors:
1030
+ con.print("[yellow]Probe errors:[/yellow]")
1031
+ for err in result.errors:
1032
+ con.print(f" [dim]— {err}[/dim]")
1033
+ con.print()
1034
+
1035
+
1036
+ # ── Scope helper ───────────────────────────────────────────────────────────────
1037
+
1038
+
1039
+ def _scope_from_area(root: Path, area: str | None) -> list[str]:
1040
+ if not area:
1041
+ return []
1042
+ area_path = root / area
1043
+ if area_path.is_file():
1044
+ return [area]
1045
+ if area_path.is_dir():
1046
+ return [str(p.relative_to(root)) for p in area_path.rglob("*.py")]
1047
+ # Treat as prefix filter
1048
+ return [
1049
+ str(p.relative_to(root))
1050
+ for p in root.rglob("*.py")
1051
+ if str(p.relative_to(root)).startswith(area)
1052
+ ]