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,2136 @@
1
+ """
2
+ Brain file scanner.
3
+
4
+ Walks the project directory. Discovers all source files.
5
+ Extracts structured data from each: imports, exports, functions, classes, routes.
6
+
7
+ Parsing strategy:
8
+ All code languages (Python, JS/TS, Rust, Svelte, Java, Go, C/C++, Swift, Ruby,
9
+ PHP, C#, Kotlin, Dart, Bash, CSS, SQL):
10
+ tree-sitter AST (accurate, structural)
11
+ Config/data languages (JSON, YAML):
12
+ stdlib parsing (fit-for-purpose)
13
+ HTML:
14
+ stdlib html.parser
15
+
16
+ Output per file (FileInfo):
17
+ path, language, size, imports, exports, functions, classes, is_entry_point, purpose
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import ast as py_ast
22
+ import hashlib
23
+ import logging
24
+ import os
25
+ import re
26
+ from concurrent.futures import ProcessPoolExecutor, as_completed
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from patchi.core.brain.file_corpus import FileCorpus
32
+ from patchi.core.brain.languages import DEFAULT_IGNORE_DIRS as _LANG_IGNORE_DIRS
33
+ from patchi.core.brain.languages import Lang, detect_language, get_parser
34
+
35
+ # ── Data model ─────────────────────────────────────────────────────────────────
36
+
37
+
38
+ _log = logging.getLogger("patchi.brain.scanner")
39
+
40
+
41
+ @dataclass
42
+ class ImportInfo:
43
+ source: str # e.g. "os", "./auth", "@/components/Button"
44
+ names: list[str] # e.g. ["path", "getcwd"] or ["default"] for default import
45
+ is_relative: bool
46
+ line: int = 0
47
+
48
+
49
+ @dataclass
50
+ class FunctionInfo:
51
+ name: str
52
+ line: int
53
+ is_async: bool = False
54
+ params: list[str] = field(default_factory=list)
55
+ decorators: list[str] = field(default_factory=list)
56
+
57
+
58
+ @dataclass
59
+ class ClassInfo:
60
+ name: str
61
+ line: int
62
+ bases: list[str] = field(default_factory=list)
63
+
64
+
65
+ @dataclass
66
+ class FileInfo:
67
+ path: str # relative to project root
68
+ language: Lang
69
+ size_bytes: int
70
+ lines: int
71
+ imports: list[ImportInfo] = field(default_factory=list)
72
+ exports: list[str] = field(default_factory=list)
73
+ functions: list[FunctionInfo] = field(default_factory=list)
74
+ classes: list[ClassInfo] = field(default_factory=list)
75
+ is_entry_point: bool = False
76
+ purpose: str = "" # one-sentence plain English
77
+ error: str | None = None # if parsing failed
78
+
79
+ def to_dict(self) -> dict:
80
+ return {
81
+ "path": self.path,
82
+ "language": self.language.value,
83
+ "size_bytes": self.size_bytes,
84
+ "lines": self.lines,
85
+ "imports": [
86
+ {"source": i.source, "names": i.names, "is_relative": i.is_relative, "line": i.line}
87
+ for i in self.imports
88
+ ],
89
+ "exports": self.exports,
90
+ "functions": [
91
+ {
92
+ "name": f.name,
93
+ "line": f.line,
94
+ "is_async": f.is_async,
95
+ "params": f.params,
96
+ "decorators": f.decorators,
97
+ }
98
+ for f in self.functions
99
+ ],
100
+ "classes": [{"name": c.name, "line": c.line, "bases": c.bases} for c in self.classes],
101
+ "is_entry_point": self.is_entry_point,
102
+ "purpose": self.purpose,
103
+ "error": self.error,
104
+ }
105
+
106
+
107
+ # ── Shared dispatch ────────────────────────────────────────────────────────────
108
+
109
+
110
+ def _parse_by_language(source: str, info: FileInfo, lang: Lang) -> None:
111
+ """Dispatch to the appropriate language parser."""
112
+ if lang == Lang.PYTHON:
113
+ _parse_python(source, info)
114
+ elif lang in (Lang.JAVASCRIPT, Lang.TYPESCRIPT):
115
+ _parse_js_ts(source, lang, info)
116
+ elif lang == Lang.RUST:
117
+ _parse_rust(source, info)
118
+ elif lang == Lang.SVELTE:
119
+ _parse_svelte(source, info)
120
+ elif lang == Lang.JAVA:
121
+ _parse_java(source, info)
122
+ elif lang == Lang.GO:
123
+ _parse_go(source, info)
124
+ elif lang in (Lang.C, Lang.CPP):
125
+ _parse_c_cpp(source, info)
126
+ elif lang == Lang.SWIFT:
127
+ _parse_swift(source, info)
128
+ elif lang == Lang.RUBY:
129
+ _parse_ruby(source, info)
130
+ elif lang == Lang.JSON:
131
+ _parse_json(source, info)
132
+ elif lang == Lang.YAML:
133
+ _parse_yaml(source, info)
134
+ elif lang == Lang.PHP:
135
+ _parse_php(source, info)
136
+ elif lang == Lang.C_SHARP:
137
+ _parse_csharp(source, info)
138
+ elif lang == Lang.KOTLIN:
139
+ _parse_kotlin(source, info)
140
+ elif lang == Lang.DART:
141
+ _parse_dart(source, info)
142
+ elif lang == Lang.BASH:
143
+ _parse_bash(source, info)
144
+ elif lang == Lang.CSS:
145
+ _parse_css(source, info)
146
+ elif lang == Lang.SQL:
147
+ _parse_sql(source, info)
148
+ elif lang == Lang.HTML:
149
+ _parse_html(source, info)
150
+ elif lang == Lang.SCALA:
151
+ # No Scala grammar on PyPI yet -- regex is the intentional, permanent
152
+ # choice here (not a placeholder), per the language expansion plan.
153
+ _parse_scala_regex(source, info)
154
+ else:
155
+ _parse_generic(source, info)
156
+
157
+
158
+ # ── Default ignore patterns ────────────────────────────────────────────────────
159
+
160
+ DEFAULT_IGNORE_DIRS = _LANG_IGNORE_DIRS
161
+
162
+ DEFAULT_IGNORE_EXTS = {
163
+ ".pyc",
164
+ ".pyo",
165
+ ".pyd",
166
+ ".so",
167
+ ".dll",
168
+ ".dylib",
169
+ ".png",
170
+ ".jpg",
171
+ ".jpeg",
172
+ ".gif",
173
+ ".webp",
174
+ ".svg",
175
+ ".ico",
176
+ ".woff",
177
+ ".woff2",
178
+ ".ttf",
179
+ ".eot",
180
+ ".mp4",
181
+ ".mp3",
182
+ ".wav",
183
+ ".ogg",
184
+ ".zip",
185
+ ".tar",
186
+ ".gz",
187
+ ".bz2",
188
+ ".7z",
189
+ ".pdf",
190
+ ".doc",
191
+ ".docx",
192
+ ".xls",
193
+ ".xlsx",
194
+ ".lock", # package-lock.json, yarn.lock, etc. — too noisy
195
+ ".map", # source maps
196
+ ".min.js", # minified — detected via endswith below
197
+ }
198
+
199
+ MAX_FILE_SIZE = 2 * 1024 * 1024 # 2MB — skip files larger than this
200
+
201
+
202
+ # ── Parallel scanning support ──────────────────────────────────────────────────
203
+
204
+ _file_hash_cache: dict[str, str] = {} # rel_path -> content hash
205
+ _file_info_cache: dict[str, dict] = {} # rel_path -> serialized FileInfo dict
206
+
207
+
208
+ def _load_ast_cache(root: Path) -> None:
209
+ """Load persisted AST hash cache from .patchi/ast_cache.json."""
210
+ global _file_hash_cache
211
+ cache_file = root / ".patchi" / "ast_cache.json"
212
+ if cache_file.exists():
213
+ try:
214
+ import json
215
+
216
+ with open(cache_file, encoding="utf-8") as f:
217
+ _file_hash_cache = json.load(f)
218
+ except (json.JSONDecodeError, OSError):
219
+ _file_hash_cache = {}
220
+
221
+
222
+ def _save_ast_cache(root: Path) -> None:
223
+ """Persist AST hash cache to .patchi/ast_cache.json."""
224
+ cache_file = root / ".patchi" / "ast_cache.json"
225
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
226
+ try:
227
+ import json
228
+
229
+ with open(cache_file, "w", encoding="utf-8") as f:
230
+ json.dump(_file_hash_cache, f)
231
+ except OSError:
232
+ pass
233
+
234
+
235
+ def _load_file_info_cache(root: Path) -> None:
236
+ """Load persisted file info cache from .patchi/file_info_cache.json."""
237
+ global _file_info_cache
238
+ cache_file = root / ".patchi" / "file_info_cache.json"
239
+ if cache_file.exists():
240
+ try:
241
+ import json
242
+
243
+ with open(cache_file, encoding="utf-8") as f:
244
+ _file_info_cache = json.load(f)
245
+ except (json.JSONDecodeError, OSError):
246
+ _file_info_cache = {}
247
+
248
+
249
+ def _save_file_info_cache(root: Path) -> None:
250
+ """Persist file info cache to .patchi/file_info_cache.json."""
251
+ cache_file = root / ".patchi" / "file_info_cache.json"
252
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
253
+ try:
254
+ import json
255
+
256
+ with open(cache_file, "w", encoding="utf-8") as f:
257
+ json.dump(_file_info_cache, f)
258
+ except OSError:
259
+ pass
260
+
261
+
262
+ def _file_content_hash(path: Path) -> str:
263
+ """Compute MD5 hash of file content for caching."""
264
+ h = hashlib.md5()
265
+ with open(path, "rb") as f:
266
+ for chunk in iter(lambda: f.read(8192), b""):
267
+ h.update(chunk)
268
+ return h.hexdigest()
269
+
270
+
271
+ def _scan_single_file(args: tuple[str, str]) -> dict:
272
+ """
273
+ Standalone worker function for parallel scanning.
274
+ Args: (root_str, file_path_str)
275
+ Returns: serialized FileInfo dict
276
+ """
277
+ root_str, file_path_str = args
278
+ root = Path(root_str)
279
+ path = Path(file_path_str)
280
+ rel_path = path.relative_to(root).as_posix()
281
+ lang = detect_language(path)
282
+
283
+ try:
284
+ stat = path.stat()
285
+ if stat.st_size > MAX_FILE_SIZE:
286
+ return {
287
+ "path": rel_path,
288
+ "language": lang.value,
289
+ "size_bytes": stat.st_size,
290
+ "lines": 0,
291
+ "error": f"File too large ({stat.st_size // 1024}KB), skipped",
292
+ }
293
+
294
+ source = path.read_text(encoding="utf-8", errors="replace")
295
+ except OSError as e:
296
+ return {
297
+ "path": rel_path,
298
+ "language": lang.value,
299
+ "size_bytes": 0,
300
+ "lines": 0,
301
+ "error": str(e),
302
+ }
303
+
304
+ lines = source.rstrip("\n").count("\n") + 1 if source.strip() else 0
305
+ size = len(source.encode("utf-8"))
306
+
307
+ info = FileInfo(path=rel_path, language=lang, size_bytes=size, lines=lines)
308
+
309
+ try:
310
+ _parse_by_language(source, info, lang)
311
+ except Exception as e:
312
+ info.error = f"Parse error: {e}"
313
+
314
+ info.is_entry_point = _is_entry_point(path, info)
315
+ info.purpose = _infer_purpose(path, info)
316
+
317
+ return info.to_dict()
318
+
319
+
320
+ # ── Scanner ────────────────────────────────────────────────────────────────────
321
+
322
+
323
+ class FileScanner:
324
+ """
325
+ Walks a project directory and parses all source files.
326
+
327
+ Usage:
328
+ scanner = FileScanner(project_root)
329
+ files = scanner.scan() # scan all
330
+ files = scanner.scan("src/auth") # targeted scan
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ root: Path,
336
+ ignore_dirs: set[str] | None = None,
337
+ ignore_paths: list[str] | None = None,
338
+ max_depth: int | None = None,
339
+ max_workers: int | None = None,
340
+ corpus: FileCorpus | None = None,
341
+ ):
342
+ self.root = root
343
+ self.ignore_dirs = (ignore_dirs or set()) | DEFAULT_IGNORE_DIRS
344
+ self.ignore_paths = set(ignore_paths or [])
345
+ self.max_depth = max_depth
346
+ self.max_workers = max_workers
347
+ self._corpus = corpus
348
+
349
+ def discover(self, area: str | None = None) -> list[Path]:
350
+ """
351
+ Return all source file paths under area (relative to root),
352
+ or the full project if area is None.
353
+ Applies ignore rules. Does not parse.
354
+ Uses FileCorpus when available for full-project scans.
355
+ """
356
+ if area is None and self._corpus is not None:
357
+ return self._discover_from_corpus()
358
+
359
+ start = self.root
360
+ if area:
361
+ candidate = self.root / area
362
+ if candidate.exists():
363
+ start = candidate
364
+
365
+ paths: list[Path] = []
366
+ base_depth = len(start.parts)
367
+
368
+ for dirpath, dirnames, filenames in os.walk(start):
369
+ current = Path(dirpath)
370
+
371
+ # Depth limit
372
+ if self.max_depth is not None:
373
+ depth = len(current.parts) - base_depth
374
+ if depth >= self.max_depth:
375
+ dirnames.clear()
376
+ continue
377
+
378
+ # Prune ignored dirs
379
+ dirnames[:] = [
380
+ d
381
+ for d in dirnames
382
+ if d not in self.ignore_dirs
383
+ and (current / d).relative_to(self.root).as_posix() not in self.ignore_paths
384
+ ]
385
+
386
+ for fname in filenames:
387
+ fpath = current / fname
388
+ rel = fpath.relative_to(self.root)
389
+
390
+ # Skip ignored paths
391
+ if any(rel.as_posix().startswith(p) for p in self.ignore_paths):
392
+ continue
393
+
394
+ # Skip minified files
395
+ if fname.endswith(".min.js") or fname.endswith(".min.css"):
396
+ continue
397
+
398
+ # Skip by extension
399
+ if fpath.suffix.lower() in DEFAULT_IGNORE_EXTS:
400
+ continue
401
+
402
+ # Skip unknown language files
403
+ lang = detect_language(fpath)
404
+ if lang == Lang.UNKNOWN:
405
+ continue
406
+
407
+ paths.append(fpath)
408
+
409
+ return paths
410
+
411
+ def _discover_from_corpus(self) -> list[Path]:
412
+ paths: list[Path] = []
413
+ ignore = self.ignore_paths
414
+ for entry in self._corpus.entries.values():
415
+ if any(entry.path.startswith(p) for p in ignore):
416
+ continue
417
+ fname = Path(entry.path).name
418
+ if fname.endswith(".min.js") or fname.endswith(".min.css"):
419
+ continue
420
+ paths.append(self.root / entry.path)
421
+ return paths
422
+
423
+ def scan_file(self, path: Path, use_cache: bool = True) -> FileInfo:
424
+ """Parse a single file and return a FileInfo.
425
+
426
+ When use_cache is True and the file content hash matches the previous
427
+ scan, the cached FileInfo dict is restored instead of re-parsing.
428
+ """
429
+ rel_path = path.relative_to(self.root).as_posix()
430
+ lang = detect_language(path)
431
+
432
+ # Incremental caching: skip re-parsing unchanged files (M-04)
433
+ if use_cache:
434
+ try:
435
+ content_hash = _file_content_hash(path)
436
+ if rel_path in _file_hash_cache and _file_hash_cache[rel_path] == content_hash:
437
+ cached = _file_info_cache.get(rel_path)
438
+ if cached and cached.get("language") == lang.value:
439
+ return FileInfo(
440
+ path=cached["path"],
441
+ language=Lang(cached["language"]),
442
+ size_bytes=cached["size_bytes"],
443
+ lines=cached["lines"],
444
+ imports=[ImportInfo(**i) for i in cached.get("imports", [])],
445
+ exports=cached.get("exports", []),
446
+ functions=[FunctionInfo(**f) for f in cached.get("functions", [])],
447
+ classes=[ClassInfo(**c) for c in cached.get("classes", [])],
448
+ is_entry_point=cached.get("is_entry_point", False),
449
+ purpose=cached.get("purpose", ""),
450
+ error=cached.get("error"),
451
+ )
452
+ except OSError:
453
+ pass
454
+
455
+ try:
456
+ stat = path.stat()
457
+ if stat.st_size > MAX_FILE_SIZE:
458
+ return FileInfo(
459
+ path=rel_path,
460
+ language=lang,
461
+ size_bytes=stat.st_size,
462
+ lines=0,
463
+ error=f"File too large ({stat.st_size // 1024}KB), skipped",
464
+ )
465
+
466
+ if self._corpus is not None:
467
+ cached = self._corpus.read(rel_path)
468
+ if cached is not None:
469
+ source = cached
470
+ else:
471
+ source = path.read_text(encoding="utf-8", errors="replace")
472
+ else:
473
+ source = path.read_text(encoding="utf-8", errors="replace")
474
+ except OSError as e:
475
+ return FileInfo(path=rel_path, language=lang, size_bytes=0, lines=0, error=str(e))
476
+
477
+ # Count lines: strip one trailing newline so "a\nb\nc\n" = 3 lines
478
+ lines = source.rstrip("\n").count("\n") + 1 if source.strip() else 0
479
+ size = len(source.encode("utf-8"))
480
+
481
+ info = FileInfo(path=rel_path, language=lang, size_bytes=size, lines=lines)
482
+
483
+ try:
484
+ _parse_by_language(source, info, lang)
485
+ except Exception as e:
486
+ info.error = f"Parse error: {e}"
487
+
488
+ # Entry point detection
489
+ info.is_entry_point = _is_entry_point(path, info)
490
+ # Purpose inference
491
+ info.purpose = _infer_purpose(path, info)
492
+
493
+ # Cache the parsed result for incremental scanning (M-04)
494
+ if use_cache:
495
+ try:
496
+ content_hash = _file_content_hash(path)
497
+ _file_hash_cache[rel_path] = content_hash
498
+ _file_info_cache[rel_path] = info.to_dict()
499
+ except OSError:
500
+ pass
501
+
502
+ return info
503
+
504
+ def scan(self, area: str | None = None, on_progress: Any = None) -> list[FileInfo]:
505
+ """
506
+ Discover and parse all files. Returns list of FileInfo objects.
507
+ on_progress(current, total, path) — called for each file if provided.
508
+ Uses ProcessPoolExecutor for parallel parsing on large projects.
509
+ """
510
+ # Load persisted hash cache for unchanged file detection
511
+ _load_ast_cache(self.root)
512
+
513
+ paths = self.discover(area)
514
+ total = len(paths)
515
+
516
+ if total == 0:
517
+ return []
518
+
519
+ # For small projects or when workers limited to 1, use sequential
520
+ if total < 50 or self.max_workers == 1:
521
+ results: list[FileInfo] = []
522
+ for i, path in enumerate(paths):
523
+ if on_progress:
524
+ on_progress(i + 1, total, path)
525
+ fi = self.scan_file(path)
526
+ results.append(fi)
527
+ return results
528
+
529
+ # Parallel scanning with hash-based caching
530
+ root_str = str(self.root)
531
+ work_items = []
532
+ cached_results = []
533
+
534
+ for path in paths:
535
+ rel = path.relative_to(self.root).as_posix()
536
+ try:
537
+ content_hash = _file_content_hash(path)
538
+ if rel in _file_hash_cache and _file_hash_cache[rel] == content_hash:
539
+ # File unchanged — try to use cached result
540
+ # (cache stores dicts, we'll deserialize later)
541
+ cached_results.append((rel, content_hash))
542
+ continue
543
+ except OSError:
544
+ pass
545
+ work_items.append((root_str, str(path)))
546
+
547
+ # Process uncached files in parallel
548
+ parallel_results = {}
549
+ workers = self.max_workers or min(32, (os.cpu_count() or 4) + 4)
550
+
551
+ if work_items:
552
+ with ProcessPoolExecutor(max_workers=workers) as executor:
553
+ futures = {executor.submit(_scan_single_file, item): item[1] for item in work_items}
554
+ done_count = 0
555
+ for future in as_completed(futures):
556
+ done_count += 1
557
+ file_path_str = futures[future]
558
+ try:
559
+ result = future.result()
560
+ parallel_results[result["path"]] = result
561
+ # Update hash + info caches (M-04)
562
+ try:
563
+ rel = Path(file_path_str).relative_to(self.root).as_posix()
564
+ _file_hash_cache[rel] = _file_content_hash(Path(file_path_str))
565
+ _file_info_cache[rel] = result
566
+ except OSError:
567
+ pass
568
+ except Exception as e:
569
+ # Fallback: sequential parse of this file
570
+ _log.warning("FileScanner.scan failed: %s", e)
571
+ try:
572
+ p = Path(file_path_str)
573
+ fi = self.scan_file(p)
574
+ parallel_results[fi.path] = fi.to_dict()
575
+ except Exception as e:
576
+ _log.warning("FileScanner.scan failed: %s", e)
577
+ if on_progress:
578
+ on_progress(
579
+ done_count + len(cached_results), total, Path(file_path_str).name
580
+ )
581
+
582
+ # Build final results list (preserving discover order)
583
+ results = []
584
+ for path in paths:
585
+ rel = path.relative_to(self.root).as_posix()
586
+ if rel in parallel_results:
587
+ d = parallel_results[rel]
588
+ fi = FileInfo(
589
+ path=d["path"],
590
+ language=Lang(d["language"]),
591
+ size_bytes=d["size_bytes"],
592
+ lines=d["lines"],
593
+ is_entry_point=d.get("is_entry_point", False),
594
+ purpose=d.get("purpose", ""),
595
+ error=d.get("error"),
596
+ )
597
+ fi.imports = [ImportInfo(**i) for i in d.get("imports", [])]
598
+ fi.exports = d.get("exports", [])
599
+ fi.functions = [FunctionInfo(**f) for f in d.get("functions", [])]
600
+ fi.classes = [ClassInfo(**c) for c in d.get("classes", [])]
601
+ results.append(fi)
602
+ else:
603
+ # Sequential fallback for any remaining
604
+ fi = self.scan_file(path)
605
+ results.append(fi)
606
+
607
+ # Persist caches for next scan (M-04)
608
+ try:
609
+ _save_ast_cache(self.root)
610
+ _save_file_info_cache(self.root)
611
+ except Exception as e:
612
+ _log.warning("FileScanner.scan failed: %s", e)
613
+ return results
614
+
615
+
616
+ # ── Python parser (using stdlib ast) ──────────────────────────────────────────
617
+
618
+
619
+ def _parse_python(source: str, info: FileInfo) -> None:
620
+ try:
621
+ tree = py_ast.parse(source, type_comments=False)
622
+ except SyntaxError as e:
623
+ info.error = f"SyntaxError: {e}"
624
+ return
625
+
626
+ for node in py_ast.walk(tree):
627
+ # Imports
628
+ if isinstance(node, py_ast.Import):
629
+ for alias in node.names:
630
+ info.imports.append(
631
+ ImportInfo(
632
+ source=alias.name,
633
+ names=[alias.asname or alias.name.split(".")[0]],
634
+ is_relative=False,
635
+ line=node.lineno,
636
+ )
637
+ )
638
+ elif isinstance(node, py_ast.ImportFrom):
639
+ module = node.module or ""
640
+ names = [a.name for a in node.names]
641
+ info.imports.append(
642
+ ImportInfo(
643
+ source=("." * (node.level or 0)) + module,
644
+ names=names,
645
+ is_relative=(node.level or 0) > 0,
646
+ line=node.lineno,
647
+ )
648
+ )
649
+
650
+ # Functions
651
+ elif isinstance(node, (py_ast.FunctionDef, py_ast.AsyncFunctionDef)):
652
+ if isinstance(node, py_ast.AsyncFunctionDef) or _is_top_or_class(node, tree):
653
+ decos = [_deco_name(d) for d in node.decorator_list]
654
+ params = [a.arg for a in node.args.args]
655
+ info.functions.append(
656
+ FunctionInfo(
657
+ name=node.name,
658
+ line=node.lineno,
659
+ is_async=isinstance(node, py_ast.AsyncFunctionDef),
660
+ params=params,
661
+ decorators=decos,
662
+ )
663
+ )
664
+
665
+ # Classes
666
+ elif isinstance(node, py_ast.ClassDef):
667
+ bases = [_name_of(b) for b in node.bases]
668
+ info.classes.append(
669
+ ClassInfo(
670
+ name=node.name,
671
+ line=node.lineno,
672
+ bases=bases,
673
+ )
674
+ )
675
+
676
+ # Module-level __all__ → exports
677
+ elif isinstance(node, py_ast.Assign) and any(
678
+ isinstance(t, py_ast.Name) and t.id == "__all__" for t in node.targets
679
+ ):
680
+ if isinstance(node.value, (py_ast.List, py_ast.Tuple)):
681
+ info.exports = [
682
+ elt.value
683
+ for elt in node.value.elts
684
+ if isinstance(elt, py_ast.Constant) and isinstance(elt.value, str)
685
+ ]
686
+
687
+
688
+ def _is_top_or_class(node: py_ast.AST, tree: py_ast.Module) -> bool:
689
+ """True if the function is at module or class level (not nested in another function)."""
690
+ return True # simplified — walk always gives us all defs, filtering by nesting is complex
691
+
692
+
693
+ def _deco_name(node: py_ast.expr) -> str:
694
+ if isinstance(node, py_ast.Name):
695
+ return node.id
696
+ if isinstance(node, py_ast.Attribute):
697
+ return f"{_name_of(node.value)}.{node.attr}"
698
+ if isinstance(node, py_ast.Call):
699
+ return _deco_name(node.func)
700
+ return ""
701
+
702
+
703
+ def _name_of(node: py_ast.expr) -> str:
704
+ if isinstance(node, py_ast.Name):
705
+ return node.id
706
+ if isinstance(node, py_ast.Attribute):
707
+ return f"{_name_of(node.value)}.{node.attr}"
708
+ return ""
709
+
710
+
711
+ # ── JavaScript / TypeScript parser (tree-sitter) ──────────────────────────────
712
+
713
+
714
+ def _parse_js_ts(source: str, lang: Lang, info: FileInfo) -> None:
715
+ parser = get_parser(lang)
716
+ if parser is None:
717
+ for m in re.finditer(r"""import\s+.*?\s+from\s+['"]([^'"]+)['"]""", source):
718
+ info.imports.append(
719
+ ImportInfo(
720
+ source=m.group(1),
721
+ names=["*"],
722
+ is_relative=m.group(1).startswith("."),
723
+ line=source[: m.start()].count("\n") + 1,
724
+ )
725
+ )
726
+ for m in re.finditer(r"""require\(['"]([^'"]+)['"]\)""", source):
727
+ info.imports.append(
728
+ ImportInfo(
729
+ source=m.group(1),
730
+ names=["*"],
731
+ is_relative=m.group(1).startswith("."),
732
+ line=source[: m.start()].count("\n") + 1,
733
+ )
734
+ )
735
+ return
736
+
737
+ tree = parser.parse(source.encode("utf-8"))
738
+ _walk_js_node(tree.root_node, source, info)
739
+
740
+
741
+ def _walk_js_node(node: Any, source: str, info: FileInfo) -> None:
742
+ """Recursively walk a JS/TS tree-sitter node."""
743
+ ntype = node.type
744
+
745
+ # Import statements: import X from 'y'
746
+ if ntype == "import_statement":
747
+ src = _js_import_source(node, source)
748
+ names = _js_import_names(node, source)
749
+ line = node.start_point[0] + 1
750
+ if src:
751
+ info.imports.append(
752
+ ImportInfo(
753
+ source=src,
754
+ names=names,
755
+ is_relative=src.startswith("."),
756
+ line=line,
757
+ )
758
+ )
759
+
760
+ # require() calls: const x = require('y')
761
+ elif ntype in ("call_expression", "new_expression"):
762
+ _extract_require(node, source, info)
763
+
764
+ # Function declarations
765
+ elif ntype in ("function_declaration", "generator_function_declaration"):
766
+ name = _js_child_text(node, "identifier", source)
767
+ if name:
768
+ info.functions.append(
769
+ FunctionInfo(
770
+ name=name,
771
+ line=node.start_point[0] + 1,
772
+ is_async=_has_child_type(node, "async"),
773
+ )
774
+ )
775
+
776
+ # Arrow functions assigned to variables
777
+ elif ntype == "lexical_declaration":
778
+ _extract_arrow_fn(node, source, info)
779
+
780
+ # Class declarations
781
+ elif ntype == "class_declaration":
782
+ name = _js_child_text(node, "identifier", source)
783
+ if name:
784
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
785
+
786
+ # Export declarations
787
+ elif ntype in ("export_statement", "export_default_declaration"):
788
+ _extract_export(node, source, info)
789
+
790
+ for child in node.children:
791
+ _walk_js_node(child, source, info)
792
+
793
+
794
+ def _js_import_source(node: Any, source: str) -> str:
795
+ for child in node.children:
796
+ if child.type in ("string", "template_string"):
797
+ return source[child.start_byte : child.end_byte].strip("'\"` ")
798
+ return ""
799
+
800
+
801
+ def _js_import_names(node: Any, source: str) -> list[str]:
802
+ names: list[str] = []
803
+ for child in node.children:
804
+ if child.type == "import_clause":
805
+ for sub in child.children:
806
+ if sub.type == "identifier":
807
+ names.append(source[sub.start_byte : sub.end_byte])
808
+ elif sub.type == "named_imports":
809
+ for item in sub.children:
810
+ if item.type == "import_specifier":
811
+ for n in item.children:
812
+ if n.type == "identifier":
813
+ names.append(source[n.start_byte : n.end_byte])
814
+ break
815
+ return names or ["*"]
816
+
817
+
818
+ def _extract_require(node: Any, source: str, info: FileInfo) -> None:
819
+ """Extract require('...') calls."""
820
+ func = None
821
+ args = []
822
+ for child in node.children:
823
+ if child.type == "identifier":
824
+ func = source[child.start_byte : child.end_byte]
825
+ elif child.type == "arguments":
826
+ for a in child.children:
827
+ if a.type in ("string", "template_string"):
828
+ args.append(source[a.start_byte : a.end_byte].strip("'\"` "))
829
+ if func == "require" and args:
830
+ info.imports.append(
831
+ ImportInfo(
832
+ source=args[0],
833
+ names=["*"],
834
+ is_relative=args[0].startswith("."),
835
+ line=node.start_point[0] + 1,
836
+ )
837
+ )
838
+
839
+
840
+ def _extract_arrow_fn(node: Any, source: str, info: FileInfo) -> None:
841
+ """Extract const foo = () => {} or const foo = async () => {}."""
842
+ for decl in node.children:
843
+ if decl.type == "variable_declarator":
844
+ name = None
845
+ for child in decl.children:
846
+ if child.type == "identifier" and name is None:
847
+ name = source[child.start_byte : child.end_byte]
848
+ elif child.type in ("arrow_function", "function") and name:
849
+ is_async = _has_child_type(child, "async")
850
+ info.functions.append(
851
+ FunctionInfo(
852
+ name=name,
853
+ line=node.start_point[0] + 1,
854
+ is_async=is_async,
855
+ )
856
+ )
857
+ break
858
+
859
+
860
+ def _extract_export(node: Any, source: str, info: FileInfo) -> None:
861
+ for child in node.children:
862
+ if child.type == "identifier":
863
+ name = source[child.start_byte : child.end_byte]
864
+ if name not in info.exports:
865
+ info.exports.append(name)
866
+
867
+
868
+ def _js_child_text(node: Any, child_type: str, source: str) -> str:
869
+ for child in node.children:
870
+ if child.type == child_type:
871
+ return source[child.start_byte : child.end_byte]
872
+ return ""
873
+
874
+
875
+ def _has_child_type(node: Any, child_type: str) -> bool:
876
+ return any(c.type == child_type for c in node.children)
877
+
878
+
879
+ # ── Rust parser (tree-sitter) ─────────────────────────────────────────────────
880
+
881
+
882
+ def _parse_rust(source: str, info: FileInfo) -> None:
883
+ parser = get_parser(Lang.RUST)
884
+ if parser is None:
885
+ _parse_generic(source, info)
886
+ return
887
+
888
+ tree = parser.parse(source.encode("utf-8"))
889
+ _walk_rust_node(tree.root_node, source, info)
890
+
891
+
892
+ def _walk_rust_node(node: Any, source: str, info: FileInfo) -> None:
893
+ ntype = node.type
894
+
895
+ if ntype == "use_declaration":
896
+ path = _node_text(node, source)
897
+ names = ["*"]
898
+ is_rel = path.starts_with("crate") or path.starts_with("self") or path.starts_with("super")
899
+ if path:
900
+ info.imports.append(
901
+ ImportInfo(
902
+ source=path, names=names, is_relative=is_rel, line=node.start_point[0] + 1
903
+ )
904
+ )
905
+
906
+ elif ntype == "extern_crate_declaration":
907
+ name = _node_text(node, source)
908
+ if name:
909
+ info.imports.append(
910
+ ImportInfo(
911
+ source=name.strip(";"),
912
+ names=["*"],
913
+ is_relative=False,
914
+ line=node.start_point[0] + 1,
915
+ )
916
+ )
917
+
918
+ elif ntype == "mod_item":
919
+ name = _node_text(node, source)
920
+ if name and ";" in name:
921
+ name = name.split(";")[0].strip()
922
+ parts = name.split()
923
+ if len(parts) >= 2 and parts[0] == "mod":
924
+ name = parts[1]
925
+ if name and name != "mod" and "{" not in name:
926
+ info.imports.append(
927
+ ImportInfo(source=name, names=["*"], is_relative=True, line=node.start_point[0] + 1)
928
+ )
929
+
930
+ elif ntype in ("function_item", "function_signature_item"):
931
+ name = _rust_child_text(node, "identifier", source)
932
+ if name:
933
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
934
+
935
+ elif ntype in ("struct_item", "enum_item", "trait_item", "type_item"):
936
+ name = _rust_child_text(node, "identifier", source)
937
+ if name:
938
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
939
+
940
+ elif ntype == "impl_item":
941
+ trait = _rust_child_text(node, "trait_type", source) or ""
942
+ for child in node.children:
943
+ if child.type in ("function_item", "function_signature_item"):
944
+ fn_name = _rust_child_text(child, "identifier", source)
945
+ if fn_name:
946
+ display = f"{trait}::{fn_name}" if trait else fn_name
947
+ info.functions.append(FunctionInfo(name=display, line=child.start_point[0] + 1))
948
+
949
+ elif ntype == "call_expression":
950
+ func = ""
951
+ for child in node.children:
952
+ if child.type in ("field_expression", "identifier", "scoped_identifier"):
953
+ func = _node_text(child, source)
954
+ break
955
+ if func and "." in func:
956
+ parts = func.split(".")
957
+ method = parts[-1]
958
+ if method in ("get", "post", "put", "delete", "patch", "route", "nest_service"):
959
+ for child in node.children:
960
+ if child.type == "arguments":
961
+ for arg in child.children:
962
+ atype = arg.type
963
+ if atype in ("string_literal", "raw_string_literal"):
964
+ route = _node_text(arg, source).strip("\"'")
965
+ if route and route.startswith("/"):
966
+ info.exports.append(f"route:{route}")
967
+
968
+ for child in node.children:
969
+ _walk_rust_node(child, source, info)
970
+
971
+
972
+ def _rust_child_text(node: Any, field_name: str, source: str) -> str:
973
+ for child in node.children:
974
+ if child.type == field_name:
975
+ return source[child.start_byte : child.end_byte]
976
+ return ""
977
+
978
+
979
+ # ── Svelte parser (tree-sitter) ──────────────────────────────────────────────
980
+
981
+
982
+ def _parse_svelte(source: str, info: FileInfo) -> None:
983
+ parser = get_parser(Lang.SVELTE)
984
+ if parser is None:
985
+ _parse_html(source, info)
986
+ return
987
+
988
+ tree = parser.parse(source.encode("utf-8"))
989
+ _walk_svelte_node(tree.root_node, source, info)
990
+
991
+
992
+ def _walk_svelte_node(node: Any, source: str, info: FileInfo) -> None:
993
+ ntype = node.type
994
+
995
+ if ntype == "script_element":
996
+ for child in node.children:
997
+ if child.type == "raw_text":
998
+ script_src = source[child.start_byte : child.end_byte]
999
+ _parse_js_ts(script_src, Lang.JAVASCRIPT, info)
1000
+
1001
+ elif ntype == "element":
1002
+ tag = ""
1003
+ for c in node.children:
1004
+ if c.type == "tag_name":
1005
+ tag = source[c.start_byte : c.end_byte]
1006
+ break
1007
+ if tag in ("a", "button", "input", "form", "select", "textarea", "nav"):
1008
+ info.exports.append(f"<{tag}>")
1009
+
1010
+ elif ntype == "html_element":
1011
+ pass # handled by children
1012
+
1013
+ for child in node.children:
1014
+ _walk_svelte_node(child, source, info)
1015
+
1016
+
1017
+ def _node_text(node: Any, source: str) -> str:
1018
+ buf = source.encode("utf-8")
1019
+ return buf[node.start_byte : node.end_byte].decode("utf-8", errors="replace").strip()
1020
+
1021
+
1022
+ # ── Tree-sitter helpers (shared by all language parsers) ───────────────────
1023
+
1024
+
1025
+ def _ts_node_text(node: Any, buf: bytes) -> str:
1026
+ try:
1027
+ if hasattr(node, "start_byte") and hasattr(node, "end_byte"):
1028
+ return buf[node.start_byte : node.end_byte].decode("utf-8", errors="replace")
1029
+ except Exception as e:
1030
+ _log.debug("_ts_node_text failed: %s", e)
1031
+ return ""
1032
+
1033
+
1034
+ def _ts_child_by_field(node: Any, field: str) -> Any | None:
1035
+ try:
1036
+ return node.child_by_field_name(field) if hasattr(node, "child_by_field_name") else None
1037
+ except Exception as e:
1038
+ _log.debug("_ts_child_by_field failed: %s", e)
1039
+ return None
1040
+
1041
+
1042
+ def _ts_children(node: Any) -> list[Any]:
1043
+ try:
1044
+ return list(node.children) if hasattr(node, "children") else []
1045
+ except Exception as e:
1046
+ _log.debug("_ts_children failed: %s", e)
1047
+ return []
1048
+
1049
+
1050
+ def _ts_node_type(node: Any) -> str:
1051
+ try:
1052
+ return node.type if hasattr(node, "type") else ""
1053
+ except Exception as e:
1054
+ _log.debug("_ts_node_type failed: %s", e)
1055
+ return ""
1056
+
1057
+
1058
+ def _ts_extract_annotations(node: Any, buf: bytes) -> list[str]:
1059
+ """Extract Java annotation/decorator text from a node."""
1060
+ annotations: list[str] = []
1061
+ for child in _ts_children(node):
1062
+ ctype = _ts_node_type(child)
1063
+ if ctype in ("marker_annotation", "annotation"):
1064
+ text = _ts_node_text(child, buf)
1065
+ if text:
1066
+ annotations.append(text.strip())
1067
+ return annotations
1068
+
1069
+
1070
+ def _ts_spring_route(annotation: str) -> str:
1071
+ """Extract route path from a Spring annotation like @GetMapping(\"/api/foo\")."""
1072
+ import re
1073
+
1074
+ m = re.search(
1075
+ r"@(?:Get|Post|Put|Delete|Patch|Request)Mapping\s*\(\s*[\"']([^\"']+)[\"']", annotation
1076
+ )
1077
+ return m.group(1) if m else ""
1078
+
1079
+
1080
+ # ── Java parser (tree-sitter) ─────────────────────────────────────────────
1081
+
1082
+
1083
+ def _parse_java(source: str, info: FileInfo) -> None:
1084
+ parser = get_parser(Lang.JAVA)
1085
+ if parser is None:
1086
+ _parse_java_regex(source, info)
1087
+ return
1088
+ tree = parser.parse(source.encode("utf-8"))
1089
+ _walk_java(tree.root_node, source.encode("utf-8"), info)
1090
+
1091
+
1092
+ def _parse_java_regex(source: str, info: FileInfo) -> None:
1093
+ """Fallback regex when tree-sitter is unavailable."""
1094
+ for m in re.finditer(r"^import\s+(?:static\s+)?([\w.]+(?:\*)?)\s*;", source, re.MULTILINE):
1095
+ info.imports.append(ImportInfo(source=m.group(1), names=["*"], is_relative=False))
1096
+ for m in re.finditer(r"class\s+(\w+)", source):
1097
+ info.classes.append(ClassInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1))
1098
+ for m in re.finditer(
1099
+ r"@(GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestMapping)\s*\(\s*[\"']([^\"']+)[\"']",
1100
+ source,
1101
+ ):
1102
+ info.exports.append(f"route:{m.group(2)}")
1103
+
1104
+
1105
+ def _walk_java(node: Any, buf: bytes, info: FileInfo) -> None:
1106
+ ntype = _ts_node_type(node)
1107
+ if ntype == "import_declaration":
1108
+ path_node = _ts_child_by_field(node, "name")
1109
+ if not path_node:
1110
+ path_node = _ts_child_by_field(node, "path")
1111
+ if not path_node:
1112
+ for c in _ts_children(node):
1113
+ if _ts_node_type(c) in ("scoped_identifier", "identifier"):
1114
+ path_node = c
1115
+ break
1116
+ path = _ts_node_text(path_node, buf) if path_node else ""
1117
+ if path:
1118
+ info.imports.append(ImportInfo(source=path, names=["*"], is_relative=False))
1119
+ elif ntype in (
1120
+ "class_declaration",
1121
+ "interface_declaration",
1122
+ "enum_declaration",
1123
+ "record_declaration",
1124
+ ):
1125
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1126
+ if name:
1127
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1128
+ elif ntype == "method_declaration":
1129
+ name_node = _ts_child_by_field(node, "name")
1130
+ name = _ts_node_text(name_node, buf) if name_node else ""
1131
+ if name:
1132
+ annotations = _ts_extract_annotations(node, buf)
1133
+ info.functions.append(
1134
+ FunctionInfo(name=name, line=node.start_point[0] + 1, decorators=annotations)
1135
+ )
1136
+ for a in annotations:
1137
+ route = _ts_spring_route(a)
1138
+ if route and f"route:{route}" not in info.exports:
1139
+ info.exports.append(f"route:{route}")
1140
+ for child in _ts_children(node):
1141
+ _walk_java(child, buf, info)
1142
+
1143
+
1144
+ # ── Go parser (tree-sitter) ────────────────────────────────────────────────
1145
+
1146
+
1147
+ def _parse_go(source: str, info: FileInfo) -> None:
1148
+ parser = get_parser(Lang.GO)
1149
+ if parser is None:
1150
+ _parse_go_regex(source, info)
1151
+ return
1152
+ tree = parser.parse(source.encode("utf-8"))
1153
+ _walk_go(tree.root_node, source.encode("utf-8"), info)
1154
+
1155
+
1156
+ def _parse_go_regex(source: str, info: FileInfo) -> None:
1157
+ for m in re.finditer(r'^import\s+"([^"]+)"', source, re.MULTILINE):
1158
+ info.imports.append(ImportInfo(source=m.group(1), names=["*"], is_relative=False))
1159
+ for m in re.finditer(r'^\s+"([^"]+)"', source, re.MULTILINE):
1160
+ info.imports.append(ImportInfo(source=m.group(1), names=["*"], is_relative=False))
1161
+ for m in re.finditer(r"^func\s+(?:\([^)]*\)\s*)?(\w+)", source, re.MULTILINE):
1162
+ info.functions.append(
1163
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1)
1164
+ )
1165
+ for m in re.finditer(r"r\.(GET|POST|PUT|DELETE|PATCH|HEAD)\s*\(\s*[\"']([^\"']+)[\"']", source):
1166
+ info.exports.append(f"route:{m.group(2)}")
1167
+
1168
+
1169
+ def _walk_go(node: Any, buf: bytes, info: FileInfo) -> None:
1170
+ ntype = _ts_node_type(node)
1171
+ if ntype == "import_declaration":
1172
+ for c in _ts_children(node):
1173
+ if _ts_node_type(c) == "import_spec":
1174
+ path_node = _ts_child_by_field(c, "path")
1175
+ if path_node:
1176
+ path = _ts_node_text(path_node, buf).strip('"`')
1177
+ if path:
1178
+ info.imports.append(ImportInfo(source=path, names=["*"], is_relative=False))
1179
+ elif ntype == "function_declaration":
1180
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1181
+ if name:
1182
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1183
+ elif ntype == "method_declaration":
1184
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1185
+ if name:
1186
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1187
+ elif ntype == "type_declaration":
1188
+ for child in _ts_children(node):
1189
+ _walk_go(child, buf, info)
1190
+ elif ntype == "type_spec":
1191
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1192
+ if name:
1193
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1194
+ elif ntype == "call_expression":
1195
+ func = _ts_child_by_field(node, "function")
1196
+ args = _ts_child_by_field(node, "arguments")
1197
+ if func:
1198
+ func_text = _ts_node_text(func, buf)
1199
+ if func_text and "." in func_text and args:
1200
+ route = _ts_go_route(func_text, args, buf)
1201
+ if route:
1202
+ info.exports.append(f"route:{route}")
1203
+ for child in _ts_children(node):
1204
+ _walk_go(child, buf, info)
1205
+
1206
+
1207
+ def _ts_go_route(func_text: str, args_node: Any, buf: bytes) -> str:
1208
+ """Extract route path from Go router calls like r.GET(\"/api/foo\")."""
1209
+ methods = {
1210
+ "GET",
1211
+ "POST",
1212
+ "PUT",
1213
+ "DELETE",
1214
+ "PATCH",
1215
+ "HEAD",
1216
+ "OPTIONS",
1217
+ "Any",
1218
+ "Handle",
1219
+ "HandleFunc",
1220
+ }
1221
+ parts = func_text.split(".")
1222
+ if len(parts) < 2:
1223
+ return ""
1224
+ method = parts[-1]
1225
+ if method not in methods:
1226
+ return ""
1227
+ for child in _ts_children(args_node):
1228
+ if _ts_node_type(child) in (
1229
+ "interpreted_string_literal",
1230
+ "string_literal",
1231
+ "raw_string_literal",
1232
+ ):
1233
+ path = _ts_node_text(child, buf).strip('"`')
1234
+ if path:
1235
+ return path
1236
+ return ""
1237
+
1238
+
1239
+ # ── C/C++ parser (tree-sitter) ────────────────────────────────────────────
1240
+
1241
+
1242
+ def _parse_c_cpp(source: str, info: FileInfo) -> None:
1243
+ lang = info.language
1244
+ parser = get_parser(lang)
1245
+ if parser is None:
1246
+ _parse_c_cpp_regex(source, info)
1247
+ return
1248
+ tree = parser.parse(source.encode("utf-8"))
1249
+ _walk_c_cpp(tree.root_node, source.encode("utf-8"), info, lang)
1250
+
1251
+
1252
+ def _parse_c_cpp_regex(source: str, info: FileInfo) -> None:
1253
+ for m in re.finditer(r"^#\s*include\s+[<\"]([^>\"]+)[>\"]", source, re.MULTILINE):
1254
+ info.imports.append(ImportInfo(source=m.group(1), names=["*"], is_relative=False))
1255
+ for m in re.finditer(
1256
+ r"^(?:static\s+)?\w+(?:\s*\*+)?\s+(\w+)\s*\([^)]*\)\s*\{", source, re.MULTILINE
1257
+ ):
1258
+ info.functions.append(
1259
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1)
1260
+ )
1261
+
1262
+
1263
+ def _walk_c_cpp(node: Any, buf: bytes, info: FileInfo, lang: Lang) -> None:
1264
+ ntype = _ts_node_type(node)
1265
+ if ntype == "preproc_include":
1266
+ path_node = _ts_child_by_field(node, "path")
1267
+ if not path_node:
1268
+ for c in _ts_children(node):
1269
+ if _ts_node_type(c) in ("string_literal", "system_lib_string"):
1270
+ path_node = c
1271
+ break
1272
+ path = _ts_node_text(path_node, buf).strip('"<>') if path_node else ""
1273
+ if path:
1274
+ info.imports.append(ImportInfo(source=path, names=["*"], is_relative=False))
1275
+ elif ntype == "function_definition":
1276
+ decl = _ts_child_by_field(node, "declarator")
1277
+ name = ""
1278
+ if decl:
1279
+ name_node = _ts_child_by_field(decl, "declarator")
1280
+ if not name_node:
1281
+ name_node = _ts_child_by_field(decl, "name")
1282
+ if name_node:
1283
+ name = _ts_node_text(name_node, buf) or ""
1284
+ if not name:
1285
+ full = _ts_node_text(node, buf)
1286
+ idx = full.find("(")
1287
+ if idx > 0:
1288
+ name = full[:idx].rsplit(None, 1)[-1]
1289
+ if name:
1290
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1291
+ elif ntype == "class_specifier":
1292
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1293
+ if name:
1294
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1295
+ elif ntype == "struct_specifier":
1296
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1297
+ if name:
1298
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1299
+ for child in _ts_children(node):
1300
+ _walk_c_cpp(child, buf, info, lang)
1301
+
1302
+
1303
+ # ── Swift parser (tree-sitter) ──────────────────────────────────────────────
1304
+
1305
+
1306
+ def _parse_swift(source: str, info: FileInfo) -> None:
1307
+ parser = get_parser(Lang.SWIFT)
1308
+ if parser is None:
1309
+ _parse_swift_regex(source, info)
1310
+ return
1311
+ tree = parser.parse(source.encode("utf-8"))
1312
+ _walk_swift(tree.root_node, source.encode("utf-8"), info)
1313
+
1314
+
1315
+ def _parse_swift_regex(source: str, info: FileInfo) -> None:
1316
+ for m in re.finditer(r"^import\s+(\w+)", source, re.MULTILINE):
1317
+ lineno = source[: m.start()].count(chr(10)) + 1
1318
+ info.imports.append(
1319
+ ImportInfo(source=m.group(1), names=["*"], is_relative=False, line=lineno)
1320
+ )
1321
+ for m in re.finditer(r"(?:public\s+)?(?:class|struct|enum|protocol|extension)\s+(\w+)", source):
1322
+ info.classes.append(ClassInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1))
1323
+ for m in re.finditer(r"(?:public\s+)?func\s+(\w+)\s*\(", source):
1324
+ info.functions.append(
1325
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1)
1326
+ )
1327
+
1328
+
1329
+ def _walk_swift(node: Any, buf: bytes, info: FileInfo) -> None:
1330
+ ntype = _ts_node_type(node)
1331
+ if ntype == "import_declaration":
1332
+ path_node = _ts_child_by_field(node, "path")
1333
+ if not path_node:
1334
+ for c in _ts_children(node):
1335
+ if _ts_node_type(c) in ("identifier", "member_access"):
1336
+ path_node = c
1337
+ break
1338
+ path = _ts_node_text(path_node, buf) if path_node else ""
1339
+ if path:
1340
+ info.imports.append(
1341
+ ImportInfo(
1342
+ source=path, names=["*"], is_relative=False, line=node.start_point[0] + 1
1343
+ )
1344
+ )
1345
+ elif ntype in (
1346
+ "class_declaration",
1347
+ "struct_declaration",
1348
+ "enum_declaration",
1349
+ "protocol_declaration",
1350
+ "extension_declaration",
1351
+ ):
1352
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1353
+ if name:
1354
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1355
+ elif ntype == "function_declaration":
1356
+ name = _ts_node_text(_ts_child_by_field(node, "name"), buf)
1357
+ if name:
1358
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1359
+ for child in _ts_children(node):
1360
+ _walk_swift(child, buf, info)
1361
+
1362
+
1363
+ # ── Ruby parser (tree-sitter) ───────────────────────────────────────────────
1364
+
1365
+
1366
+ def _parse_ruby(source: str, info: FileInfo) -> None:
1367
+ parser = get_parser(Lang.RUBY)
1368
+ if parser is None:
1369
+ _parse_ruby_regex(source, info)
1370
+ return
1371
+ tree = parser.parse(source.encode("utf-8"))
1372
+ _walk_ruby(tree.root_node, source.encode("utf-8"), info)
1373
+
1374
+
1375
+ def _parse_ruby_regex(source: str, info: FileInfo) -> None:
1376
+ for m in re.finditer(
1377
+ r'^\s*(?:require|require_relative|load)\s+["\']([^"\']+)["\']', source, re.MULTILINE
1378
+ ):
1379
+ info.imports.append(
1380
+ ImportInfo(
1381
+ source=m.group(1),
1382
+ names=["*"],
1383
+ is_relative=m.group(0).strip().startswith("require_relative"),
1384
+ line=source[: m.start()].count(chr(10)) + 1,
1385
+ )
1386
+ )
1387
+ for m in re.finditer(r"^\s*(?:class|module)\s+(\w+(?:::\w+)*)", source, re.MULTILINE):
1388
+ info.classes.append(ClassInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1))
1389
+ for m in re.finditer(r"^\s*def\s+(?:self\.)?(\w+)", source, re.MULTILINE):
1390
+ info.functions.append(
1391
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count(chr(10)) + 1)
1392
+ )
1393
+ for m in re.finditer(
1394
+ r"(?:get|post|put|patch|delete|resources)\s+['\"]([^'\"]+)['\"]", source, re.MULTILINE
1395
+ ):
1396
+ info.exports.append(f"route:{m.group(1)}")
1397
+
1398
+
1399
+ def _walk_ruby(node: Any, buf: bytes, info: FileInfo) -> None:
1400
+ ntype = _ts_node_type(node)
1401
+ if ntype == "call":
1402
+ method = ""
1403
+ args: list[str] = []
1404
+ for c in _ts_children(node):
1405
+ ctype = _ts_node_type(c)
1406
+ if ctype == "identifier":
1407
+ method = _ts_node_text(c, buf)
1408
+ elif ctype == "argument_list":
1409
+ for a in _ts_children(c):
1410
+ if _ts_node_type(a) == "string":
1411
+ args.append(_ts_node_text(a, buf).strip("\"'"))
1412
+ if method in ("require", "require_relative", "load"):
1413
+ for arg in args:
1414
+ info.imports.append(
1415
+ ImportInfo(
1416
+ source=arg,
1417
+ names=["*"],
1418
+ is_relative=(method == "require_relative"),
1419
+ line=node.start_point[0] + 1,
1420
+ )
1421
+ )
1422
+ elif method in ("get", "post", "put", "patch", "delete", "resources"):
1423
+ for arg in args:
1424
+ info.exports.append(f"route:{arg}")
1425
+ elif ntype == "method":
1426
+ for c in _ts_children(node):
1427
+ if _ts_node_type(c) == "identifier":
1428
+ name = _ts_node_text(c, buf)
1429
+ if name:
1430
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1431
+ break
1432
+ elif ntype in ("class", "module"):
1433
+ for c in _ts_children(node):
1434
+ if _ts_node_type(c) == "constant":
1435
+ name = _ts_node_text(c, buf)
1436
+ if name:
1437
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1438
+ break
1439
+ for child in _ts_children(node):
1440
+ _walk_ruby(child, buf, info)
1441
+
1442
+
1443
+ # ── JSON parser ────────────────────────────────────────────────────────────────
1444
+
1445
+
1446
+ # ── JSON parser ────────────────────────────────────────────────────────────────
1447
+
1448
+
1449
+ def _parse_json(source: str, info: FileInfo) -> None:
1450
+ import json
1451
+
1452
+ try:
1453
+ data = json.loads(source)
1454
+ except json.JSONDecodeError:
1455
+ info.error = "Invalid JSON"
1456
+ return
1457
+
1458
+ if isinstance(data, dict):
1459
+ # package.json style
1460
+ if "dependencies" in data or "devDependencies" in data:
1461
+ for dep in {**data.get("dependencies", {}), **data.get("devDependencies", {})}.keys():
1462
+ info.imports.append(ImportInfo(source=dep, names=["*"], is_relative=False, line=0))
1463
+ if "main" in data:
1464
+ info.exports.append(data["main"])
1465
+ if "scripts" in data:
1466
+ info.functions.extend([FunctionInfo(name=k, line=0) for k in data["scripts"].keys()])
1467
+
1468
+
1469
+ # ── YAML parser (yaml.safe_load) ─────────────────────────────────────────────
1470
+
1471
+
1472
+ def _parse_yaml(source: str, info: FileInfo) -> None:
1473
+ try:
1474
+ import yaml
1475
+ except ImportError:
1476
+ info.error = "pyyaml not installed"
1477
+ return
1478
+ try:
1479
+ data = yaml.safe_load(source)
1480
+ except Exception as exc:
1481
+ info.error = f"yaml parse: {exc}"
1482
+ return
1483
+ if not isinstance(data, dict):
1484
+ return
1485
+ for key, value in data.items():
1486
+ if key == "uses" and isinstance(value, str):
1487
+ info.imports.append(ImportInfo(source=value, names=["*"], is_relative=False, line=0))
1488
+ elif key == "image" and isinstance(value, str):
1489
+ info.imports.append(
1490
+ ImportInfo(source=value, names=["docker"], is_relative=False, line=0)
1491
+ )
1492
+ elif isinstance(value, list):
1493
+ for item in value:
1494
+ if isinstance(item, dict):
1495
+ sub_uses = item.get("uses")
1496
+ if sub_uses:
1497
+ info.imports.append(
1498
+ ImportInfo(source=sub_uses, names=["*"], is_relative=False, line=0)
1499
+ )
1500
+ sub_image = item.get("image")
1501
+ if sub_image:
1502
+ info.imports.append(
1503
+ ImportInfo(
1504
+ source=sub_image, names=["docker"], is_relative=False, line=0
1505
+ )
1506
+ )
1507
+
1508
+
1509
+ # ── PHP parser ────────────────────────────────────────────────────────────────
1510
+
1511
+
1512
+ def _parse_php(source: str, info: FileInfo) -> None:
1513
+ """Parse PHP using tree-sitter (AST)."""
1514
+ parser = get_parser(Lang.PHP)
1515
+ if parser is None:
1516
+ _parse_php_regex(source, info)
1517
+ return
1518
+
1519
+ try:
1520
+ tree = parser.parse(source.encode("utf-8"))
1521
+ _walk_php_node(tree.root_node, source, info)
1522
+ except Exception as e:
1523
+ _log.warning("_parse_php failed: %s", e)
1524
+ _parse_php_regex(source, info)
1525
+
1526
+
1527
+ def _parse_php_regex(source: str, info: FileInfo) -> None:
1528
+ """Regex fallback for PHP when tree-sitter unavailable."""
1529
+ for m in re.finditer(r"""(?:require|include)(?:_once)?\s*\(?['"](.*?)['"]\)?""", source):
1530
+ path = m.group(1)
1531
+ info.imports.append(
1532
+ ImportInfo(
1533
+ source=path,
1534
+ names=["*"],
1535
+ is_relative=path.startswith("."),
1536
+ line=source[: m.start()].count("\n") + 1,
1537
+ )
1538
+ )
1539
+ for m in re.finditer(r"""use\s+([\w\\]+)(?:\s+as\s+\w+)?;""", source):
1540
+ info.imports.append(
1541
+ ImportInfo(
1542
+ source=m.group(1),
1543
+ names=["*"],
1544
+ is_relative=False,
1545
+ line=source[: m.start()].count("\n") + 1,
1546
+ )
1547
+ )
1548
+ for m in re.finditer(r"""function\s+(\w+)\s*\(""", source):
1549
+ info.functions.append(
1550
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count("\n") + 1)
1551
+ )
1552
+ for m in re.finditer(r"""class\s+(\w+)""", source):
1553
+ info.classes.append(ClassInfo(name=m.group(1), line=source[: m.start()].count("\n") + 1))
1554
+
1555
+
1556
+ def _walk_php_node(node: Any, source: str, info: FileInfo) -> None:
1557
+ """Walk PHP tree-sitter AST to extract imports, functions, classes."""
1558
+ ntype = node.type
1559
+
1560
+ if ntype == "namespace_use_declaration":
1561
+ path = _node_text(node, source).removeprefix("use ")
1562
+ if path:
1563
+ info.imports.append(
1564
+ ImportInfo(
1565
+ source=path.rstrip(";"),
1566
+ names=["*"],
1567
+ is_relative=False,
1568
+ line=node.start_point[0] + 1,
1569
+ )
1570
+ )
1571
+
1572
+ elif ntype in (
1573
+ "require_expression",
1574
+ "require_once_expression",
1575
+ "include_expression",
1576
+ "include_once_expression",
1577
+ ):
1578
+ path = _php_extract_include_path(node, source)
1579
+ if path:
1580
+ info.imports.append(
1581
+ ImportInfo(
1582
+ source=path,
1583
+ names=["*"],
1584
+ is_relative=not path.startswith("/"),
1585
+ line=node.start_point[0] + 1,
1586
+ )
1587
+ )
1588
+
1589
+ elif ntype == "function_definition":
1590
+ name = _php_child_text(node, "name", source)
1591
+ if name:
1592
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1593
+
1594
+ elif ntype == "method_declaration":
1595
+ name = _php_child_text(node, "name", source)
1596
+ if name:
1597
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1598
+
1599
+ elif ntype == "class_declaration":
1600
+ name = _php_child_text(node, "name", source)
1601
+ if name:
1602
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1603
+
1604
+ for child in node.children:
1605
+ _walk_php_node(child, source, info)
1606
+
1607
+
1608
+ def _php_child_text(node: Any, field: str, source: str) -> str:
1609
+ """Extract text of a named child by field name."""
1610
+ for child in node.children:
1611
+ if child.type == field:
1612
+ return source[child.start_byte : child.end_byte]
1613
+ return ""
1614
+
1615
+
1616
+ def _php_extract_include_path(node: Any, source: str) -> str | None:
1617
+ """Extract the file path from a PHP require/include expression."""
1618
+ for child in node.children:
1619
+ if child.type in ("encapsed_string", "string"):
1620
+ return _node_text(child, source).strip("\"'")
1621
+ if child.type == "binary_expression":
1622
+ parts = child.children
1623
+ for _i, p in enumerate(parts):
1624
+ if p.type in ("encapsed_string", "string"):
1625
+ return _node_text(p, source).strip("\"'")
1626
+ return None
1627
+
1628
+
1629
+ # ── HTML parser (tree-sitter) ───────────────────────────────────────────────
1630
+
1631
+
1632
+ def _parse_html(source: str, info: FileInfo) -> None:
1633
+ """Parse HTML using tree-sitter, extracting script/src and stylesheet refs."""
1634
+ parser = get_parser(Lang.HTML)
1635
+ if parser is None:
1636
+ return
1637
+ try:
1638
+ tree = parser.parse(source.encode("utf-8"))
1639
+ except Exception as e:
1640
+ _log.warning("_parse_html failed: %s", e)
1641
+ return
1642
+
1643
+ _walk_html(tree.root_node, source, info)
1644
+
1645
+
1646
+ def _walk_html(node: Any, source: str, info: FileInfo) -> None:
1647
+ ntype = node.type
1648
+ if ntype == "script_element":
1649
+ src = _html_attr_value(node, "src", source)
1650
+ if src:
1651
+ info.imports.append(
1652
+ ImportInfo(
1653
+ source=src,
1654
+ names=["script"],
1655
+ is_relative=not src.startswith("http"),
1656
+ line=node.start_point[0] + 1,
1657
+ )
1658
+ )
1659
+ elif ntype == "element":
1660
+ tag = _html_tag_name(node, source)
1661
+ if tag == "link":
1662
+ rel = _html_attr_value(node, "rel", source)
1663
+ href = _html_attr_value(node, "href", source)
1664
+ if href and rel and "stylesheet" in rel.lower().split():
1665
+ info.imports.append(
1666
+ ImportInfo(
1667
+ source=href,
1668
+ names=["style"],
1669
+ is_relative=not href.startswith("http"),
1670
+ line=node.start_point[0] + 1,
1671
+ )
1672
+ )
1673
+ for child in node.children:
1674
+ _walk_html(child, source, info)
1675
+
1676
+
1677
+ def _html_tag_name(node: Any, source: str) -> str:
1678
+ for child in node.children:
1679
+ if child.type == "tag_name":
1680
+ return source[child.start_byte : child.end_byte]
1681
+ if child.type == "start_tag":
1682
+ for sub in child.children:
1683
+ if sub.type == "tag_name":
1684
+ return source[sub.start_byte : sub.end_byte]
1685
+ return ""
1686
+
1687
+
1688
+ def _html_attr_value(node: Any, attr_name: str, source: str) -> str:
1689
+ start_tag = next((c for c in node.children if c.type == "start_tag"), None)
1690
+ if start_tag is None:
1691
+ return ""
1692
+ for child in start_tag.children:
1693
+ if child.type == "attribute":
1694
+ raw = source[child.start_byte : child.end_byte]
1695
+ parts = raw.split("=", 1)
1696
+ if len(parts) == 2 and parts[0].strip() == attr_name:
1697
+ val = parts[1].strip().strip("\"'")
1698
+ return val
1699
+ return ""
1700
+
1701
+
1702
+ # ── C# parser (tree-sitter) ────────────────────────────────────────────────────
1703
+
1704
+
1705
+ def _parse_csharp(source: str, info: FileInfo) -> None:
1706
+ """Parse C# using tree-sitter."""
1707
+ parser = get_parser(Lang.C_SHARP)
1708
+ if parser is None:
1709
+ return
1710
+ try:
1711
+ tree = parser.parse(source.encode("utf-8"))
1712
+ _walk_csharp_node(tree.root_node, source, info)
1713
+ except Exception as e:
1714
+ _log.warning("_parse_csharp failed: %s", e)
1715
+
1716
+
1717
+ def _walk_csharp_node(node: Any, source: str, info: FileInfo) -> None:
1718
+ ntype = node.type
1719
+ if ntype == "using_directive":
1720
+ path = _node_text(node, source).removeprefix("using ").rstrip(";")
1721
+ if path:
1722
+ info.imports.append(
1723
+ ImportInfo(
1724
+ source=path, names=["*"], is_relative=False, line=node.start_point[0] + 1
1725
+ )
1726
+ )
1727
+ elif ntype == "class_declaration":
1728
+ name = _csharp_child_text(node, "identifier", source)
1729
+ if name:
1730
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1731
+ elif ntype == "method_declaration":
1732
+ name = _csharp_child_text(node, "identifier", source) or _csharp_child_text(
1733
+ node, "name", source
1734
+ )
1735
+ if name:
1736
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1737
+ for child in node.children:
1738
+ _walk_csharp_node(child, source, info)
1739
+
1740
+
1741
+ def _csharp_child_text(node: Any, field: str, source: str) -> str:
1742
+ for child in node.children:
1743
+ if child.type == field:
1744
+ return source[child.start_byte : child.end_byte]
1745
+ return ""
1746
+
1747
+
1748
+ # ── Kotlin parser (tree-sitter) ────────────────────────────────────────────────
1749
+
1750
+
1751
+ def _parse_kotlin(source: str, info: FileInfo) -> None:
1752
+ """Parse Kotlin using tree-sitter."""
1753
+ parser = get_parser(Lang.KOTLIN)
1754
+ if parser is None:
1755
+ return
1756
+ try:
1757
+ tree = parser.parse(source.encode("utf-8"))
1758
+ _walk_kotlin_node(tree.root_node, source, info)
1759
+ except Exception as e:
1760
+ _log.warning("_parse_kotlin failed: %s", e)
1761
+
1762
+
1763
+ def _walk_kotlin_node(node: Any, source: str, info: FileInfo) -> None:
1764
+ ntype = node.type
1765
+ if ntype == "import":
1766
+ raw = _node_text(node, source)
1767
+ # Skip the `import` keyword child node (also type "import")
1768
+ if raw.strip() == "import" or raw.strip() == "*":
1769
+ return
1770
+ path = raw.removeprefix("import ").rstrip(";").removesuffix(".*")
1771
+ if path:
1772
+ info.imports.append(
1773
+ ImportInfo(
1774
+ source=path, names=["*"], is_relative=False, line=node.start_point[0] + 1
1775
+ )
1776
+ )
1777
+ elif ntype == "class_declaration":
1778
+ name = _kotlin_child_text(node, "identifier", source)
1779
+ if name:
1780
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1781
+ elif ntype == "function_declaration":
1782
+ name = _kotlin_child_text(node, "identifier", source)
1783
+ if name:
1784
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1785
+ for child in node.children:
1786
+ _walk_kotlin_node(child, source, info)
1787
+
1788
+
1789
+ def _kotlin_child_text(node: Any, field: str, source: str) -> str:
1790
+ for child in node.children:
1791
+ if child.type == field:
1792
+ return source[child.start_byte : child.end_byte]
1793
+ return ""
1794
+
1795
+
1796
+ # ── Dart parser (tree-sitter) ──────────────────────────────────────────────────
1797
+
1798
+
1799
+ def _parse_dart(source: str, info: FileInfo) -> None:
1800
+ """Parse Dart using tree-sitter."""
1801
+ parser = get_parser(Lang.DART)
1802
+ if parser is None:
1803
+ return
1804
+ try:
1805
+ tree = parser.parse(source.encode("utf-8"))
1806
+ _walk_dart_node(tree.root_node, source, info)
1807
+ except Exception as e:
1808
+ _log.warning("_parse_dart failed: %s", e)
1809
+
1810
+
1811
+ def _walk_dart_node(node: Any, source: str, info: FileInfo) -> None:
1812
+ ntype = node.type
1813
+ if ntype == "library_import":
1814
+ raw = _node_text(node, source).strip(";")
1815
+ if raw.startswith("import "):
1816
+ raw = raw[7:].strip()
1817
+ is_rel = raw.startswith(("'", '"', "."))
1818
+ path = raw.strip("'\"")
1819
+ if path:
1820
+ info.imports.append(
1821
+ ImportInfo(
1822
+ source=path, names=["*"], is_relative=is_rel, line=node.start_point[0] + 1
1823
+ )
1824
+ )
1825
+ elif ntype == "class_definition":
1826
+ name = _dart_child_text(node, "identifier", source)
1827
+ if name:
1828
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1829
+ elif ntype in (
1830
+ "method_declaration",
1831
+ "function_declaration",
1832
+ "getter_declaration",
1833
+ "setter_declaration",
1834
+ ):
1835
+ name = _dart_child_text(node, "identifier", source)
1836
+ if name:
1837
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1838
+ for child in node.children:
1839
+ _walk_dart_node(child, source, info)
1840
+
1841
+
1842
+ def _dart_child_text(node: Any, field: str, source: str) -> str:
1843
+ for child in node.children:
1844
+ if child.type == field:
1845
+ return source[child.start_byte : child.end_byte]
1846
+ return ""
1847
+
1848
+
1849
+ # ── Bash parser (tree-sitter) ──────────────────────────────────────────────────
1850
+
1851
+
1852
+ def _parse_bash(source: str, info: FileInfo) -> None:
1853
+ """Parse Bash using tree-sitter."""
1854
+ parser = get_parser(Lang.BASH)
1855
+ if parser is None:
1856
+ _parse_bash_regex(source, info)
1857
+ return
1858
+ try:
1859
+ tree = parser.parse(source.encode("utf-8"))
1860
+ _walk_bash_node(tree.root_node, source, info)
1861
+ except Exception as e:
1862
+ _log.warning("_parse_bash failed: %s", e)
1863
+ _parse_bash_regex(source, info)
1864
+
1865
+
1866
+ def _parse_bash_regex(source: str, info: FileInfo) -> None:
1867
+ """Regex fallback for Bash."""
1868
+ for m in re.finditer(r"""^(?:source|\.)\s+['"]?([^\s'"]+)['"]?""", source, re.MULTILINE):
1869
+ info.imports.append(
1870
+ ImportInfo(
1871
+ source=m.group(1),
1872
+ names=["*"],
1873
+ is_relative=True,
1874
+ line=source[: m.start()].count("\n") + 1,
1875
+ )
1876
+ )
1877
+
1878
+
1879
+ def _walk_bash_node(node: Any, source: str, info: FileInfo) -> None:
1880
+ ntype = node.type
1881
+ if ntype == "function_definition":
1882
+ name_node = _bash_child_by_type(node, "word")
1883
+ name = _node_text(name_node, source) if name_node else ""
1884
+ if name:
1885
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1886
+ elif ntype == "command":
1887
+ cmd_name = ""
1888
+ for child in node.children:
1889
+ if child.type == "command_name":
1890
+ cmd_name = _node_text(child, source)
1891
+ break
1892
+ if cmd_name in ("source", "."):
1893
+ for child in node.children:
1894
+ path = ""
1895
+ if child.type == "word":
1896
+ path = _node_text(child, source).strip("\"'")
1897
+ elif child.type == "string":
1898
+ for sub in child.children:
1899
+ if sub.type == "string_content":
1900
+ path = _node_text(sub, source).strip("\"'")
1901
+ break
1902
+ if path:
1903
+ info.imports.append(
1904
+ ImportInfo(
1905
+ source=path, names=["*"], is_relative=True, line=node.start_point[0] + 1
1906
+ )
1907
+ )
1908
+ for child in node.children:
1909
+ _walk_bash_node(child, source, info)
1910
+
1911
+
1912
+ def _bash_child_by_type(node: Any, ntype: str) -> Any:
1913
+ for child in node.children:
1914
+ if child.type == ntype:
1915
+ return child
1916
+ return None
1917
+
1918
+
1919
+ # ── CSS parser (tree-sitter) ───────────────────────────────────────────────────
1920
+
1921
+
1922
+ def _parse_css(source: str, info: FileInfo) -> None:
1923
+ """Parse CSS using tree-sitter."""
1924
+ parser = get_parser(Lang.CSS)
1925
+ if parser is None:
1926
+ return
1927
+ try:
1928
+ tree = parser.parse(source.encode("utf-8"))
1929
+ _walk_css_node(tree.root_node, source, info)
1930
+ except Exception as e:
1931
+ _log.warning("_parse_css failed: %s", e)
1932
+
1933
+
1934
+ def _walk_css_node(node: Any, source: str, info: FileInfo) -> None:
1935
+ ntype = node.type
1936
+ if ntype == "import_statement":
1937
+ path = _node_text(node, source).replace("@import ", "").strip(" ;'\"")
1938
+ # Handle url() wrapper
1939
+ if path.startswith("url(") and path.endswith(")"):
1940
+ path = path[4:-1].strip("\"'")
1941
+ if path:
1942
+ info.imports.append(
1943
+ ImportInfo(
1944
+ source=path,
1945
+ names=["*"],
1946
+ is_relative=not path.startswith(("http", "//")),
1947
+ line=node.start_point[0] + 1,
1948
+ )
1949
+ )
1950
+ for child in node.children:
1951
+ _walk_css_node(child, source, info)
1952
+
1953
+
1954
+ # ── SQL parser (tree-sitter) ───────────────────────────────────────────────────
1955
+
1956
+
1957
+ def _parse_sql(source: str, info: FileInfo) -> None:
1958
+ """Parse SQL using tree-sitter."""
1959
+ parser = get_parser(Lang.SQL)
1960
+ if parser is None:
1961
+ return
1962
+ try:
1963
+ tree = parser.parse(source.encode("utf-8"))
1964
+ _walk_sql_node(tree.root_node, source, info)
1965
+ except Exception as e:
1966
+ _log.warning("_parse_sql failed: %s", e)
1967
+
1968
+
1969
+ def _walk_sql_node(node: Any, source: str, info: FileInfo) -> None:
1970
+ ntype = node.type
1971
+ if ntype == "create_table":
1972
+ name_node = _sql_child_by_type(node, "identifier") or _sql_child_by_type(
1973
+ node, "object_reference"
1974
+ )
1975
+ if name_node:
1976
+ name = _node_text(name_node, source)
1977
+ info.classes.append(ClassInfo(name=name, line=node.start_point[0] + 1))
1978
+ elif ntype in ("create_view", "create_procedure", "create_function"):
1979
+ name_node = _sql_child_by_type(node, "identifier") or _sql_child_by_type(
1980
+ node, "object_reference"
1981
+ )
1982
+ if name_node:
1983
+ name = _node_text(name_node, source)
1984
+ info.functions.append(FunctionInfo(name=name, line=node.start_point[0] + 1))
1985
+ for child in node.children:
1986
+ _walk_sql_node(child, source, info)
1987
+
1988
+
1989
+ def _sql_child_by_type(node: Any, ntype: str) -> Any:
1990
+ for child in node.children:
1991
+ if child.type == ntype:
1992
+ return child
1993
+ return None
1994
+
1995
+
1996
+ # ── Scala parser (regex — no PyPI tree-sitter grammar available yet) ──────────
1997
+
1998
+
1999
+ def _parse_scala_regex(source: str, info: FileInfo) -> None:
2000
+ """
2001
+ Regex-based Scala extraction. Intentional, not a placeholder: no
2002
+ tree-sitter-scala wheel is published on PyPI as of this writing, so this
2003
+ stays regex until a grammar ships (per the language expansion plan) --
2004
+ unlike the other regex fallbacks in this file, which back up a tree-sitter
2005
+ primary path that usually succeeds.
2006
+ """
2007
+ for m in re.finditer(r"^\s*import\s+([\w.]+(?:\.\{[^}]*\})?)", source, re.MULTILINE):
2008
+ info.imports.append(
2009
+ ImportInfo(
2010
+ source=m.group(1),
2011
+ names=["*"],
2012
+ is_relative=False,
2013
+ line=source[: m.start()].count("\n") + 1,
2014
+ )
2015
+ )
2016
+ for m in re.finditer(
2017
+ r"^\s*(?:private\s+|protected\s+|final\s+)*def\s+(\w+)", source, re.MULTILINE
2018
+ ):
2019
+ info.functions.append(
2020
+ FunctionInfo(name=m.group(1), line=source[: m.start()].count("\n") + 1)
2021
+ )
2022
+ for m in re.finditer(r"^\s*(?:case\s+)?(?:class|object|trait)\s+(\w+)", source, re.MULTILINE):
2023
+ info.classes.append(ClassInfo(name=m.group(1), line=source[: m.start()].count("\n") + 1))
2024
+
2025
+
2026
+ # ── Generic fallback parser ────────────────────────────────────────────────────
2027
+
2028
+
2029
+ def _parse_generic(source: str, info: FileInfo) -> None:
2030
+ """Minimal extraction for languages without dedicated parsers."""
2031
+ pass
2032
+
2033
+
2034
+ # ── Entry point detection ──────────────────────────────────────────────────────
2035
+
2036
+ ENTRY_POINT_NAMES = {
2037
+ "main.py",
2038
+ "app.py",
2039
+ "server.py",
2040
+ "index.py",
2041
+ "run.py",
2042
+ "manage.py",
2043
+ "index.js",
2044
+ "main.js",
2045
+ "server.js",
2046
+ "app.js",
2047
+ "index.ts",
2048
+ "main.ts",
2049
+ "index.html",
2050
+ "index.php",
2051
+ "wsgi.py",
2052
+ "asgi.py",
2053
+ "__main__.py",
2054
+ }
2055
+
2056
+ ENTRY_POINT_PATTERNS = [
2057
+ re.compile(r"if\s+__name__\s*==\s*['\"]__main__['\"]"), # Python main guard
2058
+ re.compile(r"app\.listen\("), # Express
2059
+ re.compile(r"createServer\("), # Node HTTP
2060
+ re.compile(r"ReactDOM\.render|createRoot"), # React entry
2061
+ re.compile(r"FastAPI\(\)|Flask\(__name__\)"), # Python web frameworks
2062
+ re.compile(r"uvicorn\.run\(|app\.run\("), # Python server start
2063
+ ]
2064
+
2065
+
2066
+ def _is_entry_point(path: Path, info: FileInfo) -> bool:
2067
+ if path.name in ENTRY_POINT_NAMES:
2068
+ return True
2069
+ # Check for framework entry patterns in the source (already parsed)
2070
+ return False # pattern check on raw source done at scan time — could add if needed
2071
+
2072
+
2073
+ # ── Purpose inference ──────────────────────────────────────────────────────────
2074
+
2075
+
2076
+ def _infer_purpose(path: Path, info: FileInfo) -> str:
2077
+ """
2078
+ Infer a one-sentence plain English purpose from file name, location, and structure.
2079
+ This is a heuristic — not AI. scanner agents will improve on this.
2080
+ """
2081
+ name = path.stem.lower()
2082
+ parent = path.parent.name.lower()
2083
+ lang = info.language
2084
+
2085
+ # Config files
2086
+ if path.name in {
2087
+ "package.json",
2088
+ "pyproject.toml",
2089
+ "setup.py",
2090
+ "setup.cfg",
2091
+ "Cargo.toml",
2092
+ "go.mod",
2093
+ "composer.json",
2094
+ }:
2095
+ return "Project manifest and dependency definitions."
2096
+ if path.name in {".env", ".env.example", ".env.local"}:
2097
+ return "Environment variable definitions."
2098
+ if path.suffix in {".yml", ".yaml"} and parent in {"github", ".github", "workflows"}:
2099
+ return "CI/CD workflow automation."
2100
+ if path.name in {"docker-compose.yml", "docker-compose.yaml", "Dockerfile"}:
2101
+ return "Container configuration."
2102
+
2103
+ # Test files
2104
+ if "test" in name or name.startswith("test_") or name.endswith("_test"):
2105
+ return f"Test file for {name.replace('test_', '').replace('_test', '')} module."
2106
+ if parent in {"tests", "test", "__tests__", "spec", "specs"}:
2107
+ return "Test file."
2108
+
2109
+ # Common patterns
2110
+ if "route" in name or "router" in name:
2111
+ return f"Route definitions and URL handlers for {parent}."
2112
+ if "model" in name:
2113
+ return f"Data model definitions for {parent}."
2114
+ if "controller" in name or "handler" in name:
2115
+ return f"Request handler logic for {parent}."
2116
+ if "middleware" in name:
2117
+ return f"Middleware processing for {parent}."
2118
+ if "auth" in name or "login" in name or "session" in name:
2119
+ return "Authentication and session management."
2120
+ if "config" in name or "settings" in name:
2121
+ return "Application configuration."
2122
+ if "util" in name or "helper" in name or "utils" in name:
2123
+ return f"Utility functions shared across {parent}."
2124
+ if "schema" in name or "types" in name:
2125
+ return f"Type definitions and schema validation for {parent}."
2126
+ if "migration" in name:
2127
+ return "Database migration script."
2128
+ if "seed" in name or "fixture" in name:
2129
+ return "Database seed or fixture data."
2130
+ if info.is_entry_point:
2131
+ return "Application entry point."
2132
+ if info.classes and not info.functions:
2133
+ return f"Class definitions: {', '.join(c.name for c in info.classes[:3])}."
2134
+ if info.functions and not info.classes:
2135
+ return f"Function library: {', '.join(f.name for f in info.functions[:3])}."
2136
+ return f"{lang.value.title()} source file."