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,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Live audit display — rich.Live-powered dashboard with collapsible phase panels.
|
|
3
|
+
|
|
4
|
+
Layout:
|
|
5
|
+
┌─ p audit ──────────────────────────────────────────────────────┐
|
|
6
|
+
│ ◉ Scan → 47 files · 12 routes · 6 domains · 1.2s │
|
|
7
|
+
│ ┌──────────────────────────────────────────────────────────────┐│
|
|
8
|
+
│ │ ● Discovering files... ││
|
|
9
|
+
│ │ ● Parsing src/main.py ││
|
|
10
|
+
│ │ ● FastAPI v0.115.0 detected ││
|
|
11
|
+
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ││
|
|
12
|
+
│ └────────────────────────────────────────────────────────────┘│
|
|
13
|
+
│ ◉ Security → TaintAnalyzer running (3 findings so far)... │
|
|
14
|
+
│ ┌────────────────────────────────────────────────────────────┐│
|
|
15
|
+
│ │ ● TaintAnalyzer — scanning src/routes.py ││
|
|
16
|
+
│ │ ● ⚑ Finding #3: Unsafe string concat in query builder ││
|
|
17
|
+
│ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ││
|
|
18
|
+
│ └────────────────────────────────────────────────────────────┘│
|
|
19
|
+
│ ○ Tests → pending │
|
|
20
|
+
│ ○ Health → pending │
|
|
21
|
+
└────────────────────────────────────────────────────────────────┘
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from enum import Enum
|
|
31
|
+
|
|
32
|
+
from rich.console import Console, Group, RenderableType
|
|
33
|
+
from rich.live import Live
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
|
|
36
|
+
from rich.text import Text
|
|
37
|
+
|
|
38
|
+
_log = logging.getLogger("patchi.cli.live_audit")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PhaseStatus(Enum):
|
|
42
|
+
PENDING = "○"
|
|
43
|
+
RUNNING = "◉"
|
|
44
|
+
DONE = "✓"
|
|
45
|
+
FAILED = "✗"
|
|
46
|
+
SKIPPED = "⊘"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_PHASE_COLORS = {
|
|
50
|
+
"scan": "#C8621A",
|
|
51
|
+
"security": "#4ADE80",
|
|
52
|
+
"tests": "#60A5FA",
|
|
53
|
+
"health": "#A78BFA",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class PhaseState:
|
|
59
|
+
"""Mutable state of a single phase. Updated by the audit runner threads."""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
status: PhaseStatus = PhaseStatus.PENDING
|
|
63
|
+
summary: str = ""
|
|
64
|
+
duration_ms: int = 0
|
|
65
|
+
log_lines: list[tuple[str, str]] = field(default_factory=list) # (style, text)
|
|
66
|
+
findings: int = 0
|
|
67
|
+
files_scanned: int = 0
|
|
68
|
+
total_steps: int = 0
|
|
69
|
+
current_step: int = 0
|
|
70
|
+
sub_label: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LiveAuditDisplay:
|
|
74
|
+
"""
|
|
75
|
+
Manages a rich.live.Live renderable with collapsible phase panels.
|
|
76
|
+
|
|
77
|
+
Thread-safe — call from any thread. The display auto-refreshes.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, console: Console | None = None):
|
|
81
|
+
self.con = console or Console()
|
|
82
|
+
self._lock = threading.RLock()
|
|
83
|
+
self._phases: dict[str, PhaseState] = {}
|
|
84
|
+
self._expanded_phase: str | None = None # None = auto (latest running)
|
|
85
|
+
self._live: Live | None = None
|
|
86
|
+
self._start_time: float = 0.0
|
|
87
|
+
|
|
88
|
+
def start(self, title: str = "p audit"):
|
|
89
|
+
"""Start the live display. Must be called from main thread."""
|
|
90
|
+
self._start_time = time.monotonic()
|
|
91
|
+
self._live = Live(
|
|
92
|
+
self._render(),
|
|
93
|
+
console=self.con,
|
|
94
|
+
refresh_per_second=8,
|
|
95
|
+
transient=False,
|
|
96
|
+
screen=False,
|
|
97
|
+
)
|
|
98
|
+
self._live.__enter__()
|
|
99
|
+
|
|
100
|
+
def stop(self):
|
|
101
|
+
"""Stop the live display and restore normal terminal."""
|
|
102
|
+
if self._live:
|
|
103
|
+
try:
|
|
104
|
+
self._live.__exit__(None, None, None)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
_log.warning("LiveAuditDisplay.stop failed: %s", e)
|
|
107
|
+
self._live = None
|
|
108
|
+
self.con.print()
|
|
109
|
+
|
|
110
|
+
def phase(self, name: str) -> PhaseState:
|
|
111
|
+
"""Get or create a phase by name."""
|
|
112
|
+
with self._lock:
|
|
113
|
+
if name not in self._phases:
|
|
114
|
+
self._phases[name] = PhaseState(name=name)
|
|
115
|
+
return self._phases[name]
|
|
116
|
+
|
|
117
|
+
def update(self):
|
|
118
|
+
"""Trigger a refresh. Call after modifying any phase state."""
|
|
119
|
+
if self._live:
|
|
120
|
+
try:
|
|
121
|
+
self._live.update(self._render())
|
|
122
|
+
except Exception as e:
|
|
123
|
+
_log.warning("LiveAuditDisplay.update failed: %s", e)
|
|
124
|
+
|
|
125
|
+
def _render(self) -> RenderableType:
|
|
126
|
+
"""Build the full renderable from current phase states."""
|
|
127
|
+
with self._lock:
|
|
128
|
+
elapsed = int((time.monotonic() - self._start_time) * 1000)
|
|
129
|
+
rows = []
|
|
130
|
+
color = _PHASE_COLORS
|
|
131
|
+
|
|
132
|
+
# Auto-expand the currently running phase
|
|
133
|
+
running = [p for p in self._phases.values() if p.status == PhaseStatus.RUNNING]
|
|
134
|
+
expanded = running[0].name if running else None
|
|
135
|
+
|
|
136
|
+
for phase_name in ("scan", "security", "tests", "health"):
|
|
137
|
+
phase = self._phases.get(phase_name)
|
|
138
|
+
if phase is None:
|
|
139
|
+
rows.append(self._render_header_line(phase_name, PhaseStatus.PENDING, "", 0))
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
# Header line
|
|
143
|
+
header = self._render_header_line(
|
|
144
|
+
phase_name, phase.status, phase.summary, phase.duration_ms
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Expanded body if this phase is running AND the running phase
|
|
148
|
+
is_expanded = phase_name == expanded
|
|
149
|
+
if is_expanded and phase.log_lines:
|
|
150
|
+
body = self._render_expanded_body(phase)
|
|
151
|
+
rows.append(Group(header, body))
|
|
152
|
+
else:
|
|
153
|
+
rows.append(header)
|
|
154
|
+
|
|
155
|
+
total_findings = sum(p.findings for p in self._phases.values())
|
|
156
|
+
total_files = sum(p.files_scanned for p in self._phases.values())
|
|
157
|
+
footer = Text(
|
|
158
|
+
f" Total: {total_files} files · {total_findings} findings · {elapsed}ms",
|
|
159
|
+
style="dim",
|
|
160
|
+
)
|
|
161
|
+
grid = Group(*rows, Text(""), footer)
|
|
162
|
+
return Panel(
|
|
163
|
+
grid, title=f"[bold]{color.get('audit', '#C8621A')}audit[/bold]", border_style="dim"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def _render_header_line(
|
|
167
|
+
self, name: str, status: PhaseStatus, summary: str, duration_ms: int
|
|
168
|
+
) -> RenderableType:
|
|
169
|
+
"""A single-line phase header: ◉ Scan → 47 files · 1.2s"""
|
|
170
|
+
color = _PHASE_COLORS.get(name, "white")
|
|
171
|
+
status_icon = status.value
|
|
172
|
+
status_color = {
|
|
173
|
+
PhaseStatus.DONE: "green",
|
|
174
|
+
PhaseStatus.FAILED: "red",
|
|
175
|
+
PhaseStatus.RUNNING: "yellow",
|
|
176
|
+
PhaseStatus.PENDING: "dim",
|
|
177
|
+
PhaseStatus.SKIPPED: "dim",
|
|
178
|
+
}.get(status, "dim")
|
|
179
|
+
|
|
180
|
+
label = name.capitalize()
|
|
181
|
+
dur = f"· {duration_ms}ms" if duration_ms else ""
|
|
182
|
+
summary_part = f" → {summary}" if summary else ""
|
|
183
|
+
|
|
184
|
+
parts = [
|
|
185
|
+
f"[{status_color}]{status_icon}[/{status_color}] ",
|
|
186
|
+
f"[bold {color}]{label:<10}[/bold {color}]",
|
|
187
|
+
]
|
|
188
|
+
if summary_part:
|
|
189
|
+
parts.append(f"[dim]{summary_part}[/dim]")
|
|
190
|
+
if duration_ms:
|
|
191
|
+
parts.append(f"[dim]{dur}[/dim]")
|
|
192
|
+
return Text.assemble(*parts)
|
|
193
|
+
|
|
194
|
+
def _render_expanded_body(self, phase: PhaseState) -> RenderableType:
|
|
195
|
+
"""Expanded panel showing live log lines + progress bar."""
|
|
196
|
+
lines = []
|
|
197
|
+
for style, text in phase.log_lines:
|
|
198
|
+
if style:
|
|
199
|
+
lines.append(Text(text, style=style))
|
|
200
|
+
else:
|
|
201
|
+
lines.append(Text(text))
|
|
202
|
+
|
|
203
|
+
# Progress bar if there are sub-steps
|
|
204
|
+
if phase.total_steps > 0:
|
|
205
|
+
progress = Progress(
|
|
206
|
+
TextColumn("[progress.description]{task.description}"),
|
|
207
|
+
BarColumn(),
|
|
208
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
209
|
+
TimeElapsedColumn(),
|
|
210
|
+
console=self.con,
|
|
211
|
+
)
|
|
212
|
+
task_id = progress.add_task(
|
|
213
|
+
phase.sub_label or "",
|
|
214
|
+
total=phase.total_steps,
|
|
215
|
+
completed=phase.current_step,
|
|
216
|
+
)
|
|
217
|
+
progress.update(task_id, completed=phase.current_step)
|
|
218
|
+
lines.append(Text(""))
|
|
219
|
+
lines.append(progress)
|
|
220
|
+
|
|
221
|
+
# If findings found during live phase, show count
|
|
222
|
+
if phase.findings:
|
|
223
|
+
lines.append(Text(f"\n ⚑ {phase.findings} finding(s) so far", style="yellow"))
|
|
224
|
+
|
|
225
|
+
panel = Panel(
|
|
226
|
+
Group(*lines),
|
|
227
|
+
border_style="dim",
|
|
228
|
+
padding=(0, 1),
|
|
229
|
+
width=None,
|
|
230
|
+
)
|
|
231
|
+
return panel
|
|
232
|
+
|
|
233
|
+
def log(self, phase_name: str, message: str, style: str = ""):
|
|
234
|
+
"""Append a log line to a phase."""
|
|
235
|
+
with self._lock:
|
|
236
|
+
phase = self.phase(phase_name)
|
|
237
|
+
# Keep last 50 lines
|
|
238
|
+
phase.log_lines.append((style, message))
|
|
239
|
+
if len(phase.log_lines) > 50:
|
|
240
|
+
phase.log_lines = phase.log_lines[-50:]
|
|
241
|
+
|
|
242
|
+
def set_phase_status(
|
|
243
|
+
self,
|
|
244
|
+
name: str,
|
|
245
|
+
status: PhaseStatus,
|
|
246
|
+
summary: str = "",
|
|
247
|
+
duration_ms: int = 0,
|
|
248
|
+
findings: int = 0,
|
|
249
|
+
files_scanned: int = 0,
|
|
250
|
+
):
|
|
251
|
+
with self._lock:
|
|
252
|
+
phase = self.phase(name)
|
|
253
|
+
phase.status = status
|
|
254
|
+
if summary:
|
|
255
|
+
phase.summary = summary
|
|
256
|
+
if duration_ms:
|
|
257
|
+
phase.duration_ms = duration_ms
|
|
258
|
+
if findings:
|
|
259
|
+
phase.findings = findings
|
|
260
|
+
if files_scanned:
|
|
261
|
+
phase.files_scanned = files_scanned
|
|
262
|
+
|
|
263
|
+
def set_phase_progress(self, name: str, current: int, total: int, label: str = ""):
|
|
264
|
+
with self._lock:
|
|
265
|
+
phase = self.phase(name)
|
|
266
|
+
phase.total_steps = total
|
|
267
|
+
phase.current_step = current
|
|
268
|
+
if label:
|
|
269
|
+
phase.sub_label = label
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Lightweight live progress display for single-command streaming.
|
|
3
|
+
|
|
4
|
+
Shows an inline updating panel with:
|
|
5
|
+
- Title header
|
|
6
|
+
- Live log lines (ring buffer, last 20 shown)
|
|
7
|
+
- Optional step-based progress bar
|
|
8
|
+
- Auto-collapse to one-line summary when stopped
|
|
9
|
+
|
|
10
|
+
Thread-safe — call from any thread.
|
|
11
|
+
No screen/alt-screen mode — works inline in the terminal.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from rich.console import Console, Group, RenderableType
|
|
22
|
+
from rich.live import Live
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger("patchi.cli.live_progress")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class _ProgressState:
|
|
32
|
+
log_lines: list[tuple[str, str]] = field(default_factory=list)
|
|
33
|
+
total_steps: int = 0
|
|
34
|
+
current_step: int = 0
|
|
35
|
+
sub_label: str = ""
|
|
36
|
+
findings: int = 0
|
|
37
|
+
status_text: str = "running…"
|
|
38
|
+
done: bool = False
|
|
39
|
+
failed: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class LiveProgress:
|
|
43
|
+
"""
|
|
44
|
+
Inline live progress display for a single command.
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
lp = LiveProgress(console, title="p security")
|
|
48
|
+
lp.start()
|
|
49
|
+
lp.log("Scanning foo.py")
|
|
50
|
+
lp.set_progress(5, 10, "TaintAnalyzer")
|
|
51
|
+
lp.update()
|
|
52
|
+
lp.stop(summary="3 findings in 1.2s")
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, console: Console | None = None, title: str = "progress"):
|
|
56
|
+
self.con = console or Console()
|
|
57
|
+
self._title = title
|
|
58
|
+
self._lock = threading.RLock()
|
|
59
|
+
self._state = _ProgressState()
|
|
60
|
+
self._live: Live | None = None
|
|
61
|
+
self._start_time: float = 0.0
|
|
62
|
+
|
|
63
|
+
def start(self):
|
|
64
|
+
self._start_time = time.monotonic()
|
|
65
|
+
self._live = Live(
|
|
66
|
+
self._render(),
|
|
67
|
+
console=self.con,
|
|
68
|
+
refresh_per_second=8,
|
|
69
|
+
transient=False,
|
|
70
|
+
screen=False,
|
|
71
|
+
)
|
|
72
|
+
self._live.__enter__()
|
|
73
|
+
|
|
74
|
+
def stop(self, summary: str = ""):
|
|
75
|
+
if self._live:
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._state.done = True
|
|
78
|
+
self._live.update(self._render())
|
|
79
|
+
try:
|
|
80
|
+
self._live.__exit__(None, None, None)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
_log.warning("LiveProgress.stop failed: %s", e)
|
|
83
|
+
self._live = None
|
|
84
|
+
|
|
85
|
+
elapsed = int((time.monotonic() - self._start_time) * 1000)
|
|
86
|
+
if summary:
|
|
87
|
+
self.con.print(
|
|
88
|
+
f" [bold #A78BFA]{self._title}:[/bold #A78BFA] {summary} [dim]({elapsed}ms)[/dim]"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def update(self):
|
|
92
|
+
if self._live:
|
|
93
|
+
try:
|
|
94
|
+
self._live.update(self._render())
|
|
95
|
+
except Exception as e:
|
|
96
|
+
_log.warning("LiveProgress.update failed: %s", e)
|
|
97
|
+
|
|
98
|
+
def log(self, message: str, style: str = ""):
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._state.log_lines.append((style, message))
|
|
101
|
+
if len(self._state.log_lines) > 50:
|
|
102
|
+
self._state.log_lines = self._state.log_lines[-50:]
|
|
103
|
+
|
|
104
|
+
def set_progress(self, current: int, total: int, label: str = ""):
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._state.total_steps = total
|
|
107
|
+
self._state.current_step = current
|
|
108
|
+
if label:
|
|
109
|
+
self._state.sub_label = label
|
|
110
|
+
|
|
111
|
+
def set_findings(self, count: int):
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._state.findings = count
|
|
114
|
+
|
|
115
|
+
def set_status(self, text: str):
|
|
116
|
+
with self._lock:
|
|
117
|
+
self._state.status_text = text
|
|
118
|
+
|
|
119
|
+
def _render(self) -> RenderableType:
|
|
120
|
+
with self._lock:
|
|
121
|
+
lines = []
|
|
122
|
+
for style, text in self._state.log_lines[-20:]:
|
|
123
|
+
if style:
|
|
124
|
+
lines.append(Text(text, style=style))
|
|
125
|
+
else:
|
|
126
|
+
lines.append(Text(text))
|
|
127
|
+
|
|
128
|
+
# Progress bar
|
|
129
|
+
if self._state.total_steps > 0 and not self._state.done:
|
|
130
|
+
progress = Progress(
|
|
131
|
+
TextColumn("[progress.description]{task.description}"),
|
|
132
|
+
BarColumn(),
|
|
133
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
134
|
+
TimeElapsedColumn(),
|
|
135
|
+
console=self.con,
|
|
136
|
+
)
|
|
137
|
+
task_id = progress.add_task(
|
|
138
|
+
self._state.sub_label or "",
|
|
139
|
+
total=self._state.total_steps,
|
|
140
|
+
completed=self._state.current_step,
|
|
141
|
+
)
|
|
142
|
+
progress.update(task_id, completed=self._state.current_step)
|
|
143
|
+
lines.append(Text(""))
|
|
144
|
+
lines.append(progress)
|
|
145
|
+
|
|
146
|
+
# Findings counter
|
|
147
|
+
if self._state.findings:
|
|
148
|
+
lines.append(Text(f" ! {self._state.findings} finding(s)", style="yellow"))
|
|
149
|
+
|
|
150
|
+
elapsed = int((time.monotonic() - self._start_time) * 1000) if self._start_time else 0
|
|
151
|
+
footer = Text(
|
|
152
|
+
f" {self._state.status_text} - {elapsed}ms",
|
|
153
|
+
style="dim",
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
grid = Group(*lines, Text(""), footer)
|
|
157
|
+
icon = (
|
|
158
|
+
"[#4ADE80]v[/#4ADE80]"
|
|
159
|
+
if self._state.done and not self._state.failed
|
|
160
|
+
else "[red]x[/red]"
|
|
161
|
+
if self._state.failed
|
|
162
|
+
else "[yellow]~[/yellow]"
|
|
163
|
+
)
|
|
164
|
+
return Panel(
|
|
165
|
+
grid,
|
|
166
|
+
title=f"{icon} [bold #A78BFA]{self._title}[/bold #A78BFA]",
|
|
167
|
+
border_style="dim",
|
|
168
|
+
padding=(0, 1),
|
|
169
|
+
)
|
patchi/cli/framework.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command framework — §3 Command Unification.
|
|
3
|
+
|
|
4
|
+
Replaces the three-places-to-edit pattern (lazy-import factory function +
|
|
5
|
+
argparse subparser block + elif dispatch chain) with one declarative
|
|
6
|
+
`Command` per command, registered once in `registry.py`.
|
|
7
|
+
|
|
8
|
+
Design note (refinement made during implementation, not in the original design
|
|
9
|
+
doc): a handful of existing commands read GLOBAL flags (--quiet, --verbose)
|
|
10
|
+
that are defined once on the top-level parser, not re-declared per-subcommand.
|
|
11
|
+
`Arg(global_flag=True)` marks these — the builder skips calling add_argument
|
|
12
|
+
for them (the global parser already defines it), but the dispatcher still
|
|
13
|
+
reads them into the handler's kwargs. Without this distinction, migrating
|
|
14
|
+
`scan` (which reads the global `quiet`) would either duplicate the flag
|
|
15
|
+
definition (argparse conflict) or silently drop the value.
|
|
16
|
+
|
|
17
|
+
Second design note: the `namespace_handler` shape (see Command). notify,
|
|
18
|
+
hosted, test, security and cross-repo all dispatch on parsed values in ways a
|
|
19
|
+
static Arg->kwarg mapping can't express (e.g. `p test generate` routes to a
|
|
20
|
+
different function than `p test unit`), and their legacy handlers already
|
|
21
|
+
received the whole Namespace. Rather than force those into per-subcommand
|
|
22
|
+
handlers, the framework supports passing the Namespace straight through and
|
|
23
|
+
letting the one handler self-route — `resolve` returns the top command
|
|
24
|
+
regardless of how deep the subcommand path goes.
|
|
25
|
+
|
|
26
|
+
Lazy loading is preserved exactly as the original factories did: `handler` is
|
|
27
|
+
a dotted string ("module:function"), not a function object — nothing is
|
|
28
|
+
imported until dispatch actually resolves it, so `p --help` / cold start
|
|
29
|
+
stays fast regardless of how many commands are registered.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import importlib
|
|
36
|
+
import logging
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("patchi.cli.framework")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Arg:
|
|
45
|
+
name: str # "--area" or "area" (positional)
|
|
46
|
+
help: str = ""
|
|
47
|
+
default: Any = None
|
|
48
|
+
action: str | None = None # "store_true", "store_false", etc.
|
|
49
|
+
choices: tuple | None = None
|
|
50
|
+
nargs: str | None = None
|
|
51
|
+
dest: str | None = None
|
|
52
|
+
type: type | None = None
|
|
53
|
+
global_flag: bool = False # True = already defined on the top-level
|
|
54
|
+
# parser; don't re-add, just read it
|
|
55
|
+
|
|
56
|
+
def resolved_dest(self) -> str:
|
|
57
|
+
is_positional = not self.name.startswith("-")
|
|
58
|
+
if is_positional:
|
|
59
|
+
# argparse always uses the name itself as dest for positionals --
|
|
60
|
+
# any `dest` set here would be ignored by the real parser, so
|
|
61
|
+
# resolving it here too keeps kwarg-building consistent with what
|
|
62
|
+
# actually gets parsed.
|
|
63
|
+
return self.name.replace("-", "_")
|
|
64
|
+
if self.dest:
|
|
65
|
+
return self.dest
|
|
66
|
+
return self.name.lstrip("-").replace("-", "_")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Command:
|
|
71
|
+
name: str
|
|
72
|
+
help: str
|
|
73
|
+
handler: str = "" # dotted path: "patchi.cli.commands.scan_cmd:run"
|
|
74
|
+
args: tuple[Arg, ...] = ()
|
|
75
|
+
subcommands: tuple[Command, ...] = ()
|
|
76
|
+
aliases: tuple[str, ...] = ()
|
|
77
|
+
fixed_kwargs: dict = field(default_factory=dict)
|
|
78
|
+
# ^ For cases where several subcommands share ONE handler but each needs a
|
|
79
|
+
# different hardcoded value baked in -- e.g. `restrict add`/`scan-only`/
|
|
80
|
+
# `sensitive` all call the same run_add(path, restriction_type, reason),
|
|
81
|
+
# only restriction_type differs per subcommand. These values are merged
|
|
82
|
+
# into the kwargs unconditionally, never derived from parsed args.
|
|
83
|
+
namespace_handler: bool = False
|
|
84
|
+
# ^ Self-routing Namespace-handler shape: the handler receives the WHOLE
|
|
85
|
+
# argparse Namespace (fn(args)) and routes on the subcommand dests itself
|
|
86
|
+
# (notify_cmd/hosted_cmd/token_cmd...). The subcommand tree is declared
|
|
87
|
+
# purely for parsing + help — resolve() always returns the top command, so
|
|
88
|
+
# one function owns the entire command tree. Used by notify, hosted, test,
|
|
89
|
+
# security, and cross-repo, which all dispatch on parsed values in ways a
|
|
90
|
+
# static Arg->kwarg mapping cannot express.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_args(parser: argparse.ArgumentParser, cmd: Command) -> None:
|
|
94
|
+
args = cmd.args
|
|
95
|
+
# Normalize: accept a single Arg or None as well as a list/tuple so a
|
|
96
|
+
# mis-registered command can never crash parser construction.
|
|
97
|
+
if args is None:
|
|
98
|
+
return
|
|
99
|
+
if isinstance(args, (list, tuple)):
|
|
100
|
+
arg_list = args
|
|
101
|
+
else:
|
|
102
|
+
arg_list = [args]
|
|
103
|
+
for a in arg_list:
|
|
104
|
+
if a.global_flag:
|
|
105
|
+
continue # already defined on the top-level parser
|
|
106
|
+
is_positional = not a.name.startswith("-")
|
|
107
|
+
kwargs: dict[str, Any] = {"help": a.help}
|
|
108
|
+
if a.action:
|
|
109
|
+
kwargs["action"] = a.action
|
|
110
|
+
if a.default is not None:
|
|
111
|
+
kwargs["default"] = a.default
|
|
112
|
+
if a.choices:
|
|
113
|
+
kwargs["choices"] = a.choices
|
|
114
|
+
if a.nargs:
|
|
115
|
+
kwargs["nargs"] = a.nargs
|
|
116
|
+
if a.dest and not is_positional:
|
|
117
|
+
# argparse forbids dest= on positional args (the name IS the dest) --
|
|
118
|
+
# only pass it for optional flags. A positional Arg must name itself
|
|
119
|
+
# exactly what the handler's kwarg expects; there's no override.
|
|
120
|
+
kwargs["dest"] = a.dest
|
|
121
|
+
if a.type:
|
|
122
|
+
kwargs["type"] = a.type
|
|
123
|
+
parser.add_argument(a.name, **kwargs)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _add_command(sub, cmd: Command) -> None:
|
|
127
|
+
parser = sub.add_parser(cmd.name, help=cmd.help, aliases=list(cmd.aliases))
|
|
128
|
+
_add_args(parser, cmd)
|
|
129
|
+
if cmd.subcommands:
|
|
130
|
+
sub_sub = parser.add_subparsers(dest=f"{cmd.name}_cmd")
|
|
131
|
+
for child in cmd.subcommands:
|
|
132
|
+
_add_command(sub_sub, child)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def register_commands(sub, commands: list[Command]) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Add every registered Command as a subparser on an EXISTING subparsers
|
|
138
|
+
object. Used during the incremental migration window, where most commands
|
|
139
|
+
still add themselves to the same `sub` the legacy code creates — this lets
|
|
140
|
+
registry-driven and legacy-declared commands coexist on one parser tree
|
|
141
|
+
without either side needing to know about the other.
|
|
142
|
+
"""
|
|
143
|
+
for cmd in commands:
|
|
144
|
+
_add_command(sub, cmd)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_parser(commands: list[Command], base: argparse.ArgumentParser) -> argparse.ArgumentParser:
|
|
148
|
+
"""
|
|
149
|
+
Standalone variant: builds a brand-new subparsers object on `base` and
|
|
150
|
+
registers every Command onto it. Only safe to use once nothing else adds
|
|
151
|
+
subparsers to `base` — during the migration window main.py uses
|
|
152
|
+
`register_commands` against its own existing `sub` instead.
|
|
153
|
+
"""
|
|
154
|
+
sub = base.add_subparsers(dest="command")
|
|
155
|
+
register_commands(sub, commands)
|
|
156
|
+
return base
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _find_command(commands: list[Command], name: str) -> Command | None:
|
|
160
|
+
for cmd in commands:
|
|
161
|
+
if cmd.name == name or name in cmd.aliases:
|
|
162
|
+
return cmd
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _lazy_import(dotted_handler: str):
|
|
167
|
+
mod_path, _, func_name = dotted_handler.partition(":")
|
|
168
|
+
module = importlib.import_module(mod_path)
|
|
169
|
+
return getattr(module, func_name)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _build_kwargs(cmd: Command, args: argparse.Namespace) -> dict[str, Any]:
|
|
173
|
+
kwargs = dict(cmd.fixed_kwargs)
|
|
174
|
+
for a in cmd.args:
|
|
175
|
+
dest = a.resolved_dest()
|
|
176
|
+
kwargs[dest] = getattr(args, dest, a.default)
|
|
177
|
+
return kwargs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def resolve(commands: list[Command], args: argparse.Namespace) -> Command | None:
|
|
181
|
+
"""Find the Command (and subcommand, if any) matching parsed args.command."""
|
|
182
|
+
top_name = getattr(args, "command", None)
|
|
183
|
+
if top_name is None:
|
|
184
|
+
return None
|
|
185
|
+
cmd = _find_command(commands, top_name)
|
|
186
|
+
if cmd is None:
|
|
187
|
+
return None
|
|
188
|
+
if cmd.namespace_handler:
|
|
189
|
+
# Self-routing command: one handler owns the whole subcommand tree and
|
|
190
|
+
# routes on the parsed dests itself, so the top Command is what gets
|
|
191
|
+
# dispatched no matter how deep the invocation went.
|
|
192
|
+
return cmd
|
|
193
|
+
if cmd.subcommands:
|
|
194
|
+
sub_name = getattr(args, f"{cmd.name}_cmd", None)
|
|
195
|
+
if sub_name:
|
|
196
|
+
child = _find_command(list(cmd.subcommands), sub_name)
|
|
197
|
+
if child is not None:
|
|
198
|
+
return child
|
|
199
|
+
return cmd
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def dispatch(commands: list[Command], args: argparse.Namespace):
|
|
203
|
+
"""
|
|
204
|
+
Resolve the matching Command, lazily import its handler, and call it with
|
|
205
|
+
kwargs derived from the SAME Arg specs used to build the parser — so
|
|
206
|
+
parser and dispatch can never drift out of sync (the exact bug class this
|
|
207
|
+
design exists to eliminate).
|
|
208
|
+
|
|
209
|
+
Returns None if no registered Command matches (caller should fall through
|
|
210
|
+
to the legacy dispatch ladder during the incremental migration window).
|
|
211
|
+
"""
|
|
212
|
+
cmd = resolve(commands, args)
|
|
213
|
+
|
|
214
|
+
# ── Handle `p <family> commands` ──────────────────────────────────────
|
|
215
|
+
# If the resolved command is a family (test, scan, fix, etc.) and the user
|
|
216
|
+
# typed `commands` as the subcommand, route to family commands display.
|
|
217
|
+
if cmd is not None:
|
|
218
|
+
top_name = getattr(args, "command", None)
|
|
219
|
+
sub_name = getattr(args, f"{top_name}_cmd", None)
|
|
220
|
+
if sub_name == "commands":
|
|
221
|
+
try:
|
|
222
|
+
from patchi.cli.command_families import get_family
|
|
223
|
+
from patchi.cli.commands.commands_cmd import run_family_commands
|
|
224
|
+
|
|
225
|
+
family = get_family(top_name)
|
|
226
|
+
if family:
|
|
227
|
+
run_family_commands(top_name)
|
|
228
|
+
return None, True
|
|
229
|
+
except Exception as e:
|
|
230
|
+
_log.debug("Family routing failed for %s: %s", top_name, e)
|
|
231
|
+
|
|
232
|
+
if cmd is None:
|
|
233
|
+
return None, False # (result, handled) -- not handled, fall through
|
|
234
|
+
fn = _lazy_import(cmd.handler)
|
|
235
|
+
if cmd.namespace_handler:
|
|
236
|
+
# Self-routing shape: hand the whole parsed Namespace to the handler;
|
|
237
|
+
# it dispatches on the subcommand dests itself. Kwargs are deliberately
|
|
238
|
+
# not built — the subcommands are parsing/help sugar only.
|
|
239
|
+
return fn(args), True
|
|
240
|
+
kwargs = _build_kwargs(cmd, args)
|
|
241
|
+
return fn(**kwargs), True
|