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,3129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .branding import env_get
|
|
18
|
+
from .config import (
|
|
19
|
+
AppConfig,
|
|
20
|
+
ConfigError,
|
|
21
|
+
is_generic_configured_verify_preset,
|
|
22
|
+
is_generic_verify_command_fallback,
|
|
23
|
+
normalize_verify_command_list,
|
|
24
|
+
normalize_verify_module_invocation,
|
|
25
|
+
split_verify_command_parts,
|
|
26
|
+
strip_verify_runner_prefix,
|
|
27
|
+
)
|
|
28
|
+
from .failure_category import (
|
|
29
|
+
FailureCategory,
|
|
30
|
+
failure_category_value,
|
|
31
|
+
is_infra_unavailable_error,
|
|
32
|
+
)
|
|
33
|
+
from .file_classification import SOURCE_EXTENSIONS_BY_LANGUAGE
|
|
34
|
+
from .process_reaping import ProcessGroupRegistry
|
|
35
|
+
from .repo_scan import RepoScanResult, detect_fallback_test_commands, scan_workspace
|
|
36
|
+
from .sandbox_runner import (
|
|
37
|
+
HostShellRunner,
|
|
38
|
+
build_shell_runner_from_settings,
|
|
39
|
+
with_closed_stdin,
|
|
40
|
+
)
|
|
41
|
+
from .sandbox_settings import resolve_shell_sandbox_settings
|
|
42
|
+
from .verification_command_analysis import (
|
|
43
|
+
VerificationCommandEvidentiaryCapability,
|
|
44
|
+
VerificationCommandStatus,
|
|
45
|
+
analyze_verification_command,
|
|
46
|
+
command_status_from_execution,
|
|
47
|
+
path_has_known_verification_surface,
|
|
48
|
+
verification_commands_apply_to_paths,
|
|
49
|
+
)
|
|
50
|
+
from .verification_contract import (
|
|
51
|
+
VerificationCommandExecutionMode,
|
|
52
|
+
VerificationCommandSpec,
|
|
53
|
+
VerificationCommandValidationStatus,
|
|
54
|
+
build_verification_command_specs,
|
|
55
|
+
command_specs_payload,
|
|
56
|
+
rejection_reason_is_unclassifiable,
|
|
57
|
+
)
|
|
58
|
+
from .verification_failure_summary import summarize_verification_failure
|
|
59
|
+
from .workspace_context import WorkspaceContext, WorkspaceContextError, resolve_workspace_context
|
|
60
|
+
|
|
61
|
+
VERIFY_MODES = {"off", "warn", "strict"}
|
|
62
|
+
VERIFY_SANDBOX_MODES = {"off", "warn", "strict"}
|
|
63
|
+
VERIFY_OUTPUT_PREVIEW_CHARS = 400
|
|
64
|
+
VERIFICATION_FAILURE_SNIPPET_MAX_CHARS = 240
|
|
65
|
+
_NODE_JS_EXTENSIONS = set(SOURCE_EXTENSIONS_BY_LANGUAGE["node"])
|
|
66
|
+
_NODE_BOOTSTRAP_FILENAMES = {
|
|
67
|
+
"package-lock.json",
|
|
68
|
+
"package.json",
|
|
69
|
+
"pnpm-lock.yaml",
|
|
70
|
+
"tsconfig.json",
|
|
71
|
+
"yarn.lock",
|
|
72
|
+
}
|
|
73
|
+
_NODE_PACKAGE_HINTS = {"bun", "node", "npm", "pnpm", "yarn"}
|
|
74
|
+
_DOC_ONLY_DIR_NAMES = {
|
|
75
|
+
"doc",
|
|
76
|
+
"docs",
|
|
77
|
+
"documentation",
|
|
78
|
+
"manual",
|
|
79
|
+
"manuals",
|
|
80
|
+
}
|
|
81
|
+
_DOC_ONLY_EXTENSIONS = {".adoc", ".md", ".mdx", ".rst", ".txt"}
|
|
82
|
+
_DOC_ONLY_FILENAMES = {
|
|
83
|
+
"authors",
|
|
84
|
+
"changelog",
|
|
85
|
+
"code_of_conduct",
|
|
86
|
+
"contributing",
|
|
87
|
+
"license",
|
|
88
|
+
"notice",
|
|
89
|
+
"readme",
|
|
90
|
+
"security",
|
|
91
|
+
}
|
|
92
|
+
_DOC_ONLY_FILENAME_PREFIXES = {
|
|
93
|
+
"authors",
|
|
94
|
+
"changelog",
|
|
95
|
+
"code_of_conduct",
|
|
96
|
+
"contributing",
|
|
97
|
+
"license",
|
|
98
|
+
"notice",
|
|
99
|
+
"readme",
|
|
100
|
+
"security",
|
|
101
|
+
}
|
|
102
|
+
_PYTHON_VERIFY_FILENAMES = {
|
|
103
|
+
"pyproject.toml",
|
|
104
|
+
"pytest.ini",
|
|
105
|
+
"requirements.txt",
|
|
106
|
+
"setup.cfg",
|
|
107
|
+
"setup.py",
|
|
108
|
+
"tox.ini",
|
|
109
|
+
}
|
|
110
|
+
_CI_ONLY_DIR_NAMES = {
|
|
111
|
+
".buildkite",
|
|
112
|
+
".circleci",
|
|
113
|
+
".github",
|
|
114
|
+
".gitlab",
|
|
115
|
+
".woodpecker",
|
|
116
|
+
}
|
|
117
|
+
_CI_ONLY_FILENAMES = {
|
|
118
|
+
".gitlab-ci.yml",
|
|
119
|
+
".travis.yml",
|
|
120
|
+
"buildkite.yaml",
|
|
121
|
+
"buildkite.yml",
|
|
122
|
+
"drone.yaml",
|
|
123
|
+
"drone.yml",
|
|
124
|
+
}
|
|
125
|
+
_CI_ONLY_WORKFLOW_FILENAMES = {".yaml", ".yml"}
|
|
126
|
+
_TERRAFORM_EXTENSIONS = {".tf", ".tfvars"}
|
|
127
|
+
_TERRAFORM_FILENAMES = {"terraform.lock.hcl"}
|
|
128
|
+
_COMPOSE_FILENAMES = {
|
|
129
|
+
"compose.yaml",
|
|
130
|
+
"compose.yml",
|
|
131
|
+
"docker-compose.yaml",
|
|
132
|
+
"docker-compose.yml",
|
|
133
|
+
}
|
|
134
|
+
_NODE_TEST_PATH_RE = re.compile(r"(^|/)(?:test|tests)/.*\.(?:test|spec)\.(?:[cm]?[jt]sx?|[cm]?ts)$")
|
|
135
|
+
_NODE_TEST_SUFFIX_RE = re.compile(r"\.(?:test|spec)\.(?:[cm]?[jt]sx?|[cm]?ts)$")
|
|
136
|
+
_NODE_TEST_TEXT_POSITIVE_RE = re.compile(
|
|
137
|
+
r"\b(?:use|verify(?:\s+(?:with|using))?)\s+node\s+--test\b"
|
|
138
|
+
)
|
|
139
|
+
_NODE_TEST_TEXT_NEGATIVE_RE = re.compile(
|
|
140
|
+
r"\b(?:do\s+not|don't|never|avoid)\s+(?:use\s+)?node\s+--test\b"
|
|
141
|
+
)
|
|
142
|
+
_BACKTICK_COMMAND_RE = re.compile(r"`([^`]+)`")
|
|
143
|
+
_PYTEST_TEXT_COMMAND_RE = re.compile(
|
|
144
|
+
r"\b((?:(?:python|python3|py)\s+-m\s+pytest)|pytest|py\.test)"
|
|
145
|
+
r"(?:\s+(?:-[A-Za-z0-9][A-Za-z0-9_./:=/-]*|[A-Za-z0-9_/-]+/[A-Za-z0-9_./-]*|[A-Za-z0-9_/-]+\.[A-Za-z0-9][A-Za-z0-9_./-]*))*",
|
|
146
|
+
re.IGNORECASE,
|
|
147
|
+
)
|
|
148
|
+
_VERIFY_PYTEST_ENTRYPOINTS = {"pytest", "py.test"}
|
|
149
|
+
_VERIFY_SHELL_CONTROL_FLOW_TOKENS = {"||", "&&", ";", "|", "&"}
|
|
150
|
+
_VERIFY_EXECUTION_LAYER_ERROR_MARKERS = (
|
|
151
|
+
"command not found",
|
|
152
|
+
"not recognized as an internal or external command",
|
|
153
|
+
"permission denied",
|
|
154
|
+
"no such file or directory",
|
|
155
|
+
"cannot execute",
|
|
156
|
+
"exec format error",
|
|
157
|
+
"operation not permitted",
|
|
158
|
+
)
|
|
159
|
+
_GO_TEST_NON_EXECUTION_MARKERS = {
|
|
160
|
+
"[no tests to run]": "go_test_no_tests_to_run",
|
|
161
|
+
"[no test files]": "go_test_no_test_files",
|
|
162
|
+
}
|
|
163
|
+
_PYTEST_NO_TESTS_RE = re.compile(
|
|
164
|
+
r"\b(?:collected\s+0\s+items|no\s+tests\s+ran|no\s+tests\s+collected)\b",
|
|
165
|
+
re.IGNORECASE,
|
|
166
|
+
)
|
|
167
|
+
_UNITTEST_NO_TESTS_RE = re.compile(r"\bRan\s+0\s+tests\b", re.IGNORECASE)
|
|
168
|
+
_JUNIT_ZERO_TESTS_RE = re.compile(r"\bTests\s+run:\s*0\b", re.IGNORECASE)
|
|
169
|
+
_NODE_ZERO_TESTS_RE = re.compile(r"^\s*#\s+tests\s+0\s*$", re.IGNORECASE | re.MULTILINE)
|
|
170
|
+
_NOTHING_TO_DO_RE = re.compile(
|
|
171
|
+
r"\b(?:nothing to be done|nothing to do|no work to do)\b",
|
|
172
|
+
re.IGNORECASE,
|
|
173
|
+
)
|
|
174
|
+
_VERIFICATION_FAILURE_PRIORITY_MARKERS = (
|
|
175
|
+
"ImportError",
|
|
176
|
+
"ModuleNotFoundError",
|
|
177
|
+
"NameError",
|
|
178
|
+
"AttributeError",
|
|
179
|
+
"SyntaxError",
|
|
180
|
+
"TypeError",
|
|
181
|
+
"ValueError",
|
|
182
|
+
"AssertionError",
|
|
183
|
+
)
|
|
184
|
+
_GO_TEST_OK_LINE_RE = re.compile(r"^ok\s+\S+\s+\S+")
|
|
185
|
+
_GO_TEST_NO_TEST_FILES_LINE_RE = re.compile(r"^\?\s+\S+\s+\[no test files\]$")
|
|
186
|
+
_GO_TEST_NO_TESTS_TO_RUN_LINE_RE = re.compile(r"^ok\s+\S+\s+\S+\s+\[no tests to run\]$")
|
|
187
|
+
_TOOLCHAIN_UNAVAILABLE_RE = re.compile(
|
|
188
|
+
r"\b(?:requires|supports only|needs|need)\s+"
|
|
189
|
+
r"(?:go|elixir|erlang|otp|node|npm|java|jdk|gradle|maven|ruby|python|swift)"
|
|
190
|
+
r"\b.*\b(?:\d+(?:\.\d+)*)",
|
|
191
|
+
re.IGNORECASE,
|
|
192
|
+
)
|
|
193
|
+
_LANGUAGE_VERSION_MISMATCH_RE = re.compile(
|
|
194
|
+
r"\b(?:go|elixir|erlang|otp|node|npm|java|jdk|gradle|maven|ruby|python|swift)"
|
|
195
|
+
r"\b.*\b(?:version|v\d+)\b.*\b(?:required|requires|unsupported|not supported)",
|
|
196
|
+
re.IGNORECASE,
|
|
197
|
+
)
|
|
198
|
+
_LEGACY_PYTHON_RUNTIME_INCOMPATIBILITY_RE = re.compile(
|
|
199
|
+
r"(?:attributeerror:\s*module\s+['\"]collections['\"]\s+has\s+no\s+attribute\s+"
|
|
200
|
+
r"['\"](?:mapping|mutablemapping|sequence|iterable|callable)['\"]|"
|
|
201
|
+
r"importerror:\s*cannot\s+import\s+name\s+['\"]"
|
|
202
|
+
r"(?:mapping|mutablemapping|sequence|iterable|callable)['\"]\s+from\s+['\"]collections['\"])",
|
|
203
|
+
re.IGNORECASE,
|
|
204
|
+
)
|
|
205
|
+
_DOCS_ONLY_TEXT_RE = re.compile(
|
|
206
|
+
r"\b(?:doc|docs|documentation|readme|markdown|mdx|rst|changelog|contributing)\b"
|
|
207
|
+
)
|
|
208
|
+
_DOCTEST_TEXT_RE = re.compile(r"\bdoctests?\b|python\s+-m\s+doctest", re.IGNORECASE)
|
|
209
|
+
_CI_ONLY_TEXT_RE = re.compile(
|
|
210
|
+
r"\b(?:github\s+actions?|gitlab\s+ci|circleci|buildkite|workflow|ci\s+pipeline|ci)\b"
|
|
211
|
+
)
|
|
212
|
+
_TERRAFORM_TEXT_RE = re.compile(r"\bterraform\b|\.tfvars?\b|terraform\.lock\.hcl")
|
|
213
|
+
_COMPOSE_TEXT_RE = re.compile(
|
|
214
|
+
r"\b(?:docker\s+compose|docker-compose|compose\.ya?ml|compose\s+stack)\b"
|
|
215
|
+
)
|
|
216
|
+
_JS_FRONTEND_TEXT_RE = re.compile(
|
|
217
|
+
r"\b(?:frontend|front-end|ui|client(?:-side)?|browser|react|preact|next(?:\.js)?|nextjs|vue|nuxt|svelte|angular|vite|webpack|javascript|typescript|node|npm|pnpm|yarn|component)\b"
|
|
218
|
+
)
|
|
219
|
+
_STATIC_WEB_TEXT_RE = re.compile(
|
|
220
|
+
r"\b(?:static\s+(?:site|page|html)|html|css|landing\s+page|single\s+page|browser\s+page)\b"
|
|
221
|
+
)
|
|
222
|
+
_PYTHON_CODE_TASK_TEXT_RE = re.compile(
|
|
223
|
+
r"\b(?:python|fastapi|flask|django|pydantic|sqlalchemy|alembic|uvicorn|endpoint|handler|route|serializer|schema|logging|logger|exception|traceback|import|upload|api)\b"
|
|
224
|
+
)
|
|
225
|
+
_STATIC_WEB_DIR_NAMES = {
|
|
226
|
+
"assets",
|
|
227
|
+
"css",
|
|
228
|
+
"img",
|
|
229
|
+
"images",
|
|
230
|
+
"js",
|
|
231
|
+
"public",
|
|
232
|
+
"scripts",
|
|
233
|
+
"static",
|
|
234
|
+
}
|
|
235
|
+
_STATIC_WEB_FILENAMES = {
|
|
236
|
+
"favicon.ico",
|
|
237
|
+
"manifest.json",
|
|
238
|
+
"robots.txt",
|
|
239
|
+
"site.webmanifest",
|
|
240
|
+
}
|
|
241
|
+
_STATIC_WEB_EXTENSIONS = {
|
|
242
|
+
".avif",
|
|
243
|
+
".css",
|
|
244
|
+
".gif",
|
|
245
|
+
".htm",
|
|
246
|
+
".html",
|
|
247
|
+
".ico",
|
|
248
|
+
".jpeg",
|
|
249
|
+
".jpg",
|
|
250
|
+
".js",
|
|
251
|
+
".mjs",
|
|
252
|
+
".png",
|
|
253
|
+
".svg",
|
|
254
|
+
".webmanifest",
|
|
255
|
+
".webp",
|
|
256
|
+
}
|
|
257
|
+
CONFIG_VERIFY_COMMANDS_FALLBACK_SOURCE = "config.verify_commands_fallback"
|
|
258
|
+
CONFIG_VERIFY_COMMANDS_GENERIC_PRESET_SOURCE = "config.verify_commands_generic_preset"
|
|
259
|
+
REPO_SCAN_NO_AUTHORITATIVE_SOURCE = "repo_scan.no_authoritative_commands"
|
|
260
|
+
GENERIC_VERIFY_FALLBACK_SOURCES = {
|
|
261
|
+
CONFIG_VERIFY_COMMANDS_FALLBACK_SOURCE,
|
|
262
|
+
CONFIG_VERIFY_COMMANDS_GENERIC_PRESET_SOURCE,
|
|
263
|
+
}
|
|
264
|
+
AUTHORITATIVE_VERIFY_CONTRACT_TYPES = {
|
|
265
|
+
"authoritative_override",
|
|
266
|
+
"explicit_override",
|
|
267
|
+
"repo_native",
|
|
268
|
+
}
|
|
269
|
+
VERIFICATION_FALLBACK_DETECTED_SOURCE = "verification_fallback.detected_runner"
|
|
270
|
+
VERIFICATION_FALLBACK_BEST_EFFORT_SOURCE = "verification_fallback.best_effort"
|
|
271
|
+
# Sources whose commands a human or the managed host stated outright. Alysis Code
|
|
272
|
+
# may refuse to run them, but must never silently substitute its own guess for
|
|
273
|
+
# what somebody explicitly asked to be run.
|
|
274
|
+
EXPLICIT_VERIFY_COMMAND_SOURCES = {
|
|
275
|
+
"environment.authoritative_verification_commands",
|
|
276
|
+
"cli.verify_cmd",
|
|
277
|
+
"config.verify_commands",
|
|
278
|
+
}
|
|
279
|
+
_DOCTEST_MODULE_NAMES = {"doctest"}
|
|
280
|
+
_PYTEST_DOCTEST_GLOB_PREFIX = "--doctest-glob"
|
|
281
|
+
_NODE_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS = {"build", "docker", "node", "typescript"}
|
|
282
|
+
_NODE_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS = {"docker", "javascript", "typescript"}
|
|
283
|
+
_NODE_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS = _NODE_PACKAGE_HINTS | {"docker", "just", "make"}
|
|
284
|
+
_PYTHON_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS = {"build", "docker", "python"}
|
|
285
|
+
_PYTHON_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS = {"docker", "python"}
|
|
286
|
+
_PYTHON_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS = {
|
|
287
|
+
"docker",
|
|
288
|
+
"hatch",
|
|
289
|
+
"just",
|
|
290
|
+
"make",
|
|
291
|
+
"poetry",
|
|
292
|
+
"python",
|
|
293
|
+
"setuptools",
|
|
294
|
+
"uv",
|
|
295
|
+
}
|
|
296
|
+
_MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS = {
|
|
297
|
+
"build",
|
|
298
|
+
"docker",
|
|
299
|
+
"node",
|
|
300
|
+
"python",
|
|
301
|
+
"typescript",
|
|
302
|
+
}
|
|
303
|
+
_MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS = {
|
|
304
|
+
"docker",
|
|
305
|
+
"javascript",
|
|
306
|
+
"python",
|
|
307
|
+
"typescript",
|
|
308
|
+
}
|
|
309
|
+
_MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS = (
|
|
310
|
+
_NODE_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS
|
|
311
|
+
| _PYTHON_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS
|
|
312
|
+
)
|
|
313
|
+
_REPO_CLASSIFICATION_NEUTRAL_NAMES = {"codeowners"}
|
|
314
|
+
_NODE_VERIFY_SCRIPT_PRIORITY = ("test", "build", "lint", "typecheck", "check")
|
|
315
|
+
_VALIDATION_COMMAND_PATH_MARKERS = {
|
|
316
|
+
"accept",
|
|
317
|
+
"acceptance",
|
|
318
|
+
"check",
|
|
319
|
+
"smoke",
|
|
320
|
+
"test",
|
|
321
|
+
"validate",
|
|
322
|
+
"validation",
|
|
323
|
+
"verify",
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
class VerifyError(RuntimeError):
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@dataclass(frozen=True)
|
|
332
|
+
class VerificationExecutionAssessment:
|
|
333
|
+
real_execution: bool | None
|
|
334
|
+
non_execution_reason: str | None = None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True)
|
|
338
|
+
class VerifyCommandResult:
|
|
339
|
+
command: str
|
|
340
|
+
exit_code: int
|
|
341
|
+
output: str
|
|
342
|
+
stdout: str = ""
|
|
343
|
+
stderr: str = ""
|
|
344
|
+
effective_command: str | None = None
|
|
345
|
+
fallback_used: bool = False
|
|
346
|
+
fallback_reason: str | None = None
|
|
347
|
+
real_execution: bool | None = None
|
|
348
|
+
non_execution_reason: str | None = None
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def status(self) -> VerificationCommandStatus:
|
|
352
|
+
return command_status_from_execution(
|
|
353
|
+
exit_code=self.exit_code,
|
|
354
|
+
real_execution=self.real_execution,
|
|
355
|
+
non_execution_reason=self.non_execution_reason,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def ok(self) -> bool:
|
|
360
|
+
return self.status in {
|
|
361
|
+
VerificationCommandStatus.PASSED,
|
|
362
|
+
VerificationCommandStatus.SKIPPED,
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@dataclass(frozen=True)
|
|
367
|
+
class VerifyRunResult:
|
|
368
|
+
commands: list[str]
|
|
369
|
+
command_results: list[VerifyCommandResult]
|
|
370
|
+
artifact_path: Path
|
|
371
|
+
failure_category: FailureCategory | str | None = None
|
|
372
|
+
|
|
373
|
+
@property
|
|
374
|
+
def all_passed(self) -> bool:
|
|
375
|
+
return all(item.ok for item in self.command_results)
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def failure_category_value(self) -> str | None:
|
|
379
|
+
if self.all_passed:
|
|
380
|
+
return None
|
|
381
|
+
return (
|
|
382
|
+
failure_category_value(self.failure_category)
|
|
383
|
+
or FailureCategory.VERIFICATION_FAILED.value
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
@property
|
|
387
|
+
def failed_commands(self) -> list[str]:
|
|
388
|
+
return [item.command for item in self.command_results if not item.ok]
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def summary(self) -> str:
|
|
392
|
+
if not self.command_results:
|
|
393
|
+
return "verification skipped: no commands"
|
|
394
|
+
passed = len([item for item in self.command_results if item.ok])
|
|
395
|
+
total = len(self.command_results)
|
|
396
|
+
skipped = [
|
|
397
|
+
item.command
|
|
398
|
+
for item in self.command_results
|
|
399
|
+
if item.status == VerificationCommandStatus.SKIPPED
|
|
400
|
+
]
|
|
401
|
+
if self.all_passed:
|
|
402
|
+
if skipped and len(skipped) == total:
|
|
403
|
+
return f"verification skipped: nothing to verify ({passed}/{total})"
|
|
404
|
+
if skipped:
|
|
405
|
+
return f"verification passed ({passed}/{total}); skipped: {', '.join(skipped)}"
|
|
406
|
+
return f"verification passed ({passed}/{total})"
|
|
407
|
+
return f"verification failed ({passed}/{total}); failed: {', '.join(self.failed_commands)}"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@dataclass(frozen=True)
|
|
411
|
+
class VerifyArtifactPayload:
|
|
412
|
+
artifact_path: str | None
|
|
413
|
+
artifact_saved: bool
|
|
414
|
+
artifact_readable_via_fs: bool
|
|
415
|
+
artifact_location: str
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@dataclass(frozen=True)
|
|
419
|
+
class ResolvedVerifyCommands:
|
|
420
|
+
commands: tuple[str, ...]
|
|
421
|
+
source: str
|
|
422
|
+
reason: str = field(default="", compare=False)
|
|
423
|
+
contract_type: str = field(default="", compare=False)
|
|
424
|
+
command_specs: tuple[VerificationCommandSpec, ...] = field(
|
|
425
|
+
default_factory=tuple,
|
|
426
|
+
compare=False,
|
|
427
|
+
)
|
|
428
|
+
# True once selection has degraded: the session still runs, but no command
|
|
429
|
+
# was found that Alysis Code can hold the result of a change against.
|
|
430
|
+
best_effort: bool = field(default=False, compare=False)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _default_verify_contract_type(source: str, *, commands: tuple[str, ...]) -> str:
|
|
434
|
+
if source == "session.verification_disabled":
|
|
435
|
+
return "disabled"
|
|
436
|
+
if source == "environment.authoritative_verification_commands":
|
|
437
|
+
return "authoritative_override"
|
|
438
|
+
if source == "cli.verify_cmd":
|
|
439
|
+
return "explicit_override"
|
|
440
|
+
if source in {"config.verify_commands", "repo_scan.likely_test_commands"}:
|
|
441
|
+
return "repo_native"
|
|
442
|
+
if source.startswith(
|
|
443
|
+
(
|
|
444
|
+
"task_refinement.node_test",
|
|
445
|
+
"task_refinement.doctest",
|
|
446
|
+
"task_refinement.explicit_pytest",
|
|
447
|
+
)
|
|
448
|
+
):
|
|
449
|
+
return "task_inferred"
|
|
450
|
+
if not commands or source.endswith("no_authoritative_commands"):
|
|
451
|
+
return "unavailable"
|
|
452
|
+
if source in GENERIC_VERIFY_FALLBACK_SOURCES:
|
|
453
|
+
return "generic_fallback"
|
|
454
|
+
return "selected"
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _default_verify_selection_reason(source: str) -> str:
|
|
458
|
+
return {
|
|
459
|
+
"session.verification_disabled": "verification is disabled for this session",
|
|
460
|
+
"environment.authoritative_verification_commands": (
|
|
461
|
+
"managed runtime injected authoritative verification commands"
|
|
462
|
+
),
|
|
463
|
+
"cli.verify_cmd": "explicit verification override supplied by the user",
|
|
464
|
+
"config.verify_commands": "repo-specific verify_commands configuration is authoritative",
|
|
465
|
+
"repo_scan.likely_test_commands": (
|
|
466
|
+
"repo scan discovered authoritative repo-native verification commands"
|
|
467
|
+
),
|
|
468
|
+
CONFIG_VERIFY_COMMANDS_FALLBACK_SOURCE: (
|
|
469
|
+
"using the configured generic fallback because repo scan found no repo-native command"
|
|
470
|
+
),
|
|
471
|
+
CONFIG_VERIFY_COMMANDS_GENERIC_PRESET_SOURCE: (
|
|
472
|
+
"using the configured generic verify preset because repo scan found no repo-native command"
|
|
473
|
+
),
|
|
474
|
+
REPO_SCAN_NO_AUTHORITATIVE_SOURCE: (
|
|
475
|
+
"repo scan invalidated the generic fallback because the workspace exposes no authoritative verification surface"
|
|
476
|
+
),
|
|
477
|
+
"task_refinement.node_test": (
|
|
478
|
+
"task-aware refinement preferred node --test over a generic Python fallback"
|
|
479
|
+
),
|
|
480
|
+
"task_refinement.doctest": (
|
|
481
|
+
"task-aware refinement selected doctest because the task explicitly requests it"
|
|
482
|
+
),
|
|
483
|
+
"task_refinement.explicit_pytest": (
|
|
484
|
+
"task-aware refinement selected the pytest command explicitly named by the task"
|
|
485
|
+
),
|
|
486
|
+
"task_refinement.no_authoritative_commands": (
|
|
487
|
+
"task-aware refinement suppressed the generic fallback because no confident verification command exists"
|
|
488
|
+
),
|
|
489
|
+
VERIFICATION_FALLBACK_DETECTED_SOURCE: (
|
|
490
|
+
"the originally selected verification command was unusable, so detection fell "
|
|
491
|
+
"through to the runner this workspace exposes"
|
|
492
|
+
),
|
|
493
|
+
VERIFICATION_FALLBACK_BEST_EFFORT_SOURCE: (
|
|
494
|
+
"no usable verification command was found, so verification is best-effort for "
|
|
495
|
+
"this session"
|
|
496
|
+
),
|
|
497
|
+
}.get(source, "")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _resolved_verify_commands(
|
|
501
|
+
*,
|
|
502
|
+
commands: tuple[str, ...] | list[str],
|
|
503
|
+
source: str,
|
|
504
|
+
reason: str | None = None,
|
|
505
|
+
contract_type: str | None = None,
|
|
506
|
+
best_effort: bool = False,
|
|
507
|
+
) -> ResolvedVerifyCommands:
|
|
508
|
+
normalized_commands = tuple(str(item).strip() for item in commands if str(item).strip())
|
|
509
|
+
resolved_reason = (
|
|
510
|
+
reason.strip()
|
|
511
|
+
if isinstance(reason, str) and reason.strip()
|
|
512
|
+
else _default_verify_selection_reason(source)
|
|
513
|
+
)
|
|
514
|
+
resolved_contract_type = (
|
|
515
|
+
contract_type.strip()
|
|
516
|
+
if isinstance(contract_type, str) and contract_type.strip()
|
|
517
|
+
else _default_verify_contract_type(source, commands=normalized_commands)
|
|
518
|
+
)
|
|
519
|
+
return ResolvedVerifyCommands(
|
|
520
|
+
commands=normalized_commands,
|
|
521
|
+
source=source,
|
|
522
|
+
reason=resolved_reason,
|
|
523
|
+
contract_type=resolved_contract_type,
|
|
524
|
+
command_specs=build_verification_command_specs(
|
|
525
|
+
normalized_commands,
|
|
526
|
+
source=source,
|
|
527
|
+
contract_type=resolved_contract_type,
|
|
528
|
+
),
|
|
529
|
+
best_effort=best_effort,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def verification_command_specs_for_selection(
|
|
534
|
+
selection: ResolvedVerifyCommands | None,
|
|
535
|
+
) -> tuple[VerificationCommandSpec, ...]:
|
|
536
|
+
if selection is None:
|
|
537
|
+
return tuple()
|
|
538
|
+
if selection.command_specs:
|
|
539
|
+
return selection.command_specs
|
|
540
|
+
return build_verification_command_specs(
|
|
541
|
+
selection.commands,
|
|
542
|
+
source=selection.source,
|
|
543
|
+
contract_type=selection.contract_type,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def verification_command_specs_payload(
|
|
548
|
+
selection: ResolvedVerifyCommands | None,
|
|
549
|
+
) -> dict[str, Any]:
|
|
550
|
+
specs = verification_command_specs_for_selection(selection)
|
|
551
|
+
return {"verification_command_specs": command_specs_payload(specs)}
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def trusted_shell_expression_command_set(selection: ResolvedVerifyCommands | None) -> set[str]:
|
|
555
|
+
return {
|
|
556
|
+
" ".join(spec.original_text.split())
|
|
557
|
+
for spec in verification_command_specs_for_selection(selection)
|
|
558
|
+
if spec.execution_mode == VerificationCommandExecutionMode.TRUSTED_SHELL_EXPRESSION
|
|
559
|
+
and spec.validation_status == VerificationCommandValidationStatus.VALID
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def validation_errors_for_selection(selection: ResolvedVerifyCommands | None) -> list[str]:
|
|
564
|
+
if selection is None:
|
|
565
|
+
return []
|
|
566
|
+
errors: list[str] = []
|
|
567
|
+
for spec in verification_command_specs_for_selection(selection):
|
|
568
|
+
if spec.validation_status != VerificationCommandValidationStatus.INVALID:
|
|
569
|
+
continue
|
|
570
|
+
errors.append(f"{spec.original_text}: {spec.rejection_reason or 'invalid_command'}")
|
|
571
|
+
return errors
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def command_is_docs_doctest(command: str) -> bool:
|
|
575
|
+
"""True when a command only runs doctest over documentation files.
|
|
576
|
+
|
|
577
|
+
Running the ``>>>`` examples in a README or a docs page proves the prose is
|
|
578
|
+
still accurate; it proves nothing about the change that was just made to the
|
|
579
|
+
code, so such a command is never an authoritative verification surface. Both
|
|
580
|
+
shapes are covered: ``python -m doctest <docs>`` and pytest driven at doc
|
|
581
|
+
files through ``--doctest-glob``.
|
|
582
|
+
"""
|
|
583
|
+
parts = split_verify_command_parts(str(command or ""))
|
|
584
|
+
if not parts:
|
|
585
|
+
return False
|
|
586
|
+
while parts and _is_env_assignment_token(parts[0]):
|
|
587
|
+
parts = parts[1:]
|
|
588
|
+
if parts and parts[0] == "env":
|
|
589
|
+
parts = parts[1:]
|
|
590
|
+
while parts and _is_env_assignment_token(parts[0]):
|
|
591
|
+
parts = parts[1:]
|
|
592
|
+
parts = strip_verify_runner_prefix(parts) or []
|
|
593
|
+
if not parts:
|
|
594
|
+
return False
|
|
595
|
+
module_args: list[str]
|
|
596
|
+
if _verify_command_basename(parts[0]) in _DOCTEST_MODULE_NAMES:
|
|
597
|
+
module_args = list(parts[1:])
|
|
598
|
+
elif len(parts) >= 3 and parts[1] == "-m" and parts[2].casefold() in _DOCTEST_MODULE_NAMES:
|
|
599
|
+
module_args = list(parts[3:])
|
|
600
|
+
else:
|
|
601
|
+
normalized = normalize_verify_module_invocation(parts)
|
|
602
|
+
if _verify_command_basename(normalized[0]) not in {"pytest", "py.test"}:
|
|
603
|
+
return False
|
|
604
|
+
module_args = list(normalized[1:])
|
|
605
|
+
if not any(
|
|
606
|
+
str(arg).casefold().startswith(_PYTEST_DOCTEST_GLOB_PREFIX) for arg in module_args
|
|
607
|
+
):
|
|
608
|
+
return False
|
|
609
|
+
targets = [arg for arg in module_args if not str(arg).startswith("-") and str(arg) != "--"]
|
|
610
|
+
if not targets:
|
|
611
|
+
return False
|
|
612
|
+
return all(_looks_like_docs_only_target(str(target)) for target in targets)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _verify_command_basename(token: str) -> str:
|
|
616
|
+
name = PurePosixPath(str(token).replace("\\", "/")).name.casefold()
|
|
617
|
+
return name[:-4] if name.endswith(".exe") else name
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def verification_selection_payload(
|
|
621
|
+
selection: ResolvedVerifyCommands,
|
|
622
|
+
*,
|
|
623
|
+
authoritative: bool,
|
|
624
|
+
) -> dict[str, Any]:
|
|
625
|
+
return {
|
|
626
|
+
"verification_selection_source": selection.source,
|
|
627
|
+
"verification_selection_reason": selection.reason,
|
|
628
|
+
"verification_contract_type": selection.contract_type,
|
|
629
|
+
"verification_authoritative": authoritative,
|
|
630
|
+
"verification_best_effort": selection.best_effort,
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def is_authoritative_verify_command_selection(selection: ResolvedVerifyCommands) -> bool:
|
|
635
|
+
return selection.contract_type in AUTHORITATIVE_VERIFY_CONTRACT_TYPES
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def is_generic_fallback_verify_command_selection(selection: ResolvedVerifyCommands) -> bool:
|
|
639
|
+
return selection.source in GENERIC_VERIFY_FALLBACK_SOURCES
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _task_signal_paths(task: dict[str, Any] | None) -> list[str]:
|
|
643
|
+
if not isinstance(task, dict):
|
|
644
|
+
return []
|
|
645
|
+
signals: list[str] = []
|
|
646
|
+
for key in ("estimated_files", "write_scope"):
|
|
647
|
+
raw = task.get(key)
|
|
648
|
+
if not isinstance(raw, list):
|
|
649
|
+
continue
|
|
650
|
+
for item in raw:
|
|
651
|
+
value = str(item or "").strip().replace("\\", "/")
|
|
652
|
+
while value.startswith("./"):
|
|
653
|
+
value = value[2:]
|
|
654
|
+
if value:
|
|
655
|
+
signals.append(value)
|
|
656
|
+
return signals
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _task_signal_texts(
|
|
660
|
+
task: dict[str, Any] | None,
|
|
661
|
+
*,
|
|
662
|
+
plan_requirements: list[str] | None = None,
|
|
663
|
+
) -> list[str]:
|
|
664
|
+
texts: list[str] = []
|
|
665
|
+
if isinstance(task, dict):
|
|
666
|
+
raw_acceptance = task.get("acceptance_criteria")
|
|
667
|
+
if isinstance(raw_acceptance, list):
|
|
668
|
+
for item in raw_acceptance:
|
|
669
|
+
value = str(item or "").strip()
|
|
670
|
+
if value:
|
|
671
|
+
texts.append(value)
|
|
672
|
+
if isinstance(plan_requirements, list):
|
|
673
|
+
for item in plan_requirements:
|
|
674
|
+
value = str(item or "").strip()
|
|
675
|
+
if value:
|
|
676
|
+
texts.append(value)
|
|
677
|
+
return texts
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _looks_like_js_or_ts_target(path: str) -> bool:
|
|
681
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
682
|
+
if not normalized:
|
|
683
|
+
return False
|
|
684
|
+
name = PurePosixPath(normalized).name
|
|
685
|
+
if name in _NODE_BOOTSTRAP_FILENAMES:
|
|
686
|
+
return True
|
|
687
|
+
return PurePosixPath(normalized).suffix.lower() in _NODE_JS_EXTENSIONS
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _looks_like_node_test_target(path: str) -> bool:
|
|
691
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
692
|
+
if not normalized:
|
|
693
|
+
return False
|
|
694
|
+
return bool(_NODE_TEST_PATH_RE.search(normalized) or _NODE_TEST_SUFFIX_RE.search(normalized))
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _looks_like_python_verify_target(path: str) -> bool:
|
|
698
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
699
|
+
if not normalized:
|
|
700
|
+
return False
|
|
701
|
+
pure = PurePosixPath(normalized)
|
|
702
|
+
name = pure.name
|
|
703
|
+
if name in _PYTHON_VERIFY_FILENAMES:
|
|
704
|
+
return True
|
|
705
|
+
return pure.suffix.lower() in {".py", ".pyi"}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _looks_like_docs_only_target(path: str) -> bool:
|
|
709
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
710
|
+
if not normalized:
|
|
711
|
+
return False
|
|
712
|
+
pure = PurePosixPath(normalized)
|
|
713
|
+
if path_has_known_verification_surface(normalized):
|
|
714
|
+
return False
|
|
715
|
+
parts = [part.casefold() for part in pure.parts]
|
|
716
|
+
name = pure.name.casefold()
|
|
717
|
+
stem = pure.stem.casefold()
|
|
718
|
+
if any(part in _DOC_ONLY_DIR_NAMES for part in parts[:-1]):
|
|
719
|
+
return pure.suffix.casefold() in _DOC_ONLY_EXTENSIONS
|
|
720
|
+
if name in _DOC_ONLY_DIR_NAMES:
|
|
721
|
+
return True
|
|
722
|
+
if name in _DOC_ONLY_FILENAMES or stem in _DOC_ONLY_FILENAMES:
|
|
723
|
+
return True
|
|
724
|
+
return any(
|
|
725
|
+
name == prefix
|
|
726
|
+
or name.startswith(f"{prefix}.")
|
|
727
|
+
or name.startswith(f"{prefix}-")
|
|
728
|
+
or name.startswith(f"{prefix}_")
|
|
729
|
+
for prefix in _DOC_ONLY_FILENAME_PREFIXES
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _looks_like_ci_target(path: str) -> bool:
|
|
734
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
735
|
+
if not normalized:
|
|
736
|
+
return False
|
|
737
|
+
pure = PurePosixPath(normalized)
|
|
738
|
+
parts = [part.casefold() for part in pure.parts]
|
|
739
|
+
name = pure.name.casefold()
|
|
740
|
+
if name in _CI_ONLY_FILENAMES:
|
|
741
|
+
return True
|
|
742
|
+
if name in _CI_ONLY_DIR_NAMES:
|
|
743
|
+
return True
|
|
744
|
+
if len(parts) >= 3 and parts[0] == ".github" and parts[1] == "workflows":
|
|
745
|
+
return pure.suffix.casefold() in _CI_ONLY_WORKFLOW_FILENAMES
|
|
746
|
+
return any(part in _CI_ONLY_DIR_NAMES for part in parts[:-1])
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _looks_like_terraform_target(path: str) -> bool:
|
|
750
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
751
|
+
if not normalized:
|
|
752
|
+
return False
|
|
753
|
+
pure = PurePosixPath(normalized)
|
|
754
|
+
if pure.name.casefold() in _TERRAFORM_FILENAMES:
|
|
755
|
+
return True
|
|
756
|
+
return pure.suffix.casefold() in _TERRAFORM_EXTENSIONS
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _looks_like_compose_target(path: str) -> bool:
|
|
760
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
761
|
+
if not normalized:
|
|
762
|
+
return False
|
|
763
|
+
pure = PurePosixPath(normalized)
|
|
764
|
+
return pure.name.casefold() in _COMPOSE_FILENAMES
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _looks_like_static_web_target(path: str) -> bool:
|
|
768
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
769
|
+
if not normalized:
|
|
770
|
+
return False
|
|
771
|
+
pure = PurePosixPath(normalized)
|
|
772
|
+
parts = [part.casefold() for part in pure.parts]
|
|
773
|
+
name = pure.name.casefold()
|
|
774
|
+
if name in _STATIC_WEB_DIR_NAMES:
|
|
775
|
+
return True
|
|
776
|
+
if name in _STATIC_WEB_FILENAMES:
|
|
777
|
+
return True
|
|
778
|
+
if pure.suffix.casefold() in _STATIC_WEB_EXTENSIONS:
|
|
779
|
+
return True
|
|
780
|
+
return bool(parts and parts[0] in _STATIC_WEB_DIR_NAMES)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _explicitly_requests_node_test(texts: list[str]) -> bool:
|
|
784
|
+
for raw_text in texts:
|
|
785
|
+
normalized = re.sub(r'[`"]', "", str(raw_text or "")).strip().casefold()
|
|
786
|
+
if not normalized:
|
|
787
|
+
continue
|
|
788
|
+
if _NODE_TEST_TEXT_NEGATIVE_RE.search(normalized):
|
|
789
|
+
continue
|
|
790
|
+
if _NODE_TEST_TEXT_POSITIVE_RE.search(normalized):
|
|
791
|
+
return True
|
|
792
|
+
return False
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _normalize_explicit_pytest_command(command: str) -> str | None:
|
|
796
|
+
try:
|
|
797
|
+
parts = shlex.split(command)
|
|
798
|
+
except ValueError:
|
|
799
|
+
return None
|
|
800
|
+
if not parts:
|
|
801
|
+
return None
|
|
802
|
+
lowered = [part.casefold() for part in parts]
|
|
803
|
+
if (
|
|
804
|
+
len(parts) >= 3
|
|
805
|
+
and lowered[0] in {"python", "python3", "py"}
|
|
806
|
+
and lowered[1:3] == ["-m", "pytest"]
|
|
807
|
+
):
|
|
808
|
+
return shlex.join([sys.executable, *parts[1:]])
|
|
809
|
+
if lowered[0] in _VERIFY_PYTEST_ENTRYPOINTS:
|
|
810
|
+
return shlex.join(parts)
|
|
811
|
+
return None
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _explicit_pytest_commands(texts: list[str]) -> tuple[str, ...]:
|
|
815
|
+
commands: list[str] = []
|
|
816
|
+
seen: set[str] = set()
|
|
817
|
+
for raw_text in texts:
|
|
818
|
+
text = str(raw_text or "")
|
|
819
|
+
candidates: list[tuple[str, bool]] = [
|
|
820
|
+
(match.group(1).strip(), True) for match in _BACKTICK_COMMAND_RE.finditer(text)
|
|
821
|
+
]
|
|
822
|
+
candidates.extend(
|
|
823
|
+
(match.group(0).strip(), False) for match in _PYTEST_TEXT_COMMAND_RE.finditer(text)
|
|
824
|
+
)
|
|
825
|
+
for candidate, from_backticks in candidates:
|
|
826
|
+
if "pytest" not in candidate.casefold():
|
|
827
|
+
continue
|
|
828
|
+
normalized = _normalize_explicit_pytest_command(candidate)
|
|
829
|
+
if not normalized:
|
|
830
|
+
continue
|
|
831
|
+
if not from_backticks and len(shlex.split(normalized)) <= 1:
|
|
832
|
+
continue
|
|
833
|
+
key = normalized.casefold()
|
|
834
|
+
if key in seen:
|
|
835
|
+
continue
|
|
836
|
+
seen.add(key)
|
|
837
|
+
commands.append(normalized)
|
|
838
|
+
return tuple(commands[:3])
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _texts_look_docs_only(texts: list[str]) -> bool:
|
|
842
|
+
return any(_DOCS_ONLY_TEXT_RE.search(str(item or "").casefold()) for item in texts)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _texts_request_doctest(texts: list[str]) -> bool:
|
|
846
|
+
return any(_DOCTEST_TEXT_RE.search(str(item or "")) for item in texts)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _doctest_target_paths(task_paths: list[str], *, root: Path | None) -> tuple[str, ...]:
|
|
850
|
+
candidates: list[str] = []
|
|
851
|
+
for raw_path in task_paths:
|
|
852
|
+
normalized = str(raw_path or "").strip().replace("\\", "/")
|
|
853
|
+
while normalized.startswith("./"):
|
|
854
|
+
normalized = normalized[2:]
|
|
855
|
+
if not normalized or not _looks_like_docs_only_target(normalized):
|
|
856
|
+
continue
|
|
857
|
+
if normalized == "README" and root is not None and (root / "README.md").is_file():
|
|
858
|
+
normalized = "README.md"
|
|
859
|
+
candidates.append(normalized)
|
|
860
|
+
if not candidates and root is not None:
|
|
861
|
+
for readme_name in ("README.md", "README.rst", "README.txt", "README"):
|
|
862
|
+
if (root / readme_name).is_file():
|
|
863
|
+
candidates.append(readme_name)
|
|
864
|
+
break
|
|
865
|
+
seen: set[str] = set()
|
|
866
|
+
out: list[str] = []
|
|
867
|
+
for path in candidates:
|
|
868
|
+
key = path.casefold()
|
|
869
|
+
if key in seen:
|
|
870
|
+
continue
|
|
871
|
+
seen.add(key)
|
|
872
|
+
out.append(path)
|
|
873
|
+
return tuple(out)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _doctest_verify_commands(paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
877
|
+
commands: list[str] = []
|
|
878
|
+
if paths:
|
|
879
|
+
commands.append(shlex.join([sys.executable, "-m", "doctest", *paths]))
|
|
880
|
+
for path in paths:
|
|
881
|
+
glob = PurePosixPath(path).name
|
|
882
|
+
commands.append(
|
|
883
|
+
shlex.join([sys.executable, "-m", "pytest", f"--doctest-glob={glob}", "-q", path])
|
|
884
|
+
)
|
|
885
|
+
return tuple(commands)
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _texts_look_ci_only(texts: list[str]) -> bool:
|
|
889
|
+
return any(_CI_ONLY_TEXT_RE.search(str(item or "").casefold()) for item in texts)
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _texts_look_terraform_or_compose(texts: list[str]) -> bool:
|
|
893
|
+
for item in texts:
|
|
894
|
+
normalized = str(item or "").casefold()
|
|
895
|
+
if _TERRAFORM_TEXT_RE.search(normalized) or _COMPOSE_TEXT_RE.search(normalized):
|
|
896
|
+
return True
|
|
897
|
+
return False
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _texts_mention_compose_shorthand(texts: list[str]) -> bool:
|
|
901
|
+
return any(re.search(r"\bcompose\b", str(item or "").casefold()) for item in texts)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _texts_look_js_frontend_task(texts: list[str]) -> bool:
|
|
905
|
+
return any(_JS_FRONTEND_TEXT_RE.search(str(item or "").casefold()) for item in texts)
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _texts_look_static_web_task(texts: list[str]) -> bool:
|
|
909
|
+
return any(_STATIC_WEB_TEXT_RE.search(str(item or "").casefold()) for item in texts)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _texts_look_python_code_task(texts: list[str]) -> bool:
|
|
913
|
+
return any(_PYTHON_CODE_TASK_TEXT_RE.search(str(item or "").casefold()) for item in texts)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _repo_has_node_hints(scan: RepoScanResult | None) -> bool:
|
|
917
|
+
if scan is None:
|
|
918
|
+
return False
|
|
919
|
+
language_hints = {str(item).strip().lower() for item in scan.language_hints}
|
|
920
|
+
package_hints = {str(item).strip().lower() for item in scan.package_hints}
|
|
921
|
+
return bool(
|
|
922
|
+
{"javascript", "typescript"} & language_hints
|
|
923
|
+
or _NODE_PACKAGE_HINTS & package_hints
|
|
924
|
+
or any(
|
|
925
|
+
PurePosixPath(str(item.get("path") or "")).name in _NODE_BOOTSTRAP_FILENAMES
|
|
926
|
+
for item in scan.manifests
|
|
927
|
+
)
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _repo_has_python_hints(scan: RepoScanResult | None) -> bool:
|
|
932
|
+
if scan is None:
|
|
933
|
+
return False
|
|
934
|
+
language_hints = {str(item).strip().lower() for item in scan.language_hints}
|
|
935
|
+
package_hints = {str(item).strip().lower() for item in scan.package_hints}
|
|
936
|
+
return bool(
|
|
937
|
+
"python" in language_hints
|
|
938
|
+
or "python" in package_hints
|
|
939
|
+
or any(str(item.get("kind") or "").strip().lower() == "python" for item in scan.manifests)
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _repo_has_authoritative_verify_commands(scan: RepoScanResult | None) -> bool:
|
|
944
|
+
if scan is None:
|
|
945
|
+
return False
|
|
946
|
+
return bool(normalize_verify_command_list(scan.likely_test_commands))
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _repo_has_compose_hints(scan: RepoScanResult | None) -> bool:
|
|
950
|
+
if scan is None:
|
|
951
|
+
return False
|
|
952
|
+
return any(
|
|
953
|
+
PurePosixPath(str(item.get("path") or "")).name.casefold() in _COMPOSE_FILENAMES
|
|
954
|
+
for item in scan.manifests
|
|
955
|
+
)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _scan_manifest_kind_set(scan: RepoScanResult | None) -> set[str]:
|
|
959
|
+
if scan is None:
|
|
960
|
+
return set()
|
|
961
|
+
return {
|
|
962
|
+
str(item.get("kind") or "").strip().lower()
|
|
963
|
+
for item in scan.manifests
|
|
964
|
+
if str(item.get("kind") or "").strip()
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _scan_language_hint_set(scan: RepoScanResult | None) -> set[str]:
|
|
969
|
+
if scan is None:
|
|
970
|
+
return set()
|
|
971
|
+
return {str(item).strip().lower() for item in scan.language_hints if str(item).strip()}
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _scan_package_hint_set(scan: RepoScanResult | None) -> set[str]:
|
|
975
|
+
if scan is None:
|
|
976
|
+
return set()
|
|
977
|
+
return {str(item).strip().lower() for item in scan.package_hints if str(item).strip()}
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _scan_classification_paths(scan: RepoScanResult | None) -> list[str]:
|
|
981
|
+
if scan is None:
|
|
982
|
+
return []
|
|
983
|
+
seen: set[str] = set()
|
|
984
|
+
paths: list[str] = []
|
|
985
|
+
raw_paths = [
|
|
986
|
+
*[str(item.get("path") or "") for item in scan.top_level_entries],
|
|
987
|
+
*[str(item.get("path") or "") for item in scan.manifests],
|
|
988
|
+
*[str(item or "") for item in scan.readme_paths],
|
|
989
|
+
str(scan.conventions_path or ""),
|
|
990
|
+
]
|
|
991
|
+
for raw in raw_paths:
|
|
992
|
+
normalized = str(raw or "").strip().replace("\\", "/")
|
|
993
|
+
if not normalized or normalized in seen:
|
|
994
|
+
continue
|
|
995
|
+
seen.add(normalized)
|
|
996
|
+
paths.append(normalized)
|
|
997
|
+
return paths
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _looks_like_neutral_repo_classification_target(path: str) -> bool:
|
|
1001
|
+
normalized = str(path or "").strip().replace("\\", "/")
|
|
1002
|
+
if not normalized:
|
|
1003
|
+
return True
|
|
1004
|
+
if (
|
|
1005
|
+
_looks_like_docs_only_target(normalized)
|
|
1006
|
+
or _looks_like_ci_target(normalized)
|
|
1007
|
+
or _looks_like_terraform_target(normalized)
|
|
1008
|
+
or _looks_like_compose_target(normalized)
|
|
1009
|
+
or _looks_like_js_or_ts_target(normalized)
|
|
1010
|
+
or _looks_like_python_verify_target(normalized)
|
|
1011
|
+
):
|
|
1012
|
+
return False
|
|
1013
|
+
pure = PurePosixPath(normalized)
|
|
1014
|
+
name = pure.name.casefold()
|
|
1015
|
+
if name in _REPO_CLASSIFICATION_NEUTRAL_NAMES:
|
|
1016
|
+
return True
|
|
1017
|
+
return name.startswith(".")
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _repo_paths_match_confident_shape(
|
|
1021
|
+
scan: RepoScanResult | None,
|
|
1022
|
+
*,
|
|
1023
|
+
path_matcher: Any,
|
|
1024
|
+
allow_docs: bool = False,
|
|
1025
|
+
) -> bool:
|
|
1026
|
+
matched = False
|
|
1027
|
+
for path in _scan_classification_paths(scan):
|
|
1028
|
+
if _looks_like_neutral_repo_classification_target(path):
|
|
1029
|
+
continue
|
|
1030
|
+
if path_matcher(path):
|
|
1031
|
+
matched = True
|
|
1032
|
+
continue
|
|
1033
|
+
if allow_docs and _looks_like_docs_only_target(path):
|
|
1034
|
+
continue
|
|
1035
|
+
return False
|
|
1036
|
+
return matched
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _repo_is_confident_docs_only_without_verify_surface(scan: RepoScanResult | None) -> bool:
|
|
1040
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1041
|
+
return False
|
|
1042
|
+
if (
|
|
1043
|
+
_scan_manifest_kind_set(scan)
|
|
1044
|
+
or _scan_language_hint_set(scan)
|
|
1045
|
+
or _scan_package_hint_set(scan)
|
|
1046
|
+
):
|
|
1047
|
+
return False
|
|
1048
|
+
return _repo_paths_match_confident_shape(scan, path_matcher=_looks_like_docs_only_target)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _repo_is_confident_static_web_without_verify_surface(scan: RepoScanResult | None) -> bool:
|
|
1052
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1053
|
+
return False
|
|
1054
|
+
if (
|
|
1055
|
+
_scan_manifest_kind_set(scan)
|
|
1056
|
+
or _scan_language_hint_set(scan)
|
|
1057
|
+
or _scan_package_hint_set(scan)
|
|
1058
|
+
):
|
|
1059
|
+
return False
|
|
1060
|
+
return _repo_paths_match_confident_shape(
|
|
1061
|
+
scan,
|
|
1062
|
+
path_matcher=_looks_like_static_web_target,
|
|
1063
|
+
allow_docs=True,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _repo_is_confident_ci_only_without_verify_surface(scan: RepoScanResult | None) -> bool:
|
|
1068
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1069
|
+
return False
|
|
1070
|
+
if (
|
|
1071
|
+
_scan_manifest_kind_set(scan)
|
|
1072
|
+
or _scan_language_hint_set(scan)
|
|
1073
|
+
or _scan_package_hint_set(scan)
|
|
1074
|
+
):
|
|
1075
|
+
return False
|
|
1076
|
+
return _repo_paths_match_confident_shape(
|
|
1077
|
+
scan,
|
|
1078
|
+
path_matcher=_looks_like_ci_target,
|
|
1079
|
+
allow_docs=True,
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _repo_is_confident_terraform_only_without_verify_surface(scan: RepoScanResult | None) -> bool:
|
|
1084
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1085
|
+
return False
|
|
1086
|
+
if _repo_has_node_hints(scan) or _repo_has_python_hints(scan):
|
|
1087
|
+
return False
|
|
1088
|
+
if _scan_manifest_kind_set(scan) - {"build"}:
|
|
1089
|
+
return False
|
|
1090
|
+
if _scan_language_hint_set(scan):
|
|
1091
|
+
return False
|
|
1092
|
+
if _scan_package_hint_set(scan) - {"just", "make"}:
|
|
1093
|
+
return False
|
|
1094
|
+
return _repo_paths_match_confident_shape(
|
|
1095
|
+
scan,
|
|
1096
|
+
path_matcher=_looks_like_terraform_target,
|
|
1097
|
+
allow_docs=True,
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
def _repo_is_confident_compose_only_without_verify_surface(scan: RepoScanResult | None) -> bool:
|
|
1102
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1103
|
+
return False
|
|
1104
|
+
if _repo_has_node_hints(scan) or _repo_has_python_hints(scan):
|
|
1105
|
+
return False
|
|
1106
|
+
if _scan_manifest_kind_set(scan) - {"build", "docker"}:
|
|
1107
|
+
return False
|
|
1108
|
+
if _scan_language_hint_set(scan) - {"docker"}:
|
|
1109
|
+
return False
|
|
1110
|
+
if _scan_package_hint_set(scan) - {"docker", "just", "make"}:
|
|
1111
|
+
return False
|
|
1112
|
+
return _repo_paths_match_confident_shape(
|
|
1113
|
+
scan,
|
|
1114
|
+
path_matcher=_looks_like_compose_target,
|
|
1115
|
+
allow_docs=True,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _repo_is_confident_node_workspace_without_tests(scan: RepoScanResult | None) -> bool:
|
|
1120
|
+
if (
|
|
1121
|
+
scan is None
|
|
1122
|
+
or _repo_has_authoritative_verify_commands(scan)
|
|
1123
|
+
or not _repo_has_node_hints(scan)
|
|
1124
|
+
or _repo_has_python_hints(scan)
|
|
1125
|
+
):
|
|
1126
|
+
return False
|
|
1127
|
+
manifest_kinds = _scan_manifest_kind_set(scan)
|
|
1128
|
+
language_hints = _scan_language_hint_set(scan)
|
|
1129
|
+
package_hints = _scan_package_hint_set(scan)
|
|
1130
|
+
if manifest_kinds and not manifest_kinds <= _NODE_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS:
|
|
1131
|
+
return False
|
|
1132
|
+
if language_hints and not language_hints <= _NODE_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS:
|
|
1133
|
+
return False
|
|
1134
|
+
if package_hints and not package_hints <= _NODE_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS:
|
|
1135
|
+
return False
|
|
1136
|
+
return bool(
|
|
1137
|
+
manifest_kinds & {"node", "typescript"} or package_hints & (_NODE_PACKAGE_HINTS | {"node"})
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def _repo_is_confident_python_workspace_without_tests(scan: RepoScanResult | None) -> bool:
|
|
1142
|
+
if (
|
|
1143
|
+
scan is None
|
|
1144
|
+
or _repo_has_authoritative_verify_commands(scan)
|
|
1145
|
+
or not _repo_has_python_hints(scan)
|
|
1146
|
+
or _repo_has_node_hints(scan)
|
|
1147
|
+
):
|
|
1148
|
+
return False
|
|
1149
|
+
manifest_kinds = _scan_manifest_kind_set(scan)
|
|
1150
|
+
language_hints = _scan_language_hint_set(scan)
|
|
1151
|
+
package_hints = _scan_package_hint_set(scan)
|
|
1152
|
+
if manifest_kinds and not manifest_kinds <= _PYTHON_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS:
|
|
1153
|
+
return False
|
|
1154
|
+
if language_hints and not language_hints <= _PYTHON_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS:
|
|
1155
|
+
return False
|
|
1156
|
+
if package_hints and not package_hints <= _PYTHON_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS:
|
|
1157
|
+
return False
|
|
1158
|
+
return "python" in language_hints or "python" in package_hints or "python" in manifest_kinds
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _repo_is_confident_mixed_node_python_workspace_without_tests(
|
|
1162
|
+
scan: RepoScanResult | None,
|
|
1163
|
+
) -> bool:
|
|
1164
|
+
if (
|
|
1165
|
+
scan is None
|
|
1166
|
+
or _repo_has_authoritative_verify_commands(scan)
|
|
1167
|
+
or not _repo_has_node_hints(scan)
|
|
1168
|
+
or not _repo_has_python_hints(scan)
|
|
1169
|
+
):
|
|
1170
|
+
return False
|
|
1171
|
+
manifest_kinds = _scan_manifest_kind_set(scan)
|
|
1172
|
+
language_hints = _scan_language_hint_set(scan)
|
|
1173
|
+
package_hints = _scan_package_hint_set(scan)
|
|
1174
|
+
if (
|
|
1175
|
+
manifest_kinds
|
|
1176
|
+
and not manifest_kinds <= _MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_MANIFEST_KINDS
|
|
1177
|
+
):
|
|
1178
|
+
return False
|
|
1179
|
+
if (
|
|
1180
|
+
language_hints
|
|
1181
|
+
and not language_hints <= _MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_LANGUAGE_HINTS
|
|
1182
|
+
):
|
|
1183
|
+
return False
|
|
1184
|
+
if (
|
|
1185
|
+
package_hints
|
|
1186
|
+
and not package_hints <= _MIXED_NODE_PYTHON_REPO_CLASSIFICATION_ALLOWED_PACKAGE_HINTS
|
|
1187
|
+
):
|
|
1188
|
+
return False
|
|
1189
|
+
has_node_surface = bool(
|
|
1190
|
+
manifest_kinds & {"node", "typescript"}
|
|
1191
|
+
or language_hints & {"javascript", "typescript"}
|
|
1192
|
+
or package_hints & (_NODE_PACKAGE_HINTS | {"node"})
|
|
1193
|
+
)
|
|
1194
|
+
has_python_surface = bool(
|
|
1195
|
+
"python" in manifest_kinds or "python" in language_hints or "python" in package_hints
|
|
1196
|
+
)
|
|
1197
|
+
return has_node_surface and has_python_surface
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _repo_grounded_no_authoritative_selection(
|
|
1201
|
+
scan: RepoScanResult | None,
|
|
1202
|
+
) -> ResolvedVerifyCommands | None:
|
|
1203
|
+
if scan is None or _repo_has_authoritative_verify_commands(scan):
|
|
1204
|
+
return None
|
|
1205
|
+
if _repo_is_confident_docs_only_without_verify_surface(scan):
|
|
1206
|
+
return _resolved_verify_commands(
|
|
1207
|
+
commands=(),
|
|
1208
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1209
|
+
reason="repo scan found a docs-only workspace with no authoritative verification surface",
|
|
1210
|
+
contract_type="unavailable",
|
|
1211
|
+
)
|
|
1212
|
+
if _repo_is_confident_static_web_without_verify_surface(scan):
|
|
1213
|
+
return _resolved_verify_commands(
|
|
1214
|
+
commands=(),
|
|
1215
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1216
|
+
reason="repo scan found a static web workspace with no authoritative verification surface",
|
|
1217
|
+
contract_type="unavailable",
|
|
1218
|
+
)
|
|
1219
|
+
if _repo_is_confident_ci_only_without_verify_surface(scan):
|
|
1220
|
+
return _resolved_verify_commands(
|
|
1221
|
+
commands=(),
|
|
1222
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1223
|
+
reason="repo scan found a CI-only workspace with no authoritative verification surface",
|
|
1224
|
+
contract_type="unavailable",
|
|
1225
|
+
)
|
|
1226
|
+
if _repo_is_confident_terraform_only_without_verify_surface(scan):
|
|
1227
|
+
return _resolved_verify_commands(
|
|
1228
|
+
commands=(),
|
|
1229
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1230
|
+
reason="repo scan found a Terraform-only workspace with no authoritative verification surface",
|
|
1231
|
+
contract_type="unavailable",
|
|
1232
|
+
)
|
|
1233
|
+
if _repo_is_confident_compose_only_without_verify_surface(scan):
|
|
1234
|
+
return _resolved_verify_commands(
|
|
1235
|
+
commands=(),
|
|
1236
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1237
|
+
reason="repo scan found a Compose-only workspace with no authoritative verification surface",
|
|
1238
|
+
contract_type="unavailable",
|
|
1239
|
+
)
|
|
1240
|
+
if _repo_is_confident_mixed_node_python_workspace_without_tests(scan):
|
|
1241
|
+
return _resolved_verify_commands(
|
|
1242
|
+
commands=(),
|
|
1243
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1244
|
+
reason="repo scan found a mixed workspace without an authoritative verification surface",
|
|
1245
|
+
contract_type="unavailable",
|
|
1246
|
+
)
|
|
1247
|
+
if _repo_is_confident_node_workspace_without_tests(scan):
|
|
1248
|
+
return _resolved_verify_commands(
|
|
1249
|
+
commands=(),
|
|
1250
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1251
|
+
reason="repo scan found a JS/Node workspace without a real repo-native test command",
|
|
1252
|
+
contract_type="unavailable",
|
|
1253
|
+
)
|
|
1254
|
+
if _repo_is_confident_python_workspace_without_tests(scan):
|
|
1255
|
+
return _resolved_verify_commands(
|
|
1256
|
+
commands=(),
|
|
1257
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1258
|
+
reason="repo scan found a Python workspace without a discoverable test surface",
|
|
1259
|
+
contract_type="unavailable",
|
|
1260
|
+
)
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def refine_generic_fallback_verify_command_selection(
|
|
1265
|
+
*,
|
|
1266
|
+
selection: ResolvedVerifyCommands,
|
|
1267
|
+
task: dict[str, Any] | None,
|
|
1268
|
+
root: Path | None = None,
|
|
1269
|
+
repo_scan: RepoScanResult | None = None,
|
|
1270
|
+
plan_requirements: list[str] | None = None,
|
|
1271
|
+
) -> ResolvedVerifyCommands:
|
|
1272
|
+
if not is_generic_fallback_verify_command_selection(selection):
|
|
1273
|
+
return selection
|
|
1274
|
+
suppressible_generic_fallback = is_generic_fallback_verify_command_selection(selection)
|
|
1275
|
+
|
|
1276
|
+
scan = repo_scan
|
|
1277
|
+
if scan is None and root is not None:
|
|
1278
|
+
try:
|
|
1279
|
+
scan = scan_workspace(context=resolve_workspace_context(root))
|
|
1280
|
+
except (WorkspaceContextError, OSError):
|
|
1281
|
+
scan = None
|
|
1282
|
+
|
|
1283
|
+
task_paths = _task_signal_paths(task)
|
|
1284
|
+
generic_commands_apply_to_task_paths = bool(
|
|
1285
|
+
task_paths
|
|
1286
|
+
) and verification_commands_apply_to_paths(
|
|
1287
|
+
set(task_paths),
|
|
1288
|
+
selection.commands,
|
|
1289
|
+
)
|
|
1290
|
+
task_texts = _task_signal_texts(task, plan_requirements=plan_requirements)
|
|
1291
|
+
repo_grounded_no_authoritative = _repo_grounded_no_authoritative_selection(scan)
|
|
1292
|
+
task_has_python_signals = any(_looks_like_python_verify_target(path) for path in task_paths)
|
|
1293
|
+
task_has_docs_only_signals = bool(task_paths) and all(
|
|
1294
|
+
_looks_like_docs_only_target(path) for path in task_paths
|
|
1295
|
+
)
|
|
1296
|
+
task_has_static_web_signals = bool(task_paths) and all(
|
|
1297
|
+
_looks_like_static_web_target(path) or _looks_like_docs_only_target(path)
|
|
1298
|
+
for path in task_paths
|
|
1299
|
+
)
|
|
1300
|
+
task_has_ci_only_signals = bool(task_paths) and all(
|
|
1301
|
+
_looks_like_ci_target(path) for path in task_paths
|
|
1302
|
+
)
|
|
1303
|
+
task_has_terraform_only_signals = bool(task_paths) and all(
|
|
1304
|
+
_looks_like_terraform_target(path) for path in task_paths
|
|
1305
|
+
)
|
|
1306
|
+
task_has_compose_only_signals = bool(task_paths) and all(
|
|
1307
|
+
_looks_like_compose_target(path) for path in task_paths
|
|
1308
|
+
)
|
|
1309
|
+
repo_has_authoritative_commands = _repo_has_authoritative_verify_commands(scan)
|
|
1310
|
+
repo_node_hints = _repo_has_node_hints(scan)
|
|
1311
|
+
repo_python_hints = _repo_has_python_hints(scan)
|
|
1312
|
+
|
|
1313
|
+
task_has_node_test_targets = any(_looks_like_node_test_target(path) for path in task_paths)
|
|
1314
|
+
if task_has_node_test_targets:
|
|
1315
|
+
return _resolved_verify_commands(
|
|
1316
|
+
commands=("node --test",),
|
|
1317
|
+
source="task_refinement.node_test",
|
|
1318
|
+
reason="task targets Node test files, so node --test is a better fit than generic pytest",
|
|
1319
|
+
contract_type="task_inferred",
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
task_has_node_bootstrap_targets = any(
|
|
1323
|
+
PurePosixPath(path).name in _NODE_BOOTSTRAP_FILENAMES for path in task_paths
|
|
1324
|
+
)
|
|
1325
|
+
task_has_js_targets = any(_looks_like_js_or_ts_target(path) for path in task_paths)
|
|
1326
|
+
task_explicitly_requests_node_test = _explicitly_requests_node_test(task_texts)
|
|
1327
|
+
task_has_pathless_js_frontend_signals = (
|
|
1328
|
+
not task_paths
|
|
1329
|
+
and repo_node_hints
|
|
1330
|
+
and not repo_has_authoritative_commands
|
|
1331
|
+
and _texts_look_js_frontend_task(task_texts)
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
if task_explicitly_requests_node_test and (
|
|
1335
|
+
task_has_node_bootstrap_targets or task_has_js_targets or repo_node_hints
|
|
1336
|
+
):
|
|
1337
|
+
return _resolved_verify_commands(
|
|
1338
|
+
commands=("node --test",),
|
|
1339
|
+
source="task_refinement.node_test",
|
|
1340
|
+
reason="task text explicitly requests node --test in a JS/Node-compatible context",
|
|
1341
|
+
contract_type="task_inferred",
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
repo_has_compose_hints = _repo_has_compose_hints(scan)
|
|
1345
|
+
task_has_pathless_compose_shorthand = (
|
|
1346
|
+
not task_paths and repo_has_compose_hints and _texts_mention_compose_shorthand(task_texts)
|
|
1347
|
+
)
|
|
1348
|
+
explicit_pytest_commands = _explicit_pytest_commands(task_texts)
|
|
1349
|
+
if explicit_pytest_commands and (
|
|
1350
|
+
task_has_python_signals
|
|
1351
|
+
or repo_python_hints
|
|
1352
|
+
or task_has_docs_only_signals
|
|
1353
|
+
or _texts_look_python_code_task(task_texts)
|
|
1354
|
+
):
|
|
1355
|
+
return _resolved_verify_commands(
|
|
1356
|
+
commands=explicit_pytest_commands,
|
|
1357
|
+
source="task_refinement.explicit_pytest",
|
|
1358
|
+
reason="task text explicitly names pytest verification command(s)",
|
|
1359
|
+
contract_type="task_inferred",
|
|
1360
|
+
)
|
|
1361
|
+
doctest_paths = (
|
|
1362
|
+
_doctest_target_paths(task_paths, root=root) if _texts_request_doctest(task_texts) else ()
|
|
1363
|
+
)
|
|
1364
|
+
if doctest_paths:
|
|
1365
|
+
return _resolved_verify_commands(
|
|
1366
|
+
commands=_doctest_verify_commands(doctest_paths),
|
|
1367
|
+
source="task_refinement.doctest",
|
|
1368
|
+
reason="task text explicitly requests doctest for documentation targets",
|
|
1369
|
+
contract_type="task_inferred",
|
|
1370
|
+
)
|
|
1371
|
+
if task_has_docs_only_signals or (not task_paths and _texts_look_docs_only(task_texts)):
|
|
1372
|
+
if not suppressible_generic_fallback:
|
|
1373
|
+
return selection
|
|
1374
|
+
return _resolved_verify_commands(
|
|
1375
|
+
commands=(),
|
|
1376
|
+
source="task_refinement.no_authoritative_commands",
|
|
1377
|
+
reason="docs-only task does not expose a confident verification command",
|
|
1378
|
+
contract_type="unavailable",
|
|
1379
|
+
)
|
|
1380
|
+
if task_has_static_web_signals or (not task_paths and _texts_look_static_web_task(task_texts)):
|
|
1381
|
+
if not suppressible_generic_fallback:
|
|
1382
|
+
return selection
|
|
1383
|
+
return _resolved_verify_commands(
|
|
1384
|
+
commands=(),
|
|
1385
|
+
source="task_refinement.no_authoritative_commands",
|
|
1386
|
+
reason="static web task does not expose a confident verification command",
|
|
1387
|
+
contract_type="unavailable",
|
|
1388
|
+
)
|
|
1389
|
+
if task_has_ci_only_signals or (not task_paths and _texts_look_ci_only(task_texts)):
|
|
1390
|
+
if not suppressible_generic_fallback:
|
|
1391
|
+
return selection
|
|
1392
|
+
return _resolved_verify_commands(
|
|
1393
|
+
commands=(),
|
|
1394
|
+
source="task_refinement.no_authoritative_commands",
|
|
1395
|
+
reason="CI-only task does not expose a confident repo-native verification command",
|
|
1396
|
+
contract_type="unavailable",
|
|
1397
|
+
)
|
|
1398
|
+
if (
|
|
1399
|
+
task_has_terraform_only_signals
|
|
1400
|
+
or task_has_compose_only_signals
|
|
1401
|
+
or (not task_paths and _texts_look_terraform_or_compose(task_texts))
|
|
1402
|
+
or task_has_pathless_compose_shorthand
|
|
1403
|
+
):
|
|
1404
|
+
if not suppressible_generic_fallback:
|
|
1405
|
+
return selection
|
|
1406
|
+
return _resolved_verify_commands(
|
|
1407
|
+
commands=(),
|
|
1408
|
+
source="task_refinement.no_authoritative_commands",
|
|
1409
|
+
reason="Terraform/Compose task does not expose a confident repo-native verification command",
|
|
1410
|
+
contract_type="unavailable",
|
|
1411
|
+
)
|
|
1412
|
+
task_has_pathless_python_code_signals = (
|
|
1413
|
+
not task_paths and repo_python_hints and _texts_look_python_code_task(task_texts)
|
|
1414
|
+
)
|
|
1415
|
+
if task_has_python_signals or task_has_pathless_python_code_signals:
|
|
1416
|
+
if (
|
|
1417
|
+
scan is not None
|
|
1418
|
+
and not repo_has_authoritative_commands
|
|
1419
|
+
and suppressible_generic_fallback
|
|
1420
|
+
):
|
|
1421
|
+
return _resolved_verify_commands(
|
|
1422
|
+
commands=(),
|
|
1423
|
+
source="task_refinement.no_authoritative_commands",
|
|
1424
|
+
reason="Python task has no discoverable test surface, so generic pytest is not trusted",
|
|
1425
|
+
contract_type="unavailable",
|
|
1426
|
+
)
|
|
1427
|
+
return selection
|
|
1428
|
+
|
|
1429
|
+
node_project_script_commands = (
|
|
1430
|
+
normalize_verify_command_list(
|
|
1431
|
+
_infer_node_project_script_verify_commands(root=root, repo_scan=scan)
|
|
1432
|
+
)
|
|
1433
|
+
if root is not None
|
|
1434
|
+
and scan is not None
|
|
1435
|
+
and repo_node_hints
|
|
1436
|
+
and not repo_has_authoritative_commands
|
|
1437
|
+
else ()
|
|
1438
|
+
)
|
|
1439
|
+
task_points_to_node_surface = (
|
|
1440
|
+
task_has_node_bootstrap_targets
|
|
1441
|
+
or task_has_js_targets
|
|
1442
|
+
or task_has_pathless_js_frontend_signals
|
|
1443
|
+
or (not task_paths and not repo_python_hints)
|
|
1444
|
+
)
|
|
1445
|
+
if node_project_script_commands and (not repo_python_hints or task_points_to_node_surface):
|
|
1446
|
+
return _resolved_verify_commands(
|
|
1447
|
+
commands=node_project_script_commands,
|
|
1448
|
+
source="repo_scan.likely_test_commands",
|
|
1449
|
+
reason="repo scan discovered package.json verification scripts",
|
|
1450
|
+
contract_type="repo_native",
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
if (
|
|
1454
|
+
task_has_node_bootstrap_targets
|
|
1455
|
+
or task_has_js_targets
|
|
1456
|
+
or task_has_pathless_js_frontend_signals
|
|
1457
|
+
):
|
|
1458
|
+
if not suppressible_generic_fallback:
|
|
1459
|
+
return selection
|
|
1460
|
+
return _resolved_verify_commands(
|
|
1461
|
+
commands=(),
|
|
1462
|
+
source="task_refinement.no_authoritative_commands",
|
|
1463
|
+
reason="frontend/JS task should not inherit a generic Python verification fallback",
|
|
1464
|
+
contract_type="unavailable",
|
|
1465
|
+
)
|
|
1466
|
+
|
|
1467
|
+
if repo_grounded_no_authoritative is not None and suppressible_generic_fallback:
|
|
1468
|
+
return repo_grounded_no_authoritative
|
|
1469
|
+
|
|
1470
|
+
if task_paths and suppressible_generic_fallback and not generic_commands_apply_to_task_paths:
|
|
1471
|
+
return _resolved_verify_commands(
|
|
1472
|
+
commands=(),
|
|
1473
|
+
source="task_refinement.no_authoritative_commands",
|
|
1474
|
+
reason=("configured generic verification commands do not apply to the task paths"),
|
|
1475
|
+
contract_type="unavailable",
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
if (
|
|
1479
|
+
scan is not None
|
|
1480
|
+
and suppressible_generic_fallback
|
|
1481
|
+
and not repo_has_authoritative_commands
|
|
1482
|
+
and not repo_python_hints
|
|
1483
|
+
and not task_has_python_signals
|
|
1484
|
+
and not task_has_pathless_python_code_signals
|
|
1485
|
+
and not explicit_pytest_commands
|
|
1486
|
+
and not generic_commands_apply_to_task_paths
|
|
1487
|
+
):
|
|
1488
|
+
return _resolved_verify_commands(
|
|
1489
|
+
commands=(),
|
|
1490
|
+
source=REPO_SCAN_NO_AUTHORITATIVE_SOURCE,
|
|
1491
|
+
reason=(
|
|
1492
|
+
"generic pytest fallback requires a trustworthy pre-existing Python test surface"
|
|
1493
|
+
),
|
|
1494
|
+
contract_type="unavailable",
|
|
1495
|
+
)
|
|
1496
|
+
|
|
1497
|
+
return selection
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def resolve_task_aware_verify_command_selection(
|
|
1501
|
+
*,
|
|
1502
|
+
cfg: AppConfig,
|
|
1503
|
+
verify_cmd: list[str] | None,
|
|
1504
|
+
task: dict[str, Any] | None,
|
|
1505
|
+
root: Path | None = None,
|
|
1506
|
+
repo_scan: RepoScanResult | None = None,
|
|
1507
|
+
plan_requirements: list[str] | None = None,
|
|
1508
|
+
selection: ResolvedVerifyCommands | None = None,
|
|
1509
|
+
allow_empty_config: bool = False,
|
|
1510
|
+
) -> ResolvedVerifyCommands:
|
|
1511
|
+
resolved = selection
|
|
1512
|
+
if resolved is None:
|
|
1513
|
+
resolved = resolve_verify_command_selection(
|
|
1514
|
+
cfg=cfg,
|
|
1515
|
+
verify_cmd=verify_cmd,
|
|
1516
|
+
root=root,
|
|
1517
|
+
repo_scan=repo_scan,
|
|
1518
|
+
allow_empty_config=allow_empty_config,
|
|
1519
|
+
)
|
|
1520
|
+
return refine_generic_fallback_verify_command_selection(
|
|
1521
|
+
selection=resolved,
|
|
1522
|
+
task=task,
|
|
1523
|
+
root=root,
|
|
1524
|
+
repo_scan=repo_scan,
|
|
1525
|
+
plan_requirements=plan_requirements,
|
|
1526
|
+
)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
def resolve_authoritative_task_verify_command_selection(
|
|
1530
|
+
*,
|
|
1531
|
+
cfg: AppConfig,
|
|
1532
|
+
verify_cmd: list[str] | None,
|
|
1533
|
+
task: dict[str, Any] | None,
|
|
1534
|
+
root: Path | None = None,
|
|
1535
|
+
repo_scan: RepoScanResult | None = None,
|
|
1536
|
+
plan_requirements: list[str] | None = None,
|
|
1537
|
+
selection: ResolvedVerifyCommands | None = None,
|
|
1538
|
+
allow_empty_config: bool = False,
|
|
1539
|
+
) -> ResolvedVerifyCommands:
|
|
1540
|
+
resolved = selection
|
|
1541
|
+
if resolved is not None and verify_cmd:
|
|
1542
|
+
normalized_commands = tuple(
|
|
1543
|
+
resolve_verify_commands(
|
|
1544
|
+
cfg=cfg,
|
|
1545
|
+
verify_cmd=verify_cmd,
|
|
1546
|
+
root=root,
|
|
1547
|
+
repo_scan=repo_scan,
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
if normalized_commands != resolved.commands:
|
|
1551
|
+
resolved = resolve_verify_command_selection(
|
|
1552
|
+
cfg=cfg,
|
|
1553
|
+
verify_cmd=verify_cmd,
|
|
1554
|
+
root=root,
|
|
1555
|
+
repo_scan=repo_scan,
|
|
1556
|
+
)
|
|
1557
|
+
return resolve_task_aware_verify_command_selection(
|
|
1558
|
+
cfg=cfg,
|
|
1559
|
+
verify_cmd=(verify_cmd if resolved is None else None),
|
|
1560
|
+
task=task,
|
|
1561
|
+
root=root,
|
|
1562
|
+
repo_scan=repo_scan,
|
|
1563
|
+
plan_requirements=plan_requirements,
|
|
1564
|
+
selection=resolved,
|
|
1565
|
+
allow_empty_config=allow_empty_config,
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
@dataclass(frozen=True)
|
|
1570
|
+
class VerificationCommandExecution:
|
|
1571
|
+
exit_code: int
|
|
1572
|
+
stdout: str
|
|
1573
|
+
stderr: str
|
|
1574
|
+
|
|
1575
|
+
@property
|
|
1576
|
+
def output(self) -> str:
|
|
1577
|
+
return self.stdout + self.stderr
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
def _clip_verification_failure_snippet(text: str, *, max_chars: int) -> str:
|
|
1581
|
+
snippet = str(text or "").strip()
|
|
1582
|
+
if not snippet:
|
|
1583
|
+
return ""
|
|
1584
|
+
if len(snippet) <= max_chars:
|
|
1585
|
+
return snippet
|
|
1586
|
+
return snippet[: max_chars - 3].rstrip() + "..."
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _extract_failure_snippet_line(
|
|
1590
|
+
text: str,
|
|
1591
|
+
*,
|
|
1592
|
+
max_chars: int,
|
|
1593
|
+
allow_fallback: bool,
|
|
1594
|
+
) -> str:
|
|
1595
|
+
lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
|
|
1596
|
+
if not lines:
|
|
1597
|
+
return ""
|
|
1598
|
+
|
|
1599
|
+
for line in lines:
|
|
1600
|
+
if any(marker in line for marker in _VERIFICATION_FAILURE_PRIORITY_MARKERS):
|
|
1601
|
+
return _clip_verification_failure_snippet(line, max_chars=max_chars)
|
|
1602
|
+
|
|
1603
|
+
for line in lines:
|
|
1604
|
+
if line.startswith("E ") or line.startswith("FAILED "):
|
|
1605
|
+
return _clip_verification_failure_snippet(line, max_chars=max_chars)
|
|
1606
|
+
|
|
1607
|
+
for line in lines:
|
|
1608
|
+
lowered = line.casefold()
|
|
1609
|
+
if "error" in lowered or "failed" in lowered or "exception" in lowered:
|
|
1610
|
+
return _clip_verification_failure_snippet(line, max_chars=max_chars)
|
|
1611
|
+
|
|
1612
|
+
if not allow_fallback:
|
|
1613
|
+
return ""
|
|
1614
|
+
return _clip_verification_failure_snippet(lines[0], max_chars=max_chars)
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
def extract_actionable_failure_snippet(
|
|
1618
|
+
text: str,
|
|
1619
|
+
*,
|
|
1620
|
+
max_chars: int = VERIFICATION_FAILURE_SNIPPET_MAX_CHARS,
|
|
1621
|
+
) -> str:
|
|
1622
|
+
return _extract_failure_snippet_line(
|
|
1623
|
+
text,
|
|
1624
|
+
max_chars=max(1, int(max_chars)),
|
|
1625
|
+
allow_fallback=True,
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def build_primary_verification_failure(
|
|
1630
|
+
*,
|
|
1631
|
+
result: VerifyRunResult,
|
|
1632
|
+
output_preview_chars: int = VERIFY_OUTPUT_PREVIEW_CHARS,
|
|
1633
|
+
snippet_chars: int = VERIFICATION_FAILURE_SNIPPET_MAX_CHARS,
|
|
1634
|
+
) -> dict[str, object] | None:
|
|
1635
|
+
if result.all_passed:
|
|
1636
|
+
return None
|
|
1637
|
+
|
|
1638
|
+
preview_chars = max(1, int(output_preview_chars))
|
|
1639
|
+
max_snippet_chars = max(1, int(snippet_chars))
|
|
1640
|
+
first_failed_item: VerifyCommandResult | None = None
|
|
1641
|
+
|
|
1642
|
+
for item in result.command_results:
|
|
1643
|
+
if item.ok:
|
|
1644
|
+
continue
|
|
1645
|
+
if first_failed_item is None:
|
|
1646
|
+
first_failed_item = item
|
|
1647
|
+
snippet = _extract_failure_snippet_line(
|
|
1648
|
+
item.output,
|
|
1649
|
+
max_chars=max_snippet_chars,
|
|
1650
|
+
allow_fallback=False,
|
|
1651
|
+
)
|
|
1652
|
+
if not snippet:
|
|
1653
|
+
continue
|
|
1654
|
+
effective_command = item.effective_command or item.command
|
|
1655
|
+
return {
|
|
1656
|
+
"command": item.command,
|
|
1657
|
+
"effective_command": effective_command,
|
|
1658
|
+
"snippet": snippet,
|
|
1659
|
+
"output_truncated": len(item.output) > preview_chars,
|
|
1660
|
+
"fallback_used": item.fallback_used,
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
if first_failed_item is not None:
|
|
1664
|
+
prefer_raw_output = first_failed_item.non_execution_reason == "execution_layer_failure"
|
|
1665
|
+
snippet = ""
|
|
1666
|
+
if prefer_raw_output:
|
|
1667
|
+
snippet = _extract_failure_snippet_line(
|
|
1668
|
+
first_failed_item.output,
|
|
1669
|
+
max_chars=max_snippet_chars,
|
|
1670
|
+
allow_fallback=True,
|
|
1671
|
+
)
|
|
1672
|
+
if not snippet and not prefer_raw_output:
|
|
1673
|
+
snippet = _extract_failure_snippet_line(
|
|
1674
|
+
result.summary,
|
|
1675
|
+
max_chars=max_snippet_chars,
|
|
1676
|
+
allow_fallback=True,
|
|
1677
|
+
)
|
|
1678
|
+
if not snippet:
|
|
1679
|
+
failed_command_hint = str(result.failed_commands[0]) if result.failed_commands else ""
|
|
1680
|
+
snippet = _clip_verification_failure_snippet(
|
|
1681
|
+
failed_command_hint,
|
|
1682
|
+
max_chars=max_snippet_chars,
|
|
1683
|
+
)
|
|
1684
|
+
if not snippet:
|
|
1685
|
+
snippet = _extract_failure_snippet_line(
|
|
1686
|
+
first_failed_item.output,
|
|
1687
|
+
max_chars=max_snippet_chars,
|
|
1688
|
+
allow_fallback=True,
|
|
1689
|
+
)
|
|
1690
|
+
if snippet:
|
|
1691
|
+
effective_command = first_failed_item.effective_command or first_failed_item.command
|
|
1692
|
+
return {
|
|
1693
|
+
"command": first_failed_item.command,
|
|
1694
|
+
"effective_command": effective_command,
|
|
1695
|
+
"snippet": snippet,
|
|
1696
|
+
"output_truncated": len(first_failed_item.output) > preview_chars,
|
|
1697
|
+
"fallback_used": first_failed_item.fallback_used,
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if result.failed_commands:
|
|
1701
|
+
snippet = _clip_verification_failure_snippet(
|
|
1702
|
+
str(result.failed_commands[0]),
|
|
1703
|
+
max_chars=max_snippet_chars,
|
|
1704
|
+
)
|
|
1705
|
+
if snippet:
|
|
1706
|
+
return {
|
|
1707
|
+
"command": str(result.failed_commands[0]),
|
|
1708
|
+
"effective_command": str(result.failed_commands[0]),
|
|
1709
|
+
"snippet": snippet,
|
|
1710
|
+
"output_truncated": False,
|
|
1711
|
+
"fallback_used": False,
|
|
1712
|
+
}
|
|
1713
|
+
return None
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def extract_verification_failure_snippet(
|
|
1717
|
+
*,
|
|
1718
|
+
tool_name: str,
|
|
1719
|
+
result: dict[str, Any],
|
|
1720
|
+
max_chars: int = VERIFICATION_FAILURE_SNIPPET_MAX_CHARS,
|
|
1721
|
+
) -> str:
|
|
1722
|
+
normalized_tool = str(tool_name or "").strip().lower()
|
|
1723
|
+
snippet_limit = max(1, int(max_chars))
|
|
1724
|
+
|
|
1725
|
+
if normalized_tool == "verify_run":
|
|
1726
|
+
primary_failure = result.get("primary_failure")
|
|
1727
|
+
if isinstance(primary_failure, dict):
|
|
1728
|
+
snippet = _extract_failure_snippet_line(
|
|
1729
|
+
str(primary_failure.get("snippet") or ""),
|
|
1730
|
+
max_chars=snippet_limit,
|
|
1731
|
+
allow_fallback=True,
|
|
1732
|
+
)
|
|
1733
|
+
if snippet:
|
|
1734
|
+
return snippet
|
|
1735
|
+
|
|
1736
|
+
command_results = result.get("command_results")
|
|
1737
|
+
if isinstance(command_results, list):
|
|
1738
|
+
for item in command_results:
|
|
1739
|
+
if not isinstance(item, dict):
|
|
1740
|
+
continue
|
|
1741
|
+
ok = item.get("ok")
|
|
1742
|
+
if isinstance(ok, bool):
|
|
1743
|
+
failed = not ok
|
|
1744
|
+
else:
|
|
1745
|
+
exit_code = item.get("exit_code")
|
|
1746
|
+
failed = not (isinstance(exit_code, int) and exit_code == 0)
|
|
1747
|
+
if not failed:
|
|
1748
|
+
continue
|
|
1749
|
+
snippet = _extract_failure_snippet_line(
|
|
1750
|
+
str(item.get("output_preview") or ""),
|
|
1751
|
+
max_chars=snippet_limit,
|
|
1752
|
+
allow_fallback=True,
|
|
1753
|
+
)
|
|
1754
|
+
if snippet:
|
|
1755
|
+
return snippet
|
|
1756
|
+
summary = _extract_failure_snippet_line(
|
|
1757
|
+
str(result.get("summary") or ""),
|
|
1758
|
+
max_chars=snippet_limit,
|
|
1759
|
+
allow_fallback=True,
|
|
1760
|
+
)
|
|
1761
|
+
if summary:
|
|
1762
|
+
return summary
|
|
1763
|
+
failed_commands = result.get("failed_commands")
|
|
1764
|
+
if isinstance(failed_commands, list) and failed_commands:
|
|
1765
|
+
return _clip_verification_failure_snippet(
|
|
1766
|
+
str(failed_commands[0]),
|
|
1767
|
+
max_chars=snippet_limit,
|
|
1768
|
+
)
|
|
1769
|
+
return ""
|
|
1770
|
+
|
|
1771
|
+
if normalized_tool == "shell_run":
|
|
1772
|
+
combined = "\n".join(
|
|
1773
|
+
[
|
|
1774
|
+
str(result.get("stderr") or "").strip(),
|
|
1775
|
+
str(result.get("stdout") or "").strip(),
|
|
1776
|
+
]
|
|
1777
|
+
).strip()
|
|
1778
|
+
return _extract_failure_snippet_line(
|
|
1779
|
+
combined,
|
|
1780
|
+
max_chars=snippet_limit,
|
|
1781
|
+
allow_fallback=True,
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1784
|
+
return ""
|
|
1785
|
+
|
|
1786
|
+
|
|
1787
|
+
def _truncate_verify_output(text: str, *, max_chars: int) -> tuple[str, bool]:
|
|
1788
|
+
if len(text) <= max_chars:
|
|
1789
|
+
return (text, False)
|
|
1790
|
+
if max_chars <= len("...(truncated)"):
|
|
1791
|
+
return (text[:max_chars], True)
|
|
1792
|
+
return (text[: max_chars - len("...(truncated)")].rstrip() + "...(truncated)", True)
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _is_env_assignment_token(token: str) -> bool:
|
|
1796
|
+
return re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token) is not None
|
|
1797
|
+
|
|
1798
|
+
|
|
1799
|
+
def _split_shell_command_parts(command: str) -> list[str] | None:
|
|
1800
|
+
return split_verify_command_parts(command)
|
|
1801
|
+
|
|
1802
|
+
|
|
1803
|
+
def _normalize_shell_command(command: str) -> str:
|
|
1804
|
+
return " ".join(str(command or "").split())
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
def _normalize_command_for_control_flow_detection(command: str) -> str | None:
|
|
1808
|
+
normalized = _normalize_shell_command(command)
|
|
1809
|
+
if not normalized:
|
|
1810
|
+
return None
|
|
1811
|
+
|
|
1812
|
+
current = normalized
|
|
1813
|
+
while True:
|
|
1814
|
+
parts = _split_shell_command_parts(current)
|
|
1815
|
+
if not parts:
|
|
1816
|
+
return None
|
|
1817
|
+
|
|
1818
|
+
stripped_env = _strip_execution_env_prefix(parts)
|
|
1819
|
+
if stripped_env is None:
|
|
1820
|
+
return None
|
|
1821
|
+
if stripped_env != parts:
|
|
1822
|
+
current = shlex.join(stripped_env)
|
|
1823
|
+
continue
|
|
1824
|
+
|
|
1825
|
+
stripped_runner = _strip_execution_runner_prefix(parts)
|
|
1826
|
+
if stripped_runner is None:
|
|
1827
|
+
return None
|
|
1828
|
+
if stripped_runner != parts:
|
|
1829
|
+
current = shlex.join(stripped_runner)
|
|
1830
|
+
continue
|
|
1831
|
+
|
|
1832
|
+
wrapped = _unwrap_shell_wrapper_command(current)
|
|
1833
|
+
if wrapped and wrapped != current:
|
|
1834
|
+
current = wrapped
|
|
1835
|
+
continue
|
|
1836
|
+
|
|
1837
|
+
return current
|
|
1838
|
+
|
|
1839
|
+
|
|
1840
|
+
def _unwrap_shell_wrapper_command(command: str) -> str | None:
|
|
1841
|
+
parts = _split_shell_command_parts(command)
|
|
1842
|
+
if not parts:
|
|
1843
|
+
return None
|
|
1844
|
+
|
|
1845
|
+
head = parts[0].strip().lower()
|
|
1846
|
+
if head in {"bash", "sh", "zsh"}:
|
|
1847
|
+
if len(parts) == 3 and parts[1] == "-lc":
|
|
1848
|
+
return _normalize_shell_command(parts[2])
|
|
1849
|
+
return None
|
|
1850
|
+
if head == "fish":
|
|
1851
|
+
if len(parts) == 3 and parts[1] == "-c":
|
|
1852
|
+
return _normalize_shell_command(parts[2])
|
|
1853
|
+
return None
|
|
1854
|
+
if head == "cmd":
|
|
1855
|
+
if len(parts) == 3 and parts[1].lower() == "/c":
|
|
1856
|
+
return _normalize_shell_command(parts[2])
|
|
1857
|
+
return None
|
|
1858
|
+
if head in {"powershell", "pwsh"}:
|
|
1859
|
+
if len(parts) == 3 and parts[1].lower() == "-command":
|
|
1860
|
+
return _normalize_shell_command(parts[2])
|
|
1861
|
+
return None
|
|
1862
|
+
return None
|
|
1863
|
+
|
|
1864
|
+
|
|
1865
|
+
def _strip_execution_env_prefix(parts: list[str]) -> list[str] | None:
|
|
1866
|
+
out = list(parts)
|
|
1867
|
+
if not out:
|
|
1868
|
+
return None
|
|
1869
|
+
if out[0].lower() == "env":
|
|
1870
|
+
out = out[1:]
|
|
1871
|
+
if not out or out[0].startswith("-"):
|
|
1872
|
+
return None
|
|
1873
|
+
while out and _is_env_assignment_token(out[0]):
|
|
1874
|
+
out = out[1:]
|
|
1875
|
+
return out or None
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def _strip_execution_runner_prefix(parts: list[str]) -> list[str] | None:
|
|
1879
|
+
return strip_verify_runner_prefix(parts)
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _normalize_execution_semantics_parts(command: str) -> list[str] | None:
|
|
1883
|
+
analysis = analyze_verification_command(command, trusted=True)
|
|
1884
|
+
return list(analysis.parts) if analysis.parts else None
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
def _verification_family_for_result(command: str) -> str | None:
|
|
1888
|
+
return analyze_verification_command(command, trusted=True).command_family
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
def _has_shell_control_flow(command: str) -> bool:
|
|
1892
|
+
analysis = analyze_verification_command(command, trusted=True)
|
|
1893
|
+
return analysis.shell_control_flow in {"unsafe", "pipeline"} or analysis.rejection_reason in {
|
|
1894
|
+
"disallowed_shell_control_flow",
|
|
1895
|
+
"unsafe_pipeline",
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
def assess_verification_command_execution(
|
|
1900
|
+
*,
|
|
1901
|
+
command: str,
|
|
1902
|
+
exit_code: int,
|
|
1903
|
+
output: str,
|
|
1904
|
+
) -> VerificationExecutionAssessment:
|
|
1905
|
+
analysis = analyze_verification_command(command, trusted=True)
|
|
1906
|
+
family = analysis.command_family
|
|
1907
|
+
if _is_execution_layer_failure(exit_code=exit_code, output=output):
|
|
1908
|
+
return VerificationExecutionAssessment(
|
|
1909
|
+
real_execution=False,
|
|
1910
|
+
non_execution_reason="execution_layer_failure",
|
|
1911
|
+
)
|
|
1912
|
+
if analysis.rejection_reason:
|
|
1913
|
+
if exit_code == 0:
|
|
1914
|
+
return VerificationExecutionAssessment(
|
|
1915
|
+
real_execution=False,
|
|
1916
|
+
non_execution_reason=analysis.rejection_reason,
|
|
1917
|
+
)
|
|
1918
|
+
return VerificationExecutionAssessment(real_execution=None)
|
|
1919
|
+
if family == "pytest" and (exit_code == 5 or _PYTEST_NO_TESTS_RE.search(str(output or ""))):
|
|
1920
|
+
return VerificationExecutionAssessment(
|
|
1921
|
+
real_execution=False,
|
|
1922
|
+
non_execution_reason="pytest_no_tests_collected",
|
|
1923
|
+
)
|
|
1924
|
+
if family == "unittest" and _UNITTEST_NO_TESTS_RE.search(str(output or "")):
|
|
1925
|
+
return VerificationExecutionAssessment(
|
|
1926
|
+
real_execution=False,
|
|
1927
|
+
non_execution_reason="unittest_no_tests_run",
|
|
1928
|
+
)
|
|
1929
|
+
if family in {"maven:test", "maven:verify", "gradle:test", "dotnet:test"}:
|
|
1930
|
+
if _JUNIT_ZERO_TESTS_RE.search(str(output or "")):
|
|
1931
|
+
return VerificationExecutionAssessment(
|
|
1932
|
+
real_execution=False,
|
|
1933
|
+
non_execution_reason=f"{family.replace(':', '_')}_zero_tests",
|
|
1934
|
+
)
|
|
1935
|
+
if family in {"npm:test", "pnpm:test", "yarn:test"} and _NODE_ZERO_TESTS_RE.search(
|
|
1936
|
+
str(output or "")
|
|
1937
|
+
):
|
|
1938
|
+
return VerificationExecutionAssessment(
|
|
1939
|
+
real_execution=False,
|
|
1940
|
+
non_execution_reason=f"{family.replace(':', '_')}_zero_tests",
|
|
1941
|
+
)
|
|
1942
|
+
if (
|
|
1943
|
+
family
|
|
1944
|
+
in {
|
|
1945
|
+
"make:test",
|
|
1946
|
+
"make:check",
|
|
1947
|
+
"make:verify",
|
|
1948
|
+
"just:test",
|
|
1949
|
+
"just:check",
|
|
1950
|
+
"just:verify",
|
|
1951
|
+
}
|
|
1952
|
+
and exit_code == 0
|
|
1953
|
+
and _NOTHING_TO_DO_RE.search(str(output or ""))
|
|
1954
|
+
):
|
|
1955
|
+
return VerificationExecutionAssessment(
|
|
1956
|
+
real_execution=False,
|
|
1957
|
+
non_execution_reason="verification_nothing_to_do",
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
if exit_code != 0:
|
|
1961
|
+
return VerificationExecutionAssessment(real_execution=None)
|
|
1962
|
+
|
|
1963
|
+
if analysis.evidentiary_capability == VerificationCommandEvidentiaryCapability.NON_ASSERTIVE:
|
|
1964
|
+
return VerificationExecutionAssessment(
|
|
1965
|
+
real_execution=False,
|
|
1966
|
+
non_execution_reason=analysis.capability_reason or "non_assertive_verifier",
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
if family == "go:test":
|
|
1970
|
+
saw_zero_work = False
|
|
1971
|
+
zero_work_reason: str | None = None
|
|
1972
|
+
recognized_summary = False
|
|
1973
|
+
for raw_line in str(output or "").splitlines():
|
|
1974
|
+
line = raw_line.strip()
|
|
1975
|
+
if not line:
|
|
1976
|
+
continue
|
|
1977
|
+
if _GO_TEST_NO_TESTS_TO_RUN_LINE_RE.match(line):
|
|
1978
|
+
recognized_summary = True
|
|
1979
|
+
saw_zero_work = True
|
|
1980
|
+
zero_work_reason = "go_test_no_tests_to_run"
|
|
1981
|
+
continue
|
|
1982
|
+
if _GO_TEST_NO_TEST_FILES_LINE_RE.match(line):
|
|
1983
|
+
recognized_summary = True
|
|
1984
|
+
saw_zero_work = True
|
|
1985
|
+
if zero_work_reason is None:
|
|
1986
|
+
zero_work_reason = "go_test_no_test_files"
|
|
1987
|
+
continue
|
|
1988
|
+
if _GO_TEST_OK_LINE_RE.match(line):
|
|
1989
|
+
return VerificationExecutionAssessment(real_execution=True)
|
|
1990
|
+
if recognized_summary and saw_zero_work:
|
|
1991
|
+
return VerificationExecutionAssessment(
|
|
1992
|
+
real_execution=False,
|
|
1993
|
+
non_execution_reason=zero_work_reason or "go_test_no_tests_to_run",
|
|
1994
|
+
)
|
|
1995
|
+
if family is None:
|
|
1996
|
+
return VerificationExecutionAssessment(
|
|
1997
|
+
real_execution=None,
|
|
1998
|
+
non_execution_reason=analysis.inconclusive_reason
|
|
1999
|
+
or analysis.capability_reason
|
|
2000
|
+
or "unknown_verification_capability",
|
|
2001
|
+
)
|
|
2002
|
+
return VerificationExecutionAssessment(real_execution=True)
|
|
2003
|
+
|
|
2004
|
+
|
|
2005
|
+
def _build_pytest_module_fallback_command(command: str) -> str | None:
|
|
2006
|
+
parts = _split_shell_command_parts(command)
|
|
2007
|
+
if not parts:
|
|
2008
|
+
return None
|
|
2009
|
+
|
|
2010
|
+
env_prefix: list[str] = []
|
|
2011
|
+
idx = 0
|
|
2012
|
+
while idx < len(parts) and _is_env_assignment_token(parts[idx]):
|
|
2013
|
+
env_prefix.append(parts[idx])
|
|
2014
|
+
idx += 1
|
|
2015
|
+
|
|
2016
|
+
if idx >= len(parts):
|
|
2017
|
+
return None
|
|
2018
|
+
|
|
2019
|
+
entrypoint = parts[idx].strip().lower()
|
|
2020
|
+
if entrypoint not in _VERIFY_PYTEST_ENTRYPOINTS:
|
|
2021
|
+
return None
|
|
2022
|
+
|
|
2023
|
+
fallback_tokens = [*env_prefix, sys.executable, "-m", "pytest", *parts[idx + 1 :]]
|
|
2024
|
+
return shlex.join(fallback_tokens)
|
|
2025
|
+
|
|
2026
|
+
|
|
2027
|
+
def _token_has_shell_glob(token: str) -> bool:
|
|
2028
|
+
return any(char in token for char in "*?[")
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def _expand_verification_command_globs(command: str, *, root: Path) -> str:
|
|
2032
|
+
parts = _split_shell_command_parts(command)
|
|
2033
|
+
if not parts:
|
|
2034
|
+
return command
|
|
2035
|
+
|
|
2036
|
+
root_abs = root.resolve()
|
|
2037
|
+
expanded_parts: list[str] = []
|
|
2038
|
+
changed = False
|
|
2039
|
+
for token in parts:
|
|
2040
|
+
if (
|
|
2041
|
+
not _token_has_shell_glob(token)
|
|
2042
|
+
or _is_env_assignment_token(token)
|
|
2043
|
+
or token.startswith("-")
|
|
2044
|
+
or "://" in token
|
|
2045
|
+
):
|
|
2046
|
+
expanded_parts.append(token)
|
|
2047
|
+
continue
|
|
2048
|
+
|
|
2049
|
+
token_path = Path(token)
|
|
2050
|
+
if token_path.is_absolute() or ".." in token_path.parts:
|
|
2051
|
+
expanded_parts.append(token)
|
|
2052
|
+
continue
|
|
2053
|
+
|
|
2054
|
+
matches: list[str] = []
|
|
2055
|
+
for match in glob.glob(os.fspath(root_abs / token), recursive=True):
|
|
2056
|
+
match_path = Path(match)
|
|
2057
|
+
if not match_path.exists():
|
|
2058
|
+
continue
|
|
2059
|
+
try:
|
|
2060
|
+
matches.append(match_path.resolve().relative_to(root_abs).as_posix())
|
|
2061
|
+
except ValueError:
|
|
2062
|
+
continue
|
|
2063
|
+
if not matches:
|
|
2064
|
+
expanded_parts.append(token)
|
|
2065
|
+
continue
|
|
2066
|
+
|
|
2067
|
+
expanded_parts.extend(sorted(set(matches)))
|
|
2068
|
+
changed = True
|
|
2069
|
+
|
|
2070
|
+
return shlex.join(expanded_parts) if changed else command
|
|
2071
|
+
|
|
2072
|
+
|
|
2073
|
+
def _is_execution_layer_failure(*, exit_code: int, output: str) -> bool:
|
|
2074
|
+
lowered = str(output or "").casefold()
|
|
2075
|
+
if exit_code in {126, 127}:
|
|
2076
|
+
return True
|
|
2077
|
+
if exit_code != 1:
|
|
2078
|
+
return False
|
|
2079
|
+
if not any(marker in lowered for marker in _VERIFY_EXECUTION_LAYER_ERROR_MARKERS):
|
|
2080
|
+
return False
|
|
2081
|
+
return (
|
|
2082
|
+
"/bin/sh:" in lowered
|
|
2083
|
+
or "not recognized as an internal or external command" in lowered
|
|
2084
|
+
or "cannot execute" in lowered
|
|
2085
|
+
)
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
def _is_pytest_entrypoint_import_failure(*, command: str, output: str) -> bool:
|
|
2089
|
+
if _build_pytest_module_fallback_command(command) is None:
|
|
2090
|
+
return False
|
|
2091
|
+
lowered = str(output or "").casefold()
|
|
2092
|
+
return (
|
|
2093
|
+
"modulenotfounderror: no module named 'pytest'" in lowered
|
|
2094
|
+
or 'modulenotfounderror: no module named "pytest"' in lowered
|
|
2095
|
+
)
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def _is_toolchain_unavailable_failure(*, output: str) -> bool:
|
|
2099
|
+
lowered = str(output or "").casefold()
|
|
2100
|
+
if not lowered:
|
|
2101
|
+
return False
|
|
2102
|
+
if _TOOLCHAIN_UNAVAILABLE_RE.search(lowered):
|
|
2103
|
+
return True
|
|
2104
|
+
if _LANGUAGE_VERSION_MISMATCH_RE.search(lowered):
|
|
2105
|
+
return True
|
|
2106
|
+
if _LEGACY_PYTHON_RUNTIME_INCOMPATIBILITY_RE.search(lowered):
|
|
2107
|
+
return True
|
|
2108
|
+
return any(
|
|
2109
|
+
marker in lowered
|
|
2110
|
+
for marker in (
|
|
2111
|
+
"unsupported class file major version",
|
|
2112
|
+
"invalid source release",
|
|
2113
|
+
"module requires go",
|
|
2114
|
+
"declared in its mix.exs file it supports only elixir",
|
|
2115
|
+
"could not determine java version",
|
|
2116
|
+
"gradle version",
|
|
2117
|
+
)
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
|
|
2121
|
+
def is_toolchain_unavailable_verification_output(output: str) -> bool:
|
|
2122
|
+
return _is_toolchain_unavailable_failure(output=output)
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def _run_verify_command_once(
|
|
2126
|
+
*,
|
|
2127
|
+
command: str,
|
|
2128
|
+
root: Path,
|
|
2129
|
+
runner: object | None,
|
|
2130
|
+
runner_build_error: str | None,
|
|
2131
|
+
timeout_s: float,
|
|
2132
|
+
) -> VerificationCommandExecution:
|
|
2133
|
+
if runner_build_error is not None:
|
|
2134
|
+
return VerificationCommandExecution(
|
|
2135
|
+
exit_code=127,
|
|
2136
|
+
stdout="",
|
|
2137
|
+
stderr=f"verify sandbox unavailable: {runner_build_error}",
|
|
2138
|
+
)
|
|
2139
|
+
if runner is None:
|
|
2140
|
+
return VerificationCommandExecution(
|
|
2141
|
+
exit_code=127,
|
|
2142
|
+
stdout="",
|
|
2143
|
+
stderr="verify runner is missing; implicit host execution is disabled.",
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
try:
|
|
2147
|
+
cp = runner.run(root=root, cwd=root.resolve(), cmd=command, timeout_s=timeout_s)
|
|
2148
|
+
return VerificationCommandExecution(
|
|
2149
|
+
exit_code=cp.returncode,
|
|
2150
|
+
stdout=cp.stdout or "",
|
|
2151
|
+
stderr=cp.stderr or "",
|
|
2152
|
+
)
|
|
2153
|
+
except subprocess.TimeoutExpired:
|
|
2154
|
+
return VerificationCommandExecution(
|
|
2155
|
+
exit_code=124,
|
|
2156
|
+
stdout="",
|
|
2157
|
+
stderr=f"Command timed out after {timeout_s:g}s",
|
|
2158
|
+
)
|
|
2159
|
+
except OSError as e:
|
|
2160
|
+
return VerificationCommandExecution(exit_code=127, stdout="", stderr=str(e))
|
|
2161
|
+
except Exception as e: # noqa: BLE001
|
|
2162
|
+
return VerificationCommandExecution(exit_code=127, stdout="", stderr=str(e))
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def verify_run_result_to_payload(
|
|
2166
|
+
*,
|
|
2167
|
+
root: Path,
|
|
2168
|
+
result: VerifyRunResult,
|
|
2169
|
+
output_preview_chars: int = VERIFY_OUTPUT_PREVIEW_CHARS,
|
|
2170
|
+
) -> dict[str, object]:
|
|
2171
|
+
artifact_ref = resolve_verify_artifact_payload(root=root, artifact_path=result.artifact_path)
|
|
2172
|
+
primary_failure = build_primary_verification_failure(
|
|
2173
|
+
result=result,
|
|
2174
|
+
output_preview_chars=output_preview_chars,
|
|
2175
|
+
)
|
|
2176
|
+
|
|
2177
|
+
command_results: list[dict[str, object]] = []
|
|
2178
|
+
fallback_details: list[dict[str, object]] = []
|
|
2179
|
+
primary_failure_summary: dict[str, Any] | None = None
|
|
2180
|
+
for item in result.command_results:
|
|
2181
|
+
preview, was_truncated = _truncate_verify_output(
|
|
2182
|
+
item.output,
|
|
2183
|
+
max_chars=max(1, int(output_preview_chars)),
|
|
2184
|
+
)
|
|
2185
|
+
effective_command = item.effective_command or item.command
|
|
2186
|
+
failure_summary = None
|
|
2187
|
+
if not item.ok:
|
|
2188
|
+
failure_summary = summarize_verification_failure(
|
|
2189
|
+
root=root,
|
|
2190
|
+
command=item.command,
|
|
2191
|
+
effective_command=effective_command,
|
|
2192
|
+
output=item.output,
|
|
2193
|
+
output_truncated=was_truncated,
|
|
2194
|
+
)
|
|
2195
|
+
if failure_summary is not None and primary_failure_summary is None:
|
|
2196
|
+
primary_failure_summary = failure_summary
|
|
2197
|
+
command_payload = {
|
|
2198
|
+
"command": item.command,
|
|
2199
|
+
"effective_command": effective_command,
|
|
2200
|
+
"exit_code": item.exit_code,
|
|
2201
|
+
"status": item.status.value,
|
|
2202
|
+
"ok": item.ok,
|
|
2203
|
+
"real_execution": item.real_execution,
|
|
2204
|
+
"output_preview": preview,
|
|
2205
|
+
"output_chars": len(item.output),
|
|
2206
|
+
"output_truncated": was_truncated,
|
|
2207
|
+
"fallback_used": item.fallback_used,
|
|
2208
|
+
}
|
|
2209
|
+
if failure_summary is not None:
|
|
2210
|
+
command_payload["failure_summary"] = failure_summary
|
|
2211
|
+
if item.fallback_reason:
|
|
2212
|
+
command_payload["fallback_reason"] = item.fallback_reason
|
|
2213
|
+
if item.non_execution_reason:
|
|
2214
|
+
command_payload["non_execution_reason"] = item.non_execution_reason
|
|
2215
|
+
command_results.append(command_payload)
|
|
2216
|
+
if item.fallback_used:
|
|
2217
|
+
fallback_details.append(
|
|
2218
|
+
{
|
|
2219
|
+
"command": item.command,
|
|
2220
|
+
"effective_command": effective_command,
|
|
2221
|
+
"exit_code": item.exit_code,
|
|
2222
|
+
"status": item.status.value,
|
|
2223
|
+
"ok": item.ok,
|
|
2224
|
+
"reason": item.fallback_reason or "pytest_entrypoint_unavailable",
|
|
2225
|
+
}
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
payload: dict[str, object] = {
|
|
2229
|
+
"commands": list(result.commands),
|
|
2230
|
+
"command_results": command_results,
|
|
2231
|
+
"all_passed": result.all_passed,
|
|
2232
|
+
"failed_commands": list(result.failed_commands),
|
|
2233
|
+
"summary": result.summary,
|
|
2234
|
+
"failure_category": result.failure_category_value,
|
|
2235
|
+
"artifact_path": artifact_ref.artifact_path,
|
|
2236
|
+
"artifact_saved": artifact_ref.artifact_saved,
|
|
2237
|
+
"artifact_readable_via_fs": artifact_ref.artifact_readable_via_fs,
|
|
2238
|
+
"artifact_location": artifact_ref.artifact_location,
|
|
2239
|
+
"fallback_used": bool(fallback_details),
|
|
2240
|
+
"fallback_count": len(fallback_details),
|
|
2241
|
+
"fallback_details": fallback_details,
|
|
2242
|
+
}
|
|
2243
|
+
if primary_failure is not None:
|
|
2244
|
+
payload["primary_failure"] = primary_failure
|
|
2245
|
+
if primary_failure_summary is not None:
|
|
2246
|
+
payload["failure_summary"] = primary_failure_summary
|
|
2247
|
+
return payload
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
def compact_verification_payload(
|
|
2251
|
+
payload: dict[str, object] | None,
|
|
2252
|
+
*,
|
|
2253
|
+
max_command_results: int = 8,
|
|
2254
|
+
output_preview_chars: int = 240,
|
|
2255
|
+
) -> dict[str, object] | None:
|
|
2256
|
+
if not isinstance(payload, dict):
|
|
2257
|
+
return None
|
|
2258
|
+
|
|
2259
|
+
max_results = max(1, int(max_command_results))
|
|
2260
|
+
max_preview_chars = max(1, int(output_preview_chars))
|
|
2261
|
+
raw_results = payload.get("command_results")
|
|
2262
|
+
compact_results: list[dict[str, Any]] = []
|
|
2263
|
+
total_results = 0
|
|
2264
|
+
if isinstance(raw_results, list):
|
|
2265
|
+
total_results = len(raw_results)
|
|
2266
|
+
for item in raw_results[:max_results]:
|
|
2267
|
+
if not isinstance(item, dict):
|
|
2268
|
+
continue
|
|
2269
|
+
preview_text = str(item.get("output_preview") or "")
|
|
2270
|
+
preview, preview_truncated = _truncate_verify_output(
|
|
2271
|
+
preview_text,
|
|
2272
|
+
max_chars=max_preview_chars,
|
|
2273
|
+
)
|
|
2274
|
+
compact_item: dict[str, Any] = {
|
|
2275
|
+
"command": str(item.get("command") or ""),
|
|
2276
|
+
"effective_command": str(
|
|
2277
|
+
item.get("effective_command") or item.get("command") or ""
|
|
2278
|
+
),
|
|
2279
|
+
"exit_code": item.get("exit_code"),
|
|
2280
|
+
"ok": item.get("ok"),
|
|
2281
|
+
"real_execution": item.get("real_execution"),
|
|
2282
|
+
"fallback_used": bool(item.get("fallback_used", False)),
|
|
2283
|
+
"output_preview": preview,
|
|
2284
|
+
}
|
|
2285
|
+
if item.get("fallback_reason") is not None:
|
|
2286
|
+
compact_item["fallback_reason"] = str(item.get("fallback_reason") or "")
|
|
2287
|
+
if item.get("non_execution_reason") is not None:
|
|
2288
|
+
compact_item["non_execution_reason"] = str(item.get("non_execution_reason") or "")
|
|
2289
|
+
raw_failure_summary = item.get("failure_summary")
|
|
2290
|
+
if isinstance(raw_failure_summary, dict):
|
|
2291
|
+
compact_summary = _compact_failure_summary(raw_failure_summary)
|
|
2292
|
+
if compact_summary:
|
|
2293
|
+
compact_item["failure_summary"] = compact_summary
|
|
2294
|
+
if preview_truncated or bool(item.get("output_truncated")):
|
|
2295
|
+
compact_item["output_truncated"] = True
|
|
2296
|
+
compact_results.append(compact_item)
|
|
2297
|
+
|
|
2298
|
+
compact_payload: dict[str, object] = {
|
|
2299
|
+
"summary": str(payload.get("summary") or ""),
|
|
2300
|
+
"all_passed": payload.get("all_passed"),
|
|
2301
|
+
"failed_commands": list(payload.get("failed_commands") or []),
|
|
2302
|
+
"failure_category": payload.get("failure_category"),
|
|
2303
|
+
"command_results": compact_results,
|
|
2304
|
+
"command_results_total": total_results,
|
|
2305
|
+
"command_results_truncated": total_results > len(compact_results),
|
|
2306
|
+
"fallback_used": bool(payload.get("fallback_used", False)),
|
|
2307
|
+
"fallback_count": int(payload.get("fallback_count") or 0),
|
|
2308
|
+
}
|
|
2309
|
+
raw_primary_failure = payload.get("primary_failure")
|
|
2310
|
+
if isinstance(raw_primary_failure, dict):
|
|
2311
|
+
compact_primary_failure: dict[str, object] = {}
|
|
2312
|
+
command = str(raw_primary_failure.get("command") or "").strip()
|
|
2313
|
+
effective_command = str(raw_primary_failure.get("effective_command") or "").strip()
|
|
2314
|
+
snippet = _extract_failure_snippet_line(
|
|
2315
|
+
str(raw_primary_failure.get("snippet") or ""),
|
|
2316
|
+
max_chars=max_preview_chars,
|
|
2317
|
+
allow_fallback=True,
|
|
2318
|
+
)
|
|
2319
|
+
if command:
|
|
2320
|
+
compact_primary_failure["command"] = command
|
|
2321
|
+
if effective_command:
|
|
2322
|
+
compact_primary_failure["effective_command"] = effective_command
|
|
2323
|
+
if snippet:
|
|
2324
|
+
compact_primary_failure["snippet"] = snippet
|
|
2325
|
+
if raw_primary_failure.get("output_truncated") is not None:
|
|
2326
|
+
compact_primary_failure["output_truncated"] = bool(
|
|
2327
|
+
raw_primary_failure.get("output_truncated")
|
|
2328
|
+
)
|
|
2329
|
+
if raw_primary_failure.get("fallback_used") is not None:
|
|
2330
|
+
compact_primary_failure["fallback_used"] = bool(
|
|
2331
|
+
raw_primary_failure.get("fallback_used")
|
|
2332
|
+
)
|
|
2333
|
+
if compact_primary_failure:
|
|
2334
|
+
compact_payload["primary_failure"] = compact_primary_failure
|
|
2335
|
+
if payload.get("artifact_path") is not None:
|
|
2336
|
+
compact_payload["artifact_path"] = payload.get("artifact_path")
|
|
2337
|
+
if payload.get("artifact_saved") is not None:
|
|
2338
|
+
compact_payload["artifact_saved"] = bool(payload.get("artifact_saved"))
|
|
2339
|
+
if payload.get("artifact_location") is not None:
|
|
2340
|
+
compact_payload["artifact_location"] = str(payload.get("artifact_location") or "")
|
|
2341
|
+
raw_failure_summary = payload.get("failure_summary")
|
|
2342
|
+
if isinstance(raw_failure_summary, dict):
|
|
2343
|
+
compact_summary = _compact_failure_summary(raw_failure_summary)
|
|
2344
|
+
if compact_summary:
|
|
2345
|
+
compact_payload["failure_summary"] = compact_summary
|
|
2346
|
+
return compact_payload
|
|
2347
|
+
|
|
2348
|
+
|
|
2349
|
+
def _compact_failure_summary(summary: dict[str, Any]) -> dict[str, object]:
|
|
2350
|
+
compact: dict[str, object] = {}
|
|
2351
|
+
framework = str(summary.get("framework") or "").strip()
|
|
2352
|
+
primary_error = str(summary.get("primary_error") or "").strip()
|
|
2353
|
+
if framework:
|
|
2354
|
+
compact["framework"] = framework
|
|
2355
|
+
if primary_error:
|
|
2356
|
+
compact["primary_error"] = _clip_verification_failure_snippet(
|
|
2357
|
+
primary_error,
|
|
2358
|
+
max_chars=VERIFICATION_FAILURE_SNIPPET_MAX_CHARS,
|
|
2359
|
+
)
|
|
2360
|
+
for key, limit in (
|
|
2361
|
+
("failing_tests", 4),
|
|
2362
|
+
("stack_frames", 6),
|
|
2363
|
+
("likely_next_files", 8),
|
|
2364
|
+
):
|
|
2365
|
+
value = summary.get(key)
|
|
2366
|
+
if isinstance(value, list) and value:
|
|
2367
|
+
compact[key] = value[:limit]
|
|
2368
|
+
if summary.get("output_truncated") is not None:
|
|
2369
|
+
compact["output_truncated"] = bool(summary.get("output_truncated"))
|
|
2370
|
+
if summary.get("heuristic") is not None:
|
|
2371
|
+
compact["heuristic"] = bool(summary.get("heuristic"))
|
|
2372
|
+
if summary.get("confidence") is not None:
|
|
2373
|
+
compact["confidence"] = summary.get("confidence")
|
|
2374
|
+
return compact
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
def resolve_verify_artifact_payload(*, root: Path, artifact_path: Path) -> VerifyArtifactPayload:
|
|
2378
|
+
if not artifact_path.exists():
|
|
2379
|
+
return VerifyArtifactPayload(
|
|
2380
|
+
artifact_path=None,
|
|
2381
|
+
artifact_saved=False,
|
|
2382
|
+
artifact_readable_via_fs=False,
|
|
2383
|
+
artifact_location="missing",
|
|
2384
|
+
)
|
|
2385
|
+
|
|
2386
|
+
root_abs = root.resolve()
|
|
2387
|
+
artifact_abs = artifact_path.resolve()
|
|
2388
|
+
try:
|
|
2389
|
+
rel_path = artifact_abs.relative_to(root_abs).as_posix()
|
|
2390
|
+
except ValueError:
|
|
2391
|
+
return VerifyArtifactPayload(
|
|
2392
|
+
artifact_path=None,
|
|
2393
|
+
artifact_saved=True,
|
|
2394
|
+
artifact_readable_via_fs=False,
|
|
2395
|
+
artifact_location="external_session_store",
|
|
2396
|
+
)
|
|
2397
|
+
|
|
2398
|
+
return VerifyArtifactPayload(
|
|
2399
|
+
artifact_path=rel_path,
|
|
2400
|
+
artifact_saved=True,
|
|
2401
|
+
artifact_readable_via_fs=True,
|
|
2402
|
+
artifact_location="workspace_root",
|
|
2403
|
+
)
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
def normalize_verify_mode(mode: str) -> str:
|
|
2407
|
+
value = mode.strip().lower()
|
|
2408
|
+
if value not in VERIFY_MODES:
|
|
2409
|
+
raise VerifyError("Invalid --verify. Use one of: off, warn, strict.")
|
|
2410
|
+
return value
|
|
2411
|
+
|
|
2412
|
+
|
|
2413
|
+
def resolve_verify_command_selection(
|
|
2414
|
+
*,
|
|
2415
|
+
cfg: AppConfig,
|
|
2416
|
+
verify_cmd: list[str] | None,
|
|
2417
|
+
root: Path | None = None,
|
|
2418
|
+
repo_scan: RepoScanResult | None = None,
|
|
2419
|
+
allow_empty_config: bool = False,
|
|
2420
|
+
) -> ResolvedVerifyCommands:
|
|
2421
|
+
if verify_cmd:
|
|
2422
|
+
commands = normalize_verify_command_list(verify_cmd)
|
|
2423
|
+
if not commands:
|
|
2424
|
+
raise VerifyError("--verify-cmd values cannot be empty.")
|
|
2425
|
+
return _resolved_verify_commands(commands=commands, source="cli.verify_cmd")
|
|
2426
|
+
|
|
2427
|
+
commands = normalize_verify_command_list(cfg.verify_commands)
|
|
2428
|
+
if not commands:
|
|
2429
|
+
managed_host_verifier_unavailable = bool(
|
|
2430
|
+
(cfg.extra_fields or {}).get("managed_host_verifier_unavailable")
|
|
2431
|
+
)
|
|
2432
|
+
if not allow_empty_config and not managed_host_verifier_unavailable:
|
|
2433
|
+
raise VerifyError("Configured verify_commands is empty.")
|
|
2434
|
+
inferred = _resolve_repo_inferred_verify_commands(root=root, repo_scan=repo_scan)
|
|
2435
|
+
if inferred:
|
|
2436
|
+
return _resolved_verify_commands(
|
|
2437
|
+
commands=inferred,
|
|
2438
|
+
source="repo_scan.likely_test_commands",
|
|
2439
|
+
reason=(
|
|
2440
|
+
"managed host verifier is unavailable, but repo scan discovered "
|
|
2441
|
+
"authoritative repo-native verification commands"
|
|
2442
|
+
if managed_host_verifier_unavailable
|
|
2443
|
+
else (
|
|
2444
|
+
"configured verify_commands is empty, but repo scan discovered "
|
|
2445
|
+
"authoritative repo-native verification commands"
|
|
2446
|
+
)
|
|
2447
|
+
),
|
|
2448
|
+
)
|
|
2449
|
+
return _resolved_verify_commands(
|
|
2450
|
+
commands=(),
|
|
2451
|
+
source=(
|
|
2452
|
+
"environment.managed_host_verifier_unavailable"
|
|
2453
|
+
if managed_host_verifier_unavailable
|
|
2454
|
+
else REPO_SCAN_NO_AUTHORITATIVE_SOURCE
|
|
2455
|
+
),
|
|
2456
|
+
reason=(
|
|
2457
|
+
"managed host verifier is unavailable and no repo-native verification "
|
|
2458
|
+
"commands were discovered"
|
|
2459
|
+
if managed_host_verifier_unavailable
|
|
2460
|
+
else (
|
|
2461
|
+
"configured verify_commands is empty and no repo-native verification "
|
|
2462
|
+
"commands were discovered"
|
|
2463
|
+
)
|
|
2464
|
+
),
|
|
2465
|
+
contract_type="unavailable",
|
|
2466
|
+
)
|
|
2467
|
+
generic_config_preset = is_generic_configured_verify_preset(commands)
|
|
2468
|
+
if commands and not generic_config_preset:
|
|
2469
|
+
return _resolved_verify_commands(commands=commands, source="config.verify_commands")
|
|
2470
|
+
|
|
2471
|
+
inferred = _resolve_repo_inferred_verify_commands(root=root, repo_scan=repo_scan)
|
|
2472
|
+
if inferred:
|
|
2473
|
+
return _resolved_verify_commands(commands=inferred, source="repo_scan.likely_test_commands")
|
|
2474
|
+
|
|
2475
|
+
verify_commands_explicitly_set = "verify_commands" in getattr(cfg, "model_fields_set", set())
|
|
2476
|
+
source = (
|
|
2477
|
+
CONFIG_VERIFY_COMMANDS_FALLBACK_SOURCE
|
|
2478
|
+
if is_generic_verify_command_fallback(commands) and not verify_commands_explicitly_set
|
|
2479
|
+
else CONFIG_VERIFY_COMMANDS_GENERIC_PRESET_SOURCE
|
|
2480
|
+
)
|
|
2481
|
+
return _resolved_verify_commands(commands=commands, source=source)
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def resolve_verify_commands(
|
|
2485
|
+
*,
|
|
2486
|
+
cfg: AppConfig,
|
|
2487
|
+
verify_cmd: list[str] | None,
|
|
2488
|
+
root: Path | None = None,
|
|
2489
|
+
repo_scan: RepoScanResult | None = None,
|
|
2490
|
+
) -> list[str]:
|
|
2491
|
+
return list(
|
|
2492
|
+
resolve_verify_command_selection(
|
|
2493
|
+
cfg=cfg,
|
|
2494
|
+
verify_cmd=verify_cmd,
|
|
2495
|
+
root=root,
|
|
2496
|
+
repo_scan=repo_scan,
|
|
2497
|
+
).commands
|
|
2498
|
+
)
|
|
2499
|
+
|
|
2500
|
+
|
|
2501
|
+
@dataclass(frozen=True)
|
|
2502
|
+
class VerificationSelectionRepair:
|
|
2503
|
+
selection: ResolvedVerifyCommands
|
|
2504
|
+
dropped_commands: tuple[str, ...] = tuple()
|
|
2505
|
+
warning: str = ""
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
def repair_invalid_verify_command_selection(
|
|
2509
|
+
selection: ResolvedVerifyCommands,
|
|
2510
|
+
*,
|
|
2511
|
+
root: Path | None = None,
|
|
2512
|
+
repo_scan: RepoScanResult | None = None,
|
|
2513
|
+
) -> VerificationSelectionRepair:
|
|
2514
|
+
"""Make an unusable verification selection survivable instead of fatal.
|
|
2515
|
+
|
|
2516
|
+
Selecting a verification command is an optimization over the work the agent
|
|
2517
|
+
is about to do, not a precondition for it: a session that cannot name an
|
|
2518
|
+
authoritative command must still run the task and report honestly on what it
|
|
2519
|
+
could verify. That applies to a command Alysis Code could not *classify*. A
|
|
2520
|
+
command Alysis Code recognized and *refused* -- a vacuous verifier, a masked
|
|
2521
|
+
failure, an unsafe pipeline -- is a different thing entirely: running it
|
|
2522
|
+
would manufacture passing evidence, so it still fails fast rather than
|
|
2523
|
+
quietly becoming the session's contract.
|
|
2524
|
+
|
|
2525
|
+
Among survivable commands, explicitly stated ones (managed host,
|
|
2526
|
+
``--verify-cmd``, configured ``verify_commands``) are kept as-is with a
|
|
2527
|
+
warning -- Alysis Code does not substitute its own guess for what somebody
|
|
2528
|
+
asked for. Commands Alysis Code inferred itself are dropped, detection falls
|
|
2529
|
+
through to whatever runner the workspace exposes, and an empty result
|
|
2530
|
+
degrades to a best-effort contract rather than an error.
|
|
2531
|
+
"""
|
|
2532
|
+
errors = validation_errors_for_selection(selection)
|
|
2533
|
+
if not errors:
|
|
2534
|
+
return VerificationSelectionRepair(selection=selection)
|
|
2535
|
+
|
|
2536
|
+
specs = verification_command_specs_for_selection(selection)
|
|
2537
|
+
# Scoped to authoritative selections exactly as the original guard was: this
|
|
2538
|
+
# change narrows what is fatal (by reason), it must never widen it.
|
|
2539
|
+
if is_authoritative_verify_command_selection(selection):
|
|
2540
|
+
refused = [
|
|
2541
|
+
f"{spec.original_text}: {spec.rejection_reason or 'invalid_command'}"
|
|
2542
|
+
for spec in specs
|
|
2543
|
+
if spec.validation_status == VerificationCommandValidationStatus.INVALID
|
|
2544
|
+
and not rejection_reason_is_unclassifiable(spec.rejection_reason)
|
|
2545
|
+
]
|
|
2546
|
+
if refused:
|
|
2547
|
+
raise VerifyError(
|
|
2548
|
+
"authoritative verification command is invalid: " + "; ".join(refused[:3])
|
|
2549
|
+
)
|
|
2550
|
+
|
|
2551
|
+
invalid = tuple(
|
|
2552
|
+
spec.original_text
|
|
2553
|
+
for spec in specs
|
|
2554
|
+
if spec.validation_status == VerificationCommandValidationStatus.INVALID
|
|
2555
|
+
)
|
|
2556
|
+
detail = "; ".join(errors[:3])
|
|
2557
|
+
|
|
2558
|
+
if selection.source in EXPLICIT_VERIFY_COMMAND_SOURCES:
|
|
2559
|
+
return VerificationSelectionRepair(
|
|
2560
|
+
selection=selection,
|
|
2561
|
+
warning=(
|
|
2562
|
+
"verification command was explicitly requested but cannot be treated as "
|
|
2563
|
+
f"authoritative evidence ({detail}); keeping it and continuing"
|
|
2564
|
+
),
|
|
2565
|
+
)
|
|
2566
|
+
|
|
2567
|
+
remaining = tuple(command for command in selection.commands if command not in invalid)
|
|
2568
|
+
if remaining:
|
|
2569
|
+
return VerificationSelectionRepair(
|
|
2570
|
+
selection=_resolved_verify_commands(
|
|
2571
|
+
commands=remaining,
|
|
2572
|
+
source=selection.source,
|
|
2573
|
+
reason=_appended_reason(selection.reason, f"dropped unusable command(s): {detail}"),
|
|
2574
|
+
contract_type=selection.contract_type,
|
|
2575
|
+
),
|
|
2576
|
+
dropped_commands=invalid,
|
|
2577
|
+
warning=f"ignoring unusable verification command(s): {detail}",
|
|
2578
|
+
)
|
|
2579
|
+
|
|
2580
|
+
detected = _detect_fallback_verify_commands(root=root, repo_scan=repo_scan)
|
|
2581
|
+
if detected:
|
|
2582
|
+
return VerificationSelectionRepair(
|
|
2583
|
+
selection=_resolved_verify_commands(
|
|
2584
|
+
commands=detected,
|
|
2585
|
+
source=VERIFICATION_FALLBACK_DETECTED_SOURCE,
|
|
2586
|
+
contract_type="repo_native",
|
|
2587
|
+
),
|
|
2588
|
+
dropped_commands=invalid,
|
|
2589
|
+
warning=(
|
|
2590
|
+
f"ignoring unusable verification command(s): {detail}; "
|
|
2591
|
+
f"using detected command(s) instead: {', '.join(detected)}"
|
|
2592
|
+
),
|
|
2593
|
+
)
|
|
2594
|
+
|
|
2595
|
+
return VerificationSelectionRepair(
|
|
2596
|
+
selection=_resolved_verify_commands(
|
|
2597
|
+
commands=(),
|
|
2598
|
+
source=VERIFICATION_FALLBACK_BEST_EFFORT_SOURCE,
|
|
2599
|
+
contract_type="unavailable",
|
|
2600
|
+
best_effort=True,
|
|
2601
|
+
),
|
|
2602
|
+
dropped_commands=invalid,
|
|
2603
|
+
warning=(
|
|
2604
|
+
f"ignoring unusable verification command(s): {detail}; no verification command was "
|
|
2605
|
+
"detected, continuing with best-effort verification"
|
|
2606
|
+
),
|
|
2607
|
+
)
|
|
2608
|
+
|
|
2609
|
+
|
|
2610
|
+
def _detect_fallback_verify_commands(
|
|
2611
|
+
*,
|
|
2612
|
+
root: Path | None,
|
|
2613
|
+
repo_scan: RepoScanResult | None,
|
|
2614
|
+
) -> tuple[str, ...]:
|
|
2615
|
+
"""Detection chain consulted after a selected command turns out unusable.
|
|
2616
|
+
|
|
2617
|
+
Declared pytest config and ``unittest`` discovery come first: they answer
|
|
2618
|
+
"what does this workspace say it runs" for exactly the old-style layouts the
|
|
2619
|
+
primary inference stays silent about. Anything else Alysis Code already knows
|
|
2620
|
+
how to recognize (make, just, npm, maven, go, cargo, a pytest test layout)
|
|
2621
|
+
comes from the repo scan behind them.
|
|
2622
|
+
"""
|
|
2623
|
+
layers: tuple[Callable[[], tuple[str, ...] | list[str]], ...] = (
|
|
2624
|
+
(lambda: detect_fallback_test_commands(root=root)) if root is not None else (lambda: []),
|
|
2625
|
+
lambda: _resolve_repo_inferred_verify_commands(root=root, repo_scan=repo_scan),
|
|
2626
|
+
)
|
|
2627
|
+
# Evaluated lazily: the repo-scan layer can trigger a full rescan of the
|
|
2628
|
+
# workspace, which must not happen once an earlier layer has already answered.
|
|
2629
|
+
for layer in layers:
|
|
2630
|
+
detected = _valid_verify_commands(
|
|
2631
|
+
layer(),
|
|
2632
|
+
source=VERIFICATION_FALLBACK_DETECTED_SOURCE,
|
|
2633
|
+
contract_type="repo_native",
|
|
2634
|
+
)
|
|
2635
|
+
if detected:
|
|
2636
|
+
return detected
|
|
2637
|
+
return ()
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
|
+
def _appended_reason(reason: str, note: str) -> str:
|
|
2641
|
+
base = str(reason or "").strip()
|
|
2642
|
+
return f"{base}; {note}" if base else note
|
|
2643
|
+
|
|
2644
|
+
|
|
2645
|
+
def _valid_verify_commands(
|
|
2646
|
+
commands: tuple[str, ...] | list[str],
|
|
2647
|
+
*,
|
|
2648
|
+
source: str,
|
|
2649
|
+
contract_type: str,
|
|
2650
|
+
) -> tuple[str, ...]:
|
|
2651
|
+
return tuple(
|
|
2652
|
+
spec.original_text
|
|
2653
|
+
for spec in build_verification_command_specs(
|
|
2654
|
+
commands,
|
|
2655
|
+
source=source,
|
|
2656
|
+
contract_type=contract_type,
|
|
2657
|
+
)
|
|
2658
|
+
if spec.validation_status == VerificationCommandValidationStatus.VALID
|
|
2659
|
+
)
|
|
2660
|
+
|
|
2661
|
+
|
|
2662
|
+
def _repo_scan_describes_root(scan: RepoScanResult, root: Path) -> bool:
|
|
2663
|
+
raw_scan_root = str(getattr(scan, "workspace_root", "") or "").strip()
|
|
2664
|
+
if not raw_scan_root:
|
|
2665
|
+
return False
|
|
2666
|
+
try:
|
|
2667
|
+
scan_root = Path(raw_scan_root).expanduser().resolve()
|
|
2668
|
+
target_root = root.resolve()
|
|
2669
|
+
except OSError:
|
|
2670
|
+
return False
|
|
2671
|
+
return os.path.normcase(str(scan_root)) == os.path.normcase(str(target_root))
|
|
2672
|
+
|
|
2673
|
+
|
|
2674
|
+
def _resolve_repo_inferred_verify_commands(
|
|
2675
|
+
*,
|
|
2676
|
+
root: Path | None,
|
|
2677
|
+
repo_scan: RepoScanResult | None,
|
|
2678
|
+
) -> tuple[str, ...]:
|
|
2679
|
+
# A scan of the requested root is authoritative as-is: files created after it
|
|
2680
|
+
# was captured must not retroactively establish a verification contract. A scan
|
|
2681
|
+
# of a different root (e.g. the base workspace while verifying a merge-candidate
|
|
2682
|
+
# copy) is stale for command inference, so rescan the candidate root while
|
|
2683
|
+
# preserving the original scan's focus.
|
|
2684
|
+
scan = repo_scan
|
|
2685
|
+
if root is not None and (scan is None or not _repo_scan_describes_root(scan, root)):
|
|
2686
|
+
try:
|
|
2687
|
+
scan = scan_workspace(context=_resolve_verify_workspace_context(root, repo_scan))
|
|
2688
|
+
except (WorkspaceContextError, OSError):
|
|
2689
|
+
scan = repo_scan
|
|
2690
|
+
if scan is None:
|
|
2691
|
+
return ()
|
|
2692
|
+
# A scan may predate this filter (persisted scans are replayed verbatim), so
|
|
2693
|
+
# docs-doctest commands are rejected here as well as at inference time.
|
|
2694
|
+
likely_commands = tuple(
|
|
2695
|
+
command
|
|
2696
|
+
for command in normalize_verify_command_list(scan.likely_test_commands)
|
|
2697
|
+
if not command_is_docs_doctest(command)
|
|
2698
|
+
)
|
|
2699
|
+
if likely_commands:
|
|
2700
|
+
return likely_commands
|
|
2701
|
+
return ()
|
|
2702
|
+
|
|
2703
|
+
|
|
2704
|
+
def _infer_node_project_script_verify_commands(
|
|
2705
|
+
*,
|
|
2706
|
+
root: Path,
|
|
2707
|
+
repo_scan: RepoScanResult,
|
|
2708
|
+
) -> tuple[str, ...]:
|
|
2709
|
+
package_json_paths = [
|
|
2710
|
+
str(item.get("path") or "").strip()
|
|
2711
|
+
for item in repo_scan.manifests
|
|
2712
|
+
if PurePosixPath(str(item.get("path") or "")).name == "package.json"
|
|
2713
|
+
]
|
|
2714
|
+
commands: list[str] = []
|
|
2715
|
+
for package_json_path in sorted(package_json_paths, key=_node_package_manifest_sort_key):
|
|
2716
|
+
scripts = _read_package_json_scripts(root / package_json_path)
|
|
2717
|
+
if not scripts:
|
|
2718
|
+
continue
|
|
2719
|
+
manager = _node_package_manager_for_manifest(
|
|
2720
|
+
root=root,
|
|
2721
|
+
package_json_path=package_json_path,
|
|
2722
|
+
repo_scan=repo_scan,
|
|
2723
|
+
)
|
|
2724
|
+
selected_scripts = _select_node_verify_scripts(scripts)
|
|
2725
|
+
for script_name in selected_scripts:
|
|
2726
|
+
commands.append(
|
|
2727
|
+
_node_package_script_command(
|
|
2728
|
+
manager=manager,
|
|
2729
|
+
package_json_path=package_json_path,
|
|
2730
|
+
script_name=script_name,
|
|
2731
|
+
)
|
|
2732
|
+
)
|
|
2733
|
+
if commands:
|
|
2734
|
+
break
|
|
2735
|
+
return tuple(commands)
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def _node_package_manifest_sort_key(path: str) -> tuple[int, int, str]:
|
|
2739
|
+
pure = PurePosixPath(str(path or "."))
|
|
2740
|
+
return (0 if pure.parent.as_posix() in {"", "."} else 1, len(pure.parts), str(path))
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _read_package_json_scripts(path: Path) -> dict[str, str]:
|
|
2744
|
+
try:
|
|
2745
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
2746
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
2747
|
+
return {}
|
|
2748
|
+
if not isinstance(payload, dict):
|
|
2749
|
+
return {}
|
|
2750
|
+
scripts = payload.get("scripts")
|
|
2751
|
+
if not isinstance(scripts, dict):
|
|
2752
|
+
return {}
|
|
2753
|
+
return {
|
|
2754
|
+
str(name).strip(): str(command).strip()
|
|
2755
|
+
for name, command in scripts.items()
|
|
2756
|
+
if str(name).strip() and isinstance(command, str) and str(command).strip()
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
|
|
2760
|
+
def _select_node_verify_scripts(scripts: dict[str, str]) -> tuple[str, ...]:
|
|
2761
|
+
selected: list[str] = []
|
|
2762
|
+
for script_name in _NODE_VERIFY_SCRIPT_PRIORITY:
|
|
2763
|
+
command = scripts.get(script_name)
|
|
2764
|
+
if not command:
|
|
2765
|
+
continue
|
|
2766
|
+
if script_name == "test" and "no test specified" in command.casefold():
|
|
2767
|
+
continue
|
|
2768
|
+
selected.append(script_name)
|
|
2769
|
+
if script_name == "test":
|
|
2770
|
+
return tuple(selected)
|
|
2771
|
+
return tuple(selected)
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
def _node_package_manager_for_manifest(
|
|
2775
|
+
*,
|
|
2776
|
+
root: Path,
|
|
2777
|
+
package_json_path: str,
|
|
2778
|
+
repo_scan: RepoScanResult,
|
|
2779
|
+
) -> str:
|
|
2780
|
+
try:
|
|
2781
|
+
payload = json.loads((root / package_json_path).read_text(encoding="utf-8"))
|
|
2782
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
2783
|
+
payload = None
|
|
2784
|
+
if isinstance(payload, dict):
|
|
2785
|
+
raw_manager = payload.get("packageManager")
|
|
2786
|
+
if isinstance(raw_manager, str):
|
|
2787
|
+
lowered = raw_manager.strip().casefold()
|
|
2788
|
+
for manager in ("pnpm", "yarn", "bun", "npm"):
|
|
2789
|
+
if lowered.startswith(f"{manager}@"):
|
|
2790
|
+
return manager
|
|
2791
|
+
manifest_paths = {str(item.get("path") or "") for item in repo_scan.manifests}
|
|
2792
|
+
package_dir = PurePosixPath(package_json_path).parent
|
|
2793
|
+
for candidate_dir in (package_dir, *package_dir.parents):
|
|
2794
|
+
prefix = "" if candidate_dir.as_posix() in {"", "."} else f"{candidate_dir.as_posix()}/"
|
|
2795
|
+
if f"{prefix}pnpm-lock.yaml" in manifest_paths:
|
|
2796
|
+
return "pnpm"
|
|
2797
|
+
if f"{prefix}yarn.lock" in manifest_paths:
|
|
2798
|
+
return "yarn"
|
|
2799
|
+
if f"{prefix}bun.lockb" in manifest_paths:
|
|
2800
|
+
return "bun"
|
|
2801
|
+
if f"{prefix}package-lock.json" in manifest_paths:
|
|
2802
|
+
return "npm"
|
|
2803
|
+
return "npm"
|
|
2804
|
+
|
|
2805
|
+
|
|
2806
|
+
def _node_package_script_command(
|
|
2807
|
+
*,
|
|
2808
|
+
manager: str,
|
|
2809
|
+
package_json_path: str,
|
|
2810
|
+
script_name: str,
|
|
2811
|
+
) -> str:
|
|
2812
|
+
package_dir = PurePosixPath(package_json_path).parent.as_posix()
|
|
2813
|
+
verb = "test" if script_name == "test" else f"run {script_name}"
|
|
2814
|
+
if package_dir in {"", "."}:
|
|
2815
|
+
return f"{manager} {verb}"
|
|
2816
|
+
quoted_dir = shlex.quote(package_dir)
|
|
2817
|
+
if manager == "pnpm":
|
|
2818
|
+
return f"pnpm --dir {quoted_dir} {verb}"
|
|
2819
|
+
if manager == "yarn":
|
|
2820
|
+
return f"yarn --cwd {quoted_dir} {verb}"
|
|
2821
|
+
if manager == "bun":
|
|
2822
|
+
return f"bun --cwd {quoted_dir} {verb}"
|
|
2823
|
+
return f"npm --prefix {quoted_dir} {verb}"
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def _resolve_verify_workspace_context(
|
|
2827
|
+
root: Path, repo_scan: RepoScanResult | None
|
|
2828
|
+
) -> WorkspaceContext:
|
|
2829
|
+
root = root.resolve()
|
|
2830
|
+
focus_relpath = str(getattr(repo_scan, "focus_relpath", "") or ".").strip()
|
|
2831
|
+
if focus_relpath and focus_relpath not in {".", ""}:
|
|
2832
|
+
focus_path = (root / focus_relpath).resolve()
|
|
2833
|
+
try:
|
|
2834
|
+
focus_path.relative_to(root)
|
|
2835
|
+
except ValueError:
|
|
2836
|
+
focus_path = root
|
|
2837
|
+
if focus_path.exists():
|
|
2838
|
+
return _workspace_context_for_root_focus(root=root, focus_path=focus_path)
|
|
2839
|
+
inferred_plain_focus = _plain_scan_focus_path_from_candidate_root(root, repo_scan)
|
|
2840
|
+
if inferred_plain_focus is not None:
|
|
2841
|
+
return _workspace_context_for_root_focus(root=root, focus_path=inferred_plain_focus)
|
|
2842
|
+
return resolve_workspace_context(root)
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
def _workspace_context_for_root_focus(*, root: Path, focus_path: Path) -> WorkspaceContext:
|
|
2846
|
+
root_context = resolve_workspace_context(root)
|
|
2847
|
+
focus_path = focus_path.resolve()
|
|
2848
|
+
if focus_path == root_context.workspace_root:
|
|
2849
|
+
return root_context
|
|
2850
|
+
if root_context.git_root is not None:
|
|
2851
|
+
return resolve_workspace_context(focus_path)
|
|
2852
|
+
focus_relpath = focus_path.relative_to(root_context.workspace_root).as_posix()
|
|
2853
|
+
return WorkspaceContext(
|
|
2854
|
+
input_path=focus_path,
|
|
2855
|
+
focus_path=focus_path,
|
|
2856
|
+
workspace_root=root_context.workspace_root,
|
|
2857
|
+
git_root=root_context.git_root,
|
|
2858
|
+
focus_relpath=focus_relpath,
|
|
2859
|
+
workspace_kind=root_context.workspace_kind,
|
|
2860
|
+
has_head_commit=root_context.has_head_commit,
|
|
2861
|
+
current_branch=root_context.current_branch,
|
|
2862
|
+
)
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
def _plain_scan_focus_path_from_candidate_root(
|
|
2866
|
+
root: Path,
|
|
2867
|
+
repo_scan: RepoScanResult | None,
|
|
2868
|
+
) -> Path | None:
|
|
2869
|
+
if repo_scan is None:
|
|
2870
|
+
return None
|
|
2871
|
+
if str(repo_scan.focus_relpath or ".").strip() not in {"", "."}:
|
|
2872
|
+
return None
|
|
2873
|
+
previous_root = Path(str(repo_scan.workspace_root or "")).expanduser()
|
|
2874
|
+
focus_name = previous_root.name
|
|
2875
|
+
if not focus_name or focus_name == root.name:
|
|
2876
|
+
return None
|
|
2877
|
+
candidate = (root / focus_name).resolve()
|
|
2878
|
+
try:
|
|
2879
|
+
candidate.relative_to(root)
|
|
2880
|
+
except ValueError:
|
|
2881
|
+
return None
|
|
2882
|
+
if not candidate.is_dir():
|
|
2883
|
+
return None
|
|
2884
|
+
signal_paths = [
|
|
2885
|
+
item.get("path", "")
|
|
2886
|
+
for item in [*repo_scan.manifests, *({"path": path} for path in repo_scan.readme_paths)]
|
|
2887
|
+
]
|
|
2888
|
+
signal_paths.extend(repo_scan.observed_paths)
|
|
2889
|
+
for raw_path in signal_paths:
|
|
2890
|
+
rel_path = str(raw_path or "").strip()
|
|
2891
|
+
if rel_path and (candidate / rel_path).exists():
|
|
2892
|
+
return candidate
|
|
2893
|
+
return None
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def _parse_verify_sandbox_mode(
|
|
2897
|
+
raw: object,
|
|
2898
|
+
*,
|
|
2899
|
+
field_name: str,
|
|
2900
|
+
default: str,
|
|
2901
|
+
) -> str:
|
|
2902
|
+
if raw is None:
|
|
2903
|
+
return default
|
|
2904
|
+
value = str(raw).strip().lower()
|
|
2905
|
+
if value in VERIFY_SANDBOX_MODES:
|
|
2906
|
+
return value
|
|
2907
|
+
opts = ", ".join(sorted(VERIFY_SANDBOX_MODES))
|
|
2908
|
+
raise VerifyError(f"Invalid {field_name}: {raw!r}. Expected one of: {opts}.")
|
|
2909
|
+
|
|
2910
|
+
|
|
2911
|
+
def resolve_verify_sandbox_mode(cfg: AppConfig) -> str:
|
|
2912
|
+
mode = "strict"
|
|
2913
|
+
raw_cfg = cfg.extra_fields.get("verify_sandbox")
|
|
2914
|
+
if raw_cfg is not None and not isinstance(raw_cfg, dict):
|
|
2915
|
+
raise VerifyError("Invalid verify_sandbox config: expected object.")
|
|
2916
|
+
cfg_map = raw_cfg if isinstance(raw_cfg, dict) else {}
|
|
2917
|
+
mode = _parse_verify_sandbox_mode(
|
|
2918
|
+
cfg_map.get("mode"),
|
|
2919
|
+
field_name="verify_sandbox.mode",
|
|
2920
|
+
default=mode,
|
|
2921
|
+
)
|
|
2922
|
+
mode = _parse_verify_sandbox_mode(
|
|
2923
|
+
env_get("ALYSIS_VERIFY_SANDBOX_MODE"),
|
|
2924
|
+
field_name="ALYSIS_VERIFY_SANDBOX_MODE",
|
|
2925
|
+
default=mode,
|
|
2926
|
+
)
|
|
2927
|
+
return mode
|
|
2928
|
+
|
|
2929
|
+
|
|
2930
|
+
@dataclass(frozen=True)
|
|
2931
|
+
class _PathSnapshot:
|
|
2932
|
+
existed: bool
|
|
2933
|
+
temp_dir: tempfile.TemporaryDirectory[str] | None = None
|
|
2934
|
+
snapshot_path: Path | None = None
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def _snapshot_path(path: Path) -> _PathSnapshot:
|
|
2938
|
+
if not path.exists():
|
|
2939
|
+
return _PathSnapshot(existed=False)
|
|
2940
|
+
temp_dir = tempfile.TemporaryDirectory(prefix="alysis-verify-snapshot-")
|
|
2941
|
+
snapshot_path = Path(temp_dir.name) / path.name
|
|
2942
|
+
if path.is_dir():
|
|
2943
|
+
shutil.copytree(path, snapshot_path)
|
|
2944
|
+
else:
|
|
2945
|
+
shutil.copy2(path, snapshot_path)
|
|
2946
|
+
return _PathSnapshot(existed=True, temp_dir=temp_dir, snapshot_path=snapshot_path)
|
|
2947
|
+
|
|
2948
|
+
|
|
2949
|
+
def _restore_path_snapshot(path: Path, snapshot: _PathSnapshot) -> None:
|
|
2950
|
+
try:
|
|
2951
|
+
if path.is_dir():
|
|
2952
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
2953
|
+
elif path.exists():
|
|
2954
|
+
path.unlink()
|
|
2955
|
+
if snapshot.existed and snapshot.snapshot_path is not None:
|
|
2956
|
+
if snapshot.snapshot_path.is_dir():
|
|
2957
|
+
shutil.copytree(snapshot.snapshot_path, path)
|
|
2958
|
+
else:
|
|
2959
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
2960
|
+
shutil.copy2(snapshot.snapshot_path, path)
|
|
2961
|
+
finally:
|
|
2962
|
+
if snapshot.temp_dir is not None:
|
|
2963
|
+
snapshot.temp_dir.cleanup()
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
def run_task_verification(
|
|
2967
|
+
*,
|
|
2968
|
+
root: Path,
|
|
2969
|
+
commands: list[str],
|
|
2970
|
+
artifact_path: Path,
|
|
2971
|
+
cfg: AppConfig | None = None,
|
|
2972
|
+
timeout_s: float = 900,
|
|
2973
|
+
process_group_registry: ProcessGroupRegistry | None = None,
|
|
2974
|
+
) -> VerifyRunResult:
|
|
2975
|
+
artifact_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2976
|
+
results: list[VerifyCommandResult] = []
|
|
2977
|
+
lines: list[str] = [
|
|
2978
|
+
"# Verification Output",
|
|
2979
|
+
f"root: {os.fspath(root.resolve())}",
|
|
2980
|
+
"",
|
|
2981
|
+
]
|
|
2982
|
+
effective_cfg = cfg or AppConfig(model="")
|
|
2983
|
+
verify_sandbox_mode = resolve_verify_sandbox_mode(effective_cfg)
|
|
2984
|
+
# The verifier's own commands are tracked too: a verify_run that hits its
|
|
2985
|
+
# timeout orphans workers exactly the way an agent-started test run does.
|
|
2986
|
+
# Verification is automated: its commands run with stdin closed so an
|
|
2987
|
+
# interactive prompt fails immediately and is reported, instead of waiting
|
|
2988
|
+
# on a terminal no one is reading until the per-command timeout expires.
|
|
2989
|
+
runner = (
|
|
2990
|
+
HostShellRunner(
|
|
2991
|
+
process_group_registry=process_group_registry,
|
|
2992
|
+
close_stdin=True,
|
|
2993
|
+
)
|
|
2994
|
+
if verify_sandbox_mode == "off"
|
|
2995
|
+
else None
|
|
2996
|
+
)
|
|
2997
|
+
runner_build_error: str | None = None
|
|
2998
|
+
failure_category: FailureCategory | None = None
|
|
2999
|
+
pytest_cache_path = root / ".pytest_cache"
|
|
3000
|
+
pytest_cache_snapshot = _snapshot_path(pytest_cache_path)
|
|
3001
|
+
try:
|
|
3002
|
+
if verify_sandbox_mode != "off":
|
|
3003
|
+
try:
|
|
3004
|
+
base_settings = resolve_shell_sandbox_settings(effective_cfg)
|
|
3005
|
+
verify_settings = replace(base_settings, mode=verify_sandbox_mode)
|
|
3006
|
+
runner = with_closed_stdin(
|
|
3007
|
+
build_shell_runner_from_settings(
|
|
3008
|
+
verify_settings,
|
|
3009
|
+
root,
|
|
3010
|
+
warning_callback=None,
|
|
3011
|
+
process_group_registry=process_group_registry,
|
|
3012
|
+
)
|
|
3013
|
+
)
|
|
3014
|
+
except (ConfigError, VerifyError) as e:
|
|
3015
|
+
runner_build_error = str(e)
|
|
3016
|
+
failure_category = FailureCategory.INFRA_UNAVAILABLE
|
|
3017
|
+
|
|
3018
|
+
for idx, command in enumerate(commands, start=1):
|
|
3019
|
+
effective_command = _expand_verification_command_globs(command, root=root)
|
|
3020
|
+
initial_execution = _run_verify_command_once(
|
|
3021
|
+
command=effective_command,
|
|
3022
|
+
root=root,
|
|
3023
|
+
runner=runner,
|
|
3024
|
+
runner_build_error=runner_build_error,
|
|
3025
|
+
timeout_s=timeout_s,
|
|
3026
|
+
)
|
|
3027
|
+
exit_code = initial_execution.exit_code
|
|
3028
|
+
stdout = initial_execution.stdout
|
|
3029
|
+
stderr = initial_execution.stderr
|
|
3030
|
+
output = initial_execution.output
|
|
3031
|
+
fallback_used = False
|
|
3032
|
+
fallback_reason: str | None = None
|
|
3033
|
+
|
|
3034
|
+
fallback_command = _build_pytest_module_fallback_command(command)
|
|
3035
|
+
if (
|
|
3036
|
+
runner_build_error is None
|
|
3037
|
+
and fallback_command
|
|
3038
|
+
and (
|
|
3039
|
+
_is_execution_layer_failure(
|
|
3040
|
+
exit_code=initial_execution.exit_code,
|
|
3041
|
+
output=output,
|
|
3042
|
+
)
|
|
3043
|
+
or _is_pytest_entrypoint_import_failure(command=command, output=output)
|
|
3044
|
+
)
|
|
3045
|
+
):
|
|
3046
|
+
fallback_used = True
|
|
3047
|
+
fallback_reason = "pytest_entrypoint_unavailable"
|
|
3048
|
+
effective_command = fallback_command
|
|
3049
|
+
fallback_execution = _run_verify_command_once(
|
|
3050
|
+
command=fallback_command,
|
|
3051
|
+
root=root,
|
|
3052
|
+
runner=runner,
|
|
3053
|
+
runner_build_error=runner_build_error,
|
|
3054
|
+
timeout_s=timeout_s,
|
|
3055
|
+
)
|
|
3056
|
+
exit_code = fallback_execution.exit_code
|
|
3057
|
+
stdout = fallback_execution.stdout
|
|
3058
|
+
stderr = fallback_execution.stderr
|
|
3059
|
+
output = fallback_execution.output
|
|
3060
|
+
|
|
3061
|
+
execution_assessment = assess_verification_command_execution(
|
|
3062
|
+
command=effective_command,
|
|
3063
|
+
exit_code=exit_code,
|
|
3064
|
+
output=output,
|
|
3065
|
+
)
|
|
3066
|
+
result = VerifyCommandResult(
|
|
3067
|
+
command=command,
|
|
3068
|
+
effective_command=effective_command,
|
|
3069
|
+
exit_code=exit_code,
|
|
3070
|
+
output=output,
|
|
3071
|
+
stdout=stdout,
|
|
3072
|
+
stderr=stderr,
|
|
3073
|
+
fallback_used=fallback_used,
|
|
3074
|
+
fallback_reason=fallback_reason,
|
|
3075
|
+
real_execution=execution_assessment.real_execution,
|
|
3076
|
+
non_execution_reason=execution_assessment.non_execution_reason,
|
|
3077
|
+
)
|
|
3078
|
+
results.append(result)
|
|
3079
|
+
if failure_category is None and (
|
|
3080
|
+
result.non_execution_reason == "execution_layer_failure"
|
|
3081
|
+
or is_infra_unavailable_error(result.output)
|
|
3082
|
+
or _is_toolchain_unavailable_failure(output=result.output)
|
|
3083
|
+
):
|
|
3084
|
+
failure_category = FailureCategory.INFRA_UNAVAILABLE
|
|
3085
|
+
|
|
3086
|
+
lines.extend(
|
|
3087
|
+
[
|
|
3088
|
+
f"## Command {idx}",
|
|
3089
|
+
f"requested_command: {command}",
|
|
3090
|
+
f"effective_command: {effective_command}",
|
|
3091
|
+
f"fallback_used: {str(fallback_used).lower()}",
|
|
3092
|
+
]
|
|
3093
|
+
)
|
|
3094
|
+
if fallback_reason:
|
|
3095
|
+
lines.append(f"fallback_reason: {fallback_reason}")
|
|
3096
|
+
lines.extend(
|
|
3097
|
+
[
|
|
3098
|
+
"----- initial output -----",
|
|
3099
|
+
initial_execution.output.rstrip() or "(no output)",
|
|
3100
|
+
]
|
|
3101
|
+
)
|
|
3102
|
+
if result.non_execution_reason:
|
|
3103
|
+
lines.append(f"non_execution_reason: {result.non_execution_reason}")
|
|
3104
|
+
lines.extend(
|
|
3105
|
+
[
|
|
3106
|
+
f"exit_code: {exit_code}",
|
|
3107
|
+
f"status: {result.status.value}",
|
|
3108
|
+
f"real_execution: {str(result.real_execution).lower() if result.real_execution is not None else 'unknown'}",
|
|
3109
|
+
"----- output -----",
|
|
3110
|
+
output.rstrip() or "(no output)",
|
|
3111
|
+
"",
|
|
3112
|
+
]
|
|
3113
|
+
)
|
|
3114
|
+
|
|
3115
|
+
run_result = VerifyRunResult(
|
|
3116
|
+
commands=commands,
|
|
3117
|
+
command_results=results,
|
|
3118
|
+
artifact_path=artifact_path,
|
|
3119
|
+
failure_category=(
|
|
3120
|
+
failure_category
|
|
3121
|
+
if failure_category is not None or all(item.ok for item in results)
|
|
3122
|
+
else FailureCategory.VERIFICATION_FAILED
|
|
3123
|
+
),
|
|
3124
|
+
)
|
|
3125
|
+
lines.extend(["Summary:", run_result.summary, ""])
|
|
3126
|
+
artifact_path.write_text("\n".join(lines), encoding="utf-8")
|
|
3127
|
+
return run_result
|
|
3128
|
+
finally:
|
|
3129
|
+
_restore_path_snapshot(pytest_cache_path, pytest_cache_snapshot)
|