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.
- patchi/__init__.py +1 -0
- patchi/__main__.py +13 -0
- patchi/cli/__init__.py +0 -0
- patchi/cli/command_families.py +235 -0
- patchi/cli/commands/__init__.py +0 -0
- patchi/cli/commands/access_cmd.py +65 -0
- patchi/cli/commands/agent_stats_cmd.py +273 -0
- patchi/cli/commands/agents_cmd.py +187 -0
- patchi/cli/commands/ai_cmd.py +308 -0
- patchi/cli/commands/assure_cmd.py +538 -0
- patchi/cli/commands/audit_cmd.py +286 -0
- patchi/cli/commands/auto_cmd.py +110 -0
- patchi/cli/commands/blame_cmd.py +53 -0
- patchi/cli/commands/chains_cmd.py +327 -0
- patchi/cli/commands/charter_cmd.py +173 -0
- patchi/cli/commands/chat_cmd.py +597 -0
- patchi/cli/commands/check_cmd.py +232 -0
- patchi/cli/commands/cleanup_cmd.py +328 -0
- patchi/cli/commands/cockpit_cmd.py +208 -0
- patchi/cli/commands/commands_cmd.py +160 -0
- patchi/cli/commands/cross_repo_cmd.py +113 -0
- patchi/cli/commands/deps_cmd.py +110 -0
- patchi/cli/commands/dev_check_cmd.py +342 -0
- patchi/cli/commands/dev_cmd.py +445 -0
- patchi/cli/commands/doctor_cmd.py +457 -0
- patchi/cli/commands/findings_cmd.py +187 -0
- patchi/cli/commands/fix_cmd.py +648 -0
- patchi/cli/commands/fix_review_cmd.py +273 -0
- patchi/cli/commands/heatmap_cmd.py +62 -0
- patchi/cli/commands/help_cmd.py +159 -0
- patchi/cli/commands/hosted_cmd.py +980 -0
- patchi/cli/commands/init.py +456 -0
- patchi/cli/commands/key_cmd.py +571 -0
- patchi/cli/commands/learn_cmd.py +140 -0
- patchi/cli/commands/link_cmd.py +104 -0
- patchi/cli/commands/log_cmd.py +57 -0
- patchi/cli/commands/memory_cmd.py +215 -0
- patchi/cli/commands/model_cmd.py +207 -0
- patchi/cli/commands/notify_cmd.py +213 -0
- patchi/cli/commands/notify_prompts.py +29 -0
- patchi/cli/commands/patch_cmd.py +231 -0
- patchi/cli/commands/plan_cmd.py +128 -0
- patchi/cli/commands/plugins_cmd.py +307 -0
- patchi/cli/commands/queue_cmd.py +152 -0
- patchi/cli/commands/quick_cmd.py +159 -0
- patchi/cli/commands/ready_cmd.py +292 -0
- patchi/cli/commands/report_cmd.py +416 -0
- patchi/cli/commands/restrict_cmd.py +127 -0
- patchi/cli/commands/review_cmd.py +225 -0
- patchi/cli/commands/rules_cmd.py +233 -0
- patchi/cli/commands/scan_cmd.py +1908 -0
- patchi/cli/commands/settings_cmd.py +45 -0
- patchi/cli/commands/status_cmd.py +679 -0
- patchi/cli/commands/test_cmd.py +1052 -0
- patchi/cli/commands/trend_cmd.py +296 -0
- patchi/cli/commands/undo_cmd.py +235 -0
- patchi/cli/commands/update_cmd.py +345 -0
- patchi/cli/commands/verify_cmd.py +56 -0
- patchi/cli/commands/vr_cmd.py +440 -0
- patchi/cli/commands/watch_cmd.py +304 -0
- patchi/cli/commands/web_cmd.py +114 -0
- patchi/cli/console.py +101 -0
- patchi/cli/display/__init__.py +1 -0
- patchi/cli/display/live_audit.py +269 -0
- patchi/cli/display/live_progress.py +169 -0
- patchi/cli/framework.py +241 -0
- patchi/cli/logo.py +128 -0
- patchi/cli/main.py +222 -0
- patchi/cli/registry.py +1171 -0
- patchi/cli/style.py +68 -0
- patchi/cli/themes.py +187 -0
- patchi/cli/ux.py +444 -0
- patchi/core/__init__.py +0 -0
- patchi/core/agents/__init__.py +0 -0
- patchi/core/agents/api_fuzzer_agent.py +141 -0
- patchi/core/agents/attack_agent.py +242 -0
- patchi/core/agents/base.py +1012 -0
- patchi/core/agents/bug_predictor_agent.py +58 -0
- patchi/core/agents/build_tool_validator.py +150 -0
- patchi/core/agents/cache.py +192 -0
- patchi/core/agents/chaos_agent.py +133 -0
- patchi/core/agents/cicd_generator.py +240 -0
- patchi/core/agents/comment_scanner.py +280 -0
- patchi/core/agents/console_logging_agent.py +156 -0
- patchi/core/agents/coordinator.py +858 -0
- patchi/core/agents/core_scanner.py +269 -0
- patchi/core/agents/coverage_prioritizer.py +181 -0
- patchi/core/agents/dead_code_hygiene.py +296 -0
- patchi/core/agents/dead_code_scanner.py +692 -0
- patchi/core/agents/dependency_scanner.py +725 -0
- patchi/core/agents/doc_claim_agent.py +573 -0
- patchi/core/agents/duplicate_scanner.py +568 -0
- patchi/core/agents/env_scanner.py +410 -0
- patchi/core/agents/feature_flag_agent.py +89 -0
- patchi/core/agents/frontend_framework_agent.py +133 -0
- patchi/core/agents/goroutine_agent.py +82 -0
- patchi/core/agents/governor.py +1972 -0
- patchi/core/agents/i18n_agent.py +95 -0
- patchi/core/agents/license_compliance.py +247 -0
- patchi/core/agents/linking_agent.py +251 -0
- patchi/core/agents/memory_profiler_agent.py +102 -0
- patchi/core/agents/modernization_agent.py +84 -0
- patchi/core/agents/mutation_agent.py +174 -0
- patchi/core/agents/nl_query_agent.py +74 -0
- patchi/core/agents/promise_rejection_agent.py +100 -0
- patchi/core/agents/refactoring_agent.py +286 -0
- patchi/core/agents/resource_leak_agent.py +91 -0
- patchi/core/agents/route_graph_scanner.py +951 -0
- patchi/core/agents/run_agent.py +106 -0
- patchi/core/agents/rust_unwrap_agent.py +100 -0
- patchi/core/agents/sbom_generator.py +284 -0
- patchi/core/agents/scanners.py +67 -0
- patchi/core/agents/side/__init__.py +1 -0
- patchi/core/agents/side/build_agent.py +65 -0
- patchi/core/agents/side/format_agent.py +50 -0
- patchi/core/agents/side/install_agent.py +63 -0
- patchi/core/agents/side_file_scanner.py +549 -0
- patchi/core/agents/snapshot_drift_detector.py +146 -0
- patchi/core/agents/spa_route_inventory.py +203 -0
- patchi/core/agents/test_scanner.py +442 -0
- patchi/core/agents/tool_health.py +109 -0
- patchi/core/agents/tool_runner.py +162 -0
- patchi/core/agents/type_scanner.py +168 -0
- patchi/core/agents/ui_scanner.py +558 -0
- patchi/core/ai/__init__.py +20 -0
- patchi/core/ai/agent_audit.py +153 -0
- patchi/core/ai/agent_profiler.py +277 -0
- patchi/core/ai/client.py +499 -0
- patchi/core/ai/cost_tracker.py +107 -0
- patchi/core/ai/model_router.py +318 -0
- patchi/core/ai/orchestrator.py +625 -0
- patchi/core/ai/prompts.py +681 -0
- patchi/core/ai/smart.py +302 -0
- patchi/core/ai/tool_executor.py +24 -0
- patchi/core/ai/tools/__init__.py +10 -0
- patchi/core/ai/tools/executor.py +621 -0
- patchi/core/ai/tools/realize.py +1160 -0
- patchi/core/ai/tools/registry.py +1577 -0
- patchi/core/assurance/__init__.py +33 -0
- patchi/core/assurance/builder.py +211 -0
- patchi/core/assurance/graph.py +342 -0
- patchi/core/assurance/invariants.py +168 -0
- patchi/core/atomic.py +61 -0
- patchi/core/attackers/__init__.py +17 -0
- patchi/core/attackers/api_abuse.py +89 -0
- patchi/core/attackers/auth_bypass.py +73 -0
- patchi/core/attackers/base.py +91 -0
- patchi/core/attackers/business_logic.py +88 -0
- patchi/core/attackers/planner.py +108 -0
- patchi/core/attackers/priv_escalation.py +88 -0
- patchi/core/attackers/recon.py +87 -0
- patchi/core/brain/__init__.py +0 -0
- patchi/core/brain/ast_utils/__init__.py +81 -0
- patchi/core/brain/ast_utils/assignments.py +219 -0
- patchi/core/brain/ast_utils/calls.py +139 -0
- patchi/core/brain/ast_utils/config.py +132 -0
- patchi/core/brain/ast_utils/control_flow.py +74 -0
- patchi/core/brain/ast_utils/dead_symbols.py +205 -0
- patchi/core/brain/ast_utils/decorators.py +108 -0
- patchi/core/brain/ast_utils/helpers.py +144 -0
- patchi/core/brain/ast_utils/imports.py +169 -0
- patchi/core/brain/ast_utils/scan.py +143 -0
- patchi/core/brain/ast_utils/taint.py +310 -0
- patchi/core/brain/audit.py +379 -0
- patchi/core/brain/baseline.py +195 -0
- patchi/core/brain/blast_radius.py +312 -0
- patchi/core/brain/body_tags.py +144 -0
- patchi/core/brain/brain.py +1161 -0
- patchi/core/brain/brain_context.py +393 -0
- patchi/core/brain/brain_watcher.py +340 -0
- patchi/core/brain/charter.py +473 -0
- patchi/core/brain/classifier.py +214 -0
- patchi/core/brain/cockpit.py +350 -0
- patchi/core/brain/code_query.py +710 -0
- patchi/core/brain/contract.py +889 -0
- patchi/core/brain/contract_diff.py +155 -0
- patchi/core/brain/council.py +800 -0
- patchi/core/brain/cross_repo.py +104 -0
- patchi/core/brain/dead_code_tools.py +157 -0
- patchi/core/brain/doc_validator.py +197 -0
- patchi/core/brain/domain_activator.py +2183 -0
- patchi/core/brain/enriched_context.py +220 -0
- patchi/core/brain/file_corpus.py +220 -0
- patchi/core/brain/framework.py +953 -0
- patchi/core/brain/freshness.py +273 -0
- patchi/core/brain/git_aware.py +206 -0
- patchi/core/brain/heatmap.py +104 -0
- patchi/core/brain/ignore_parser.py +153 -0
- patchi/core/brain/import_graph.py +486 -0
- patchi/core/brain/languages.py +397 -0
- patchi/core/brain/layered_brain.py +419 -0
- patchi/core/brain/learning.py +229 -0
- patchi/core/brain/orphaned_endpoints.py +313 -0
- patchi/core/brain/personas/__init__.py +648 -0
- patchi/core/brain/personas/bad_user.py +165 -0
- patchi/core/brain/personas/base.py +349 -0
- patchi/core/brain/proactive.py +807 -0
- patchi/core/brain/project_context.py +121 -0
- patchi/core/brain/project_reader.py +521 -0
- patchi/core/brain/reasoning.py +269 -0
- patchi/core/brain/route_detector/__init__.py +15 -0
- patchi/core/brain/route_detector/base.py +31 -0
- patchi/core/brain/route_detector/csharp.py +209 -0
- patchi/core/brain/route_detector/file_based.py +81 -0
- patchi/core/brain/route_detector/go.py +152 -0
- patchi/core/brain/route_detector/java.py +195 -0
- patchi/core/brain/route_detector/javascript.py +197 -0
- patchi/core/brain/route_detector/php.py +158 -0
- patchi/core/brain/route_detector/python.py +193 -0
- patchi/core/brain/route_detector/registry.py +25 -0
- patchi/core/brain/route_detector/ruby.py +134 -0
- patchi/core/brain/route_detector/rust.py +216 -0
- patchi/core/brain/route_detector/swift.py +118 -0
- patchi/core/brain/route_mapper.py +265 -0
- patchi/core/brain/scanner.py +2136 -0
- patchi/core/brain/secrets.py +280 -0
- patchi/core/brain/symbol_graph.py +1955 -0
- patchi/core/brain/team.py +28 -0
- patchi/core/brain/trace_log.py +132 -0
- patchi/core/brain/type_checker/__init__.py +65 -0
- patchi/core/brain/type_checker/base.py +60 -0
- patchi/core/brain/type_checker/csharp.py +59 -0
- patchi/core/brain/type_checker/dart.py +59 -0
- patchi/core/brain/type_checker/go.py +81 -0
- patchi/core/brain/type_checker/java.py +75 -0
- patchi/core/brain/type_checker/kotlin.py +75 -0
- patchi/core/brain/type_checker/php.py +88 -0
- patchi/core/brain/type_checker/python.py +87 -0
- patchi/core/brain/type_checker/rust.py +81 -0
- patchi/core/brain/type_checker/swift.py +59 -0
- patchi/core/brain/type_checker/typescript.py +298 -0
- patchi/core/brain/understander.py +130 -0
- patchi/core/brain/verify.py +379 -0
- patchi/core/campaigns/__init__.py +14 -0
- patchi/core/campaigns/base.py +62 -0
- patchi/core/campaigns/data_flow_abuse.py +153 -0
- patchi/core/campaigns/orchestrator.py +140 -0
- patchi/core/campaigns/state_transitions.py +178 -0
- patchi/core/ci_bundle.py +104 -0
- patchi/core/config.py +311 -0
- patchi/core/constants.py +245 -0
- patchi/core/debug/__init__.py +25 -0
- patchi/core/debug/adapters/__init__.py +11 -0
- patchi/core/debug/adapters/codelldb.py +379 -0
- patchi/core/debug/adapters/node.py +222 -0
- patchi/core/debug/adapters/powershell.py +210 -0
- patchi/core/debug/adapters/python.py +244 -0
- patchi/core/debug/capture.py +210 -0
- patchi/core/debug/dap_client.py +268 -0
- patchi/core/detector/__init__.py +0 -0
- patchi/core/detector/audit.py +194 -0
- patchi/core/detector/bus.py +226 -0
- patchi/core/detector/dispatcher.py +275 -0
- patchi/core/detector/event.py +254 -0
- patchi/core/detector/sigma_engine.py +378 -0
- patchi/core/detector/triage.py +289 -0
- patchi/core/export/sarif.py +113 -0
- patchi/core/fix/__init__.py +17 -0
- patchi/core/fix/applier.py +574 -0
- patchi/core/fix/base.py +510 -0
- patchi/core/fix/code_fixer.py +169 -0
- patchi/core/fix/dead_code_remover.py +131 -0
- patchi/core/fix/fix_agents.py +775 -0
- patchi/core/fix/patch.py +444 -0
- patchi/core/fix/risk_gate.py +383 -0
- patchi/core/fix/security_fixer.py +93 -0
- patchi/core/fix/verify_loop.py +204 -0
- patchi/core/fuzz/__init__.py +8 -0
- patchi/core/fuzz/corpus.py +137 -0
- patchi/core/fuzz/input_fuzzer.py +256 -0
- patchi/core/fuzz/sequence_fuzzer.py +162 -0
- patchi/core/fuzz/state_fuzzer.py +236 -0
- patchi/core/health.py +407 -0
- patchi/core/hosted/__init__.py +1 -0
- patchi/core/hosted/anomaly.py +298 -0
- patchi/core/hosted/audit_log.py +102 -0
- patchi/core/hosted/compliance_report.py +148 -0
- patchi/core/hosted/ip_reputation.py +169 -0
- patchi/core/hosted/log_parsers.py +251 -0
- patchi/core/hosted/tokens.py +127 -0
- patchi/core/hosted/watchlist.py +164 -0
- patchi/core/hosted/webhooks.py +156 -0
- patchi/core/memory.py +437 -0
- patchi/core/notifications/__init__.py +18 -0
- patchi/core/notifications/channels.py +192 -0
- patchi/core/notifications/digest.py +141 -0
- patchi/core/notifications/escalation.py +158 -0
- patchi/core/notifications/notifier.py +182 -0
- patchi/core/notifications/quiet_hours.py +69 -0
- patchi/core/plugins/__init__.py +48 -0
- patchi/core/plugins/analyzer.py +326 -0
- patchi/core/plugins/analyzers/__init__.py +6 -0
- patchi/core/plugins/analyzers/dead_code_analyzer.py +120 -0
- patchi/core/plugins/analyzers/secret_scanner.py +158 -0
- patchi/core/plugins/analyzers/todo_scanner.py +115 -0
- patchi/core/plugins/registry.py +335 -0
- patchi/core/queue.py +375 -0
- patchi/core/reliability/__init__.py +13 -0
- patchi/core/reliability/chaos.py +163 -0
- patchi/core/reliability/fault_injector.py +172 -0
- patchi/core/reliability/idempotency.py +128 -0
- patchi/core/reliability/recovery.py +147 -0
- patchi/core/runtime/__init__.py +5 -0
- patchi/core/runtime/tracer.py +230 -0
- patchi/core/scan_bus.py +156 -0
- patchi/core/security/__init__.py +1 -0
- patchi/core/security/ai_validator.py +462 -0
- patchi/core/security/app_mapper.py +216 -0
- patchi/core/security/app_profile.py +464 -0
- patchi/core/security/attack_feedback.py +141 -0
- patchi/core/security/attack_scenarios/__init__.py +1 -0
- patchi/core/security/attack_tree.py +176 -0
- patchi/core/security/auth_audit_agent.py +344 -0
- patchi/core/security/authz_agent.py +602 -0
- patchi/core/security/auto_fix_proactive.py +620 -0
- patchi/core/security/auto_fixer.py +660 -0
- patchi/core/security/auto_ticket.py +53 -0
- patchi/core/security/bandit_agent.py +152 -0
- patchi/core/security/blast_radius.py +226 -0
- patchi/core/security/browser_tester.py +333 -0
- patchi/core/security/business_logic_agent.py +263 -0
- patchi/core/security/catch_block_auditor.py +395 -0
- patchi/core/security/cdn_cache_agent.py +357 -0
- patchi/core/security/chain_analyzer.py +321 -0
- patchi/core/security/chain_to_assurance.py +168 -0
- patchi/core/security/charter.py +510 -0
- patchi/core/security/cloud_waf_detector.py +295 -0
- patchi/core/security/codeql_agent.py +233 -0
- patchi/core/security/compliance_agent.py +348 -0
- patchi/core/security/confidence_gate.py +423 -0
- patchi/core/security/container_scanner.py +282 -0
- patchi/core/security/crypto_agent.py +378 -0
- patchi/core/security/cve_monitor.py +262 -0
- patchi/core/security/dast_agent.py +865 -0
- patchi/core/security/defectdojo.py +238 -0
- patchi/core/security/defenders/__init__.py +75 -0
- patchi/core/security/defenders/auth_middleware.py +115 -0
- patchi/core/security/defenders/base.py +97 -0
- patchi/core/security/defenders/block_ip.py +71 -0
- patchi/core/security/defenders/block_ws_origin.py +36 -0
- patchi/core/security/defenders/crypto_fix.py +118 -0
- patchi/core/security/defenders/enforce_rate_limit.py +108 -0
- patchi/core/security/defenders/fix_code.py +93 -0
- patchi/core/security/defenders/invalidate_session.py +40 -0
- patchi/core/security/defenders/patch_config.py +70 -0
- patchi/core/security/defenders/rotate_secret.py +54 -0
- patchi/core/security/defenders/suspend_account.py +41 -0
- patchi/core/security/defenders/update_dependency.py +160 -0
- patchi/core/security/defense_layer.py +217 -0
- patchi/core/security/dependency_vulnerability_agent.py +616 -0
- patchi/core/security/detection_pipeline.py +289 -0
- patchi/core/security/dns_security_agent.py +497 -0
- patchi/core/security/domain_activator_v2.py +913 -0
- patchi/core/security/domain_loader.py +1004 -0
- patchi/core/security/domains/__init__.py +0 -0
- patchi/core/security/email_authentication_agent.py +417 -0
- patchi/core/security/env_var_validator.py +304 -0
- patchi/core/security/evidence.py +100 -0
- patchi/core/security/falco_runtime_agent.py +387 -0
- patchi/core/security/fix-playbooks/__init__.py +0 -0
- patchi/core/security/gated_finding.py +85 -0
- patchi/core/security/git_diff_activator.py +367 -0
- patchi/core/security/governance.py +208 -0
- patchi/core/security/gradual_gate.py +34 -0
- patchi/core/security/history.py +315 -0
- patchi/core/security/iac_scanner.py +250 -0
- patchi/core/security/ignore_expiry.py +111 -0
- patchi/core/security/ignore_learner.py +416 -0
- patchi/core/security/injection_agent.py +319 -0
- patchi/core/security/insecure_randomness_agent.py +288 -0
- patchi/core/security/intent_analyzer.py +326 -0
- patchi/core/security/jwt_agent.py +453 -0
- patchi/core/security/kubernetes_agent.py +638 -0
- patchi/core/security/layer2_orchestrator.py +341 -0
- patchi/core/security/llm_security_agent.py +252 -0
- patchi/core/security/misconfig_agent.py +600 -0
- patchi/core/security/mobile_security_agent.py +474 -0
- patchi/core/security/network_agent.py +376 -0
- patchi/core/security/noise_filter.py +274 -0
- patchi/core/security/orchestrator.py +313 -0
- patchi/core/security/pattern_context.py +125 -0
- patchi/core/security/pattern_loader.py +101 -0
- patchi/core/security/pentest/__init__.py +4 -0
- patchi/core/security/pentest/base.py +58 -0
- patchi/core/security/pentest/dalfox_adapter.py +40 -0
- patchi/core/security/pentest/ffuf_adapter.py +48 -0
- patchi/core/security/pentest/nuclei_adapter.py +50 -0
- patchi/core/security/pentest/registry.py +80 -0
- patchi/core/security/pentest/shannon_adapter.py +107 -0
- patchi/core/security/pentest/sqlmap_adapter.py +56 -0
- patchi/core/security/pentest/zap_adapter.py +52 -0
- patchi/core/security/plan_auditor.py +276 -0
- patchi/core/security/policy_engine.py +272 -0
- patchi/core/security/prechecks.py +172 -0
- patchi/core/security/precommit_hook.py +247 -0
- patchi/core/security/privacy_agent.py +361 -0
- patchi/core/security/push_notification_agent.py +417 -0
- patchi/core/security/pysa_agent.py +143 -0
- patchi/core/security/reasoning.py +442 -0
- patchi/core/security/red_team_agent.py +184 -0
- patchi/core/security/red_team_engine.py +1142 -0
- patchi/core/security/remediation.py +360 -0
- patchi/core/security/request_interceptor.py +294 -0
- patchi/core/security/runtime_validator.py +245 -0
- patchi/core/security/saml_sso_agent.py +401 -0
- patchi/core/security/sast_agent.py +213 -0
- patchi/core/security/sast_gate.py +95 -0
- patchi/core/security/scheduler.py +287 -0
- patchi/core/security/secrets_runtime_agent.py +476 -0
- patchi/core/security/security_agents.py +175 -0
- patchi/core/security/security_config.py +551 -0
- patchi/core/security/security_probe.py +762 -0
- patchi/core/security/security_taint.py +1005 -0
- patchi/core/security/sensitive_data_agent.py +271 -0
- patchi/core/security/service_mesh_agent.py +412 -0
- patchi/core/security/session_management_agent.py +245 -0
- patchi/core/security/ssrf_agent.py +422 -0
- patchi/core/security/supply_chain.py +449 -0
- patchi/core/security/threat_model_generator.py +378 -0
- patchi/core/security/threat_model_updater.py +196 -0
- patchi/core/security/tool_adapters.py +135 -0
- patchi/core/security/tool_verify.py +173 -0
- patchi/core/security/websocket_security_agent.py +188 -0
- patchi/core/snapshot.py +242 -0
- patchi/core/tenant.py +386 -0
- patchi/core/testing/__init__.py +17 -0
- patchi/core/testing/_browser.py +147 -0
- patchi/core/testing/ads_agent.py +141 -0
- patchi/core/testing/api_contract_agent.py +450 -0
- patchi/core/testing/app_discovery_agent.py +641 -0
- patchi/core/testing/app_launcher.py +184 -0
- patchi/core/testing/browser_test_agent.py +516 -0
- patchi/core/testing/e2e_flow_agent.py +354 -0
- patchi/core/testing/flake_detector_agent.py +292 -0
- patchi/core/testing/gate.py +31 -0
- patchi/core/testing/live_test_runner.py +304 -0
- patchi/core/testing/live_v2/__init__.py +1 -0
- patchi/core/testing/live_v2/browser_pool.py +349 -0
- patchi/core/testing/live_v2/browser_test_runner.py +313 -0
- patchi/core/testing/live_v2/runner.py +626 -0
- patchi/core/testing/live_v2/screenshot_manager.py +474 -0
- patchi/core/testing/live_v2/stress_orchestrator.py +523 -0
- patchi/core/testing/live_v2/video_recorder.py +303 -0
- patchi/core/testing/regression_agent.py +302 -0
- patchi/core/testing/security_test_agent.py +253 -0
- patchi/core/testing/stress_test_agent.py +203 -0
- patchi/core/testing/test_agents.py +50 -0
- patchi/core/testing/test_config.py +194 -0
- patchi/core/testing/ui_accessibility_agent.py +324 -0
- patchi/core/testing/ui_button_agent.py +294 -0
- patchi/core/testing/ui_layout_agent.py +330 -0
- patchi/core/testing/unit_test_agent.py +1149 -0
- patchi/core/testing/visual_regression_agent.py +260 -0
- patchi/web/__init__.py +1 -0
- patchi/web/api/__init__.py +1 -0
- patchi/web/api/brain_map.py +114 -0
- patchi/web/api/charts.py +47 -0
- patchi/web/api/chat.py +224 -0
- patchi/web/api/cicd.py +576 -0
- patchi/web/api/dast_evidence.py +49 -0
- patchi/web/api/dev_check.py +320 -0
- patchi/web/api/fix.py +134 -0
- patchi/web/api/guard.py +65 -0
- patchi/web/api/hosted.py +128 -0
- patchi/web/api/hosted_v2.py +234 -0
- patchi/web/api/live_testing.py +699 -0
- patchi/web/api/scan.py +582 -0
- patchi/web/api/smart.py +111 -0
- patchi/web/api/tenant.py +117 -0
- patchi/web/api/tools.py +85 -0
- patchi/web/api_legacy.py +2401 -0
- patchi/web/app.py +310 -0
- patchi/web/events.py +483 -0
- patchi/web/routes/__init__.py +1 -0
- patchi/web/routes/assurance.py +541 -0
- patchi/web/routes/brain.py +36 -0
- patchi/web/routes/charter.py +227 -0
- patchi/web/routes/chat.py +23 -0
- patchi/web/routes/dashboard.py +558 -0
- patchi/web/routes/dashboard_v2.py +402 -0
- patchi/web/routes/findings.py +330 -0
- patchi/web/routes/guard.py +35 -0
- patchi/web/routes/history.py +126 -0
- patchi/web/routes/landing.py +52 -0
- patchi/web/routes/live_testing.py +24 -0
- patchi/web/routes/review.py +57 -0
- patchi/web/routes/self_improvement.py +110 -0
- patchi/web/routes/settings.py +49 -0
- patchi/web/routes/tokens.py +29 -0
- patchi/web/spawn.py +139 -0
- patchi/web/ws.py +141 -0
- patchi-0.7.5.dist-info/METADATA +315 -0
- patchi-0.7.5.dist-info/RECORD +496 -0
- patchi-0.7.5.dist-info/WHEEL +5 -0
- patchi-0.7.5.dist-info/licenses/LICENSE +60 -0
- 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."
|