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,1161 @@
1
+ """
2
+ The Brain — main scan orchestrator for Patchi.
3
+
4
+ Coordinates:
5
+ 1. File scanner (discovery + AST parse)
6
+ 2. Framework detector
7
+ 3. Route mapper
8
+ 4. Import graph builder (+ circular dep detection)
9
+ 5. Blast radius map
10
+ 6. Dead code detection
11
+ 7. App Contract inference
12
+ 8. Freshness snapshot save
13
+ 9. Brain memory save
14
+
15
+ The Brain never touches restricted paths. Every path is checked against
16
+ the config restrictions before scanning.
17
+
18
+ Output is a BrainReport: rich structured knowledge of the entire project.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import time
24
+ from collections.abc import Callable
25
+ from dataclasses import dataclass, field
26
+ from datetime import UTC, datetime
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from patchi.core import config as cfg
31
+ from patchi.core import memory as mem
32
+ from patchi.core.brain.blast_radius import (
33
+ BlastRadius,
34
+ build_blast_radius_map,
35
+ )
36
+ from patchi.core.brain.contract import ContractBuilder, ContractFlow
37
+ from patchi.core.brain.file_corpus import FileCorpus
38
+ from patchi.core.brain.framework import FrameworkDetector, StackInfo
39
+ from patchi.core.brain.freshness import save_freshness_snapshot
40
+ from patchi.core.brain.import_graph import (
41
+ ImportGraph,
42
+ build_graph,
43
+ find_circular_dependencies,
44
+ find_dead_files,
45
+ )
46
+ from patchi.core.brain.layered_brain import build_layers, layers_to_dict
47
+ from patchi.core.brain.route_mapper import RouteInfo, RouteMapper
48
+ from patchi.core.brain.scanner import FileInfo, FileScanner
49
+ from patchi.core.constants import RestrictionType
50
+
51
+ _log = logging.getLogger("patchi.brain.brain")
52
+
53
+
54
+ logger = logging.getLogger("patchi.brain.brain")
55
+
56
+ # ── Brain report ───────────────────────────────────────────────────────────────
57
+
58
+
59
+ @dataclass
60
+ class BrainReport:
61
+ """Full structured knowledge of the project after a brain scan."""
62
+
63
+ # Scan metadata
64
+ scanned_at: str = ""
65
+ duration_seconds: float = 0.0
66
+ area: str | None = None # None = full project
67
+
68
+ # File knowledge
69
+ file_infos: list[FileInfo] = field(default_factory=list)
70
+ file_count: int = 0
71
+ language_breakdown: dict[str, int] = field(default_factory=dict)
72
+
73
+ # Stack
74
+ stack: StackInfo | None = None
75
+
76
+ # Routes
77
+ routes: list[RouteInfo] = field(default_factory=list)
78
+ route_count: int = 0
79
+
80
+ # Graph
81
+ import_graph: ImportGraph | None = None
82
+ circular_dependencies: list[Any] = field(default_factory=list)
83
+ dead_files: list[str] = field(default_factory=list)
84
+ blast_radius_map: dict[str, BlastRadius] = field(default_factory=dict) # Updated type
85
+
86
+ # Graph diff (changes since last scan)
87
+ graph_diff: dict[str, Any] = field(default_factory=dict)
88
+
89
+ # Layered brain (Pillar 1 of the super-agent architecture)
90
+ layers: dict = field(default_factory=dict)
91
+ layers_rebuilt: list[str] = field(default_factory=list) # names rebuilt this scan
92
+ layers_changed: bool = False # whether any file content changed since last scan
93
+
94
+ # Guard-rail (charter) violations (Pillar 2)
95
+ charter_violations: list[dict] = field(default_factory=list)
96
+
97
+ # Project understanding
98
+ project_purpose: str = ""
99
+ project_domain: str = ""
100
+
101
+ # Context phase (domain activation, infrastructure discovery)
102
+ project_context: dict = field(default_factory=dict)
103
+ active_security_domains: list[str] = field(default_factory=list)
104
+ infrastructure_files: list[str] = field(default_factory=list)
105
+ detected_imports: dict[str, set[str]] = field(default_factory=dict)
106
+ # L1 enriched context (Slice 1: one AI call per scan, heuristic fallback)
107
+ enriched_context: dict = field(default_factory=dict)
108
+
109
+ # Contract (unconfirmed until user confirms)
110
+ inferred_flows: list[ContractFlow] = field(default_factory=list)
111
+ confirmed_flows: list[ContractFlow] = field(default_factory=list)
112
+
113
+ # Doc validation
114
+ doc_validation: dict = field(default_factory=dict)
115
+
116
+ # Errors encountered during scan
117
+ errors: list[dict] = field(default_factory=list)
118
+
119
+ @property
120
+ def is_partial(self) -> bool:
121
+ return self.area is not None
122
+
123
+ def summary_dict(self) -> dict:
124
+ """Compact dict for storing in memory.brain"""
125
+ return {
126
+ "scanned_at": self.scanned_at,
127
+ "duration": round(self.duration_seconds, 2),
128
+ "area": self.area,
129
+ "file_count": self.file_count,
130
+ "route_count": self.route_count,
131
+ "languages": self.language_breakdown,
132
+ "framework": self.stack.frameworks[0].name
133
+ if self.stack and self.stack.frameworks
134
+ else "Unknown",
135
+ "frameworks": [f.to_dict() for f in (self.stack.frameworks if self.stack else [])],
136
+ "runtime": self.stack.runtime if self.stack else "",
137
+ "has_typescript": self.stack.has_typescript if self.stack else False,
138
+ "circular_deps": [c.short_label for c in self.circular_dependencies],
139
+ "dead_files": self.dead_files,
140
+ "error_count": len([fi for fi in self.file_infos if fi.error]),
141
+ "inferred_flows": [f.to_dict() for f in self.inferred_flows],
142
+ "confirmed_flows": [f.to_dict() for f in self.confirmed_flows],
143
+ "project_purpose": self.project_purpose,
144
+ "project_domain": self.project_domain,
145
+ "project_context": self.project_context,
146
+ "active_security_domains": self.active_security_domains,
147
+ "enriched_context": getattr(self, "enriched_context", {}),
148
+ "body_tags_version": 1,
149
+ "doc_validation": self.doc_validation,
150
+ "graph_diff": self.graph_diff,
151
+ "stale": False,
152
+ "last_scan": self.scanned_at,
153
+ }
154
+
155
+
156
+ # ── Progress event ─────────────────────────────────────────────────────────────
157
+
158
+
159
+ @dataclass
160
+ class ScanProgress:
161
+ """Emitted during scan for CLI progress bars and Web UI live feed."""
162
+
163
+ phase: (
164
+ str # "discovery" | "parsing" | "framework" | "routes" | "graph" | "context" | "contract"
165
+ )
166
+ current: int = 0
167
+ total: int = 0
168
+ message: str = ""
169
+ file_path: str = "" # for "brain.scan.file_found" events
170
+
171
+
172
+ # ── Brain ──────────────────────────────────────────────────────────────────────
173
+
174
+
175
+ class Brain:
176
+ """
177
+ The brain of Patchi. Coordinates the full scan pipeline.
178
+
179
+ Usage:
180
+ brain = Brain(project_root)
181
+ report = brain.scan()
182
+ report = brain.scan("src/auth") # targeted
183
+ """
184
+
185
+ def __init__(
186
+ self,
187
+ root: Path,
188
+ on_progress: Callable[[ScanProgress], None] | None = None,
189
+ ):
190
+ self.root = root
191
+ self.on_progress = on_progress or (lambda _: None)
192
+
193
+ def scan(self, area: str | None = None) -> BrainReport:
194
+ """
195
+ Run the full brain scan pipeline.
196
+
197
+ area: optional path/description to limit scope.
198
+ Returns a BrainReport with everything the Brain now knows.
199
+ """
200
+ start_time = time.monotonic()
201
+ report = BrainReport(
202
+ scanned_at=datetime.now(UTC).isoformat(),
203
+ area=area,
204
+ )
205
+
206
+ try:
207
+ config = cfg.load(self.root)
208
+ except RuntimeError:
209
+ config = {}
210
+
211
+ restrictions = config.get("restrictions", [])
212
+ ignore_paths = config.get("ignore_paths", [])
213
+ max_depth = config.get("scan_depth")
214
+
215
+ # Build set of no-touch paths
216
+ no_touch_paths: set[str] = set()
217
+ for r in restrictions:
218
+ if r.get("enabled", True) and r.get("type") == RestrictionType.NO_TOUCH.value:
219
+ no_touch_paths.add(r["path"])
220
+ self._emit(ScanProgress(phase="discovery", message="Discovering project files…"))
221
+
222
+ # Load previous brain memory early (used for graph diff and confirmed flows)
223
+ brain_mem = mem.get_brain(self.root)
224
+
225
+ # Load incremental scan caches (M-04: hash-based file skipping)
226
+ from patchi.core.brain.scanner import (
227
+ _load_ast_cache,
228
+ _load_file_info_cache,
229
+ _save_ast_cache,
230
+ _save_file_info_cache,
231
+ )
232
+
233
+ _load_ast_cache(self.root)
234
+ _load_file_info_cache(self.root)
235
+
236
+ # Noise exclusion at discovery time: lockfiles, generated/minified
237
+ # bundles, and docs never enter the corpus, so no agent wastes a
238
+ # pass on them and no findings can originate there. Supersedes the
239
+ # old hardcoded 5-lockfile skip_files list.
240
+ #
241
+ # IgnoreLearner adds self-learned rules on top: .gitignore patterns,
242
+ # directories with chronic false-positive history, and structurally
243
+ # tool-owned data dirs (e.g. YAML rule packs nothing imports).
244
+ learner = None
245
+ known_fps: list = []
246
+ try:
247
+ import json as _json
248
+
249
+ from patchi.core.security.ignore_learner import IgnoreLearner
250
+
251
+ fp_path = self.root / ".patchi/memory/known_false_positives.json"
252
+ if fp_path.is_file():
253
+ try:
254
+ known_fps = _json.loads(fp_path.read_text(encoding="utf-8"))
255
+ except Exception: # noqa: BLE001
256
+ known_fps = []
257
+ scan_cfg = {}
258
+ try:
259
+ from patchi.core.config import load as _cfg_load
260
+
261
+ scan_cfg = _cfg_load(self.root)
262
+ except Exception: # noqa: BLE001
263
+ scan_cfg = {}
264
+ if not isinstance(scan_cfg, dict):
265
+ scan_cfg = {}
266
+ learner = IgnoreLearner(self.root, scan_cfg)
267
+ learner.build(known_fps=known_fps or None)
268
+ except Exception as _le: # noqa: BLE001 — learning is best-effort
269
+ logging.getLogger("patchi.brain").debug("ignore learner unavailable: %s", _le)
270
+ learner = None
271
+
272
+ corpus = FileCorpus(self.root, exclude_noise=True, ignore_learner=learner)
273
+
274
+ # Second pass: composition analysis needs the discovered file list.
275
+ # Tool-owned data dirs (YAML rule packs, changelog dirs, ...) found
276
+ # now are pruned immediately and remembered for future scans.
277
+ if learner is not None:
278
+ try:
279
+ learner.build(
280
+ known_fps=known_fps or None,
281
+ file_paths=list(corpus.entries.keys()),
282
+ )
283
+ corpus.prune_with(learner)
284
+ learner.promote_to_global()
285
+ except Exception as _pe: # noqa: BLE001
286
+ logging.getLogger("patchi.brain").debug("learner second pass failed: %s", _pe)
287
+
288
+ scanner = FileScanner(
289
+ root=self.root,
290
+ ignore_paths=list(no_touch_paths) + ignore_paths,
291
+ max_depth=max_depth,
292
+ corpus=corpus,
293
+ )
294
+
295
+ all_paths = scanner.discover(area)
296
+ total = len(all_paths)
297
+ self._emit(
298
+ ScanProgress(phase="parsing", current=0, total=total, message=f"Parsing {total} files…")
299
+ )
300
+
301
+ file_infos: list[FileInfo] = []
302
+ for i, path in enumerate(all_paths):
303
+ rel = path.relative_to(self.root).as_posix()
304
+ self._emit(
305
+ ScanProgress(
306
+ phase="parsing",
307
+ current=i + 1,
308
+ total=total,
309
+ file_path=rel,
310
+ message=f"Parsing {rel}",
311
+ )
312
+ )
313
+ fi = scanner.scan_file(path)
314
+ file_infos.append(fi)
315
+ if fi.error:
316
+ report.errors.append({"file": fi.path, "error": fi.error})
317
+
318
+ report.file_infos = file_infos
319
+ report.file_count = len(file_infos)
320
+ report.language_breakdown = _count_languages(file_infos)
321
+ self._emit(ScanProgress(phase="framework", message="Detecting framework and stack…"))
322
+ detector = FrameworkDetector(self.root, corpus=corpus)
323
+ stack = detector.detect()
324
+ report.stack = stack
325
+
326
+ if stack.frameworks:
327
+ fw_names = ", ".join(f.name for f in stack.frameworks[:3])
328
+ self._emit(ScanProgress(phase="framework", message=f"Detected: {fw_names}"))
329
+ self._emit(ScanProgress(phase="routes", message="Mapping routes and endpoints…"))
330
+ mapper = RouteMapper(self.root, stack)
331
+ routes = mapper.extract(file_infos)
332
+ report.routes = routes
333
+ report.route_count = len(routes)
334
+ self._emit(ScanProgress(phase="routes", message=f"Found {len(routes)} routes"))
335
+ self._emit(ScanProgress(phase="graph", message="Building import graph…"))
336
+ graph = build_graph(file_infos, self.root)
337
+ circular_deps = find_circular_dependencies(graph)
338
+ dead_files = find_dead_files(file_infos, graph)
339
+ blast_map = build_blast_radius_map(graph) # Updated call
340
+
341
+ # ── Import graph diff against previous scan ──────────────────────────
342
+ old_edges: set[tuple[str, str]] = set()
343
+ old_nodes: set[str] = set()
344
+ prev_graph_data = brain_mem.get("import_graph", {})
345
+ if prev_graph_data:
346
+ for src, targets in prev_graph_data.get("edges", {}).items():
347
+ for tgt in targets:
348
+ old_edges.add((src, tgt))
349
+ old_nodes = set(prev_graph_data.get("nodes", []))
350
+
351
+ new_edges: set[tuple[str, str]] = set()
352
+ for src, targets in graph.edges.items():
353
+ for tgt in targets:
354
+ new_edges.add((src, tgt))
355
+ new_nodes = set(graph.nodes)
356
+
357
+ report.graph_diff = {
358
+ "added_edges": sorted([list(e) for e in new_edges - old_edges]),
359
+ "removed_edges": sorted([list(e) for e in old_edges - new_edges]),
360
+ "new_files": sorted(new_nodes - old_nodes),
361
+ "removed_files": sorted(old_nodes - new_nodes),
362
+ }
363
+
364
+ report.import_graph = graph
365
+ report.circular_dependencies = circular_deps
366
+ report.dead_files = dead_files
367
+ report.blast_radius_map = blast_map # Updated assignment
368
+
369
+ # ── Layered brain (Pillar 1) — incremental when prior state exists ────
370
+ self._emit(ScanProgress(phase="graph", message="Building layered brain…"))
371
+ try:
372
+ from patchi.core.brain.brain_watcher import (
373
+ build_or_update,
374
+ file_snapshot,
375
+ )
376
+ from patchi.core.brain.layered_brain import layers_from_dict
377
+
378
+ _old_data = mem.read(mem.MemoryCategory.LAYERS, self.root) or {}
379
+ _old_layers = layers_from_dict(_old_data) if _old_data.get("layers") else {}
380
+ _old_snap = _old_data.get("file_snapshot")
381
+ report.layers, _rebuilt, _changes = build_or_update(
382
+ _old_layers,
383
+ file_infos,
384
+ graph,
385
+ routes,
386
+ stack,
387
+ old_snapshot=_old_snap,
388
+ root=self.root,
389
+ )
390
+ report.layers_rebuilt = sorted(_rebuilt)
391
+ report.layers_changed = _changes.any
392
+ _new_snap = file_snapshot(file_infos, self.root)
393
+ # Record which layers are stale so consumers (scan/incremental) can
394
+ # skip or rebuild only what changed. BrainWatcher "detects, never acts".
395
+ if _changes.any:
396
+ try:
397
+ mem.mark_brain_stale(
398
+ sorted(_changes.all_paths),
399
+ self.root,
400
+ stale_layers=sorted(_rebuilt),
401
+ )
402
+ except Exception as e:
403
+ logger.warning("Brain.scan failed: %s", e)
404
+ except Exception as e:
405
+ # Fall back to a full (non-incremental) build — never break the scan.
406
+ logger.warning("Brain.scan failed: %s", e)
407
+ report.layers = build_layers(file_infos, graph, routes, stack)
408
+ report.layers_rebuilt = sorted(report.layers.keys())
409
+ report.layers_changed = True
410
+ _new_snap = {}
411
+
412
+ # ── Guard rails: charter drift detection (Pillar 2) ──────────────────
413
+ self._emit(ScanProgress(phase="graph", message="Checking project charter…"))
414
+ try:
415
+ from patchi.core.brain.charter import check_charter, load_charter
416
+
417
+ charter = load_charter(self.root)
418
+ if charter is not None:
419
+ detected_fw = (
420
+ [f.name for f in stack.frameworks] if stack and stack.frameworks else []
421
+ )
422
+ violations = check_charter(
423
+ charter,
424
+ {name: lyr.to_dict() for name, lyr in report.layers.items()},
425
+ detected_frameworks=detected_fw,
426
+ routes=routes,
427
+ file_infos=file_infos,
428
+ )
429
+ report.charter_violations = [v.to_finding() for v in violations]
430
+ except Exception as e:
431
+ logger.warning("Brain.scan failed: %s", e)
432
+
433
+ self._emit(
434
+ ScanProgress(
435
+ phase="graph",
436
+ message=(
437
+ f"{len(graph.nodes)} nodes · "
438
+ f"{len(circular_deps)} circular deps · "
439
+ f"{len(dead_files)} dead files"
440
+ ),
441
+ )
442
+ )
443
+ # ── Context phase: discover docs, infrastructure, dependencies ────────
444
+ self._emit(
445
+ ScanProgress(phase="context", message="Discovering documentation and infrastructure…")
446
+ )
447
+ context_data = self._discover_project_context(self.root, file_infos, report, stack)
448
+ report.project_context = context_data["context"]
449
+ report.active_security_domains = context_data["active_domains"]
450
+ report.infrastructure_files = context_data["infrastructure_files"]
451
+
452
+ self._emit(
453
+ ScanProgress(
454
+ phase="context",
455
+ message=(
456
+ f"{len(context_data['active_domains'])} security domain(s) activated · "
457
+ f"dep: {context_data['context'].get('deployment_model', 'local')}"
458
+ ),
459
+ )
460
+ )
461
+ # ── L2 Body tags + Understander (core-aware, not insertion order) ─
462
+ body_tags: dict[str, dict] = {}
463
+ try:
464
+ from patchi.core.brain.body_tags import build_body_tags, save_body_tags
465
+ from patchi.core.brain.understander import Understander
466
+
467
+ # Need layers already built (previous phase sets report.layers at line ~410)
468
+ layers_dict = getattr(report, "layers", {}) or {}
469
+ body_tags = build_body_tags(
470
+ report.file_infos, report.import_graph, layers_dict, report.routes, report.blast_radius_map
471
+ )
472
+ save_body_tags(body_tags, self.root)
473
+ self._emit(ScanProgress(phase="context", message=f"Body tags: {len(body_tags)} files tagged"))
474
+ # stash for understander reuse
475
+ report.body_tags = body_tags # type: ignore[attr-defined]
476
+ # Understander instance for enriched + contract phases
477
+ _understander = Understander(self.root, report.file_infos, body_tags, report.blast_radius_map, report.routes)
478
+ report._understander = _understander # type: ignore[attr-defined]
479
+ except Exception as exc: # noqa: BLE001
480
+ logger.warning("body_tags/understander failed: %s", exc)
481
+ body_tags = {}
482
+ # keep report._understander unset -> fallbacks use slicing
483
+
484
+ # ── L1 Enriched context (one AI call, offline-safe) ──────────────────
485
+ try:
486
+ from patchi.core.brain.enriched_context import enrich_project_context
487
+
488
+ cfg_for_ai: dict = {}
489
+ try:
490
+ cfg_for_ai = cfg.load(self.root)
491
+ if not isinstance(cfg_for_ai, dict):
492
+ cfg_for_ai = {}
493
+ except Exception:
494
+ cfg_for_ai = {}
495
+ # L1 spec: ProjectInsight + StackInfo + layer summaries (1 call, 1s timeout honoured)
496
+ _core_hint: dict = {}
497
+ if getattr(report, "_understander", None) is not None:
498
+ try:
499
+ _u_block = report._understander.as_prompt_block(limit=8) # type: ignore[attr-defined]
500
+ _core_hint["core_files_block"] = _u_block
501
+ except Exception as _exc:
502
+ _log.debug('suppressed: %s', _exc)
503
+ # ProjectInsight
504
+ try:
505
+ from patchi.core.brain.project_reader import read_project_insight
506
+
507
+ _pi = read_project_insight(self.root)
508
+ _core_hint["project_insight"] = _pi.to_dict()
509
+ except Exception as _exc:
510
+ _log.debug('suppressed: %s', _exc)
511
+ # Layer summaries (up to 10)
512
+ try:
513
+ _layer_summ = []
514
+ for lname, lyr in getattr(report, "layers", {}).items():
515
+ _layer_summ.append({"name": lname, "level": getattr(lyr, "level", 0), "summary": getattr(lyr, "summary", "")[:220]})
516
+ if len(_layer_summ) >= 10:
517
+ break
518
+ _core_hint["layer_summaries"] = _layer_summ
519
+ except Exception as _exc:
520
+ _log.debug('suppressed: %s', _exc)
521
+ report.enriched_context = enrich_project_context(
522
+ self.root,
523
+ cfg_for_ai,
524
+ report.stack,
525
+ report.routes,
526
+ report.file_infos,
527
+ report.project_context,
528
+ report.active_security_domains,
529
+ extra_context=_core_hint,
530
+ )
531
+ self._emit(
532
+ ScanProgress(
533
+ phase="context",
534
+ message=f"Enriched: {report.enriched_context.get('domain','?')} "
535
+ f"({report.enriched_context.get('source','?')})",
536
+ )
537
+ )
538
+ except Exception as exc: # noqa: BLE001
539
+ logger.warning("enriched_context failed, heuristic fallback: %s", exc)
540
+ report.enriched_context = {
541
+ "purpose_1sent": report.project_context.get("purpose", "") if isinstance(report.project_context, dict) else "",
542
+ "source": "heuristic_wire_fallback",
543
+ }
544
+ # ── Project purpose (AI-powered or fallback) ──────────────────────────
545
+ self._emit(ScanProgress(phase="contract", message="Understanding project purpose…"))
546
+ report.project_purpose, report.project_domain = self._infer_project_purpose(
547
+ file_infos, stack, report
548
+ )
549
+
550
+ self._emit(ScanProgress(phase="contract", message="Understanding project…"))
551
+ builder = ContractBuilder(routes, file_infos, dead_files, circular_deps, root=self.root)
552
+
553
+ # Smart inference: reads project files first, then falls back
554
+ # to AI-powered, then pattern-based
555
+ config = {}
556
+ try:
557
+ config = cfg.load(self.root)
558
+ except RuntimeError:
559
+ config = {}
560
+
561
+ # Try project-aware inference first (reads README, package.json, etc.)
562
+ project_flows = builder.project_infer()
563
+ if project_flows:
564
+ inferred = project_flows
565
+ else:
566
+ # Fall back to AI-powered contract
567
+ ai_flows = builder.ai_infer(config)
568
+ inferred = ai_flows if ai_flows is not None else builder.infer()
569
+ report.inferred_flows = inferred
570
+
571
+ # Auto-lock contract if flows were inferred (non-interactive)
572
+ # This allows p fix to work without requiring --contract first
573
+ if inferred and not brain_mem.get("contract_locked"):
574
+ # Auto-confirm all non-suggested flows with medium+ confidence
575
+ auto_confirmed = [f for f in inferred if not f.suggested and f.confidence in ("high", "medium")]
576
+ if auto_confirmed:
577
+ for f in auto_confirmed:
578
+ f.confirmed = True
579
+ brain_mem["confirmed_flows"] = [f.to_dict() for f in auto_confirmed]
580
+ brain_mem["contract_locked"] = True
581
+ brain_mem["contract_auto_formed"] = True
582
+ logger.info("Auto-locked contract with %d flows", len(auto_confirmed))
583
+
584
+ # Load any previously confirmed flows from memory
585
+ if brain_mem.get("confirmed_flows"):
586
+ from patchi.core.brain.contract import flows_from_dict
587
+
588
+ report.confirmed_flows = flows_from_dict(brain_mem["confirmed_flows"])
589
+ save_freshness_snapshot(self.root, [fi.path for fi in file_infos])
590
+
591
+ # ── Documentation validation ──────────────────────────────────────────
592
+ self._emit(
593
+ ScanProgress(phase="doc-validation", message="Validating documentation against code…")
594
+ )
595
+ doc_result = self._run_doc_validation(self.root, file_infos, routes, config, brain_mem)
596
+ report.doc_validation = doc_result
597
+ brain_mem["doc_validation"] = doc_result
598
+
599
+ # ── Save to memory ────────────────────────────────────────────────────
600
+ report.duration_seconds = time.monotonic() - start_time
601
+ brain_data = report.summary_dict()
602
+ brain_data["import_graph"] = graph.to_dict()
603
+
604
+ # Preserve confirmed flows from previous scan if this is targeted
605
+ if area and brain_mem.get("confirmed_flows"):
606
+ brain_data["confirmed_flows"] = brain_mem["confirmed_flows"]
607
+
608
+ # Persist doc validation results
609
+ if brain_mem.get("doc_validation"):
610
+ brain_data["doc_validation"] = brain_mem["doc_validation"]
611
+
612
+ mem.save_brain(brain_data, self.root)
613
+
614
+ # ── Build assurance graph (auto-populated each scan) ──────────────────
615
+ try:
616
+ from patchi.core.assurance.builder import build_assurance_graph
617
+
618
+ assurance_graph = build_assurance_graph(
619
+ self.root,
620
+ brain_data=brain_data,
621
+ route_map=[r.to_dict() for r in report.routes] if report.routes else None,
622
+ import_graph_data=graph.to_dict() if graph else None,
623
+ )
624
+ assurance_graph.save(self.root)
625
+ report.assurance_graph = (
626
+ assurance_graph.to_dict()
627
+ if hasattr(report, "assurance_graph")
628
+ else assurance_graph.to_dict()
629
+ )
630
+ except Exception as e:
631
+ logger.debug("Assurance graph build skipped: %s", e)
632
+
633
+ # ── Contract diff 3.1.3-7 — frontend ↔ backend mismatch (missing/orphan/method/param) ─
634
+ try:
635
+ from patchi.core.brain.contract_diff import build_and_save as _cd_build
636
+
637
+ _cd = _cd_build(self.root, report.file_infos, report.routes)
638
+ report.contract_diff = _cd.to_dict() # type: ignore[attr-defined]
639
+ # surface as findings for p findings / gate (only missing + orphan at medium)
640
+ from patchi.core.agents.base import Finding, Severity
641
+
642
+ _cd_findings = []
643
+ for m in _cd.missing_routes:
644
+ _cd_findings.append(
645
+ Finding(
646
+ agent="ContractDiff",
647
+ type="contract_missing_route",
648
+ severity=Severity.MEDIUM,
649
+ file=m.get("file", ""),
650
+ line=m.get("line", 0),
651
+ message=f"Frontend calls {m.get('method')} {m.get('raw')} with no backend route (404)",
652
+ cwe="CWE-444",
653
+ ).to_dict()
654
+ )
655
+ for o in _cd.orphan_endpoints:
656
+ _cd_findings.append(
657
+ Finding(
658
+ agent="ContractDiff",
659
+ type="contract_orphan_endpoint",
660
+ severity=Severity.LOW,
661
+ file=o.get("file", ""),
662
+ line=o.get("line", 0),
663
+ message=f"Backend {o.get('method')} {o.get('path')} never called by frontend (dead API)",
664
+ ).to_dict()
665
+ )
666
+ if _cd_findings:
667
+ mem.save_scan_result("ContractDiff", {"findings": _cd_findings}, self.root)
668
+ except Exception as exc: # noqa: BLE001
669
+ _log.debug("contract_diff failed: %s", exc)
670
+
671
+ # Persist the layered brain (Pillar 1) as a separate memory file, plus the
672
+ # file-content snapshot that powers incremental (no-op) rebuilds.
673
+ # L3: build RAG index at scan time (cached layer[].summary embeddings)
674
+ try:
675
+ if report.layers:
676
+ _layers_data = layers_to_dict(report.layers)
677
+ if _new_snap:
678
+ _layers_data["file_snapshot"] = _new_snap
679
+ # L3 RAG index: term frequencies per layer, stored for cosine query
680
+ try:
681
+ import re as _re
682
+
683
+ _rag_index: dict[str, dict] = {}
684
+ for _lname, _lyr in report.layers.items():
685
+ txt = f"{_lname} {getattr(_lyr,'summary','')} {getattr(_lyr,'purpose','')}".lower()
686
+ toks = [t for t in _re.findall(r"[a-z0-9_]+", txt) if len(t) > 2]
687
+ tf: dict[str, int] = {}
688
+ for t in toks:
689
+ tf[t] = tf.get(t, 0) + 1
690
+ _rag_index[_lname] = {"tf": tf, "summary": getattr(_lyr, "summary", "")[:500]}
691
+ _layers_data["rag_index"] = _rag_index
692
+ _layers_data["rag_index_version"] = 1
693
+ except Exception as _exc:
694
+ _log.debug('suppressed: %s', _exc)
695
+ mem.save_layers(_layers_data, self.root)
696
+ except Exception as e:
697
+ logger.warning("Brain.scan failed: %s", e)
698
+ # Persist charter violations as a scan result (Pillar 2) so they surface
699
+ # in the unified findings list for both humans and agents. Always saved
700
+ # (even if empty) to clear stale findings from a previous scan.
701
+ try:
702
+ mem.save_scan_result(
703
+ "CharterGuard",
704
+ {"findings": report.charter_violations},
705
+ self.root,
706
+ )
707
+ except Exception as e:
708
+ logger.warning("Brain.scan failed: %s", e)
709
+ mem.save_scan_result(
710
+ "Brain",
711
+ {
712
+ "file_count": report.file_count,
713
+ "route_count": report.route_count,
714
+ "duration": round(report.duration_seconds, 2),
715
+ "area": area,
716
+ },
717
+ self.root,
718
+ )
719
+
720
+ # Record health score to scan_history SQLite (M-09)
721
+ try:
722
+ from patchi.core.health import compute as compute_health
723
+ from patchi.core.security.history import patchi_record_scan
724
+
725
+ hs = compute_health(self.root)
726
+ # Flatten findings from scan results for history
727
+ scan_results = mem.get_scan_results(self.root)
728
+ all_findings = []
729
+ for _agent_name, data in scan_results.items():
730
+ for f in data.get("findings", []):
731
+ if isinstance(f, dict):
732
+ all_findings.append(f)
733
+ patchi_record_scan(
734
+ self.root,
735
+ "Brain",
736
+ all_findings,
737
+ duration_ms=int(report.duration_seconds * 1000),
738
+ health_score=hs.total,
739
+ )
740
+ except Exception as e:
741
+ logger.warning("Brain.scan failed: %s", e)
742
+
743
+ # Persist incremental scan caches (M-04)
744
+ try:
745
+ _save_ast_cache(self.root)
746
+ _save_file_info_cache(self.root)
747
+ except Exception as e:
748
+ logger.warning("Brain.scan failed: %s", e)
749
+
750
+ return report
751
+
752
+ def _run_doc_validation(
753
+ self,
754
+ root: Path,
755
+ file_infos: list[FileInfo],
756
+ routes: list[RouteInfo],
757
+ config: dict,
758
+ brain_mem: dict,
759
+ ) -> dict:
760
+ """Validate documentation against actual code.
761
+
762
+ Uses DocClaimAgent (LLM-based) when AI is available, falls back
763
+ to heuristic doc_validator for offline mode.
764
+ """
765
+ ai_config = config.get("ai", {})
766
+ has_ai = bool(ai_config.get("keys") or ai_config.get("local_model_name"))
767
+
768
+ if has_ai:
769
+ try:
770
+ from patchi.core.agents.base import AgentInput, AgentResult
771
+ from patchi.core.agents.doc_claim_agent import (
772
+ DocClaimAgent,
773
+ verify_claims_against_code,
774
+ )
775
+
776
+ agent = DocClaimAgent()
777
+ inp = AgentInput(
778
+ root=root,
779
+ scope=[],
780
+ brain=brain_mem,
781
+ config=config,
782
+ extra={},
783
+ )
784
+ result = AgentResult(agent_name="DocClaimAgent", agent_group="SCANNER")
785
+ agent._run(inp, result)
786
+
787
+ claims = result.data.get("claims", [])
788
+ if claims:
789
+ claims = verify_claims_against_code(
790
+ claims=claims,
791
+ file_infos=file_infos,
792
+ routes=routes,
793
+ config=config,
794
+ )
795
+ verified = [c for c in claims if c.get("verified")]
796
+ stale = [c for c in claims if not c.get("verified")]
797
+ doc_files = result.data.get("doc_files_found", [])
798
+ return {
799
+ "validated_claims": verified,
800
+ "stale_claims": stale,
801
+ "summary": (
802
+ f"DocClaimAgent: {len(verified)} verified, {len(stale)} stale "
803
+ f"across {len(doc_files)} doc file(s)."
804
+ ),
805
+ "doc_files_found": doc_files,
806
+ "total_claims": len(claims),
807
+ "method": "llm",
808
+ }
809
+
810
+ # LLM returned no claims but was available — use heuristic fallback
811
+ logger.info("DocClaimAgent returned no claims, falling back to heuristic")
812
+ except Exception as e:
813
+ logger.warning(f"DocClaimAgent failed: {e}")
814
+
815
+ # Fallback: heuristic doc_validator
816
+ try:
817
+ from patchi.core.brain.doc_validator import validate_project_docs
818
+
819
+ result = validate_project_docs(root, file_infos, routes, ai_config)
820
+ result["method"] = "heuristic"
821
+ return result
822
+ except Exception as e:
823
+ logger.warning(f"Doc validator fallback also failed: {e}")
824
+ return {
825
+ "validated_claims": [],
826
+ "stale_claims": [],
827
+ "summary": "Doc validation unavailable",
828
+ "doc_files_found": [],
829
+ "total_claims": 0,
830
+ "method": "none",
831
+ }
832
+
833
+ def _infer_project_purpose(
834
+ self,
835
+ file_infos: list[FileInfo],
836
+ stack: StackInfo,
837
+ report: BrainReport,
838
+ ) -> tuple[str, str]:
839
+ """
840
+ Infer what the project does using AI (preferred) or fallback heuristics.
841
+ Returns (project_purpose, project_domain).
842
+ """
843
+ # Try AI first
844
+ try:
845
+ config = cfg.load(self.root)
846
+ ai_config = config.get("ai", {})
847
+ except RuntimeError:
848
+ config = {}
849
+ ai_config = {}
850
+ has_ai = bool(ai_config.get("keys")) or bool(ai_config.get("local_model_name"))
851
+ active_domains = report.active_security_domains
852
+ if has_ai:
853
+ purpose = self._ai_project_purpose(file_infos, stack, config, report.routes)
854
+ if purpose:
855
+ domain = self._domain_from_purpose(purpose, active_domains)
856
+ return purpose, domain
857
+
858
+ # Fallback: build purpose from frameworks, routes, and file purposes
859
+ frameworks = [f.name for f in (stack.frameworks if stack else [])]
860
+ fw = ", ".join(frameworks) if frameworks else "Unknown"
861
+
862
+ # Determine category from file purposes
863
+ purposes = [fi.purpose for fi in file_infos if fi.purpose]
864
+ has_routes = bool(report.routes)
865
+ has_cli = any("cli" in p.lower() for p in purposes)
866
+ has_web = any(
867
+ "web" in p.lower() or "route" in p.lower() or "api" in p.lower() or "http" in p.lower()
868
+ for p in purposes
869
+ )
870
+ has_test = any("test" in p.lower() for p in purposes)
871
+ has_security = any(
872
+ "security" in p.lower() or "scanner" in p.lower() or "audit" in p.lower()
873
+ for p in purposes
874
+ )
875
+ has_models = any(
876
+ "model" in p.lower() or "schema" in p.lower() or "database" in p.lower()
877
+ for p in purposes
878
+ )
879
+
880
+ # Build category description
881
+ parts = []
882
+ if has_web and has_routes:
883
+ parts.append("web application")
884
+ elif has_web:
885
+ parts.append("web-enabled project")
886
+ if has_cli:
887
+ parts.append("CLI tool")
888
+ if has_models:
889
+ parts.append("data-driven")
890
+ if has_security:
891
+ parts.append("security analysis tool")
892
+ if has_test:
893
+ parts.append("test framework")
894
+ if not parts:
895
+ parts.append("software project")
896
+
897
+ category = " + ".join(parts)
898
+ route_count = len(report.routes)
899
+ route_note = f" with {route_count} routes" if route_count > 0 else ""
900
+
901
+ purpose = f"A {fw} {category}{route_note}."
902
+ domain = self._domain_from_purpose(purpose, report.active_security_domains)
903
+ return purpose, domain
904
+
905
+ def _ai_project_purpose(
906
+ self,
907
+ file_infos: list[FileInfo],
908
+ stack: StackInfo,
909
+ config: dict,
910
+ routes: list[RouteInfo] | None = None,
911
+ ) -> str | None:
912
+ """Use AI to generate a one-sentence project purpose."""
913
+ from patchi.core.ai.prompts import Skill, get_system_prompt
914
+ from patchi.core.fix.base import _call_ai
915
+
916
+ # Summarise the top 30 files for context
917
+ lines = []
918
+ for fi in file_infos[:30]:
919
+ funcs = ", ".join(f.name for f in fi.functions[:3])
920
+ classes = ", ".join(c.name for c in fi.classes[:3])
921
+ parts = [fi.path]
922
+ if fi.purpose:
923
+ parts.append(f"({fi.purpose})")
924
+ if funcs:
925
+ parts.append(f"fns: [{funcs}]")
926
+ if classes:
927
+ parts.append(f"cls: [{classes}]")
928
+ lines.append(" ".join(parts))
929
+
930
+ frameworks = [f.name for f in (stack.frameworks if stack else [])]
931
+ fw = ", ".join(frameworks) if frameworks else "Unknown"
932
+ route_lines = [r.method + " " + r.path for r in (routes or [])[:20]]
933
+
934
+ prompt = (
935
+ "Analyse this codebase and answer in ONE SHORT SENTENCE what the project does. "
936
+ "Then on the next line, tell me the domain "
937
+ "(e.g. web-app, CLI-tool, library, game, dev-tool, mobile-app, data-pipeline).\n\n"
938
+ f"Framework: {fw}\n"
939
+ f"Total files: {len(file_infos)}\n"
940
+ f"Routes ({len(route_lines)} shown):\n" + "\n".join(route_lines) + "\n\n"
941
+ "Key files:\n" + "\n".join(lines) + "\n\n"
942
+ "Format:\n"
943
+ "PURPOSE: <one sentence>\n"
944
+ "DOMAIN: <domain>"
945
+ )
946
+ sys_prompt = get_system_prompt(Skill.SCAN_SUMMARY)
947
+ result = _call_ai(prompt, config, max_tokens=200, system_prompt=sys_prompt)
948
+ if not result:
949
+ return None
950
+
951
+ lines = result.strip().split("\n")
952
+ purpose = ""
953
+ domain = ""
954
+ for line in lines:
955
+ if line.upper().startswith("PURPOSE:"):
956
+ purpose = line.split(":", 1)[1].strip()
957
+ elif line.upper().startswith("DOMAIN:"):
958
+ domain = line.split(":", 1)[1].strip()
959
+
960
+ if purpose:
961
+ self.project_domain = domain
962
+ return purpose
963
+ return None
964
+
965
+ @staticmethod
966
+ def _domain_from_purpose(purpose: str, active_domains: list[str] | None = None) -> str:
967
+ """Infer a short domain label from a purpose sentence heuristically."""
968
+ p = purpose.lower()
969
+ if active_domains:
970
+ if "mobile" in active_domains:
971
+ return "mobile-app"
972
+ if "desktop-app" in active_domains:
973
+ return "desktop-app"
974
+ if "agent-orchestration" in active_domains:
975
+ return "agent-orchestrator"
976
+ if "github-app-bot" in active_domains:
977
+ return "github-app"
978
+ if "mcp-tool-surface" in active_domains:
979
+ return "mcp-tool"
980
+ if any(w in p for w in ("web", "http", "api", "rest", "server", "frontend")):
981
+ return "web-app"
982
+ if any(w in p for w in ("cli", "command line", "terminal")):
983
+ return "CLI-tool"
984
+ if any(w in p for w in ("library", "package", "sdk")):
985
+ return "library"
986
+ if any(w in p for w in ("game", "gaming")):
987
+ return "game"
988
+ if any(w in p for w in ("mobile", "android", "ios")):
989
+ return "mobile-app"
990
+ if any(w in p for w in ("data", "pipeline", "etl", "analytics")):
991
+ return "data-pipeline"
992
+ if any(w in p for w in ("dev", "developer", "tool")):
993
+ return "dev-tool"
994
+ return "unknown"
995
+
996
+ def _discover_project_context(
997
+ self,
998
+ root: Path,
999
+ file_infos: list[FileInfo],
1000
+ report: BrainReport,
1001
+ stack: StackInfo,
1002
+ ) -> dict:
1003
+ """
1004
+ Discover documentation, config files, infrastructure, and dependencies
1005
+ to build a rich project context dict and activate security domains.
1006
+
1007
+ This is the Brain's "context" phase — it gathers everything needed
1008
+ to determine what kind of project this is and which domains apply.
1009
+ """
1010
+ from patchi.core.brain.domain_activator import build_project_context
1011
+
1012
+ doc_files: list[str] = []
1013
+ config_files: list[str] = []
1014
+ infra_files: list[str] = []
1015
+ dependency_names: set[str] = set()
1016
+ detected_imports: set[str] = set()
1017
+ file_extensions: set[str] = set()
1018
+
1019
+ doc_patterns = {".md", ".rst", ".txt", ".adoc"}
1020
+ config_patterns = {".toml", ".json", ".cfg", ".conf", ".ini", ".env"}
1021
+ infra_keywords = [
1022
+ "dockerfile",
1023
+ "docker-compose",
1024
+ "kubernetes",
1025
+ "k8s",
1026
+ "deployment",
1027
+ "service.yaml",
1028
+ "terraform",
1029
+ "cloudformation",
1030
+ "pulumi",
1031
+ "github/",
1032
+ "gitlab-ci",
1033
+ "jenkinsfile",
1034
+ "circleci",
1035
+ "package-lock.json",
1036
+ "yarn.lock",
1037
+ "pnpm-lock.yaml",
1038
+ "requirements.txt",
1039
+ "Pipfile",
1040
+ "poetry.lock",
1041
+ "go.sum",
1042
+ "Cargo.lock",
1043
+ "Gemfile.lock",
1044
+ "serverless.yml",
1045
+ "template.yaml",
1046
+ "wrangler.toml",
1047
+ "manifest.json",
1048
+ ]
1049
+
1050
+ for fi in file_infos:
1051
+ rel = fi.path.replace("\\", "/")
1052
+ lower_path = rel.lower()
1053
+ ext = Path(rel).suffix
1054
+
1055
+ if ext in doc_patterns:
1056
+ doc_files.append(rel)
1057
+ elif ext in config_patterns:
1058
+ config_files.append(rel)
1059
+ if any(kw in lower_path for kw in infra_keywords):
1060
+ infra_files.append(rel)
1061
+
1062
+ file_extensions.add(ext if ext.startswith(".") else f".{ext}" if ext else "")
1063
+
1064
+ for imp in fi.imports:
1065
+ detected_imports.add(imp.name if hasattr(imp, "name") else str(imp))
1066
+
1067
+ file_extensions.discard("")
1068
+
1069
+ # Extract dependency names from package manifests
1070
+ for mf in ["package.json", "requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod"]:
1071
+ mf_path = root / mf
1072
+ if mf_path.exists():
1073
+ try:
1074
+ text = mf_path.read_text(encoding="utf-8", errors="replace")
1075
+ if mf == "package.json":
1076
+ import json
1077
+
1078
+ pkg = json.loads(text)
1079
+ for section in ("dependencies", "devDependencies", "peerDependencies"):
1080
+ for name in pkg.get(section, {}):
1081
+ dependency_names.add(name.lower())
1082
+ else:
1083
+ for line in text.splitlines():
1084
+ line = line.strip()
1085
+ if line and not line.startswith(("#", "//", "--", "[")):
1086
+ if "=" in line:
1087
+ dependency_names.add(line.split("=")[0].strip().lower())
1088
+ elif ":" in line:
1089
+ pass # skip JSON-like lines
1090
+ elif line.startswith("require ") or line.startswith("module "):
1091
+ pass # skip Go module directives
1092
+ else:
1093
+ name = line.split(">=")[0].split("~=")[0].split("==")[0].strip()
1094
+ if name and not name.startswith(("-", "_", ".")):
1095
+ dependency_names.add(name.lower())
1096
+ except Exception as e:
1097
+ logger.warning("Brain._discover_project_context failed: %s", e)
1098
+
1099
+ # Determine framework flags
1100
+ has_web = bool(stack.frameworks) and any(
1101
+ f.name.lower() in ("fastapi", "flask", "django", "express", "spring", "gin", "echo")
1102
+ for f in stack.frameworks
1103
+ )
1104
+ has_cli = bool(stack.frameworks) and any(
1105
+ f.name.lower() in ("click", "typer", "argparse", "commander", "cobra", "urfave/cli")
1106
+ for f in stack.frameworks
1107
+ )
1108
+ has_mobile = any(
1109
+ ext in (".kt", ".kts", ".swift", ".dart", ".java", ".gradle") for ext in file_extensions
1110
+ )
1111
+
1112
+ route_paths = [r.path for r in report.routes if hasattr(r, "path")]
1113
+ config_keys: set[str] = set()
1114
+ for cf in config_files:
1115
+ full_path = root / cf
1116
+ try:
1117
+ text = full_path.read_text(encoding="utf-8", errors="replace")[:5000]
1118
+ for line in text.splitlines():
1119
+ if "=" in line or ":" in line:
1120
+ config_keys.add(line.split("=")[0].split(":")[0].strip().lower())
1121
+ except Exception as e:
1122
+ logger.warning("Brain._discover_project_context failed: %s", e)
1123
+
1124
+ context = build_project_context(
1125
+ file_infos=file_infos,
1126
+ detected_imports=detected_imports,
1127
+ dependency_names=dependency_names,
1128
+ route_paths=route_paths,
1129
+ config_keys=config_keys,
1130
+ infrastructure_files=infra_files,
1131
+ has_web_framework=has_web,
1132
+ has_cli_framework=has_cli,
1133
+ has_mobile_code=has_mobile,
1134
+ file_extensions=file_extensions,
1135
+ )
1136
+
1137
+ return {
1138
+ "context": context,
1139
+ "active_domains": context.get("relevant_domains", []),
1140
+ "infrastructure_files": infra_files,
1141
+ "doc_files": doc_files,
1142
+ "config_files": config_files,
1143
+ }
1144
+
1145
+ def _emit(self, progress: ScanProgress) -> None:
1146
+ try:
1147
+ self.on_progress(progress)
1148
+ except Exception as e:
1149
+ logger.warning("Brain._emit failed: %s", e)
1150
+
1151
+
1152
+ # ── Helpers ────────────────────────────────────────────────────────────────────
1153
+
1154
+
1155
+ def _count_languages(file_infos: list[FileInfo]) -> dict[str, int]:
1156
+ counts: dict[str, int] = {}
1157
+ for fi in file_infos:
1158
+ lang = fi.language.value
1159
+ counts[lang] = counts.get(lang, 0) + 1
1160
+ # Sort by count descending
1161
+ return dict(sorted(counts.items(), key=lambda x: x[1], reverse=True))