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,2217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..config import normalize_verify_command_list
|
|
15
|
+
from ..repo_scan import RepoScanResult
|
|
16
|
+
from ..runtime_artifacts import is_runtime_artifact_path
|
|
17
|
+
from ..verification_command_analysis import (
|
|
18
|
+
CheckerEntrypointFingerprint,
|
|
19
|
+
analyze_verification_command,
|
|
20
|
+
)
|
|
21
|
+
from .turn_contract import (
|
|
22
|
+
MAX_EXPECTATIONS,
|
|
23
|
+
MIN_EXPECTED_OUTPUT_LITERAL_LEN,
|
|
24
|
+
Expectation,
|
|
25
|
+
ExpectationKind,
|
|
26
|
+
)
|
|
27
|
+
from .verification_commands import _matching_effective_verification_commands
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AcceptanceCriterionKind(StrEnum):
|
|
31
|
+
REQUIRED_ARTIFACT_PATH = "required_artifact_path"
|
|
32
|
+
CONTENT_FORMAT_SCHEMA = "content_format_schema"
|
|
33
|
+
EXPLICIT_COMMAND_IO = "explicit_command_io"
|
|
34
|
+
FUNCTIONAL_API_PROTOCOL = "functional_api_protocol"
|
|
35
|
+
PERSISTENT_SERVICE = "persistent_service"
|
|
36
|
+
DEPENDENCY_VERSION = "dependency_version"
|
|
37
|
+
PRESERVATION_UNCHANGED_PATH = "preservation_unchanged_path"
|
|
38
|
+
THRESHOLD = "threshold"
|
|
39
|
+
EXPLICIT_HOST_USER_VERIFICATION_COMMAND = "explicit_host_user_verification_command"
|
|
40
|
+
PREEXISTING_REPO_CHECK_SURFACE = "preexisting_repo_check_surface"
|
|
41
|
+
REFERENCE_PATH = "reference_path"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AcceptanceCriterionStatus(StrEnum):
|
|
45
|
+
UNVERIFIED = "UNVERIFIED"
|
|
46
|
+
PASSED = "PASSED"
|
|
47
|
+
FAILED = "FAILED"
|
|
48
|
+
BLOCKED = "BLOCKED"
|
|
49
|
+
NOT_APPLICABLE = "NOT_APPLICABLE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AcceptanceCriterionSource(StrEnum):
|
|
53
|
+
USER_INSTRUCTION = "user_instruction"
|
|
54
|
+
TASK_BRIEF = "task_brief"
|
|
55
|
+
PLANNING_CONSTRAINT = "planning_constraint"
|
|
56
|
+
HOST_VERIFICATION = "host_verification"
|
|
57
|
+
REPO_SCAN = "repo_scan"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class EvidenceOrigin(StrEnum):
|
|
61
|
+
HOST_AUTHORITATIVE = "HOST_AUTHORITATIVE"
|
|
62
|
+
USER_EXPLICIT = "USER_EXPLICIT"
|
|
63
|
+
PREEXISTING_REPO_NATIVE = "PREEXISTING_REPO_NATIVE"
|
|
64
|
+
PREEXISTING_TASK_CHECKER = "PREEXISTING_TASK_CHECKER"
|
|
65
|
+
DIRECT_BLACK_BOX = "DIRECT_BLACK_BOX"
|
|
66
|
+
SELF_AUTHORED = "SELF_AUTHORED"
|
|
67
|
+
AD_HOC_OBSERVATION = "AD_HOC_OBSERVATION"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class AcceptancePathKind(StrEnum):
|
|
71
|
+
WORKSPACE_RELATIVE = "WORKSPACE_RELATIVE"
|
|
72
|
+
ABSOLUTE_WITHIN_WORKSPACE = "ABSOLUTE_WITHIN_WORKSPACE"
|
|
73
|
+
ABSOLUTE_EXTERNAL = "ABSOLUTE_EXTERNAL"
|
|
74
|
+
UNRESOLVED = "UNRESOLVED"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AcceptancePathRole(StrEnum):
|
|
78
|
+
REQUIRED_OUTPUT = "required_output"
|
|
79
|
+
EXISTING_INPUT = "existing_input"
|
|
80
|
+
PRESERVATION_TARGET = "preservation_target"
|
|
81
|
+
VERIFICATION_CHECKER = "verification_checker"
|
|
82
|
+
UNKNOWN_REFERENCE = "unknown_reference"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class AcceptanceCriterionConfidence(StrEnum):
|
|
86
|
+
AUTHORITATIVE = "AUTHORITATIVE"
|
|
87
|
+
EXPLICIT = "EXPLICIT"
|
|
88
|
+
DERIVED_HIGH_CONFIDENCE = "DERIVED_HIGH_CONFIDENCE"
|
|
89
|
+
HEURISTIC = "HEURISTIC"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AcceptanceCriterionEnforcement(StrEnum):
|
|
93
|
+
HARD = "HARD"
|
|
94
|
+
ADVISORY = "ADVISORY"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class AcceptancePathRef:
|
|
99
|
+
raw_text: str
|
|
100
|
+
display_path: str
|
|
101
|
+
path_kind: AcceptancePathKind
|
|
102
|
+
role: AcceptancePathRole
|
|
103
|
+
workspace_relative_path: str = ""
|
|
104
|
+
absolute_path: str = ""
|
|
105
|
+
clause: str = ""
|
|
106
|
+
|
|
107
|
+
def as_payload(self) -> dict[str, Any]:
|
|
108
|
+
return {
|
|
109
|
+
"raw_text": self.raw_text,
|
|
110
|
+
"display_path": self.display_path,
|
|
111
|
+
"path_kind": self.path_kind.value,
|
|
112
|
+
"role": self.role.value,
|
|
113
|
+
"workspace_relative_path": self.workspace_relative_path,
|
|
114
|
+
"absolute_path": self.absolute_path,
|
|
115
|
+
"clause": self.clause,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class AcceptanceThreshold:
|
|
121
|
+
metric: str
|
|
122
|
+
operator: str
|
|
123
|
+
value: float
|
|
124
|
+
unit: str = ""
|
|
125
|
+
|
|
126
|
+
def as_payload(self) -> dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"metric": self.metric,
|
|
129
|
+
"operator": self.operator,
|
|
130
|
+
"value": self.value,
|
|
131
|
+
"unit": self.unit,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class AcceptanceEvidence:
|
|
137
|
+
evidence_id: str
|
|
138
|
+
origin: EvidenceOrigin
|
|
139
|
+
summary: str
|
|
140
|
+
passed: bool | None = None
|
|
141
|
+
command: str = ""
|
|
142
|
+
paths: tuple[str, ...] = tuple()
|
|
143
|
+
criterion_ids: tuple[str, ...] = tuple()
|
|
144
|
+
category: str = ""
|
|
145
|
+
|
|
146
|
+
def as_payload(self) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"id": self.evidence_id,
|
|
149
|
+
"origin": self.origin.value,
|
|
150
|
+
"summary": self.summary,
|
|
151
|
+
"passed": self.passed,
|
|
152
|
+
"command": self.command,
|
|
153
|
+
"paths": list(self.paths),
|
|
154
|
+
"criterion_ids": list(self.criterion_ids),
|
|
155
|
+
"category": self.category,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class AcceptanceCriterion:
|
|
161
|
+
criterion_id: str
|
|
162
|
+
kind: AcceptanceCriterionKind
|
|
163
|
+
source: AcceptanceCriterionSource | str
|
|
164
|
+
description: str
|
|
165
|
+
paths: tuple[str, ...] = tuple()
|
|
166
|
+
path_refs: tuple[AcceptancePathRef, ...] = tuple()
|
|
167
|
+
commands: tuple[str, ...] = tuple()
|
|
168
|
+
ports: tuple[int, ...] = tuple()
|
|
169
|
+
thresholds: tuple[AcceptanceThreshold, ...] = tuple()
|
|
170
|
+
required: bool = True
|
|
171
|
+
status: AcceptanceCriterionStatus = AcceptanceCriterionStatus.UNVERIFIED
|
|
172
|
+
evidence_ids: list[str] = field(default_factory=list)
|
|
173
|
+
service_ids: list[str] = field(default_factory=list)
|
|
174
|
+
failure_summary: str = ""
|
|
175
|
+
required_for_finalization: bool = True
|
|
176
|
+
confidence: AcceptanceCriterionConfidence = AcceptanceCriterionConfidence.EXPLICIT
|
|
177
|
+
enforcement: AcceptanceCriterionEnforcement = AcceptanceCriterionEnforcement.HARD
|
|
178
|
+
|
|
179
|
+
def add_evidence(
|
|
180
|
+
self,
|
|
181
|
+
evidence_id: str,
|
|
182
|
+
*,
|
|
183
|
+
status: AcceptanceCriterionStatus | None = None,
|
|
184
|
+
summary: str = "",
|
|
185
|
+
) -> None:
|
|
186
|
+
if evidence_id not in self.evidence_ids:
|
|
187
|
+
self.evidence_ids.append(evidence_id)
|
|
188
|
+
if status is not None:
|
|
189
|
+
self.status = status
|
|
190
|
+
if summary:
|
|
191
|
+
self.failure_summary = summary
|
|
192
|
+
|
|
193
|
+
def as_payload(self) -> dict[str, Any]:
|
|
194
|
+
return {
|
|
195
|
+
"id": self.criterion_id,
|
|
196
|
+
"kind": self.kind.value,
|
|
197
|
+
"source": str(getattr(self.source, "value", self.source)),
|
|
198
|
+
"description": self.description,
|
|
199
|
+
"paths": list(self.paths),
|
|
200
|
+
"path_refs": [path_ref.as_payload() for path_ref in self.path_refs],
|
|
201
|
+
"commands": list(self.commands),
|
|
202
|
+
"ports": list(self.ports),
|
|
203
|
+
"thresholds": [threshold.as_payload() for threshold in self.thresholds],
|
|
204
|
+
"required": self.required,
|
|
205
|
+
"status": self.status.value,
|
|
206
|
+
"evidence_ids": list(self.evidence_ids),
|
|
207
|
+
"service_ids": list(self.service_ids),
|
|
208
|
+
"failure_summary": self.failure_summary,
|
|
209
|
+
"required_for_finalization": self.required_for_finalization,
|
|
210
|
+
"confidence": self.confidence.value,
|
|
211
|
+
"enforcement": self.enforcement.value,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass(frozen=True)
|
|
216
|
+
class AcceptanceWorkspaceSnapshot:
|
|
217
|
+
preexisting_paths: frozenset[str] = frozenset()
|
|
218
|
+
preexisting_test_paths: frozenset[str] = frozenset()
|
|
219
|
+
preexisting_checker_paths: frozenset[str] = frozenset()
|
|
220
|
+
preexisting_checker_fingerprints: tuple[CheckerEntrypointFingerprint, ...] = tuple()
|
|
221
|
+
preexisting_verify_commands: tuple[str, ...] = tuple()
|
|
222
|
+
|
|
223
|
+
def as_payload(self) -> dict[str, Any]:
|
|
224
|
+
return {
|
|
225
|
+
"preexisting_paths": sorted(self.preexisting_paths)[:200],
|
|
226
|
+
"preexisting_test_paths": sorted(self.preexisting_test_paths)[:200],
|
|
227
|
+
"preexisting_checker_paths": sorted(self.preexisting_checker_paths)[:200],
|
|
228
|
+
"preexisting_checker_fingerprints": [
|
|
229
|
+
item.as_payload() for item in self.preexisting_checker_fingerprints[:200]
|
|
230
|
+
],
|
|
231
|
+
"preexisting_verify_commands": list(self.preexisting_verify_commands),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class AcceptanceContract:
|
|
237
|
+
criteria: list[AcceptanceCriterion] = field(default_factory=list)
|
|
238
|
+
evidence: list[AcceptanceEvidence] = field(default_factory=list)
|
|
239
|
+
snapshot: AcceptanceWorkspaceSnapshot = field(default_factory=AcceptanceWorkspaceSnapshot)
|
|
240
|
+
allowed_output_paths: set[str] = field(default_factory=set)
|
|
241
|
+
path_refs: list[AcceptancePathRef] = field(default_factory=list)
|
|
242
|
+
# Turn-contract v2 (step 4): concrete, checkable expectations extracted from the
|
|
243
|
+
# task text (expected-output literals, named loci, named behaviors). Empty is
|
|
244
|
+
# valid; the completion gate demands a disposition per expectation.
|
|
245
|
+
expectations: list[Expectation] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
def next_evidence_id(self) -> str:
|
|
248
|
+
return f"ev{len(self.evidence) + 1:03d}"
|
|
249
|
+
|
|
250
|
+
def add_evidence(
|
|
251
|
+
self,
|
|
252
|
+
*,
|
|
253
|
+
origin: EvidenceOrigin,
|
|
254
|
+
summary: str,
|
|
255
|
+
passed: bool | None = None,
|
|
256
|
+
command: str = "",
|
|
257
|
+
paths: tuple[str, ...] = tuple(),
|
|
258
|
+
criterion_ids: tuple[str, ...] = tuple(),
|
|
259
|
+
category: str = "",
|
|
260
|
+
) -> AcceptanceEvidence:
|
|
261
|
+
evidence = AcceptanceEvidence(
|
|
262
|
+
evidence_id=self.next_evidence_id(),
|
|
263
|
+
origin=origin,
|
|
264
|
+
summary=summary,
|
|
265
|
+
passed=passed,
|
|
266
|
+
command=command,
|
|
267
|
+
paths=tuple(paths),
|
|
268
|
+
criterion_ids=tuple(criterion_ids),
|
|
269
|
+
category=category,
|
|
270
|
+
)
|
|
271
|
+
self.evidence.append(evidence)
|
|
272
|
+
return evidence
|
|
273
|
+
|
|
274
|
+
def required_criteria(self) -> list[AcceptanceCriterion]:
|
|
275
|
+
return [
|
|
276
|
+
criterion
|
|
277
|
+
for criterion in self.criteria
|
|
278
|
+
if criterion.required and criterion.required_for_finalization
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
def status_counts(self) -> dict[str, int]:
|
|
282
|
+
counts = {status.value: 0 for status in AcceptanceCriterionStatus}
|
|
283
|
+
for criterion in self.criteria:
|
|
284
|
+
counts[criterion.status.value] = counts.get(criterion.status.value, 0) + 1
|
|
285
|
+
return counts
|
|
286
|
+
|
|
287
|
+
def problem_names(self) -> list[str]:
|
|
288
|
+
problems: list[str] = []
|
|
289
|
+
required = self.required_criteria()
|
|
290
|
+
if any(item.status == AcceptanceCriterionStatus.FAILED for item in required):
|
|
291
|
+
problems.append("acceptance_criteria_failed")
|
|
292
|
+
if any(item.status == AcceptanceCriterionStatus.BLOCKED for item in required):
|
|
293
|
+
problems.append("acceptance_evidence_insufficient")
|
|
294
|
+
if any(
|
|
295
|
+
item.status == AcceptanceCriterionStatus.UNVERIFIED
|
|
296
|
+
and item.kind != AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE
|
|
297
|
+
for item in required
|
|
298
|
+
):
|
|
299
|
+
problems.append("acceptance_criteria_unverified")
|
|
300
|
+
if any(
|
|
301
|
+
item.status == AcceptanceCriterionStatus.FAILED
|
|
302
|
+
and item.kind == AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH
|
|
303
|
+
for item in required
|
|
304
|
+
):
|
|
305
|
+
problems.append("unexpected_scope_changes")
|
|
306
|
+
return list(dict.fromkeys(problems))
|
|
307
|
+
|
|
308
|
+
def failure_summaries(self) -> list[str]:
|
|
309
|
+
return [
|
|
310
|
+
f"{criterion.criterion_id}: {criterion.failure_summary or criterion.description}"
|
|
311
|
+
for criterion in self.required_criteria()
|
|
312
|
+
if criterion.status
|
|
313
|
+
in {
|
|
314
|
+
AcceptanceCriterionStatus.FAILED,
|
|
315
|
+
AcceptanceCriterionStatus.BLOCKED,
|
|
316
|
+
AcceptanceCriterionStatus.UNVERIFIED,
|
|
317
|
+
}
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
def as_payload(self) -> dict[str, Any]:
|
|
321
|
+
return {
|
|
322
|
+
"criteria": [criterion.as_payload() for criterion in self.criteria],
|
|
323
|
+
"evidence": [evidence.as_payload() for evidence in self.evidence[-20:]],
|
|
324
|
+
"snapshot": self.snapshot.as_payload(),
|
|
325
|
+
"allowed_output_paths": sorted(self.allowed_output_paths),
|
|
326
|
+
"path_refs": [path_ref.as_payload() for path_ref in self.path_refs],
|
|
327
|
+
"expectations": [expectation.as_payload() for expectation in self.expectations],
|
|
328
|
+
"status_counts": self.status_counts(),
|
|
329
|
+
"problems": self.problem_names(),
|
|
330
|
+
"failure_summaries": self.failure_summaries()[:10],
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
_BACKTICK_COMMAND_RE = re.compile(r"`([^`\n]+)`")
|
|
335
|
+
_PATH_RE = re.compile(
|
|
336
|
+
r"(?<![\w.-])("
|
|
337
|
+
r"(?:[A-Za-z]:[\\/]|[\\/]{1,2}|\.{1,2}[\\/])?"
|
|
338
|
+
r"(?:[A-Za-z0-9_.-]+[\\/])+[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)?|"
|
|
339
|
+
r"(?:\.{1,2}[\\/])?[A-Za-z0-9_.-]+\."
|
|
340
|
+
r"(?:py|js|ts|tsx|jsx|json|toml|yaml|yml|txt|md|html|css|csv|xml|sql|sh|go|rs|java|rb|php|out|expected|actual|bin)"
|
|
341
|
+
r")(?=$|[\s,;:!?\]\)}]|[.](?:\s|$))"
|
|
342
|
+
)
|
|
343
|
+
_PORT_RE = re.compile(r"\bport\s+([1-9][0-9]{1,4})\b", re.I)
|
|
344
|
+
_THRESHOLD_RE = re.compile(
|
|
345
|
+
r"\b(?P<metric>accuracy|score|coverage|latency|runtime|time|duration|size|memory|throughput|performance)\b"
|
|
346
|
+
r"[^.\n]{0,80}?"
|
|
347
|
+
r"(?P<op>>=|<=|>|<|at least|at most|under|below|above|over|less than|more than)\s*"
|
|
348
|
+
r"(?P<value>[0-9]+(?:\.[0-9]+)?)\s*(?P<unit>%|ms|s|sec|seconds|mb|kb|fps|x)?",
|
|
349
|
+
re.I,
|
|
350
|
+
)
|
|
351
|
+
_NUMBER_RE = re.compile(r"[-+]?[0-9]+(?:\.[0-9]+)?")
|
|
352
|
+
_FORMAT_RE = re.compile(r"\b(json|yaml|csv|xml|html|markdown|schema|format)\b", re.I)
|
|
353
|
+
_EXPLICIT_FORMAT_RE = re.compile(
|
|
354
|
+
r"\b(?:valid|well-formed|as|in|format(?:ted)?\s+as|schema(?:\s+of)?|must\s+be|should\s+be)\s+"
|
|
355
|
+
r"(json|yaml|csv|xml|html|markdown)\b|"
|
|
356
|
+
r"\b(json|yaml|csv|xml|html|markdown)\s+(?:format|schema|file|document|output)\b",
|
|
357
|
+
re.I,
|
|
358
|
+
)
|
|
359
|
+
_SERVICE_RE = re.compile(
|
|
360
|
+
r"\b(?:keep|remain|stay)\s+(?:the\s+)?(?:server|service|process|daemon)?\s*running\b|"
|
|
361
|
+
r"\b(?:persistent|background)\s+(?:server|service|process|daemon)\b|"
|
|
362
|
+
r"\blisten(?:ing)?\s+on\s+port\b",
|
|
363
|
+
re.I,
|
|
364
|
+
)
|
|
365
|
+
_PRESERVE_RE = re.compile(
|
|
366
|
+
r"\b(?:do\s+not|don't|dont|without|never|leave|keep)\s+"
|
|
367
|
+
r"(?:modify|change|touch|overwrite|alter|remove|delete|unchanged)\b[^\n]*",
|
|
368
|
+
re.I,
|
|
369
|
+
)
|
|
370
|
+
_ONLY_RE = re.compile(r"\b(?:only|just)\s+(?:write|create|modify|change|touch)\b[^.\n]*", re.I)
|
|
371
|
+
_OUTPUT_ROLE_RE = re.compile(
|
|
372
|
+
r"\b(?:save|write|create|produce|output|generate|emit|export|move\s+to|put\s+in|store)\b",
|
|
373
|
+
re.I,
|
|
374
|
+
)
|
|
375
|
+
_INPUT_ROLE_RE = re.compile(
|
|
376
|
+
r"\b(?:read\s+from|input|initial|reference|source|given\s+at|provided\s+at|load\s+from|using)\b",
|
|
377
|
+
re.I,
|
|
378
|
+
)
|
|
379
|
+
_PRESERVATION_ROLE_RE = re.compile(
|
|
380
|
+
r"\b(?:do\s+not|don't|dont|without|never|leave|keep|preserve)\b"
|
|
381
|
+
r"[^;\n]{0,80}?"
|
|
382
|
+
r"\b(?:modify|change|touch|overwrite|alter|remove|delete|unchanged|intact)\b|"
|
|
383
|
+
r"\b(?:preserve|keep)\b[^;\n]{0,80}?\b(?:unchanged|intact)\b",
|
|
384
|
+
re.I,
|
|
385
|
+
)
|
|
386
|
+
_CHECKER_ROLE_RE = re.compile(
|
|
387
|
+
r"\b(?:test|check|verify|validator|validation|compare|diff|cmp)\b", re.I
|
|
388
|
+
)
|
|
389
|
+
_COMMAND_INTRO_RE = re.compile(
|
|
390
|
+
r"\b(?:run|execute|test\s+with|verify\s+with|validate\s+with|check\s+with|install\s+with|"
|
|
391
|
+
r"using\s+command|command|shell|terminal|bash)\b",
|
|
392
|
+
re.I,
|
|
393
|
+
)
|
|
394
|
+
_PYTHON_SNIPPET_INTRO_RE = re.compile(
|
|
395
|
+
r"\b(?:python|py)\b.{0,40}\b(?:snippet|code|validation|check|assert|execute|run)\b|"
|
|
396
|
+
r"\b(?:snippet|code|validation|check|assert|execute|run)\b.{0,40}\b(?:python|py)\b",
|
|
397
|
+
re.I,
|
|
398
|
+
)
|
|
399
|
+
_COMMAND_HEADS = {
|
|
400
|
+
"bash",
|
|
401
|
+
"cargo",
|
|
402
|
+
"cmp",
|
|
403
|
+
"curl",
|
|
404
|
+
"diff",
|
|
405
|
+
"go",
|
|
406
|
+
"just",
|
|
407
|
+
"make",
|
|
408
|
+
"node",
|
|
409
|
+
"npm",
|
|
410
|
+
"pnpm",
|
|
411
|
+
"pytest",
|
|
412
|
+
"python",
|
|
413
|
+
"python3",
|
|
414
|
+
"py",
|
|
415
|
+
"sh",
|
|
416
|
+
"uv",
|
|
417
|
+
"yarn",
|
|
418
|
+
}
|
|
419
|
+
_CHECK_PATH_MARKERS = {"check", "checks", "test", "tests", "verify", "validation", "validator"}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def build_acceptance_contract(
|
|
423
|
+
*,
|
|
424
|
+
root: Path,
|
|
425
|
+
instruction: str,
|
|
426
|
+
authoritative_verification_commands: list[str] | None = None,
|
|
427
|
+
effective_verification_commands: list[str] | None = None,
|
|
428
|
+
task_brief: str = "",
|
|
429
|
+
repo_scan: RepoScanResult | None = None,
|
|
430
|
+
planning_constraints: Any | None = None,
|
|
431
|
+
) -> AcceptanceContract:
|
|
432
|
+
snapshot = capture_acceptance_workspace_snapshot(
|
|
433
|
+
root=root,
|
|
434
|
+
repo_scan=repo_scan,
|
|
435
|
+
authoritative_verification_commands=authoritative_verification_commands,
|
|
436
|
+
effective_verification_commands=effective_verification_commands,
|
|
437
|
+
)
|
|
438
|
+
texts = [str(instruction or "").strip(), str(task_brief or "").strip()]
|
|
439
|
+
texts = [item for item in texts if item]
|
|
440
|
+
criteria: list[AcceptanceCriterion] = []
|
|
441
|
+
allowed_output_paths: set[str] = set()
|
|
442
|
+
path_refs = _extract_path_refs(root=root, texts=texts)
|
|
443
|
+
|
|
444
|
+
for path_ref in path_refs:
|
|
445
|
+
if path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT:
|
|
446
|
+
if path_ref.workspace_relative_path:
|
|
447
|
+
allowed_output_paths.add(path_ref.workspace_relative_path)
|
|
448
|
+
criteria.append(
|
|
449
|
+
_criterion(
|
|
450
|
+
criteria,
|
|
451
|
+
kind=AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH,
|
|
452
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
453
|
+
description=f"Required output path: {path_ref.display_path}",
|
|
454
|
+
paths=_legacy_paths_from_refs((path_ref,)),
|
|
455
|
+
path_refs=(path_ref,),
|
|
456
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
457
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
elif path_ref.role == AcceptancePathRole.PRESERVATION_TARGET:
|
|
461
|
+
criteria.append(
|
|
462
|
+
_criterion(
|
|
463
|
+
criteria,
|
|
464
|
+
kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
|
|
465
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
466
|
+
description=f"Preserve unchanged: {path_ref.display_path}",
|
|
467
|
+
paths=_legacy_paths_from_refs((path_ref,)),
|
|
468
|
+
path_refs=(path_ref,),
|
|
469
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
470
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
elif path_ref.role in {
|
|
474
|
+
AcceptancePathRole.EXISTING_INPUT,
|
|
475
|
+
AcceptancePathRole.VERIFICATION_CHECKER,
|
|
476
|
+
AcceptancePathRole.UNKNOWN_REFERENCE,
|
|
477
|
+
}:
|
|
478
|
+
criteria.append(
|
|
479
|
+
_criterion(
|
|
480
|
+
criteria,
|
|
481
|
+
kind=AcceptanceCriterionKind.REFERENCE_PATH,
|
|
482
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
483
|
+
description=f"Path reference: {path_ref.display_path}",
|
|
484
|
+
paths=_legacy_paths_from_refs((path_ref,)),
|
|
485
|
+
path_refs=(path_ref,),
|
|
486
|
+
confidence=AcceptanceCriterionConfidence.HEURISTIC,
|
|
487
|
+
enforcement=AcceptanceCriterionEnforcement.ADVISORY,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
for path_ref, fmt, explicit in _extract_path_scoped_formats(path_refs, texts=texts):
|
|
492
|
+
confidence = (
|
|
493
|
+
AcceptanceCriterionConfidence.EXPLICIT
|
|
494
|
+
if explicit
|
|
495
|
+
else AcceptanceCriterionConfidence.HEURISTIC
|
|
496
|
+
)
|
|
497
|
+
enforcement = (
|
|
498
|
+
AcceptanceCriterionEnforcement.HARD
|
|
499
|
+
if explicit and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
|
|
500
|
+
else AcceptanceCriterionEnforcement.ADVISORY
|
|
501
|
+
)
|
|
502
|
+
criteria.append(
|
|
503
|
+
_criterion(
|
|
504
|
+
criteria,
|
|
505
|
+
kind=AcceptanceCriterionKind.CONTENT_FORMAT_SCHEMA,
|
|
506
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
507
|
+
description=f"{fmt.upper()} format requirement for {path_ref.display_path}",
|
|
508
|
+
paths=_legacy_paths_from_refs((path_ref,)),
|
|
509
|
+
path_refs=(path_ref,),
|
|
510
|
+
confidence=confidence,
|
|
511
|
+
enforcement=enforcement,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
for command in _extract_explicit_commands(texts):
|
|
516
|
+
criteria.append(
|
|
517
|
+
_criterion(
|
|
518
|
+
criteria,
|
|
519
|
+
kind=AcceptanceCriterionKind.EXPLICIT_COMMAND_IO,
|
|
520
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
521
|
+
description=f"Explicit command must pass: {command}",
|
|
522
|
+
commands=(command,),
|
|
523
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
524
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
for threshold in _extract_thresholds(texts):
|
|
529
|
+
criteria.append(
|
|
530
|
+
_criterion(
|
|
531
|
+
criteria,
|
|
532
|
+
kind=AcceptanceCriterionKind.THRESHOLD,
|
|
533
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
534
|
+
description=(
|
|
535
|
+
f"Threshold: {threshold.metric} {threshold.operator} "
|
|
536
|
+
f"{threshold.value:g}{threshold.unit}"
|
|
537
|
+
),
|
|
538
|
+
thresholds=(threshold,),
|
|
539
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
540
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
for port in _extract_ports(texts):
|
|
545
|
+
criteria.append(
|
|
546
|
+
_criterion(
|
|
547
|
+
criteria,
|
|
548
|
+
kind=AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL,
|
|
549
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
550
|
+
description=f"Protocol/API behavior on port {port}",
|
|
551
|
+
ports=(port,),
|
|
552
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
553
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if any(_SERVICE_RE.search(text) for text in texts):
|
|
558
|
+
criteria.append(
|
|
559
|
+
_criterion(
|
|
560
|
+
criteria,
|
|
561
|
+
kind=AcceptanceCriterionKind.PERSISTENT_SERVICE,
|
|
562
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
563
|
+
description="Persistent service must survive finalization",
|
|
564
|
+
ports=tuple(_extract_ports(texts)),
|
|
565
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
566
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
if any(_ONLY_RE.search(text) for text in texts) and allowed_output_paths:
|
|
571
|
+
criteria.append(
|
|
572
|
+
_criterion(
|
|
573
|
+
criteria,
|
|
574
|
+
kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
|
|
575
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
576
|
+
description="No unexpected material paths outside requested outputs",
|
|
577
|
+
paths=tuple(sorted(allowed_output_paths)),
|
|
578
|
+
path_refs=tuple(
|
|
579
|
+
ref
|
|
580
|
+
for ref in path_refs
|
|
581
|
+
if ref.role == AcceptancePathRole.REQUIRED_OUTPUT
|
|
582
|
+
and ref.workspace_relative_path in allowed_output_paths
|
|
583
|
+
),
|
|
584
|
+
confidence=AcceptanceCriterionConfidence.EXPLICIT,
|
|
585
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
for command in normalize_verify_command_list(authoritative_verification_commands or []):
|
|
590
|
+
criteria.append(
|
|
591
|
+
_criterion(
|
|
592
|
+
criteria,
|
|
593
|
+
kind=AcceptanceCriterionKind.EXPLICIT_HOST_USER_VERIFICATION_COMMAND,
|
|
594
|
+
source=AcceptanceCriterionSource.HOST_VERIFICATION,
|
|
595
|
+
description=f"Host verification command must pass: {command}",
|
|
596
|
+
commands=(command,),
|
|
597
|
+
confidence=AcceptanceCriterionConfidence.AUTHORITATIVE,
|
|
598
|
+
enforcement=AcceptanceCriterionEnforcement.HARD,
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
for command in normalize_verify_command_list(effective_verification_commands or []):
|
|
603
|
+
if command in {item for criterion in criteria for item in criterion.commands}:
|
|
604
|
+
continue
|
|
605
|
+
source = (
|
|
606
|
+
AcceptanceCriterionSource.REPO_SCAN
|
|
607
|
+
if command in snapshot.preexisting_verify_commands
|
|
608
|
+
else AcceptanceCriterionSource.HOST_VERIFICATION
|
|
609
|
+
)
|
|
610
|
+
criteria.append(
|
|
611
|
+
_criterion(
|
|
612
|
+
criteria,
|
|
613
|
+
kind=AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE,
|
|
614
|
+
source=source,
|
|
615
|
+
description=f"Pre-existing verification surface: {command}",
|
|
616
|
+
commands=(command,),
|
|
617
|
+
confidence=AcceptanceCriterionConfidence.DERIVED_HIGH_CONFIDENCE,
|
|
618
|
+
enforcement=AcceptanceCriterionEnforcement.ADVISORY,
|
|
619
|
+
)
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
for item in _planning_constraint_criteria(criteria, planning_constraints):
|
|
623
|
+
criteria.append(item)
|
|
624
|
+
|
|
625
|
+
residual = _residual_functional_requirement(texts)
|
|
626
|
+
if residual:
|
|
627
|
+
criteria.append(
|
|
628
|
+
_criterion(
|
|
629
|
+
criteria,
|
|
630
|
+
kind=AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL,
|
|
631
|
+
source=AcceptanceCriterionSource.USER_INSTRUCTION,
|
|
632
|
+
description=f"Functional requirement context: {residual}",
|
|
633
|
+
confidence=AcceptanceCriterionConfidence.HEURISTIC,
|
|
634
|
+
enforcement=AcceptanceCriterionEnforcement.ADVISORY,
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
return AcceptanceContract(
|
|
639
|
+
criteria=_dedupe_criteria(criteria),
|
|
640
|
+
snapshot=snapshot,
|
|
641
|
+
allowed_output_paths=allowed_output_paths,
|
|
642
|
+
path_refs=path_refs,
|
|
643
|
+
expectations=extract_task_expectations(texts=texts, path_refs=path_refs),
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def capture_acceptance_workspace_snapshot(
|
|
648
|
+
*,
|
|
649
|
+
root: Path,
|
|
650
|
+
repo_scan: RepoScanResult | None = None,
|
|
651
|
+
authoritative_verification_commands: list[str] | None = None,
|
|
652
|
+
effective_verification_commands: list[str] | None = None,
|
|
653
|
+
) -> AcceptanceWorkspaceSnapshot:
|
|
654
|
+
preexisting_paths: set[str] = set()
|
|
655
|
+
preexisting_test_paths: set[str] = set()
|
|
656
|
+
preexisting_checker_paths: set[str] = set()
|
|
657
|
+
if repo_scan is not None:
|
|
658
|
+
for raw_path in [
|
|
659
|
+
*(str(item.get("path") or "") for item in repo_scan.manifests),
|
|
660
|
+
*repo_scan.readme_paths,
|
|
661
|
+
*repo_scan.observed_paths,
|
|
662
|
+
]:
|
|
663
|
+
path = _normalize_rel_path(raw_path)
|
|
664
|
+
if path:
|
|
665
|
+
preexisting_paths.add(path)
|
|
666
|
+
if _is_test_or_checker_path(path):
|
|
667
|
+
preexisting_test_paths.add(path)
|
|
668
|
+
root = root.resolve()
|
|
669
|
+
visited = 0
|
|
670
|
+
for candidate in _iter_bounded_existing_paths(root):
|
|
671
|
+
visited += 1
|
|
672
|
+
if visited > 600:
|
|
673
|
+
break
|
|
674
|
+
preexisting_paths.add(candidate)
|
|
675
|
+
if _is_test_or_checker_path(candidate):
|
|
676
|
+
preexisting_test_paths.add(candidate)
|
|
677
|
+
if _is_checker_path(candidate):
|
|
678
|
+
preexisting_checker_paths.add(candidate)
|
|
679
|
+
for command in normalize_verify_command_list(
|
|
680
|
+
[
|
|
681
|
+
*normalize_verify_command_list(authoritative_verification_commands or []),
|
|
682
|
+
*normalize_verify_command_list(effective_verification_commands or []),
|
|
683
|
+
]
|
|
684
|
+
):
|
|
685
|
+
analysis = analyze_verification_command(command, trusted=True, workspace_root=root)
|
|
686
|
+
for path in analysis.checker_entrypoint_paths:
|
|
687
|
+
normalized_path = _normalize_rel_path(path)
|
|
688
|
+
if normalized_path and (root / normalized_path).exists():
|
|
689
|
+
preexisting_checker_paths.add(normalized_path)
|
|
690
|
+
preexisting_test_paths.add(normalized_path)
|
|
691
|
+
preexisting_paths.add(normalized_path)
|
|
692
|
+
checker_fingerprints = tuple(
|
|
693
|
+
_fingerprint_checker_path(root=root, relpath=path)
|
|
694
|
+
for path in sorted(preexisting_checker_paths)
|
|
695
|
+
)
|
|
696
|
+
return AcceptanceWorkspaceSnapshot(
|
|
697
|
+
preexisting_paths=frozenset(preexisting_paths),
|
|
698
|
+
preexisting_test_paths=frozenset(preexisting_test_paths),
|
|
699
|
+
preexisting_checker_paths=frozenset(preexisting_checker_paths),
|
|
700
|
+
preexisting_checker_fingerprints=checker_fingerprints,
|
|
701
|
+
preexisting_verify_commands=tuple(
|
|
702
|
+
normalize_verify_command_list(
|
|
703
|
+
repo_scan.likely_test_commands if repo_scan is not None else []
|
|
704
|
+
)
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def record_acceptance_tool_effect(
|
|
710
|
+
*,
|
|
711
|
+
contract: AcceptanceContract | None,
|
|
712
|
+
root: Path,
|
|
713
|
+
tool_name: str,
|
|
714
|
+
arguments: dict[str, Any],
|
|
715
|
+
status: str,
|
|
716
|
+
result: dict[str, Any],
|
|
717
|
+
touched_paths: set[str],
|
|
718
|
+
known_verification_commands: list[str] | None = None,
|
|
719
|
+
verification_authoritative: bool = False,
|
|
720
|
+
evidence_category: str = "",
|
|
721
|
+
evidence_allowed: bool | None = None,
|
|
722
|
+
) -> None:
|
|
723
|
+
if contract is None:
|
|
724
|
+
return
|
|
725
|
+
normalized_tool = str(tool_name or "").strip().lower()
|
|
726
|
+
command = _observed_command(tool_name=normalized_tool, arguments=arguments, result=result)
|
|
727
|
+
command_passed = _command_passed(status=status, result=result)
|
|
728
|
+
origin = classify_evidence_origin(
|
|
729
|
+
contract=contract,
|
|
730
|
+
root=root,
|
|
731
|
+
command=command,
|
|
732
|
+
touched_paths=touched_paths,
|
|
733
|
+
known_verification_commands=known_verification_commands,
|
|
734
|
+
verification_authoritative=verification_authoritative,
|
|
735
|
+
)
|
|
736
|
+
criterion_ids: list[str] = []
|
|
737
|
+
if command:
|
|
738
|
+
criterion_ids.extend(
|
|
739
|
+
_update_command_and_threshold_criteria(
|
|
740
|
+
contract=contract,
|
|
741
|
+
command=command,
|
|
742
|
+
output=_tool_output(result),
|
|
743
|
+
passed=command_passed,
|
|
744
|
+
origin=origin,
|
|
745
|
+
)
|
|
746
|
+
)
|
|
747
|
+
criterion_ids.extend(
|
|
748
|
+
_update_path_criteria(
|
|
749
|
+
contract=contract,
|
|
750
|
+
root=root,
|
|
751
|
+
touched_paths=touched_paths,
|
|
752
|
+
status=status,
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
if normalized_tool in {
|
|
756
|
+
"shell_service_start",
|
|
757
|
+
"shell_service_status",
|
|
758
|
+
"workspace_preview_start",
|
|
759
|
+
}:
|
|
760
|
+
criterion_ids.extend(
|
|
761
|
+
_update_durable_service_criteria(
|
|
762
|
+
contract=contract,
|
|
763
|
+
result=result,
|
|
764
|
+
)
|
|
765
|
+
)
|
|
766
|
+
elif normalized_tool == "shell_background":
|
|
767
|
+
# persist=true routes shell_background through the durable-service
|
|
768
|
+
# manager, so its result carries durable ownership and is durable
|
|
769
|
+
# evidence. A plain background start produces no such payload and is
|
|
770
|
+
# still blocked exactly as before.
|
|
771
|
+
durable_criterion_ids = _update_durable_service_criteria(
|
|
772
|
+
contract=contract,
|
|
773
|
+
result=result,
|
|
774
|
+
)
|
|
775
|
+
criterion_ids.extend(
|
|
776
|
+
durable_criterion_ids
|
|
777
|
+
if durable_criterion_ids
|
|
778
|
+
else _block_session_owned_service_criteria(contract=contract)
|
|
779
|
+
)
|
|
780
|
+
if not criterion_ids and normalized_tool in {"verify_run", "shell_run"}:
|
|
781
|
+
criterion_ids.extend(
|
|
782
|
+
_update_repo_surface_criteria(
|
|
783
|
+
contract=contract,
|
|
784
|
+
command=command,
|
|
785
|
+
passed=command_passed,
|
|
786
|
+
evidence_allowed=evidence_allowed,
|
|
787
|
+
known_verification_commands=known_verification_commands,
|
|
788
|
+
origin=origin,
|
|
789
|
+
)
|
|
790
|
+
)
|
|
791
|
+
if command or touched_paths or criterion_ids:
|
|
792
|
+
evidence = contract.add_evidence(
|
|
793
|
+
origin=origin,
|
|
794
|
+
summary=_evidence_summary(command=command, touched_paths=touched_paths),
|
|
795
|
+
passed=command_passed,
|
|
796
|
+
command=command,
|
|
797
|
+
paths=tuple(sorted(touched_paths)),
|
|
798
|
+
criterion_ids=tuple(sorted(set(criterion_ids))),
|
|
799
|
+
category=evidence_category,
|
|
800
|
+
)
|
|
801
|
+
for criterion in contract.criteria:
|
|
802
|
+
if criterion.criterion_id in criterion_ids and evidence.evidence_id not in (
|
|
803
|
+
criterion.evidence_ids
|
|
804
|
+
):
|
|
805
|
+
criterion.evidence_ids.append(evidence.evidence_id)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def finalize_acceptance_contract(
|
|
809
|
+
*,
|
|
810
|
+
contract: AcceptanceContract | None,
|
|
811
|
+
root: Path,
|
|
812
|
+
touched_paths: set[str],
|
|
813
|
+
durable_service_status: Callable[[str], dict[str, Any]] | None = None,
|
|
814
|
+
) -> None:
|
|
815
|
+
if contract is None:
|
|
816
|
+
return
|
|
817
|
+
for criterion in contract.criteria:
|
|
818
|
+
if criterion.kind == AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH:
|
|
819
|
+
missing = _missing_required_output_paths(criterion=criterion, root=root)
|
|
820
|
+
if missing:
|
|
821
|
+
criterion.status = AcceptanceCriterionStatus.UNVERIFIED
|
|
822
|
+
criterion.failure_summary = "Required output path is missing: " + ", ".join(missing)
|
|
823
|
+
elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
824
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
825
|
+
elif criterion.kind == AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH:
|
|
826
|
+
if "outside requested outputs" in criterion.description.casefold():
|
|
827
|
+
unexpected = [
|
|
828
|
+
path
|
|
829
|
+
for path in sorted(touched_paths)
|
|
830
|
+
if _path_is_material_for_scope(path, root=root)
|
|
831
|
+
and not _path_matches_any(path, criterion.paths)
|
|
832
|
+
]
|
|
833
|
+
if unexpected:
|
|
834
|
+
criterion.status = AcceptanceCriterionStatus.FAILED
|
|
835
|
+
criterion.failure_summary = "Unexpected material path changed: " + ", ".join(
|
|
836
|
+
unexpected[:8]
|
|
837
|
+
)
|
|
838
|
+
elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
839
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
840
|
+
else:
|
|
841
|
+
changed = [
|
|
842
|
+
path
|
|
843
|
+
for path in sorted(touched_paths)
|
|
844
|
+
if _path_matches_any(path, _workspace_paths_for_criterion(criterion))
|
|
845
|
+
]
|
|
846
|
+
if changed:
|
|
847
|
+
criterion.status = AcceptanceCriterionStatus.FAILED
|
|
848
|
+
criterion.failure_summary = "Preservation path changed: " + ", ".join(changed)
|
|
849
|
+
elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
850
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
851
|
+
elif criterion.kind == AcceptanceCriterionKind.PERSISTENT_SERVICE:
|
|
852
|
+
if criterion.service_ids:
|
|
853
|
+
_finalize_persistent_service_criterion(
|
|
854
|
+
criterion=criterion,
|
|
855
|
+
durable_service_status=durable_service_status,
|
|
856
|
+
)
|
|
857
|
+
elif criterion.status == AcceptanceCriterionStatus.PASSED:
|
|
858
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
859
|
+
criterion.failure_summary = (
|
|
860
|
+
"Same-session service evidence is not durable-service evidence"
|
|
861
|
+
)
|
|
862
|
+
elif (
|
|
863
|
+
criterion.kind == AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL
|
|
864
|
+
and criterion.service_ids
|
|
865
|
+
):
|
|
866
|
+
_finalize_persistent_service_criterion(
|
|
867
|
+
criterion=criterion,
|
|
868
|
+
durable_service_status=durable_service_status,
|
|
869
|
+
)
|
|
870
|
+
elif criterion.kind == AcceptanceCriterionKind.CONTENT_FORMAT_SCHEMA:
|
|
871
|
+
_finalize_content_format_criterion(criterion=criterion, root=root)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def classify_evidence_origin(
|
|
875
|
+
*,
|
|
876
|
+
contract: AcceptanceContract,
|
|
877
|
+
command: str,
|
|
878
|
+
touched_paths: set[str],
|
|
879
|
+
root: Path | None = None,
|
|
880
|
+
known_verification_commands: list[str] | None = None,
|
|
881
|
+
verification_authoritative: bool = False,
|
|
882
|
+
) -> EvidenceOrigin:
|
|
883
|
+
command = _normalize_command(command)
|
|
884
|
+
if command and _command_references_mutable_preexisting_checker(
|
|
885
|
+
command,
|
|
886
|
+
contract=contract,
|
|
887
|
+
root=root,
|
|
888
|
+
):
|
|
889
|
+
return EvidenceOrigin.SELF_AUTHORED
|
|
890
|
+
if command and verification_authoritative:
|
|
891
|
+
matches = _matching_effective_verification_commands(
|
|
892
|
+
observed_command=command,
|
|
893
|
+
effective_verification_commands=known_verification_commands,
|
|
894
|
+
)
|
|
895
|
+
if matches:
|
|
896
|
+
return EvidenceOrigin.HOST_AUTHORITATIVE
|
|
897
|
+
if command and any(
|
|
898
|
+
_commands_equivalent(command, candidate)
|
|
899
|
+
for criterion in contract.criteria
|
|
900
|
+
if criterion.kind == AcceptanceCriterionKind.EXPLICIT_COMMAND_IO
|
|
901
|
+
for candidate in criterion.commands
|
|
902
|
+
):
|
|
903
|
+
return EvidenceOrigin.USER_EXPLICIT
|
|
904
|
+
if touched_paths and any(
|
|
905
|
+
_is_self_authored_check_path(path, contract) for path in touched_paths
|
|
906
|
+
):
|
|
907
|
+
return EvidenceOrigin.SELF_AUTHORED
|
|
908
|
+
if command and _command_references_self_authored_check(command, contract):
|
|
909
|
+
return EvidenceOrigin.SELF_AUTHORED
|
|
910
|
+
if command and _is_direct_black_box_command(command):
|
|
911
|
+
return EvidenceOrigin.DIRECT_BLACK_BOX
|
|
912
|
+
if command and _command_references_preexisting_checker(command, contract):
|
|
913
|
+
return EvidenceOrigin.PREEXISTING_TASK_CHECKER
|
|
914
|
+
if command and (
|
|
915
|
+
command in contract.snapshot.preexisting_verify_commands
|
|
916
|
+
or bool(
|
|
917
|
+
_matching_effective_verification_commands(
|
|
918
|
+
observed_command=command,
|
|
919
|
+
effective_verification_commands=contract.snapshot.preexisting_verify_commands,
|
|
920
|
+
)
|
|
921
|
+
)
|
|
922
|
+
):
|
|
923
|
+
return EvidenceOrigin.PREEXISTING_REPO_NATIVE
|
|
924
|
+
return EvidenceOrigin.AD_HOC_OBSERVATION
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def acceptance_contract_problem_payload(contract: AcceptanceContract | None) -> dict[str, Any]:
|
|
928
|
+
if contract is None:
|
|
929
|
+
return {
|
|
930
|
+
"acceptance_status_counts": {},
|
|
931
|
+
"acceptance_problems": [],
|
|
932
|
+
"acceptance_failure_summaries": [],
|
|
933
|
+
}
|
|
934
|
+
return {
|
|
935
|
+
"acceptance_status_counts": contract.status_counts(),
|
|
936
|
+
"acceptance_problems": contract.problem_names(),
|
|
937
|
+
"acceptance_failure_summaries": contract.failure_summaries()[:10],
|
|
938
|
+
"acceptance_contract": contract.as_payload(),
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _criterion(
|
|
943
|
+
existing: list[AcceptanceCriterion],
|
|
944
|
+
*,
|
|
945
|
+
kind: AcceptanceCriterionKind,
|
|
946
|
+
source: AcceptanceCriterionSource | str,
|
|
947
|
+
description: str,
|
|
948
|
+
paths: tuple[str, ...] = tuple(),
|
|
949
|
+
path_refs: tuple[AcceptancePathRef, ...] = tuple(),
|
|
950
|
+
commands: tuple[str, ...] = tuple(),
|
|
951
|
+
ports: tuple[int, ...] = tuple(),
|
|
952
|
+
thresholds: tuple[AcceptanceThreshold, ...] = tuple(),
|
|
953
|
+
required: bool | None = None,
|
|
954
|
+
required_for_finalization: bool | None = None,
|
|
955
|
+
confidence: AcceptanceCriterionConfidence = AcceptanceCriterionConfidence.EXPLICIT,
|
|
956
|
+
enforcement: AcceptanceCriterionEnforcement = AcceptanceCriterionEnforcement.HARD,
|
|
957
|
+
) -> AcceptanceCriterion:
|
|
958
|
+
is_hard = enforcement == AcceptanceCriterionEnforcement.HARD
|
|
959
|
+
resolved_required = is_hard if required is None else bool(required)
|
|
960
|
+
resolved_required_for_finalization = (
|
|
961
|
+
is_hard if required_for_finalization is None else bool(required_for_finalization)
|
|
962
|
+
)
|
|
963
|
+
return AcceptanceCriterion(
|
|
964
|
+
criterion_id=f"ac{len(existing) + 1:03d}",
|
|
965
|
+
kind=kind,
|
|
966
|
+
source=source,
|
|
967
|
+
description=" ".join(str(description or "").split())[:500],
|
|
968
|
+
paths=tuple(_normalize_rel_path(path) for path in paths if _normalize_rel_path(path)),
|
|
969
|
+
path_refs=tuple(path_refs),
|
|
970
|
+
commands=tuple(_normalize_command(command) for command in commands if command.strip()),
|
|
971
|
+
ports=tuple(ports),
|
|
972
|
+
thresholds=tuple(thresholds),
|
|
973
|
+
required=resolved_required,
|
|
974
|
+
required_for_finalization=resolved_required_for_finalization,
|
|
975
|
+
confidence=confidence,
|
|
976
|
+
enforcement=enforcement,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _dedupe_criteria(criteria: list[AcceptanceCriterion]) -> list[AcceptanceCriterion]:
|
|
981
|
+
seen: set[tuple[Any, ...]] = set()
|
|
982
|
+
out: list[AcceptanceCriterion] = []
|
|
983
|
+
for criterion in criteria:
|
|
984
|
+
key = (
|
|
985
|
+
criterion.kind.value,
|
|
986
|
+
criterion.description.casefold(),
|
|
987
|
+
criterion.paths,
|
|
988
|
+
criterion.commands,
|
|
989
|
+
criterion.ports,
|
|
990
|
+
tuple(
|
|
991
|
+
(item.metric, item.operator, item.value, item.unit) for item in criterion.thresholds
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
if key in seen:
|
|
995
|
+
continue
|
|
996
|
+
seen.add(key)
|
|
997
|
+
criterion.criterion_id = f"ac{len(out) + 1:03d}"
|
|
998
|
+
out.append(criterion)
|
|
999
|
+
return out
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _iter_clauses(texts: list[str]) -> list[str]:
|
|
1003
|
+
clauses: list[str] = []
|
|
1004
|
+
boundary = re.compile(
|
|
1005
|
+
r"\n+|;|,(?=\s*(?:and\s+)?(?:save|write|create|produce|output|generate|emit|move|"
|
|
1006
|
+
r"read|input|initial|reference|source|given|do\s+not|don't|dont|without|never|"
|
|
1007
|
+
r"leave|keep|preserve|run|verify|check)\b)|"
|
|
1008
|
+
r"\band\s+(?=(?:save|write|create|produce|output|generate|emit|move|read|"
|
|
1009
|
+
r"input|initial|reference|source|given|do\s+not|don't|dont|without|never|"
|
|
1010
|
+
r"leave|keep|preserve|run|verify|check)\b)|"
|
|
1011
|
+
r"(?<=[.!?])\s+(?=[A-Z])",
|
|
1012
|
+
re.I,
|
|
1013
|
+
)
|
|
1014
|
+
for text in texts:
|
|
1015
|
+
for clause in boundary.split(str(text or "")):
|
|
1016
|
+
normalized = " ".join(clause.split())
|
|
1017
|
+
if normalized:
|
|
1018
|
+
clauses.append(normalized)
|
|
1019
|
+
return clauses
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _clause_path_role(clause: str) -> AcceptancePathRole:
|
|
1023
|
+
if _PRESERVATION_ROLE_RE.search(clause):
|
|
1024
|
+
return AcceptancePathRole.PRESERVATION_TARGET
|
|
1025
|
+
if _OUTPUT_ROLE_RE.search(clause):
|
|
1026
|
+
return AcceptancePathRole.REQUIRED_OUTPUT
|
|
1027
|
+
if _INPUT_ROLE_RE.search(clause):
|
|
1028
|
+
return AcceptancePathRole.EXISTING_INPUT
|
|
1029
|
+
if _CHECKER_ROLE_RE.search(clause):
|
|
1030
|
+
return AcceptancePathRole.VERIFICATION_CHECKER
|
|
1031
|
+
return AcceptancePathRole.UNKNOWN_REFERENCE
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _clean_path_token(path: str) -> str:
|
|
1035
|
+
cleaned = str(path or "").strip().strip("`'\"").replace("\\", "/")
|
|
1036
|
+
cleaned = cleaned.rstrip(".,;:!?)]}")
|
|
1037
|
+
while cleaned.startswith("./"):
|
|
1038
|
+
cleaned = cleaned[2:]
|
|
1039
|
+
return cleaned
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def _extract_path_refs(*, root: Path, texts: list[str]) -> list[AcceptancePathRef]:
|
|
1043
|
+
refs: list[AcceptancePathRef] = []
|
|
1044
|
+
seen: set[tuple[str, str, str]] = set()
|
|
1045
|
+
for clause in _iter_clauses(texts):
|
|
1046
|
+
role = _clause_path_role(clause)
|
|
1047
|
+
for match in _PATH_RE.finditer(clause):
|
|
1048
|
+
path_ref = _resolve_acceptance_path(
|
|
1049
|
+
root=root,
|
|
1050
|
+
raw_text=match.group(1),
|
|
1051
|
+
role=role,
|
|
1052
|
+
clause=clause,
|
|
1053
|
+
)
|
|
1054
|
+
if path_ref is None:
|
|
1055
|
+
continue
|
|
1056
|
+
if (
|
|
1057
|
+
path_ref.role == AcceptancePathRole.UNKNOWN_REFERENCE
|
|
1058
|
+
and not _looks_like_explicit_artifact_path(path_ref.display_path)
|
|
1059
|
+
and path_ref.path_kind != AcceptancePathKind.ABSOLUTE_EXTERNAL
|
|
1060
|
+
):
|
|
1061
|
+
continue
|
|
1062
|
+
key = (
|
|
1063
|
+
path_ref.display_path.casefold(),
|
|
1064
|
+
path_ref.role.value,
|
|
1065
|
+
path_ref.path_kind.value,
|
|
1066
|
+
)
|
|
1067
|
+
if key in seen:
|
|
1068
|
+
continue
|
|
1069
|
+
seen.add(key)
|
|
1070
|
+
refs.append(path_ref)
|
|
1071
|
+
return refs[:40]
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _resolve_acceptance_path(
|
|
1075
|
+
*,
|
|
1076
|
+
root: Path,
|
|
1077
|
+
raw_text: str,
|
|
1078
|
+
role: AcceptancePathRole,
|
|
1079
|
+
clause: str,
|
|
1080
|
+
) -> AcceptancePathRef | None:
|
|
1081
|
+
cleaned = _clean_path_token(raw_text)
|
|
1082
|
+
if not cleaned or cleaned.startswith("-"):
|
|
1083
|
+
return None
|
|
1084
|
+
pure = PurePosixPath(cleaned)
|
|
1085
|
+
if ".." in pure.parts:
|
|
1086
|
+
return AcceptancePathRef(
|
|
1087
|
+
raw_text=raw_text,
|
|
1088
|
+
display_path=cleaned,
|
|
1089
|
+
path_kind=AcceptancePathKind.UNRESOLVED,
|
|
1090
|
+
role=role,
|
|
1091
|
+
clause=clause,
|
|
1092
|
+
)
|
|
1093
|
+
absolute = _classify_absolute_acceptance_path(cleaned=cleaned, root=root)
|
|
1094
|
+
if absolute is not None:
|
|
1095
|
+
rel, absolute_path = absolute
|
|
1096
|
+
if rel is None:
|
|
1097
|
+
return AcceptancePathRef(
|
|
1098
|
+
raw_text=raw_text,
|
|
1099
|
+
display_path=cleaned,
|
|
1100
|
+
path_kind=AcceptancePathKind.ABSOLUTE_EXTERNAL,
|
|
1101
|
+
role=role,
|
|
1102
|
+
absolute_path=absolute_path,
|
|
1103
|
+
clause=clause,
|
|
1104
|
+
)
|
|
1105
|
+
return AcceptancePathRef(
|
|
1106
|
+
raw_text=raw_text,
|
|
1107
|
+
display_path=rel or ".",
|
|
1108
|
+
path_kind=AcceptancePathKind.ABSOLUTE_WITHIN_WORKSPACE,
|
|
1109
|
+
role=role,
|
|
1110
|
+
workspace_relative_path=rel,
|
|
1111
|
+
absolute_path=absolute_path,
|
|
1112
|
+
clause=clause,
|
|
1113
|
+
)
|
|
1114
|
+
normalized = _normalize_rel_path(cleaned)
|
|
1115
|
+
if not normalized:
|
|
1116
|
+
return None
|
|
1117
|
+
candidate = Path(root).expanduser().resolve(strict=False) / normalized
|
|
1118
|
+
return AcceptancePathRef(
|
|
1119
|
+
raw_text=raw_text,
|
|
1120
|
+
display_path=normalized,
|
|
1121
|
+
path_kind=AcceptancePathKind.WORKSPACE_RELATIVE,
|
|
1122
|
+
role=role,
|
|
1123
|
+
workspace_relative_path=normalized,
|
|
1124
|
+
absolute_path=candidate.as_posix(),
|
|
1125
|
+
clause=clause,
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def _classify_absolute_acceptance_path(
|
|
1130
|
+
*,
|
|
1131
|
+
cleaned: str,
|
|
1132
|
+
root: Path,
|
|
1133
|
+
) -> tuple[str | None, str] | None:
|
|
1134
|
+
"""Classify absolute paths without applying host-OS semantics to user text.
|
|
1135
|
+
|
|
1136
|
+
``Path('/usr/local/bin/tool').resolve()`` on Windows silently prefixes the
|
|
1137
|
+
current drive, while ``Path('C:/workspace/file')`` is not absolute on POSIX.
|
|
1138
|
+
Acceptance paths can describe either style regardless of the host, so compare
|
|
1139
|
+
them with their matching pure-path model and preserve external spelling.
|
|
1140
|
+
"""
|
|
1141
|
+
|
|
1142
|
+
root_text = _clean_path_token(str(root))
|
|
1143
|
+
windows_candidate = PureWindowsPath(cleaned)
|
|
1144
|
+
if windows_candidate.is_absolute() and windows_candidate.drive:
|
|
1145
|
+
absolute_path = windows_candidate.as_posix()
|
|
1146
|
+
windows_root = PureWindowsPath(root_text)
|
|
1147
|
+
if windows_root.is_absolute() and windows_root.drive:
|
|
1148
|
+
try:
|
|
1149
|
+
rel = windows_candidate.relative_to(windows_root).as_posix()
|
|
1150
|
+
except ValueError:
|
|
1151
|
+
rel = None
|
|
1152
|
+
return rel, absolute_path
|
|
1153
|
+
return None, absolute_path
|
|
1154
|
+
|
|
1155
|
+
if not cleaned.startswith("/"):
|
|
1156
|
+
return None
|
|
1157
|
+
|
|
1158
|
+
posix_candidate = PurePosixPath(cleaned)
|
|
1159
|
+
posix_root = PurePosixPath(root_text)
|
|
1160
|
+
absolute_path = posix_candidate.as_posix()
|
|
1161
|
+
if posix_root.is_absolute():
|
|
1162
|
+
try:
|
|
1163
|
+
rel = posix_candidate.relative_to(posix_root).as_posix()
|
|
1164
|
+
except ValueError:
|
|
1165
|
+
rel = None
|
|
1166
|
+
return rel, absolute_path
|
|
1167
|
+
return None, absolute_path
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def _legacy_paths_from_refs(path_refs: tuple[AcceptancePathRef, ...]) -> tuple[str, ...]:
|
|
1171
|
+
paths: list[str] = []
|
|
1172
|
+
for path_ref in path_refs:
|
|
1173
|
+
if path_ref.workspace_relative_path:
|
|
1174
|
+
paths.append(path_ref.workspace_relative_path)
|
|
1175
|
+
elif path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL:
|
|
1176
|
+
paths.append(path_ref.display_path)
|
|
1177
|
+
return tuple(dict.fromkeys(paths))
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _extract_path_scoped_formats(
|
|
1181
|
+
path_refs: list[AcceptancePathRef],
|
|
1182
|
+
*,
|
|
1183
|
+
texts: list[str],
|
|
1184
|
+
) -> list[tuple[AcceptancePathRef, str, bool]]:
|
|
1185
|
+
out: list[tuple[AcceptancePathRef, str, bool]] = []
|
|
1186
|
+
seen: set[tuple[str, str]] = set()
|
|
1187
|
+
output_refs = [
|
|
1188
|
+
path_ref for path_ref in path_refs if path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
|
|
1189
|
+
]
|
|
1190
|
+
extension_formats = {
|
|
1191
|
+
".json": "json",
|
|
1192
|
+
".yaml": "yaml",
|
|
1193
|
+
".yml": "yaml",
|
|
1194
|
+
".csv": "csv",
|
|
1195
|
+
".xml": "xml",
|
|
1196
|
+
".html": "html",
|
|
1197
|
+
".md": "markdown",
|
|
1198
|
+
}
|
|
1199
|
+
for path_ref in path_refs:
|
|
1200
|
+
if path_ref.role != AcceptancePathRole.REQUIRED_OUTPUT:
|
|
1201
|
+
continue
|
|
1202
|
+
explicit_format = _explicit_format_for_clause(path_ref.clause)
|
|
1203
|
+
if explicit_format:
|
|
1204
|
+
key = (path_ref.display_path.casefold(), explicit_format)
|
|
1205
|
+
if key not in seen:
|
|
1206
|
+
seen.add(key)
|
|
1207
|
+
out.append((path_ref, explicit_format, True))
|
|
1208
|
+
continue
|
|
1209
|
+
suffix = PurePosixPath(path_ref.display_path).suffix.casefold()
|
|
1210
|
+
inferred = extension_formats.get(suffix)
|
|
1211
|
+
if inferred:
|
|
1212
|
+
key = (path_ref.display_path.casefold(), inferred)
|
|
1213
|
+
if key not in seen:
|
|
1214
|
+
seen.add(key)
|
|
1215
|
+
out.append((path_ref, inferred, False))
|
|
1216
|
+
if len(output_refs) == 1:
|
|
1217
|
+
for clause in _iter_clauses(texts):
|
|
1218
|
+
if _PATH_RE.search(clause):
|
|
1219
|
+
continue
|
|
1220
|
+
if not re.search(r"\b(?:output|result|artifact|file)\b", clause, re.I):
|
|
1221
|
+
continue
|
|
1222
|
+
explicit_format = _explicit_format_for_clause(clause)
|
|
1223
|
+
if not explicit_format:
|
|
1224
|
+
continue
|
|
1225
|
+
path_ref = output_refs[0]
|
|
1226
|
+
key = (path_ref.display_path.casefold(), explicit_format)
|
|
1227
|
+
if key not in seen:
|
|
1228
|
+
seen.add(key)
|
|
1229
|
+
out.append((path_ref, explicit_format, True))
|
|
1230
|
+
return out
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _explicit_format_for_clause(clause: str) -> str:
|
|
1234
|
+
for match in _EXPLICIT_FORMAT_RE.finditer(clause or ""):
|
|
1235
|
+
value = (match.group(1) or match.group(2) or "").casefold()
|
|
1236
|
+
if value:
|
|
1237
|
+
return value
|
|
1238
|
+
return ""
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _extract_paths(text: str) -> list[str]:
|
|
1242
|
+
out: list[str] = []
|
|
1243
|
+
seen: set[str] = set()
|
|
1244
|
+
for path_ref in _extract_path_refs(root=Path("."), texts=[text]):
|
|
1245
|
+
path = path_ref.workspace_relative_path or path_ref.display_path
|
|
1246
|
+
if not path or path.startswith("-") or ".." in PurePosixPath(path).parts:
|
|
1247
|
+
continue
|
|
1248
|
+
if not _looks_like_explicit_artifact_path(path):
|
|
1249
|
+
continue
|
|
1250
|
+
if path.casefold() in seen:
|
|
1251
|
+
continue
|
|
1252
|
+
seen.add(path.casefold())
|
|
1253
|
+
out.append(path)
|
|
1254
|
+
return out[:24]
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _extract_explicit_commands(texts: list[str]) -> list[str]:
|
|
1258
|
+
commands: list[str] = []
|
|
1259
|
+
seen: set[str] = set()
|
|
1260
|
+
for text in texts:
|
|
1261
|
+
for match in _BACKTICK_COMMAND_RE.finditer(text):
|
|
1262
|
+
candidate = _normalize_command(match.group(1))
|
|
1263
|
+
context = str(text or "")[max(0, match.start() - 80) : match.start()]
|
|
1264
|
+
python_snippet = _python_interpreter_snippet_command(candidate, context=context)
|
|
1265
|
+
if python_snippet:
|
|
1266
|
+
candidate = python_snippet
|
|
1267
|
+
elif not candidate or not _looks_like_command(candidate, context=context):
|
|
1268
|
+
continue
|
|
1269
|
+
key = candidate.casefold()
|
|
1270
|
+
if key in seen:
|
|
1271
|
+
continue
|
|
1272
|
+
seen.add(key)
|
|
1273
|
+
commands.append(candidate)
|
|
1274
|
+
return commands[:8]
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def extract_explicit_acceptance_commands(*texts: str) -> list[str]:
|
|
1278
|
+
return _extract_explicit_commands([str(text or "") for text in texts if str(text or "")])
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
# ---------------------------------------------------------------------------
|
|
1282
|
+
# Turn-contract v2 (step 4): expectation extraction.
|
|
1283
|
+
#
|
|
1284
|
+
# A zero-new-regex projection of the derivation's EXISTING extracted signals into
|
|
1285
|
+
# the contract-v2 expectations schema. Backtick literals the task shows as desired
|
|
1286
|
+
# output become ``expected_output`` expectations; files the task points at as the
|
|
1287
|
+
# fix site become ``named_locus`` expectations. This adds no new NL/keyword/regex
|
|
1288
|
+
# heuristic over task text — it reuses ``_BACKTICK_COMMAND_RE``, the existing
|
|
1289
|
+
# command/path classifiers, and the already-computed path refs. Precision over
|
|
1290
|
+
# recall: capped, deduped, and limited to signals a later mechanical check can
|
|
1291
|
+
# confirm (an ``expected_output`` literal observed in a run, or an edited locus).
|
|
1292
|
+
# ---------------------------------------------------------------------------
|
|
1293
|
+
|
|
1294
|
+
# A named locus is a file the task tells us to create or modify. UNKNOWN_REFERENCE
|
|
1295
|
+
# is deliberately excluded: the role classifier cannot tell "fix the bug in X"
|
|
1296
|
+
# (an edit site) from "count the lines in X" (a read-only input), so treating every
|
|
1297
|
+
# bare file mention as an edit target would spuriously demand editing inputs.
|
|
1298
|
+
# Precision over recall — a REQUIRED_OUTPUT path is unambiguously an edit target.
|
|
1299
|
+
_EXPECTATION_LOCUS_ROLES = {
|
|
1300
|
+
AcceptancePathRole.REQUIRED_OUTPUT,
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _expectation_span_is_path_like(span: str) -> bool:
|
|
1305
|
+
# A forward slash is the path separator used throughout; a bare backslash is
|
|
1306
|
+
# NOT treated as a path signal because LaTeX/math literals (``\dagger``) — a
|
|
1307
|
+
# common expected_output shape — carry backslashes. Windows-style ``dir\file``
|
|
1308
|
+
# paths still resolve via the explicit-artifact-path extension check below.
|
|
1309
|
+
token = span.strip().strip("`'\"")
|
|
1310
|
+
if "/" in token:
|
|
1311
|
+
return True
|
|
1312
|
+
return _looks_like_explicit_artifact_path(token)
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _expectation_span_is_symbol_like(span: str) -> bool:
|
|
1316
|
+
token = span.strip()
|
|
1317
|
+
if not token or any(char.isspace() for char in token):
|
|
1318
|
+
return False
|
|
1319
|
+
core = token[:-2] if token.endswith("()") else token
|
|
1320
|
+
parts = [part for part in core.split(".") if part]
|
|
1321
|
+
return bool(parts) and all(part.isidentifier() for part in parts)
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def _expectation_source_window(text: str, start: int, end: int) -> str:
|
|
1325
|
+
window = str(text or "")[max(0, start - 60) : end + 60]
|
|
1326
|
+
return " ".join(window.split())[:280]
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def extract_task_expectations(
|
|
1330
|
+
*,
|
|
1331
|
+
texts: list[str],
|
|
1332
|
+
path_refs: list[AcceptancePathRef],
|
|
1333
|
+
) -> list[Expectation]:
|
|
1334
|
+
"""Extract concrete, checkable expectations from the task text (contract v2).
|
|
1335
|
+
|
|
1336
|
+
Empty is valid (many tasks name no such literal or locus). Deterministic.
|
|
1337
|
+
"""
|
|
1338
|
+
expectations: list[Expectation] = []
|
|
1339
|
+
seen: set[tuple[str, str]] = set()
|
|
1340
|
+
|
|
1341
|
+
def _add(kind: ExpectationKind, raw_text: str, source_quote: str) -> None:
|
|
1342
|
+
text = str(raw_text or "").strip()
|
|
1343
|
+
if not text:
|
|
1344
|
+
return
|
|
1345
|
+
key = (kind.value, " ".join(text.split()).casefold())
|
|
1346
|
+
if key in seen:
|
|
1347
|
+
return
|
|
1348
|
+
seen.add(key)
|
|
1349
|
+
expectations.append(
|
|
1350
|
+
Expectation(
|
|
1351
|
+
expectation_id=f"exp{len(expectations) + 1:03d}",
|
|
1352
|
+
kind=kind,
|
|
1353
|
+
text=text,
|
|
1354
|
+
source_quote=" ".join(str(source_quote or "").split())[:280],
|
|
1355
|
+
)
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
# named_locus: files the task points at as the fix site / subject of the change
|
|
1359
|
+
# (a create/modify target or a bare reference — not a read-only input, a
|
|
1360
|
+
# preservation target, or a checker path, which editing would not confirm).
|
|
1361
|
+
for path_ref in path_refs:
|
|
1362
|
+
if path_ref.role not in _EXPECTATION_LOCUS_ROLES:
|
|
1363
|
+
continue
|
|
1364
|
+
locus = path_ref.workspace_relative_path or path_ref.display_path
|
|
1365
|
+
_add(ExpectationKind.NAMED_LOCUS, locus, path_ref.clause)
|
|
1366
|
+
|
|
1367
|
+
# expected_output: inline backtick literals shown as desired output — excluding
|
|
1368
|
+
# commands (already command criteria), paths (already loci) and bare identifiers
|
|
1369
|
+
# (code references, never runtime output).
|
|
1370
|
+
for text in texts:
|
|
1371
|
+
for match in _BACKTICK_COMMAND_RE.finditer(str(text or "")):
|
|
1372
|
+
span = match.group(1).strip()
|
|
1373
|
+
if len(span) < MIN_EXPECTED_OUTPUT_LITERAL_LEN:
|
|
1374
|
+
continue
|
|
1375
|
+
context = str(text)[max(0, match.start() - 80) : match.start()]
|
|
1376
|
+
if _looks_like_command(span, context=context):
|
|
1377
|
+
continue
|
|
1378
|
+
if _expectation_span_is_path_like(span):
|
|
1379
|
+
continue
|
|
1380
|
+
if _expectation_span_is_symbol_like(span):
|
|
1381
|
+
continue
|
|
1382
|
+
_add(
|
|
1383
|
+
ExpectationKind.EXPECTED_OUTPUT,
|
|
1384
|
+
span,
|
|
1385
|
+
_expectation_source_window(text, match.start(), match.end()),
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
return expectations[:MAX_EXPECTATIONS]
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _extract_thresholds(texts: list[str]) -> list[AcceptanceThreshold]:
|
|
1392
|
+
thresholds: list[AcceptanceThreshold] = []
|
|
1393
|
+
for text in texts:
|
|
1394
|
+
for match in _THRESHOLD_RE.finditer(text):
|
|
1395
|
+
operator = match.group("op").casefold()
|
|
1396
|
+
operator = {
|
|
1397
|
+
"at least": ">=",
|
|
1398
|
+
"above": ">",
|
|
1399
|
+
"over": ">",
|
|
1400
|
+
"more than": ">",
|
|
1401
|
+
"at most": "<=",
|
|
1402
|
+
"under": "<",
|
|
1403
|
+
"below": "<",
|
|
1404
|
+
"less than": "<",
|
|
1405
|
+
}.get(operator, operator)
|
|
1406
|
+
thresholds.append(
|
|
1407
|
+
AcceptanceThreshold(
|
|
1408
|
+
metric=match.group("metric").casefold(),
|
|
1409
|
+
operator=operator,
|
|
1410
|
+
value=float(match.group("value")),
|
|
1411
|
+
unit=str(match.group("unit") or ""),
|
|
1412
|
+
)
|
|
1413
|
+
)
|
|
1414
|
+
return thresholds[:8]
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _extract_ports(texts: list[str]) -> list[int]:
|
|
1418
|
+
ports: list[int] = []
|
|
1419
|
+
seen: set[int] = set()
|
|
1420
|
+
for text in texts:
|
|
1421
|
+
for match in _PORT_RE.finditer(text):
|
|
1422
|
+
port = int(match.group(1))
|
|
1423
|
+
if 0 < port <= 65535 and port not in seen:
|
|
1424
|
+
seen.add(port)
|
|
1425
|
+
ports.append(port)
|
|
1426
|
+
return ports[:8]
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def _extract_formats(texts: list[str]) -> list[str]:
|
|
1430
|
+
formats: list[str] = []
|
|
1431
|
+
seen: set[str] = set()
|
|
1432
|
+
for text in texts:
|
|
1433
|
+
for match in _FORMAT_RE.finditer(text):
|
|
1434
|
+
value = match.group(1).casefold()
|
|
1435
|
+
if value in seen:
|
|
1436
|
+
continue
|
|
1437
|
+
seen.add(value)
|
|
1438
|
+
formats.append(value)
|
|
1439
|
+
return formats[:8]
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _residual_functional_requirement(texts: list[str]) -> str:
|
|
1443
|
+
combined = " ".join(" ".join(text.split()) for text in texts if text.strip())
|
|
1444
|
+
if not combined:
|
|
1445
|
+
return ""
|
|
1446
|
+
return combined[:280]
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def _path_appears_preserved(path: str, texts: list[str]) -> bool:
|
|
1450
|
+
for text in texts:
|
|
1451
|
+
for match in _PRESERVE_RE.finditer(text):
|
|
1452
|
+
if path in _extract_paths(match.group(0)):
|
|
1453
|
+
return True
|
|
1454
|
+
return False
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _planning_constraint_criteria(
|
|
1458
|
+
existing: list[AcceptanceCriterion],
|
|
1459
|
+
planning_constraints: Any | None,
|
|
1460
|
+
) -> list[AcceptanceCriterion]:
|
|
1461
|
+
out: list[AcceptanceCriterion] = []
|
|
1462
|
+
if planning_constraints is None:
|
|
1463
|
+
return out
|
|
1464
|
+
for attr in ("forbidden_roots", "decoy_roots", "unrelated_roots"):
|
|
1465
|
+
for item in getattr(planning_constraints, attr, ()) or ():
|
|
1466
|
+
path = _normalize_rel_path(str(getattr(item, "path", "") or ""))
|
|
1467
|
+
if not path:
|
|
1468
|
+
continue
|
|
1469
|
+
out.append(
|
|
1470
|
+
_criterion(
|
|
1471
|
+
[*existing, *out],
|
|
1472
|
+
kind=AcceptanceCriterionKind.PRESERVATION_UNCHANGED_PATH,
|
|
1473
|
+
source=AcceptanceCriterionSource.PLANNING_CONSTRAINT,
|
|
1474
|
+
description=f"Planning constraint preserves blocked scope: {path}",
|
|
1475
|
+
paths=(path,),
|
|
1476
|
+
)
|
|
1477
|
+
)
|
|
1478
|
+
return out
|
|
1479
|
+
|
|
1480
|
+
|
|
1481
|
+
def _iter_bounded_existing_paths(root: Path) -> list[str]:
|
|
1482
|
+
out: list[str] = []
|
|
1483
|
+
skip = {
|
|
1484
|
+
".git",
|
|
1485
|
+
".hg",
|
|
1486
|
+
".svn",
|
|
1487
|
+
".venv",
|
|
1488
|
+
"__pycache__",
|
|
1489
|
+
"node_modules",
|
|
1490
|
+
"target",
|
|
1491
|
+
".pytest_cache",
|
|
1492
|
+
".ruff_cache",
|
|
1493
|
+
}
|
|
1494
|
+
stack = [(root, 0)]
|
|
1495
|
+
while stack and len(out) < 600:
|
|
1496
|
+
current, depth = stack.pop()
|
|
1497
|
+
try:
|
|
1498
|
+
entries = sorted(current.iterdir(), key=lambda item: item.name)
|
|
1499
|
+
except OSError:
|
|
1500
|
+
continue
|
|
1501
|
+
for entry in entries:
|
|
1502
|
+
if entry.name in skip:
|
|
1503
|
+
continue
|
|
1504
|
+
try:
|
|
1505
|
+
rel = entry.relative_to(root).as_posix()
|
|
1506
|
+
except ValueError:
|
|
1507
|
+
continue
|
|
1508
|
+
out.append(rel)
|
|
1509
|
+
if entry.is_dir() and depth < 3:
|
|
1510
|
+
stack.append((entry, depth + 1))
|
|
1511
|
+
if len(out) >= 600:
|
|
1512
|
+
break
|
|
1513
|
+
return out
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def _normalize_rel_path(path: str) -> str:
|
|
1517
|
+
cleaned = str(path or "").strip().replace("\\", "/")
|
|
1518
|
+
cleaned = cleaned.rstrip(".,;:!?)]}")
|
|
1519
|
+
while cleaned.startswith("./"):
|
|
1520
|
+
cleaned = cleaned[2:]
|
|
1521
|
+
return cleaned
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def _looks_like_explicit_artifact_path(path: str) -> bool:
|
|
1525
|
+
pure = PurePosixPath(path)
|
|
1526
|
+
name = pure.name
|
|
1527
|
+
if not name:
|
|
1528
|
+
return False
|
|
1529
|
+
if "." in name:
|
|
1530
|
+
return True
|
|
1531
|
+
return name in {
|
|
1532
|
+
"Dockerfile",
|
|
1533
|
+
"Gemfile",
|
|
1534
|
+
"Makefile",
|
|
1535
|
+
"Procfile",
|
|
1536
|
+
"Rakefile",
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _normalize_command(command: str) -> str:
|
|
1541
|
+
return " ".join(str(command or "").strip().split())
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _python_interpreter_snippet_command(command: str, *, context: str) -> str:
|
|
1545
|
+
if not _PYTHON_SNIPPET_INTRO_RE.search(context or ""):
|
|
1546
|
+
return ""
|
|
1547
|
+
if "\n" in command or "\r" in command:
|
|
1548
|
+
return ""
|
|
1549
|
+
lowered = command.casefold()
|
|
1550
|
+
if not (
|
|
1551
|
+
lowered.startswith(("from ", "import ", "assert "))
|
|
1552
|
+
or "; assert " in lowered
|
|
1553
|
+
or lowered.startswith(("print(", "raise "))
|
|
1554
|
+
):
|
|
1555
|
+
return ""
|
|
1556
|
+
try:
|
|
1557
|
+
ast.parse(command, mode="exec")
|
|
1558
|
+
except SyntaxError:
|
|
1559
|
+
return ""
|
|
1560
|
+
return "python -c " + shlex.quote(command)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _looks_like_command(command: str, *, context: str = "") -> bool:
|
|
1564
|
+
try:
|
|
1565
|
+
parts = shlex.split(command)
|
|
1566
|
+
except ValueError:
|
|
1567
|
+
return False
|
|
1568
|
+
if not parts:
|
|
1569
|
+
return False
|
|
1570
|
+
if _COMMAND_INTRO_RE.search(context or ""):
|
|
1571
|
+
return True
|
|
1572
|
+
head = Path(parts[0]).name.casefold()
|
|
1573
|
+
if head in _COMMAND_HEADS:
|
|
1574
|
+
return True
|
|
1575
|
+
if "/" in parts[0] and not parts[0].startswith("-"):
|
|
1576
|
+
return True
|
|
1577
|
+
if parts[0].startswith("./") and len(parts) >= 1:
|
|
1578
|
+
return True
|
|
1579
|
+
return False
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _is_test_or_checker_path(path: str) -> bool:
|
|
1583
|
+
pure = PurePosixPath(path)
|
|
1584
|
+
parts = {part.casefold() for part in pure.parts}
|
|
1585
|
+
name = pure.name.casefold()
|
|
1586
|
+
return (
|
|
1587
|
+
name.startswith("test_")
|
|
1588
|
+
or name.endswith("_test.py")
|
|
1589
|
+
or name.endswith(".test.js")
|
|
1590
|
+
or name.endswith(".spec.js")
|
|
1591
|
+
or bool(parts & _CHECK_PATH_MARKERS)
|
|
1592
|
+
)
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _is_checker_path(path: str) -> bool:
|
|
1596
|
+
pure = PurePosixPath(path)
|
|
1597
|
+
parts = {part.casefold() for part in pure.parts}
|
|
1598
|
+
stem = pure.stem.casefold()
|
|
1599
|
+
return bool(parts & {"checks", "verify", "validation"}) or any(
|
|
1600
|
+
marker in stem for marker in ("check", "verify", "validate")
|
|
1601
|
+
)
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _is_self_authored_check_path(path: str, contract: AcceptanceContract) -> bool:
|
|
1605
|
+
normalized = _normalize_rel_path(path)
|
|
1606
|
+
return _is_test_or_checker_path(normalized) and (
|
|
1607
|
+
normalized not in contract.snapshot.preexisting_test_paths
|
|
1608
|
+
and normalized not in contract.snapshot.preexisting_checker_paths
|
|
1609
|
+
)
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _command_references_self_authored_check(command: str, contract: AcceptanceContract) -> bool:
|
|
1613
|
+
return any(
|
|
1614
|
+
_is_self_authored_check_path(path, contract) for path in _command_path_tokens(command)
|
|
1615
|
+
)
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _command_references_preexisting_checker(command: str, contract: AcceptanceContract) -> bool:
|
|
1619
|
+
return any(
|
|
1620
|
+
path in contract.snapshot.preexisting_test_paths
|
|
1621
|
+
or path in contract.snapshot.preexisting_checker_paths
|
|
1622
|
+
for path in _command_path_tokens(command)
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _command_references_mutable_preexisting_checker(
|
|
1627
|
+
command: str,
|
|
1628
|
+
*,
|
|
1629
|
+
contract: AcceptanceContract,
|
|
1630
|
+
root: Path | None,
|
|
1631
|
+
) -> bool:
|
|
1632
|
+
if root is None:
|
|
1633
|
+
return False
|
|
1634
|
+
fingerprints = {
|
|
1635
|
+
_normalize_rel_path(item.display_path): item
|
|
1636
|
+
for item in contract.snapshot.preexisting_checker_fingerprints
|
|
1637
|
+
}
|
|
1638
|
+
for path in _command_path_tokens(command):
|
|
1639
|
+
if path not in contract.snapshot.preexisting_checker_paths:
|
|
1640
|
+
continue
|
|
1641
|
+
before = fingerprints.get(path)
|
|
1642
|
+
if before is None:
|
|
1643
|
+
return True
|
|
1644
|
+
after = _fingerprint_checker_path(root=root, relpath=path)
|
|
1645
|
+
if (
|
|
1646
|
+
before.resolved_path != after.resolved_path
|
|
1647
|
+
or before.is_regular_file != after.is_regular_file
|
|
1648
|
+
or before.size != after.size
|
|
1649
|
+
or before.sha256 != after.sha256
|
|
1650
|
+
):
|
|
1651
|
+
return True
|
|
1652
|
+
return False
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _fingerprint_checker_path(
|
|
1656
|
+
*,
|
|
1657
|
+
root: Path,
|
|
1658
|
+
relpath: str,
|
|
1659
|
+
max_bytes: int = 2_000_000,
|
|
1660
|
+
) -> CheckerEntrypointFingerprint:
|
|
1661
|
+
display_path = _normalize_rel_path(relpath)
|
|
1662
|
+
candidate = (root / display_path).resolve(strict=False)
|
|
1663
|
+
try:
|
|
1664
|
+
resolved = candidate.resolve(strict=True)
|
|
1665
|
+
stat = resolved.stat()
|
|
1666
|
+
except OSError:
|
|
1667
|
+
return CheckerEntrypointFingerprint(
|
|
1668
|
+
display_path=display_path,
|
|
1669
|
+
resolved_path=str(candidate),
|
|
1670
|
+
is_regular_file=False,
|
|
1671
|
+
size=None,
|
|
1672
|
+
sha256=None,
|
|
1673
|
+
)
|
|
1674
|
+
if not resolved.is_file() or stat.st_size > max_bytes:
|
|
1675
|
+
return CheckerEntrypointFingerprint(
|
|
1676
|
+
display_path=display_path,
|
|
1677
|
+
resolved_path=str(resolved),
|
|
1678
|
+
is_regular_file=resolved.is_file(),
|
|
1679
|
+
size=int(stat.st_size),
|
|
1680
|
+
sha256=None,
|
|
1681
|
+
)
|
|
1682
|
+
digest = hashlib.sha256()
|
|
1683
|
+
with resolved.open("rb") as handle:
|
|
1684
|
+
for chunk in iter(lambda: handle.read(65536), b""):
|
|
1685
|
+
digest.update(chunk)
|
|
1686
|
+
return CheckerEntrypointFingerprint(
|
|
1687
|
+
display_path=display_path,
|
|
1688
|
+
resolved_path=str(resolved),
|
|
1689
|
+
is_regular_file=True,
|
|
1690
|
+
size=int(stat.st_size),
|
|
1691
|
+
sha256=digest.hexdigest(),
|
|
1692
|
+
)
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _command_path_tokens(command: str) -> list[str]:
|
|
1696
|
+
try:
|
|
1697
|
+
parts = shlex.split(command)
|
|
1698
|
+
except ValueError:
|
|
1699
|
+
return []
|
|
1700
|
+
return [
|
|
1701
|
+
_normalize_rel_path(part)
|
|
1702
|
+
for part in parts
|
|
1703
|
+
if "/" in part or "." in PurePosixPath(part).name
|
|
1704
|
+
]
|
|
1705
|
+
|
|
1706
|
+
|
|
1707
|
+
def _is_direct_black_box_command(command: str) -> bool:
|
|
1708
|
+
try:
|
|
1709
|
+
parts = shlex.split(command)
|
|
1710
|
+
except ValueError:
|
|
1711
|
+
return False
|
|
1712
|
+
if not parts:
|
|
1713
|
+
return False
|
|
1714
|
+
return Path(parts[0]).name.casefold() in {"diff", "cmp", "curl"}
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _observed_command(
|
|
1718
|
+
*,
|
|
1719
|
+
tool_name: str,
|
|
1720
|
+
arguments: dict[str, Any],
|
|
1721
|
+
result: dict[str, Any],
|
|
1722
|
+
) -> str:
|
|
1723
|
+
if tool_name == "verify_run":
|
|
1724
|
+
commands = result.get("commands")
|
|
1725
|
+
if isinstance(commands, list) and len(commands) == 1:
|
|
1726
|
+
return _normalize_command(str(commands[0]))
|
|
1727
|
+
return ""
|
|
1728
|
+
if tool_name == "shell_run":
|
|
1729
|
+
return _normalize_command(str(result.get("effective_cmd") or arguments.get("cmd") or ""))
|
|
1730
|
+
return ""
|
|
1731
|
+
|
|
1732
|
+
|
|
1733
|
+
def _command_passed(*, status: str, result: dict[str, Any]) -> bool | None:
|
|
1734
|
+
if status == "failed":
|
|
1735
|
+
return False
|
|
1736
|
+
if "all_passed" in result:
|
|
1737
|
+
return bool(result.get("all_passed"))
|
|
1738
|
+
exit_code = result.get("exit_code")
|
|
1739
|
+
if isinstance(exit_code, int):
|
|
1740
|
+
return exit_code == 0
|
|
1741
|
+
return None
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def _tool_output(result: dict[str, Any]) -> str:
|
|
1745
|
+
parts = [
|
|
1746
|
+
str(result.get("stdout") or ""),
|
|
1747
|
+
str(result.get("stderr") or ""),
|
|
1748
|
+
str(result.get("output") or ""),
|
|
1749
|
+
str(result.get("output_preview") or ""),
|
|
1750
|
+
]
|
|
1751
|
+
command_results = result.get("command_results")
|
|
1752
|
+
if isinstance(command_results, list):
|
|
1753
|
+
for item in command_results:
|
|
1754
|
+
if isinstance(item, dict):
|
|
1755
|
+
parts.append(str(item.get("output_preview") or item.get("output") or ""))
|
|
1756
|
+
return "\n".join(part for part in parts if part)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def _update_command_and_threshold_criteria(
|
|
1760
|
+
*,
|
|
1761
|
+
contract: AcceptanceContract,
|
|
1762
|
+
command: str,
|
|
1763
|
+
output: str,
|
|
1764
|
+
passed: bool | None,
|
|
1765
|
+
origin: EvidenceOrigin,
|
|
1766
|
+
) -> list[str]:
|
|
1767
|
+
matched: list[str] = []
|
|
1768
|
+
for criterion in contract.criteria:
|
|
1769
|
+
if criterion.kind == AcceptanceCriterionKind.EXPLICIT_COMMAND_IO and any(
|
|
1770
|
+
_commands_equivalent(command, candidate) for candidate in criterion.commands
|
|
1771
|
+
):
|
|
1772
|
+
status = (
|
|
1773
|
+
AcceptanceCriterionStatus.PASSED
|
|
1774
|
+
if passed is True
|
|
1775
|
+
else AcceptanceCriterionStatus.FAILED
|
|
1776
|
+
if passed is False
|
|
1777
|
+
else AcceptanceCriterionStatus.UNVERIFIED
|
|
1778
|
+
)
|
|
1779
|
+
criterion.status = status
|
|
1780
|
+
if status == AcceptanceCriterionStatus.FAILED:
|
|
1781
|
+
criterion.failure_summary = f"Explicit command failed: {command}"
|
|
1782
|
+
matched.append(criterion.criterion_id)
|
|
1783
|
+
elif criterion.kind == AcceptanceCriterionKind.THRESHOLD:
|
|
1784
|
+
status, summary = _evaluate_thresholds(
|
|
1785
|
+
thresholds=criterion.thresholds,
|
|
1786
|
+
output=output,
|
|
1787
|
+
passed=passed,
|
|
1788
|
+
origin=origin,
|
|
1789
|
+
)
|
|
1790
|
+
if status is not None:
|
|
1791
|
+
_apply_status_from_evidence(
|
|
1792
|
+
criterion=criterion,
|
|
1793
|
+
status=status,
|
|
1794
|
+
summary=summary,
|
|
1795
|
+
origin=origin,
|
|
1796
|
+
)
|
|
1797
|
+
matched.append(criterion.criterion_id)
|
|
1798
|
+
elif criterion.kind == AcceptanceCriterionKind.PERSISTENT_SERVICE and passed is True:
|
|
1799
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1800
|
+
criterion.failure_summary = (
|
|
1801
|
+
"Same-session command succeeded but does not prove durable service lifetime"
|
|
1802
|
+
)
|
|
1803
|
+
matched.append(criterion.criterion_id)
|
|
1804
|
+
elif (
|
|
1805
|
+
criterion.kind == AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH
|
|
1806
|
+
and passed is True
|
|
1807
|
+
and origin
|
|
1808
|
+
in {
|
|
1809
|
+
EvidenceOrigin.HOST_AUTHORITATIVE,
|
|
1810
|
+
EvidenceOrigin.USER_EXPLICIT,
|
|
1811
|
+
EvidenceOrigin.PREEXISTING_TASK_CHECKER,
|
|
1812
|
+
EvidenceOrigin.DIRECT_BLACK_BOX,
|
|
1813
|
+
}
|
|
1814
|
+
and _external_or_unresolved_output_is_mentioned(
|
|
1815
|
+
criterion=criterion,
|
|
1816
|
+
command=command,
|
|
1817
|
+
output=output,
|
|
1818
|
+
)
|
|
1819
|
+
):
|
|
1820
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
1821
|
+
criterion.failure_summary = ""
|
|
1822
|
+
matched.append(criterion.criterion_id)
|
|
1823
|
+
return matched
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
def _update_durable_service_criteria(
|
|
1827
|
+
*,
|
|
1828
|
+
contract: AcceptanceContract,
|
|
1829
|
+
result: dict[str, Any],
|
|
1830
|
+
) -> list[str]:
|
|
1831
|
+
matched: list[str] = []
|
|
1832
|
+
service_id = str(result.get("service_id") or "").strip()
|
|
1833
|
+
durable_payload = str(result.get("ownership") or "") == "DURABLE_SERVICE" and service_id
|
|
1834
|
+
if not durable_payload:
|
|
1835
|
+
return matched
|
|
1836
|
+
for criterion in contract.criteria:
|
|
1837
|
+
if criterion.kind == AcceptanceCriterionKind.FUNCTIONAL_API_PROTOCOL:
|
|
1838
|
+
if not criterion.ports:
|
|
1839
|
+
continue
|
|
1840
|
+
elif criterion.kind != AcceptanceCriterionKind.PERSISTENT_SERVICE:
|
|
1841
|
+
continue
|
|
1842
|
+
if service_id not in criterion.service_ids:
|
|
1843
|
+
criterion.service_ids.append(service_id)
|
|
1844
|
+
if _durable_service_satisfies_criterion(criterion=criterion, payload=result):
|
|
1845
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
1846
|
+
criterion.failure_summary = ""
|
|
1847
|
+
else:
|
|
1848
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1849
|
+
criterion.failure_summary = _durable_service_failure_summary(result)
|
|
1850
|
+
matched.append(criterion.criterion_id)
|
|
1851
|
+
return matched
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
def _block_session_owned_service_criteria(*, contract: AcceptanceContract) -> list[str]:
|
|
1855
|
+
matched: list[str] = []
|
|
1856
|
+
for criterion in contract.criteria:
|
|
1857
|
+
if criterion.kind != AcceptanceCriterionKind.PERSISTENT_SERVICE:
|
|
1858
|
+
continue
|
|
1859
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1860
|
+
criterion.failure_summary = (
|
|
1861
|
+
"shell_background is session-owned and is reaped on AgentSession.close; "
|
|
1862
|
+
"use shell_service_start for durable-service evidence"
|
|
1863
|
+
)
|
|
1864
|
+
matched.append(criterion.criterion_id)
|
|
1865
|
+
return matched
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _update_repo_surface_criteria(
|
|
1869
|
+
*,
|
|
1870
|
+
contract: AcceptanceContract,
|
|
1871
|
+
command: str,
|
|
1872
|
+
passed: bool | None,
|
|
1873
|
+
evidence_allowed: bool | None,
|
|
1874
|
+
known_verification_commands: list[str] | None,
|
|
1875
|
+
origin: EvidenceOrigin,
|
|
1876
|
+
) -> list[str]:
|
|
1877
|
+
matched: list[str] = []
|
|
1878
|
+
if not command:
|
|
1879
|
+
return matched
|
|
1880
|
+
for criterion in contract.criteria:
|
|
1881
|
+
if criterion.kind not in {
|
|
1882
|
+
AcceptanceCriterionKind.PREEXISTING_REPO_CHECK_SURFACE,
|
|
1883
|
+
AcceptanceCriterionKind.EXPLICIT_HOST_USER_VERIFICATION_COMMAND,
|
|
1884
|
+
}:
|
|
1885
|
+
continue
|
|
1886
|
+
commands = criterion.commands or tuple(known_verification_commands or ())
|
|
1887
|
+
if not any(_commands_equivalent(command, candidate) for candidate in commands):
|
|
1888
|
+
continue
|
|
1889
|
+
if origin == EvidenceOrigin.SELF_AUTHORED:
|
|
1890
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1891
|
+
criterion.failure_summary = (
|
|
1892
|
+
"Self-authored or mutable_authoritative_checker evidence is supplemental "
|
|
1893
|
+
"for this criterion"
|
|
1894
|
+
)
|
|
1895
|
+
elif evidence_allowed is False:
|
|
1896
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1897
|
+
criterion.failure_summary = "Verification evidence was supplemental or unsafe"
|
|
1898
|
+
elif passed is True:
|
|
1899
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
1900
|
+
elif passed is False:
|
|
1901
|
+
criterion.status = AcceptanceCriterionStatus.FAILED
|
|
1902
|
+
criterion.failure_summary = f"Verification command failed: {command}"
|
|
1903
|
+
matched.append(criterion.criterion_id)
|
|
1904
|
+
return matched
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def _workspace_paths_for_criterion(criterion: AcceptanceCriterion) -> tuple[str, ...]:
|
|
1908
|
+
paths: list[str] = []
|
|
1909
|
+
if criterion.path_refs:
|
|
1910
|
+
for path_ref in criterion.path_refs:
|
|
1911
|
+
if path_ref.workspace_relative_path:
|
|
1912
|
+
paths.append(path_ref.workspace_relative_path)
|
|
1913
|
+
return tuple(dict.fromkeys(paths))
|
|
1914
|
+
return tuple(path for path in criterion.paths if path and not path.startswith("/"))
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
def _missing_required_output_paths(*, criterion: AcceptanceCriterion, root: Path) -> list[str]:
|
|
1918
|
+
missing: list[str] = []
|
|
1919
|
+
workspace_paths = _workspace_paths_for_criterion(criterion)
|
|
1920
|
+
for path in workspace_paths:
|
|
1921
|
+
if not (root / path).exists():
|
|
1922
|
+
missing.append(path)
|
|
1923
|
+
external_refs = [
|
|
1924
|
+
path_ref
|
|
1925
|
+
for path_ref in criterion.path_refs
|
|
1926
|
+
if path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL
|
|
1927
|
+
and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
|
|
1928
|
+
]
|
|
1929
|
+
for path_ref in external_refs:
|
|
1930
|
+
if criterion.status != AcceptanceCriterionStatus.PASSED:
|
|
1931
|
+
missing.append(f"{path_ref.display_path} (external output requires trusted evidence)")
|
|
1932
|
+
unresolved_refs = [
|
|
1933
|
+
path_ref
|
|
1934
|
+
for path_ref in criterion.path_refs
|
|
1935
|
+
if path_ref.path_kind == AcceptancePathKind.UNRESOLVED
|
|
1936
|
+
and path_ref.role == AcceptancePathRole.REQUIRED_OUTPUT
|
|
1937
|
+
]
|
|
1938
|
+
for path_ref in unresolved_refs:
|
|
1939
|
+
if criterion.status != AcceptanceCriterionStatus.PASSED:
|
|
1940
|
+
missing.append(f"{path_ref.display_path} (unresolved output path)")
|
|
1941
|
+
if not criterion.path_refs:
|
|
1942
|
+
missing.extend(path for path in criterion.paths if path and not (root / path).exists())
|
|
1943
|
+
return list(dict.fromkeys(missing))
|
|
1944
|
+
|
|
1945
|
+
|
|
1946
|
+
def _update_path_criteria(
|
|
1947
|
+
*,
|
|
1948
|
+
contract: AcceptanceContract,
|
|
1949
|
+
root: Path,
|
|
1950
|
+
touched_paths: set[str],
|
|
1951
|
+
status: str,
|
|
1952
|
+
) -> list[str]:
|
|
1953
|
+
matched: list[str] = []
|
|
1954
|
+
for criterion in contract.criteria:
|
|
1955
|
+
if criterion.kind != AcceptanceCriterionKind.REQUIRED_ARTIFACT_PATH:
|
|
1956
|
+
continue
|
|
1957
|
+
paths = _workspace_paths_for_criterion(criterion)
|
|
1958
|
+
if not paths:
|
|
1959
|
+
continue
|
|
1960
|
+
if all((root / path).exists() for path in paths):
|
|
1961
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
1962
|
+
matched.append(criterion.criterion_id)
|
|
1963
|
+
elif status == "failed" and any(_path_matches_any(path, paths) for path in touched_paths):
|
|
1964
|
+
criterion.status = AcceptanceCriterionStatus.FAILED
|
|
1965
|
+
criterion.failure_summary = "Attempted output path update failed"
|
|
1966
|
+
matched.append(criterion.criterion_id)
|
|
1967
|
+
return matched
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _finalize_persistent_service_criterion(
|
|
1971
|
+
*,
|
|
1972
|
+
criterion: AcceptanceCriterion,
|
|
1973
|
+
durable_service_status: Callable[[str], dict[str, Any]] | None,
|
|
1974
|
+
) -> None:
|
|
1975
|
+
if durable_service_status is None:
|
|
1976
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1977
|
+
criterion.failure_summary = "Durable service status recheck is unavailable"
|
|
1978
|
+
return
|
|
1979
|
+
failures: list[str] = []
|
|
1980
|
+
for service_id in list(dict.fromkeys(criterion.service_ids)):
|
|
1981
|
+
try:
|
|
1982
|
+
payload = durable_service_status(service_id)
|
|
1983
|
+
except Exception as exc: # noqa: BLE001
|
|
1984
|
+
failures.append(f"{service_id}: status recheck failed: {exc}")
|
|
1985
|
+
continue
|
|
1986
|
+
if _durable_service_satisfies_criterion(criterion=criterion, payload=payload):
|
|
1987
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
1988
|
+
criterion.failure_summary = ""
|
|
1989
|
+
return
|
|
1990
|
+
failures.append(f"{service_id}: {_durable_service_failure_summary(payload)}")
|
|
1991
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
1992
|
+
criterion.failure_summary = "Durable service readiness recheck failed: " + "; ".join(
|
|
1993
|
+
failures[:4]
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def _durable_service_satisfies_criterion(
|
|
1998
|
+
*,
|
|
1999
|
+
criterion: AcceptanceCriterion,
|
|
2000
|
+
payload: dict[str, Any],
|
|
2001
|
+
) -> bool:
|
|
2002
|
+
if str(payload.get("ownership") or "") != "DURABLE_SERVICE":
|
|
2003
|
+
return False
|
|
2004
|
+
if str(payload.get("status") or "").casefold() != "running":
|
|
2005
|
+
return False
|
|
2006
|
+
if payload.get("alive") is not True:
|
|
2007
|
+
return False
|
|
2008
|
+
readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
|
|
2009
|
+
if str(readiness.get("status") or "").casefold() != "ready":
|
|
2010
|
+
return False
|
|
2011
|
+
if criterion.ports:
|
|
2012
|
+
if str(readiness.get("type") or "").casefold() != "tcp":
|
|
2013
|
+
return False
|
|
2014
|
+
try:
|
|
2015
|
+
port = int(readiness.get("port") or 0)
|
|
2016
|
+
except (TypeError, ValueError):
|
|
2017
|
+
return False
|
|
2018
|
+
if port not in set(criterion.ports):
|
|
2019
|
+
return False
|
|
2020
|
+
return True
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _durable_service_failure_summary(payload: dict[str, Any]) -> str:
|
|
2024
|
+
service_id = str(payload.get("service_id") or "?")
|
|
2025
|
+
status = str(payload.get("status") or "?")
|
|
2026
|
+
alive = payload.get("alive")
|
|
2027
|
+
readiness = payload.get("readiness") if isinstance(payload.get("readiness"), dict) else {}
|
|
2028
|
+
readiness_status = str(readiness.get("status") or "?")
|
|
2029
|
+
readiness_type = str(readiness.get("type") or "?")
|
|
2030
|
+
detail = str(readiness.get("detail") or "").strip()
|
|
2031
|
+
suffix = f": {detail}" if detail else ""
|
|
2032
|
+
return (
|
|
2033
|
+
f"Durable service {service_id} is not ready "
|
|
2034
|
+
f"(status={status}, alive={alive}, readiness={readiness_type}/{readiness_status})"
|
|
2035
|
+
f"{suffix}"
|
|
2036
|
+
)
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _evaluate_thresholds(
|
|
2040
|
+
*,
|
|
2041
|
+
thresholds: tuple[AcceptanceThreshold, ...],
|
|
2042
|
+
output: str,
|
|
2043
|
+
passed: bool | None,
|
|
2044
|
+
origin: EvidenceOrigin,
|
|
2045
|
+
) -> tuple[AcceptanceCriterionStatus | None, str]:
|
|
2046
|
+
if not thresholds:
|
|
2047
|
+
return None, ""
|
|
2048
|
+
numbers = [float(match.group(0)) for match in _NUMBER_RE.finditer(output or "")]
|
|
2049
|
+
if not numbers:
|
|
2050
|
+
if passed is False:
|
|
2051
|
+
return AcceptanceCriterionStatus.FAILED, "Threshold command failed"
|
|
2052
|
+
return None, ""
|
|
2053
|
+
threshold = thresholds[0]
|
|
2054
|
+
measured = _median(numbers)
|
|
2055
|
+
if (
|
|
2056
|
+
threshold.metric in {"latency", "runtime", "time", "duration", "performance"}
|
|
2057
|
+
and len(numbers) < 2
|
|
2058
|
+
):
|
|
2059
|
+
return (
|
|
2060
|
+
AcceptanceCriterionStatus.BLOCKED,
|
|
2061
|
+
"Performance threshold has insufficient repeated samples",
|
|
2062
|
+
)
|
|
2063
|
+
ok = _compare(measured, threshold.operator, threshold.value)
|
|
2064
|
+
if ok:
|
|
2065
|
+
if origin == EvidenceOrigin.SELF_AUTHORED:
|
|
2066
|
+
return (
|
|
2067
|
+
AcceptanceCriterionStatus.BLOCKED,
|
|
2068
|
+
"Self-authored threshold evidence is supplemental without independent coverage",
|
|
2069
|
+
)
|
|
2070
|
+
return AcceptanceCriterionStatus.PASSED, ""
|
|
2071
|
+
return (
|
|
2072
|
+
AcceptanceCriterionStatus.FAILED,
|
|
2073
|
+
f"Measured {threshold.metric} {measured:g}{threshold.unit} misses "
|
|
2074
|
+
f"{threshold.operator} {threshold.value:g}{threshold.unit}",
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _external_or_unresolved_output_is_mentioned(
|
|
2079
|
+
*,
|
|
2080
|
+
criterion: AcceptanceCriterion,
|
|
2081
|
+
command: str,
|
|
2082
|
+
output: str,
|
|
2083
|
+
) -> bool:
|
|
2084
|
+
haystack = f"{command}\n{output}".casefold()
|
|
2085
|
+
for path_ref in criterion.path_refs:
|
|
2086
|
+
if path_ref.path_kind not in {
|
|
2087
|
+
AcceptancePathKind.ABSOLUTE_EXTERNAL,
|
|
2088
|
+
AcceptancePathKind.UNRESOLVED,
|
|
2089
|
+
}:
|
|
2090
|
+
continue
|
|
2091
|
+
if path_ref.display_path.casefold() in haystack or path_ref.raw_text.casefold() in haystack:
|
|
2092
|
+
return True
|
|
2093
|
+
return False
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
def _compare(measured: float, operator: str, target: float) -> bool:
|
|
2097
|
+
if operator == ">=":
|
|
2098
|
+
return measured >= target
|
|
2099
|
+
if operator == ">":
|
|
2100
|
+
return measured > target
|
|
2101
|
+
if operator == "<=":
|
|
2102
|
+
return measured <= target
|
|
2103
|
+
if operator == "<":
|
|
2104
|
+
return measured < target
|
|
2105
|
+
return False
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
def _median(values: list[float]) -> float:
|
|
2109
|
+
ordered = sorted(values)
|
|
2110
|
+
mid = len(ordered) // 2
|
|
2111
|
+
if len(ordered) % 2:
|
|
2112
|
+
return ordered[mid]
|
|
2113
|
+
return (ordered[mid - 1] + ordered[mid]) / 2.0
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
def _apply_status_from_evidence(
|
|
2117
|
+
*,
|
|
2118
|
+
criterion: AcceptanceCriterion,
|
|
2119
|
+
status: AcceptanceCriterionStatus,
|
|
2120
|
+
summary: str,
|
|
2121
|
+
origin: EvidenceOrigin,
|
|
2122
|
+
) -> None:
|
|
2123
|
+
if (
|
|
2124
|
+
criterion.status == AcceptanceCriterionStatus.FAILED
|
|
2125
|
+
and origin == EvidenceOrigin.SELF_AUTHORED
|
|
2126
|
+
and status != AcceptanceCriterionStatus.FAILED
|
|
2127
|
+
):
|
|
2128
|
+
return
|
|
2129
|
+
criterion.status = status
|
|
2130
|
+
criterion.failure_summary = summary
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
def _finalize_content_format_criterion(
|
|
2134
|
+
*,
|
|
2135
|
+
criterion: AcceptanceCriterion,
|
|
2136
|
+
root: Path,
|
|
2137
|
+
) -> None:
|
|
2138
|
+
if not criterion.required:
|
|
2139
|
+
if criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
2140
|
+
criterion.status = AcceptanceCriterionStatus.NOT_APPLICABLE
|
|
2141
|
+
return
|
|
2142
|
+
if not criterion.paths:
|
|
2143
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
2144
|
+
criterion.failure_summary = "Format/schema requirement has no concrete output path"
|
|
2145
|
+
return
|
|
2146
|
+
paths = _workspace_paths_for_criterion(criterion)
|
|
2147
|
+
external = [
|
|
2148
|
+
path_ref.display_path
|
|
2149
|
+
for path_ref in criterion.path_refs
|
|
2150
|
+
if path_ref.path_kind == AcceptancePathKind.ABSOLUTE_EXTERNAL
|
|
2151
|
+
]
|
|
2152
|
+
if external and not paths:
|
|
2153
|
+
if criterion.enforcement == AcceptanceCriterionEnforcement.HARD:
|
|
2154
|
+
criterion.status = AcceptanceCriterionStatus.BLOCKED
|
|
2155
|
+
criterion.failure_summary = (
|
|
2156
|
+
"External output format requires trusted command evidence: " + ", ".join(external)
|
|
2157
|
+
)
|
|
2158
|
+
elif criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
2159
|
+
criterion.status = AcceptanceCriterionStatus.NOT_APPLICABLE
|
|
2160
|
+
return
|
|
2161
|
+
missing = [path for path in paths if not (root / path).exists()]
|
|
2162
|
+
if missing:
|
|
2163
|
+
criterion.status = AcceptanceCriterionStatus.UNVERIFIED
|
|
2164
|
+
criterion.failure_summary = "Format output path is missing: " + ", ".join(missing)
|
|
2165
|
+
return
|
|
2166
|
+
lowered = criterion.description.casefold()
|
|
2167
|
+
if "json" in lowered:
|
|
2168
|
+
invalid: list[str] = []
|
|
2169
|
+
for path in paths:
|
|
2170
|
+
candidate = root / path
|
|
2171
|
+
try:
|
|
2172
|
+
json.loads(candidate.read_text(encoding="utf-8"))
|
|
2173
|
+
except (OSError, json.JSONDecodeError):
|
|
2174
|
+
invalid.append(path)
|
|
2175
|
+
if invalid:
|
|
2176
|
+
criterion.status = AcceptanceCriterionStatus.FAILED
|
|
2177
|
+
criterion.failure_summary = "Invalid JSON output: " + ", ".join(invalid)
|
|
2178
|
+
return
|
|
2179
|
+
if criterion.status == AcceptanceCriterionStatus.UNVERIFIED:
|
|
2180
|
+
criterion.status = AcceptanceCriterionStatus.PASSED
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def _commands_equivalent(left: str, right: str) -> bool:
|
|
2184
|
+
left_norm = _normalize_command(left).casefold()
|
|
2185
|
+
right_norm = _normalize_command(right).casefold()
|
|
2186
|
+
if not left_norm or not right_norm:
|
|
2187
|
+
return False
|
|
2188
|
+
if left_norm == right_norm:
|
|
2189
|
+
return True
|
|
2190
|
+
return bool(
|
|
2191
|
+
_matching_effective_verification_commands(
|
|
2192
|
+
observed_command=left_norm,
|
|
2193
|
+
effective_verification_commands=[right_norm],
|
|
2194
|
+
)
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
def _evidence_summary(*, command: str, touched_paths: set[str]) -> str:
|
|
2199
|
+
if command:
|
|
2200
|
+
return f"Executed command: {command}"
|
|
2201
|
+
if touched_paths:
|
|
2202
|
+
return "Touched paths: " + ", ".join(sorted(touched_paths)[:8])
|
|
2203
|
+
return "Observed tool result"
|
|
2204
|
+
|
|
2205
|
+
|
|
2206
|
+
def _path_matches_any(path: str, roots: tuple[str, ...]) -> bool:
|
|
2207
|
+
normalized = _normalize_rel_path(path).casefold()
|
|
2208
|
+
for root in roots:
|
|
2209
|
+
root_norm = _normalize_rel_path(root).casefold()
|
|
2210
|
+
if normalized == root_norm or normalized.startswith(root_norm.rstrip("/") + "/"):
|
|
2211
|
+
return True
|
|
2212
|
+
return False
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def _path_is_material_for_scope(path: str, *, root: Path) -> bool:
|
|
2216
|
+
normalized = _normalize_rel_path(path)
|
|
2217
|
+
return bool(normalized) and not is_runtime_artifact_path(normalized, root=root)
|