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,269 @@
1
+ """
2
+ Live audit display — rich.Live-powered dashboard with collapsible phase panels.
3
+
4
+ Layout:
5
+ ┌─ p audit ──────────────────────────────────────────────────────┐
6
+ │ ◉ Scan → 47 files · 12 routes · 6 domains · 1.2s │
7
+ │ ┌──────────────────────────────────────────────────────────────┐│
8
+ │ │ ● Discovering files... ││
9
+ │ │ ● Parsing src/main.py ││
10
+ │ │ ● FastAPI v0.115.0 detected ││
11
+ │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ││
12
+ │ └────────────────────────────────────────────────────────────┘│
13
+ │ ◉ Security → TaintAnalyzer running (3 findings so far)... │
14
+ │ ┌────────────────────────────────────────────────────────────┐│
15
+ │ │ ● TaintAnalyzer — scanning src/routes.py ││
16
+ │ │ ● ⚑ Finding #3: Unsafe string concat in query builder ││
17
+ │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ││
18
+ │ └────────────────────────────────────────────────────────────┘│
19
+ │ ○ Tests → pending │
20
+ │ ○ Health → pending │
21
+ └────────────────────────────────────────────────────────────────┘
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import threading
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+
32
+ from rich.console import Console, Group, RenderableType
33
+ from rich.live import Live
34
+ from rich.panel import Panel
35
+ from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
36
+ from rich.text import Text
37
+
38
+ _log = logging.getLogger("patchi.cli.live_audit")
39
+
40
+
41
+ class PhaseStatus(Enum):
42
+ PENDING = "○"
43
+ RUNNING = "◉"
44
+ DONE = "✓"
45
+ FAILED = "✗"
46
+ SKIPPED = "⊘"
47
+
48
+
49
+ _PHASE_COLORS = {
50
+ "scan": "#C8621A",
51
+ "security": "#4ADE80",
52
+ "tests": "#60A5FA",
53
+ "health": "#A78BFA",
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class PhaseState:
59
+ """Mutable state of a single phase. Updated by the audit runner threads."""
60
+
61
+ name: str
62
+ status: PhaseStatus = PhaseStatus.PENDING
63
+ summary: str = ""
64
+ duration_ms: int = 0
65
+ log_lines: list[tuple[str, str]] = field(default_factory=list) # (style, text)
66
+ findings: int = 0
67
+ files_scanned: int = 0
68
+ total_steps: int = 0
69
+ current_step: int = 0
70
+ sub_label: str = ""
71
+
72
+
73
+ class LiveAuditDisplay:
74
+ """
75
+ Manages a rich.live.Live renderable with collapsible phase panels.
76
+
77
+ Thread-safe — call from any thread. The display auto-refreshes.
78
+ """
79
+
80
+ def __init__(self, console: Console | None = None):
81
+ self.con = console or Console()
82
+ self._lock = threading.RLock()
83
+ self._phases: dict[str, PhaseState] = {}
84
+ self._expanded_phase: str | None = None # None = auto (latest running)
85
+ self._live: Live | None = None
86
+ self._start_time: float = 0.0
87
+
88
+ def start(self, title: str = "p audit"):
89
+ """Start the live display. Must be called from main thread."""
90
+ self._start_time = time.monotonic()
91
+ self._live = Live(
92
+ self._render(),
93
+ console=self.con,
94
+ refresh_per_second=8,
95
+ transient=False,
96
+ screen=False,
97
+ )
98
+ self._live.__enter__()
99
+
100
+ def stop(self):
101
+ """Stop the live display and restore normal terminal."""
102
+ if self._live:
103
+ try:
104
+ self._live.__exit__(None, None, None)
105
+ except Exception as e:
106
+ _log.warning("LiveAuditDisplay.stop failed: %s", e)
107
+ self._live = None
108
+ self.con.print()
109
+
110
+ def phase(self, name: str) -> PhaseState:
111
+ """Get or create a phase by name."""
112
+ with self._lock:
113
+ if name not in self._phases:
114
+ self._phases[name] = PhaseState(name=name)
115
+ return self._phases[name]
116
+
117
+ def update(self):
118
+ """Trigger a refresh. Call after modifying any phase state."""
119
+ if self._live:
120
+ try:
121
+ self._live.update(self._render())
122
+ except Exception as e:
123
+ _log.warning("LiveAuditDisplay.update failed: %s", e)
124
+
125
+ def _render(self) -> RenderableType:
126
+ """Build the full renderable from current phase states."""
127
+ with self._lock:
128
+ elapsed = int((time.monotonic() - self._start_time) * 1000)
129
+ rows = []
130
+ color = _PHASE_COLORS
131
+
132
+ # Auto-expand the currently running phase
133
+ running = [p for p in self._phases.values() if p.status == PhaseStatus.RUNNING]
134
+ expanded = running[0].name if running else None
135
+
136
+ for phase_name in ("scan", "security", "tests", "health"):
137
+ phase = self._phases.get(phase_name)
138
+ if phase is None:
139
+ rows.append(self._render_header_line(phase_name, PhaseStatus.PENDING, "", 0))
140
+ continue
141
+
142
+ # Header line
143
+ header = self._render_header_line(
144
+ phase_name, phase.status, phase.summary, phase.duration_ms
145
+ )
146
+
147
+ # Expanded body if this phase is running AND the running phase
148
+ is_expanded = phase_name == expanded
149
+ if is_expanded and phase.log_lines:
150
+ body = self._render_expanded_body(phase)
151
+ rows.append(Group(header, body))
152
+ else:
153
+ rows.append(header)
154
+
155
+ total_findings = sum(p.findings for p in self._phases.values())
156
+ total_files = sum(p.files_scanned for p in self._phases.values())
157
+ footer = Text(
158
+ f" Total: {total_files} files · {total_findings} findings · {elapsed}ms",
159
+ style="dim",
160
+ )
161
+ grid = Group(*rows, Text(""), footer)
162
+ return Panel(
163
+ grid, title=f"[bold]{color.get('audit', '#C8621A')}audit[/bold]", border_style="dim"
164
+ )
165
+
166
+ def _render_header_line(
167
+ self, name: str, status: PhaseStatus, summary: str, duration_ms: int
168
+ ) -> RenderableType:
169
+ """A single-line phase header: ◉ Scan → 47 files · 1.2s"""
170
+ color = _PHASE_COLORS.get(name, "white")
171
+ status_icon = status.value
172
+ status_color = {
173
+ PhaseStatus.DONE: "green",
174
+ PhaseStatus.FAILED: "red",
175
+ PhaseStatus.RUNNING: "yellow",
176
+ PhaseStatus.PENDING: "dim",
177
+ PhaseStatus.SKIPPED: "dim",
178
+ }.get(status, "dim")
179
+
180
+ label = name.capitalize()
181
+ dur = f"· {duration_ms}ms" if duration_ms else ""
182
+ summary_part = f" → {summary}" if summary else ""
183
+
184
+ parts = [
185
+ f"[{status_color}]{status_icon}[/{status_color}] ",
186
+ f"[bold {color}]{label:<10}[/bold {color}]",
187
+ ]
188
+ if summary_part:
189
+ parts.append(f"[dim]{summary_part}[/dim]")
190
+ if duration_ms:
191
+ parts.append(f"[dim]{dur}[/dim]")
192
+ return Text.assemble(*parts)
193
+
194
+ def _render_expanded_body(self, phase: PhaseState) -> RenderableType:
195
+ """Expanded panel showing live log lines + progress bar."""
196
+ lines = []
197
+ for style, text in phase.log_lines:
198
+ if style:
199
+ lines.append(Text(text, style=style))
200
+ else:
201
+ lines.append(Text(text))
202
+
203
+ # Progress bar if there are sub-steps
204
+ if phase.total_steps > 0:
205
+ progress = Progress(
206
+ TextColumn("[progress.description]{task.description}"),
207
+ BarColumn(),
208
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
209
+ TimeElapsedColumn(),
210
+ console=self.con,
211
+ )
212
+ task_id = progress.add_task(
213
+ phase.sub_label or "",
214
+ total=phase.total_steps,
215
+ completed=phase.current_step,
216
+ )
217
+ progress.update(task_id, completed=phase.current_step)
218
+ lines.append(Text(""))
219
+ lines.append(progress)
220
+
221
+ # If findings found during live phase, show count
222
+ if phase.findings:
223
+ lines.append(Text(f"\n ⚑ {phase.findings} finding(s) so far", style="yellow"))
224
+
225
+ panel = Panel(
226
+ Group(*lines),
227
+ border_style="dim",
228
+ padding=(0, 1),
229
+ width=None,
230
+ )
231
+ return panel
232
+
233
+ def log(self, phase_name: str, message: str, style: str = ""):
234
+ """Append a log line to a phase."""
235
+ with self._lock:
236
+ phase = self.phase(phase_name)
237
+ # Keep last 50 lines
238
+ phase.log_lines.append((style, message))
239
+ if len(phase.log_lines) > 50:
240
+ phase.log_lines = phase.log_lines[-50:]
241
+
242
+ def set_phase_status(
243
+ self,
244
+ name: str,
245
+ status: PhaseStatus,
246
+ summary: str = "",
247
+ duration_ms: int = 0,
248
+ findings: int = 0,
249
+ files_scanned: int = 0,
250
+ ):
251
+ with self._lock:
252
+ phase = self.phase(name)
253
+ phase.status = status
254
+ if summary:
255
+ phase.summary = summary
256
+ if duration_ms:
257
+ phase.duration_ms = duration_ms
258
+ if findings:
259
+ phase.findings = findings
260
+ if files_scanned:
261
+ phase.files_scanned = files_scanned
262
+
263
+ def set_phase_progress(self, name: str, current: int, total: int, label: str = ""):
264
+ with self._lock:
265
+ phase = self.phase(name)
266
+ phase.total_steps = total
267
+ phase.current_step = current
268
+ if label:
269
+ phase.sub_label = label
@@ -0,0 +1,169 @@
1
+ """
2
+ Lightweight live progress display for single-command streaming.
3
+
4
+ Shows an inline updating panel with:
5
+ - Title header
6
+ - Live log lines (ring buffer, last 20 shown)
7
+ - Optional step-based progress bar
8
+ - Auto-collapse to one-line summary when stopped
9
+
10
+ Thread-safe — call from any thread.
11
+ No screen/alt-screen mode — works inline in the terminal.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import threading
18
+ import time
19
+ from dataclasses import dataclass, field
20
+
21
+ from rich.console import Console, Group, RenderableType
22
+ from rich.live import Live
23
+ from rich.panel import Panel
24
+ from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
25
+ from rich.text import Text
26
+
27
+ _log = logging.getLogger("patchi.cli.live_progress")
28
+
29
+
30
+ @dataclass
31
+ class _ProgressState:
32
+ log_lines: list[tuple[str, str]] = field(default_factory=list)
33
+ total_steps: int = 0
34
+ current_step: int = 0
35
+ sub_label: str = ""
36
+ findings: int = 0
37
+ status_text: str = "running…"
38
+ done: bool = False
39
+ failed: bool = False
40
+
41
+
42
+ class LiveProgress:
43
+ """
44
+ Inline live progress display for a single command.
45
+
46
+ Usage:
47
+ lp = LiveProgress(console, title="p security")
48
+ lp.start()
49
+ lp.log("Scanning foo.py")
50
+ lp.set_progress(5, 10, "TaintAnalyzer")
51
+ lp.update()
52
+ lp.stop(summary="3 findings in 1.2s")
53
+ """
54
+
55
+ def __init__(self, console: Console | None = None, title: str = "progress"):
56
+ self.con = console or Console()
57
+ self._title = title
58
+ self._lock = threading.RLock()
59
+ self._state = _ProgressState()
60
+ self._live: Live | None = None
61
+ self._start_time: float = 0.0
62
+
63
+ def start(self):
64
+ self._start_time = time.monotonic()
65
+ self._live = Live(
66
+ self._render(),
67
+ console=self.con,
68
+ refresh_per_second=8,
69
+ transient=False,
70
+ screen=False,
71
+ )
72
+ self._live.__enter__()
73
+
74
+ def stop(self, summary: str = ""):
75
+ if self._live:
76
+ with self._lock:
77
+ self._state.done = True
78
+ self._live.update(self._render())
79
+ try:
80
+ self._live.__exit__(None, None, None)
81
+ except Exception as e:
82
+ _log.warning("LiveProgress.stop failed: %s", e)
83
+ self._live = None
84
+
85
+ elapsed = int((time.monotonic() - self._start_time) * 1000)
86
+ if summary:
87
+ self.con.print(
88
+ f" [bold #A78BFA]{self._title}:[/bold #A78BFA] {summary} [dim]({elapsed}ms)[/dim]"
89
+ )
90
+
91
+ def update(self):
92
+ if self._live:
93
+ try:
94
+ self._live.update(self._render())
95
+ except Exception as e:
96
+ _log.warning("LiveProgress.update failed: %s", e)
97
+
98
+ def log(self, message: str, style: str = ""):
99
+ with self._lock:
100
+ self._state.log_lines.append((style, message))
101
+ if len(self._state.log_lines) > 50:
102
+ self._state.log_lines = self._state.log_lines[-50:]
103
+
104
+ def set_progress(self, current: int, total: int, label: str = ""):
105
+ with self._lock:
106
+ self._state.total_steps = total
107
+ self._state.current_step = current
108
+ if label:
109
+ self._state.sub_label = label
110
+
111
+ def set_findings(self, count: int):
112
+ with self._lock:
113
+ self._state.findings = count
114
+
115
+ def set_status(self, text: str):
116
+ with self._lock:
117
+ self._state.status_text = text
118
+
119
+ def _render(self) -> RenderableType:
120
+ with self._lock:
121
+ lines = []
122
+ for style, text in self._state.log_lines[-20:]:
123
+ if style:
124
+ lines.append(Text(text, style=style))
125
+ else:
126
+ lines.append(Text(text))
127
+
128
+ # Progress bar
129
+ if self._state.total_steps > 0 and not self._state.done:
130
+ progress = Progress(
131
+ TextColumn("[progress.description]{task.description}"),
132
+ BarColumn(),
133
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
134
+ TimeElapsedColumn(),
135
+ console=self.con,
136
+ )
137
+ task_id = progress.add_task(
138
+ self._state.sub_label or "",
139
+ total=self._state.total_steps,
140
+ completed=self._state.current_step,
141
+ )
142
+ progress.update(task_id, completed=self._state.current_step)
143
+ lines.append(Text(""))
144
+ lines.append(progress)
145
+
146
+ # Findings counter
147
+ if self._state.findings:
148
+ lines.append(Text(f" ! {self._state.findings} finding(s)", style="yellow"))
149
+
150
+ elapsed = int((time.monotonic() - self._start_time) * 1000) if self._start_time else 0
151
+ footer = Text(
152
+ f" {self._state.status_text} - {elapsed}ms",
153
+ style="dim",
154
+ )
155
+
156
+ grid = Group(*lines, Text(""), footer)
157
+ icon = (
158
+ "[#4ADE80]v[/#4ADE80]"
159
+ if self._state.done and not self._state.failed
160
+ else "[red]x[/red]"
161
+ if self._state.failed
162
+ else "[yellow]~[/yellow]"
163
+ )
164
+ return Panel(
165
+ grid,
166
+ title=f"{icon} [bold #A78BFA]{self._title}[/bold #A78BFA]",
167
+ border_style="dim",
168
+ padding=(0, 1),
169
+ )
@@ -0,0 +1,241 @@
1
+ """
2
+ Command framework — §3 Command Unification.
3
+
4
+ Replaces the three-places-to-edit pattern (lazy-import factory function +
5
+ argparse subparser block + elif dispatch chain) with one declarative
6
+ `Command` per command, registered once in `registry.py`.
7
+
8
+ Design note (refinement made during implementation, not in the original design
9
+ doc): a handful of existing commands read GLOBAL flags (--quiet, --verbose)
10
+ that are defined once on the top-level parser, not re-declared per-subcommand.
11
+ `Arg(global_flag=True)` marks these — the builder skips calling add_argument
12
+ for them (the global parser already defines it), but the dispatcher still
13
+ reads them into the handler's kwargs. Without this distinction, migrating
14
+ `scan` (which reads the global `quiet`) would either duplicate the flag
15
+ definition (argparse conflict) or silently drop the value.
16
+
17
+ Second design note: the `namespace_handler` shape (see Command). notify,
18
+ hosted, test, security and cross-repo all dispatch on parsed values in ways a
19
+ static Arg->kwarg mapping can't express (e.g. `p test generate` routes to a
20
+ different function than `p test unit`), and their legacy handlers already
21
+ received the whole Namespace. Rather than force those into per-subcommand
22
+ handlers, the framework supports passing the Namespace straight through and
23
+ letting the one handler self-route — `resolve` returns the top command
24
+ regardless of how deep the subcommand path goes.
25
+
26
+ Lazy loading is preserved exactly as the original factories did: `handler` is
27
+ a dotted string ("module:function"), not a function object — nothing is
28
+ imported until dispatch actually resolves it, so `p --help` / cold start
29
+ stays fast regardless of how many commands are registered.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import importlib
36
+ import logging
37
+ from dataclasses import dataclass, field
38
+ from typing import Any
39
+
40
+ _log = logging.getLogger("patchi.cli.framework")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Arg:
45
+ name: str # "--area" or "area" (positional)
46
+ help: str = ""
47
+ default: Any = None
48
+ action: str | None = None # "store_true", "store_false", etc.
49
+ choices: tuple | None = None
50
+ nargs: str | None = None
51
+ dest: str | None = None
52
+ type: type | None = None
53
+ global_flag: bool = False # True = already defined on the top-level
54
+ # parser; don't re-add, just read it
55
+
56
+ def resolved_dest(self) -> str:
57
+ is_positional = not self.name.startswith("-")
58
+ if is_positional:
59
+ # argparse always uses the name itself as dest for positionals --
60
+ # any `dest` set here would be ignored by the real parser, so
61
+ # resolving it here too keeps kwarg-building consistent with what
62
+ # actually gets parsed.
63
+ return self.name.replace("-", "_")
64
+ if self.dest:
65
+ return self.dest
66
+ return self.name.lstrip("-").replace("-", "_")
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Command:
71
+ name: str
72
+ help: str
73
+ handler: str = "" # dotted path: "patchi.cli.commands.scan_cmd:run"
74
+ args: tuple[Arg, ...] = ()
75
+ subcommands: tuple[Command, ...] = ()
76
+ aliases: tuple[str, ...] = ()
77
+ fixed_kwargs: dict = field(default_factory=dict)
78
+ # ^ For cases where several subcommands share ONE handler but each needs a
79
+ # different hardcoded value baked in -- e.g. `restrict add`/`scan-only`/
80
+ # `sensitive` all call the same run_add(path, restriction_type, reason),
81
+ # only restriction_type differs per subcommand. These values are merged
82
+ # into the kwargs unconditionally, never derived from parsed args.
83
+ namespace_handler: bool = False
84
+ # ^ Self-routing Namespace-handler shape: the handler receives the WHOLE
85
+ # argparse Namespace (fn(args)) and routes on the subcommand dests itself
86
+ # (notify_cmd/hosted_cmd/token_cmd...). The subcommand tree is declared
87
+ # purely for parsing + help — resolve() always returns the top command, so
88
+ # one function owns the entire command tree. Used by notify, hosted, test,
89
+ # security, and cross-repo, which all dispatch on parsed values in ways a
90
+ # static Arg->kwarg mapping cannot express.
91
+
92
+
93
+ def _add_args(parser: argparse.ArgumentParser, cmd: Command) -> None:
94
+ args = cmd.args
95
+ # Normalize: accept a single Arg or None as well as a list/tuple so a
96
+ # mis-registered command can never crash parser construction.
97
+ if args is None:
98
+ return
99
+ if isinstance(args, (list, tuple)):
100
+ arg_list = args
101
+ else:
102
+ arg_list = [args]
103
+ for a in arg_list:
104
+ if a.global_flag:
105
+ continue # already defined on the top-level parser
106
+ is_positional = not a.name.startswith("-")
107
+ kwargs: dict[str, Any] = {"help": a.help}
108
+ if a.action:
109
+ kwargs["action"] = a.action
110
+ if a.default is not None:
111
+ kwargs["default"] = a.default
112
+ if a.choices:
113
+ kwargs["choices"] = a.choices
114
+ if a.nargs:
115
+ kwargs["nargs"] = a.nargs
116
+ if a.dest and not is_positional:
117
+ # argparse forbids dest= on positional args (the name IS the dest) --
118
+ # only pass it for optional flags. A positional Arg must name itself
119
+ # exactly what the handler's kwarg expects; there's no override.
120
+ kwargs["dest"] = a.dest
121
+ if a.type:
122
+ kwargs["type"] = a.type
123
+ parser.add_argument(a.name, **kwargs)
124
+
125
+
126
+ def _add_command(sub, cmd: Command) -> None:
127
+ parser = sub.add_parser(cmd.name, help=cmd.help, aliases=list(cmd.aliases))
128
+ _add_args(parser, cmd)
129
+ if cmd.subcommands:
130
+ sub_sub = parser.add_subparsers(dest=f"{cmd.name}_cmd")
131
+ for child in cmd.subcommands:
132
+ _add_command(sub_sub, child)
133
+
134
+
135
+ def register_commands(sub, commands: list[Command]) -> None:
136
+ """
137
+ Add every registered Command as a subparser on an EXISTING subparsers
138
+ object. Used during the incremental migration window, where most commands
139
+ still add themselves to the same `sub` the legacy code creates — this lets
140
+ registry-driven and legacy-declared commands coexist on one parser tree
141
+ without either side needing to know about the other.
142
+ """
143
+ for cmd in commands:
144
+ _add_command(sub, cmd)
145
+
146
+
147
+ def build_parser(commands: list[Command], base: argparse.ArgumentParser) -> argparse.ArgumentParser:
148
+ """
149
+ Standalone variant: builds a brand-new subparsers object on `base` and
150
+ registers every Command onto it. Only safe to use once nothing else adds
151
+ subparsers to `base` — during the migration window main.py uses
152
+ `register_commands` against its own existing `sub` instead.
153
+ """
154
+ sub = base.add_subparsers(dest="command")
155
+ register_commands(sub, commands)
156
+ return base
157
+
158
+
159
+ def _find_command(commands: list[Command], name: str) -> Command | None:
160
+ for cmd in commands:
161
+ if cmd.name == name or name in cmd.aliases:
162
+ return cmd
163
+ return None
164
+
165
+
166
+ def _lazy_import(dotted_handler: str):
167
+ mod_path, _, func_name = dotted_handler.partition(":")
168
+ module = importlib.import_module(mod_path)
169
+ return getattr(module, func_name)
170
+
171
+
172
+ def _build_kwargs(cmd: Command, args: argparse.Namespace) -> dict[str, Any]:
173
+ kwargs = dict(cmd.fixed_kwargs)
174
+ for a in cmd.args:
175
+ dest = a.resolved_dest()
176
+ kwargs[dest] = getattr(args, dest, a.default)
177
+ return kwargs
178
+
179
+
180
+ def resolve(commands: list[Command], args: argparse.Namespace) -> Command | None:
181
+ """Find the Command (and subcommand, if any) matching parsed args.command."""
182
+ top_name = getattr(args, "command", None)
183
+ if top_name is None:
184
+ return None
185
+ cmd = _find_command(commands, top_name)
186
+ if cmd is None:
187
+ return None
188
+ if cmd.namespace_handler:
189
+ # Self-routing command: one handler owns the whole subcommand tree and
190
+ # routes on the parsed dests itself, so the top Command is what gets
191
+ # dispatched no matter how deep the invocation went.
192
+ return cmd
193
+ if cmd.subcommands:
194
+ sub_name = getattr(args, f"{cmd.name}_cmd", None)
195
+ if sub_name:
196
+ child = _find_command(list(cmd.subcommands), sub_name)
197
+ if child is not None:
198
+ return child
199
+ return cmd
200
+
201
+
202
+ def dispatch(commands: list[Command], args: argparse.Namespace):
203
+ """
204
+ Resolve the matching Command, lazily import its handler, and call it with
205
+ kwargs derived from the SAME Arg specs used to build the parser — so
206
+ parser and dispatch can never drift out of sync (the exact bug class this
207
+ design exists to eliminate).
208
+
209
+ Returns None if no registered Command matches (caller should fall through
210
+ to the legacy dispatch ladder during the incremental migration window).
211
+ """
212
+ cmd = resolve(commands, args)
213
+
214
+ # ── Handle `p <family> commands` ──────────────────────────────────────
215
+ # If the resolved command is a family (test, scan, fix, etc.) and the user
216
+ # typed `commands` as the subcommand, route to family commands display.
217
+ if cmd is not None:
218
+ top_name = getattr(args, "command", None)
219
+ sub_name = getattr(args, f"{top_name}_cmd", None)
220
+ if sub_name == "commands":
221
+ try:
222
+ from patchi.cli.command_families import get_family
223
+ from patchi.cli.commands.commands_cmd import run_family_commands
224
+
225
+ family = get_family(top_name)
226
+ if family:
227
+ run_family_commands(top_name)
228
+ return None, True
229
+ except Exception as e:
230
+ _log.debug("Family routing failed for %s: %s", top_name, e)
231
+
232
+ if cmd is None:
233
+ return None, False # (result, handled) -- not handled, fall through
234
+ fn = _lazy_import(cmd.handler)
235
+ if cmd.namespace_handler:
236
+ # Self-routing shape: hand the whole parsed Namespace to the handler;
237
+ # it dispatches on the subcommand dests itself. Kwargs are deliberately
238
+ # not built — the subcommands are parsing/help sugar only.
239
+ return fn(args), True
240
+ kwargs = _build_kwargs(cmd, args)
241
+ return fn(**kwargs), True