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,1052 @@
|
|
|
1
|
+
"""
|
|
2
|
+
`p test` — Run tests, generate suites, view history, manage config.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
p test — run default tests (unit + regression)
|
|
6
|
+
p test unit — unit tests only
|
|
7
|
+
p test smoke — smoke tests (buttons + layout + a11y)
|
|
8
|
+
p test full — all test agents
|
|
9
|
+
p test <type> [area] — specific test type, optional scope
|
|
10
|
+
|
|
11
|
+
p test generate — AI generates a full test suite
|
|
12
|
+
p test generate unit — AI generates unit tests only
|
|
13
|
+
p test generate e2e — AI generates E2E tests only
|
|
14
|
+
|
|
15
|
+
p test report — show test run history
|
|
16
|
+
p test report --last 10 — show last 10 runs
|
|
17
|
+
|
|
18
|
+
p test config show — show current test configuration
|
|
19
|
+
p test config set <key> <value> — update a config value
|
|
20
|
+
p test config flows add — add a custom test flow (interactive)
|
|
21
|
+
p test config flows remove <n> — remove a custom test flow
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from rich.panel import Panel
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
from rich.text import Text
|
|
34
|
+
|
|
35
|
+
from patchi.cli.console import con
|
|
36
|
+
from patchi.cli.display.live_progress import LiveProgress
|
|
37
|
+
from patchi.core import memory as mem
|
|
38
|
+
from patchi.core.config import require_project_root
|
|
39
|
+
|
|
40
|
+
_MAPS_DIR = Path(__file__).resolve().parent
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_log = logging.getLogger("patchi.cli.test_cmd")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_test_type_map() -> dict[str, list[str]]:
|
|
47
|
+
"""Load test type mapping from external JSON config."""
|
|
48
|
+
maps_file = _MAPS_DIR / "agent_maps.json"
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(maps_file.read_text(encoding="utf-8"))
|
|
51
|
+
return data["test"]["test_types"]
|
|
52
|
+
except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
|
|
53
|
+
con.print(f"[yellow]Warning: could not load agent_maps.json: {e}[/yellow]")
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
TEST_TYPE_MAP = _load_test_type_map()
|
|
58
|
+
VALID_TYPES = sorted(TEST_TYPE_MAP.keys())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run(
|
|
62
|
+
test_type: str | None = None,
|
|
63
|
+
area: str | None = None,
|
|
64
|
+
root: Path | None = None,
|
|
65
|
+
action: str | None = None,
|
|
66
|
+
config_action: str | None = None,
|
|
67
|
+
key: str | None = None,
|
|
68
|
+
value: str | None = None,
|
|
69
|
+
last_n: int = 20,
|
|
70
|
+
attack: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Entry point for `p test [type] [area]` and subcommands."""
|
|
73
|
+
# Route to subcommands
|
|
74
|
+
if action == "generate":
|
|
75
|
+
run_generate(test_type=test_type, root=root)
|
|
76
|
+
return
|
|
77
|
+
if action == "report":
|
|
78
|
+
run_report(last_n=last_n, root=root)
|
|
79
|
+
return
|
|
80
|
+
if action == "config":
|
|
81
|
+
run_config(action=config_action, key=key, value=value, root=root)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
# ── Attack mode ─────────────────────────────────────────────────────────
|
|
85
|
+
if attack:
|
|
86
|
+
_run_attack(root)
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
r = root or require_project_root()
|
|
91
|
+
except RuntimeError as e:
|
|
92
|
+
con.print(f"[red]{e}[/red]")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Import all test agents (triggers registration)
|
|
96
|
+
_import_all_agents()
|
|
97
|
+
|
|
98
|
+
from patchi.core import config as cfg
|
|
99
|
+
from patchi.core.agents.base import AgentGroup, AgentInput, list_agents
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
config = cfg.load(r)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
_log.warning("run failed: %s", e)
|
|
105
|
+
config = {}
|
|
106
|
+
|
|
107
|
+
brain = mem.get_brain(r)
|
|
108
|
+
scope = _scope_from_area(r, area)
|
|
109
|
+
|
|
110
|
+
# Determine which agents to run
|
|
111
|
+
all_test_agents = list_agents(AgentGroup.TEST)
|
|
112
|
+
agent_map = {a.name: a for a in all_test_agents}
|
|
113
|
+
|
|
114
|
+
if test_type is None:
|
|
115
|
+
# Default: unit + regression
|
|
116
|
+
to_run_names = ["UnitTestAgent", "RegressionAgent"]
|
|
117
|
+
else:
|
|
118
|
+
to_run_names = TEST_TYPE_MAP.get(test_type.lower(), [])
|
|
119
|
+
if not to_run_names:
|
|
120
|
+
# Try direct agent name match
|
|
121
|
+
if test_type in agent_map:
|
|
122
|
+
to_run_names = [test_type]
|
|
123
|
+
else:
|
|
124
|
+
con.print(f"[red]Unknown test type: {test_type!r}[/red]")
|
|
125
|
+
con.print(f"[dim]Valid types: {' · '.join(VALID_TYPES)}[/dim]")
|
|
126
|
+
con.print(f"[dim]Or an agent name: {' · '.join(sorted(agent_map))}[/dim]")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
to_run = [agent_map[n] for n in to_run_names if n in agent_map]
|
|
130
|
+
if not to_run:
|
|
131
|
+
con.print("[red]No matching test agents found.[/red]")
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
# ConsoleLoggingAgent rides along on browser-family runs (§11: auto when
|
|
135
|
+
# p test is active). Fail-open inside the agent if no server/playwright.
|
|
136
|
+
_browser_family = {
|
|
137
|
+
"BrowserTestAgent", "UIButtonAgent", "UILayoutAgent", "UIAccessibilityAgent",
|
|
138
|
+
"VisualRegressionAgent", "E2EFlowAgent", "ConsoleLoggingAgent",
|
|
139
|
+
}
|
|
140
|
+
if "ConsoleLoggingAgent" in agent_map and any(n in _browser_family for n in to_run_names):
|
|
141
|
+
if all(a.name != "ConsoleLoggingAgent" for a in to_run):
|
|
142
|
+
to_run.append(agent_map["ConsoleLoggingAgent"])
|
|
143
|
+
to_run_names = [*to_run_names, "ConsoleLoggingAgent"]
|
|
144
|
+
|
|
145
|
+
# Chain: Check → Run → Test/Attack (Run relies on Check green, Test relies on Run)
|
|
146
|
+
_launcher_url = None
|
|
147
|
+
needs_launcher = any(n in ("BrowserTestAgent", "UIButtonAgent", "UILayoutAgent", "UIAccessibilityAgent", "VisualRegressionAgent", "E2EFlowAgent", "ConsoleLoggingAgent") for n in to_run_names) or test_type in ("browser", "e2e", "visual", "full")
|
|
148
|
+
needs_run = needs_launcher or any(n in ("RedTeamAgent", "DastAgent", "ApiFuzzerAgent") for n in to_run_names)
|
|
149
|
+
if needs_run:
|
|
150
|
+
# 1. Check must be green — gate via .patchi/p_check_status.json
|
|
151
|
+
try:
|
|
152
|
+
from patchi.core.testing.gate import require_ready
|
|
153
|
+
|
|
154
|
+
ready, url, st = require_ready(r)
|
|
155
|
+
if not ready:
|
|
156
|
+
con.print(f"[yellow]P-Check not READY_TO_SERVE — running p check first[/yellow] [dim]({st.get('status') if st else 'no status'})[/dim]")
|
|
157
|
+
# Run P-Check side agents inline (install/build/format)
|
|
158
|
+
try:
|
|
159
|
+
from patchi.cli.commands.check_cmd import run as check_run
|
|
160
|
+
|
|
161
|
+
check_run(root=r)
|
|
162
|
+
ready, url, st = require_ready(r)
|
|
163
|
+
if not ready:
|
|
164
|
+
con.print(f"[red]P-Check still BLOCKED: {st.get('error') if st else 'unknown'} — aborting test (fix via p check --fix)[/red]")
|
|
165
|
+
return
|
|
166
|
+
except Exception as exc: # noqa: BLE001
|
|
167
|
+
_log.debug("p check inline failed: %s", exc)
|
|
168
|
+
except Exception as exc: # noqa: BLE001
|
|
169
|
+
_log.debug("gate check failed: %s", exc)
|
|
170
|
+
# 2. Run — start app after Check green
|
|
171
|
+
try:
|
|
172
|
+
from patchi.core.agents.base import AgentInput as _AI
|
|
173
|
+
from patchi.core.agents.run_agent import RunAgent
|
|
174
|
+
|
|
175
|
+
run_inp = _AI(root=r, scope=[], brain=brain, config=config, extra={})
|
|
176
|
+
run_res = RunAgent().run(run_inp)
|
|
177
|
+
if run_res.status.value == "done" and run_res.data.get("base_url"):
|
|
178
|
+
_launcher_url = run_res.data["base_url"]
|
|
179
|
+
con.print(f"[dim]RunAgent: app at {_launcher_url} — P-Check was green[/dim]")
|
|
180
|
+
else:
|
|
181
|
+
_log.debug("RunAgent %s: %s", run_res.status, run_res.data.get("gate_reason") or run_res.errors)
|
|
182
|
+
if run_res.data.get("gate_blocked"):
|
|
183
|
+
con.print(f"[red]Run blocked: {run_res.data.get('gate_reason')}[/red]")
|
|
184
|
+
return
|
|
185
|
+
# Fallback to direct launcher if RunAgent failed but Check was green
|
|
186
|
+
from patchi.core.testing.app_launcher import ensure_running
|
|
187
|
+
|
|
188
|
+
_launcher_url = ensure_running(r, config, {})
|
|
189
|
+
if _launcher_url:
|
|
190
|
+
con.print(f"[dim]App running at {_launcher_url} — launcher fallback[/dim]")
|
|
191
|
+
except Exception as exc: # noqa: BLE001
|
|
192
|
+
_log.debug("RunAgent failed: %s", exc)
|
|
193
|
+
# Fallback direct
|
|
194
|
+
try:
|
|
195
|
+
from patchi.core.testing.app_launcher import ensure_running
|
|
196
|
+
|
|
197
|
+
_launcher_url = ensure_running(r, config, {})
|
|
198
|
+
except Exception as _exc:
|
|
199
|
+
_log.debug('suppressed: %s', _exc)
|
|
200
|
+
|
|
201
|
+
con.print()
|
|
202
|
+
label = f" [dim]→ {area}[/dim]" if area else ""
|
|
203
|
+
con.print(
|
|
204
|
+
f"[bold #C8621A]Testing{label}[/bold #C8621A] "
|
|
205
|
+
f"[dim]{', '.join(a.name.replace('Agent', '') for a in to_run)}[/dim]"
|
|
206
|
+
)
|
|
207
|
+
con.print()
|
|
208
|
+
|
|
209
|
+
# Chain progress display
|
|
210
|
+
from patchi.cli.ux import format_step, status_icon, status_style
|
|
211
|
+
|
|
212
|
+
total_steps = len(to_run) + (2 if needs_run else 0) # +2 for check and run if needed
|
|
213
|
+
current_step = 0
|
|
214
|
+
|
|
215
|
+
def show_step(step_name: str, status: str = "running"):
|
|
216
|
+
nonlocal current_step
|
|
217
|
+
current_step += 1
|
|
218
|
+
icon = status_icon(status)
|
|
219
|
+
style = status_style(status)
|
|
220
|
+
con.print(format_step(current_step, total_steps, f"[{style}]{icon} {step_name}[/{style}]"))
|
|
221
|
+
|
|
222
|
+
# Show chain steps
|
|
223
|
+
if needs_run:
|
|
224
|
+
show_step("P-Check gate verification", "done")
|
|
225
|
+
show_step("RunAgent — starting app", "done")
|
|
226
|
+
|
|
227
|
+
# Run via coordinator (sequential for tests — order matters)
|
|
228
|
+
results = []
|
|
229
|
+
agent_names = ", ".join(a.name.replace("Agent", "") for a in to_run)
|
|
230
|
+
lp = LiveProgress(con, title=f"Tests ({agent_names})")
|
|
231
|
+
lp.start()
|
|
232
|
+
for agent_cls in to_run:
|
|
233
|
+
short_name = agent_cls.name.replace("Agent", "")
|
|
234
|
+
show_step(f"Running {short_name}")
|
|
235
|
+
lp.set_progress(len(results) + 1, len(to_run), short_name)
|
|
236
|
+
lp.log(f" {short_name}…")
|
|
237
|
+
lp.update()
|
|
238
|
+
|
|
239
|
+
extra = {}
|
|
240
|
+
if _launcher_url:
|
|
241
|
+
extra["base_url"] = _launcher_url
|
|
242
|
+
extra["live_probe"] = True
|
|
243
|
+
inp = AgentInput(
|
|
244
|
+
root=r,
|
|
245
|
+
scope=scope,
|
|
246
|
+
brain=brain,
|
|
247
|
+
config=config,
|
|
248
|
+
extra=extra,
|
|
249
|
+
on_message=lambda n, msg, s: (lp.log(f" {msg}", s), lp.update()),
|
|
250
|
+
)
|
|
251
|
+
result = agent_cls().run(inp)
|
|
252
|
+
results.append(result)
|
|
253
|
+
|
|
254
|
+
# Per-test result lines
|
|
255
|
+
suite = result.data.get("suite", {})
|
|
256
|
+
for t in suite.get("tests", []):
|
|
257
|
+
label = t.get("name", t.get("label", "?"))
|
|
258
|
+
passed = t.get("passed", t.get("status")) in (True, "passed", "ok")
|
|
259
|
+
icon = "[#4ADE80]v[/#4ADE80]" if passed else "[#FF4D6D]x[/#FF4D6D]"
|
|
260
|
+
lp.log(f" {icon} {label}", "green" if passed else "red")
|
|
261
|
+
|
|
262
|
+
lp.update()
|
|
263
|
+
|
|
264
|
+
# Save to scan results so memory tracks test runs
|
|
265
|
+
try:
|
|
266
|
+
mem.save_scan_result(
|
|
267
|
+
agent_cls.name,
|
|
268
|
+
{
|
|
269
|
+
"status": result.status.value,
|
|
270
|
+
"duration_ms": result.duration_ms,
|
|
271
|
+
"finding_count": result.finding_count,
|
|
272
|
+
**result.data,
|
|
273
|
+
},
|
|
274
|
+
r,
|
|
275
|
+
)
|
|
276
|
+
except Exception as e:
|
|
277
|
+
_log.warning("run failed: %s", e)
|
|
278
|
+
|
|
279
|
+
total_tests = sum(len(r.data.get("suite", {}).get("tests", [])) for r in results)
|
|
280
|
+
total_passed = sum(r.data.get("suite", {}).get("passed", 0) for r in results)
|
|
281
|
+
total_failed = sum(r.data.get("suite", {}).get("failed", 0) for r in results)
|
|
282
|
+
lp.stop(summary=f"{total_tests} tests - {total_passed} passed, {total_failed} failed")
|
|
283
|
+
# Launcher teardown
|
|
284
|
+
if _launcher_url:
|
|
285
|
+
try:
|
|
286
|
+
from patchi.core.testing.app_launcher import stop
|
|
287
|
+
|
|
288
|
+
stop()
|
|
289
|
+
con.print(f"[dim]Launcher: app at {_launcher_url} stopped[/dim]")
|
|
290
|
+
except Exception as exc: # noqa: BLE001
|
|
291
|
+
_log.debug("launcher stop failed: %s", exc)
|
|
292
|
+
_show_results(results)
|
|
293
|
+
|
|
294
|
+
# Merge TEST findings through the UI verification gate before memory —
|
|
295
|
+
# only findings whose file/line check out against the codebase are saved.
|
|
296
|
+
try:
|
|
297
|
+
from patchi.core.brain.verify import verify_ui_findings
|
|
298
|
+
|
|
299
|
+
ui_dicts: list[dict] = []
|
|
300
|
+
for res in results:
|
|
301
|
+
for f in res.findings:
|
|
302
|
+
ui_dicts.append(f.to_dict() if hasattr(f, "to_dict") else dict(f))
|
|
303
|
+
kept, dropped = verify_ui_findings(r, ui_dicts)
|
|
304
|
+
mem.save_scan_result(
|
|
305
|
+
"UI",
|
|
306
|
+
{
|
|
307
|
+
"status": "ok",
|
|
308
|
+
"finding_count": len(kept),
|
|
309
|
+
"dropped_unverifiable": len(dropped),
|
|
310
|
+
"findings": kept,
|
|
311
|
+
},
|
|
312
|
+
r,
|
|
313
|
+
)
|
|
314
|
+
if dropped:
|
|
315
|
+
con.print(
|
|
316
|
+
f"[dim]UI gate: kept {len(kept)}, dropped {len(dropped)} unverifiable[/dim]"
|
|
317
|
+
)
|
|
318
|
+
except Exception as e:
|
|
319
|
+
_log.warning("UI merge failed: %s", e)
|
|
320
|
+
|
|
321
|
+
# Save to test history
|
|
322
|
+
_save_to_history(results, test_type or "default", area, r)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def run_test(args) -> None:
|
|
326
|
+
"""Namespace-shaped entry for the CLI registry (self-routing).
|
|
327
|
+
|
|
328
|
+
The framework passes the whole parsed Namespace; this replicates the exact
|
|
329
|
+
routing the legacy main.py ladder did before the migration, so `p test`
|
|
330
|
+
keeps the same behavior for every `type` value.
|
|
331
|
+
"""
|
|
332
|
+
test_type = getattr(args, "type", None)
|
|
333
|
+
if test_type == "generate":
|
|
334
|
+
run(action="generate", test_type=getattr(args, "area", None))
|
|
335
|
+
elif test_type == "report":
|
|
336
|
+
run(action="report", last_n=getattr(args, "last", 20))
|
|
337
|
+
elif test_type == "config":
|
|
338
|
+
run(
|
|
339
|
+
action="config",
|
|
340
|
+
config_action=getattr(args, "area", None),
|
|
341
|
+
key=getattr(args, "config_key", None),
|
|
342
|
+
value=getattr(args, "config_value", None),
|
|
343
|
+
)
|
|
344
|
+
else:
|
|
345
|
+
run(
|
|
346
|
+
test_type=test_type,
|
|
347
|
+
area=getattr(args, "area", None),
|
|
348
|
+
attack=getattr(args, "attack", False),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def run_generate(test_type: str | None = None, root: Path | None = None) -> None:
|
|
353
|
+
"""Entry point for `p test generate [type]`."""
|
|
354
|
+
try:
|
|
355
|
+
r = root or require_project_root()
|
|
356
|
+
except RuntimeError as e:
|
|
357
|
+
con.print(f"[red]{e}[/red]")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
from patchi.core import config as cfg
|
|
361
|
+
from patchi.core.ai.client import call_ai
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
config = cfg.load(r)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
_log.warning("run_generate failed: %s", e)
|
|
367
|
+
config = {}
|
|
368
|
+
|
|
369
|
+
# Quick availability check
|
|
370
|
+
test_response = call_ai(config, "Reply with OK", "test", max_tokens=5)
|
|
371
|
+
if not test_response:
|
|
372
|
+
con.print("[yellow]No AI configured. Set up a key with `p key add` or use Ollama.[/yellow]")
|
|
373
|
+
con.print("[dim]Tip: `p ai status` to see what's available.[/dim]")
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
con.print()
|
|
377
|
+
con.print("[bold #C8621A]Generating tests with AI…[/bold #C8621A]")
|
|
378
|
+
con.print()
|
|
379
|
+
|
|
380
|
+
# Patchi decides — not user configured: Understander + coverage gap
|
|
381
|
+
try:
|
|
382
|
+
from patchi.core.ai.tools.realize import generate_tests as _realize_gen
|
|
383
|
+
|
|
384
|
+
# Let Patchi auto-pick untested core files (Understander + body_tags)
|
|
385
|
+
auto_res = _realize_gen(r, ["auto"], test_type or "unit")
|
|
386
|
+
# auto_res already wrote .patchi/generated_tests/test_*.py
|
|
387
|
+
# Now also do AI skeleton for deeper tests on same auto-picked files
|
|
388
|
+
auto_files = [c.get("source", "") for c in auto_res.get("created", []) if c.get("status") == "created"]
|
|
389
|
+
if auto_files:
|
|
390
|
+
project_files = [p.replace(str(r) + "/", "").replace("\\", "/") for p in auto_files]
|
|
391
|
+
else:
|
|
392
|
+
# fallback to Understander core
|
|
393
|
+
from patchi.core.brain.body_tags import load_body_tags
|
|
394
|
+
from patchi.core.brain.file_corpus import FileCorpus
|
|
395
|
+
from patchi.core.brain.understander import Understander
|
|
396
|
+
|
|
397
|
+
tags = load_body_tags(r)
|
|
398
|
+
corpus = FileCorpus(r)
|
|
399
|
+
class _FI:
|
|
400
|
+
def __init__(self, p: str):
|
|
401
|
+
self.path = p
|
|
402
|
+
|
|
403
|
+
fis = [_FI(e.path) for e in corpus.files()]
|
|
404
|
+
if tags:
|
|
405
|
+
u = Understander(r, fis, tags, {}, [])
|
|
406
|
+
core = u.core_files(limit=8)
|
|
407
|
+
project_files = [c["path"] for c in core if not (r / f"tests/test_{Path(c['path']).stem}.py").exists()]
|
|
408
|
+
if not project_files:
|
|
409
|
+
project_files = [c["path"] for c in core[:3]]
|
|
410
|
+
else:
|
|
411
|
+
project_files = _discover_project_files(r)[:8]
|
|
412
|
+
except Exception as e:
|
|
413
|
+
_log.debug("Patchi auto-pick failed, fallback: %s", e)
|
|
414
|
+
project_files = _discover_project_files(r)[:8]
|
|
415
|
+
top_files = project_files
|
|
416
|
+
if not project_files:
|
|
417
|
+
con.print("[yellow]Patchi found no untested core files to generate for.[/yellow]")
|
|
418
|
+
con.print("[dim]Patchi decides coverage gap: all core files already have tests or no core detected[/dim]")
|
|
419
|
+
return
|
|
420
|
+
con.print(f"[dim]Patchi auto-picked {len(project_files)} untested core files: {', '.join(project_files[:3])}{' …' if len(project_files)>3 else ''}[/dim]")
|
|
421
|
+
scope_msg = f" for {test_type} tests" if test_type else " — Patchi decided"
|
|
422
|
+
top_files = project_files # Patchi decides, not user
|
|
423
|
+
|
|
424
|
+
# Extract AST skeletons — guaranteed real names, no hallucination
|
|
425
|
+
skeletons = []
|
|
426
|
+
for fp in top_files:
|
|
427
|
+
full = r / fp
|
|
428
|
+
try:
|
|
429
|
+
skeleton = _extract_skeleton(str(full))
|
|
430
|
+
if skeleton.strip():
|
|
431
|
+
skeletons.append(f"### {fp}\n{skeleton}")
|
|
432
|
+
except Exception as e:
|
|
433
|
+
_log.warning("run_generate failed: %s", e)
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
if not skeletons:
|
|
437
|
+
con.print("[yellow]Could not extract skeletons from any files.[/yellow]")
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
skeletons_text = "\n\n".join(skeletons)
|
|
441
|
+
|
|
442
|
+
# Smarter context: mutation survived, branch gaps, coverage, fuzz boundaries
|
|
443
|
+
extra_context = []
|
|
444
|
+
try:
|
|
445
|
+
from patchi.core import memory as _mem2
|
|
446
|
+
|
|
447
|
+
scans = _mem2.get_scan_results(r) or {}
|
|
448
|
+
mut = scans.get("MutationAgent", {}).get("findings", [])
|
|
449
|
+
if mut:
|
|
450
|
+
mut_lines = [f"- {f.get('file')}:{f.get('line')} {f.get('message','')[:80]}" for f in mut[:5]]
|
|
451
|
+
extra_context.append("MUTATION SURVIVED (must kill):\n" + "\n".join(mut_lines))
|
|
452
|
+
except Exception as _exc:
|
|
453
|
+
_log.debug('suppressed: %s', _exc)
|
|
454
|
+
try:
|
|
455
|
+
# Branch gaps: heuristic from FileCorpus
|
|
456
|
+
from patchi.core.brain.file_corpus import FileCorpus
|
|
457
|
+
|
|
458
|
+
corpus = FileCorpus(r)
|
|
459
|
+
branch_gaps = []
|
|
460
|
+
for fp in top_files:
|
|
461
|
+
try:
|
|
462
|
+
txt = (r / fp).read_text(encoding="utf-8", errors="replace")
|
|
463
|
+
branches = txt.count(" if ") + txt.count(" else") + txt.count(" ? ")
|
|
464
|
+
if branches > 6:
|
|
465
|
+
branch_gaps.append(f"- {fp}: {branches} branches")
|
|
466
|
+
except OSError:
|
|
467
|
+
pass
|
|
468
|
+
if branch_gaps:
|
|
469
|
+
extra_context.append("BRANCH GAPS (cover each if/else):\n" + "\n".join(branch_gaps[:5]))
|
|
470
|
+
except Exception as _exc:
|
|
471
|
+
_log.debug('suppressed: %s', _exc)
|
|
472
|
+
try:
|
|
473
|
+
from patchi.core.fuzz.input_fuzzer import InputFuzzer
|
|
474
|
+
|
|
475
|
+
fz = InputFuzzer(seed=42)
|
|
476
|
+
fuzz_samples = [f.to_dict() for f in fz.fuzz_string("test", count=5)]
|
|
477
|
+
extra_context.append("FUZZ BOUNDARIES (test these):\n" + "\n".join(f"- {s['label']}: {s['value']!r} ({s['strategy']})" for s in fuzz_samples[:4]))
|
|
478
|
+
except Exception as _exc:
|
|
479
|
+
_log.debug('suppressed: %s', _exc)
|
|
480
|
+
extra_block = "\n\n".join(extra_context) if extra_context else "No extra gap data — cover happy path + one edge per function."
|
|
481
|
+
if extra_block:
|
|
482
|
+
extra_block = f"\n\nSMART CONTEXT — Patchi decided what to catch:\n{extra_block}\n"
|
|
483
|
+
|
|
484
|
+
prompt = f"""Generate a pytest test file{scope_msg} for these Python modules.
|
|
485
|
+
|
|
486
|
+
MODULE SKELETONS (these are real — use ONLY these names):
|
|
487
|
+
{skeletons_text}
|
|
488
|
+
{extra_block}
|
|
489
|
+
Rules:
|
|
490
|
+
1. Import from the exact paths shown (e.g. from patchi.core.agents.base import ...)
|
|
491
|
+
2. Use ONLY the function/class names listed above — do not invent new ones
|
|
492
|
+
3. Mock call_ai, HTTP, and file I/O
|
|
493
|
+
4. Keep under 250 lines
|
|
494
|
+
5. Return ONE test file in a ```python block
|
|
495
|
+
6. NEVER use module-level `with patch(...)` blocks — use @patch decorators on test methods only
|
|
496
|
+
7. NEVER import the module under test at the top level — import inside each test method
|
|
497
|
+
8. Keep all imports inside test functions/methods to avoid side effects at collection time
|
|
498
|
+
9. SMART: For each function, add one happy path + one boundary/branch test + one mutation-killing test (use extra context above). For security-sensitive fns, add injection/encoding payload test from FUZZ BOUNDARIES.
|
|
499
|
+
10. Prioritize covering MUTATION SURVIVED and BRANCH GAPS listed — those are Patchi's priority catch list."""
|
|
500
|
+
|
|
501
|
+
from patchi.core.ai.prompts import SYSTEM_PROMPTS, Skill
|
|
502
|
+
|
|
503
|
+
system_prompt = SYSTEM_PROMPTS.get(Skill.TEST_GENERATE, "You are a test generator.")
|
|
504
|
+
|
|
505
|
+
lp = LiveProgress(con, title="Generate tests")
|
|
506
|
+
lp.start()
|
|
507
|
+
lp.log(" Generating tests via AI…")
|
|
508
|
+
lp.update()
|
|
509
|
+
try:
|
|
510
|
+
response_text = call_ai(config, system_prompt, prompt, max_tokens=4000)
|
|
511
|
+
|
|
512
|
+
if response_text:
|
|
513
|
+
code = response_text
|
|
514
|
+
if "```python" in code:
|
|
515
|
+
code = code.split("```python")[1].split("```")[0]
|
|
516
|
+
elif "```" in code:
|
|
517
|
+
code = code.split("```")[1].split("```")[0]
|
|
518
|
+
|
|
519
|
+
# Determine output path
|
|
520
|
+
if test_type:
|
|
521
|
+
out_path = r / "tests" / f"test_{test_type}_generated.py"
|
|
522
|
+
else:
|
|
523
|
+
out_path = r / "tests" / "test_generated_suite.py"
|
|
524
|
+
|
|
525
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
526
|
+
out_path.write_text(code.strip(), encoding="utf-8")
|
|
527
|
+
|
|
528
|
+
# Quick verify — no second AI call, just check if it compiles
|
|
529
|
+
err = _verify_test(code, r)
|
|
530
|
+
if err:
|
|
531
|
+
lp.log(f" [yellow]Tests written but may have issues: {err[:100]}[/yellow]")
|
|
532
|
+
lp.stop(summary="Tests generated with warnings")
|
|
533
|
+
con.print(f"[yellow]⚠ Tests written but may have issues: {err[:100]}[/yellow]")
|
|
534
|
+
else:
|
|
535
|
+
lp.log(" Tests verified and written")
|
|
536
|
+
lp.stop(summary="Tests generated and verified")
|
|
537
|
+
con.print(
|
|
538
|
+
f"[#4ADE80]✓[/#4ADE80] Tests verified and written to [bold]{out_path.relative_to(r)}[/bold]"
|
|
539
|
+
)
|
|
540
|
+
con.print("[dim]Run with: p test unit[/dim]")
|
|
541
|
+
else:
|
|
542
|
+
lp.stop(summary="AI returned empty response")
|
|
543
|
+
con.print("[yellow]AI returned empty response. Try again or check your key.[/yellow]")
|
|
544
|
+
|
|
545
|
+
except Exception as e:
|
|
546
|
+
lp.stop(summary=f"Failed: {e}")
|
|
547
|
+
con.print(f"[red]Generation failed: {e}[/red]")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def run_report(last_n: int = 20, root: Path | None = None) -> None:
|
|
551
|
+
"""Entry point for `p test report`."""
|
|
552
|
+
try:
|
|
553
|
+
r = root or require_project_root()
|
|
554
|
+
except RuntimeError as e:
|
|
555
|
+
con.print(f"[red]{e}[/red]")
|
|
556
|
+
return
|
|
557
|
+
|
|
558
|
+
from patchi.core.testing.test_config import get_test_history
|
|
559
|
+
|
|
560
|
+
history = get_test_history(r)
|
|
561
|
+
|
|
562
|
+
if not history:
|
|
563
|
+
con.print("[dim]No test runs recorded yet. Run `p test` first.[/dim]")
|
|
564
|
+
return
|
|
565
|
+
|
|
566
|
+
runs = history[-last_n:]
|
|
567
|
+
|
|
568
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
|
|
569
|
+
table.add_column("Time", style="dim", width=19)
|
|
570
|
+
table.add_column("Type", style="bold #F2EDD6", width=12)
|
|
571
|
+
table.add_column("Status", width=8)
|
|
572
|
+
table.add_column("Passed", justify="right", width=7)
|
|
573
|
+
table.add_column("Failed", justify="right", width=7)
|
|
574
|
+
table.add_column("Duration", justify="right", width=10)
|
|
575
|
+
table.add_column("Agents", style="dim", width=30)
|
|
576
|
+
|
|
577
|
+
for entry in reversed(runs):
|
|
578
|
+
ts = entry.get("timestamp", "")
|
|
579
|
+
try:
|
|
580
|
+
ts_fmt = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) if ts else "—"
|
|
581
|
+
except Exception as e:
|
|
582
|
+
_log.warning("run_report failed: %s", e)
|
|
583
|
+
ts_fmt = str(ts)[:16]
|
|
584
|
+
|
|
585
|
+
status = entry.get("status", "unknown")
|
|
586
|
+
passed = entry.get("passed", 0)
|
|
587
|
+
failed = entry.get("failed", 0)
|
|
588
|
+
dur = entry.get("duration_ms", 0)
|
|
589
|
+
agents = entry.get("agents", [])
|
|
590
|
+
test_type = entry.get("test_type", "—")
|
|
591
|
+
|
|
592
|
+
if status == "passed" or (passed > 0 and failed == 0):
|
|
593
|
+
status_text = Text("PASS", style="#4ADE80 bold")
|
|
594
|
+
elif status == "failed" or failed > 0:
|
|
595
|
+
status_text = Text("FAIL", style="#FF4D6D bold")
|
|
596
|
+
else:
|
|
597
|
+
status_text = Text(status, style="dim")
|
|
598
|
+
|
|
599
|
+
table.add_row(
|
|
600
|
+
ts_fmt,
|
|
601
|
+
test_type,
|
|
602
|
+
status_text,
|
|
603
|
+
str(passed) if passed else "—",
|
|
604
|
+
Text(str(failed), style="#FF4D6D") if failed else "—",
|
|
605
|
+
f"{dur}ms" if dur else "—",
|
|
606
|
+
", ".join(a.replace("Agent", "") for a in agents[:4])
|
|
607
|
+
+ ("…" if len(agents) > 4 else ""),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
con.print()
|
|
611
|
+
con.print(Panel(table, title=f"Test History (last {len(runs)} runs)", border_style="#C8621A"))
|
|
612
|
+
con.print()
|
|
613
|
+
|
|
614
|
+
# Coverage summary
|
|
615
|
+
from patchi.core.testing.test_config import get_coverage
|
|
616
|
+
|
|
617
|
+
cov = get_coverage(r)
|
|
618
|
+
total_runs = cov.get("total_runs", 0)
|
|
619
|
+
agents_run = cov.get("agents_run", {})
|
|
620
|
+
if total_runs:
|
|
621
|
+
con.print(f"[dim]Total runs: {total_runs}[/dim]")
|
|
622
|
+
if agents_run:
|
|
623
|
+
con.print(
|
|
624
|
+
f"[dim]Agents exercised: {', '.join(f'{k}({v})' for k, v in sorted(agents_run.items(), key=lambda x: -x[1]))}[/dim]"
|
|
625
|
+
)
|
|
626
|
+
con.print()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def run_config(
|
|
630
|
+
action: str | None = None,
|
|
631
|
+
key: str | None = None,
|
|
632
|
+
value: str | None = None,
|
|
633
|
+
root: Path | None = None,
|
|
634
|
+
) -> None:
|
|
635
|
+
"""Entry point for `p test config`."""
|
|
636
|
+
try:
|
|
637
|
+
r = root or require_project_root()
|
|
638
|
+
except RuntimeError as e:
|
|
639
|
+
con.print(f"[red]{e}[/red]")
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
from patchi.core.testing import test_config as tc
|
|
643
|
+
|
|
644
|
+
if action is None or action == "show":
|
|
645
|
+
config = tc.load(r)
|
|
646
|
+
con.print()
|
|
647
|
+
con.print(
|
|
648
|
+
Panel(
|
|
649
|
+
json.dumps(config, indent=2),
|
|
650
|
+
title="Test Configuration",
|
|
651
|
+
border_style="#C8621A",
|
|
652
|
+
subtitle=f"{tc.TESTS_CONFIG}",
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
con.print()
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
if action == "set":
|
|
659
|
+
if not key:
|
|
660
|
+
con.print("[red]Usage: p test config set <key> <value>[/red]")
|
|
661
|
+
return
|
|
662
|
+
# Try to parse as JSON for non-string values
|
|
663
|
+
parsed_value = value
|
|
664
|
+
if value:
|
|
665
|
+
try:
|
|
666
|
+
parsed_value = json.loads(value)
|
|
667
|
+
except (json.JSONDecodeError, ValueError):
|
|
668
|
+
pass
|
|
669
|
+
tc.set_value(key, parsed_value, r)
|
|
670
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] [bold]{key}[/bold] = {parsed_value}")
|
|
671
|
+
return
|
|
672
|
+
|
|
673
|
+
if action == "flows":
|
|
674
|
+
_manage_flows(key, value, r)
|
|
675
|
+
return
|
|
676
|
+
|
|
677
|
+
con.print(f"[yellow]Unknown config action: {action!r}[/yellow]")
|
|
678
|
+
con.print("[dim]Valid: show, set, flows[/dim]")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _manage_flows(sub_action: str | None, name: str | None, root: Path) -> None:
|
|
682
|
+
"""Manage custom test flows."""
|
|
683
|
+
from patchi.core.testing import test_config as tc
|
|
684
|
+
|
|
685
|
+
if sub_action == "add":
|
|
686
|
+
con.print("[dim]Add a custom test flow:[/dim]")
|
|
687
|
+
flow_name = name or input(" Flow name: ").strip()
|
|
688
|
+
if not flow_name:
|
|
689
|
+
con.print("[red]Name required.[/red]")
|
|
690
|
+
return
|
|
691
|
+
url = input(" URL path (e.g. /login): ").strip()
|
|
692
|
+
steps_raw = input(" Steps (comma-separated): ").strip()
|
|
693
|
+
steps = [s.strip() for s in steps_raw.split(",") if s.strip()]
|
|
694
|
+
tc.add_custom_flow(flow_name, url, steps, root)
|
|
695
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] Flow [bold]{flow_name}[/bold] added.")
|
|
696
|
+
|
|
697
|
+
elif sub_action == "remove":
|
|
698
|
+
if not name:
|
|
699
|
+
con.print("[red]Usage: p test config flows remove <name>[/red]")
|
|
700
|
+
return
|
|
701
|
+
removed = tc.remove_custom_flow(name, root)
|
|
702
|
+
if removed:
|
|
703
|
+
con.print(f"[#4ADE80]✓[/#4ADE80] Flow [bold]{name}[/bold] removed.")
|
|
704
|
+
else:
|
|
705
|
+
con.print(f"[yellow]Flow {name!r} not found.[/yellow]")
|
|
706
|
+
|
|
707
|
+
elif sub_action == "list" or sub_action is None:
|
|
708
|
+
config = tc.load(root)
|
|
709
|
+
flows = config.get("custom_flows", [])
|
|
710
|
+
if not flows:
|
|
711
|
+
con.print("[dim]No custom flows configured.[/dim]")
|
|
712
|
+
con.print("[dim]Add one with: p test config flows add[/dim]")
|
|
713
|
+
return
|
|
714
|
+
for f in flows:
|
|
715
|
+
con.print(f" [bold]{f['name']}[/bold] [dim]{f['url']}[/dim]")
|
|
716
|
+
if f.get("steps"):
|
|
717
|
+
con.print(f" [dim]Steps: {' → '.join(f['steps'])}[/dim]")
|
|
718
|
+
|
|
719
|
+
else:
|
|
720
|
+
con.print(f"[yellow]Unknown flows action: {sub_action!r}[/yellow]")
|
|
721
|
+
con.print("[dim]Valid: add, remove, list[/dim]")
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _import_all_agents():
|
|
728
|
+
"""Import all agent modules to trigger registration (never hand-list)."""
|
|
729
|
+
from patchi.core.agents.base import discover_agent_modules
|
|
730
|
+
|
|
731
|
+
failures = discover_agent_modules()
|
|
732
|
+
for failure in failures:
|
|
733
|
+
_log.debug("agent discovery: %s", failure)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _extract_skeleton(filepath: str) -> str:
|
|
737
|
+
"""Extract function/class signatures and imports — no bodies, no hallucination."""
|
|
738
|
+
import ast
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
742
|
+
tree = ast.parse(f.read())
|
|
743
|
+
except (SyntaxError, OSError):
|
|
744
|
+
return ""
|
|
745
|
+
|
|
746
|
+
lines = []
|
|
747
|
+
for node in ast.iter_child_nodes(tree):
|
|
748
|
+
if isinstance(node, ast.FunctionDef):
|
|
749
|
+
args = [a.arg for a in node.args.args if a.arg != "self"]
|
|
750
|
+
lines.append(f"def {node.name}({', '.join(args)}): ...")
|
|
751
|
+
elif isinstance(node, ast.AsyncFunctionDef):
|
|
752
|
+
args = [a.arg for a in node.args.args if a.arg != "self"]
|
|
753
|
+
lines.append(f"async def {node.name}({', '.join(args)}): ...")
|
|
754
|
+
elif isinstance(node, ast.ClassDef):
|
|
755
|
+
bases = [ast.unparse(b) for b in node.bases]
|
|
756
|
+
base_str = f"({', '.join(bases)})" if bases else ""
|
|
757
|
+
lines.append(f"class {node.name}{base_str}:")
|
|
758
|
+
for item in ast.iter_child_nodes(node):
|
|
759
|
+
if isinstance(item, ast.FunctionDef):
|
|
760
|
+
margs = [a.arg for a in item.args.args if a.arg != "self"]
|
|
761
|
+
lines.append(f" def {item.name}({', '.join(margs)}): ...")
|
|
762
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
763
|
+
lines.append(ast.unparse(node))
|
|
764
|
+
|
|
765
|
+
return "\n".join(lines)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _verify_test(test_code: str, root: Path) -> str | None:
|
|
769
|
+
"""Try to compile+import the test. Returns error string or None if OK."""
|
|
770
|
+
import importlib.util
|
|
771
|
+
import tempfile
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
tmp = Path(tempfile.mktemp(suffix=".py"))
|
|
775
|
+
tmp.write_text(test_code, encoding="utf-8")
|
|
776
|
+
spec = importlib.util.spec_from_file_location("_test_check", str(tmp))
|
|
777
|
+
module = importlib.util.module_from_spec(spec)
|
|
778
|
+
spec.loader.exec_module(module)
|
|
779
|
+
tmp.unlink(missing_ok=True)
|
|
780
|
+
return None
|
|
781
|
+
except (ImportError, AttributeError, SyntaxError) as e:
|
|
782
|
+
try:
|
|
783
|
+
tmp.unlink(missing_ok=True)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
_log.warning("_verify_test failed: %s", e)
|
|
786
|
+
return str(e)
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _discover_project_files(root: Path) -> list[str]:
|
|
790
|
+
"""Discover Python source files in the project."""
|
|
791
|
+
from patchi.core.agents.base import safe_rglob
|
|
792
|
+
|
|
793
|
+
files = []
|
|
794
|
+
for p in safe_rglob(root, "*.py"):
|
|
795
|
+
rel = str(p.relative_to(root))
|
|
796
|
+
if rel.startswith("tests/") or rel.startswith("test_"):
|
|
797
|
+
continue
|
|
798
|
+
if "__pycache__" in rel:
|
|
799
|
+
continue
|
|
800
|
+
files.append(rel)
|
|
801
|
+
return sorted(files)[:50]
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _save_to_history(results: list, test_type: str, area: str | None, root: Path) -> None:
|
|
805
|
+
"""Save test run to history for reporting."""
|
|
806
|
+
from patchi.core.agents.base import AgentStatus
|
|
807
|
+
from patchi.core.testing.test_config import append_test_history, update_coverage
|
|
808
|
+
|
|
809
|
+
passed = sum(1 for r in results if r.finding_count == 0 and r.status != AgentStatus.SKIPPED)
|
|
810
|
+
failed = sum(1 for r in results if r.finding_count > 0 or r.status == AgentStatus.FAILED)
|
|
811
|
+
total_dur = sum(r.duration_ms for r in results)
|
|
812
|
+
agent_names = [r.agent_name for r in results]
|
|
813
|
+
|
|
814
|
+
entry = {
|
|
815
|
+
"timestamp": time.time(),
|
|
816
|
+
"test_type": test_type,
|
|
817
|
+
"area": area,
|
|
818
|
+
"status": "passed" if failed == 0 else "failed",
|
|
819
|
+
"passed": passed,
|
|
820
|
+
"failed": failed,
|
|
821
|
+
"duration_ms": total_dur,
|
|
822
|
+
"agents": agent_names,
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
append_test_history(entry, root)
|
|
826
|
+
update_coverage(entry, root)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
# ── Results display ────────────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def _show_results(results: list) -> None:
|
|
833
|
+
from patchi.core.agents.base import AgentStatus
|
|
834
|
+
|
|
835
|
+
all_passed = all(r.finding_count == 0 for r in results if r.status != AgentStatus.SKIPPED)
|
|
836
|
+
|
|
837
|
+
con.print()
|
|
838
|
+
|
|
839
|
+
if all_passed and results:
|
|
840
|
+
con.print(
|
|
841
|
+
Panel(
|
|
842
|
+
"[bold #4ADE80]All tests passed.[/bold #4ADE80]",
|
|
843
|
+
border_style="#4ADE80",
|
|
844
|
+
padding=(0, 1),
|
|
845
|
+
)
|
|
846
|
+
)
|
|
847
|
+
for r in results:
|
|
848
|
+
suite = r.data.get("suite", {})
|
|
849
|
+
if suite:
|
|
850
|
+
con.print(
|
|
851
|
+
f" [dim]✓ {r.agent_name.replace('Agent', '')}:[/dim] "
|
|
852
|
+
f"[dim]{suite.get('passed', 0)} passed in "
|
|
853
|
+
f"{suite.get('duration_ms', r.duration_ms)}ms[/dim]"
|
|
854
|
+
)
|
|
855
|
+
con.print()
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
# Show per-agent results
|
|
859
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
|
|
860
|
+
table.add_column("Agent", style="bold #F2EDD6", width=20)
|
|
861
|
+
table.add_column("Status", width=10)
|
|
862
|
+
table.add_column("Passed", justify="right", width=8)
|
|
863
|
+
table.add_column("Failed", justify="right", width=8)
|
|
864
|
+
table.add_column("Duration", justify="right", width=10)
|
|
865
|
+
table.add_column("Note", style="dim", width=30)
|
|
866
|
+
|
|
867
|
+
for r in results:
|
|
868
|
+
suite = r.data.get("suite", {})
|
|
869
|
+
passed = suite.get("passed", r.data.get("passed", 0))
|
|
870
|
+
failed = suite.get("failed", r.data.get("failed", r.finding_count))
|
|
871
|
+
dur_ms = suite.get("duration_ms", r.duration_ms)
|
|
872
|
+
|
|
873
|
+
if r.status == AgentStatus.SKIPPED:
|
|
874
|
+
status_text = Text("skipped", style="dim")
|
|
875
|
+
elif failed > 0 or r.finding_count > 0:
|
|
876
|
+
status_text = Text("failed", style="#FF4D6D bold")
|
|
877
|
+
else:
|
|
878
|
+
status_text = Text("passed", style="#4ADE80 bold")
|
|
879
|
+
|
|
880
|
+
note = r.data.get("message", "")[:30]
|
|
881
|
+
|
|
882
|
+
table.add_row(
|
|
883
|
+
r.agent_name.replace("Agent", ""),
|
|
884
|
+
status_text,
|
|
885
|
+
str(passed) if passed else "—",
|
|
886
|
+
Text(str(failed), style="#FF4D6D") if failed else "—",
|
|
887
|
+
f"{dur_ms}ms",
|
|
888
|
+
note,
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
con.print(table)
|
|
892
|
+
|
|
893
|
+
# Show individual failures
|
|
894
|
+
sev_colors = {
|
|
895
|
+
"critical": "#FF4D6D",
|
|
896
|
+
"high": "#FF8C42",
|
|
897
|
+
"medium": "#FACC15",
|
|
898
|
+
"low": "#4ADE80",
|
|
899
|
+
}
|
|
900
|
+
for r in results:
|
|
901
|
+
if r.findings:
|
|
902
|
+
con.print()
|
|
903
|
+
con.print(f"[bold #F2EDD6]{r.agent_name.replace('Agent', '')} failures:[/bold #F2EDD6]")
|
|
904
|
+
for finding in r.findings[:8]:
|
|
905
|
+
sev = finding.severity.value
|
|
906
|
+
color = sev_colors.get(sev, "white")
|
|
907
|
+
con.print(f" [{color}]●[/{color}] {finding.message[:80]}")
|
|
908
|
+
if finding.detail:
|
|
909
|
+
for line in finding.detail.splitlines()[:3]:
|
|
910
|
+
con.print(f" [dim]{line[:80]}[/dim]")
|
|
911
|
+
|
|
912
|
+
con.print()
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
# ── Attack mode ────────────────────────────────────────────────────────────────
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _run_attack(root: Path | None) -> None:
|
|
919
|
+
"""Run the AttackAgent (Metasploit auxiliary/scanner probe)."""
|
|
920
|
+
try:
|
|
921
|
+
r = root or require_project_root()
|
|
922
|
+
except RuntimeError as e:
|
|
923
|
+
con.print(f"[red]{e}[/red]")
|
|
924
|
+
return
|
|
925
|
+
|
|
926
|
+
from patchi.core import config as cfg
|
|
927
|
+
from patchi.core import memory as mem
|
|
928
|
+
from patchi.core.agents.attack_agent import AttackAgent
|
|
929
|
+
from patchi.core.agents.base import AgentInput
|
|
930
|
+
|
|
931
|
+
try:
|
|
932
|
+
config = cfg.load(r)
|
|
933
|
+
except Exception as e:
|
|
934
|
+
_log.warning("_run_attack failed: %s", e)
|
|
935
|
+
config = {}
|
|
936
|
+
|
|
937
|
+
brain = mem.get_brain(r)
|
|
938
|
+
|
|
939
|
+
con.print()
|
|
940
|
+
con.print(
|
|
941
|
+
"[bold #C8621A]Attack Scan[/bold #C8621A] [dim]Metasploit auxiliary/scanner probes against localhost[/dim]"
|
|
942
|
+
)
|
|
943
|
+
con.print()
|
|
944
|
+
|
|
945
|
+
inp = AgentInput(
|
|
946
|
+
root=r,
|
|
947
|
+
scope=[],
|
|
948
|
+
brain=brain,
|
|
949
|
+
config=config,
|
|
950
|
+
extra={"msf_password": config.get("msf", {}).get("password", "")},
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
msf_target = config.get("msf", {}).get("target", "127.0.0.1:8000")
|
|
954
|
+
inp.extra["msf_target"] = msf_target
|
|
955
|
+
|
|
956
|
+
lp = LiveProgress(con, title="Attack probes")
|
|
957
|
+
lp.start()
|
|
958
|
+
lp.log(" Running attack probes…")
|
|
959
|
+
lp.update()
|
|
960
|
+
result = AttackAgent().run(inp)
|
|
961
|
+
lp.stop(summary=f"{result.finding_count} findings")
|
|
962
|
+
_show_attack_results(result, msf_target)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _show_attack_results(result, default_target: str = "127.0.0.1:8000") -> None:
|
|
966
|
+
"""Display Metasploit attack scan results."""
|
|
967
|
+
from patchi.core.agents.base import AgentStatus
|
|
968
|
+
|
|
969
|
+
con.print()
|
|
970
|
+
if result.status == AgentStatus.SKIPPED or result.status == AgentStatus.FAILED:
|
|
971
|
+
for err in result.errors:
|
|
972
|
+
con.print(f" [yellow]![/yellow] [dim]{err}[/dim]")
|
|
973
|
+
con.print()
|
|
974
|
+
return
|
|
975
|
+
|
|
976
|
+
target = result.data.get("target", default_target)
|
|
977
|
+
frameworks = result.data.get("frameworks", [])
|
|
978
|
+
modules_run = result.data.get("modules_selected", [])
|
|
979
|
+
|
|
980
|
+
# Info panel
|
|
981
|
+
con.print(
|
|
982
|
+
Panel(
|
|
983
|
+
f"[bold #F2EDD6]Target:[/bold #F2EDD6] [dim]{target}[/dim]\n"
|
|
984
|
+
+ (
|
|
985
|
+
f"[bold #F2EDD6]Framework:[/bold #F2EDD6] [dim]{', '.join(frameworks)}[/dim]\n"
|
|
986
|
+
if frameworks
|
|
987
|
+
else ""
|
|
988
|
+
)
|
|
989
|
+
+ f"[bold #F2EDD6]Modules:[/bold #F2EDD6] [dim]{len(modules_run)} probe(s)[/dim]\n"
|
|
990
|
+
+ f"[bold #F2EDD6]Findings:[/bold #F2EDD6] [dim]{result.finding_count}[/dim]",
|
|
991
|
+
border_style="#C8621A",
|
|
992
|
+
)
|
|
993
|
+
)
|
|
994
|
+
con.print()
|
|
995
|
+
|
|
996
|
+
if not result.findings and not result.errors:
|
|
997
|
+
con.print("[dim]No findings from Metasploit probes.[/dim]")
|
|
998
|
+
con.print()
|
|
999
|
+
return
|
|
1000
|
+
|
|
1001
|
+
# Module results table
|
|
1002
|
+
table = Table(show_header=True, header_style="bold #C8621A", box=None, pad_edge=False)
|
|
1003
|
+
table.add_column("Module", style="bold #F2EDD6", width=32)
|
|
1004
|
+
table.add_column("Severity", width=10)
|
|
1005
|
+
table.add_column("Detail", style="dim", width=60)
|
|
1006
|
+
|
|
1007
|
+
for f in result.findings:
|
|
1008
|
+
sev_colors = {
|
|
1009
|
+
"critical": "#FF4D6D",
|
|
1010
|
+
"high": "#FF8C42",
|
|
1011
|
+
"medium": "#FACC15",
|
|
1012
|
+
"low": "#4ADE80",
|
|
1013
|
+
"info": "#B8A898",
|
|
1014
|
+
}
|
|
1015
|
+
sev = f.severity.value
|
|
1016
|
+
color = sev_colors.get(sev, "white")
|
|
1017
|
+
mod_name = f.extra.get("module", f.type)
|
|
1018
|
+
detail_preview = f.detail[:60].replace("\n", " ") if f.detail else "—"
|
|
1019
|
+
table.add_row(
|
|
1020
|
+
mod_name,
|
|
1021
|
+
Text(sev, style=color),
|
|
1022
|
+
detail_preview,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
con.print(table)
|
|
1026
|
+
con.print()
|
|
1027
|
+
|
|
1028
|
+
# Show any errors
|
|
1029
|
+
if result.errors:
|
|
1030
|
+
con.print("[yellow]Probe errors:[/yellow]")
|
|
1031
|
+
for err in result.errors:
|
|
1032
|
+
con.print(f" [dim]— {err}[/dim]")
|
|
1033
|
+
con.print()
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# ── Scope helper ───────────────────────────────────────────────────────────────
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _scope_from_area(root: Path, area: str | None) -> list[str]:
|
|
1040
|
+
if not area:
|
|
1041
|
+
return []
|
|
1042
|
+
area_path = root / area
|
|
1043
|
+
if area_path.is_file():
|
|
1044
|
+
return [area]
|
|
1045
|
+
if area_path.is_dir():
|
|
1046
|
+
return [str(p.relative_to(root)) for p in area_path.rglob("*.py")]
|
|
1047
|
+
# Treat as prefix filter
|
|
1048
|
+
return [
|
|
1049
|
+
str(p.relative_to(root))
|
|
1050
|
+
for p in root.rglob("*.py")
|
|
1051
|
+
if str(p.relative_to(root)).startswith(area)
|
|
1052
|
+
]
|