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,195 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Baseline locking for Patchi.
|
|
3
|
+
|
|
4
|
+
Snapshots the current finding state and compares against previous baselines.
|
|
5
|
+
Enables "only fail on new issues" workflow for CI/CD.
|
|
6
|
+
|
|
7
|
+
Baseline is stored in `.patchi/memory/baseline.json`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from patchi.core.agents.base import Finding, Severity
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger("patchi.brain.baseline")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BaselineSnapshot:
|
|
26
|
+
"""A snapshot of findings at a point in time."""
|
|
27
|
+
|
|
28
|
+
created_at: str = ""
|
|
29
|
+
total_findings: int = 0
|
|
30
|
+
by_severity: dict[str, int] = field(default_factory=dict)
|
|
31
|
+
by_agent: dict[str, int] = field(default_factory=dict)
|
|
32
|
+
finding_hashes: set[str] = field(default_factory=set)
|
|
33
|
+
file_finding_counts: dict[str, int] = field(default_factory=dict)
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {
|
|
37
|
+
"created_at": self.created_at,
|
|
38
|
+
"total_findings": self.total_findings,
|
|
39
|
+
"by_severity": self.by_severity,
|
|
40
|
+
"by_agent": self.by_agent,
|
|
41
|
+
"finding_hashes": sorted(self.finding_hashes),
|
|
42
|
+
"file_finding_counts": self.file_finding_counts,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict) -> BaselineSnapshot:
|
|
47
|
+
return cls(
|
|
48
|
+
created_at=data.get("created_at", ""),
|
|
49
|
+
total_findings=data.get("total_findings", 0),
|
|
50
|
+
by_severity=data.get("by_severity", {}),
|
|
51
|
+
by_agent=data.get("by_agent", {}),
|
|
52
|
+
finding_hashes=set(data.get("finding_hashes", [])),
|
|
53
|
+
file_finding_counts=data.get("file_finding_counts", {}),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class BaselineDiff:
|
|
59
|
+
"""Difference between current scan and baseline."""
|
|
60
|
+
|
|
61
|
+
new_findings: list[Finding] = field(default_factory=list)
|
|
62
|
+
resolved_findings: list[dict] = field(default_factory=list)
|
|
63
|
+
total_baseline: int = 0
|
|
64
|
+
total_current: int = 0
|
|
65
|
+
new_count: int = 0
|
|
66
|
+
resolved_count: int = 0
|
|
67
|
+
has_regression: bool = False
|
|
68
|
+
summary: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _finding_hash(finding: Finding) -> str:
|
|
72
|
+
"""Deterministic hash for a finding — used to detect new vs known issues."""
|
|
73
|
+
raw = f"{finding.file}:{finding.line}:{finding.type}:{finding.severity.value}:{finding.message}"
|
|
74
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _finding_dict_hash(finding: dict) -> str:
|
|
78
|
+
raw = f"{finding.get('file', '')}:{finding.get('line', 0)}:{finding.get('type', '')}:{finding.get('severity', '')}:{finding.get('message', '')}"
|
|
79
|
+
return hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _baseline_path(root: Path) -> Path:
|
|
83
|
+
return root / ".patchi" / "memory" / "baseline.json"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_baseline(findings: list[Finding], root: Path) -> BaselineSnapshot:
|
|
87
|
+
"""Snapshot current findings as the new baseline."""
|
|
88
|
+
snapshot = BaselineSnapshot(
|
|
89
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
90
|
+
total_findings=len(findings),
|
|
91
|
+
by_severity={s.value: 0 for s in Severity},
|
|
92
|
+
by_agent={},
|
|
93
|
+
finding_hashes=set(),
|
|
94
|
+
file_finding_counts={},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
for f in findings:
|
|
98
|
+
snapshot.by_severity[f.severity.value] = snapshot.by_severity.get(f.severity.value, 0) + 1
|
|
99
|
+
snapshot.by_agent[f.agent] = snapshot.by_agent.get(f.agent, 0) + 1
|
|
100
|
+
snapshot.finding_hashes.add(_finding_hash(f))
|
|
101
|
+
snapshot.file_finding_counts[f.file] = snapshot.file_finding_counts.get(f.file, 0) + 1
|
|
102
|
+
|
|
103
|
+
path = _baseline_path(root)
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(json.dumps(snapshot.to_dict(), indent=2), encoding="utf-8")
|
|
106
|
+
return snapshot
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_baseline(root: Path) -> BaselineSnapshot | None:
|
|
110
|
+
"""Load the saved baseline, if any."""
|
|
111
|
+
path = _baseline_path(root)
|
|
112
|
+
if not path.exists():
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
116
|
+
return BaselineSnapshot.from_dict(data)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
_log.warning("load_baseline failed: %s", e)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def diff_baseline(current_findings: list[dict | Finding], root: Path) -> BaselineDiff | None:
|
|
123
|
+
"""
|
|
124
|
+
Compare current findings against baseline.
|
|
125
|
+
Returns None if no baseline exists.
|
|
126
|
+
|
|
127
|
+
Accepts both Finding objects and dicts (from JSON output).
|
|
128
|
+
"""
|
|
129
|
+
baseline = load_baseline(root)
|
|
130
|
+
if baseline is None:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Convert all to Finding objects for uniform handling
|
|
134
|
+
normalized: list[Finding] = []
|
|
135
|
+
for item in current_findings:
|
|
136
|
+
if isinstance(item, Finding):
|
|
137
|
+
normalized.append(item)
|
|
138
|
+
elif isinstance(item, dict):
|
|
139
|
+
normalized.append(
|
|
140
|
+
Finding(
|
|
141
|
+
agent=item.get("agent", ""),
|
|
142
|
+
type=item.get("type", item.get("finding_type", "")),
|
|
143
|
+
severity=Severity(item.get("severity", "info")),
|
|
144
|
+
file=item.get("file", ""),
|
|
145
|
+
line=item.get("line", 0),
|
|
146
|
+
message=item.get("message", ""),
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
current_hashes = {_finding_hash(f) for f in normalized}
|
|
151
|
+
|
|
152
|
+
new_findings = [f for f in normalized if _finding_hash(f) not in baseline.finding_hashes]
|
|
153
|
+
resolved_hashes = baseline.finding_hashes - current_hashes
|
|
154
|
+
|
|
155
|
+
diff = BaselineDiff(
|
|
156
|
+
new_findings=new_findings,
|
|
157
|
+
resolved_findings=[{"hash": h} for h in resolved_hashes],
|
|
158
|
+
total_baseline=baseline.total_findings,
|
|
159
|
+
total_current=len(normalized),
|
|
160
|
+
new_count=len(new_findings),
|
|
161
|
+
resolved_count=len(resolved_hashes),
|
|
162
|
+
has_regression=len(new_findings) > 0,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Build summary
|
|
166
|
+
parts = []
|
|
167
|
+
if diff.new_count > 0:
|
|
168
|
+
parts.append(f"{diff.new_count} new finding(s)")
|
|
169
|
+
if diff.resolved_count > 0:
|
|
170
|
+
parts.append(f"{diff.resolved_count} resolved")
|
|
171
|
+
parts.append(f"baseline: {diff.total_baseline}")
|
|
172
|
+
parts.append(f"current: {diff.total_current}")
|
|
173
|
+
diff.summary = ", ".join(parts)
|
|
174
|
+
|
|
175
|
+
return diff
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def update_baseline(findings: list[Finding | dict], root: Path) -> BaselineSnapshot:
|
|
179
|
+
"""Convenience: load current findings, diff against baseline, save new baseline."""
|
|
180
|
+
normalized: list[Finding] = []
|
|
181
|
+
for item in findings:
|
|
182
|
+
if isinstance(item, Finding):
|
|
183
|
+
normalized.append(item)
|
|
184
|
+
elif isinstance(item, dict):
|
|
185
|
+
normalized.append(
|
|
186
|
+
Finding(
|
|
187
|
+
agent=item.get("agent", ""),
|
|
188
|
+
type=item.get("type", item.get("finding_type", "")),
|
|
189
|
+
severity=Severity(item.get("severity", "info")),
|
|
190
|
+
file=item.get("file", ""),
|
|
191
|
+
line=item.get("line", 0),
|
|
192
|
+
message=item.get("message", ""),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return save_baseline(normalized, root)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blast radius calculation utilities.
|
|
3
|
+
|
|
4
|
+
Two levels:
|
|
5
|
+
- File-level (v1, original): Uses ImportGraph. BFS on reverse edges.
|
|
6
|
+
- Symbol-level (v2): Uses SymbolGraph. Weighted by test coverage + criticality.
|
|
7
|
+
Both directions (downstream dependents + upstream dependencies).
|
|
8
|
+
Damage-vs-repair: structural diff before/after a change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections import deque
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from patchi.core.brain.import_graph import ImportGraph
|
|
18
|
+
|
|
19
|
+
# ── File-level (v1) ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class BlastRadius:
|
|
24
|
+
"""Information about the blast radius of a file."""
|
|
25
|
+
|
|
26
|
+
target: str
|
|
27
|
+
direct_dependents: list[str]
|
|
28
|
+
all_dependents: list[str]
|
|
29
|
+
risk_level: str # 'low', 'medium', 'high'
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def total_affected(self) -> int:
|
|
33
|
+
return len(self.all_dependents)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def calculate_blast_radius(target: str, graph: ImportGraph) -> BlastRadius:
|
|
37
|
+
"""BFS on reverse graph to find all transitive dependents."""
|
|
38
|
+
direct = sorted(graph.reverse.get(target, set()))
|
|
39
|
+
all_affected: set[str] = set()
|
|
40
|
+
queue = deque(direct)
|
|
41
|
+
while queue:
|
|
42
|
+
node = queue.popleft()
|
|
43
|
+
if node == target or node in all_affected:
|
|
44
|
+
continue
|
|
45
|
+
all_affected.add(node)
|
|
46
|
+
for parent in graph.reverse.get(node, set()):
|
|
47
|
+
if parent not in all_affected:
|
|
48
|
+
queue.append(parent)
|
|
49
|
+
|
|
50
|
+
count = len(all_affected)
|
|
51
|
+
risk = "low" if count == 0 else ("medium" if count <= 5 else "high")
|
|
52
|
+
|
|
53
|
+
return BlastRadius(
|
|
54
|
+
target=target,
|
|
55
|
+
direct_dependents=direct,
|
|
56
|
+
all_dependents=sorted(all_affected),
|
|
57
|
+
risk_level=risk,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_blast_radius_map(graph: ImportGraph) -> dict[str, BlastRadius]:
|
|
62
|
+
return {node: calculate_blast_radius(node, graph) for node in graph.nodes}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Symbol-level (v2) ──────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SymbolBlastResult:
|
|
70
|
+
"""Symbol-level blast radius with weighted scoring."""
|
|
71
|
+
|
|
72
|
+
symbol_name: str
|
|
73
|
+
file: str
|
|
74
|
+
line: int
|
|
75
|
+
# Downstream: what depends on this symbol
|
|
76
|
+
downstream_direct: list[dict] = field(default_factory=list)
|
|
77
|
+
downstream_all: list[dict] = field(default_factory=list)
|
|
78
|
+
downstream_count: int = 0
|
|
79
|
+
# Upstream: what this symbol depends on
|
|
80
|
+
upstream_direct: list[dict] = field(default_factory=list)
|
|
81
|
+
upstream_count: int = 0
|
|
82
|
+
# Weighted scores
|
|
83
|
+
test_coverage_score: float = 1.0 # 0-1, higher = better tested
|
|
84
|
+
criticality_score: float = 0.0 # 0-1, higher = more critical
|
|
85
|
+
risk_score: float = 0.0 # weighted combination
|
|
86
|
+
risk_level: str = "low"
|
|
87
|
+
# Damage vs repair
|
|
88
|
+
structural_change_count: int = 0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def total_affected(self) -> int:
|
|
92
|
+
return self.downstream_count
|
|
93
|
+
|
|
94
|
+
def to_dict(self) -> dict:
|
|
95
|
+
return {
|
|
96
|
+
"symbol": self.symbol_name,
|
|
97
|
+
"file": self.file,
|
|
98
|
+
"line": self.line,
|
|
99
|
+
"downstream": {
|
|
100
|
+
"direct": len(self.downstream_direct),
|
|
101
|
+
"all": self.downstream_count,
|
|
102
|
+
"samples": self.downstream_direct[:5],
|
|
103
|
+
},
|
|
104
|
+
"upstream": {
|
|
105
|
+
"direct": len(self.upstream_direct),
|
|
106
|
+
"samples": self.upstream_direct[:5],
|
|
107
|
+
},
|
|
108
|
+
"test_coverage_score": round(self.test_coverage_score, 3),
|
|
109
|
+
"criticality_score": round(self.criticality_score, 3),
|
|
110
|
+
"risk_score": round(self.risk_score, 3),
|
|
111
|
+
"risk_level": self.risk_level,
|
|
112
|
+
"structural_changes": self.structural_change_count,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def calculate_symbol_blast_radius(
|
|
117
|
+
symbol_name: str,
|
|
118
|
+
file: str,
|
|
119
|
+
symbol_graph: Any,
|
|
120
|
+
test_coverage: dict[str, float] | None = None,
|
|
121
|
+
criticality_tags: dict[str, float] | None = None,
|
|
122
|
+
max_depth: int = 5,
|
|
123
|
+
) -> SymbolBlastResult:
|
|
124
|
+
"""Compute symbol-level blast radius with weighted scoring.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
symbol_name: Name of the symbol to analyze.
|
|
128
|
+
file: File path where the symbol is defined.
|
|
129
|
+
symbol_graph: SymbolGraph instance.
|
|
130
|
+
test_coverage: Dict of {file_path: coverage_ratio 0-1}.
|
|
131
|
+
criticality_tags: Dict of {symbol_name: criticality_score 0-1}.
|
|
132
|
+
max_depth: Max transitive depth.
|
|
133
|
+
"""
|
|
134
|
+
test_coverage = test_coverage or {}
|
|
135
|
+
criticality_tags = criticality_tags or {}
|
|
136
|
+
|
|
137
|
+
target = symbol_graph.get_symbol(symbol_name, file)
|
|
138
|
+
if not target:
|
|
139
|
+
return SymbolBlastResult(
|
|
140
|
+
symbol_name=symbol_name,
|
|
141
|
+
file=file,
|
|
142
|
+
line=0,
|
|
143
|
+
risk_level="unknown",
|
|
144
|
+
risk_score=0.0,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Downstream: dependents
|
|
148
|
+
downstream_set: dict[str, dict] = {}
|
|
149
|
+
queue: list[tuple[str, str, int]] = [
|
|
150
|
+
(s.name, s.file, 1) for s in symbol_graph.get_dependents(symbol_name, file)
|
|
151
|
+
]
|
|
152
|
+
visited: set[int] = {target.id}
|
|
153
|
+
direct_downstream: list[dict] = []
|
|
154
|
+
|
|
155
|
+
while queue:
|
|
156
|
+
name, fpath, depth = queue.pop(0)
|
|
157
|
+
if depth > max_depth:
|
|
158
|
+
continue
|
|
159
|
+
sym = symbol_graph.get_symbol(name, fpath)
|
|
160
|
+
if not sym or sym.id in visited:
|
|
161
|
+
continue
|
|
162
|
+
visited.add(sym.id)
|
|
163
|
+
entry = {"name": name, "file": fpath, "depth": depth}
|
|
164
|
+
downstream_set[f"{name}:{fpath}"] = entry
|
|
165
|
+
if depth == 1:
|
|
166
|
+
direct_downstream.append(entry)
|
|
167
|
+
# Recurse into their dependents
|
|
168
|
+
for dep in symbol_graph.get_dependents(name, fpath):
|
|
169
|
+
if dep.id not in visited:
|
|
170
|
+
queue.append((dep.name, dep.file, depth + 1))
|
|
171
|
+
|
|
172
|
+
# Upstream: dependencies
|
|
173
|
+
upstream_list: list[dict] = []
|
|
174
|
+
for dep in symbol_graph.get_dependencies(target.id):
|
|
175
|
+
upstream_list.append({"name": dep.name, "file": dep.file})
|
|
176
|
+
|
|
177
|
+
# File-level test coverage weight
|
|
178
|
+
file_coverage = test_coverage.get(file, 0.5)
|
|
179
|
+
downstream_file_coverage = 0.0
|
|
180
|
+
if downstream_set:
|
|
181
|
+
downstream_files = {v["file"] for v in downstream_set.values()}
|
|
182
|
+
covered = sum(test_coverage.get(f, 0.0) for f in downstream_files)
|
|
183
|
+
downstream_file_coverage = covered / len(downstream_files) if downstream_files else 0.0
|
|
184
|
+
|
|
185
|
+
test_coverage_score = (file_coverage + downstream_file_coverage) / 2.0
|
|
186
|
+
|
|
187
|
+
# Criticality: higher for exported/route/core symbols
|
|
188
|
+
criticality = criticality_tags.get(symbol_name, 0.3)
|
|
189
|
+
if target.kind == "route":
|
|
190
|
+
criticality = max(criticality, 0.8)
|
|
191
|
+
elif target.is_exported:
|
|
192
|
+
criticality = max(criticality, 0.5)
|
|
193
|
+
criticality_score = min(criticality, 1.0)
|
|
194
|
+
|
|
195
|
+
# Risk score: more downstream = higher risk, less test coverage = higher risk
|
|
196
|
+
downstream_risk = min(len(downstream_set) / 50.0, 1.0)
|
|
197
|
+
coverage_risk = 1.0 - test_coverage_score
|
|
198
|
+
risk_score = downstream_risk * 0.4 + coverage_risk * 0.3 + criticality_score * 0.3
|
|
199
|
+
risk_score = min(risk_score, 1.0)
|
|
200
|
+
|
|
201
|
+
risk_level = "low"
|
|
202
|
+
if risk_score > 0.3:
|
|
203
|
+
risk_level = "medium"
|
|
204
|
+
if risk_score > 0.6:
|
|
205
|
+
risk_level = "high"
|
|
206
|
+
if risk_score > 0.85:
|
|
207
|
+
risk_level = "critical"
|
|
208
|
+
|
|
209
|
+
return SymbolBlastResult(
|
|
210
|
+
symbol_name=symbol_name,
|
|
211
|
+
file=file,
|
|
212
|
+
line=target.line,
|
|
213
|
+
downstream_direct=direct_downstream,
|
|
214
|
+
downstream_all=list(downstream_set.values()),
|
|
215
|
+
downstream_count=len(downstream_set),
|
|
216
|
+
upstream_direct=upstream_list,
|
|
217
|
+
upstream_count=len(upstream_list),
|
|
218
|
+
test_coverage_score=test_coverage_score,
|
|
219
|
+
criticality_score=criticality_score,
|
|
220
|
+
risk_score=risk_score,
|
|
221
|
+
risk_level=risk_level,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def calculate_file_symbol_blast_radius(
|
|
226
|
+
file_path: str,
|
|
227
|
+
symbol_graph: Any,
|
|
228
|
+
test_coverage: dict[str, float] | None = None,
|
|
229
|
+
criticality_tags: dict[str, float] | None = None,
|
|
230
|
+
) -> list[SymbolBlastResult]:
|
|
231
|
+
"""Compute symbol-level blast radius for all symbols in a file."""
|
|
232
|
+
symbols = symbol_graph.get_symbols_in_file(file_path)
|
|
233
|
+
return [
|
|
234
|
+
calculate_symbol_blast_radius(s.name, s.file, symbol_graph, test_coverage, criticality_tags)
|
|
235
|
+
for s in symbols
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class GraphDiffImpact:
|
|
241
|
+
"""Structural impact analysis from a graph diff."""
|
|
242
|
+
|
|
243
|
+
file: str
|
|
244
|
+
symbols_added: int = 0
|
|
245
|
+
symbols_removed: int = 0
|
|
246
|
+
symbols_modified: int = 0
|
|
247
|
+
downstream_impact: int = 0
|
|
248
|
+
upstream_impact: int = 0
|
|
249
|
+
risk_level: str = "low"
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def total_changes(self) -> int:
|
|
253
|
+
return self.symbols_added + self.symbols_removed + self.symbols_modified
|
|
254
|
+
|
|
255
|
+
def to_dict(self) -> dict:
|
|
256
|
+
return {
|
|
257
|
+
"file": self.file,
|
|
258
|
+
"symbols_added": self.symbols_added,
|
|
259
|
+
"symbols_removed": self.symbols_removed,
|
|
260
|
+
"symbols_modified": self.symbols_modified,
|
|
261
|
+
"downstream_impact": self.downstream_impact,
|
|
262
|
+
"upstream_impact": self.upstream_impact,
|
|
263
|
+
"risk_level": self.risk_level,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def analyze_graph_diff(
|
|
268
|
+
diff: Any,
|
|
269
|
+
symbol_graph: Any,
|
|
270
|
+
) -> list[GraphDiffImpact]:
|
|
271
|
+
"""Analyze structural impact of a graph diff (damage vs repair).
|
|
272
|
+
|
|
273
|
+
For each file with structural changes, compute downstream/upstream impact.
|
|
274
|
+
"""
|
|
275
|
+
if not diff or not diff.has_changes:
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
affected_files: dict[str, GraphDiffImpact] = {}
|
|
279
|
+
|
|
280
|
+
for sym in diff.added:
|
|
281
|
+
if sym.file not in affected_files:
|
|
282
|
+
affected_files[sym.file] = GraphDiffImpact(file=sym.file)
|
|
283
|
+
affected_files[sym.file].symbols_added += 1
|
|
284
|
+
|
|
285
|
+
for sym in diff.removed:
|
|
286
|
+
if sym.file not in affected_files:
|
|
287
|
+
affected_files[sym.file] = GraphDiffImpact(file=sym.file)
|
|
288
|
+
affected_files[sym.file].symbols_removed += 1
|
|
289
|
+
|
|
290
|
+
for sym in diff.modified:
|
|
291
|
+
if sym.file not in affected_files:
|
|
292
|
+
affected_files[sym.file] = GraphDiffImpact(file=sym.file)
|
|
293
|
+
affected_files[sym.file].symbols_modified += 1
|
|
294
|
+
|
|
295
|
+
for file, impact in affected_files.items():
|
|
296
|
+
symbols = symbol_graph.get_symbols_in_file(file)
|
|
297
|
+
for s in symbols:
|
|
298
|
+
result = calculate_symbol_blast_radius(s.name, s.file, symbol_graph)
|
|
299
|
+
impact.downstream_impact = max(impact.downstream_impact, result.downstream_count)
|
|
300
|
+
impact.upstream_impact = max(impact.upstream_impact, result.upstream_count)
|
|
301
|
+
|
|
302
|
+
total = impact.total_changes
|
|
303
|
+
downstream = impact.downstream_impact
|
|
304
|
+
impact.risk_level = "low"
|
|
305
|
+
if (total >= 3 and downstream >= 5) or (total >= 1 and downstream >= 20):
|
|
306
|
+
impact.risk_level = "critical"
|
|
307
|
+
elif (total >= 2 and downstream >= 3) or downstream >= 10:
|
|
308
|
+
impact.risk_level = "high"
|
|
309
|
+
elif total >= 1 and downstream >= 1:
|
|
310
|
+
impact.risk_level = "medium"
|
|
311
|
+
|
|
312
|
+
return list(affected_files.values())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Body Tags — understander-first tagging for Patchi.
|
|
3
|
+
|
|
4
|
+
Generates .patchi/memory/body_tags.json on every Brain.scan from
|
|
5
|
+
deterministic signals already computed: import_graph fan-in, blast_radius,
|
|
6
|
+
layered_brain level, route hit, stack framework, file size.
|
|
7
|
+
|
|
8
|
+
Roles map to human anatomy (Body skill):
|
|
9
|
+
brain = orchestrators, contracts, memory, config
|
|
10
|
+
muscle = agents, scanners, security, testing, fix
|
|
11
|
+
bone = data types, schemas, constants, models
|
|
12
|
+
blood = wiring (import_graph hubs, routing)
|
|
13
|
+
skin = web/api/cli surface
|
|
14
|
+
nerve = watchers, interceptors, guards, queue
|
|
15
|
+
|
|
16
|
+
Criticality: critical|high|medium|low based on fan-in + dependents + route adjacency.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
_log = logging.getLogger("patchi.brain.body_tags")
|
|
27
|
+
|
|
28
|
+
BODY_TAGS_FILE = ".patchi/memory/body_tags.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _classify_role(path: str, layers: dict, is_route_file: bool, is_hub: bool) -> tuple[str, str]:
|
|
32
|
+
p = path.lower()
|
|
33
|
+
if "brain" in p or "memory" in p or "config" in p or "contract" in p:
|
|
34
|
+
return "brain", "orchestrator"
|
|
35
|
+
if "web/routes" in p or "web/api" in p or "cli/commands" in p:
|
|
36
|
+
return "skin", "surface"
|
|
37
|
+
if "security" in p or "agents" in p or "testing" in p or "fix/" in p:
|
|
38
|
+
return "muscle", "scanner" if "security" in p or "agents" in p else "fixer"
|
|
39
|
+
if "constants" in p or "scanner" in p or "file_corpus" in p or "import_graph" in p:
|
|
40
|
+
return "bone", "structure"
|
|
41
|
+
if is_hub or "import_graph" in p or "route_mapper" in p or "blast_radius" in p:
|
|
42
|
+
return "blood", "wiring"
|
|
43
|
+
if "watch" in p or "interceptor" in p or "guard" in p or "queue" in p:
|
|
44
|
+
return "nerve", "guard"
|
|
45
|
+
# fallback via layered brain
|
|
46
|
+
for lname, lyr in layers.items():
|
|
47
|
+
if path in getattr(lyr, "files", []):
|
|
48
|
+
if lyr.level == 1:
|
|
49
|
+
return "bone", "module"
|
|
50
|
+
unicode = "skin" if "web" in lname or "cli" in lname else "muscle"
|
|
51
|
+
return unicode, lname
|
|
52
|
+
return "muscle", "general"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_body_tags(
|
|
56
|
+
file_infos: list[Any],
|
|
57
|
+
graph: Any,
|
|
58
|
+
layers: dict,
|
|
59
|
+
routes: list[Any],
|
|
60
|
+
blast_map: dict[str, Any] | None = None,
|
|
61
|
+
) -> dict[str, dict[str, Any]]:
|
|
62
|
+
# fan-in via graph.reverse, hub threshold
|
|
63
|
+
reverse = getattr(graph, "reverse", {}) if graph else {}
|
|
64
|
+
fan_in_counts = {fp: len(reverse.get(fp, [])) for fp in [fi.path for fi in file_infos]}
|
|
65
|
+
hub_threshold = 5 # top hubs
|
|
66
|
+
sorted_fan = sorted(fan_in_counts.items(), key=lambda x: -x[1])
|
|
67
|
+
hubs = {fp for fp, _ in sorted_fan[:20] if _ >= hub_threshold}
|
|
68
|
+
# route adjacency
|
|
69
|
+
_route_paths = {getattr(r, "path", "") for r in (routes or [])}
|
|
70
|
+
route_files = set()
|
|
71
|
+
for fi in file_infos:
|
|
72
|
+
low = fi.path.lower()
|
|
73
|
+
if any(seg in low for seg in ["route", "web", "api", "handler"]):
|
|
74
|
+
route_files.add(fi.path)
|
|
75
|
+
|
|
76
|
+
tags: dict[str, dict[str, Any]] = {}
|
|
77
|
+
for fi in file_infos:
|
|
78
|
+
path = fi.path
|
|
79
|
+
fan_in = fan_in_counts.get(path, 0)
|
|
80
|
+
is_hub = path in hubs
|
|
81
|
+
is_route = path in route_files
|
|
82
|
+
role, system = _classify_role(path, layers, is_route, is_hub)
|
|
83
|
+
# criticality
|
|
84
|
+
br = blast_map.get(path) if blast_map and path in blast_map else None
|
|
85
|
+
all_deps = getattr(br, "all_dependents", None) if br is not None else None
|
|
86
|
+
if all_deps is None:
|
|
87
|
+
all_deps = getattr(br, "dependents", []) if br is not None else []
|
|
88
|
+
dep_count = len(all_deps) if isinstance(all_deps, (list, set, tuple)) else 0
|
|
89
|
+
if fan_in >= 10 or dep_count >= 15 or path.lower() in ("patchi/core/brain/brain.py", "patchi/core/memory.py"):
|
|
90
|
+
crit = "critical"
|
|
91
|
+
elif fan_in >= 5 or is_hub or is_route or "core/brain" in path:
|
|
92
|
+
crit = "high"
|
|
93
|
+
elif fan_in >= 2 or "core/" in path:
|
|
94
|
+
crit = "medium"
|
|
95
|
+
else:
|
|
96
|
+
crit = "low"
|
|
97
|
+
# layer name
|
|
98
|
+
layer_name = ""
|
|
99
|
+
for lname, lyr in layers.items():
|
|
100
|
+
if path in getattr(lyr, "files", []):
|
|
101
|
+
layer_name = lname
|
|
102
|
+
break
|
|
103
|
+
# score for understander ranking
|
|
104
|
+
score = fan_in * 10 + dep_count * 3 + (20 if crit == "critical" else 10 if crit == "high" else 0) + (5 if is_route else 0)
|
|
105
|
+
tags[path] = {
|
|
106
|
+
"role": role,
|
|
107
|
+
"system": system,
|
|
108
|
+
"layer": layer_name,
|
|
109
|
+
"criticality": crit,
|
|
110
|
+
"fan_in": fan_in,
|
|
111
|
+
"score": score,
|
|
112
|
+
"is_hub": is_hub,
|
|
113
|
+
"is_route_file": is_route,
|
|
114
|
+
}
|
|
115
|
+
return tags
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def save_body_tags(tags: dict[str, dict], root: Path) -> Path:
|
|
119
|
+
out = root / BODY_TAGS_FILE
|
|
120
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
# atomic write
|
|
122
|
+
tmp = out.with_suffix(".tmp")
|
|
123
|
+
payload = {"version": 1, "count": len(tags), "tags": tags}
|
|
124
|
+
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
125
|
+
# replace dance for win32
|
|
126
|
+
import sys as _sys
|
|
127
|
+
|
|
128
|
+
if _sys.platform == "win32" and out.exists():
|
|
129
|
+
out.unlink()
|
|
130
|
+
tmp.replace(out)
|
|
131
|
+
_log.info("body_tags: wrote %d tags to %s", len(tags), out)
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load_body_tags(root: Path) -> dict[str, dict]:
|
|
136
|
+
p = root / BODY_TAGS_FILE
|
|
137
|
+
if not p.exists():
|
|
138
|
+
return {}
|
|
139
|
+
try:
|
|
140
|
+
data: Any = json.loads(p.read_text(encoding="utf-8"))
|
|
141
|
+
return data.get("tags", {}) if isinstance(data, dict) else {}
|
|
142
|
+
except Exception as exc: # noqa: BLE001
|
|
143
|
+
_log.debug("body_tags load failed: %s", exc)
|
|
144
|
+
return {}
|