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