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,310 @@
1
+ """
2
+ AST utilities — source-to-sink taint tracking.
3
+
4
+ Follows data flow from untrusted SOURCES (user input, request objects, env
5
+ vars, etc.) into dangerous SINKS (SQL execution, command execution, eval,
6
+ HTML rendering, ...) and reports a tainted call only when the value reaches a
7
+ sink without being sanitized.
8
+
9
+ The analysis is intra-procedural and best-effort: it tracks simple local
10
+ aliasing (`x = src; sink(x)`) and string concatenation of tainted values.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import logging
16
+
17
+ from patchi.core.brain.languages import Lang, get_parser
18
+
19
+ from .assignments import find_assignments
20
+ from .config import CALL_NODE_TYPES
21
+ from .helpers import child_by_field, children, node_text
22
+
23
+ # Untrusted data origins
24
+ SOURCES = {
25
+ "request.args",
26
+ "request.form",
27
+ "request.json",
28
+ "request.data",
29
+ "request.GET",
30
+ "request.POST",
31
+ "request.body",
32
+ "request.headers",
33
+ "request.query_params",
34
+ "request.path_params",
35
+ "request.cookies",
36
+ "req.query",
37
+ "req.body",
38
+ "req.params",
39
+ "req.headers",
40
+ "input",
41
+ "sys.argv",
42
+ "os.environ",
43
+ "os.getenv",
44
+ "self.request",
45
+ "self.params",
46
+ "params",
47
+ "query",
48
+ "payload",
49
+ "event.target",
50
+ "event.data",
51
+ "document.location",
52
+ "location.search",
53
+ }
54
+
55
+ # Sanitizers that neutralize common taint categories
56
+ SANITIZERS = {
57
+ "escape",
58
+ "sanitize",
59
+ "html.escape",
60
+ "markupsafe.escape",
61
+ "bleach.clean",
62
+ "django.utils.html.escape",
63
+ "parameterized",
64
+ "prepare",
65
+ "quote",
66
+ "urlencode",
67
+ "encode_for_sql",
68
+ }
69
+
70
+
71
+ _log = logging.getLogger("patchi.brain.taint")
72
+
73
+
74
+ def _is_source(text: str) -> bool:
75
+ t = text.strip()
76
+ return (
77
+ t in SOURCES
78
+ or any(t.startswith(s + ".") for s in SOURCES)
79
+ or t
80
+ in (
81
+ "request",
82
+ "req",
83
+ "input()",
84
+ "event",
85
+ "location",
86
+ )
87
+ )
88
+
89
+
90
+ def _is_sanitized(text: str) -> bool:
91
+ return any(s in text for s in SANITIZERS)
92
+
93
+
94
+ def track_taint(content: str, lang: Lang, sinks: set[str]) -> list[dict]:
95
+ """
96
+ Track tainted data from SOURCES into `sinks`.
97
+
98
+ Returns list of dicts with keys: name, line, col, full_text, tainted_via.
99
+ A sink call is reported when at least one of its arguments is either a
100
+ direct source or an alias of a source.
101
+ """
102
+ if lang == Lang.PYTHON:
103
+ return _track_python(content, sinks)
104
+ parser = get_parser(lang)
105
+ if not parser:
106
+ return []
107
+ try:
108
+ tree = parser.parse(content.encode("utf-8"))
109
+ except Exception as e:
110
+ _log.warning("track_taint failed: %s", e)
111
+ return []
112
+ call_types = CALL_NODE_TYPES.get(lang, set())
113
+ results: list[dict] = []
114
+ aliases = _collect_aliases(content, lang)
115
+ _walk_taint(tree.root_node, call_types, sinks, aliases, results)
116
+ return results
117
+
118
+
119
+ def _collect_aliases(content: str, lang: Lang) -> dict[str, str]:
120
+ """Map variable name -> source text it was assigned from (one hop)."""
121
+ aliases: dict[str, str] = {}
122
+ for asg in find_assignments(content, lang):
123
+ target = asg.get("target", "")
124
+ value = asg.get("value", "")
125
+ if target and value and _is_source(value):
126
+ aliases[target.split(".")[0]] = value
127
+ return aliases
128
+
129
+
130
+ def _argument_texts(call_node: object) -> list[str]:
131
+ arg_nodes = (
132
+ child_by_field(call_node, "arguments")
133
+ or child_by_field(call_node, "argument_list")
134
+ or child_by_field(call_node, "value_arguments")
135
+ or child_by_field(call_node, "argument_part")
136
+ )
137
+ if arg_nodes is None:
138
+ return []
139
+ out = []
140
+ for c in children(arg_nodes):
141
+ if c.type in ("(", ")", ",", "[", "]", "{", "}"):
142
+ continue
143
+ out.append(node_text(c))
144
+ return out
145
+
146
+
147
+ def _walk_taint(
148
+ node: object,
149
+ call_types: set[str],
150
+ sinks: set[str],
151
+ aliases: dict[str, str],
152
+ results: list[dict],
153
+ ) -> None:
154
+ try:
155
+ ntype = node.type if hasattr(node, "type") else ""
156
+ except Exception as e:
157
+ _log.warning("_walk_taint failed: %s", e)
158
+ return
159
+
160
+ if ntype in call_types:
161
+ fn = _call_name(node)
162
+ leaf = fn.split(".")[-1] if "." in fn else fn
163
+ if fn in sinks or leaf in sinks:
164
+ arg_texts = _argument_texts(node)
165
+ tainted = []
166
+ for arg in arg_texts:
167
+ base = arg.split(".")[0].split("[")[0].strip()
168
+ if (
169
+ _is_source(arg)
170
+ or base in aliases
171
+ or (any(s in arg for s in aliases) and not _is_sanitized(arg))
172
+ ):
173
+ tainted.append(arg)
174
+ if tainted:
175
+ try:
176
+ line = node.start_point[0] + 1 if node.start_point else 0
177
+ col = node.start_point[1] if node.start_point else 0
178
+ except Exception as e:
179
+ _log.warning("_walk_taint failed: %s", e)
180
+ line, col = 0, 0
181
+ results.append(
182
+ {
183
+ "name": fn,
184
+ "line": line,
185
+ "col": col,
186
+ "full_text": node_text(node),
187
+ "tainted_via": tainted,
188
+ }
189
+ )
190
+
191
+ for child in children(node):
192
+ _walk_taint(child, call_types, sinks, aliases, results)
193
+
194
+
195
+ def _call_name(node: object) -> str:
196
+ fn = child_by_field(node, "function") or child_by_field(node, "name")
197
+ if fn is not None:
198
+ return node_text(fn).split("(")[0].strip()
199
+ parts = []
200
+ for child in children(node):
201
+ if child.type in ("argument_list", "arguments", "value_arguments", "argument_part"):
202
+ break
203
+ txt = node_text(child)
204
+ if txt and txt not in ("(", ")", ".", "::"):
205
+ parts.append(txt)
206
+ return ".".join(parts) if parts else ""
207
+
208
+
209
+ def _track_python(content: str, sinks: set[str]) -> list[dict]:
210
+ import ast
211
+
212
+ results: list[dict] = []
213
+ try:
214
+ tree = ast.parse(content)
215
+ except SyntaxError:
216
+ return results
217
+
218
+ aliases: dict[str, str] = {}
219
+ for node in ast.walk(tree):
220
+ if isinstance(node, ast.Assign):
221
+ for t in node.targets:
222
+ if isinstance(t, ast.Name) and isinstance(node.value, ast.Call):
223
+ call = node.value
224
+ if isinstance(call.func, ast.Name) and _is_source(_py_src_expr(call.func.id)):
225
+ aliases[t.id] = call.func.id
226
+ elif isinstance(call.func, ast.Attribute) and _is_source(
227
+ _py_src_expr(_py_attr_str(call.func))
228
+ ):
229
+ aliases[t.id] = _py_attr_str(call.func)
230
+
231
+ for node in ast.walk(tree):
232
+ if isinstance(node, ast.Call):
233
+ fn_str = _py_call_str(node.func)
234
+ leaf = fn_str.split(".")[-1]
235
+ if fn_str in sinks or leaf in sinks:
236
+ tainted = []
237
+ for arg in node.args:
238
+ arg_str = _py_arg_str(arg)
239
+ base = arg_str.split(".")[0].split("[")[0].strip()
240
+ if (
241
+ _is_source(arg_str)
242
+ or base in aliases
243
+ or (
244
+ any(a in arg_str for a in aliases.values())
245
+ and not _is_sanitized(arg_str)
246
+ )
247
+ ):
248
+ tainted.append(arg_str)
249
+ if tainted:
250
+ results.append(
251
+ {
252
+ "name": fn_str,
253
+ "line": getattr(node, "lineno", 0),
254
+ "col": getattr(node, "col_offset", 0),
255
+ "full_text": _ast_text(node, content),
256
+ "tainted_via": tainted,
257
+ }
258
+ )
259
+ return results
260
+
261
+
262
+ # ── Python helpers ─────────────────────────────────────────────────────────────
263
+
264
+
265
+ def _py_src_expr(s: str) -> str:
266
+ return s
267
+
268
+
269
+ def _py_attr_str(node: ast.AST) -> str:
270
+ parts = []
271
+ cur = node
272
+ while isinstance(cur, ast.Attribute):
273
+ parts.append(cur.attr)
274
+ cur = cur.value
275
+ if isinstance(cur, ast.Name):
276
+ parts.append(cur.id)
277
+ return ".".join(reversed(parts))
278
+
279
+
280
+ def _py_call_str(node: ast.AST) -> str:
281
+ if isinstance(node, ast.Name):
282
+ return node.id
283
+ if isinstance(node, ast.Attribute):
284
+ return _py_attr_str(node)
285
+ return ""
286
+
287
+
288
+ def _py_arg_str(node: ast.AST) -> str:
289
+ if isinstance(node, ast.Call):
290
+ return _py_call_str(node.func)
291
+ if isinstance(node, ast.Attribute):
292
+ return _py_attr_str(node)
293
+ if isinstance(node, ast.Name):
294
+ return node.id
295
+ if isinstance(node, ast.Constant):
296
+ return repr(node.value)
297
+ if isinstance(node, ast.Subscript):
298
+ return _py_arg_str(node.value)
299
+ return _py_call_str(node) or _py_attr_str(node)
300
+
301
+
302
+ def _ast_text(node: ast.AST, source: str) -> str:
303
+ try:
304
+ lines = source.splitlines()
305
+ start = getattr(node, "lineno", 1) - 1
306
+ end = getattr(node, "end_lineno", start + 1) - 1
307
+ return "\n".join(lines[start : end + 1])
308
+ except Exception as e:
309
+ _log.debug("_ast_text failed: %s", e)
310
+ return ""
@@ -0,0 +1,379 @@
1
+ """
2
+ Drift Detector + Plan-vs-Built Report Card (Dream Assistant spec, priority #2).
3
+
4
+ The agent (and the developer) declare *intent* — a Plan: which layers exist,
5
+ what the charter is, what the codebase currently looks like. Later, `p audit`
6
+ re-scans the *actual* built state and reports the gap between Plan and Built.
7
+
8
+ This is deliberately additive: it snapshots the current memory (layers, charter,
9
+ scan results) as a Plan and diffs it against the live state.
10
+
11
+ Usage:
12
+ p audit save [--intent "..."] — snapshot current state as the agreed Plan
13
+ p audit [--no-scan] — Plan-vs-Built report card + drift
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+
23
+ _PLAN_FILE = ".patchi/plan.json"
24
+
25
+
26
+ _log = logging.getLogger("patchi.brain.audit")
27
+
28
+
29
+ def _snapshot_scope(root: Path) -> dict:
30
+ """
31
+ Capture the REAL scope of the codebase: for every source file its language,
32
+ exported symbols (functions/classes/exports), and a content hash. This is
33
+ what makes the Drift Detector a true scope diff, not just a finding-count
34
+ proxy.
35
+ """
36
+ from patchi.core.brain.scanner import FileScanner
37
+
38
+ try:
39
+ fis = FileScanner(root).scan()
40
+ except Exception as e:
41
+ _log.warning("_snapshot_scope failed: %s", e)
42
+ return {}
43
+ scope: dict[str, dict] = {}
44
+ for fi in fis:
45
+ if fi.error:
46
+ continue
47
+ try:
48
+ data = (Path(root) / fi.path).read_bytes()
49
+ h = hashlib.sha256(data).hexdigest()[:16]
50
+ except Exception as e:
51
+ _log.warning("_snapshot_scope failed: %s", e)
52
+ h = ""
53
+ syms = sorted(
54
+ {f.name for f in fi.functions} | {c.name for c in fi.classes} | set(fi.exports)
55
+ )
56
+ scope[fi.path] = {"lang": fi.language.value, "symbols": syms, "hash": h}
57
+ return scope
58
+
59
+
60
+ def _diff_scope(plan_scope: dict, cur_scope: dict) -> dict:
61
+ """Real file/symbol scope diff between the agreed Plan and the built state."""
62
+ plan_files = set(plan_scope)
63
+ cur_files = set(cur_scope)
64
+ added = sorted(cur_files - plan_files)
65
+ removed = sorted(plan_files - cur_files)
66
+ modified = []
67
+ for f in sorted(plan_files & cur_files):
68
+ p = plan_scope[f]
69
+ c = cur_scope[f]
70
+ if p.get("hash") == c.get("hash"):
71
+ continue
72
+ added_sym = sorted(set(c.get("symbols", [])) - set(p.get("symbols", [])))
73
+ removed_sym = sorted(set(p.get("symbols", [])) - set(c.get("symbols", [])))
74
+ modified.append(
75
+ {
76
+ "file": f,
77
+ "symbols_added": added_sym,
78
+ "symbols_removed": removed_sym,
79
+ "symbols_unchanged": sorted(set(p.get("symbols", [])) & set(c.get("symbols", []))),
80
+ }
81
+ )
82
+ return {
83
+ "added_files": added,
84
+ "removed_files": removed,
85
+ "modified_files": modified,
86
+ "added_count": len(added),
87
+ "removed_count": len(removed),
88
+ "modified_count": len(modified),
89
+ }
90
+
91
+
92
+ def _now() -> str:
93
+ return datetime.now(UTC).isoformat()
94
+
95
+
96
+ def _count_findings(result: dict) -> int:
97
+ """Recursively count concrete findings inside one scanner result."""
98
+ if not isinstance(result, dict):
99
+ return 0
100
+ total = 0
101
+ for key, val in result.items():
102
+ if key in ("timestamp", "scanned_at", "layer", "name"):
103
+ continue
104
+ if key == "findings" and isinstance(val, list):
105
+ total += len([f for f in val if f])
106
+ elif isinstance(val, list):
107
+ for item in val:
108
+ if isinstance(item, dict) and (item.get("findings") or item.get("issues")):
109
+ total += len(item.get("findings") or item.get("issues") or [])
110
+ elif isinstance(item, dict):
111
+ total += _count_findings(item)
112
+ elif isinstance(val, dict):
113
+ total += _count_findings(val)
114
+ return total
115
+
116
+
117
+ def _charter_violations(scans: dict) -> int:
118
+ cg = scans.get("CharterGuard", {})
119
+ if isinstance(cg, dict):
120
+ return len(cg.get("findings", []) or [])
121
+ return 0
122
+
123
+
124
+ def _snapshot_state(root: Path) -> dict:
125
+ from patchi.core import memory as mem
126
+
127
+ charter = mem.get_charter(root) or {}
128
+ layers = mem.get_layers(root) or {}
129
+ scans = mem.get_scan_results(root) or {}
130
+ scan_summary = {
131
+ name: {
132
+ "finding_count": _count_findings(res),
133
+ "timestamp": (res.get("timestamp") if isinstance(res, dict) else None),
134
+ }
135
+ for name, res in scans.items()
136
+ }
137
+ return {
138
+ "layers": layers,
139
+ "charter": charter,
140
+ "scan_summary": scan_summary,
141
+ "charter_violations": _charter_violations(scans),
142
+ "total_findings": sum(v["finding_count"] for v in scan_summary.values()),
143
+ }
144
+
145
+
146
+ def save_plan(root: Path, intent: str | None = None) -> dict:
147
+ """Snapshot the current state (including real scope) as the agreed Plan."""
148
+ root = Path(root)
149
+ state = _snapshot_state(root)
150
+ plan = {
151
+ "saved_at": _now(),
152
+ "intent": intent or "",
153
+ **state,
154
+ "scope": _snapshot_scope(root),
155
+ }
156
+ p = root / _PLAN_FILE
157
+ p.parent.mkdir(parents=True, exist_ok=True)
158
+ tmp = p.with_suffix(".tmp")
159
+ tmp.write_text(json.dumps(plan, indent=2, default=str), encoding="utf-8")
160
+ tmp.replace(p)
161
+ return plan
162
+
163
+
164
+ def load_plan(root: Path) -> dict | None:
165
+ p = Path(root) / _PLAN_FILE
166
+ if not p.exists():
167
+ return None
168
+ try:
169
+ return json.loads(p.read_text(encoding="utf-8"))
170
+ except Exception as e:
171
+ _log.warning("load_plan failed: %s", e)
172
+ return None
173
+
174
+
175
+ def compute_drift(root: Path, scans: dict | None = None) -> dict:
176
+ """
177
+ Diff the agreed Plan against the live built state.
178
+
179
+ scans: optional injectable current scan results (for tests).
180
+ """
181
+ root = Path(root)
182
+ plan = load_plan(root)
183
+ if not plan:
184
+ return {"has_plan": False}
185
+
186
+ current = _snapshot_state(root) if scans is None else _summarize(scans)
187
+ cur_scope = _snapshot_scope(root)
188
+
189
+ plan_layers = set((plan.get("layers") or {}).keys())
190
+ cur_layers = set((current.get("layers") or {}).keys())
191
+ added = sorted(cur_layers - plan_layers)
192
+ removed = sorted(plan_layers - cur_layers)
193
+ unchanged = sorted(plan_layers & cur_layers)
194
+
195
+ scope_diff = _diff_scope(plan.get("scope", {}) or {}, cur_scope)
196
+
197
+ drift = {
198
+ "has_plan": True,
199
+ "saved_at": plan.get("saved_at"),
200
+ "intent": plan.get("intent", ""),
201
+ "layers_added": added,
202
+ "layers_removed": removed,
203
+ "layers_unchanged": unchanged,
204
+ "plan_total_findings": plan.get("total_findings", 0),
205
+ "built_total_findings": current.get("total_findings", 0),
206
+ "new_findings": max(0, current.get("total_findings", 0) - plan.get("total_findings", 0)),
207
+ "plan_charter_violations": plan.get("charter_violations", 0),
208
+ "built_charter_violations": current.get("charter_violations", 0),
209
+ "new_charter_violations": max(
210
+ 0, current.get("charter_violations", 0) - plan.get("charter_violations", 0)
211
+ ),
212
+ "per_scanner": _diff_scanners(
213
+ plan.get("scan_summary", {}), current.get("scan_summary", {})
214
+ ),
215
+ "scope_diff": scope_diff,
216
+ }
217
+ drift["clean"] = (
218
+ not added
219
+ and not removed
220
+ and drift["new_findings"] == 0
221
+ and drift["new_charter_violations"] == 0
222
+ and scope_diff["added_count"] == 0
223
+ and scope_diff["removed_count"] == 0
224
+ and scope_diff["modified_count"] == 0
225
+ )
226
+ return drift
227
+
228
+
229
+ def _summarize(scans: dict) -> dict:
230
+ scan_summary = {
231
+ name: {"finding_count": _count_findings(res), "timestamp": None}
232
+ for name, res in scans.items()
233
+ }
234
+ return {
235
+ "layers": {},
236
+ "charter": {},
237
+ "scan_summary": scan_summary,
238
+ "charter_violations": _charter_violations(scans),
239
+ "total_findings": sum(v["finding_count"] for v in scan_summary.values()),
240
+ }
241
+
242
+
243
+ def _diff_scanners(plan_summary: dict, cur_summary: dict) -> list[dict]:
244
+ rows = []
245
+ all_names = sorted(set(plan_summary) | set(cur_summary))
246
+ for name in all_names:
247
+ plan_c = plan_summary.get(name, {}).get("finding_count", 0)
248
+ cur_c = cur_summary.get(name, {}).get("finding_count", 0)
249
+ delta = cur_c - plan_c
250
+ rows.append({"scanner": name, "plan": plan_c, "built": cur_c, "delta": delta})
251
+ return rows
252
+
253
+
254
+ def report_card(root: Path, run_scan: bool = True) -> dict:
255
+ """
256
+ Plan-vs-Built Report Card. Re-scans (unless --no-scan) then computes drift
257
+ and a plain-language summary.
258
+ """
259
+ root = Path(root)
260
+ if run_scan:
261
+ try:
262
+ from patchi.core.brain.brain import Brain
263
+
264
+ Brain(root).scan()
265
+ except Exception as e:
266
+ _log.warning("report_card failed: %s", e)
267
+ drift = compute_drift(root)
268
+ if not drift.get("has_plan"):
269
+ return {"has_plan": False, "summary": "No Plan saved. Run `p audit save` first."}
270
+
271
+ lines = [f"Plan saved at {drift['saved_at']}"]
272
+ if drift.get("intent"):
273
+ lines.append(f"Intent: {drift['intent']}")
274
+ if drift["layers_added"]:
275
+ lines.append(f" + Layers built beyond plan: {', '.join(drift['layers_added'])}")
276
+ if drift["layers_removed"]:
277
+ lines.append(f" - Layers in plan but missing: {', '.join(drift['layers_removed'])}")
278
+ sd = drift.get("scope_diff", {}) or {}
279
+ if sd.get("added_files"):
280
+ lines.append(
281
+ f" + Files added beyond plan ({sd['added_count']}): "
282
+ f"{', '.join(f for f in sd['added_files'][:8])}"
283
+ + (" …" if sd["added_count"] > 8 else "")
284
+ )
285
+ if sd.get("removed_files"):
286
+ lines.append(
287
+ f" - Files removed vs plan ({sd['removed_count']}): "
288
+ f"{', '.join(f for f in sd['removed_files'][:8])}"
289
+ + (" …" if sd["removed_count"] > 8 else "")
290
+ )
291
+ if sd.get("modified_files"):
292
+ lines.append(f" ~ Files changed vs plan ({sd['modified_count']}):")
293
+ for m in sd["modified_files"][:8]:
294
+ bits = []
295
+ if m["symbols_added"]:
296
+ bits.append(f"+{len(m['symbols_added'])} sym")
297
+ if m["symbols_removed"]:
298
+ bits.append(f"-{len(m['symbols_removed'])} sym")
299
+ if not bits:
300
+ bits.append("content changed")
301
+ lines.append(f" • {m['file']} ({', '.join(bits)})")
302
+ lines.append(
303
+ f" Findings: plan {drift['plan_total_findings']} -> built {drift['built_total_findings']} "
304
+ f"({drift['new_findings']} new)"
305
+ )
306
+ if drift["new_charter_violations"]:
307
+ lines.append(f" Charter violations: {drift['new_charter_violations']} new")
308
+ for row in drift["per_scanner"]:
309
+ if row["delta"]:
310
+ lines.append(
311
+ f" • {row['scanner']}: {row['plan']} -> {row['built']} ({row['delta']:+d})"
312
+ )
313
+ status = "ON PLAN ✅" if drift["clean"] else "DRIFT DETECTED ⚠️"
314
+ lines.insert(0, f"Plan-vs-Built: {status}")
315
+ drift["summary"] = "\n".join(lines)
316
+ return drift
317
+
318
+
319
+ def drift_vs_plan_file(root: Path, plan_file: str | Path) -> dict:
320
+ """
321
+ Audit the built state against an externally supplied plan/spec file
322
+ (the `p audit --plan-file` path). The file is treated as the agreed Plan
323
+ and diffed against the live state.
324
+ """
325
+ root = Path(root)
326
+ path = Path(plan_file)
327
+ if not path.exists():
328
+ return {"has_plan": False, "error": f"Plan file not found: {path}"}
329
+ try:
330
+ plan = json.loads(path.read_text(encoding="utf-8"))
331
+ except Exception as e:
332
+ return {"has_plan": False, "error": f"Could not parse plan file: {e}"}
333
+
334
+ current = _snapshot_state(root)
335
+ cur_scope = _snapshot_scope(root)
336
+ plan_layers = set((plan.get("layers") or {}).keys())
337
+ cur_layers = set((current.get("layers") or {}).keys())
338
+ plan_total = plan.get("total_findings", 0)
339
+ cur_total = current.get("total_findings", 0)
340
+ plan_charter = plan.get("charter_violations", 0)
341
+ cur_charter = current.get("charter_violations", 0)
342
+
343
+ plan_summary = plan.get("scan_summary", {})
344
+ cur_summary = current.get("scan_summary", {})
345
+
346
+ drift = {
347
+ "has_plan": True,
348
+ "from_file": str(path),
349
+ "intent": plan.get("intent", ""),
350
+ "layers_added": sorted(cur_layers - plan_layers),
351
+ "layers_removed": sorted(plan_layers - cur_layers),
352
+ "plan_total_findings": plan_total,
353
+ "built_total_findings": cur_total,
354
+ "new_findings": max(0, cur_total - plan_total),
355
+ "new_charter_violations": max(0, cur_charter - plan_charter),
356
+ "per_scanner": _diff_scanners(plan_summary, cur_summary),
357
+ "scope_diff": _diff_scope(plan.get("scope", {}) or {}, cur_scope),
358
+ }
359
+ drift["clean"] = (
360
+ not drift["layers_added"]
361
+ and not drift["layers_removed"]
362
+ and drift["new_findings"] == 0
363
+ and drift["new_charter_violations"] == 0
364
+ )
365
+ status = "ON PLAN ✅" if drift["clean"] else "DRIFT DETECTED ⚠️"
366
+ lines = [f"Plan-vs-Built (vs {path.name}): {status}"]
367
+ if drift["intent"]:
368
+ lines.append(f"Intent: {drift['intent']}")
369
+ if drift["layers_added"]:
370
+ lines.append(f" + Layers beyond plan: {', '.join(drift['layers_added'])}")
371
+ if drift["layers_removed"]:
372
+ lines.append(f" - Layers missing vs plan: {', '.join(drift['layers_removed'])}")
373
+ lines.append(
374
+ f" Findings: plan {plan_total} -> built {cur_total} ({drift['new_findings']} new)"
375
+ )
376
+ if drift["new_charter_violations"]:
377
+ lines.append(f" Charter violations: {drift['new_charter_violations']} new")
378
+ drift["summary"] = "\n".join(lines)
379
+ return drift