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
patchi/core/snapshot.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snapshot / rollback system for Patchi.
|
|
3
|
+
|
|
4
|
+
Before any fix is applied, Patchi creates a snapshot of the target file(s).
|
|
5
|
+
If tests fail after a fix, the snapshot is used to restore the original state.
|
|
6
|
+
|
|
7
|
+
Snapshots are stored as JSON in .patchi/snapshots/<snapshot_id>.json.
|
|
8
|
+
They contain the full original file content, so rollback is always possible
|
|
9
|
+
regardless of how many changes were made.
|
|
10
|
+
|
|
11
|
+
Design decisions:
|
|
12
|
+
- Full-file snapshots rather than diffs: simpler to implement, always reliable.
|
|
13
|
+
- Diffs are computed separately for display (review panel) but rollback always
|
|
14
|
+
uses the full-file snapshot.
|
|
15
|
+
- Snapshots are never deleted automatically. The user cleans them via `p memory`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import uuid
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from patchi.core.atomic import atomic_replace, atomic_write_json
|
|
25
|
+
from patchi.core.config import require_project_root
|
|
26
|
+
from patchi.core.constants import SNAPSHOT_DIR
|
|
27
|
+
|
|
28
|
+
# ── Data model ─────────────────────────────────────────────────────────────────
|
|
29
|
+
#
|
|
30
|
+
# A snapshot record looks like:
|
|
31
|
+
# {
|
|
32
|
+
# "id": "a1b2c3d4",
|
|
33
|
+
# "patch_id": "e5f6g7h8", # the fix this snapshot guards
|
|
34
|
+
# "created": "2025-01-01T00:00:00+00:00",
|
|
35
|
+
# "files": {
|
|
36
|
+
# "src/auth/middleware.py": "<full original content>",
|
|
37
|
+
# ...
|
|
38
|
+
# }
|
|
39
|
+
# }
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _snapshots_dir(root: Path) -> Path:
|
|
43
|
+
return root / SNAPSHOT_DIR
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _snapshot_path(snapshot_id: str, root: Path) -> Path:
|
|
47
|
+
return _snapshots_dir(root) / f"{snapshot_id}.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _root(root: Path | None) -> Path:
|
|
51
|
+
return root or require_project_root()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Create ─────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create(file_paths: list[str], patch_id: str, root: Path | None = None) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Read the current content of every file in file_paths and store a snapshot.
|
|
60
|
+
Returns the snapshot_id.
|
|
61
|
+
|
|
62
|
+
file_paths should be relative to the project root.
|
|
63
|
+
Files that don't exist are recorded with content = None (new-file case).
|
|
64
|
+
"""
|
|
65
|
+
r = _root(root)
|
|
66
|
+
_snapshots_dir(r).mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
files: dict[str, str | None] = {}
|
|
69
|
+
for rel_path in file_paths:
|
|
70
|
+
abs_path = r / rel_path
|
|
71
|
+
if abs_path.exists():
|
|
72
|
+
files[rel_path] = abs_path.read_text(encoding="utf-8")
|
|
73
|
+
else:
|
|
74
|
+
files[rel_path] = None # file doesn't exist yet (creation fix)
|
|
75
|
+
|
|
76
|
+
snapshot_id = str(uuid.uuid4())[:8]
|
|
77
|
+
record = {
|
|
78
|
+
"id": snapshot_id,
|
|
79
|
+
"patch_id": patch_id,
|
|
80
|
+
"created": _now(),
|
|
81
|
+
"files": files,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
_write_json(_snapshot_path(snapshot_id, r), record)
|
|
85
|
+
return snapshot_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── Restore (rollback) ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def restore(snapshot_id: str, root: Path | None = None) -> list[str]:
|
|
92
|
+
"""
|
|
93
|
+
Roll back all files covered by this snapshot to their original content.
|
|
94
|
+
Returns a list of file paths that were restored.
|
|
95
|
+
|
|
96
|
+
If the original content was None (file didn't exist), the file is deleted.
|
|
97
|
+
"""
|
|
98
|
+
r = _root(root)
|
|
99
|
+
record = load(snapshot_id, r)
|
|
100
|
+
if record is None:
|
|
101
|
+
raise FileNotFoundError(f"Snapshot {snapshot_id!r} not found.")
|
|
102
|
+
|
|
103
|
+
restored: list[str] = []
|
|
104
|
+
for rel_path, original_content in record["files"].items():
|
|
105
|
+
abs_path = r / rel_path
|
|
106
|
+
if original_content is None:
|
|
107
|
+
# File was created by the fix — delete it on rollback
|
|
108
|
+
if abs_path.exists():
|
|
109
|
+
abs_path.unlink()
|
|
110
|
+
else:
|
|
111
|
+
abs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
# Atomic write: tmp + rename to prevent half-rolled-back state
|
|
113
|
+
tmp = abs_path.with_suffix(abs_path.suffix + ".rollback_tmp")
|
|
114
|
+
tmp.write_text(original_content, encoding="utf-8")
|
|
115
|
+
tmp.replace(abs_path)
|
|
116
|
+
restored.append(rel_path)
|
|
117
|
+
|
|
118
|
+
return restored
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Read ───────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load(snapshot_id: str, root: Path | None = None) -> dict | None:
|
|
125
|
+
"""Return the snapshot record, or None if it doesn't exist."""
|
|
126
|
+
r = _root(root)
|
|
127
|
+
path = _snapshot_path(snapshot_id, r)
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return None
|
|
130
|
+
return _read_json(path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_all(root: Path | None = None) -> list[dict]:
|
|
134
|
+
"""Return all snapshots, sorted newest first, with file content omitted."""
|
|
135
|
+
r = _root(root)
|
|
136
|
+
snap_dir = _snapshots_dir(r)
|
|
137
|
+
if not snap_dir.exists():
|
|
138
|
+
return []
|
|
139
|
+
|
|
140
|
+
records: list[dict] = []
|
|
141
|
+
for snap_file in sorted(snap_dir.glob("*.json"), reverse=True):
|
|
142
|
+
record = _read_json(snap_file)
|
|
143
|
+
# Strip file contents for the list view — only show paths
|
|
144
|
+
records.append(
|
|
145
|
+
{
|
|
146
|
+
"id": record["id"],
|
|
147
|
+
"patch_id": record.get("patch_id"),
|
|
148
|
+
"created": record.get("created"),
|
|
149
|
+
"file_count": len(record.get("files", {})),
|
|
150
|
+
"file_paths": list(record.get("files", {}).keys()),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
return records
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_by_patch_id(patch_id: str, root: Path | None = None) -> dict | None:
|
|
157
|
+
"""Find the snapshot that guards a specific patch."""
|
|
158
|
+
r = _root(root)
|
|
159
|
+
snap_dir = _snapshots_dir(r)
|
|
160
|
+
if not snap_dir.exists():
|
|
161
|
+
return None
|
|
162
|
+
for snap_file in snap_dir.glob("*.json"):
|
|
163
|
+
record = _read_json(snap_file)
|
|
164
|
+
if record.get("patch_id") == patch_id:
|
|
165
|
+
return record
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Delete ─────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def delete(snapshot_id: str, root: Path | None = None) -> bool:
|
|
173
|
+
"""Delete a single snapshot. Returns True if it existed."""
|
|
174
|
+
r = _root(root)
|
|
175
|
+
path = _snapshot_path(snapshot_id, r)
|
|
176
|
+
if path.exists():
|
|
177
|
+
path.unlink()
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def delete_all(root: Path | None = None) -> int:
|
|
183
|
+
"""Delete all snapshots. Returns count deleted."""
|
|
184
|
+
r = _root(root)
|
|
185
|
+
snap_dir = _snapshots_dir(r)
|
|
186
|
+
if not snap_dir.exists():
|
|
187
|
+
return 0
|
|
188
|
+
count = 0
|
|
189
|
+
for snap_file in snap_dir.glob("*.json"):
|
|
190
|
+
snap_file.unlink()
|
|
191
|
+
count += 1
|
|
192
|
+
return count
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ── Diff helper ────────────────────────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def compute_diff(snapshot_id: str, root: Path | None = None) -> dict[str, str]:
|
|
199
|
+
"""
|
|
200
|
+
Compute unified diffs for all files in this snapshot vs their current state.
|
|
201
|
+
Returns a dict of {relative_path: unified_diff_string}.
|
|
202
|
+
"""
|
|
203
|
+
import difflib
|
|
204
|
+
|
|
205
|
+
r = _root(root)
|
|
206
|
+
record = load(snapshot_id, r)
|
|
207
|
+
if record is None:
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
diffs: dict[str, str] = {}
|
|
211
|
+
for rel_path, original in record["files"].items():
|
|
212
|
+
abs_path = r / rel_path
|
|
213
|
+
current = abs_path.read_text(encoding="utf-8") if abs_path.exists() else ""
|
|
214
|
+
original = original or ""
|
|
215
|
+
|
|
216
|
+
diff_lines = list(
|
|
217
|
+
difflib.unified_diff(
|
|
218
|
+
original.splitlines(keepends=True),
|
|
219
|
+
current.splitlines(keepends=True),
|
|
220
|
+
fromfile=f"a/{rel_path}",
|
|
221
|
+
tofile=f"b/{rel_path}",
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
diffs[rel_path] = "".join(diff_lines)
|
|
225
|
+
|
|
226
|
+
return diffs
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Utilities ──────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
233
|
+
atomic_write_json(path, data)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _read_json(path: Path) -> Any:
|
|
237
|
+
with path.open("r", encoding="utf-8") as f:
|
|
238
|
+
return json.load(f)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _now() -> str:
|
|
242
|
+
return datetime.now(UTC).isoformat()
|
patchi/core/tenant.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant Manager — Project-level state isolation for multi-tenant operation.
|
|
3
|
+
|
|
4
|
+
Each project gets isolated:
|
|
5
|
+
- Brain state (.patchi/)
|
|
6
|
+
- Scan results
|
|
7
|
+
- Cost tracking
|
|
8
|
+
- Findings history
|
|
9
|
+
- Configuration
|
|
10
|
+
|
|
11
|
+
The active project is tracked in the server state and can be switched at runtime.
|
|
12
|
+
Supports request-scoped tenant context for concurrent web requests.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import threading
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
_log = logging.getLogger("patchi.tenant")
|
|
25
|
+
|
|
26
|
+
# Thread-local storage for request-scoped tenant context
|
|
27
|
+
_thread_local = threading.local()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class TenantState:
|
|
32
|
+
"""State for a single project/tenant."""
|
|
33
|
+
|
|
34
|
+
root: Path
|
|
35
|
+
name: str
|
|
36
|
+
active: bool = True
|
|
37
|
+
last_accessed: str = ""
|
|
38
|
+
findings_count: int = 0
|
|
39
|
+
scan_count: int = 0
|
|
40
|
+
cost_estimate: float = 0.0
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict:
|
|
43
|
+
return {
|
|
44
|
+
"root": str(self.root),
|
|
45
|
+
"name": self.name,
|
|
46
|
+
"active": self.active,
|
|
47
|
+
"last_accessed": self.last_accessed,
|
|
48
|
+
"findings_count": self.findings_count,
|
|
49
|
+
"scan_count": self.scan_count,
|
|
50
|
+
"cost_estimate": self.cost_estimate,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TenantManager:
|
|
55
|
+
"""Manages multiple project tenants with state isolation."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, state_dir: Path | None = None):
|
|
58
|
+
self._state_dir = state_dir or Path.home() / ".patchi" / "tenants"
|
|
59
|
+
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
self._tenants: dict[str, TenantState] = {}
|
|
61
|
+
self._active_tenant: str | None = None
|
|
62
|
+
self._load_state()
|
|
63
|
+
|
|
64
|
+
def register_project(self, root: Path, name: str | None = None) -> TenantState:
|
|
65
|
+
"""Register a project as a tenant."""
|
|
66
|
+
key = str(root.resolve())
|
|
67
|
+
if key in self._tenants:
|
|
68
|
+
self._tenants[key].active = True
|
|
69
|
+
return self._tenants[key]
|
|
70
|
+
|
|
71
|
+
tenant = TenantState(
|
|
72
|
+
root=root.resolve(),
|
|
73
|
+
name=name or root.name,
|
|
74
|
+
active=True,
|
|
75
|
+
)
|
|
76
|
+
self._tenants[key] = tenant
|
|
77
|
+
self._save_state()
|
|
78
|
+
_log.info("Registered tenant: %s (%s)", tenant.name, key)
|
|
79
|
+
return tenant
|
|
80
|
+
|
|
81
|
+
def switch_project(self, root: Path) -> TenantState:
|
|
82
|
+
"""Switch to a different project."""
|
|
83
|
+
key = str(root.resolve())
|
|
84
|
+
if key not in self._tenants:
|
|
85
|
+
self.register_project(root)
|
|
86
|
+
|
|
87
|
+
# Deactivate previous
|
|
88
|
+
if self._active_tenant and self._active_tenant in self._tenants:
|
|
89
|
+
self._tenants[self._active_tenant].active = False
|
|
90
|
+
|
|
91
|
+
# Activate new
|
|
92
|
+
self._tenants[key].active = True
|
|
93
|
+
self._active_tenant = key
|
|
94
|
+
|
|
95
|
+
# Initialize the project state
|
|
96
|
+
self._init_project_state(root)
|
|
97
|
+
|
|
98
|
+
self._save_state()
|
|
99
|
+
_log.info("Switched to tenant: %s", self._tenants[key].name)
|
|
100
|
+
return self._tenants[key]
|
|
101
|
+
|
|
102
|
+
def get_active(self) -> TenantState | None:
|
|
103
|
+
"""Get the currently active tenant."""
|
|
104
|
+
if self._active_tenant:
|
|
105
|
+
return self._tenants.get(self._active_tenant)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def get_active_root(self) -> Path:
|
|
109
|
+
"""Get the active project root path."""
|
|
110
|
+
tenant = self.get_active()
|
|
111
|
+
if tenant:
|
|
112
|
+
return tenant.root
|
|
113
|
+
# Fallback to current working directory
|
|
114
|
+
return Path.cwd()
|
|
115
|
+
|
|
116
|
+
def list_projects(self) -> list[TenantState]:
|
|
117
|
+
"""List all registered projects."""
|
|
118
|
+
return list(self._tenants.values())
|
|
119
|
+
|
|
120
|
+
def remove_project(self, root: Path) -> bool:
|
|
121
|
+
"""Remove a project from the tenant list."""
|
|
122
|
+
key = str(root.resolve())
|
|
123
|
+
if key in self._tenants:
|
|
124
|
+
del self._tenants[key]
|
|
125
|
+
if self._active_tenant == key:
|
|
126
|
+
self._active_tenant = None
|
|
127
|
+
self._save_state()
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def discover_projects(
|
|
132
|
+
self, near: Path | None = None, max_depth: int = 2, limit: int = 25
|
|
133
|
+
) -> list[Path]:
|
|
134
|
+
"""Find Patchi projects on disk near a reference directory.
|
|
135
|
+
|
|
136
|
+
Search bases (deduplicated):
|
|
137
|
+
- the reference dir itself and its parent (siblings pattern:
|
|
138
|
+
~/repos/project-a while viewing project-b)
|
|
139
|
+
- one level of children of the parent (workspace folders)
|
|
140
|
+
|
|
141
|
+
Only directories containing .patchi/ count. Bounded scan — never
|
|
142
|
+
walks the whole filesystem, and never scans the system temp tree
|
|
143
|
+
(test fixtures would flood the results).
|
|
144
|
+
"""
|
|
145
|
+
import tempfile
|
|
146
|
+
|
|
147
|
+
base = (near or Path.cwd()).resolve()
|
|
148
|
+
candidates: dict[str, Path] = {}
|
|
149
|
+
roots_to_scan: list[Path] = []
|
|
150
|
+
|
|
151
|
+
for candidate_base in (base, base.parent, base.parent.parent):
|
|
152
|
+
if not candidate_base.is_dir():
|
|
153
|
+
continue
|
|
154
|
+
if candidate_base not in roots_to_scan:
|
|
155
|
+
roots_to_scan.append(candidate_base)
|
|
156
|
+
|
|
157
|
+
# The system temp tree is full of throwaway test fixtures — exclude it
|
|
158
|
+
# and everything beneath it from discovery.
|
|
159
|
+
try:
|
|
160
|
+
sys_temp = Path(tempfile.gettempdir()).resolve()
|
|
161
|
+
|
|
162
|
+
def _in_temp(p: Path) -> bool:
|
|
163
|
+
try:
|
|
164
|
+
return p == sys_temp or sys_temp in p.parents
|
|
165
|
+
except OSError:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
roots_to_scan = [r for r in roots_to_scan if not _in_temp(r)]
|
|
169
|
+
except Exception as _exc:
|
|
170
|
+
_log.warning('discover_projects failed: %s', _exc)
|
|
171
|
+
|
|
172
|
+
for scan_root in roots_to_scan:
|
|
173
|
+
try:
|
|
174
|
+
for child in sorted(scan_root.iterdir()):
|
|
175
|
+
if len(candidates) >= limit:
|
|
176
|
+
break
|
|
177
|
+
if not child.is_dir() or child.name.startswith("."):
|
|
178
|
+
continue
|
|
179
|
+
if _in_temp(child):
|
|
180
|
+
continue
|
|
181
|
+
if (child / ".patchi").is_dir():
|
|
182
|
+
key = str(child.resolve())
|
|
183
|
+
candidates.setdefault(key, child)
|
|
184
|
+
# Also the scan root itself may be a project
|
|
185
|
+
if (scan_root / ".patchi").is_dir():
|
|
186
|
+
key = str(scan_root.resolve())
|
|
187
|
+
candidates.setdefault(key, scan_root)
|
|
188
|
+
except (PermissionError, OSError):
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
return list(candidates.values())
|
|
192
|
+
|
|
193
|
+
def update_stats(self, root: Path, **kwargs) -> None:
|
|
194
|
+
"""Update tenant statistics."""
|
|
195
|
+
key = str(root.resolve())
|
|
196
|
+
if key in self._tenants:
|
|
197
|
+
for k, v in kwargs.items():
|
|
198
|
+
if hasattr(self._tenants[key], k):
|
|
199
|
+
setattr(self._tenants[key], k, v)
|
|
200
|
+
self._save_state()
|
|
201
|
+
|
|
202
|
+
def _init_project_state(self, root: Path) -> None:
|
|
203
|
+
"""Initialize .patchi/ directory for a project."""
|
|
204
|
+
patchi_dir = root / ".patchi"
|
|
205
|
+
patchi_dir.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
|
|
207
|
+
# Ensure required subdirs
|
|
208
|
+
for subdir in ["logs", "evidence", "snapshots", "cache"]:
|
|
209
|
+
(patchi_dir / subdir).mkdir(exist_ok=True)
|
|
210
|
+
|
|
211
|
+
# Initialize config if not present
|
|
212
|
+
config_file = patchi_dir / "config.json"
|
|
213
|
+
if not config_file.exists():
|
|
214
|
+
config_file.write_text("{}")
|
|
215
|
+
|
|
216
|
+
# Initialize memory (full .patchi structure + default memory files)
|
|
217
|
+
from patchi.core.config import init_project
|
|
218
|
+
|
|
219
|
+
init_project(root)
|
|
220
|
+
|
|
221
|
+
# Initialize cost tracker
|
|
222
|
+
from patchi.core.ai import cost_tracker
|
|
223
|
+
|
|
224
|
+
cost_tracker.init(root)
|
|
225
|
+
|
|
226
|
+
def _load_state(self) -> None:
|
|
227
|
+
"""Load tenant state from disk."""
|
|
228
|
+
state_file = self._state_dir / "tenants.json"
|
|
229
|
+
try:
|
|
230
|
+
with open(state_file, encoding="utf-8") as f:
|
|
231
|
+
data = json.load(f)
|
|
232
|
+
for key, tdata in data.get("tenants", {}).items():
|
|
233
|
+
self._tenants[key] = TenantState(
|
|
234
|
+
root=Path(tdata["root"]),
|
|
235
|
+
name=tdata.get("name", ""),
|
|
236
|
+
active=tdata.get("active", True),
|
|
237
|
+
last_accessed=tdata.get("last_accessed", ""),
|
|
238
|
+
findings_count=tdata.get("findings_count", 0),
|
|
239
|
+
scan_count=tdata.get("scan_count", 0),
|
|
240
|
+
cost_estimate=tdata.get("cost_estimate", 0.0),
|
|
241
|
+
)
|
|
242
|
+
self._active_tenant = data.get("active_tenant")
|
|
243
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def _save_state(self) -> None:
|
|
247
|
+
"""Persist tenant state to disk."""
|
|
248
|
+
state_file = self._state_dir / "tenants.json"
|
|
249
|
+
data = {
|
|
250
|
+
"active_tenant": self._active_tenant,
|
|
251
|
+
"tenants": {k: v.to_dict() for k, v in self._tenants.items()},
|
|
252
|
+
}
|
|
253
|
+
try:
|
|
254
|
+
state_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
255
|
+
except Exception as e:
|
|
256
|
+
_log.warning("Failed to save tenant state: %s", e)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Global Tenant Manager ────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
_tenant_manager: TenantManager | None = None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def get_tenant_manager() -> TenantManager:
|
|
265
|
+
"""Get the global tenant manager."""
|
|
266
|
+
global _tenant_manager
|
|
267
|
+
if _tenant_manager is None:
|
|
268
|
+
_tenant_manager = TenantManager()
|
|
269
|
+
return _tenant_manager
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Request-Scoped Tenant Context ───────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@contextmanager
|
|
276
|
+
def tenant_context(root: Path):
|
|
277
|
+
"""Context manager that scopes the active tenant for the current thread.
|
|
278
|
+
|
|
279
|
+
Used by web request handlers to ensure each request operates on the
|
|
280
|
+
correct project's state without global state corruption.
|
|
281
|
+
|
|
282
|
+
Usage::
|
|
283
|
+
|
|
284
|
+
with tenant_context(project_root):
|
|
285
|
+
# All memory/config calls use project_root
|
|
286
|
+
scan_results = mem.get_scan_results(project_root)
|
|
287
|
+
"""
|
|
288
|
+
previous = getattr(_thread_local, "tenant_root", None)
|
|
289
|
+
_thread_local.tenant_root = root
|
|
290
|
+
try:
|
|
291
|
+
yield root
|
|
292
|
+
finally:
|
|
293
|
+
_thread_local.tenant_root = previous
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def get_current_tenant_root() -> Path | None:
|
|
297
|
+
"""Get the request-scoped tenant root, or None if not in a context."""
|
|
298
|
+
return getattr(_thread_local, "tenant_root", None)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Per-Tenant Cost Tracking ────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
_tenant_costs: dict[str, float] = {} # root -> cumulative cost
|
|
304
|
+
tenant_costs_lock = threading.Lock()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def track_tenant_cost(root: Path, cost_usd: float) -> None:
|
|
308
|
+
"""Record AI cost for a specific tenant."""
|
|
309
|
+
key = str(root.resolve())
|
|
310
|
+
with tenant_costs_lock:
|
|
311
|
+
_tenant_costs[key] = _tenant_costs.get(key, 0) + cost_usd
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_tenant_cost(root: Path) -> float:
|
|
315
|
+
"""Get cumulative AI cost for a tenant."""
|
|
316
|
+
key = str(root.resolve())
|
|
317
|
+
with tenant_costs_lock:
|
|
318
|
+
return _tenant_costs.get(key, 0.0)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def get_all_tenant_costs() -> dict[str, float]:
|
|
322
|
+
"""Get costs for all tenants."""
|
|
323
|
+
with tenant_costs_lock:
|
|
324
|
+
return dict(_tenant_costs)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def check_tenant_cost_alert(root: Path, config: dict | None = None) -> dict | None:
|
|
328
|
+
"""Check if per-tenant spending exceeds the configured limit.
|
|
329
|
+
|
|
330
|
+
Returns a dict with alert info if threshold exceeded, None otherwise.
|
|
331
|
+
Never raises — handles None, missing keys, and non-numeric values gracefully.
|
|
332
|
+
|
|
333
|
+
Config keys (under ``ai``):
|
|
334
|
+
cost_limit — dollar amount that triggers the alert (default 10.0)
|
|
335
|
+
cost_warn_pct — percentage of limit that triggers warning (default 80)
|
|
336
|
+
"""
|
|
337
|
+
try:
|
|
338
|
+
cfg = config or {}
|
|
339
|
+
ai_cfg = cfg.get("ai") or {}
|
|
340
|
+
|
|
341
|
+
# Safely parse cost_limit — handle None, empty string, non-numeric
|
|
342
|
+
raw_limit = ai_cfg.get("cost_limit")
|
|
343
|
+
if raw_limit in (None, "", "null", "None"):
|
|
344
|
+
limit = 10.0
|
|
345
|
+
else:
|
|
346
|
+
try:
|
|
347
|
+
limit = float(raw_limit)
|
|
348
|
+
except (TypeError, ValueError):
|
|
349
|
+
limit = 10.0
|
|
350
|
+
|
|
351
|
+
# Safely parse cost_warn_pct
|
|
352
|
+
raw_pct = ai_cfg.get("cost_warn_pct")
|
|
353
|
+
if raw_pct in (None, "", "null", "None"):
|
|
354
|
+
warn_pct = 80.0
|
|
355
|
+
else:
|
|
356
|
+
try:
|
|
357
|
+
warn_pct = float(raw_pct)
|
|
358
|
+
except (TypeError, ValueError):
|
|
359
|
+
warn_pct = 80.0
|
|
360
|
+
|
|
361
|
+
if limit <= 0:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
spent = get_tenant_cost(root)
|
|
365
|
+
pct = (spent / limit) * 100 if limit > 0 else 0
|
|
366
|
+
|
|
367
|
+
if pct >= 100:
|
|
368
|
+
return {
|
|
369
|
+
"level": "critical",
|
|
370
|
+
"message": f"AI budget exhausted: ${spent:.2f} / ${limit:.2f}",
|
|
371
|
+
"spent": spent,
|
|
372
|
+
"limit": limit,
|
|
373
|
+
"pct": round(pct, 1),
|
|
374
|
+
}
|
|
375
|
+
elif pct >= warn_pct:
|
|
376
|
+
return {
|
|
377
|
+
"level": "warning",
|
|
378
|
+
"message": f"AI spending at {pct:.0f}% of budget: ${spent:.2f} / ${limit:.2f}",
|
|
379
|
+
"spent": spent,
|
|
380
|
+
"limit": limit,
|
|
381
|
+
"pct": round(pct, 1),
|
|
382
|
+
}
|
|
383
|
+
return None
|
|
384
|
+
except Exception:
|
|
385
|
+
# Never crash — cost alerts are non-critical
|
|
386
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .test_agents import (
|
|
2
|
+
BrowserTestAgent,
|
|
3
|
+
RegressionAgent,
|
|
4
|
+
StressTestAgent,
|
|
5
|
+
TestCase,
|
|
6
|
+
TestSuite,
|
|
7
|
+
UnitTestAgent,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"TestCase",
|
|
12
|
+
"TestSuite",
|
|
13
|
+
"UnitTestAgent",
|
|
14
|
+
"BrowserTestAgent",
|
|
15
|
+
"StressTestAgent",
|
|
16
|
+
"RegressionAgent",
|
|
17
|
+
]
|