alysis-code 0.13.0__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.
- alysis_code/__init__.py +3 -0
- alysis_code/__main__.py +4 -0
- alysis_code/_build_info.py +14 -0
- alysis_code/account_login.py +468 -0
- alysis_code/agent/README.md +35 -0
- alysis_code/agent/__init__.py +11 -0
- alysis_code/agent/acceptance_contract.py +2217 -0
- alysis_code/agent/blast_radius.py +1403 -0
- alysis_code/agent/cache_keepalive.py +227 -0
- alysis_code/agent/completion_certificate.py +366 -0
- alysis_code/agent/completion_gate.py +306 -0
- alysis_code/agent/empty_response_stall.py +403 -0
- alysis_code/agent/errors.py +28 -0
- alysis_code/agent/llm_calls.py +475 -0
- alysis_code/agent/mutation_classification.py +227 -0
- alysis_code/agent/prompt_context.py +2508 -0
- alysis_code/agent/read_ledger.py +253 -0
- alysis_code/agent/regression_baseline.py +642 -0
- alysis_code/agent/reproduction_first.py +610 -0
- alysis_code/agent/sensitive_output.py +629 -0
- alysis_code/agent/session.py +3218 -0
- alysis_code/agent/steering.py +191 -0
- alysis_code/agent/subagent_execution.py +5177 -0
- alysis_code/agent/subagent_workspace.py +666 -0
- alysis_code/agent/tools_assembly.py +4728 -0
- alysis_code/agent/turn/__init__.py +101 -0
- alysis_code/agent/turn/core.py +8483 -0
- alysis_code/agent/turn/events.py +113 -0
- alysis_code/agent/turn/exploration.py +590 -0
- alysis_code/agent/turn/interventions.py +65 -0
- alysis_code/agent/turn/read_cache.py +420 -0
- alysis_code/agent/turn/snapshot.py +179 -0
- alysis_code/agent/turn_contract.py +661 -0
- alysis_code/agent/turn_path.py +129 -0
- alysis_code/agent/verification.py +2885 -0
- alysis_code/agent/verification_commands.py +512 -0
- alysis_code/agent/verification_evidence.py +738 -0
- alysis_code/agent_loop.py +693 -0
- alysis_code/agent_runtimes/__init__.py +51 -0
- alysis_code/agent_runtimes/base.py +114 -0
- alysis_code/agent_runtimes/builtins.py +129 -0
- alysis_code/agent_runtimes/codex_cli.py +664 -0
- alysis_code/agent_runtimes/host.py +263 -0
- alysis_code/agent_runtimes/registry.py +64 -0
- alysis_code/agent_runtimes/service.py +150 -0
- alysis_code/agentbox_client.py +416 -0
- alysis_code/agentbox_integration.py +310 -0
- alysis_code/alysis_cloud.py +152 -0
- alysis_code/approval_scope.py +276 -0
- alysis_code/assets/README.md +33 -0
- alysis_code/assets/__init__.py +126 -0
- alysis_code/assets/asset_read_core.py +281 -0
- alysis_code/assets/budget_allocator.py +456 -0
- alysis_code/assets/comprehender.py +759 -0
- alysis_code/assets/index.py +654 -0
- alysis_code/assets/ingestion.py +275 -0
- alysis_code/assets/legacy_migration.py +413 -0
- alysis_code/assets/models.py +263 -0
- alysis_code/assets/ocr.py +239 -0
- alysis_code/assets/owl/ascii/f-000.txt +13 -0
- alysis_code/assets/owl/ascii/f-001.txt +13 -0
- alysis_code/assets/owl/ascii/f-002.txt +13 -0
- alysis_code/assets/owl/ascii/f-003.txt +13 -0
- alysis_code/assets/owl/ascii/f-004.txt +13 -0
- alysis_code/assets/owl/ascii/f-005.txt +13 -0
- alysis_code/assets/owl/ascii/f-006.txt +13 -0
- alysis_code/assets/owl/ascii/f-007.txt +13 -0
- alysis_code/assets/owl/ascii/f-008.txt +13 -0
- alysis_code/assets/owl/ascii/f-009.txt +13 -0
- alysis_code/assets/owl/ascii/f-010.txt +13 -0
- alysis_code/assets/owl/ascii/f-011.txt +13 -0
- alysis_code/assets/owl/ascii/f-012.txt +13 -0
- alysis_code/assets/owl/ascii/f-013.txt +13 -0
- alysis_code/assets/owl/ascii/f-014.txt +13 -0
- alysis_code/assets/owl/ascii/f-015.txt +13 -0
- alysis_code/assets/owl/ascii/f-016.txt +13 -0
- alysis_code/assets/owl/ascii/f-017.txt +13 -0
- alysis_code/assets/owl/ascii/f-018.txt +13 -0
- alysis_code/assets/owl/ascii/f-019.txt +13 -0
- alysis_code/assets/owl/ascii/f-020.txt +13 -0
- alysis_code/assets/owl/index.html +98 -0
- alysis_code/assets/owl/show-owl.sh +761 -0
- alysis_code/assets/paths.py +49 -0
- alysis_code/assets/plan_binding.py +326 -0
- alysis_code/assets/planner_context.py +466 -0
- alysis_code/assets/planner_tools.py +184 -0
- alysis_code/assets/prompts.py +101 -0
- alysis_code/assets/replanner_context.py +239 -0
- alysis_code/assets/surface.py +521 -0
- alysis_code/assets/untrusted_content.py +48 -0
- alysis_code/assets/usage_logger.py +94 -0
- alysis_code/assets/worker_mirror.py +428 -0
- alysis_code/assets/worker_section.py +303 -0
- alysis_code/assets/worker_tools.py +468 -0
- alysis_code/atomic_io.py +83 -0
- alysis_code/auth_diagnostics.py +272 -0
- alysis_code/background_runner.py +366 -0
- alysis_code/branding.py +270 -0
- alysis_code/budget_policy.py +390 -0
- alysis_code/build_identity.py +465 -0
- alysis_code/builtin_hooks/__init__.py +7 -0
- alysis_code/builtin_hooks/notify_done_windows.py +65 -0
- alysis_code/bwrap_etc.py +76 -0
- alysis_code/cancellation.py +41 -0
- alysis_code/capabilities.py +137 -0
- alysis_code/chatgpt_codex_static_provider.py +133 -0
- alysis_code/cli.py +51 -0
- alysis_code/cli_impl/__init__.py +1 -0
- alysis_code/cli_impl/assets_cli.py +537 -0
- alysis_code/cli_impl/assets_modal.py +412 -0
- alysis_code/cli_impl/chat/__init__.py +156 -0
- alysis_code/cli_impl/chat/commands.py +2616 -0
- alysis_code/cli_impl/chat/loop.py +4508 -0
- alysis_code/cli_impl/chat/mid_turn_policy.py +125 -0
- alysis_code/cli_impl/chat/rendering.py +444 -0
- alysis_code/cli_impl/chat/state.py +124 -0
- alysis_code/cli_impl/chat_resume.py +830 -0
- alysis_code/cli_impl/chat_slash_completer.py +258 -0
- alysis_code/cli_impl/commands/__init__.py +11 -0
- alysis_code/cli_impl/commands/_shared.py +89 -0
- alysis_code/cli_impl/commands/auth.py +623 -0
- alysis_code/cli_impl/commands/chat_resume_helpers.py +1531 -0
- alysis_code/cli_impl/commands/chat_state.py +158 -0
- alysis_code/cli_impl/commands/chat_status.py +1248 -0
- alysis_code/cli_impl/commands/chat_terminal.py +942 -0
- alysis_code/cli_impl/commands/chat_tui_panels.py +1018 -0
- alysis_code/cli_impl/commands/cli_common.py +1223 -0
- alysis_code/cli_impl/commands/cli_surface.py +77 -0
- alysis_code/cli_impl/commands/config.py +131 -0
- alysis_code/cli_impl/commands/conventions.py +85 -0
- alysis_code/cli_impl/commands/execution_helpers.py +350 -0
- alysis_code/cli_impl/commands/extensions.py +401 -0
- alysis_code/cli_impl/commands/forge.py +1282 -0
- alysis_code/cli_impl/commands/forge_asset_view.py +121 -0
- alysis_code/cli_impl/commands/forge_helpers.py +1215 -0
- alysis_code/cli_impl/commands/hooks.py +737 -0
- alysis_code/cli_impl/commands/ide_bridge.py +31 -0
- alysis_code/cli_impl/commands/mcp.py +700 -0
- alysis_code/cli_impl/commands/profile.py +453 -0
- alysis_code/cli_impl/commands/prompt_helpers.py +307 -0
- alysis_code/cli_impl/commands/report.py +88 -0
- alysis_code/cli_impl/commands/root.py +1118 -0
- alysis_code/cli_impl/commands/sandbox.py +184 -0
- alysis_code/cli_impl/commands/server.py +54 -0
- alysis_code/cli_impl/commands/sessions.py +252 -0
- alysis_code/cli_impl/commands/skills.py +404 -0
- alysis_code/cli_impl/commands/startup.py +946 -0
- alysis_code/cli_impl/commands/tools.py +335 -0
- alysis_code/cli_impl/commands/update.py +364 -0
- alysis_code/cli_impl/commands/welcome.py +972 -0
- alysis_code/cli_impl/config_menu.py +3882 -0
- alysis_code/cli_impl/forge.py +4509 -0
- alysis_code/cli_impl/forge_recovery.py +485 -0
- alysis_code/cli_impl/setup_wizard.py +2409 -0
- alysis_code/cli_impl/tui/__init__.py +58 -0
- alysis_code/cli_impl/tui/app.py +4551 -0
- alysis_code/cli_impl/tui/config.py +32 -0
- alysis_code/cli_impl/tui/config_flow.py +2754 -0
- alysis_code/cli_impl/tui/config_overlay.py +566 -0
- alysis_code/cli_impl/tui/content.py +78 -0
- alysis_code/cli_impl/tui/footer.py +218 -0
- alysis_code/cli_impl/tui/forge_status.py +136 -0
- alysis_code/cli_impl/tui/markdown.py +244 -0
- alysis_code/cli_impl/tui/owl.py +109 -0
- alysis_code/cli_impl/tui/plan_meta.py +477 -0
- alysis_code/cli_impl/tui/setup_app.py +519 -0
- alysis_code/cli_impl/tui/setup_flow.py +1622 -0
- alysis_code/cli_impl/tui/state.py +101 -0
- alysis_code/cli_impl/tui/subagent_identity.py +66 -0
- alysis_code/cli_impl/tui/subagent_panel.py +186 -0
- alysis_code/cli_impl/tui/surface.py +796 -0
- alysis_code/cli_impl/tui/transcript.py +514 -0
- alysis_code/cli_impl/tui/update_prompt.py +79 -0
- alysis_code/cli_impl/tui/workspace_guard.py +384 -0
- alysis_code/clipboard.py +172 -0
- alysis_code/code_review.py +1211 -0
- alysis_code/compaction/__init__.py +28 -0
- alysis_code/compaction/conversation_compactor.py +2932 -0
- alysis_code/compaction/importance.py +177 -0
- alysis_code/compaction/settings.py +297 -0
- alysis_code/compaction/tool_output_offload.py +447 -0
- alysis_code/config.py +3509 -0
- alysis_code/conflict_auto_resolver.py +895 -0
- alysis_code/context/__init__.py +1 -0
- alysis_code/context/tool_schema_budgeter.py +220 -0
- alysis_code/crash_diagnostics.py +282 -0
- alysis_code/custom_tools/README.md +34 -0
- alysis_code/custom_tools/__init__.py +43 -0
- alysis_code/custom_tools/discovery.py +903 -0
- alysis_code/custom_tools/runtime.py +1516 -0
- alysis_code/custom_tools/session.py +227 -0
- alysis_code/custom_tools/trust.py +232 -0
- alysis_code/diff_paths.py +113 -0
- alysis_code/direction_change.py +293 -0
- alysis_code/dispatch_timing.py +306 -0
- alysis_code/durable_service_manager.py +1236 -0
- alysis_code/edit_discipline.py +659 -0
- alysis_code/error_text.py +73 -0
- alysis_code/execution_budget.py +411 -0
- alysis_code/execution_context.py +915 -0
- alysis_code/execution_deadline.py +1065 -0
- alysis_code/execution_shared.py +1904 -0
- alysis_code/extensions/README.md +30 -0
- alysis_code/extensions/__init__.py +93 -0
- alysis_code/extensions/activation.py +138 -0
- alysis_code/extensions/install.py +1436 -0
- alysis_code/extensions/manifest.py +487 -0
- alysis_code/extensions/models.py +74 -0
- alysis_code/extensions/paths.py +56 -0
- alysis_code/extensions/registry.json +4 -0
- alysis_code/extensions/registry.py +52 -0
- alysis_code/extensions/state.py +83 -0
- alysis_code/extensions/workspace_trust.py +101 -0
- alysis_code/failed_task_evidence.py +369 -0
- alysis_code/failure_category.py +315 -0
- alysis_code/feedback_report.py +1647 -0
- alysis_code/file_classification.py +485 -0
- alysis_code/forge.py +2064 -0
- alysis_code/forge_completion.py +362 -0
- alysis_code/forge_events.py +475 -0
- alysis_code/frontmatter_utils.py +95 -0
- alysis_code/git_evidence.py +1181 -0
- alysis_code/git_ops.py +560 -0
- alysis_code/git_safe.py +62 -0
- alysis_code/git_worktrees.py +190 -0
- alysis_code/hooks/README.md +33 -0
- alysis_code/hooks/__init__.py +67 -0
- alysis_code/hooks/audit.py +171 -0
- alysis_code/hooks/config.py +225 -0
- alysis_code/hooks/dispatcher.py +1110 -0
- alysis_code/hooks/models.py +447 -0
- alysis_code/hooks/trust.py +202 -0
- alysis_code/host_actions.py +543 -0
- alysis_code/host_browser.py +103 -0
- alysis_code/ide/__init__.py +5 -0
- alysis_code/ide/activity_events.py +399 -0
- alysis_code/ide/approvals.py +337 -0
- alysis_code/ide/artifacts.py +153 -0
- alysis_code/ide/browser_egress_proxy.py +1076 -0
- alysis_code/ide/cdp_websocket_transport.py +1192 -0
- alysis_code/ide/change_ledger.py +1721 -0
- alysis_code/ide/context_blocks.py +979 -0
- alysis_code/ide/event_stream.py +531 -0
- alysis_code/ide/forge_protocol.py +3112 -0
- alysis_code/ide/forge_request_ledger.py +737 -0
- alysis_code/ide/health.py +965 -0
- alysis_code/ide/managed_browser.py +2251 -0
- alysis_code/ide/management_protocol.py +3414 -0
- alysis_code/ide/mcp_oauth_coordinator.py +744 -0
- alysis_code/ide/mcp_oauth_lifecycle.py +1504 -0
- alysis_code/ide/prompt_queue.py +1070 -0
- alysis_code/ide/protocol.py +191 -0
- alysis_code/ide/resumable_swarm.py +1543 -0
- alysis_code/ide/session_search.py +295 -0
- alysis_code/ide/stdio_bridge.py +9935 -0
- alysis_code/ide/structured_state.py +1579 -0
- alysis_code/ide/swarm_protocol.py +816 -0
- alysis_code/integration_gate.py +506 -0
- alysis_code/interactive_input_guard.py +39 -0
- alysis_code/interactive_plan_mode.py +26 -0
- alysis_code/internal_artifacts.py +179 -0
- alysis_code/knowledge_base.py +1409 -0
- alysis_code/knowledge_capture.py +1190 -0
- alysis_code/knowledge_librarian.py +605 -0
- alysis_code/language_policy.py +34 -0
- alysis_code/litellm_static_provider.py +535 -0
- alysis_code/llm/__init__.py +1 -0
- alysis_code/llm/anthropic_messages.py +2288 -0
- alysis_code/llm/base.py +71 -0
- alysis_code/llm/cache_capabilities.py +985 -0
- alysis_code/llm/cache_control_blocks.py +244 -0
- alysis_code/llm/cache_policy.py +388 -0
- alysis_code/llm/factory.py +373 -0
- alysis_code/llm/gemini_generate_content.py +2652 -0
- alysis_code/llm/gemini_interactions.py +739 -0
- alysis_code/llm/metadata.py +450 -0
- alysis_code/llm/openai_compat.py +2947 -0
- alysis_code/llm/openai_responses.py +2604 -0
- alysis_code/llm/protocols.py +609 -0
- alysis_code/llm/provider_limits.py +525 -0
- alysis_code/llm/request_plan.py +389 -0
- alysis_code/llm/request_shape.py +238 -0
- alysis_code/llm/streaming.py +108 -0
- alysis_code/llm/temperature_compat.py +78 -0
- alysis_code/llm/types.py +195 -0
- alysis_code/llm/usage_normalization.py +222 -0
- alysis_code/llm_error_display.py +315 -0
- alysis_code/logging_redaction.py +326 -0
- alysis_code/managed_host_deadline.py +196 -0
- alysis_code/mcp/README.md +33 -0
- alysis_code/mcp/__init__.py +24 -0
- alysis_code/mcp/client.py +1137 -0
- alysis_code/mcp/config.py +597 -0
- alysis_code/mcp/errors.py +113 -0
- alysis_code/mcp/forge_scope.py +154 -0
- alysis_code/mcp/jsonrpc.py +214 -0
- alysis_code/mcp/manager.py +2308 -0
- alysis_code/mcp/models.py +666 -0
- alysis_code/mcp/oauth.py +972 -0
- alysis_code/mcp/oauth_runtime.py +310 -0
- alysis_code/mcp/oauth_store.py +276 -0
- alysis_code/mcp/prompts.py +329 -0
- alysis_code/mcp/resources.py +295 -0
- alysis_code/mcp/roots.py +106 -0
- alysis_code/mcp/server_requests.py +75 -0
- alysis_code/mcp/token_store.py +859 -0
- alysis_code/mcp/transport_http.py +1338 -0
- alysis_code/mcp/transport_stdio.py +1267 -0
- alysis_code/mcp/untrusted_content.py +119 -0
- alysis_code/merge_conflict_reviewer.py +729 -0
- alysis_code/model_catalog/__init__.py +1 -0
- alysis_code/model_catalog/chatgpt_codex_subscription_snapshot.json +186 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.json +44715 -0
- alysis_code/model_catalog/litellm_model_prices_snapshot.meta.json +17 -0
- alysis_code/model_metadata_policy.py +223 -0
- alysis_code/model_metadata_utils.py +103 -0
- alysis_code/model_registry.py +1420 -0
- alysis_code/model_router.py +147 -0
- alysis_code/permission_policy.py +1016 -0
- alysis_code/personas.py +451 -0
- alysis_code/pipeline_facts.py +233 -0
- alysis_code/plan_assistant.py +4763 -0
- alysis_code/plan_mode.py +393 -0
- alysis_code/plan_reconciliation.py +1228 -0
- alysis_code/plan_repair.py +652 -0
- alysis_code/plan_validation.py +1099 -0
- alysis_code/planning_constraints.py +904 -0
- alysis_code/policy.py +95 -0
- alysis_code/preview_server.py +457 -0
- alysis_code/process_reaping.py +566 -0
- alysis_code/profile_presets.py +1834 -0
- alysis_code/profiles.py +666 -0
- alysis_code/provider_auth/__init__.py +29 -0
- alysis_code/provider_auth/base.py +99 -0
- alysis_code/provider_auth/openai_codex.py +951 -0
- alysis_code/provider_auth/registry.py +76 -0
- alysis_code/provider_auth/store.py +125 -0
- alysis_code/provider_diagnostics.py +1209 -0
- alysis_code/provider_model_catalog.py +685 -0
- alysis_code/provider_telemetry.py +1699 -0
- alysis_code/provider_url.py +75 -0
- alysis_code/reasoning_contracts.py +911 -0
- alysis_code/remote_sync.py +350 -0
- alysis_code/replanning.py +1195 -0
- alysis_code/repo_scan.py +1152 -0
- alysis_code/request_estimation.py +296 -0
- alysis_code/review_gate.py +617 -0
- alysis_code/run_lock.py +1141 -0
- alysis_code/run_outcome.py +58 -0
- alysis_code/run_provenance.py +774 -0
- alysis_code/run_state.py +445 -0
- alysis_code/runtime_artifacts.py +116 -0
- alysis_code/runtime_context_features.py +78 -0
- alysis_code/runtime_kind.py +52 -0
- alysis_code/safety/__init__.py +11 -0
- alysis_code/safety/mcp_sanitize.py +29 -0
- alysis_code/safety/safe_http.py +297 -0
- alysis_code/safety/subagent_report.py +184 -0
- alysis_code/sandbox_doctor.py +682 -0
- alysis_code/sandbox_runner.py +1025 -0
- alysis_code/sandbox_settings.py +423 -0
- alysis_code/serialized_paths.py +355 -0
- alysis_code/server/__init__.py +3 -0
- alysis_code/server/app.py +367 -0
- alysis_code/server/auth.py +34 -0
- alysis_code/server/job_config.py +30 -0
- alysis_code/server/settings.py +215 -0
- alysis_code/server/store.py +193 -0
- alysis_code/server/worker_runner.py +657 -0
- alysis_code/service_persistence.py +355 -0
- alysis_code/session_artifacts.py +108 -0
- alysis_code/session_metrics.py +331 -0
- alysis_code/session_store.py +624 -0
- alysis_code/skills/README.md +34 -0
- alysis_code/skills/__init__.py +104 -0
- alysis_code/skills/conventions.py +84 -0
- alysis_code/skills/discovery.py +176 -0
- alysis_code/skills/eval_models.py +232 -0
- alysis_code/skills/eval_runner.py +372 -0
- alysis_code/skills/evals.py +1344 -0
- alysis_code/skills/install.py +293 -0
- alysis_code/skills/loader.py +118 -0
- alysis_code/skills/matching.py +103 -0
- alysis_code/skills/models.py +71 -0
- alysis_code/skills/paths.py +56 -0
- alysis_code/skills/prompting.py +500 -0
- alysis_code/skills/scaffold.py +142 -0
- alysis_code/skills/state.py +441 -0
- alysis_code/skills/transactions.py +125 -0
- alysis_code/skills/validation.py +304 -0
- alysis_code/step_budget.py +238 -0
- alysis_code/subagent_labels.py +49 -0
- alysis_code/subagents.py +1072 -0
- alysis_code/surface/__init__.py +80 -0
- alysis_code/surface/base.py +305 -0
- alysis_code/surface/console.py +387 -0
- alysis_code/surface/events.py +372 -0
- alysis_code/surface/hidden_surface.py +529 -0
- alysis_code/surface/noop_surface.py +219 -0
- alysis_code/surface/rich_surface.py +1555 -0
- alysis_code/surface/styles.py +67 -0
- alysis_code/surface/theme.py +455 -0
- alysis_code/surface/types.py +100 -0
- alysis_code/swarm_backend.py +926 -0
- alysis_code/swarm_orchestrator.py +4020 -0
- alysis_code/swarm_scheduler.py +441 -0
- alysis_code/swarm_trace.py +429 -0
- alysis_code/swarm_worker.py +2119 -0
- alysis_code/swarm_write_guard.py +348 -0
- alysis_code/task_dependencies.py +170 -0
- alysis_code/task_readiness.py +992 -0
- alysis_code/task_scope.py +2148 -0
- alysis_code/terminal_manager.py +762 -0
- alysis_code/terminal_ownership.py +460 -0
- alysis_code/text_normalization.py +30 -0
- alysis_code/token_budget.py +97 -0
- alysis_code/tools/README.md +34 -0
- alysis_code/tools/__init__.py +1 -0
- alysis_code/tools/artifacts.py +127 -0
- alysis_code/tools/availability.py +188 -0
- alysis_code/tools/fs.py +1456 -0
- alysis_code/tools/git.py +461 -0
- alysis_code/tools/history.py +229 -0
- alysis_code/tools/http_timeout.py +78 -0
- alysis_code/tools/image_generation.py +552 -0
- alysis_code/tools/registry.py +2936 -0
- alysis_code/tools/repo_map.py +476 -0
- alysis_code/tools/search.py +563 -0
- alysis_code/tools/shell.py +135 -0
- alysis_code/tools/symbols.py +1350 -0
- alysis_code/tools/test_discovery.py +643 -0
- alysis_code/tools/web.py +482 -0
- alysis_code/tools/web_search.py +2012 -0
- alysis_code/tools/web_search_dashscope.py +557 -0
- alysis_code/tools/web_search_ddgs.py +221 -0
- alysis_code/tools/web_search_provider_adapters.py +1429 -0
- alysis_code/tools/web_search_tavily.py +194 -0
- alysis_code/updates.py +933 -0
- alysis_code/usage_tracker.py +1990 -0
- alysis_code/verification_command_analysis.py +1004 -0
- alysis_code/verification_contract.py +574 -0
- alysis_code/verification_failure_summary.py +273 -0
- alysis_code/verification_repair.py +385 -0
- alysis_code/verify_gate.py +3129 -0
- alysis_code/web_research.py +1872 -0
- alysis_code/web_search_adapters.py +66 -0
- alysis_code/web_search_policy.py +27 -0
- alysis_code/workspace_binding.py +389 -0
- alysis_code/workspace_binding_ui.py +408 -0
- alysis_code/workspace_context.py +273 -0
- alysis_code/workspace_isolation.py +138 -0
- alysis_code/workspace_provisioning.py +455 -0
- alysis_code-0.13.0.dist-info/METADATA +507 -0
- alysis_code-0.13.0.dist-info/RECORD +458 -0
- alysis_code-0.13.0.dist-info/WHEEL +4 -0
- alysis_code-0.13.0.dist-info/entry_points.txt +3 -0
- alysis_code-0.13.0.dist-info/licenses/LICENSE +176 -0
- alysis_code-0.13.0.dist-info/licenses/NOTICE +4 -0
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
"""Blast-radius regression gate (verification protocol, step 6).
|
|
2
|
+
|
|
3
|
+
Steps 2-5 all ask "is the change correct?". This step asks the question the
|
|
4
|
+
benchmark failures actually turned on: *what else did the change break?* A patch
|
|
5
|
+
that is correct in itself but edits shared code can take out hundreds of existing
|
|
6
|
+
tests, and the agent never notices because everything it chose to run still
|
|
7
|
+
passes.
|
|
8
|
+
|
|
9
|
+
The protocol adds a scope the agent did not choose:
|
|
10
|
+
|
|
11
|
+
1. from the paths the change touches, select the tests likely affected -- the
|
|
12
|
+
name-mirror test, tests that statically import the touched modules, and tests
|
|
13
|
+
in/near the touched package;
|
|
14
|
+
2. that scope must be run on the CLEAN tree (no pre-existing file modified yet).
|
|
15
|
+
Its failures are the baseline: they are not the patch's fault and are never
|
|
16
|
+
attributed to it;
|
|
17
|
+
3. after the fix verifies, the same scope runs again. Failures present now but
|
|
18
|
+
not in the baseline are regressions the change introduced;
|
|
19
|
+
4. regressions are repaired with the fix preserved -- and when a change breaks a
|
|
20
|
+
large number of baseline tests, the approach itself is over-broad, so the
|
|
21
|
+
directive escalates from "fix each failure" to "rewrite the patch narrowly";
|
|
22
|
+
5. regressions that survive the repair budget are stated in the summary. A known
|
|
23
|
+
regression is never shipped silently.
|
|
24
|
+
|
|
25
|
+
Design invariants (identical in spirit to steps 2-5):
|
|
26
|
+
|
|
27
|
+
* Everything here is pure except ``build_repo_test_index``, the single bounded
|
|
28
|
+
filesystem walk (mirroring ``reproduction_first.surviving_repro_artifacts`` and
|
|
29
|
+
``acceptance_contract``'s bounded probes). It never raises.
|
|
30
|
+
* The host never runs anything. It selects the scope, tells the agent the scope,
|
|
31
|
+
and observes what the agent actually ran -- the same observational contract the
|
|
32
|
+
rest of the verification protocol keeps.
|
|
33
|
+
* Baselines come only from runs that happened on the unpatched tree. No git state
|
|
34
|
+
is ever mutated to reconstruct one (the step-3 rule), and a run made after
|
|
35
|
+
product code changed is never graced into a baseline: the agent may already have
|
|
36
|
+
finished its fix by then, and crediting that run would mask exactly the breakage
|
|
37
|
+
this step exists to catch.
|
|
38
|
+
* Comparability is decided by what a run actually selected, not by command
|
|
39
|
+
equality: a clean whole-suite run is a universal baseline, and a failure whose
|
|
40
|
+
file the baseline never covered is reported ``unattributed`` rather than guessed
|
|
41
|
+
either way.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import math
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from enum import IntEnum, StrEnum
|
|
52
|
+
from pathlib import Path, PurePosixPath
|
|
53
|
+
from typing import Any
|
|
54
|
+
|
|
55
|
+
from ..branding import env_get
|
|
56
|
+
from .regression_baseline import TestReport, node_id_file_path
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Kill-switch + policy (mirrors the evidence-v2 / regression / repro idiom)
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _blast_radius_gate_enabled(cfg: Any | None) -> bool:
|
|
64
|
+
"""Kill-switch for the blast-radius regression gate (step 6).
|
|
65
|
+
|
|
66
|
+
``ALYSIS_BLAST_RADIUS`` (off/0/false/no/disabled) wins over the config
|
|
67
|
+
value; default is on. When off, scope runs are still captured for telemetry
|
|
68
|
+
but the turn directives and the completion-gate policy revert to legacy.
|
|
69
|
+
"""
|
|
70
|
+
env_value = env_get("ALYSIS_BLAST_RADIUS")
|
|
71
|
+
if env_value is not None:
|
|
72
|
+
normalized = str(env_value).strip().lower()
|
|
73
|
+
if normalized in {"off", "0", "false", "no", "disabled"}:
|
|
74
|
+
return False
|
|
75
|
+
if normalized in {"on", "1", "true", "yes", "enabled"}:
|
|
76
|
+
return True
|
|
77
|
+
return bool(getattr(cfg, "blast_radius_gate_enabled", True))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
#: Default ceiling on how many test files one scope may name. The scope is a
|
|
81
|
+
#: safety net, not a full suite run; past this the runtime cost stops paying for
|
|
82
|
+
#: itself and the nearest tiers already carry the signal.
|
|
83
|
+
DEFAULT_MAX_SCOPE_FILES = 40
|
|
84
|
+
#: Default wall-clock ceiling for one scope run. Exceeding it shrinks the scope
|
|
85
|
+
#: (nearest tests kept) for the next run -- it never disables the gate.
|
|
86
|
+
DEFAULT_SCOPE_SECONDS_CAP = 300.0
|
|
87
|
+
#: Default count of newly-broken baseline tests past which the change is treated
|
|
88
|
+
#: as over-broad: the patch is rewritten narrowly instead of patched up per test.
|
|
89
|
+
DEFAULT_OVER_BROAD_THRESHOLD = 20
|
|
90
|
+
#: A shrunk scope never drops below this; shrinking must not become skipping.
|
|
91
|
+
MIN_SCOPE_FILES = 1
|
|
92
|
+
#: Ceilings that keep a long turn's recorded state small.
|
|
93
|
+
MAX_SCOPE_RUNS = 40
|
|
94
|
+
MAX_LISTED_IDS = 12
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class BlastRadiusPolicy:
|
|
99
|
+
"""Resolved, clamped knobs for one turn."""
|
|
100
|
+
|
|
101
|
+
max_scope_files: int = DEFAULT_MAX_SCOPE_FILES
|
|
102
|
+
scope_seconds_cap: float = DEFAULT_SCOPE_SECONDS_CAP
|
|
103
|
+
over_broad_threshold: int = DEFAULT_OVER_BROAD_THRESHOLD
|
|
104
|
+
|
|
105
|
+
def as_payload(self) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"max_scope_files": self.max_scope_files,
|
|
108
|
+
"scope_seconds_cap": self.scope_seconds_cap,
|
|
109
|
+
"over_broad_threshold": self.over_broad_threshold,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _positive_int(value: Any, fallback: int) -> int:
|
|
114
|
+
try:
|
|
115
|
+
parsed = int(value)
|
|
116
|
+
except (TypeError, ValueError):
|
|
117
|
+
return fallback
|
|
118
|
+
return parsed if parsed > 0 else fallback
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _positive_float(value: Any, fallback: float) -> float:
|
|
122
|
+
try:
|
|
123
|
+
parsed = float(value)
|
|
124
|
+
except (TypeError, ValueError):
|
|
125
|
+
return fallback
|
|
126
|
+
if not math.isfinite(parsed) or parsed <= 0:
|
|
127
|
+
return fallback
|
|
128
|
+
return parsed
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_blast_radius_policy(cfg: Any | None) -> BlastRadiusPolicy:
|
|
132
|
+
"""Read the knobs off the config, falling back to the defaults on anything odd."""
|
|
133
|
+
return BlastRadiusPolicy(
|
|
134
|
+
max_scope_files=_positive_int(
|
|
135
|
+
getattr(cfg, "blast_radius_max_scope_files", None), DEFAULT_MAX_SCOPE_FILES
|
|
136
|
+
),
|
|
137
|
+
scope_seconds_cap=_positive_float(
|
|
138
|
+
getattr(cfg, "blast_radius_scope_seconds_cap", None), DEFAULT_SCOPE_SECONDS_CAP
|
|
139
|
+
),
|
|
140
|
+
over_broad_threshold=_positive_int(
|
|
141
|
+
getattr(cfg, "blast_radius_over_broad_threshold", None),
|
|
142
|
+
DEFAULT_OVER_BROAD_THRESHOLD,
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Paths, languages, test-file conventions
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
_IGNORED_DIR_NAMES = frozenset(
|
|
152
|
+
{
|
|
153
|
+
".git",
|
|
154
|
+
".hg",
|
|
155
|
+
".svn",
|
|
156
|
+
".venv",
|
|
157
|
+
"venv",
|
|
158
|
+
"env",
|
|
159
|
+
"node_modules",
|
|
160
|
+
"__pycache__",
|
|
161
|
+
".mypy_cache",
|
|
162
|
+
".ruff_cache",
|
|
163
|
+
".pytest_cache",
|
|
164
|
+
".tox",
|
|
165
|
+
".nox",
|
|
166
|
+
".eggs",
|
|
167
|
+
"site-packages",
|
|
168
|
+
"dist",
|
|
169
|
+
"build",
|
|
170
|
+
"target",
|
|
171
|
+
"vendor",
|
|
172
|
+
"htmlcov",
|
|
173
|
+
".idea",
|
|
174
|
+
".vscode",
|
|
175
|
+
".alysis",
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
_PY_EXTENSIONS = frozenset({".py"})
|
|
180
|
+
_JS_EXTENSIONS = frozenset({".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"})
|
|
181
|
+
_GO_EXTENSIONS = frozenset({".go"})
|
|
182
|
+
#: Source roots stripped when synthesizing a fallback dotted module name for a
|
|
183
|
+
#: namespace package (one with no ``__init__.py`` to anchor the real name).
|
|
184
|
+
_SOURCE_ROOT_NAMES = frozenset({"src", "lib", "source", "app", "packages"})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ScopeLanguage(StrEnum):
|
|
188
|
+
PYTHON = "python"
|
|
189
|
+
JAVASCRIPT = "javascript"
|
|
190
|
+
GO = "go"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
_EXTENSION_LANGUAGES = {
|
|
194
|
+
**{ext: ScopeLanguage.PYTHON for ext in _PY_EXTENSIONS},
|
|
195
|
+
**{ext: ScopeLanguage.JAVASCRIPT for ext in _JS_EXTENSIONS},
|
|
196
|
+
**{ext: ScopeLanguage.GO for ext in _GO_EXTENSIONS},
|
|
197
|
+
}
|
|
198
|
+
#: Only the pytest/unittest family produces the per-test ids the step-3 parsers
|
|
199
|
+
#: read, so only a Python scope can be diffed test-by-test. Other languages still
|
|
200
|
+
#: get a scope (the advisory is useful) but never block the gate: a gate that
|
|
201
|
+
#: cannot read its own evidence must not pretend to have any.
|
|
202
|
+
_DIFFABLE_LANGUAGES = frozenset({ScopeLanguage.PYTHON})
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def normalize_repo_path(path: str) -> str:
|
|
206
|
+
"""Repo-relative, forward-slashed, leading ``./`` and trailing ``/`` removed."""
|
|
207
|
+
cleaned = str(path or "").strip().strip("`'\"").replace("\\", "/")
|
|
208
|
+
while cleaned.startswith("./"):
|
|
209
|
+
cleaned = cleaned[2:]
|
|
210
|
+
return cleaned.strip("/")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def path_language(path: str) -> ScopeLanguage | None:
|
|
214
|
+
suffix = PurePosixPath(normalize_repo_path(path)).suffix.casefold()
|
|
215
|
+
return _EXTENSION_LANGUAGES.get(suffix)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def is_test_file(path: str) -> bool:
|
|
219
|
+
"""True when a path follows a test-file naming convention of a known runner.
|
|
220
|
+
|
|
221
|
+
Deliberately conventional rather than clever: ``test_x.py`` / ``x_test.py``
|
|
222
|
+
for Python, ``x.test.ts`` / ``x.spec.js`` (and ``__tests__/``) for JS/TS, and
|
|
223
|
+
``x_test.go`` for Go. These are the shapes the runners themselves collect by.
|
|
224
|
+
|
|
225
|
+
Kept to plain string operations: the index walk calls this once per file in the
|
|
226
|
+
repo, and building a ``PurePosixPath`` per call is the difference between a
|
|
227
|
+
tolerable index and a visible stall.
|
|
228
|
+
"""
|
|
229
|
+
normalized = normalize_repo_path(path).casefold()
|
|
230
|
+
if not normalized:
|
|
231
|
+
return False
|
|
232
|
+
head, _, name = normalized.rpartition("/")
|
|
233
|
+
dot = name.rfind(".")
|
|
234
|
+
if dot <= 0:
|
|
235
|
+
return False
|
|
236
|
+
suffix = name[dot:]
|
|
237
|
+
if suffix in _PY_EXTENSIONS:
|
|
238
|
+
return name.startswith("test_") or name.endswith("_test.py")
|
|
239
|
+
if suffix in _JS_EXTENSIONS:
|
|
240
|
+
if ".test." in name or ".spec." in name:
|
|
241
|
+
return True
|
|
242
|
+
return "__tests__" in head.split("/")
|
|
243
|
+
if suffix in _GO_EXTENSIONS:
|
|
244
|
+
return name.endswith("_test.go")
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parent_dir(path: str) -> str:
|
|
249
|
+
parent = PurePosixPath(normalize_repo_path(path)).parent.as_posix()
|
|
250
|
+
return "" if parent == "." else parent
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _shared_prefix_length(left: str, right: str) -> int:
|
|
254
|
+
left_parts = left.split("/")
|
|
255
|
+
right_parts = right.split("/")
|
|
256
|
+
shared = 0
|
|
257
|
+
for a, b in zip(left_parts, right_parts, strict=False):
|
|
258
|
+
if a != b:
|
|
259
|
+
break
|
|
260
|
+
shared += 1
|
|
261
|
+
return shared
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
# Static import scan
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
_PY_FROM_IMPORT_RE = re.compile(r"^[ \t]*from[ \t]+([.\w]+)[ \t]+import[ \t]+(.+)$", re.MULTILINE)
|
|
269
|
+
_PY_IMPORT_RE = re.compile(r"^[ \t]*import[ \t]+([.\w]+(?:[ \t]*,[ \t]*[.\w]+)*)", re.MULTILINE)
|
|
270
|
+
_PY_IMPORTED_NAME_RE = re.compile(r"[A-Za-z_]\w*")
|
|
271
|
+
_JS_IMPORT_RE = re.compile(
|
|
272
|
+
r"""(?:from|require|import)[ \t]*\(?[ \t]*['"]([^'"\n]+)['"]""",
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def extract_python_import_tokens(text: str) -> frozenset[str]:
|
|
277
|
+
"""Dotted module tokens a Python source text imports.
|
|
278
|
+
|
|
279
|
+
``from a.b import c, d`` yields ``a.b``, ``a.b.c`` and ``a.b.d`` so a test that
|
|
280
|
+
imports a symbol *out of* the touched module still matches it. Relative imports
|
|
281
|
+
(``from . import x``) carry no absolute name and are skipped rather than guessed
|
|
282
|
+
at -- a wrong guess would put an unrelated test in the scope.
|
|
283
|
+
"""
|
|
284
|
+
tokens: set[str] = set()
|
|
285
|
+
body = str(text or "")
|
|
286
|
+
for module, names in _PY_FROM_IMPORT_RE.findall(body):
|
|
287
|
+
if module.startswith("."):
|
|
288
|
+
continue
|
|
289
|
+
tokens.add(module)
|
|
290
|
+
head = names.split("#", 1)[0]
|
|
291
|
+
for name in _PY_IMPORTED_NAME_RE.findall(head):
|
|
292
|
+
if name not in {"as", "import"}:
|
|
293
|
+
tokens.add(f"{module}.{name}")
|
|
294
|
+
for group in _PY_IMPORT_RE.findall(body):
|
|
295
|
+
for raw in group.split(","):
|
|
296
|
+
module = raw.strip()
|
|
297
|
+
if module and not module.startswith("."):
|
|
298
|
+
tokens.add(module)
|
|
299
|
+
return frozenset(tokens)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def extract_js_import_tokens(text: str) -> frozenset[str]:
|
|
303
|
+
"""Module specifiers a JS/TS source text imports (``import``/``require``)."""
|
|
304
|
+
return frozenset(
|
|
305
|
+
specifier.strip()
|
|
306
|
+
for specifier in _JS_IMPORT_RE.findall(str(text or ""))
|
|
307
|
+
if specifier.strip()
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def extract_import_tokens(path: str, text: str) -> frozenset[str]:
|
|
312
|
+
language = path_language(path)
|
|
313
|
+
if language == ScopeLanguage.PYTHON:
|
|
314
|
+
return extract_python_import_tokens(text)
|
|
315
|
+
if language == ScopeLanguage.JAVASCRIPT:
|
|
316
|
+
return extract_js_import_tokens(text)
|
|
317
|
+
return frozenset()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def python_module_names(path: str, package_dirs: Iterable[str] = ()) -> tuple[str, ...]:
|
|
321
|
+
"""Dotted names under which a Python file can be imported.
|
|
322
|
+
|
|
323
|
+
The authoritative name comes from walking up while each ancestor directory is
|
|
324
|
+
a package (holds ``__init__.py``), which is what makes a ``src/`` layout resolve
|
|
325
|
+
to ``pkg.mod`` rather than ``src.pkg.mod``. Two fallbacks are added for
|
|
326
|
+
namespace packages, which have no ``__init__.py`` to anchor the walk: the full
|
|
327
|
+
path dotted, and the path with a leading source-root component stripped.
|
|
328
|
+
"""
|
|
329
|
+
normalized = normalize_repo_path(path)
|
|
330
|
+
pure = PurePosixPath(normalized)
|
|
331
|
+
if pure.suffix.casefold() != ".py":
|
|
332
|
+
return ()
|
|
333
|
+
parts = list(pure.with_suffix("").parts)
|
|
334
|
+
if parts and parts[-1] == "__init__":
|
|
335
|
+
parts = parts[:-1]
|
|
336
|
+
if not parts:
|
|
337
|
+
return ()
|
|
338
|
+
packages = {normalize_repo_path(item) for item in package_dirs}
|
|
339
|
+
dirs = parts[:-1]
|
|
340
|
+
root_index = len(dirs)
|
|
341
|
+
index = len(dirs)
|
|
342
|
+
while index > 0 and "/".join(dirs[:index]) in packages:
|
|
343
|
+
root_index = index - 1
|
|
344
|
+
index -= 1
|
|
345
|
+
|
|
346
|
+
candidates: list[list[str]] = [parts[root_index:], list(parts)]
|
|
347
|
+
stripped = list(parts)
|
|
348
|
+
while stripped and stripped[0] in _SOURCE_ROOT_NAMES:
|
|
349
|
+
stripped = stripped[1:]
|
|
350
|
+
if stripped:
|
|
351
|
+
candidates.append(stripped)
|
|
352
|
+
|
|
353
|
+
names: list[str] = []
|
|
354
|
+
for candidate in candidates:
|
|
355
|
+
if candidate and all(part.isidentifier() for part in candidate):
|
|
356
|
+
names.append(".".join(candidate))
|
|
357
|
+
return tuple(dict.fromkeys(names))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def python_import_matches(tokens: Iterable[str], module_names: Sequence[str]) -> bool:
|
|
361
|
+
"""True when any import token refers to one of ``module_names``.
|
|
362
|
+
|
|
363
|
+
Equality and prefix matching are exact. The suffix fallback (an import token
|
|
364
|
+
ending in ``.<name>``) is what catches a namespace-package layout whose real
|
|
365
|
+
dotted root we could not anchor -- restricted to multi-component names, since a
|
|
366
|
+
bare ``utils`` would otherwise match every ``anything.utils`` in the repo.
|
|
367
|
+
"""
|
|
368
|
+
token_set = {str(token).strip() for token in tokens if str(token).strip()}
|
|
369
|
+
if not token_set:
|
|
370
|
+
return False
|
|
371
|
+
for name in module_names:
|
|
372
|
+
if not name:
|
|
373
|
+
continue
|
|
374
|
+
suffix_ok = "." in name
|
|
375
|
+
for token in token_set:
|
|
376
|
+
if token == name or token.startswith(f"{name}."):
|
|
377
|
+
return True
|
|
378
|
+
if suffix_ok and token.endswith(f".{name}"):
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def js_import_matches(*, importer: str, tokens: Iterable[str], target: str) -> bool:
|
|
384
|
+
"""True when a JS/TS specifier resolves to ``target``.
|
|
385
|
+
|
|
386
|
+
Relative specifiers are resolved against the importing file's directory, which
|
|
387
|
+
is exact. Bare/aliased specifiers (``@/utils/foo``) fall back to a path-suffix
|
|
388
|
+
match after dropping the alias component.
|
|
389
|
+
"""
|
|
390
|
+
target_path = normalize_repo_path(target)
|
|
391
|
+
if not target_path:
|
|
392
|
+
return False
|
|
393
|
+
target_stem = target_path.rsplit(".", 1)[0]
|
|
394
|
+
importer_dir = _parent_dir(importer)
|
|
395
|
+
for raw in tokens:
|
|
396
|
+
specifier = str(raw or "").strip()
|
|
397
|
+
if not specifier:
|
|
398
|
+
continue
|
|
399
|
+
if specifier.startswith("."):
|
|
400
|
+
base = f"{importer_dir}/{specifier}" if importer_dir else specifier
|
|
401
|
+
try:
|
|
402
|
+
resolved = normalize_repo_path(PurePosixPath(base).as_posix())
|
|
403
|
+
except ValueError: # pragma: no cover - defensive
|
|
404
|
+
continue
|
|
405
|
+
resolved = _collapse_relative(resolved)
|
|
406
|
+
if resolved and (resolved == target_stem or resolved == target_path):
|
|
407
|
+
return True
|
|
408
|
+
continue
|
|
409
|
+
cleaned = specifier.lstrip("@~").lstrip("/")
|
|
410
|
+
cleaned = cleaned.split("?", 1)[0]
|
|
411
|
+
if not cleaned or "/" not in cleaned:
|
|
412
|
+
continue
|
|
413
|
+
stem = cleaned.rsplit(".", 1)[0] if "." in cleaned.rsplit("/", 1)[-1] else cleaned
|
|
414
|
+
if target_stem.endswith(f"/{stem}") or target_stem == stem:
|
|
415
|
+
return True
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _collapse_relative(path: str) -> str:
|
|
420
|
+
parts: list[str] = []
|
|
421
|
+
for part in path.split("/"):
|
|
422
|
+
if part in {"", "."}:
|
|
423
|
+
continue
|
|
424
|
+
if part == "..":
|
|
425
|
+
if parts:
|
|
426
|
+
parts.pop()
|
|
427
|
+
continue
|
|
428
|
+
parts.append(part)
|
|
429
|
+
return "/".join(parts)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------------------
|
|
433
|
+
# Repo index (the one filesystem-touching helper)
|
|
434
|
+
# ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
#: Bounds on the index walk. A repo larger than these is indexed partially and
|
|
437
|
+
#: says so (``truncated``) rather than silently selecting from half a repo.
|
|
438
|
+
MAX_INDEXED_FILES = 40_000
|
|
439
|
+
MAX_INDEXED_TEST_FILES = 4_000
|
|
440
|
+
MAX_IMPORT_SCANS = 1_500
|
|
441
|
+
MAX_IMPORT_SCAN_BYTES = 16_384
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass(frozen=True)
|
|
445
|
+
class RepoTestIndex:
|
|
446
|
+
"""A bounded snapshot of the repo's test surface, taken once per turn."""
|
|
447
|
+
|
|
448
|
+
test_files: tuple[str, ...] = ()
|
|
449
|
+
imports: Mapping[str, frozenset[str]] = field(default_factory=dict)
|
|
450
|
+
package_dirs: frozenset[str] = frozenset()
|
|
451
|
+
truncated: bool = False
|
|
452
|
+
import_scan_truncated: bool = False
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def empty(self) -> bool:
|
|
456
|
+
return not self.test_files
|
|
457
|
+
|
|
458
|
+
def as_payload(self) -> dict[str, Any]:
|
|
459
|
+
return {
|
|
460
|
+
"test_file_count": len(self.test_files),
|
|
461
|
+
"package_dir_count": len(self.package_dirs),
|
|
462
|
+
"truncated": self.truncated,
|
|
463
|
+
"import_scan_truncated": self.import_scan_truncated,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
EMPTY_REPO_TEST_INDEX = RepoTestIndex()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def build_repo_test_index(
|
|
471
|
+
root: Path,
|
|
472
|
+
*,
|
|
473
|
+
max_files: int = MAX_INDEXED_FILES,
|
|
474
|
+
max_test_files: int = MAX_INDEXED_TEST_FILES,
|
|
475
|
+
max_import_scans: int = MAX_IMPORT_SCANS,
|
|
476
|
+
) -> RepoTestIndex:
|
|
477
|
+
"""Walk ``root`` once, collecting test files, their imports and package dirs.
|
|
478
|
+
|
|
479
|
+
Ignored trees are pruned rather than filtered afterwards: a ``.venv`` or
|
|
480
|
+
``node_modules`` can hold more files than the whole repo, and descending into one
|
|
481
|
+
before discarding it would put a multi-second stall in the middle of a turn.
|
|
482
|
+
|
|
483
|
+
Bounded on every axis (files walked, test files kept, files whose imports are
|
|
484
|
+
read, bytes read per file) and never raises: an unreadable tree yields an empty
|
|
485
|
+
index and the gate simply does not apply. Imports are read from the head of each
|
|
486
|
+
file, where import statements live. Directory order is sorted, so the same repo
|
|
487
|
+
always yields the same index.
|
|
488
|
+
"""
|
|
489
|
+
root_path = Path(root)
|
|
490
|
+
test_files: list[str] = []
|
|
491
|
+
package_dirs: set[str] = set()
|
|
492
|
+
scanned = 0
|
|
493
|
+
truncated = False
|
|
494
|
+
try:
|
|
495
|
+
walker = os.walk(root_path, onerror=None)
|
|
496
|
+
for current, dirnames, filenames in walker:
|
|
497
|
+
dirnames[:] = sorted(name for name in dirnames if name not in _IGNORED_DIR_NAMES)
|
|
498
|
+
try:
|
|
499
|
+
prefix = Path(current).relative_to(root_path).as_posix()
|
|
500
|
+
except ValueError:
|
|
501
|
+
dirnames[:] = []
|
|
502
|
+
continue
|
|
503
|
+
prefix = "" if prefix == "." else prefix
|
|
504
|
+
if scanned >= max_files:
|
|
505
|
+
truncated = True
|
|
506
|
+
break
|
|
507
|
+
for name in sorted(filenames):
|
|
508
|
+
if scanned >= max_files:
|
|
509
|
+
truncated = True
|
|
510
|
+
break
|
|
511
|
+
scanned += 1
|
|
512
|
+
relative = f"{prefix}/{name}" if prefix else name
|
|
513
|
+
if name == "__init__.py":
|
|
514
|
+
package_dirs.add(prefix)
|
|
515
|
+
if is_test_file(relative):
|
|
516
|
+
if len(test_files) >= max_test_files:
|
|
517
|
+
truncated = True
|
|
518
|
+
continue
|
|
519
|
+
test_files.append(relative)
|
|
520
|
+
except (OSError, ValueError):
|
|
521
|
+
return EMPTY_REPO_TEST_INDEX
|
|
522
|
+
|
|
523
|
+
imports: dict[str, frozenset[str]] = {}
|
|
524
|
+
import_scan_truncated = False
|
|
525
|
+
for relative in test_files:
|
|
526
|
+
if len(imports) >= max_import_scans:
|
|
527
|
+
import_scan_truncated = True
|
|
528
|
+
break
|
|
529
|
+
language = path_language(relative)
|
|
530
|
+
if language not in {ScopeLanguage.PYTHON, ScopeLanguage.JAVASCRIPT}:
|
|
531
|
+
continue
|
|
532
|
+
try:
|
|
533
|
+
with (root_path / relative).open("rb") as handle:
|
|
534
|
+
head = handle.read(MAX_IMPORT_SCAN_BYTES).decode("utf-8", "ignore")
|
|
535
|
+
except OSError:
|
|
536
|
+
continue
|
|
537
|
+
imports[relative] = extract_import_tokens(relative, head)
|
|
538
|
+
|
|
539
|
+
return RepoTestIndex(
|
|
540
|
+
test_files=tuple(test_files),
|
|
541
|
+
imports=imports,
|
|
542
|
+
package_dirs=frozenset(package_dirs),
|
|
543
|
+
truncated=truncated,
|
|
544
|
+
import_scan_truncated=import_scan_truncated,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# ---------------------------------------------------------------------------
|
|
549
|
+
# Scope selection (pure)
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
class ScopeTier(IntEnum):
|
|
554
|
+
"""Proximity of a test file to the change. Lower is nearer."""
|
|
555
|
+
|
|
556
|
+
#: The touched file *is* a test file, or a test file named after it.
|
|
557
|
+
MIRROR = 0
|
|
558
|
+
#: The test file statically imports a touched module.
|
|
559
|
+
IMPORTER = 1
|
|
560
|
+
#: The test file sits in the same directory as a touched file.
|
|
561
|
+
SIBLING = 2
|
|
562
|
+
#: The test file is under the touched package, or its mirrored test package.
|
|
563
|
+
PACKAGE = 3
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
_TIER_REASONS = {
|
|
567
|
+
ScopeTier.MIRROR: "test file mirrors a touched source file",
|
|
568
|
+
ScopeTier.IMPORTER: "test file imports a touched module",
|
|
569
|
+
ScopeTier.SIBLING: "test file sits beside a touched file",
|
|
570
|
+
ScopeTier.PACKAGE: "test file is under a touched package",
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@dataclass(frozen=True)
|
|
575
|
+
class ScopeEntry:
|
|
576
|
+
path: str
|
|
577
|
+
tier: ScopeTier
|
|
578
|
+
anchor: str = ""
|
|
579
|
+
|
|
580
|
+
@property
|
|
581
|
+
def reason(self) -> str:
|
|
582
|
+
return _TIER_REASONS.get(self.tier, "selected by proximity")
|
|
583
|
+
|
|
584
|
+
def as_payload(self) -> dict[str, Any]:
|
|
585
|
+
return {
|
|
586
|
+
"path": self.path,
|
|
587
|
+
"tier": int(self.tier),
|
|
588
|
+
"tier_name": self.tier.name.casefold(),
|
|
589
|
+
"anchor": self.anchor,
|
|
590
|
+
"reason": self.reason,
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@dataclass(frozen=True)
|
|
595
|
+
class BlastRadiusScope:
|
|
596
|
+
"""The test scope selected for one set of touched paths."""
|
|
597
|
+
|
|
598
|
+
entries: tuple[ScopeEntry, ...] = ()
|
|
599
|
+
language: ScopeLanguage | None = None
|
|
600
|
+
touched_paths: tuple[str, ...] = ()
|
|
601
|
+
dropped_for_cap: tuple[str, ...] = ()
|
|
602
|
+
dropped_for_runtime: tuple[str, ...] = ()
|
|
603
|
+
shrink_rounds: int = 0
|
|
604
|
+
index_truncated: bool = False
|
|
605
|
+
|
|
606
|
+
@property
|
|
607
|
+
def paths(self) -> tuple[str, ...]:
|
|
608
|
+
return tuple(entry.path for entry in self.entries)
|
|
609
|
+
|
|
610
|
+
@property
|
|
611
|
+
def empty(self) -> bool:
|
|
612
|
+
return not self.entries
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def diffable(self) -> bool:
|
|
616
|
+
"""True when a run of this scope produces per-test ids the gate can diff."""
|
|
617
|
+
return bool(self.entries) and self.language in _DIFFABLE_LANGUAGES
|
|
618
|
+
|
|
619
|
+
def suggested_command(self) -> str:
|
|
620
|
+
"""A concrete command for the scope, for the advisory text.
|
|
621
|
+
|
|
622
|
+
Advisory only: coverage is judged by which test files a run actually
|
|
623
|
+
selected, never by matching this string, so a project whose runner differs
|
|
624
|
+
can run the same files any way it likes.
|
|
625
|
+
"""
|
|
626
|
+
if self.empty:
|
|
627
|
+
return ""
|
|
628
|
+
joined = " ".join(self.paths)
|
|
629
|
+
if self.language == ScopeLanguage.PYTHON:
|
|
630
|
+
return f"python -m pytest {joined} -q"
|
|
631
|
+
if self.language == ScopeLanguage.GO:
|
|
632
|
+
packages = sorted({f"./{_parent_dir(path)}".rstrip("/") or "." for path in self.paths})
|
|
633
|
+
return f"go test {' '.join(packages)}"
|
|
634
|
+
return joined
|
|
635
|
+
|
|
636
|
+
def as_payload(self) -> dict[str, Any]:
|
|
637
|
+
return {
|
|
638
|
+
"entries": [entry.as_payload() for entry in self.entries],
|
|
639
|
+
"paths": list(self.paths),
|
|
640
|
+
"language": self.language.value if self.language is not None else "",
|
|
641
|
+
"diffable": self.diffable,
|
|
642
|
+
"touched_paths": list(self.touched_paths),
|
|
643
|
+
"dropped_for_cap": list(self.dropped_for_cap),
|
|
644
|
+
"dropped_for_runtime": list(self.dropped_for_runtime),
|
|
645
|
+
"shrink_rounds": self.shrink_rounds,
|
|
646
|
+
"index_truncated": self.index_truncated,
|
|
647
|
+
"suggested_command": self.suggested_command(),
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
EMPTY_SCOPE = BlastRadiusScope()
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _mirror_names(touched: str) -> frozenset[str]:
|
|
655
|
+
pure = PurePosixPath(touched)
|
|
656
|
+
stem = pure.stem
|
|
657
|
+
suffix = pure.suffix.casefold()
|
|
658
|
+
if suffix in _PY_EXTENSIONS:
|
|
659
|
+
return frozenset({f"test_{stem}.py", f"{stem}_test.py"})
|
|
660
|
+
if suffix in _GO_EXTENSIONS:
|
|
661
|
+
return frozenset({f"{stem}_test.go"})
|
|
662
|
+
if suffix in _JS_EXTENSIONS:
|
|
663
|
+
return frozenset(
|
|
664
|
+
{f"{stem}.test{ext}" for ext in _JS_EXTENSIONS}
|
|
665
|
+
| {f"{stem}.spec{ext}" for ext in _JS_EXTENSIONS}
|
|
666
|
+
)
|
|
667
|
+
return frozenset()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _package_relative_dir(touched: str, package_dirs: frozenset[str]) -> str:
|
|
671
|
+
"""The touched file's directory, relative to the top of its package.
|
|
672
|
+
|
|
673
|
+
``src/pkg/sub/mod.py`` -> ``pkg/sub``, so a mirrored ``tests/pkg/sub`` matches by
|
|
674
|
+
suffix without needing to know the project's test-directory convention.
|
|
675
|
+
"""
|
|
676
|
+
directory = _parent_dir(touched)
|
|
677
|
+
if not directory:
|
|
678
|
+
return ""
|
|
679
|
+
parts = directory.split("/")
|
|
680
|
+
index = len(parts)
|
|
681
|
+
root_index = len(parts)
|
|
682
|
+
while index > 0 and "/".join(parts[:index]) in package_dirs:
|
|
683
|
+
root_index = index - 1
|
|
684
|
+
index -= 1
|
|
685
|
+
return "/".join(parts[root_index:])
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _tier_for(
|
|
689
|
+
*,
|
|
690
|
+
test_path: str,
|
|
691
|
+
touched: str,
|
|
692
|
+
index: RepoTestIndex,
|
|
693
|
+
module_names: Sequence[str],
|
|
694
|
+
mirror_names: frozenset[str],
|
|
695
|
+
package_relative_dir: str,
|
|
696
|
+
) -> ScopeTier | None:
|
|
697
|
+
if test_path == touched:
|
|
698
|
+
return ScopeTier.MIRROR
|
|
699
|
+
test_name = PurePosixPath(test_path).name
|
|
700
|
+
if test_name in mirror_names:
|
|
701
|
+
return ScopeTier.MIRROR
|
|
702
|
+
tokens = index.imports.get(test_path)
|
|
703
|
+
if tokens:
|
|
704
|
+
language = path_language(touched)
|
|
705
|
+
if language == ScopeLanguage.PYTHON and module_names:
|
|
706
|
+
if python_import_matches(tokens, module_names):
|
|
707
|
+
return ScopeTier.IMPORTER
|
|
708
|
+
elif language == ScopeLanguage.JAVASCRIPT and js_import_matches(
|
|
709
|
+
importer=test_path, tokens=tokens, target=touched
|
|
710
|
+
):
|
|
711
|
+
return ScopeTier.IMPORTER
|
|
712
|
+
touched_dir = _parent_dir(touched)
|
|
713
|
+
test_dir = _parent_dir(test_path)
|
|
714
|
+
if touched_dir and test_dir == touched_dir:
|
|
715
|
+
return ScopeTier.SIBLING
|
|
716
|
+
if touched_dir and test_dir.startswith(f"{touched_dir}/"):
|
|
717
|
+
return ScopeTier.PACKAGE
|
|
718
|
+
if package_relative_dir and (
|
|
719
|
+
test_dir == package_relative_dir or test_dir.endswith(f"/{package_relative_dir}")
|
|
720
|
+
):
|
|
721
|
+
return ScopeTier.PACKAGE
|
|
722
|
+
return None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _dominant_language(
|
|
726
|
+
ranked: Sequence[tuple[ScopeTier, str, str]],
|
|
727
|
+
) -> ScopeLanguage | None:
|
|
728
|
+
"""The language the scope is run as: most-represented, Python winning ties.
|
|
729
|
+
|
|
730
|
+
A scope has to be runnable by one runner, so a change spanning languages picks
|
|
731
|
+
one. Python wins ties because it is the only language whose runs the gate can
|
|
732
|
+
diff test-by-test.
|
|
733
|
+
"""
|
|
734
|
+
counts: dict[ScopeLanguage, int] = {}
|
|
735
|
+
for _tier, path, _anchor in ranked:
|
|
736
|
+
language = path_language(path)
|
|
737
|
+
if language is not None:
|
|
738
|
+
counts[language] = counts.get(language, 0) + 1
|
|
739
|
+
if not counts:
|
|
740
|
+
return None
|
|
741
|
+
return min(
|
|
742
|
+
counts,
|
|
743
|
+
key=lambda language: (-counts[language], language != ScopeLanguage.PYTHON, language.value),
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def select_blast_radius_scope(
|
|
748
|
+
*,
|
|
749
|
+
touched_paths: Iterable[str],
|
|
750
|
+
index: RepoTestIndex,
|
|
751
|
+
policy: BlastRadiusPolicy | None = None,
|
|
752
|
+
) -> BlastRadiusScope:
|
|
753
|
+
"""Select the tests likely affected by ``touched_paths``. Pure and deterministic.
|
|
754
|
+
|
|
755
|
+
Each candidate takes its *best* (nearest) tier over all touched paths, and the
|
|
756
|
+
scope is ordered nearest-first so the runtime cap and any later shrink both drop
|
|
757
|
+
the weakest evidence first.
|
|
758
|
+
"""
|
|
759
|
+
resolved_policy = policy or BlastRadiusPolicy()
|
|
760
|
+
# Only source files in a language we understand have a blast radius we can
|
|
761
|
+
# reason about. A README or a data fixture would otherwise drag in every test
|
|
762
|
+
# sharing its directory on proximity alone, which is noise, not evidence.
|
|
763
|
+
touched = tuple(
|
|
764
|
+
dict.fromkeys(
|
|
765
|
+
normalized
|
|
766
|
+
for normalized in (normalize_repo_path(item) for item in touched_paths)
|
|
767
|
+
if normalized and path_language(normalized) is not None
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
if not touched or index.empty:
|
|
771
|
+
return BlastRadiusScope(touched_paths=touched, index_truncated=index.truncated)
|
|
772
|
+
|
|
773
|
+
best: dict[str, tuple[ScopeTier, str]] = {}
|
|
774
|
+
for item in touched:
|
|
775
|
+
module_names = python_module_names(item, index.package_dirs)
|
|
776
|
+
mirror_names = _mirror_names(item)
|
|
777
|
+
package_relative_dir = _package_relative_dir(item, index.package_dirs)
|
|
778
|
+
for test_path in index.test_files:
|
|
779
|
+
tier = _tier_for(
|
|
780
|
+
test_path=test_path,
|
|
781
|
+
touched=item,
|
|
782
|
+
index=index,
|
|
783
|
+
module_names=module_names,
|
|
784
|
+
mirror_names=mirror_names,
|
|
785
|
+
package_relative_dir=package_relative_dir,
|
|
786
|
+
)
|
|
787
|
+
if tier is None:
|
|
788
|
+
continue
|
|
789
|
+
current = best.get(test_path)
|
|
790
|
+
if current is None or tier < current[0]:
|
|
791
|
+
best[test_path] = (tier, item)
|
|
792
|
+
|
|
793
|
+
ranked = sorted(
|
|
794
|
+
((tier, path, anchor) for path, (tier, anchor) in best.items()),
|
|
795
|
+
key=lambda item: (
|
|
796
|
+
int(item[0]),
|
|
797
|
+
-max((_shared_prefix_length(item[1], touch) for touch in touched), default=0),
|
|
798
|
+
item[1],
|
|
799
|
+
),
|
|
800
|
+
)
|
|
801
|
+
language = _dominant_language(ranked)
|
|
802
|
+
ranked = [item for item in ranked if language is None or path_language(item[1]) == language]
|
|
803
|
+
|
|
804
|
+
kept = ranked[: resolved_policy.max_scope_files]
|
|
805
|
+
dropped = [path for _tier, path, _anchor in ranked[resolved_policy.max_scope_files :]]
|
|
806
|
+
return BlastRadiusScope(
|
|
807
|
+
entries=tuple(
|
|
808
|
+
ScopeEntry(path=path, tier=tier, anchor=anchor) for tier, path, anchor in kept
|
|
809
|
+
),
|
|
810
|
+
language=language,
|
|
811
|
+
touched_paths=touched,
|
|
812
|
+
dropped_for_cap=tuple(dropped),
|
|
813
|
+
index_truncated=index.truncated,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def shrink_scope_once(scope: BlastRadiusScope) -> BlastRadiusScope | None:
|
|
818
|
+
"""One shrink step, nearest tests kept. ``None`` when it cannot shrink further.
|
|
819
|
+
|
|
820
|
+
Shrinking drops the widest proximity tier present; when every entry shares one
|
|
821
|
+
tier there is nothing to drop by proximity, so it halves the list instead. It
|
|
822
|
+
never returns an empty scope -- a scope too slow to run whole still runs its
|
|
823
|
+
nearest test, because the alternative is shipping with no blast-radius evidence
|
|
824
|
+
at all.
|
|
825
|
+
"""
|
|
826
|
+
if scope.empty or len(scope.entries) <= MIN_SCOPE_FILES:
|
|
827
|
+
return None
|
|
828
|
+
widest = max(entry.tier for entry in scope.entries)
|
|
829
|
+
kept = [entry for entry in scope.entries if entry.tier < widest]
|
|
830
|
+
if not kept:
|
|
831
|
+
kept = list(scope.entries[: max(MIN_SCOPE_FILES, len(scope.entries) // 2)])
|
|
832
|
+
kept_paths = {entry.path for entry in kept}
|
|
833
|
+
dropped = tuple(entry.path for entry in scope.entries if entry.path not in kept_paths)
|
|
834
|
+
return BlastRadiusScope(
|
|
835
|
+
entries=tuple(kept),
|
|
836
|
+
language=scope.language,
|
|
837
|
+
touched_paths=scope.touched_paths,
|
|
838
|
+
dropped_for_cap=scope.dropped_for_cap,
|
|
839
|
+
dropped_for_runtime=tuple(dict.fromkeys((*scope.dropped_for_runtime, *dropped))),
|
|
840
|
+
shrink_rounds=scope.shrink_rounds + 1,
|
|
841
|
+
index_truncated=scope.index_truncated,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def shrink_scope_for_runtime(
|
|
846
|
+
scope: BlastRadiusScope,
|
|
847
|
+
*,
|
|
848
|
+
observed_seconds: float,
|
|
849
|
+
policy: BlastRadiusPolicy | None = None,
|
|
850
|
+
) -> BlastRadiusScope | None:
|
|
851
|
+
"""Shrink an over-budget scope, nearest tests kept. ``None`` when no shrink is due."""
|
|
852
|
+
resolved_policy = policy or BlastRadiusPolicy()
|
|
853
|
+
if scope.empty or observed_seconds <= resolved_policy.scope_seconds_cap:
|
|
854
|
+
return None
|
|
855
|
+
return shrink_scope_once(scope)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def apply_scope_shrink_rounds(scope: BlastRadiusScope, rounds: int) -> BlastRadiusScope:
|
|
859
|
+
"""Re-apply ``rounds`` shrink steps to a freshly selected scope.
|
|
860
|
+
|
|
861
|
+
The scope is re-selected whenever the change touches more files, which would
|
|
862
|
+
otherwise undo a shrink the runtime cap had already forced and quietly hand back
|
|
863
|
+
a scope known to be too slow. Carrying the round count forward keeps the cap's
|
|
864
|
+
decision in force across re-selection.
|
|
865
|
+
"""
|
|
866
|
+
for _ in range(max(0, int(rounds))):
|
|
867
|
+
smaller = shrink_scope_once(scope)
|
|
868
|
+
if smaller is None:
|
|
869
|
+
break
|
|
870
|
+
scope = smaller
|
|
871
|
+
return scope
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
# ---------------------------------------------------------------------------
|
|
875
|
+
# Observed scope runs (facts)
|
|
876
|
+
# ---------------------------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
class ScopePhase(StrEnum):
|
|
880
|
+
"""When a test run happened relative to the first change to existing code."""
|
|
881
|
+
|
|
882
|
+
#: Ran while no pre-existing repo path had been modified -- the clean tree.
|
|
883
|
+
BASELINE = "baseline"
|
|
884
|
+
#: Ran after existing code had already been changed.
|
|
885
|
+
GATE = "gate"
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
_COMMAND_TOKEN_SPLIT_RE = re.compile(r"[\s;|&()<>]+")
|
|
889
|
+
_TOKEN_TRAILING_JUNK = ",;:'\"`)]}"
|
|
890
|
+
_SELECTOR_EXTENSIONS = frozenset(_PY_EXTENSIONS | _JS_EXTENSIONS | _GO_EXTENSIONS)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def command_path_selectors(command: str) -> tuple[str, ...]:
|
|
894
|
+
"""The test paths/directories a command explicitly selected.
|
|
895
|
+
|
|
896
|
+
An empty result means the command named no paths -- a whole-suite run, which
|
|
897
|
+
covers every scope. Node-id suffixes (``file.py::test``) reduce to their file,
|
|
898
|
+
and flag values (``-k expr``, ``--maxfail=2``) are not paths so they drop out.
|
|
899
|
+
"""
|
|
900
|
+
selectors: list[str] = []
|
|
901
|
+
for raw in _COMMAND_TOKEN_SPLIT_RE.split(str(command or "")):
|
|
902
|
+
token = raw.strip().strip("`'\"").rstrip(_TOKEN_TRAILING_JUNK)
|
|
903
|
+
if not token or token.startswith("-"):
|
|
904
|
+
continue
|
|
905
|
+
token = token.split("::", 1)[0]
|
|
906
|
+
normalized = normalize_repo_path(token)
|
|
907
|
+
if not normalized:
|
|
908
|
+
continue
|
|
909
|
+
pure = PurePosixPath(normalized)
|
|
910
|
+
if pure.suffix.casefold() in _SELECTOR_EXTENSIONS:
|
|
911
|
+
selectors.append(normalized)
|
|
912
|
+
elif "/" in normalized and "." not in pure.name:
|
|
913
|
+
# A directory selector (``pytest tests/unit``). A bare word is not
|
|
914
|
+
# treated as one: it is far more likely a subcommand or an -k value.
|
|
915
|
+
selectors.append(normalized)
|
|
916
|
+
return tuple(dict.fromkeys(selectors))
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def selection_covers(selectors: Sequence[str], path: str) -> bool:
|
|
920
|
+
"""True when ``path`` was inside what a run selected (no selectors = whole suite)."""
|
|
921
|
+
if not selectors:
|
|
922
|
+
return True
|
|
923
|
+
normalized = normalize_repo_path(path)
|
|
924
|
+
if not normalized:
|
|
925
|
+
return False
|
|
926
|
+
for selector in selectors:
|
|
927
|
+
clean = normalize_repo_path(selector)
|
|
928
|
+
if not clean:
|
|
929
|
+
continue
|
|
930
|
+
if normalized == clean or normalized.startswith(f"{clean}/"):
|
|
931
|
+
return True
|
|
932
|
+
return False
|
|
933
|
+
|
|
934
|
+
|
|
935
|
+
@dataclass(frozen=True)
|
|
936
|
+
class ScopeRun:
|
|
937
|
+
"""One observed test run, with what it selected and when it happened."""
|
|
938
|
+
|
|
939
|
+
command: str
|
|
940
|
+
selectors: tuple[str, ...]
|
|
941
|
+
phase: ScopePhase
|
|
942
|
+
report: TestReport
|
|
943
|
+
duration_seconds: float | None = None
|
|
944
|
+
|
|
945
|
+
@property
|
|
946
|
+
def whole_suite(self) -> bool:
|
|
947
|
+
return not self.selectors
|
|
948
|
+
|
|
949
|
+
@property
|
|
950
|
+
def usable(self) -> bool:
|
|
951
|
+
"""Only a run whose failing ids are fully known can be compared."""
|
|
952
|
+
return self.report.usable_as_baseline
|
|
953
|
+
|
|
954
|
+
def covers(self, paths: Iterable[str]) -> bool:
|
|
955
|
+
return all(selection_covers(self.selectors, path) for path in paths)
|
|
956
|
+
|
|
957
|
+
def as_payload(self) -> dict[str, Any]:
|
|
958
|
+
return {
|
|
959
|
+
"command": self.command,
|
|
960
|
+
"selectors": list(self.selectors),
|
|
961
|
+
"whole_suite": self.whole_suite,
|
|
962
|
+
"phase": self.phase.value,
|
|
963
|
+
"duration_seconds": self.duration_seconds,
|
|
964
|
+
"report": self.report.as_payload(),
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def classify_scope_phase(
|
|
969
|
+
*,
|
|
970
|
+
touched_repo_paths: Iterable[str],
|
|
971
|
+
created_paths: Iterable[str],
|
|
972
|
+
) -> ScopePhase:
|
|
973
|
+
"""Baseline iff no pre-existing repo path has been modified yet.
|
|
974
|
+
|
|
975
|
+
Files the agent authored this turn are excluded: creating a new file changes no
|
|
976
|
+
existing behaviour, so a run made after writing a new test still observes the
|
|
977
|
+
unpatched tree. This is the same discriminator step 5 uses, and for the same
|
|
978
|
+
reason -- the edit generation counts the new file and would close the baseline
|
|
979
|
+
window before the agent ever got to use it.
|
|
980
|
+
"""
|
|
981
|
+
created = {
|
|
982
|
+
normalized
|
|
983
|
+
for normalized in (normalize_repo_path(path) for path in created_paths)
|
|
984
|
+
if normalized
|
|
985
|
+
}
|
|
986
|
+
product = {
|
|
987
|
+
normalized
|
|
988
|
+
for normalized in (normalize_repo_path(path) for path in touched_repo_paths)
|
|
989
|
+
if normalized and normalized not in created
|
|
990
|
+
}
|
|
991
|
+
return ScopePhase.GATE if product else ScopePhase.BASELINE
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
# ---------------------------------------------------------------------------
|
|
995
|
+
# Assessment (pure)
|
|
996
|
+
# ---------------------------------------------------------------------------
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
class BlastRadiusStatus(StrEnum):
|
|
1000
|
+
"""The blast-radius protocol's state at a decision point."""
|
|
1001
|
+
|
|
1002
|
+
#: Off, non-execute, nothing edited, or no diffable test surface near the change.
|
|
1003
|
+
NOT_APPLICABLE = "not_applicable"
|
|
1004
|
+
#: A scope exists but no post-fix run has covered it yet.
|
|
1005
|
+
GATE_MISSING = "gate_missing"
|
|
1006
|
+
#: The scope did run after the fix, but its output could not be parsed into
|
|
1007
|
+
#: per-test results. Honest degradation, not a deficit to nudge on: re-running
|
|
1008
|
+
#: the same runner would produce the same unreadable output.
|
|
1009
|
+
UNREADABLE = "unreadable"
|
|
1010
|
+
#: The scope ran after the fix, but no clean-tree run covers it, so its failures
|
|
1011
|
+
#: cannot be told apart from breakage that was already there.
|
|
1012
|
+
UNATTRIBUTED = "unattributed"
|
|
1013
|
+
#: The scope ran before and after the fix and broke nothing new.
|
|
1014
|
+
CLEAN = "clean"
|
|
1015
|
+
#: Tests that passed in the clean-tree baseline fail after the change.
|
|
1016
|
+
REGRESSED = "regressed"
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
@dataclass(frozen=True)
|
|
1020
|
+
class BlastRadiusAssessment:
|
|
1021
|
+
"""The mechanical state of the blast-radius gate for a turn."""
|
|
1022
|
+
|
|
1023
|
+
status: BlastRadiusStatus = BlastRadiusStatus.NOT_APPLICABLE
|
|
1024
|
+
applicable: bool = False
|
|
1025
|
+
scope: BlastRadiusScope = EMPTY_SCOPE
|
|
1026
|
+
new_failures: tuple[str, ...] = ()
|
|
1027
|
+
pre_existing: tuple[str, ...] = ()
|
|
1028
|
+
unattributed: tuple[str, ...] = ()
|
|
1029
|
+
repaired: tuple[str, ...] = ()
|
|
1030
|
+
agent_authored: tuple[str, ...] = ()
|
|
1031
|
+
baseline_command: str = ""
|
|
1032
|
+
gate_command: str = ""
|
|
1033
|
+
baseline_whole_suite: bool = False
|
|
1034
|
+
over_broad_threshold: int = DEFAULT_OVER_BROAD_THRESHOLD
|
|
1035
|
+
|
|
1036
|
+
@property
|
|
1037
|
+
def has_baseline(self) -> bool:
|
|
1038
|
+
return bool(self.baseline_command)
|
|
1039
|
+
|
|
1040
|
+
@property
|
|
1041
|
+
def regressed(self) -> bool:
|
|
1042
|
+
return self.status == BlastRadiusStatus.REGRESSED
|
|
1043
|
+
|
|
1044
|
+
@property
|
|
1045
|
+
def over_broad(self) -> bool:
|
|
1046
|
+
"""The change broke so much that narrowing the patch beats fixing each test."""
|
|
1047
|
+
return len(self.new_failures) >= self.over_broad_threshold
|
|
1048
|
+
|
|
1049
|
+
@property
|
|
1050
|
+
def satisfied(self) -> bool:
|
|
1051
|
+
return self.status == BlastRadiusStatus.CLEAN
|
|
1052
|
+
|
|
1053
|
+
def as_payload(self) -> dict[str, Any]:
|
|
1054
|
+
return {
|
|
1055
|
+
"status": self.status.value,
|
|
1056
|
+
"applicable": self.applicable,
|
|
1057
|
+
"satisfied": self.satisfied,
|
|
1058
|
+
"over_broad": self.over_broad,
|
|
1059
|
+
"over_broad_threshold": self.over_broad_threshold,
|
|
1060
|
+
"new_failures": list(self.new_failures),
|
|
1061
|
+
"pre_existing": list(self.pre_existing),
|
|
1062
|
+
"unattributed": list(self.unattributed),
|
|
1063
|
+
"repaired": list(self.repaired),
|
|
1064
|
+
"agent_authored": list(self.agent_authored),
|
|
1065
|
+
"baseline_command": self.baseline_command,
|
|
1066
|
+
"gate_command": self.gate_command,
|
|
1067
|
+
"baseline_whole_suite": self.baseline_whole_suite,
|
|
1068
|
+
"has_baseline": self.has_baseline,
|
|
1069
|
+
"scope": self.scope.as_payload(),
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _created_components(paths: Iterable[str]) -> list[str]:
|
|
1074
|
+
return [
|
|
1075
|
+
normalized for normalized in (normalize_repo_path(path) for path in paths) if normalized
|
|
1076
|
+
]
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _join_commands(commands: Iterable[str]) -> str:
|
|
1080
|
+
"""Render the baseline's command(s) for a message, bounded so it stays readable."""
|
|
1081
|
+
distinct = [command for command in dict.fromkeys(commands) if command]
|
|
1082
|
+
if not distinct:
|
|
1083
|
+
return ""
|
|
1084
|
+
rendered = ", ".join(distinct[:2])
|
|
1085
|
+
remaining = len(distinct) - min(len(distinct), 2)
|
|
1086
|
+
return f"{rendered} (+{remaining} more)" if remaining > 0 else rendered
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _id_file_is_agent_created(test_id: str, created: Sequence[str]) -> bool:
|
|
1090
|
+
file_path = node_id_file_path(test_id)
|
|
1091
|
+
if not file_path:
|
|
1092
|
+
return False
|
|
1093
|
+
normalized = normalize_repo_path(file_path)
|
|
1094
|
+
# Exact match only, for the reason step 3 documents: a basename match would let
|
|
1095
|
+
# a created ``test_foo.py`` mask a genuine regression in ``tests/test_foo.py``.
|
|
1096
|
+
return bool(normalized) and any(normalized == item for item in created)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def assess_blast_radius(
|
|
1100
|
+
*,
|
|
1101
|
+
scope: BlastRadiusScope,
|
|
1102
|
+
runs: Sequence[ScopeRun],
|
|
1103
|
+
applicable: bool,
|
|
1104
|
+
policy: BlastRadiusPolicy | None = None,
|
|
1105
|
+
agent_created_paths: Iterable[str] = (),
|
|
1106
|
+
) -> BlastRadiusAssessment:
|
|
1107
|
+
"""Diff the scope's post-fix run against its clean-tree baseline. Deterministic.
|
|
1108
|
+
|
|
1109
|
+
The comparison is by *coverage*, not by command equality. The gate run must cover
|
|
1110
|
+
the whole scope -- that is what "run the scope" means. The baseline does not: every
|
|
1111
|
+
clean-tree run observed the same unpatched tree, so their coverage and their
|
|
1112
|
+
failures compose into one baseline, and attribution is then decided per failing
|
|
1113
|
+
test against what that composite actually ran. A failure the baseline never
|
|
1114
|
+
covered is ``unattributed`` -- never guessed into either column.
|
|
1115
|
+
"""
|
|
1116
|
+
resolved_policy = policy or BlastRadiusPolicy()
|
|
1117
|
+
common: dict[str, Any] = {
|
|
1118
|
+
"applicable": bool(applicable),
|
|
1119
|
+
"scope": scope,
|
|
1120
|
+
"over_broad_threshold": resolved_policy.over_broad_threshold,
|
|
1121
|
+
}
|
|
1122
|
+
if not applicable or scope.empty or not scope.diffable:
|
|
1123
|
+
return BlastRadiusAssessment(status=BlastRadiusStatus.NOT_APPLICABLE, **common)
|
|
1124
|
+
|
|
1125
|
+
scope_paths = scope.paths
|
|
1126
|
+
usable = [run for run in runs if run.usable]
|
|
1127
|
+
gate_runs = [run for run in usable if run.phase == ScopePhase.GATE and run.covers(scope_paths)]
|
|
1128
|
+
if not gate_runs:
|
|
1129
|
+
# Distinguish "never ran the scope" from "ran it, could not read the result".
|
|
1130
|
+
# Only the first is a deficit the agent can clear; nudging on the second
|
|
1131
|
+
# would loop forever against a runner whose output shape we cannot parse.
|
|
1132
|
+
unreadable = [
|
|
1133
|
+
run
|
|
1134
|
+
for run in runs
|
|
1135
|
+
if not run.usable and run.phase == ScopePhase.GATE and run.covers(scope_paths)
|
|
1136
|
+
]
|
|
1137
|
+
if unreadable:
|
|
1138
|
+
return BlastRadiusAssessment(
|
|
1139
|
+
status=BlastRadiusStatus.UNREADABLE,
|
|
1140
|
+
gate_command=unreadable[-1].command,
|
|
1141
|
+
**common,
|
|
1142
|
+
)
|
|
1143
|
+
return BlastRadiusAssessment(status=BlastRadiusStatus.GATE_MISSING, **common)
|
|
1144
|
+
gate = gate_runs[-1]
|
|
1145
|
+
|
|
1146
|
+
baseline_runs = [run for run in usable if run.phase == ScopePhase.BASELINE]
|
|
1147
|
+
baseline_whole_suite = any(run.whole_suite for run in baseline_runs)
|
|
1148
|
+
baseline_selectors: tuple[str, ...] = (
|
|
1149
|
+
()
|
|
1150
|
+
if baseline_whole_suite
|
|
1151
|
+
else tuple(dict.fromkeys(item for run in baseline_runs for item in run.selectors))
|
|
1152
|
+
)
|
|
1153
|
+
baseline_failing = frozenset(
|
|
1154
|
+
test_id for run in baseline_runs for test_id in run.report.failing_ids
|
|
1155
|
+
)
|
|
1156
|
+
created = _created_components(agent_created_paths)
|
|
1157
|
+
|
|
1158
|
+
new_failures: list[str] = []
|
|
1159
|
+
pre_existing: list[str] = []
|
|
1160
|
+
unattributed: list[str] = []
|
|
1161
|
+
agent_authored: list[str] = []
|
|
1162
|
+
for test_id in gate.report.failing_ids:
|
|
1163
|
+
if _id_file_is_agent_created(test_id, created):
|
|
1164
|
+
agent_authored.append(test_id)
|
|
1165
|
+
elif not baseline_runs:
|
|
1166
|
+
unattributed.append(test_id)
|
|
1167
|
+
elif test_id in baseline_failing:
|
|
1168
|
+
pre_existing.append(test_id)
|
|
1169
|
+
elif selection_covers(baseline_selectors, node_id_file_path(test_id) or ""):
|
|
1170
|
+
new_failures.append(test_id)
|
|
1171
|
+
else:
|
|
1172
|
+
unattributed.append(test_id)
|
|
1173
|
+
|
|
1174
|
+
gate_failing = frozenset(gate.report.failing_ids)
|
|
1175
|
+
repaired = [
|
|
1176
|
+
test_id
|
|
1177
|
+
for test_id in dict.fromkeys(
|
|
1178
|
+
item for run in baseline_runs for item in run.report.failing_ids
|
|
1179
|
+
)
|
|
1180
|
+
if test_id not in gate_failing
|
|
1181
|
+
and selection_covers(gate.selectors, node_id_file_path(test_id) or "")
|
|
1182
|
+
]
|
|
1183
|
+
|
|
1184
|
+
if new_failures:
|
|
1185
|
+
status = BlastRadiusStatus.REGRESSED
|
|
1186
|
+
elif not baseline_runs or unattributed:
|
|
1187
|
+
status = BlastRadiusStatus.UNATTRIBUTED
|
|
1188
|
+
else:
|
|
1189
|
+
status = BlastRadiusStatus.CLEAN
|
|
1190
|
+
|
|
1191
|
+
return BlastRadiusAssessment(
|
|
1192
|
+
status=status,
|
|
1193
|
+
new_failures=tuple(new_failures),
|
|
1194
|
+
pre_existing=tuple(pre_existing),
|
|
1195
|
+
unattributed=tuple(unattributed),
|
|
1196
|
+
repaired=tuple(repaired),
|
|
1197
|
+
agent_authored=tuple(agent_authored),
|
|
1198
|
+
baseline_command=_join_commands(run.command for run in baseline_runs),
|
|
1199
|
+
gate_command=gate.command,
|
|
1200
|
+
baseline_whole_suite=baseline_whole_suite,
|
|
1201
|
+
**common,
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def blast_radius_blocks_finalization(
|
|
1206
|
+
assessment: BlastRadiusAssessment,
|
|
1207
|
+
*,
|
|
1208
|
+
material_edit_count: int,
|
|
1209
|
+
) -> bool:
|
|
1210
|
+
"""True when the gate must not let this turn finalize yet.
|
|
1211
|
+
|
|
1212
|
+
Only two states block: a scope that was never run after the fix, and proven new
|
|
1213
|
+
failures. ``unattributed`` does not block here -- step 3's own unattributed stage
|
|
1214
|
+
already owns that case, and blocking twice for one fact would just burn repair
|
|
1215
|
+
rounds. A turn that changed nothing has no blast radius.
|
|
1216
|
+
"""
|
|
1217
|
+
if not assessment.applicable or material_edit_count <= 0:
|
|
1218
|
+
return False
|
|
1219
|
+
return assessment.status in {BlastRadiusStatus.GATE_MISSING, BlastRadiusStatus.REGRESSED}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
# ---------------------------------------------------------------------------
|
|
1223
|
+
# Directives and advisories (agent-facing text)
|
|
1224
|
+
# ---------------------------------------------------------------------------
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
BLAST_RADIUS_TURN_DIRECTIVE = (
|
|
1228
|
+
"Blast-radius protocol (a correct fix that breaks other tests is a failed task):\n"
|
|
1229
|
+
"- BEFORE you change any existing file, run the tests that cover the area you are "
|
|
1230
|
+
"about to touch (the module's own test file, and the tests around it) once, and keep "
|
|
1231
|
+
"their result. That run is your baseline: whatever already fails there is not yours, "
|
|
1232
|
+
"and I will not attribute it to your change. Without it I cannot tell your breakage "
|
|
1233
|
+
"apart from breakage that was already in the repo.\n"
|
|
1234
|
+
"- AFTER your fix verifies, run that same set again. Anything failing now that passed "
|
|
1235
|
+
"in the baseline is a regression you introduced, and it is part of your task.\n"
|
|
1236
|
+
"- Repair regressions by narrowing your change, not by widening it. If one change "
|
|
1237
|
+
"breaks a large number of previously passing tests, the approach itself is wrong: "
|
|
1238
|
+
"revert it and write a narrower patch rather than patching up each failing test.\n"
|
|
1239
|
+
"- Never delete, skip, or weaken an existing test to make it pass."
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def build_blast_radius_scope_advisory(
|
|
1244
|
+
scope: BlastRadiusScope,
|
|
1245
|
+
*,
|
|
1246
|
+
has_baseline: bool,
|
|
1247
|
+
) -> str:
|
|
1248
|
+
"""The concrete scope, emitted once the first change to existing code lands."""
|
|
1249
|
+
if scope.empty:
|
|
1250
|
+
return ""
|
|
1251
|
+
command = scope.suggested_command()
|
|
1252
|
+
listed = ", ".join(scope.paths[:MAX_LISTED_IDS])
|
|
1253
|
+
extra = len(scope.paths) - min(len(scope.paths), MAX_LISTED_IDS)
|
|
1254
|
+
if extra > 0:
|
|
1255
|
+
listed += f" (+{extra} more)"
|
|
1256
|
+
lines = [
|
|
1257
|
+
"Blast-radius scope for your change: "
|
|
1258
|
+
+ listed
|
|
1259
|
+
+ ". These are the tests nearest what you touched - the ones that mirror it, "
|
|
1260
|
+
"import it, or sit in the same package.",
|
|
1261
|
+
]
|
|
1262
|
+
if has_baseline:
|
|
1263
|
+
lines.append(
|
|
1264
|
+
"A clean-tree run already covers this scope, so I can attribute failures. "
|
|
1265
|
+
f"Re-run it after your fix (for example `{command}`) and make sure nothing "
|
|
1266
|
+
"that passed then fails now."
|
|
1267
|
+
)
|
|
1268
|
+
else:
|
|
1269
|
+
lines.append(
|
|
1270
|
+
"Nothing was run on the clean tree covering this scope, so failures here "
|
|
1271
|
+
"cannot yet be told apart from breakage that was already in the repo. Run it "
|
|
1272
|
+
f"after your fix anyway (for example `{command}`) and read the result against "
|
|
1273
|
+
"what you know about the repo - do not assume a failure is pre-existing."
|
|
1274
|
+
)
|
|
1275
|
+
lines.append(
|
|
1276
|
+
"Advisory only - this does not block your edit. Run any equivalent command; "
|
|
1277
|
+
"what matters is that these files are covered."
|
|
1278
|
+
)
|
|
1279
|
+
return " ".join(lines)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _format_ids(ids: Sequence[str]) -> str:
|
|
1283
|
+
listed = list(ids[:MAX_LISTED_IDS])
|
|
1284
|
+
rendered = ", ".join(listed)
|
|
1285
|
+
remaining = len(ids) - len(listed)
|
|
1286
|
+
if remaining > 0:
|
|
1287
|
+
rendered += f" (+{remaining} more)"
|
|
1288
|
+
return rendered
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def build_blast_radius_nudge_line(assessment: BlastRadiusAssessment) -> str:
|
|
1292
|
+
"""The bounded repair nudge for the current blast-radius status."""
|
|
1293
|
+
if not assessment.applicable:
|
|
1294
|
+
return ""
|
|
1295
|
+
if assessment.status == BlastRadiusStatus.GATE_MISSING:
|
|
1296
|
+
command = assessment.scope.suggested_command()
|
|
1297
|
+
detail = f" (for example `{command}`)" if command else ""
|
|
1298
|
+
return (
|
|
1299
|
+
"- You have not run the tests around what you changed: "
|
|
1300
|
+
+ _format_ids(assessment.scope.paths)
|
|
1301
|
+
+ f". Run them now{detail} and confirm your change did not break them. A "
|
|
1302
|
+
"written explanation cannot clear this - only the run can."
|
|
1303
|
+
)
|
|
1304
|
+
if assessment.status != BlastRadiusStatus.REGRESSED:
|
|
1305
|
+
return ""
|
|
1306
|
+
baseline = assessment.baseline_command or "the clean-tree baseline"
|
|
1307
|
+
if assessment.over_broad:
|
|
1308
|
+
return (
|
|
1309
|
+
f"- Your change broke {len(assessment.new_failures)} tests that passed before it: "
|
|
1310
|
+
+ _format_ids(assessment.new_failures)
|
|
1311
|
+
+ f". That many failures from one change means the change itself is too broad, "
|
|
1312
|
+
f"not that each test needs fixing. Revert it and write a narrower patch that "
|
|
1313
|
+
f"touches only what the task requires, then re-run both your reproduction and "
|
|
1314
|
+
f"`{baseline}`. Do not edit, skip, or delete those tests."
|
|
1315
|
+
)
|
|
1316
|
+
return (
|
|
1317
|
+
"- Tests your change broke (they passed in the clean-tree baseline of "
|
|
1318
|
+
f"`{baseline}`): "
|
|
1319
|
+
+ _format_ids(assessment.new_failures)
|
|
1320
|
+
+ ". Fix them while keeping your fix intact - prefer narrowing your change over "
|
|
1321
|
+
"adding more of it - then re-run both your reproduction and this scope. Do not "
|
|
1322
|
+
"edit, skip, or delete those tests to make them pass."
|
|
1323
|
+
)
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
_STATUS_SUMMARY_LINES = {
|
|
1327
|
+
BlastRadiusStatus.CLEAN: (
|
|
1328
|
+
"Blast radius: re-ran {count} nearby test file(s) after the fix{detail}; nothing "
|
|
1329
|
+
"that passed before it fails now."
|
|
1330
|
+
),
|
|
1331
|
+
BlastRadiusStatus.REGRESSED: (
|
|
1332
|
+
"⛔ REGRESSIONS INTRODUCED — {n} test(s) that passed before my change now fail: "
|
|
1333
|
+
"{ids}. Baseline: `{baseline}`. I could not clear these within this run, so this "
|
|
1334
|
+
"result ships with KNOWN BREAKAGE outside the fix itself."
|
|
1335
|
+
),
|
|
1336
|
+
BlastRadiusStatus.UNATTRIBUTED: (
|
|
1337
|
+
"⚠️ Blast radius: ran {count} nearby test file(s) after the fix{detail}, but with no "
|
|
1338
|
+
"clean-tree run covering them {attribution_detail}"
|
|
1339
|
+
),
|
|
1340
|
+
BlastRadiusStatus.GATE_MISSING: (
|
|
1341
|
+
"⚠️ Blast radius: the tests around what I changed ({count} file(s)) were never run "
|
|
1342
|
+
"after the fix, so nothing confirms the change did not break them."
|
|
1343
|
+
),
|
|
1344
|
+
BlastRadiusStatus.UNREADABLE: (
|
|
1345
|
+
"⚠️ Blast radius: the tests around what I changed ({count} file(s)) ran after the "
|
|
1346
|
+
"fix{detail}, but I could not read per-test results out of the runner's output, so "
|
|
1347
|
+
"I cannot say whether the change broke any of them."
|
|
1348
|
+
),
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def build_blast_radius_status_summary(assessment: BlastRadiusAssessment) -> str:
|
|
1353
|
+
"""The visible blast-radius line appended to the summary.
|
|
1354
|
+
|
|
1355
|
+
Emitted for every applicable turn, satisfied or not: a clean result says so
|
|
1356
|
+
plainly, and an unresolved regression leads with the failures. Reporting success
|
|
1357
|
+
without naming what else the change touched is the failure mode this whole step
|
|
1358
|
+
exists to remove, so silence is never an option here.
|
|
1359
|
+
"""
|
|
1360
|
+
if not assessment.applicable:
|
|
1361
|
+
return ""
|
|
1362
|
+
template = _STATUS_SUMMARY_LINES.get(assessment.status, "")
|
|
1363
|
+
if not template:
|
|
1364
|
+
return ""
|
|
1365
|
+
command = (assessment.gate_command or "").strip()
|
|
1366
|
+
unattributed_ids = assessment.new_failures or assessment.unattributed
|
|
1367
|
+
attribution_detail = (
|
|
1368
|
+
"I cannot tell whether these failures are mine: "
|
|
1369
|
+
f"{_format_ids(unattributed_ids)}. Their cause is UNATTRIBUTED — neither confirmed "
|
|
1370
|
+
"pre-existing nor confirmed a regression."
|
|
1371
|
+
if unattributed_ids
|
|
1372
|
+
else (
|
|
1373
|
+
"no failures were reported, but the clean result is UNATTRIBUTED because it "
|
|
1374
|
+
"cannot be compared with a baseline."
|
|
1375
|
+
)
|
|
1376
|
+
)
|
|
1377
|
+
line = template.format(
|
|
1378
|
+
count=len(assessment.scope.paths),
|
|
1379
|
+
detail=f" (`{command}`)" if command else "",
|
|
1380
|
+
n=len(assessment.new_failures),
|
|
1381
|
+
# Whichever column this status is about: proven breakage, else the failures
|
|
1382
|
+
# that could not be attributed.
|
|
1383
|
+
ids=_format_ids(unattributed_ids),
|
|
1384
|
+
attribution_detail=attribution_detail,
|
|
1385
|
+
baseline=assessment.baseline_command or "the clean-tree baseline",
|
|
1386
|
+
)
|
|
1387
|
+
if assessment.status == BlastRadiusStatus.REGRESSED and assessment.over_broad:
|
|
1388
|
+
line += (
|
|
1389
|
+
f" Breaking {len(assessment.new_failures)} previously passing tests means the "
|
|
1390
|
+
"change is over-broad and should be rewritten narrowly, not patched up test by "
|
|
1391
|
+
"test."
|
|
1392
|
+
)
|
|
1393
|
+
if assessment.scope.dropped_for_runtime:
|
|
1394
|
+
line += (
|
|
1395
|
+
f" Scope was shrunk to stay inside the runtime cap; "
|
|
1396
|
+
f"{len(assessment.scope.dropped_for_runtime)} further test file(s) were not run."
|
|
1397
|
+
)
|
|
1398
|
+
elif assessment.scope.dropped_for_cap:
|
|
1399
|
+
line += (
|
|
1400
|
+
f" Scope was capped; {len(assessment.scope.dropped_for_cap)} further test file(s) "
|
|
1401
|
+
"were not run."
|
|
1402
|
+
)
|
|
1403
|
+
return f"\n\n---\n{line}"
|