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,807 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proactive Agent — Phase 4 of the Patchi super-agent.
|
|
3
|
+
|
|
4
|
+
Turns a change-set into *safe, mechanical* fixes and applies them behind a flag.
|
|
5
|
+
This is the "proactive" half of Pillar 3's event table: when a file is saved,
|
|
6
|
+
Patchi proposes (and, with ``--apply``, performs) fixes such as:
|
|
7
|
+
|
|
8
|
+
- missing_import → a name is used but never imported/defined; add the import
|
|
9
|
+
- unused_import → an imported name is never referenced; drop the line
|
|
10
|
+
- dead_code → a top-level def is referenced nowhere; flag for removal
|
|
11
|
+
- signature_callers→ a function's signature changed; list/update its callers
|
|
12
|
+
- charter_violation→ the change breaks a guard-rail boundary
|
|
13
|
+
- format → run the project formatter (ruff/black) if available
|
|
14
|
+
|
|
15
|
+
Detection is offline (AST for Python, textual heuristics otherwise) and reads
|
|
16
|
+
only the Layered Brain / import graph — no raw re-scan of the whole repo.
|
|
17
|
+
Applying is always opt-in (``--apply`` for safe fixes, ``--unsafe`` for all).
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from patchi.core.brain import learning
|
|
31
|
+
from patchi.core.brain.layered_brain import _classify_subsystem, _module_of
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from patchi.core.brain.import_graph import ImportGraph
|
|
35
|
+
from patchi.core.brain.scanner import FileInfo
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# fix_type → (human label, is_safe_to_auto_apply)
|
|
39
|
+
_FIX_META: dict[str, tuple[str, bool]] = {
|
|
40
|
+
"missing_import": ("Add missing import", True),
|
|
41
|
+
"unused_import": ("Remove unused import", True),
|
|
42
|
+
"dead_code": ("Remove dead code", True),
|
|
43
|
+
"format": ("Format file", True),
|
|
44
|
+
"signature_callers": ("Update callers after signature change", False),
|
|
45
|
+
"charter_violation": ("Charter violation in change", False),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_log = logging.getLogger("patchi.brain.proactive")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ProposedFix:
|
|
54
|
+
"""A single safe/proposed fix for one changed file."""
|
|
55
|
+
|
|
56
|
+
fix_type: str
|
|
57
|
+
file: str
|
|
58
|
+
description: str
|
|
59
|
+
safe: bool
|
|
60
|
+
name: str = "" # symbol involved (function/class/import name)
|
|
61
|
+
suggested: str = "" # e.g. the import line to add, or replacement text
|
|
62
|
+
callers: list[str] = field(default_factory=list)
|
|
63
|
+
line: int = 0 # line of the symbol (for UI/debug)
|
|
64
|
+
|
|
65
|
+
def to_dict(self) -> dict:
|
|
66
|
+
return {
|
|
67
|
+
"fix_type": self.fix_type,
|
|
68
|
+
"file": self.file,
|
|
69
|
+
"description": self.description,
|
|
70
|
+
"safe": self.safe,
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"suggested": self.suggested,
|
|
73
|
+
"callers": self.callers,
|
|
74
|
+
"line": self.line,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _word_in_source(name: str, src: str) -> bool:
|
|
79
|
+
return bool(re.search(rf"(?<![\w.]){re.escape(name)}(?![\w])", src))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProactiveAgent:
|
|
83
|
+
"""Proposes (and optionally applies) safe fixes for a change-set."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, root: Path):
|
|
86
|
+
self.root = Path(root)
|
|
87
|
+
|
|
88
|
+
# ── Analysis ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def analyze_change(
|
|
91
|
+
self,
|
|
92
|
+
changed_files: list[str],
|
|
93
|
+
file_infos: list[FileInfo],
|
|
94
|
+
graph: ImportGraph | None = None,
|
|
95
|
+
charter: object | None = None,
|
|
96
|
+
include_format: bool = True,
|
|
97
|
+
) -> list[ProposedFix]:
|
|
98
|
+
fi_map = {fi.path: fi for fi in file_infos}
|
|
99
|
+
public_api: dict[str, str] = {}
|
|
100
|
+
for fi in file_infos:
|
|
101
|
+
for fn in fi.functions:
|
|
102
|
+
public_api.setdefault(fn.name, fi.path)
|
|
103
|
+
for cl in fi.classes:
|
|
104
|
+
public_api.setdefault(cl.name, fi.path)
|
|
105
|
+
|
|
106
|
+
# Shared read cache for this whole call. Without it, dead-code checking
|
|
107
|
+
# re-reads every other file from disk for *every candidate symbol in
|
|
108
|
+
# every file* — O(files^2 * symbols_per_file) disk reads. On a
|
|
109
|
+
# whole-project scan (hundreds of files) that's well over a million
|
|
110
|
+
# redundant reads and can make the call take minutes or effectively hang.
|
|
111
|
+
# File contents don't change during this synchronous scan, so caching
|
|
112
|
+
# is safe.
|
|
113
|
+
src_cache: dict[str, str] = {}
|
|
114
|
+
|
|
115
|
+
def _cached_read(path: str) -> str | None:
|
|
116
|
+
if path not in src_cache:
|
|
117
|
+
src = self._read(path)
|
|
118
|
+
if src is None:
|
|
119
|
+
return None
|
|
120
|
+
src_cache[path] = src
|
|
121
|
+
return src_cache[path]
|
|
122
|
+
|
|
123
|
+
fixes: list[ProposedFix] = []
|
|
124
|
+
for path in changed_files:
|
|
125
|
+
fi = fi_map.get(path)
|
|
126
|
+
if not fi:
|
|
127
|
+
continue
|
|
128
|
+
src = _cached_read(path)
|
|
129
|
+
if src is None:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if fi.language == "python":
|
|
133
|
+
fixes += self._check_imports(path, src, fi, public_api)
|
|
134
|
+
fixes += self._check_dead_code(
|
|
135
|
+
path, src, fi, file_infos, fi_map, src_cache=src_cache
|
|
136
|
+
)
|
|
137
|
+
fixes += self._check_signature(path, src, fi)
|
|
138
|
+
if charter is not None:
|
|
139
|
+
fixes += self._check_charter(path, fi, graph, charter)
|
|
140
|
+
if include_format and self._formatter():
|
|
141
|
+
fixes.append(
|
|
142
|
+
ProposedFix(
|
|
143
|
+
"format",
|
|
144
|
+
path,
|
|
145
|
+
"File can be auto-formatted",
|
|
146
|
+
True,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return fixes
|
|
150
|
+
|
|
151
|
+
# ── Import analysis (Python AST) ──────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def _check_imports(
|
|
154
|
+
self, path: str, src: str, fi: FileInfo, public_api: dict[str, str]
|
|
155
|
+
) -> list[ProposedFix]:
|
|
156
|
+
try:
|
|
157
|
+
tree = ast.parse(src)
|
|
158
|
+
except SyntaxError:
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
imported_names: set[str] = set()
|
|
162
|
+
import_lines: dict[str, int] = {}
|
|
163
|
+
for node in ast.walk(tree):
|
|
164
|
+
if isinstance(node, ast.Import):
|
|
165
|
+
for alias in node.names:
|
|
166
|
+
imported_names.add(alias.asname or alias.name.split(".")[0])
|
|
167
|
+
import_lines[alias.asname or alias.name.split(".")[0]] = node.lineno
|
|
168
|
+
elif isinstance(node, ast.ImportFrom):
|
|
169
|
+
for alias in node.names:
|
|
170
|
+
imported_names.add(alias.asname or alias.name)
|
|
171
|
+
import_lines[alias.asname or alias.name] = node.lineno
|
|
172
|
+
|
|
173
|
+
defined_names: set[str] = {fn.name for fn in fi.functions} | {cl.name for cl in fi.classes}
|
|
174
|
+
|
|
175
|
+
used_names: set[str] = set()
|
|
176
|
+
for node in ast.walk(tree):
|
|
177
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
178
|
+
used_names.add(node.id)
|
|
179
|
+
elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load):
|
|
180
|
+
used_names.add(node.attr)
|
|
181
|
+
|
|
182
|
+
fixes: list[ProposedFix] = []
|
|
183
|
+
# Missing imports
|
|
184
|
+
for name in sorted(used_names - imported_names - defined_names):
|
|
185
|
+
definer = public_api.get(name)
|
|
186
|
+
if definer and definer != path:
|
|
187
|
+
module = definer.rsplit(".py", 1)[0].replace("/", ".")
|
|
188
|
+
fixes.append(
|
|
189
|
+
ProposedFix(
|
|
190
|
+
"missing_import",
|
|
191
|
+
path,
|
|
192
|
+
f"'{name}' is used but not imported; define it in '{definer}'",
|
|
193
|
+
True,
|
|
194
|
+
name=name,
|
|
195
|
+
suggested=f"from {module} import {name}",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
# Unused imports
|
|
199
|
+
for name in sorted(imported_names - used_names - defined_names):
|
|
200
|
+
fixes.append(
|
|
201
|
+
ProposedFix(
|
|
202
|
+
"unused_import",
|
|
203
|
+
path,
|
|
204
|
+
f"Import '{name}' is never referenced",
|
|
205
|
+
True,
|
|
206
|
+
name=name,
|
|
207
|
+
line=import_lines.get(name, 0),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return fixes
|
|
211
|
+
|
|
212
|
+
# ── Dead code (referenced nowhere in the project) ─────────────────────────────
|
|
213
|
+
|
|
214
|
+
def _check_dead_code(
|
|
215
|
+
self,
|
|
216
|
+
path: str,
|
|
217
|
+
src: str,
|
|
218
|
+
fi: FileInfo,
|
|
219
|
+
file_infos: list[FileInfo],
|
|
220
|
+
fi_map: dict[str, FileInfo],
|
|
221
|
+
src_cache: dict[str, str] | None = None,
|
|
222
|
+
) -> list[ProposedFix]:
|
|
223
|
+
fixes: list[ProposedFix] = []
|
|
224
|
+
# Map name -> def line span so we can exclude the definition itself
|
|
225
|
+
# when searching for *references* within the same file.
|
|
226
|
+
span: dict[str, tuple[int, int]] = {}
|
|
227
|
+
for f in fi.functions:
|
|
228
|
+
span[f.name] = (getattr(f, "line", 1), getattr(f, "end_line", getattr(f, "line", 1)))
|
|
229
|
+
for c in fi.classes:
|
|
230
|
+
span[c.name] = (getattr(c, "line", 1), getattr(c, "end_line", getattr(c, "line", 1)))
|
|
231
|
+
candidates = list(span)
|
|
232
|
+
# Skip likely entry points (invoked externally, not within the project).
|
|
233
|
+
entry_names = {"main", "run", "__main__"}
|
|
234
|
+
candidates = [n for n in candidates if n not in entry_names and not n.startswith("__")]
|
|
235
|
+
|
|
236
|
+
# Read every other file's source exactly once for this whole method call
|
|
237
|
+
# (shared across all candidate symbols), instead of once per symbol.
|
|
238
|
+
# Uses the caller-provided cache when available so it's shared across
|
|
239
|
+
# the *entire* analyze_change() call too, not just this one file.
|
|
240
|
+
cache = src_cache if src_cache is not None else {}
|
|
241
|
+
other_sources: dict[str, str] = {}
|
|
242
|
+
for other in file_infos:
|
|
243
|
+
if other.path in cache:
|
|
244
|
+
other_sources[other.path] = cache[other.path]
|
|
245
|
+
continue
|
|
246
|
+
src_o = self._read(other.path)
|
|
247
|
+
if src_o is None:
|
|
248
|
+
continue
|
|
249
|
+
cache[other.path] = src_o
|
|
250
|
+
other_sources[other.path] = src_o
|
|
251
|
+
|
|
252
|
+
for name in candidates:
|
|
253
|
+
# Search every file; for the defining file, blank out the def lines
|
|
254
|
+
# so the definition does not count as a reference.
|
|
255
|
+
referenced = False
|
|
256
|
+
for other_path, src_o in other_sources.items():
|
|
257
|
+
if other_path == path:
|
|
258
|
+
lines = src_o.splitlines()
|
|
259
|
+
lo, hi = span.get(name, (0, 0))
|
|
260
|
+
for ln in range(lo - 1, hi):
|
|
261
|
+
if 0 <= ln < len(lines):
|
|
262
|
+
lines[ln] = ""
|
|
263
|
+
src_o = "\n".join(lines)
|
|
264
|
+
if _word_in_source(name, src_o):
|
|
265
|
+
referenced = True
|
|
266
|
+
break
|
|
267
|
+
if not referenced:
|
|
268
|
+
# Find the def line for context.
|
|
269
|
+
line = 0
|
|
270
|
+
for fn in fi.functions:
|
|
271
|
+
if fn.name == name:
|
|
272
|
+
line = getattr(fn, "line", 0)
|
|
273
|
+
for cl in fi.classes:
|
|
274
|
+
if cl.name == name:
|
|
275
|
+
line = getattr(cl, "line", 0)
|
|
276
|
+
fixes.append(
|
|
277
|
+
ProposedFix(
|
|
278
|
+
"dead_code",
|
|
279
|
+
path,
|
|
280
|
+
f"'{name}' is defined but referenced nowhere in the project",
|
|
281
|
+
True,
|
|
282
|
+
name=name,
|
|
283
|
+
line=line,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
return fixes
|
|
287
|
+
|
|
288
|
+
# ── Signature change → caller update ───────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
def _check_signature(self, path: str, src: str, fi: FileInfo) -> list[ProposedFix]:
|
|
291
|
+
try:
|
|
292
|
+
tree = ast.parse(src)
|
|
293
|
+
except SyntaxError:
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
current: dict[str, list[str]] = {}
|
|
297
|
+
for node in tree.body:
|
|
298
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
299
|
+
args = node.args
|
|
300
|
+
params = [a.arg for a in args.args]
|
|
301
|
+
params += [a.arg for a in args.kwonlyargs]
|
|
302
|
+
if args.vararg:
|
|
303
|
+
params.append("*" + args.vararg.arg)
|
|
304
|
+
if args.kwarg:
|
|
305
|
+
params.append("**" + args.kwarg.arg)
|
|
306
|
+
current[node.name] = params
|
|
307
|
+
|
|
308
|
+
baseline = self._load_baseline().get(path, {})
|
|
309
|
+
if not baseline:
|
|
310
|
+
self._store_baseline({path: current})
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
fixes: list[ProposedFix] = []
|
|
314
|
+
for name, params in current.items():
|
|
315
|
+
if name in baseline and baseline[name] != params:
|
|
316
|
+
callers = self._find_callers(path, name)
|
|
317
|
+
fixes.append(
|
|
318
|
+
ProposedFix(
|
|
319
|
+
"signature_callers",
|
|
320
|
+
path,
|
|
321
|
+
f"'{name}' signature changed ({baseline[name]} → {params}); "
|
|
322
|
+
f"review {len(callers)} caller(s)",
|
|
323
|
+
False,
|
|
324
|
+
name=name,
|
|
325
|
+
callers=callers,
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
# Update baseline for next run.
|
|
329
|
+
self._store_baseline({path: current})
|
|
330
|
+
return fixes
|
|
331
|
+
|
|
332
|
+
def _find_callers(self, path: str, name: str) -> list[str]:
|
|
333
|
+
"""Files (other than path) that contain a call site '<name>('."""
|
|
334
|
+
callers: list[str] = []
|
|
335
|
+
# Use the import graph to narrow the search to importers.
|
|
336
|
+
from patchi.core.brain.import_graph import build_import_graph
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
graph = build_import_graph(self.root)
|
|
340
|
+
candidates = set(graph.reverse.get(path, set()))
|
|
341
|
+
candidates |= graph.get_transitive_dependents(path)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
_log.warning("ProactiveAgent._find_callers failed: %s", e)
|
|
344
|
+
candidates = set()
|
|
345
|
+
# Fall back to a whole-project textual scan if no graph.
|
|
346
|
+
if not candidates:
|
|
347
|
+
from patchi.core.brain.scanner import FileScanner
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
candidates = {fi.path for fi in FileScanner(self.root).scan()}
|
|
351
|
+
except Exception as e:
|
|
352
|
+
_log.warning("ProactiveAgent._find_callers failed: %s", e)
|
|
353
|
+
candidates = set()
|
|
354
|
+
for c in candidates:
|
|
355
|
+
if c == path:
|
|
356
|
+
continue
|
|
357
|
+
if re.search(rf"(?<![\w.]){re.escape(name)}\s*\(", self._read(c) or ""):
|
|
358
|
+
callers.append(c)
|
|
359
|
+
return sorted(callers)
|
|
360
|
+
|
|
361
|
+
# ── Charter check on the changed file ──────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def _check_charter(
|
|
364
|
+
self, path: str, fi: FileInfo, graph: ImportGraph | None, charter: object
|
|
365
|
+
) -> list[ProposedFix]:
|
|
366
|
+
from patchi.core.brain.charter import _matches_boundary
|
|
367
|
+
|
|
368
|
+
boundaries = getattr(charter, "boundaries", [])
|
|
369
|
+
if not boundaries:
|
|
370
|
+
return []
|
|
371
|
+
sub = _classify_subsystem(_module_of(path))
|
|
372
|
+
# Subsystems this file imports.
|
|
373
|
+
imported_subs: set[str] = set()
|
|
374
|
+
if graph is not None:
|
|
375
|
+
for tgt in graph.edges.get(path, set()):
|
|
376
|
+
imported_subs.add(_classify_subsystem(_module_of(tgt)))
|
|
377
|
+
fixes: list[ProposedFix] = []
|
|
378
|
+
for b in boundaries:
|
|
379
|
+
for imp in sorted(imported_subs):
|
|
380
|
+
if _matches_boundary(sub, imp, b):
|
|
381
|
+
fixes.append(
|
|
382
|
+
ProposedFix(
|
|
383
|
+
"charter_violation",
|
|
384
|
+
path,
|
|
385
|
+
f"Change makes '{sub}' import '{imp}', violating charter "
|
|
386
|
+
f"'{b.get('from')} must not import {b.get('to')}'",
|
|
387
|
+
False,
|
|
388
|
+
name=f"{sub}→{imp}",
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
return fixes
|
|
392
|
+
|
|
393
|
+
# ── Application ────────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def apply(fix: ProposedFix, root: Path, apply_unsafe: bool = False) -> bool:
|
|
397
|
+
"""Apply a single fix. Returns True if applied. Safe fixes only unless apply_unsafe."""
|
|
398
|
+
if not fix.safe and not apply_unsafe:
|
|
399
|
+
return False
|
|
400
|
+
path = Path(root) / fix.file
|
|
401
|
+
if not path.exists():
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
if fix.fix_type == "missing_import":
|
|
405
|
+
return _apply_missing_import(path, fix.suggested)
|
|
406
|
+
if fix.fix_type == "unused_import":
|
|
407
|
+
return _apply_unused_import(path, fix.name, fix.line)
|
|
408
|
+
if fix.fix_type == "dead_code":
|
|
409
|
+
return _apply_dead_code(path, fix.name, fix.line)
|
|
410
|
+
if fix.fix_type == "format":
|
|
411
|
+
return _apply_format(path)
|
|
412
|
+
# signature_callers / charter_violation are never auto-applied.
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
# ── Helpers ─────────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
def _read(self, path: str) -> str | None:
|
|
418
|
+
try:
|
|
419
|
+
return (self.root / path).read_text(encoding="utf-8")
|
|
420
|
+
except Exception as e:
|
|
421
|
+
_log.warning("ProactiveAgent._read failed: %s", e)
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
def _formatter(self) -> str | None:
|
|
425
|
+
for tool in ("ruff", "black"):
|
|
426
|
+
if shutil.which(tool):
|
|
427
|
+
return tool
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
def _baseline_path(self) -> Path:
|
|
431
|
+
return self.root / ".patchi" / "memory" / "proactive_signatures.json"
|
|
432
|
+
|
|
433
|
+
def _load_baseline(self) -> dict:
|
|
434
|
+
import json
|
|
435
|
+
|
|
436
|
+
p = self._baseline_path()
|
|
437
|
+
if p.exists():
|
|
438
|
+
try:
|
|
439
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
440
|
+
except Exception as e:
|
|
441
|
+
_log.warning("ProactiveAgent._load_baseline failed: %s", e)
|
|
442
|
+
return {}
|
|
443
|
+
return {}
|
|
444
|
+
|
|
445
|
+
def _store_baseline(self, update: dict) -> None:
|
|
446
|
+
import json
|
|
447
|
+
|
|
448
|
+
p = self._baseline_path()
|
|
449
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
450
|
+
data = self._load_baseline()
|
|
451
|
+
data.update(update)
|
|
452
|
+
p.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── Low-level text edits ─────────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _write_guarded(path: Path, text: str) -> bool:
|
|
459
|
+
"""Write file content, refusing to wipe a file to empty/whitespace.
|
|
460
|
+
|
|
461
|
+
Guards the auto-apply path so a 'safe' fix can never destroy a file's
|
|
462
|
+
contents (e.g. removing the only top-level definition as 'dead code').
|
|
463
|
+
"""
|
|
464
|
+
if not text.strip():
|
|
465
|
+
return False
|
|
466
|
+
path.write_text(text, encoding="utf-8")
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _apply_missing_import(path: Path, import_line: str) -> bool:
|
|
471
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
472
|
+
# Insert after the last existing import, else at the top (after docstring).
|
|
473
|
+
insert_at = 0
|
|
474
|
+
last_import = -1
|
|
475
|
+
for i, line in enumerate(lines):
|
|
476
|
+
stripped = line.lstrip()
|
|
477
|
+
if stripped.startswith(("import ", "from ")):
|
|
478
|
+
last_import = i
|
|
479
|
+
elif stripped and not stripped.startswith("#"):
|
|
480
|
+
break
|
|
481
|
+
insert_at = last_import + 1 if last_import >= 0 else 0
|
|
482
|
+
if any(ln.strip() == import_line for ln in lines):
|
|
483
|
+
return False # already present
|
|
484
|
+
lines.insert(insert_at, import_line)
|
|
485
|
+
return _write_guarded(path, "\n".join(lines) + "\n")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _apply_unused_import(path: Path, name: str, line: int) -> bool:
|
|
489
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
490
|
+
target = line - 1 if line > 0 else None
|
|
491
|
+
if target is not None and 0 <= target < len(lines) and name in lines[target]:
|
|
492
|
+
del lines[target]
|
|
493
|
+
return _write_guarded(path, "\n".join(lines) + "\n")
|
|
494
|
+
# Fallback: remove first import line containing the name.
|
|
495
|
+
for i, line in enumerate(lines):
|
|
496
|
+
if re.search(
|
|
497
|
+
rf"(^|\b)(import\s+.*\b{re.escape(name)}\b|from\s+\S+\s+import\s+.*\b{re.escape(name)}\b)",
|
|
498
|
+
line,
|
|
499
|
+
):
|
|
500
|
+
del lines[i]
|
|
501
|
+
return _write_guarded(path, "\n".join(lines) + "\n")
|
|
502
|
+
return False
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _apply_dead_code(path: Path, name: str, line: int) -> bool:
|
|
506
|
+
"""Remove the top-level def block for ``name`` (best-effort, safe-only behind flag)."""
|
|
507
|
+
src = path.read_text(encoding="utf-8")
|
|
508
|
+
try:
|
|
509
|
+
tree = ast.parse(src)
|
|
510
|
+
except SyntaxError:
|
|
511
|
+
return False
|
|
512
|
+
target = None
|
|
513
|
+
for node in tree.body:
|
|
514
|
+
if (
|
|
515
|
+
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
516
|
+
and node.name == name
|
|
517
|
+
):
|
|
518
|
+
target = node
|
|
519
|
+
break
|
|
520
|
+
if target is None:
|
|
521
|
+
return False
|
|
522
|
+
start = target.lineno - 1
|
|
523
|
+
end = target.end_lineno # 1-based inclusive
|
|
524
|
+
lines = src.splitlines()
|
|
525
|
+
# Safety: never remove the only top-level definition in a file.
|
|
526
|
+
top_level_defs = [
|
|
527
|
+
n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))
|
|
528
|
+
]
|
|
529
|
+
if len(top_level_defs) <= 1:
|
|
530
|
+
return False
|
|
531
|
+
del lines[start:end]
|
|
532
|
+
# Drop a trailing blank line left behind.
|
|
533
|
+
while start < len(lines) and lines[start].strip() == "":
|
|
534
|
+
del lines[start]
|
|
535
|
+
return _write_guarded(path, "\n".join(lines) + "\n")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _apply_format(path: Path) -> bool:
|
|
539
|
+
tool = shutil.which("ruff") or shutil.which("black")
|
|
540
|
+
if not tool:
|
|
541
|
+
return False
|
|
542
|
+
try:
|
|
543
|
+
subprocess.run([tool, "format", str(path)], check=False, capture_output=True, timeout=30)
|
|
544
|
+
return True
|
|
545
|
+
except Exception as e:
|
|
546
|
+
_log.warning("_apply_format failed: %s", e)
|
|
547
|
+
return False
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def run_proactive(
|
|
551
|
+
root: Path,
|
|
552
|
+
files: list[str],
|
|
553
|
+
apply: bool = False,
|
|
554
|
+
unsafe: bool = False,
|
|
555
|
+
include_format: bool = True,
|
|
556
|
+
) -> dict:
|
|
557
|
+
"""
|
|
558
|
+
High-level entry point used by `p auto` and watch mode.
|
|
559
|
+
|
|
560
|
+
Scans project context, proposes proactive fixes for the given changed files,
|
|
561
|
+
optionally applies the safe ones, and honours learned rejections
|
|
562
|
+
(see patchi.core.brain.learning — the Learning Brain).
|
|
563
|
+
|
|
564
|
+
Returns a structured result:
|
|
565
|
+
{
|
|
566
|
+
"fixes": [ProposedFix] not suppressed,
|
|
567
|
+
"applied": [ProposedFix] actually applied,
|
|
568
|
+
"escalated": [ProposedFix] unsafe (need human/Governor review),
|
|
569
|
+
"skipped": [ProposedFix] safe but not applied (apply=False),
|
|
570
|
+
"suppressed": [ProposedFix] hidden because the user rejected them,
|
|
571
|
+
}
|
|
572
|
+
"""
|
|
573
|
+
from patchi.core.brain.charter import load_charter
|
|
574
|
+
from patchi.core.brain.import_graph import build_import_graph
|
|
575
|
+
from patchi.core.brain.scanner import FileScanner
|
|
576
|
+
|
|
577
|
+
scanner = FileScanner(root)
|
|
578
|
+
file_infos = scanner.scan()
|
|
579
|
+
graph = build_import_graph(root)
|
|
580
|
+
charter = load_charter(root)
|
|
581
|
+
agent = ProactiveAgent(root)
|
|
582
|
+
all_fixes = agent.analyze_change(
|
|
583
|
+
files, file_infos, graph, charter, include_format=include_format
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
# Phase 5 — Learning Brain: don't propose fix types the user rejects.
|
|
587
|
+
suppressed = [f for f in all_fixes if not learning.should_suggest(f.fix_type, root)]
|
|
588
|
+
active = [f for f in all_fixes if f not in suppressed]
|
|
589
|
+
|
|
590
|
+
applied, escalated, skipped = [], [], []
|
|
591
|
+
for f in active:
|
|
592
|
+
if apply and ProactiveAgent.apply(f, root, apply_unsafe=unsafe):
|
|
593
|
+
applied.append(f)
|
|
594
|
+
learning.record_acceptance(f.fix_type, "ProactiveAgent", root)
|
|
595
|
+
elif not f.safe:
|
|
596
|
+
escalated.append(f)
|
|
597
|
+
else:
|
|
598
|
+
skipped.append(f)
|
|
599
|
+
|
|
600
|
+
# Record to fix history when fixes are applied
|
|
601
|
+
if applied:
|
|
602
|
+
_record_fix_history(root, applied, skipped)
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
"fixes": active,
|
|
606
|
+
"applied": applied,
|
|
607
|
+
"escalated": escalated,
|
|
608
|
+
"skipped": skipped,
|
|
609
|
+
"suppressed": suppressed,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _record_fix_history(root: Path, applied: list[ProposedFix], skipped: list[ProposedFix]) -> None:
|
|
614
|
+
"""Record a fix operation to the history file for the charter page."""
|
|
615
|
+
if not applied and not skipped:
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
import json
|
|
619
|
+
from datetime import UTC, datetime
|
|
620
|
+
|
|
621
|
+
history_path = root / ".patchi" / "memory" / "fix_history.json"
|
|
622
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
623
|
+
|
|
624
|
+
# Load existing history
|
|
625
|
+
history = []
|
|
626
|
+
if history_path.exists():
|
|
627
|
+
try:
|
|
628
|
+
history = json.loads(history_path.read_text(encoding="utf-8"))
|
|
629
|
+
except Exception:
|
|
630
|
+
history = []
|
|
631
|
+
|
|
632
|
+
# Record this operation
|
|
633
|
+
entry = {
|
|
634
|
+
"id": datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f"),
|
|
635
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
636
|
+
"applied": [f.to_dict() for f in applied],
|
|
637
|
+
"skipped": [f.to_dict() for f in skipped],
|
|
638
|
+
"files_changed": list({f.file for f in applied}),
|
|
639
|
+
"count": len(applied),
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
history.append(entry)
|
|
643
|
+
|
|
644
|
+
# Keep last 50 entries
|
|
645
|
+
history = history[-50:]
|
|
646
|
+
|
|
647
|
+
history_path.write_text(json.dumps(history, indent=2, default=str), encoding="utf-8")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def load_fix_history(root: Path, limit: int = 20) -> list[dict]:
|
|
651
|
+
"""Load fix history for the charter page."""
|
|
652
|
+
import json
|
|
653
|
+
|
|
654
|
+
history_path = root / ".patchi" / "memory" / "fix_history.json"
|
|
655
|
+
if not history_path.exists():
|
|
656
|
+
return []
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
history = json.loads(history_path.read_text(encoding="utf-8"))
|
|
660
|
+
return list(reversed(history[-limit:])) # newest first
|
|
661
|
+
except Exception:
|
|
662
|
+
return []
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def revert_fix(root: Path, fix_id: str) -> dict:
|
|
666
|
+
"""Revert a fix operation by restoring files from git."""
|
|
667
|
+
import json
|
|
668
|
+
import subprocess
|
|
669
|
+
|
|
670
|
+
history_path = root / ".patchi" / "memory" / "fix_history.json"
|
|
671
|
+
if not history_path.exists():
|
|
672
|
+
return {"success": False, "error": "No history found"}
|
|
673
|
+
|
|
674
|
+
try:
|
|
675
|
+
history = json.loads(history_path.read_text(encoding="utf-8"))
|
|
676
|
+
except Exception:
|
|
677
|
+
return {"success": False, "error": "Failed to read history"}
|
|
678
|
+
|
|
679
|
+
# Find the entry
|
|
680
|
+
entry = None
|
|
681
|
+
for h in history:
|
|
682
|
+
if h.get("id") == fix_id:
|
|
683
|
+
entry = h
|
|
684
|
+
break
|
|
685
|
+
|
|
686
|
+
if not entry:
|
|
687
|
+
return {"success": False, "error": f"Fix {fix_id} not found"}
|
|
688
|
+
|
|
689
|
+
# Restore files from git
|
|
690
|
+
reverted = []
|
|
691
|
+
failed = []
|
|
692
|
+
for file_info in entry.get("applied", []):
|
|
693
|
+
file_path = file_info.get("file", "")
|
|
694
|
+
if not file_path:
|
|
695
|
+
continue
|
|
696
|
+
try:
|
|
697
|
+
root / file_path
|
|
698
|
+
result = subprocess.run(
|
|
699
|
+
["git", "checkout", "HEAD", "--", file_path],
|
|
700
|
+
cwd=str(root),
|
|
701
|
+
capture_output=True,
|
|
702
|
+
text=True,
|
|
703
|
+
timeout=10,
|
|
704
|
+
)
|
|
705
|
+
if result.returncode == 0:
|
|
706
|
+
reverted.append(file_path)
|
|
707
|
+
else:
|
|
708
|
+
failed.append(file_path)
|
|
709
|
+
except Exception:
|
|
710
|
+
failed.append(file_path)
|
|
711
|
+
|
|
712
|
+
# Remove from history
|
|
713
|
+
history = [h for h in history if h.get("id") != fix_id]
|
|
714
|
+
history_path.write_text(json.dumps(history, indent=2, default=str), encoding="utf-8")
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
"success": len(failed) == 0,
|
|
718
|
+
"reverted": reverted,
|
|
719
|
+
"failed": failed,
|
|
720
|
+
"count": len(reverted),
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def escalate_to_governor(root: Path, fix: ProposedFix) -> None:
|
|
725
|
+
"""
|
|
726
|
+
Surface an unsafe fix (e.g. charter violation) to human review by recording
|
|
727
|
+
it as an open issue. This is the lightweight Governor escalation sink — the
|
|
728
|
+
issue shows up in the unified findings/issues list for both humans and ants.
|
|
729
|
+
"""
|
|
730
|
+
from patchi.core import memory as mem
|
|
731
|
+
|
|
732
|
+
mem.save_issue(
|
|
733
|
+
{
|
|
734
|
+
"source": "ProactiveAgent",
|
|
735
|
+
"fix_type": fix.fix_type,
|
|
736
|
+
"file": fix.file,
|
|
737
|
+
"name": fix.name,
|
|
738
|
+
"description": fix.description,
|
|
739
|
+
"severity": "high" if fix.fix_type == "charter_violation" else "medium",
|
|
740
|
+
},
|
|
741
|
+
root,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ── Prioritized Fix List (Dream Assistant spec) ──────────────────────────────────
|
|
746
|
+
|
|
747
|
+
# Lower number = higher priority (fix first). Charter/contract breaches and
|
|
748
|
+
# breaking changes rank above cosmetic cleanups.
|
|
749
|
+
_FIX_PRIORITY: dict[str, int] = {
|
|
750
|
+
"charter_violation": 1,
|
|
751
|
+
"signature_callers": 2,
|
|
752
|
+
"missing_import": 3,
|
|
753
|
+
"dead_code": 4,
|
|
754
|
+
"unused_import": 5,
|
|
755
|
+
"format": 6,
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def rank_fixes(fixes: list[ProposedFix]) -> list[ProposedFix]:
|
|
760
|
+
"""Return fixes sorted by priority (high→low), then safe-before-unsafe."""
|
|
761
|
+
return sorted(
|
|
762
|
+
fixes,
|
|
763
|
+
key=lambda f: (_FIX_PRIORITY.get(f.fix_type, 9), 0 if f.safe else 1, f.file, f.name),
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def build_fix_list(
|
|
768
|
+
root: Path,
|
|
769
|
+
area: str | None = None,
|
|
770
|
+
include_format: bool = False,
|
|
771
|
+
include_missing_import: bool = False,
|
|
772
|
+
) -> list[ProposedFix]:
|
|
773
|
+
"""
|
|
774
|
+
Propose fixes across the whole project (or an area) and return them ranked
|
|
775
|
+
— the standalone "Prioritized Fix List" view.
|
|
776
|
+
|
|
777
|
+
``missing_import`` is excluded by default: across a whole repo every
|
|
778
|
+
cross-module symbol reads as "used but not imported in this file", which is
|
|
779
|
+
noise here (it is meaningful only for *changed* files in ``run_proactive``).
|
|
780
|
+
"""
|
|
781
|
+
from patchi.core.brain.charter import load_charter
|
|
782
|
+
from patchi.core.brain.import_graph import build_import_graph
|
|
783
|
+
from patchi.core.brain.scanner import FileScanner
|
|
784
|
+
|
|
785
|
+
root = Path(root)
|
|
786
|
+
scanner = FileScanner(root)
|
|
787
|
+
file_infos = scanner.scan()
|
|
788
|
+
graph = build_import_graph(root)
|
|
789
|
+
charter = load_charter(root)
|
|
790
|
+
agent = ProactiveAgent(root)
|
|
791
|
+
|
|
792
|
+
if area:
|
|
793
|
+
area_rel = str(area).rstrip("/\\")
|
|
794
|
+
files = [
|
|
795
|
+
fi.path
|
|
796
|
+
for fi in file_infos
|
|
797
|
+
if fi.path == area_rel or fi.path.startswith(area_rel + "/")
|
|
798
|
+
]
|
|
799
|
+
else:
|
|
800
|
+
files = [fi.path for fi in file_infos]
|
|
801
|
+
|
|
802
|
+
fixes = agent.analyze_change(files, file_infos, graph, charter, include_format=include_format)
|
|
803
|
+
# Honour learned rejections (Phase 5) for the list too.
|
|
804
|
+
visible = [f for f in fixes if learning.should_suggest(f.fix_type, root)]
|
|
805
|
+
if not include_missing_import:
|
|
806
|
+
visible = [f for f in visible if f.fix_type != "missing_import"]
|
|
807
|
+
return rank_fixes(visible)
|