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,2148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Collection
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .file_classification import BROAD_SOURCE_EXTENSIONS, INFERRED_FILE_EXTENSIONS, classify_path
|
|
14
|
+
from .git_safe import build_git_process_env
|
|
15
|
+
from .runtime_artifacts import ROOT_RUNTIME_ARTIFACT_DIR_NAMES, is_runtime_artifact_path
|
|
16
|
+
|
|
17
|
+
_AGENT_INTERNAL_SCOPE_PREFIXES = tuple(sorted(ROOT_RUNTIME_ARTIFACT_DIR_NAMES | {".forge"}))
|
|
18
|
+
_IGNORED_INTERNAL_PREFIXES = _AGENT_INTERNAL_SCOPE_PREFIXES
|
|
19
|
+
_SPECIAL_FILENAMES = {
|
|
20
|
+
"README",
|
|
21
|
+
"README.md",
|
|
22
|
+
"Dockerfile",
|
|
23
|
+
"Makefile",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"NOTICE",
|
|
26
|
+
"CHANGELOG",
|
|
27
|
+
}
|
|
28
|
+
_README_ALIAS_FILENAMES = frozenset({"README", "README.md"})
|
|
29
|
+
_BROAD_NON_PACKAGE_DIR_NAMES = {
|
|
30
|
+
"app",
|
|
31
|
+
"apps",
|
|
32
|
+
"bin",
|
|
33
|
+
"doc",
|
|
34
|
+
"docs",
|
|
35
|
+
"example",
|
|
36
|
+
"examples",
|
|
37
|
+
"lib",
|
|
38
|
+
"scripts",
|
|
39
|
+
"src",
|
|
40
|
+
"tool",
|
|
41
|
+
"tools",
|
|
42
|
+
}
|
|
43
|
+
_INFERRED_FILE_EXTENSIONS = INFERRED_FILE_EXTENSIONS
|
|
44
|
+
_INFERRED_SOURCE_FILE_EXTENSIONS = frozenset(
|
|
45
|
+
suffix.lstrip(".").casefold() for suffix in BROAD_SOURCE_EXTENSIONS
|
|
46
|
+
)
|
|
47
|
+
_PATH_HINT_RE = re.compile(
|
|
48
|
+
r"(?<![\w/-])("
|
|
49
|
+
r"(?:\.[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)*)"
|
|
50
|
+
r"|(?:[A-Za-z0-9_-]+(?:/[A-Za-z0-9_.-]+)+)"
|
|
51
|
+
r"|(?:[A-Za-z0-9_.-]+\.[A-Za-z0-9_*?\[\]-]+)"
|
|
52
|
+
r"|(?:README(?:\.md)?)"
|
|
53
|
+
r"|(?:Dockerfile|Makefile|LICENSE|NOTICE|CHANGELOG)"
|
|
54
|
+
r")(?![\w/-])"
|
|
55
|
+
)
|
|
56
|
+
_PATH_HINT_CONTEXT_SPLIT_RE = re.compile(r"[\n;]+|(?<=[.!?])\s+")
|
|
57
|
+
_FORBIDDEN_PATH_DIRECT_PREFIX_RE = re.compile(
|
|
58
|
+
r"(?:^|[\s,(])(?:(?:do\s+not|don't|must\s+not|never)\s+"
|
|
59
|
+
r"(?:edit|modify|change|touch|write(?:\s+to)?|update|delete|remove|create)"
|
|
60
|
+
r"|without\s+(?:touching|modifying|changing|writing\s+to)"
|
|
61
|
+
r"|not\s+(?:touch|read|write|include)"
|
|
62
|
+
r"|no\s+longer\s+read"
|
|
63
|
+
r"|(?:exclude|ignore|skip)"
|
|
64
|
+
r"|(?:leave|keep)\s+(?:the\s+)?(?:file\s+)?"
|
|
65
|
+
r"|(?:preserve|retain|maintain)\s+(?:the\s+)?(?:file\s+)?)\s+"
|
|
66
|
+
r"(?:the\s+)?(?:(?:untracked|tracked|existing|current|local|generated|root|"
|
|
67
|
+
r"workspace|repo|repository)\s+){0,3}(?:file\s+)?$",
|
|
68
|
+
re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
_FORBIDDEN_PATH_DIRECT_OBJECT_RE = re.compile(
|
|
71
|
+
r"\b(?:preserve|retain|maintain|leave|keep)\s+(?:the\s+)?"
|
|
72
|
+
r"(?:(?:untracked|tracked|existing|current|local|generated|root|workspace|repo|repository)\s+){0,3}"
|
|
73
|
+
r"(?:file\s+)?$",
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
76
|
+
_FORBIDDEN_PATH_SUFFIX_MARKER_RE = re.compile(
|
|
77
|
+
r"^\s*(?:file\s+)?(?:is|are|must\s+remain|should\s+remain|stays?|stay|left)?\s*"
|
|
78
|
+
r"(?:not\s+)?(?:untouched|unchanged|unmodified|preserved|not\s+modified|"
|
|
79
|
+
r"not\s+changed|not\s+touched)\b"
|
|
80
|
+
r"|^\s*(?:file\s+)?not\s+(?:modified|changed|touched|written)\b",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
_CONDITIONAL_FORBIDDEN_PATH_EXCEPTION_RE = re.compile(
|
|
84
|
+
r"\bunless\s+(?:a\s+|the\s+)?(?:genuine\s+|real\s+|actual\s+)?"
|
|
85
|
+
r"(?:bug|defect|implementation\s+bug|product\s+bug)\s+"
|
|
86
|
+
r"(?:is\s+)?(?:found|discovered|identified|confirmed)\b"
|
|
87
|
+
r"|\bunless\s+(?:it\s+is\s+)?(?:necessary|required|needed)\s+"
|
|
88
|
+
r"(?:to\s+fix|for\s+the\s+fix|to\s+make\s+verification\s+pass)\b"
|
|
89
|
+
r"|\bexcept\s+(?:when|if)\s+(?:it\s+is\s+)?(?:necessary|required|needed)\b",
|
|
90
|
+
re.IGNORECASE,
|
|
91
|
+
)
|
|
92
|
+
_FORBIDDEN_PATH_MARKER_WINDOW_CHARS = 100
|
|
93
|
+
_NON_MATERIAL_ROOT_SCRATCH_FILENAMES = frozenset(
|
|
94
|
+
{
|
|
95
|
+
"command_output.txt",
|
|
96
|
+
"output.txt",
|
|
97
|
+
"pip_err.txt",
|
|
98
|
+
"pip_install_out.txt",
|
|
99
|
+
"pip_log.txt",
|
|
100
|
+
"pip_out.txt",
|
|
101
|
+
"pip_output.txt",
|
|
102
|
+
"pytest_output.txt",
|
|
103
|
+
"pytest_results.txt",
|
|
104
|
+
"shell_output.txt",
|
|
105
|
+
"stderr.txt",
|
|
106
|
+
"stdout.txt",
|
|
107
|
+
"test_output.txt",
|
|
108
|
+
"test_results.txt",
|
|
109
|
+
"wheel_log.txt",
|
|
110
|
+
"wheel_output.txt",
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
_NON_MATERIAL_ROOT_SCRATCH_SUFFIXES = (
|
|
114
|
+
"-output.txt",
|
|
115
|
+
"-results.txt",
|
|
116
|
+
"_output.txt",
|
|
117
|
+
"_results.txt",
|
|
118
|
+
".log",
|
|
119
|
+
)
|
|
120
|
+
_NON_MATERIAL_ROOT_SCRATCH_RE = re.compile(
|
|
121
|
+
r"^(?:pytest|test|shell|command|run|stdout|stderr|debug|diagnostic|tmp|temp|"
|
|
122
|
+
r"pip|uv|poetry|python|wheel|build|install|package|npm|pnpm|yarn|node|cargo|go)"
|
|
123
|
+
r"[_-](?:out|output|stdout|stderr|err|errors?|log|logs|full|results?|dump|"
|
|
124
|
+
r"lines[_-]dump|install[_-]out)"
|
|
125
|
+
r"(?:\d+)?\.txt$"
|
|
126
|
+
r"|^_(?:(?:tmp|temp|debug|diag|diagnostic|out|output|content|pytest|test)"
|
|
127
|
+
r"(?:\d+|[_-][a-z0-9][a-z0-9_-]*)?|d\d+)\.txt$"
|
|
128
|
+
)
|
|
129
|
+
_NON_MATERIAL_ROOT_STATE_FILE_RE = re.compile(
|
|
130
|
+
r"^\.(?:[a-z0-9_-]*"
|
|
131
|
+
r"(?:state|data|db|store|storage|cache|habits?|todos?|tasks?|notes?|items?)"
|
|
132
|
+
r"[a-z0-9_-]*)\.json$"
|
|
133
|
+
)
|
|
134
|
+
_EXTENSIONLESS_FILE_RE = re.compile(r"[A-Za-z0-9_-]+")
|
|
135
|
+
_CARGO_MANIFEST_FILENAME = "Cargo.toml"
|
|
136
|
+
_CARGO_LOCK_FILENAME = "Cargo.lock"
|
|
137
|
+
_CARGO_WORKSPACE_RE = re.compile(r"(?m)^\s*\[workspace\]\s*$")
|
|
138
|
+
_CARGO_PACKAGE_RE = re.compile(r"(?m)^\s*\[package\]\s*$")
|
|
139
|
+
_RUST_SCOPE_DIR_NAMES = frozenset({"src", "tests", "test", "benches", "examples"})
|
|
140
|
+
_RUST_ENTRYPOINT_FILENAMES = ("lib.rs", "main.rs")
|
|
141
|
+
_READ_ONLY_GIT_TIMEOUT_S = 5.0
|
|
142
|
+
_UNTRACKED_SOURCE_KINDS = frozenset(
|
|
143
|
+
{"config", "fixture", "frontend_surface", "implementation", "source", "test"}
|
|
144
|
+
)
|
|
145
|
+
SCRATCH_ARTIFACT_ENV = "ALYSIS_ARTIFACT_SCRATCH_DIR"
|
|
146
|
+
|
|
147
|
+
SCOPE_CLASS_IN_SCOPE = "in_scope"
|
|
148
|
+
SCOPE_CLASS_EXPECTED_COMPANION = "expected_companion_file"
|
|
149
|
+
SCOPE_CLASS_SCRATCH_ARTIFACT = "scratch_diagnostic_artifact"
|
|
150
|
+
SCOPE_CLASS_LIKELY_MISSING_SCOPE = "likely_legitimate_missing_scope"
|
|
151
|
+
SCOPE_CLASS_DANGEROUS_UNRELATED = "dangerous_unrelated_path"
|
|
152
|
+
SCOPE_CLASS_FORBIDDEN = "forbidden_path_violation"
|
|
153
|
+
SCOPE_CLASS_ADJACENT = "adjacent_to_declared_scope"
|
|
154
|
+
SCOPE_CLASS_PROTECTED = "protected_path"
|
|
155
|
+
|
|
156
|
+
# Triage buckets an out-of-scope change falls into. ``adjacent`` is work the plan should
|
|
157
|
+
# plainly have declared and is safe to amend into the task scope; ``protected`` is never
|
|
158
|
+
# amendable; ``unrelated`` is a plan defect a human or replanner has to resolve.
|
|
159
|
+
SCOPE_TRIAGE_ADJACENT = "adjacent"
|
|
160
|
+
SCOPE_TRIAGE_PROTECTED = "protected"
|
|
161
|
+
SCOPE_TRIAGE_UNRELATED = "unrelated"
|
|
162
|
+
|
|
163
|
+
SCOPE_ADJACENT_NEW_FILE_IN_SCOPE_DIR = "new_file_in_declared_scope_directory"
|
|
164
|
+
SCOPE_ADJACENT_SIBLING_TEST = "sibling_test_file_for_declared_path"
|
|
165
|
+
SCOPE_ADJACENT_GENERATED_ARTIFACT = "generated_artifact_of_declared_path"
|
|
166
|
+
|
|
167
|
+
_SCOPE_TRIAGE_BY_CLASSIFICATION = {
|
|
168
|
+
SCOPE_CLASS_ADJACENT: SCOPE_TRIAGE_ADJACENT,
|
|
169
|
+
SCOPE_CLASS_EXPECTED_COMPANION: SCOPE_TRIAGE_ADJACENT,
|
|
170
|
+
SCOPE_CLASS_SCRATCH_ARTIFACT: SCOPE_TRIAGE_ADJACENT,
|
|
171
|
+
SCOPE_CLASS_PROTECTED: SCOPE_TRIAGE_PROTECTED,
|
|
172
|
+
SCOPE_CLASS_FORBIDDEN: SCOPE_TRIAGE_PROTECTED,
|
|
173
|
+
SCOPE_CLASS_LIKELY_MISSING_SCOPE: SCOPE_TRIAGE_UNRELATED,
|
|
174
|
+
SCOPE_CLASS_DANGEROUS_UNRELATED: SCOPE_TRIAGE_UNRELATED,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Paths no task scope may ever cover, amendment or not: agent-internal runtime state and
|
|
178
|
+
# version-control metadata.
|
|
179
|
+
_PROTECTED_SCOPE_PREFIXES = tuple(sorted({*_AGENT_INTERNAL_SCOPE_PREFIXES, ".git", ".hg", ".svn"}))
|
|
180
|
+
_WINDOWS_ABSOLUTE_PATH_RE = re.compile(r"^[A-Za-z]:/")
|
|
181
|
+
# Directories whose contents are vendored or built, never "adjacent" source work.
|
|
182
|
+
_NON_ADJACENT_DIR_NAMES = frozenset({"node_modules", "vendor", "dist", "build", "target"})
|
|
183
|
+
_TEST_FILENAME_STEM_PREFIXES = ("test_", "test-")
|
|
184
|
+
_TEST_FILENAME_STEM_SUFFIXES = ("_test", "-test", ".test", ".spec", "_spec")
|
|
185
|
+
_GENERATED_ARTIFACT_NAME_SUFFIXES = (".map", ".lock", ".sum", ".snap")
|
|
186
|
+
_GENERATED_ARTIFACT_NAME_MARKERS = (".generated.", ".g.", ".min.", ".pb.", "_pb2.", ".d.ts")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class ForbiddenPathHint:
|
|
191
|
+
path: str
|
|
192
|
+
reason_code: str
|
|
193
|
+
evidence: str
|
|
194
|
+
|
|
195
|
+
def to_payload(self) -> dict[str, str]:
|
|
196
|
+
return {
|
|
197
|
+
"path": self.path,
|
|
198
|
+
"reason_code": self.reason_code,
|
|
199
|
+
"evidence": self.evidence,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class ScopeCompanionExpansion:
|
|
205
|
+
source_path: str
|
|
206
|
+
companion_path: str
|
|
207
|
+
reason_code: str
|
|
208
|
+
evidence: str
|
|
209
|
+
|
|
210
|
+
def to_payload(self) -> dict[str, str]:
|
|
211
|
+
return {
|
|
212
|
+
"source_path": self.source_path,
|
|
213
|
+
"companion_path": self.companion_path,
|
|
214
|
+
"reason_code": self.reason_code,
|
|
215
|
+
"evidence": self.evidence,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass(frozen=True)
|
|
220
|
+
class ScopeViolationDiagnostic:
|
|
221
|
+
path: str
|
|
222
|
+
classification: str
|
|
223
|
+
reason_code: str
|
|
224
|
+
evidence: str
|
|
225
|
+
recommended_action: str
|
|
226
|
+
allowed: bool = False
|
|
227
|
+
source_path: str | None = None
|
|
228
|
+
suggested_pattern: str = ""
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def triage(self) -> str:
|
|
232
|
+
"""Which of adjacent/protected/unrelated this diagnostic belongs to."""
|
|
233
|
+
return _SCOPE_TRIAGE_BY_CLASSIFICATION.get(self.classification, SCOPE_TRIAGE_UNRELATED)
|
|
234
|
+
|
|
235
|
+
def to_payload(self) -> dict[str, Any]:
|
|
236
|
+
payload: dict[str, Any] = {
|
|
237
|
+
"path": self.path,
|
|
238
|
+
"classification": self.classification,
|
|
239
|
+
"triage": self.triage,
|
|
240
|
+
"reason_code": self.reason_code,
|
|
241
|
+
"evidence": self.evidence,
|
|
242
|
+
"recommended_action": self.recommended_action,
|
|
243
|
+
"allowed": self.allowed,
|
|
244
|
+
}
|
|
245
|
+
if self.source_path is not None:
|
|
246
|
+
payload["source_path"] = self.source_path
|
|
247
|
+
if self.suggested_pattern:
|
|
248
|
+
payload["suggested_pattern"] = self.suggested_pattern
|
|
249
|
+
return payload
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True)
|
|
253
|
+
class ScopeAmendment:
|
|
254
|
+
"""One write_scope entry an adjacent change earned for its task."""
|
|
255
|
+
|
|
256
|
+
path: str
|
|
257
|
+
pattern: str
|
|
258
|
+
reason_code: str
|
|
259
|
+
evidence: str
|
|
260
|
+
suggested_pattern: str = ""
|
|
261
|
+
source_path: str | None = None
|
|
262
|
+
|
|
263
|
+
def to_payload(self) -> dict[str, Any]:
|
|
264
|
+
payload: dict[str, Any] = {
|
|
265
|
+
"path": self.path,
|
|
266
|
+
"pattern": self.pattern,
|
|
267
|
+
"reason_code": self.reason_code,
|
|
268
|
+
"evidence": self.evidence,
|
|
269
|
+
}
|
|
270
|
+
if self.suggested_pattern:
|
|
271
|
+
payload["suggested_pattern"] = self.suggested_pattern
|
|
272
|
+
if self.source_path is not None:
|
|
273
|
+
payload["source_path"] = self.source_path
|
|
274
|
+
return payload
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class ScopeAssessment:
|
|
279
|
+
ok: bool
|
|
280
|
+
blocking_paths: list[str]
|
|
281
|
+
diagnostics: list[ScopeViolationDiagnostic]
|
|
282
|
+
effective_changed_files: list[str]
|
|
283
|
+
expanded_allowed_scope: list[str]
|
|
284
|
+
companion_expansions: list[ScopeCompanionExpansion]
|
|
285
|
+
in_scope_paths: list[str] = field(default_factory=list)
|
|
286
|
+
adjacent_paths: list[str] = field(default_factory=list)
|
|
287
|
+
amendments: list[ScopeAmendment] = field(default_factory=list)
|
|
288
|
+
suggested_scope_patterns: list[str] = field(default_factory=list)
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def protected_paths(self) -> list[str]:
|
|
292
|
+
return [item.path for item in self.diagnostics if item.triage == SCOPE_TRIAGE_PROTECTED]
|
|
293
|
+
|
|
294
|
+
@property
|
|
295
|
+
def unrelated_paths(self) -> list[str]:
|
|
296
|
+
return [
|
|
297
|
+
item.path
|
|
298
|
+
for item in self.diagnostics
|
|
299
|
+
if item.triage == SCOPE_TRIAGE_UNRELATED and not item.allowed
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
def to_payload(self) -> dict[str, Any]:
|
|
303
|
+
return {
|
|
304
|
+
"ok": self.ok,
|
|
305
|
+
"blocking_paths": self.blocking_paths,
|
|
306
|
+
"diagnostics": [item.to_payload() for item in self.diagnostics],
|
|
307
|
+
"effective_changed_files": self.effective_changed_files,
|
|
308
|
+
"expanded_allowed_scope": self.expanded_allowed_scope,
|
|
309
|
+
"companion_expansions": [item.to_payload() for item in self.companion_expansions],
|
|
310
|
+
"in_scope_paths": self.in_scope_paths,
|
|
311
|
+
"adjacent_paths": self.adjacent_paths,
|
|
312
|
+
"amendments": [item.to_payload() for item in self.amendments],
|
|
313
|
+
"suggested_scope_patterns": self.suggested_scope_patterns,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass(frozen=True)
|
|
318
|
+
class WorkspaceGitDiffState:
|
|
319
|
+
available: bool
|
|
320
|
+
tracked_diff_paths: tuple[str, ...] = ()
|
|
321
|
+
untracked_source_paths: tuple[str, ...] = ()
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def changed_paths(self) -> tuple[str, ...]:
|
|
325
|
+
return tuple(sorted({*self.tracked_diff_paths, *self.untracked_source_paths}))
|
|
326
|
+
|
|
327
|
+
@property
|
|
328
|
+
def empty(self) -> bool:
|
|
329
|
+
return self.available and not self.changed_paths
|
|
330
|
+
|
|
331
|
+
def to_payload(self) -> dict[str, Any]:
|
|
332
|
+
return {
|
|
333
|
+
"available": self.available,
|
|
334
|
+
"empty": self.empty,
|
|
335
|
+
"tracked_diff_paths": list(self.tracked_diff_paths),
|
|
336
|
+
"untracked_source_paths": list(self.untracked_source_paths),
|
|
337
|
+
"changed_paths": list(self.changed_paths),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@dataclass(frozen=True)
|
|
342
|
+
class ExistingTestEdit:
|
|
343
|
+
path: str
|
|
344
|
+
added_lines: int | None = None
|
|
345
|
+
deleted_lines: int | None = None
|
|
346
|
+
|
|
347
|
+
def to_payload(self) -> dict[str, Any]:
|
|
348
|
+
return {
|
|
349
|
+
"path": self.path,
|
|
350
|
+
"added_lines": self.added_lines,
|
|
351
|
+
"deleted_lines": self.deleted_lines,
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass(frozen=True)
|
|
356
|
+
class ExistingTestEditsState:
|
|
357
|
+
available: bool
|
|
358
|
+
edits: tuple[ExistingTestEdit, ...] = ()
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def paths(self) -> tuple[str, ...]:
|
|
362
|
+
return tuple(edit.path for edit in self.edits)
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def has_edits(self) -> bool:
|
|
366
|
+
return bool(self.edits)
|
|
367
|
+
|
|
368
|
+
def to_payload(self) -> dict[str, Any]:
|
|
369
|
+
return {
|
|
370
|
+
"available": self.available,
|
|
371
|
+
"has_edits": self.has_edits,
|
|
372
|
+
"paths": list(self.paths),
|
|
373
|
+
"edits": [edit.to_payload() for edit in self.edits],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _normalize_path(value: str) -> str:
|
|
378
|
+
cleaned = value.strip().replace("\\", "/")
|
|
379
|
+
while cleaned.startswith("./"):
|
|
380
|
+
cleaned = cleaned[2:]
|
|
381
|
+
return cleaned
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _run_git_capture(
|
|
385
|
+
root: Path, args: list[str], *, text: bool = False
|
|
386
|
+
) -> subprocess.CompletedProcess[Any] | None:
|
|
387
|
+
try:
|
|
388
|
+
return subprocess.run(
|
|
389
|
+
["git", "-C", os.fspath(root), *args],
|
|
390
|
+
check=False,
|
|
391
|
+
capture_output=True,
|
|
392
|
+
text=text,
|
|
393
|
+
env=build_git_process_env(),
|
|
394
|
+
timeout=_READ_ONLY_GIT_TIMEOUT_S,
|
|
395
|
+
)
|
|
396
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
397
|
+
return None
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _has_glob(value: str) -> bool:
|
|
401
|
+
return any(ch in value for ch in ["*", "?", "["])
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _strip_wrapping_punctuation(value: str) -> str:
|
|
405
|
+
cleaned = value.strip().strip("`'\"")
|
|
406
|
+
while cleaned and cleaned[-1] in {",", ";", ":", ".", ")", "]", "}"}:
|
|
407
|
+
cleaned = cleaned[:-1].rstrip()
|
|
408
|
+
while cleaned and cleaned[0] in {"(", "[", "{", ":"}:
|
|
409
|
+
cleaned = cleaned[1:].lstrip()
|
|
410
|
+
return cleaned
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def normalize_repo_path_entry(
|
|
414
|
+
value: str,
|
|
415
|
+
*,
|
|
416
|
+
allow_extensionless_file: bool = False,
|
|
417
|
+
root: Path | None = None,
|
|
418
|
+
) -> str | None:
|
|
419
|
+
cleaned = _normalize_path(_strip_wrapping_punctuation(value))
|
|
420
|
+
if not cleaned:
|
|
421
|
+
return None
|
|
422
|
+
if cleaned.startswith(("/", "../")):
|
|
423
|
+
return None
|
|
424
|
+
if "://" in cleaned or "\n" in cleaned or "\t" in cleaned:
|
|
425
|
+
return None
|
|
426
|
+
if " " in cleaned:
|
|
427
|
+
return None
|
|
428
|
+
if cleaned in {".", ".."}:
|
|
429
|
+
return None
|
|
430
|
+
if cleaned.endswith("/"):
|
|
431
|
+
cleaned = cleaned.rstrip("/") + "/**"
|
|
432
|
+
if cleaned in _SPECIAL_FILENAMES:
|
|
433
|
+
return cleaned
|
|
434
|
+
if _has_glob(cleaned):
|
|
435
|
+
return cleaned
|
|
436
|
+
existing_dir_pattern = _existing_directory_scope_pattern(cleaned, root=root)
|
|
437
|
+
if existing_dir_pattern is not None:
|
|
438
|
+
return existing_dir_pattern
|
|
439
|
+
if "/" in cleaned or "." in cleaned:
|
|
440
|
+
return cleaned
|
|
441
|
+
if (
|
|
442
|
+
allow_extensionless_file
|
|
443
|
+
and cleaned not in _BROAD_NON_PACKAGE_DIR_NAMES
|
|
444
|
+
and _EXTENSIONLESS_FILE_RE.fullmatch(cleaned)
|
|
445
|
+
):
|
|
446
|
+
return cleaned
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _existing_directory_scope_pattern(value: str, *, root: Path | None) -> str | None:
|
|
451
|
+
if root is None:
|
|
452
|
+
return None
|
|
453
|
+
cleaned = str(value or "").strip().replace("\\", "/").strip("/")
|
|
454
|
+
if not cleaned or _has_glob(cleaned):
|
|
455
|
+
return None
|
|
456
|
+
try:
|
|
457
|
+
root_abs = root.resolve()
|
|
458
|
+
candidate = (root_abs / cleaned).resolve()
|
|
459
|
+
candidate.relative_to(root_abs)
|
|
460
|
+
except (OSError, ValueError):
|
|
461
|
+
return None
|
|
462
|
+
if not candidate.is_dir():
|
|
463
|
+
return None
|
|
464
|
+
return cleaned + "/**"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def split_normalized_repo_path_list(
|
|
468
|
+
value: Any, *, root: Path | None = None
|
|
469
|
+
) -> tuple[list[str], list[str]]:
|
|
470
|
+
if not isinstance(value, list):
|
|
471
|
+
return [], []
|
|
472
|
+
seen: set[str] = set()
|
|
473
|
+
out: list[str] = []
|
|
474
|
+
dropped: list[str] = []
|
|
475
|
+
for item in value:
|
|
476
|
+
raw = str(item).strip()
|
|
477
|
+
if not raw:
|
|
478
|
+
continue
|
|
479
|
+
normalized = normalize_repo_path_entry(
|
|
480
|
+
raw,
|
|
481
|
+
allow_extensionless_file=True,
|
|
482
|
+
root=root,
|
|
483
|
+
)
|
|
484
|
+
identity_key = _scope_pattern_identity_key(normalized) if normalized else ""
|
|
485
|
+
if not normalized or identity_key in seen:
|
|
486
|
+
if not normalized:
|
|
487
|
+
dropped.append(raw)
|
|
488
|
+
continue
|
|
489
|
+
seen.add(identity_key)
|
|
490
|
+
out.append(normalized)
|
|
491
|
+
return out, dropped
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def normalize_repo_path_list(value: Any, *, root: Path | None = None) -> list[str]:
|
|
495
|
+
out, _ = split_normalized_repo_path_list(value, root=root)
|
|
496
|
+
return out
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def is_explicit_repo_path_pattern(value: str) -> bool:
|
|
500
|
+
normalized = normalize_repo_path_entry(value, allow_extensionless_file=True)
|
|
501
|
+
if not normalized:
|
|
502
|
+
return False
|
|
503
|
+
return not _has_glob(normalized)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def extract_repo_path_hints(text: str) -> list[str]:
|
|
507
|
+
seen: set[str] = set()
|
|
508
|
+
hints: list[str] = []
|
|
509
|
+
for match in _PATH_HINT_RE.findall(text or ""):
|
|
510
|
+
normalized = normalize_repo_path_entry(match)
|
|
511
|
+
if not normalized:
|
|
512
|
+
continue
|
|
513
|
+
tail = normalized.rstrip("/").split("/")[-1]
|
|
514
|
+
if tail not in _SPECIAL_FILENAMES:
|
|
515
|
+
if "." not in tail:
|
|
516
|
+
continue
|
|
517
|
+
ext = tail.rsplit(".", 1)[1].lower()
|
|
518
|
+
if ext not in _INFERRED_FILE_EXTENSIONS:
|
|
519
|
+
continue
|
|
520
|
+
if (
|
|
521
|
+
"/" not in normalized
|
|
522
|
+
and ext in _INFERRED_SOURCE_FILE_EXTENSIONS
|
|
523
|
+
and not _single_segment_source_hint_looks_pathlike(tail)
|
|
524
|
+
):
|
|
525
|
+
continue
|
|
526
|
+
head_segment = normalized.split("/", 1)[0]
|
|
527
|
+
if head_segment.isdigit():
|
|
528
|
+
continue
|
|
529
|
+
if normalized in seen:
|
|
530
|
+
continue
|
|
531
|
+
seen.add(normalized)
|
|
532
|
+
hints.append(normalized)
|
|
533
|
+
if "README.md" in seen and "README" in seen:
|
|
534
|
+
hints = [hint for hint in hints if hint != "README"]
|
|
535
|
+
return hints
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _single_segment_source_hint_looks_pathlike(tail: str) -> bool:
|
|
539
|
+
stem, _sep, _ext = tail.rpartition(".")
|
|
540
|
+
if not stem:
|
|
541
|
+
return False
|
|
542
|
+
if stem in {"__init__", "conftest"}:
|
|
543
|
+
return True
|
|
544
|
+
if stem.casefold() in {"setup", "manage", "index", "main", "app", "server", "client"}:
|
|
545
|
+
return True
|
|
546
|
+
# Standalone prose like "Next.js" is not a repo-relative path hint. Single-token inferred
|
|
547
|
+
# source filenames must look like conventional file names; slash-qualified paths are handled
|
|
548
|
+
# before this helper.
|
|
549
|
+
return stem == stem.casefold() and bool(re.fullmatch(r"[a-z0-9][a-z0-9_.-]*", stem))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _evidence_fragment(context: str) -> str:
|
|
553
|
+
cleaned = " ".join(str(context or "").strip().split())
|
|
554
|
+
if len(cleaned) <= 180:
|
|
555
|
+
return cleaned
|
|
556
|
+
return cleaned[:177].rstrip() + "..."
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _forbidden_reason_for_path_context(
|
|
560
|
+
*,
|
|
561
|
+
context: str,
|
|
562
|
+
hint_start: int,
|
|
563
|
+
hint_end: int,
|
|
564
|
+
) -> str | None:
|
|
565
|
+
before_hint = context[max(0, hint_start - _FORBIDDEN_PATH_MARKER_WINDOW_CHARS) : hint_start]
|
|
566
|
+
after_hint = context[hint_end : hint_end + _FORBIDDEN_PATH_MARKER_WINDOW_CHARS]
|
|
567
|
+
if _CONDITIONAL_FORBIDDEN_PATH_EXCEPTION_RE.search(after_hint) is not None:
|
|
568
|
+
return None
|
|
569
|
+
if (
|
|
570
|
+
_FORBIDDEN_PATH_DIRECT_PREFIX_RE.search(before_hint) is not None
|
|
571
|
+
or _FORBIDDEN_PATH_DIRECT_OBJECT_RE.search(before_hint) is not None
|
|
572
|
+
):
|
|
573
|
+
return "direct_path_forbidden_instruction"
|
|
574
|
+
if _FORBIDDEN_PATH_SUFFIX_MARKER_RE.search(after_hint) is not None:
|
|
575
|
+
return "path_must_remain_unchanged"
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def extract_forbidden_repo_path_hint_records(text: str) -> list[ForbiddenPathHint]:
|
|
580
|
+
hints = extract_repo_path_hints(text)
|
|
581
|
+
if not hints:
|
|
582
|
+
return []
|
|
583
|
+
forbidden: list[ForbiddenPathHint] = []
|
|
584
|
+
seen: set[str] = set()
|
|
585
|
+
contexts = [fragment.strip() for fragment in _PATH_HINT_CONTEXT_SPLIT_RE.split(text or "")]
|
|
586
|
+
for hint in hints:
|
|
587
|
+
hint_key = hint.casefold()
|
|
588
|
+
for context in contexts:
|
|
589
|
+
context_cf = context.casefold()
|
|
590
|
+
hint_start = context_cf.find(hint_key)
|
|
591
|
+
if hint_start < 0:
|
|
592
|
+
continue
|
|
593
|
+
hint_end = hint_start + len(hint_key)
|
|
594
|
+
reason_code = _forbidden_reason_for_path_context(
|
|
595
|
+
context=context_cf,
|
|
596
|
+
hint_start=hint_start,
|
|
597
|
+
hint_end=hint_end,
|
|
598
|
+
)
|
|
599
|
+
if reason_code is None:
|
|
600
|
+
continue
|
|
601
|
+
identity_key = _scope_pattern_identity_key(hint).casefold()
|
|
602
|
+
if identity_key in seen:
|
|
603
|
+
break
|
|
604
|
+
seen.add(identity_key)
|
|
605
|
+
forbidden.append(
|
|
606
|
+
ForbiddenPathHint(
|
|
607
|
+
path=hint,
|
|
608
|
+
reason_code=reason_code,
|
|
609
|
+
evidence=_evidence_fragment(context),
|
|
610
|
+
)
|
|
611
|
+
)
|
|
612
|
+
break
|
|
613
|
+
paths = {item.path for item in forbidden}
|
|
614
|
+
if "README.md" in paths and "README" in paths:
|
|
615
|
+
forbidden = [item for item in forbidden if item.path != "README"]
|
|
616
|
+
return forbidden
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def extract_forbidden_repo_path_hints(text: str) -> list[str]:
|
|
620
|
+
return [item.path for item in extract_forbidden_repo_path_hint_records(text)]
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _forbidden_path_identity_keys_for_task(task: dict[str, Any]) -> set[str]:
|
|
624
|
+
acceptance = task.get("acceptance_criteria") or []
|
|
625
|
+
acceptance_items = acceptance if isinstance(acceptance, list) else []
|
|
626
|
+
text = "\n".join(
|
|
627
|
+
[
|
|
628
|
+
str(task.get("title") or ""),
|
|
629
|
+
str(task.get("description") or ""),
|
|
630
|
+
*(str(item or "") for item in acceptance_items),
|
|
631
|
+
]
|
|
632
|
+
)
|
|
633
|
+
return {
|
|
634
|
+
_scope_pattern_identity_key(path).casefold()
|
|
635
|
+
for path in extract_forbidden_repo_path_hints(text)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def normalize_scope_patterns(task: dict[str, Any], *, root: Path | None = None) -> list[str]:
|
|
640
|
+
expanded = _expand_support_file_patterns(
|
|
641
|
+
normalize_claimed_scope_patterns(task, root=root),
|
|
642
|
+
root=root,
|
|
643
|
+
)
|
|
644
|
+
forbidden = _forbidden_path_identity_keys_for_task(task)
|
|
645
|
+
if not forbidden:
|
|
646
|
+
return expanded
|
|
647
|
+
return [
|
|
648
|
+
item for item in expanded if _scope_pattern_identity_key(item).casefold() not in forbidden
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def normalize_claimed_scope_patterns(
|
|
653
|
+
task: dict[str, Any], *, root: Path | None = None
|
|
654
|
+
) -> list[str]:
|
|
655
|
+
write_scope = normalize_repo_path_list(task.get("write_scope"), root=root)
|
|
656
|
+
estimated_files = normalize_repo_path_list(task.get("estimated_files"), root=root)
|
|
657
|
+
seen: set[str] = set()
|
|
658
|
+
combined: list[str] = []
|
|
659
|
+
for item in [*write_scope, *estimated_files]:
|
|
660
|
+
identity_key = _scope_pattern_identity_key(item)
|
|
661
|
+
if identity_key in seen:
|
|
662
|
+
continue
|
|
663
|
+
seen.add(identity_key)
|
|
664
|
+
combined.append(item)
|
|
665
|
+
return combined
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _scope_glob_variants(pattern: str) -> tuple[str, ...]:
|
|
669
|
+
variants = [pattern]
|
|
670
|
+
if "/**/" in pattern:
|
|
671
|
+
direct_child_variant = pattern.replace("/**/", "/")
|
|
672
|
+
if direct_child_variant != pattern:
|
|
673
|
+
variants.append(direct_child_variant)
|
|
674
|
+
return tuple(variants)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _is_existing_directory_scope(*, pattern: str, root: Path | None) -> bool:
|
|
678
|
+
if root is None or _has_glob(pattern) or _is_root_readme_alias(pattern):
|
|
679
|
+
return False
|
|
680
|
+
try:
|
|
681
|
+
return (root.resolve() / pattern).is_dir()
|
|
682
|
+
except OSError:
|
|
683
|
+
return False
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _normalize_scope_match_pattern(value: str) -> str | None:
|
|
687
|
+
normalized = normalize_repo_path_entry(value, allow_extensionless_file=True)
|
|
688
|
+
if normalized:
|
|
689
|
+
return normalized
|
|
690
|
+
cleaned = _normalize_path(_strip_wrapping_punctuation(value))
|
|
691
|
+
if not cleaned:
|
|
692
|
+
return None
|
|
693
|
+
if cleaned.startswith(("/", "../")):
|
|
694
|
+
return None
|
|
695
|
+
if "://" in cleaned or "\n" in cleaned or "\t" in cleaned:
|
|
696
|
+
return None
|
|
697
|
+
if cleaned in {".", ".."}:
|
|
698
|
+
return None
|
|
699
|
+
if cleaned.endswith("/"):
|
|
700
|
+
cleaned = cleaned.rstrip("/") + "/**"
|
|
701
|
+
return cleaned
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def scope_path_matches_pattern(
|
|
705
|
+
path: str,
|
|
706
|
+
pattern: str,
|
|
707
|
+
*,
|
|
708
|
+
root: Path | None = None,
|
|
709
|
+
) -> bool:
|
|
710
|
+
normalized_path = _normalize_path(path).rstrip("/")
|
|
711
|
+
normalized_pattern = _normalize_scope_match_pattern(pattern)
|
|
712
|
+
if not normalized_path or not normalized_pattern:
|
|
713
|
+
return False
|
|
714
|
+
if normalized_path.startswith(("/", "../")):
|
|
715
|
+
return False
|
|
716
|
+
if _is_root_readme_alias_match(path=normalized_path, pattern=normalized_pattern):
|
|
717
|
+
return True
|
|
718
|
+
if _has_glob(normalized_pattern):
|
|
719
|
+
return any(
|
|
720
|
+
fnmatch.fnmatchcase(normalized_path, variant)
|
|
721
|
+
for variant in _scope_glob_variants(normalized_pattern)
|
|
722
|
+
)
|
|
723
|
+
if normalized_path == normalized_pattern:
|
|
724
|
+
return True
|
|
725
|
+
if _is_existing_directory_scope(pattern=normalized_pattern, root=root):
|
|
726
|
+
return normalized_path.startswith(normalized_pattern.rstrip("/") + "/")
|
|
727
|
+
return False
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _is_ignored_internal_path(path: str) -> bool:
|
|
731
|
+
for prefix in _IGNORED_INTERNAL_PREFIXES:
|
|
732
|
+
if path == prefix or path.startswith(prefix + "/"):
|
|
733
|
+
return True
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def is_agent_internal_scope_path(path: str) -> bool:
|
|
738
|
+
cleaned = _normalize_path(path)
|
|
739
|
+
return _is_ignored_internal_path(cleaned) or is_runtime_artifact_path(cleaned)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def is_internal_alysis_path(path: str) -> bool:
|
|
743
|
+
return is_agent_internal_scope_path(path)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _is_python_file_path(path: str) -> bool:
|
|
747
|
+
return path.endswith(".py") and "/" in path
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _is_python_test_file(path: PurePosixPath) -> bool:
|
|
751
|
+
filename = path.name
|
|
752
|
+
return "tests" in path.parts[:-1] and (
|
|
753
|
+
filename.startswith("test_") or filename.endswith("_test.py")
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def _supports_python_package_init(path: PurePosixPath) -> bool:
|
|
758
|
+
parent_name = path.parent.name
|
|
759
|
+
if parent_name in {"", "."}:
|
|
760
|
+
return False
|
|
761
|
+
if parent_name == "tests":
|
|
762
|
+
return True
|
|
763
|
+
return parent_name not in _BROAD_NON_PACKAGE_DIR_NAMES
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _is_rust_source_file_path(path: str) -> bool:
|
|
767
|
+
return path.endswith(".rs")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _cargo_manifest_declares_workspace(manifest_path: Path) -> bool:
|
|
771
|
+
try:
|
|
772
|
+
text = manifest_path.read_text(encoding="utf-8")
|
|
773
|
+
except OSError:
|
|
774
|
+
return False
|
|
775
|
+
return _CARGO_WORKSPACE_RE.search(text) is not None
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _cargo_manifest_declares_package(manifest_path: Path) -> bool:
|
|
779
|
+
try:
|
|
780
|
+
text = manifest_path.read_text(encoding="utf-8")
|
|
781
|
+
except OSError:
|
|
782
|
+
return False
|
|
783
|
+
return _CARGO_PACKAGE_RE.search(text) is not None
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def _nearest_cargo_manifest_dir(*, root: Path, path: PurePosixPath) -> Path | None:
|
|
787
|
+
root_resolved = root.resolve()
|
|
788
|
+
current = root_resolved.joinpath(
|
|
789
|
+
*[segment for segment in path.parent.parts if segment not in {"", "."}]
|
|
790
|
+
)
|
|
791
|
+
while True:
|
|
792
|
+
if (current / _CARGO_MANIFEST_FILENAME).is_file():
|
|
793
|
+
return current
|
|
794
|
+
if current == root_resolved:
|
|
795
|
+
return None
|
|
796
|
+
current = current.parent
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _workspace_cargo_manifest_dir(*, root: Path, manifest_dir: Path) -> Path | None:
|
|
800
|
+
root_resolved = root.resolve()
|
|
801
|
+
current = manifest_dir
|
|
802
|
+
while True:
|
|
803
|
+
manifest_path = current / _CARGO_MANIFEST_FILENAME
|
|
804
|
+
if manifest_path.is_file() and _cargo_manifest_declares_workspace(manifest_path):
|
|
805
|
+
return current
|
|
806
|
+
if current == root_resolved:
|
|
807
|
+
return None
|
|
808
|
+
current = current.parent
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _scope_concrete_lookup_path(path: str) -> PurePosixPath | None:
|
|
812
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
813
|
+
if not normalized:
|
|
814
|
+
return None
|
|
815
|
+
pure_path = PurePosixPath(normalized)
|
|
816
|
+
if _is_rust_source_file_path(normalized):
|
|
817
|
+
return pure_path.parent
|
|
818
|
+
|
|
819
|
+
concrete_parts: list[str] = []
|
|
820
|
+
for segment in pure_path.parts:
|
|
821
|
+
if _has_glob(segment):
|
|
822
|
+
break
|
|
823
|
+
concrete_parts.append(segment)
|
|
824
|
+
if not concrete_parts:
|
|
825
|
+
return None
|
|
826
|
+
|
|
827
|
+
return PurePosixPath(*concrete_parts)
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _path_declares_package_manifest(*, root: Path, path: PurePosixPath) -> bool:
|
|
831
|
+
manifest_dir = root.resolve().joinpath(
|
|
832
|
+
*[segment for segment in path.parts if segment not in {"", "."}]
|
|
833
|
+
)
|
|
834
|
+
return _cargo_manifest_declares_package(manifest_dir / _CARGO_MANIFEST_FILENAME)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _manifest_relative_scope_path(
|
|
838
|
+
*, root: Path, manifest_dir: Path, scope_path: PurePosixPath
|
|
839
|
+
) -> PurePosixPath | None:
|
|
840
|
+
try:
|
|
841
|
+
manifest_rel = manifest_dir.relative_to(root.resolve()).as_posix()
|
|
842
|
+
except ValueError:
|
|
843
|
+
return None
|
|
844
|
+
if manifest_rel in {"", "."}:
|
|
845
|
+
return scope_path
|
|
846
|
+
try:
|
|
847
|
+
return scope_path.relative_to(PurePosixPath(manifest_rel))
|
|
848
|
+
except ValueError:
|
|
849
|
+
return None
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _rust_scope_lookup_dir(*, root: Path, path: str) -> PurePosixPath | None:
|
|
853
|
+
concrete_path = _scope_concrete_lookup_path(path)
|
|
854
|
+
if concrete_path is None:
|
|
855
|
+
return None
|
|
856
|
+
if _path_declares_package_manifest(root=root, path=concrete_path):
|
|
857
|
+
return concrete_path
|
|
858
|
+
|
|
859
|
+
manifest_dir = _nearest_cargo_manifest_dir(root=root, path=concrete_path / "_scope.rs")
|
|
860
|
+
if manifest_dir is None:
|
|
861
|
+
return None
|
|
862
|
+
manifest_path = manifest_dir / _CARGO_MANIFEST_FILENAME
|
|
863
|
+
if not _cargo_manifest_declares_package(manifest_path):
|
|
864
|
+
return None
|
|
865
|
+
|
|
866
|
+
relative_scope_path = _manifest_relative_scope_path(
|
|
867
|
+
root=root,
|
|
868
|
+
manifest_dir=manifest_dir,
|
|
869
|
+
scope_path=concrete_path,
|
|
870
|
+
)
|
|
871
|
+
if relative_scope_path is None or not relative_scope_path.parts:
|
|
872
|
+
return None
|
|
873
|
+
if relative_scope_path.parts[0] in _RUST_SCOPE_DIR_NAMES:
|
|
874
|
+
return concrete_path
|
|
875
|
+
return None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _cargo_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
|
|
879
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
880
|
+
if not normalized:
|
|
881
|
+
return []
|
|
882
|
+
|
|
883
|
+
lookup_dir = _rust_scope_lookup_dir(root=root, path=normalized)
|
|
884
|
+
if lookup_dir is None:
|
|
885
|
+
return []
|
|
886
|
+
|
|
887
|
+
manifest_dir = _nearest_cargo_manifest_dir(root=root, path=lookup_dir / "_scope.rs")
|
|
888
|
+
if manifest_dir is None:
|
|
889
|
+
return []
|
|
890
|
+
|
|
891
|
+
workspace_manifest_dir = _workspace_cargo_manifest_dir(root=root, manifest_dir=manifest_dir)
|
|
892
|
+
lock_parent = workspace_manifest_dir or manifest_dir
|
|
893
|
+
try:
|
|
894
|
+
relative_lock = lock_parent.relative_to(root.resolve()).as_posix()
|
|
895
|
+
except ValueError:
|
|
896
|
+
return []
|
|
897
|
+
if relative_lock in {"", "."}:
|
|
898
|
+
return [_CARGO_LOCK_FILENAME]
|
|
899
|
+
return [f"{relative_lock}/{_CARGO_LOCK_FILENAME}"]
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _cargo_lock_support_patterns_for_manifest(*, root: Path, path: str) -> list[str]:
|
|
903
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
904
|
+
if not normalized:
|
|
905
|
+
return []
|
|
906
|
+
pure_path = PurePosixPath(normalized)
|
|
907
|
+
if pure_path.name != _CARGO_MANIFEST_FILENAME:
|
|
908
|
+
return []
|
|
909
|
+
manifest_dir = root.resolve().joinpath(
|
|
910
|
+
*[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
|
|
911
|
+
)
|
|
912
|
+
manifest_path = manifest_dir / _CARGO_MANIFEST_FILENAME
|
|
913
|
+
if not manifest_path.is_file():
|
|
914
|
+
return []
|
|
915
|
+
if not (
|
|
916
|
+
_cargo_manifest_declares_package(manifest_path)
|
|
917
|
+
or _cargo_manifest_declares_workspace(manifest_path)
|
|
918
|
+
):
|
|
919
|
+
return []
|
|
920
|
+
workspace_manifest_dir = _workspace_cargo_manifest_dir(root=root, manifest_dir=manifest_dir)
|
|
921
|
+
lock_parent = workspace_manifest_dir or manifest_dir
|
|
922
|
+
try:
|
|
923
|
+
relative_lock = lock_parent.relative_to(root.resolve()).as_posix()
|
|
924
|
+
except ValueError:
|
|
925
|
+
return []
|
|
926
|
+
if relative_lock in {"", "."}:
|
|
927
|
+
return [_CARGO_LOCK_FILENAME]
|
|
928
|
+
return [f"{relative_lock}/{_CARGO_LOCK_FILENAME}"]
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
_NODE_LOCKFILES_BY_PACKAGE_MANAGER = {
|
|
932
|
+
"npm": "package-lock.json",
|
|
933
|
+
"pnpm": "pnpm-lock.yaml",
|
|
934
|
+
"yarn": "yarn.lock",
|
|
935
|
+
"bun": "bun.lockb",
|
|
936
|
+
}
|
|
937
|
+
_NODE_LOCKFILE_NAMES = frozenset(_NODE_LOCKFILES_BY_PACKAGE_MANAGER.values())
|
|
938
|
+
_PYPROJECT_LOCKFILE_RULES = (
|
|
939
|
+
("poetry.lock", "[tool.poetry]", "poetry"),
|
|
940
|
+
("uv.lock", "[tool.uv]", "uv"),
|
|
941
|
+
("pdm.lock", "[tool.pdm]", "pdm"),
|
|
942
|
+
)
|
|
943
|
+
# Package-manager lockfiles are the companion-expansion rules' business: those rules know
|
|
944
|
+
# which lockfile actually belongs to a declared manifest, so the generic sibling-artifact
|
|
945
|
+
# rule below must not second-guess them (a pnpm project seeing package-lock.json is a
|
|
946
|
+
# signal, not an adjacent change).
|
|
947
|
+
_MANAGED_LOCKFILE_NAMES = frozenset(
|
|
948
|
+
{
|
|
949
|
+
*_NODE_LOCKFILE_NAMES,
|
|
950
|
+
_CARGO_LOCK_FILENAME,
|
|
951
|
+
*(lock_name for lock_name, _marker, _tool in _PYPROJECT_LOCKFILE_RULES),
|
|
952
|
+
"Pipfile.lock",
|
|
953
|
+
"constraints.txt",
|
|
954
|
+
"requirements.lock",
|
|
955
|
+
"requirements.lock.txt",
|
|
956
|
+
}
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _package_manager_from_package_json(package_json: Path) -> str | None:
|
|
961
|
+
try:
|
|
962
|
+
import json
|
|
963
|
+
|
|
964
|
+
payload = json.loads(package_json.read_text(encoding="utf-8"))
|
|
965
|
+
except (OSError, ValueError):
|
|
966
|
+
return None
|
|
967
|
+
raw = str(payload.get("packageManager") or "").strip().casefold()
|
|
968
|
+
if not raw:
|
|
969
|
+
return None
|
|
970
|
+
return raw.split("@", 1)[0].strip() or None
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _node_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
|
|
974
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
975
|
+
if not normalized:
|
|
976
|
+
return []
|
|
977
|
+
pure_path = PurePosixPath(normalized)
|
|
978
|
+
if pure_path.name != "package.json":
|
|
979
|
+
return []
|
|
980
|
+
package_dir = root.resolve().joinpath(
|
|
981
|
+
*[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
|
|
982
|
+
)
|
|
983
|
+
package_json = package_dir / "package.json"
|
|
984
|
+
if not package_json.is_file():
|
|
985
|
+
return []
|
|
986
|
+
lock_name: str | None = None
|
|
987
|
+
manager = _package_manager_from_package_json(package_json)
|
|
988
|
+
if manager in _NODE_LOCKFILES_BY_PACKAGE_MANAGER:
|
|
989
|
+
lock_name = _NODE_LOCKFILES_BY_PACKAGE_MANAGER[manager]
|
|
990
|
+
else:
|
|
991
|
+
existing = sorted(name for name in _NODE_LOCKFILE_NAMES if (package_dir / name).is_file())
|
|
992
|
+
if len(existing) == 1:
|
|
993
|
+
lock_name = existing[0]
|
|
994
|
+
if lock_name is None:
|
|
995
|
+
return []
|
|
996
|
+
lock_path = pure_path.parent / lock_name
|
|
997
|
+
return [lock_path.as_posix()]
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _pyproject_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
|
|
1001
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
1002
|
+
if not normalized:
|
|
1003
|
+
return []
|
|
1004
|
+
pure_path = PurePosixPath(normalized)
|
|
1005
|
+
if pure_path.name != "pyproject.toml":
|
|
1006
|
+
return []
|
|
1007
|
+
project_dir = root.resolve().joinpath(
|
|
1008
|
+
*[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
|
|
1009
|
+
)
|
|
1010
|
+
pyproject = project_dir / "pyproject.toml"
|
|
1011
|
+
if not pyproject.is_file():
|
|
1012
|
+
return []
|
|
1013
|
+
try:
|
|
1014
|
+
pyproject_text = pyproject.read_text(encoding="utf-8").casefold()
|
|
1015
|
+
except OSError:
|
|
1016
|
+
pyproject_text = ""
|
|
1017
|
+
support: list[str] = []
|
|
1018
|
+
for lock_name, marker, _tool_name in _PYPROJECT_LOCKFILE_RULES:
|
|
1019
|
+
lock_path = project_dir / lock_name
|
|
1020
|
+
marker_present = marker.casefold() in pyproject_text
|
|
1021
|
+
if not lock_path.is_file() and not marker_present:
|
|
1022
|
+
continue
|
|
1023
|
+
support.append((pure_path.parent / lock_name).as_posix())
|
|
1024
|
+
return support
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _requirements_lock_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
|
|
1028
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
1029
|
+
if not normalized:
|
|
1030
|
+
return []
|
|
1031
|
+
pure_path = PurePosixPath(normalized)
|
|
1032
|
+
name = pure_path.name.casefold()
|
|
1033
|
+
if not (name.startswith("requirements") and name.endswith(".txt")):
|
|
1034
|
+
return []
|
|
1035
|
+
requirements_dir = root.resolve().joinpath(
|
|
1036
|
+
*[segment for segment in pure_path.parent.parts if segment not in {"", "."}]
|
|
1037
|
+
)
|
|
1038
|
+
support: list[str] = []
|
|
1039
|
+
for candidate in ("constraints.txt", "requirements.lock", "requirements.lock.txt"):
|
|
1040
|
+
if (requirements_dir / candidate).is_file():
|
|
1041
|
+
support.append((pure_path.parent / candidate).as_posix())
|
|
1042
|
+
return support
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _companion_patterns_for_path(path: str, *, root: Path | None = None) -> list[str]:
|
|
1046
|
+
if root is None:
|
|
1047
|
+
return []
|
|
1048
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
1049
|
+
if not normalized:
|
|
1050
|
+
return []
|
|
1051
|
+
companions: list[str] = []
|
|
1052
|
+
if _has_glob(normalized):
|
|
1053
|
+
companions.extend(_cargo_lock_support_patterns_for_path(root=root, path=normalized))
|
|
1054
|
+
return list(dict.fromkeys(companions))
|
|
1055
|
+
companions.extend(_cargo_lock_support_patterns_for_manifest(root=root, path=normalized))
|
|
1056
|
+
companions.extend(_cargo_lock_support_patterns_for_path(root=root, path=normalized))
|
|
1057
|
+
companions.extend(_node_lock_support_patterns_for_path(root=root, path=normalized))
|
|
1058
|
+
companions.extend(_pyproject_lock_support_patterns_for_path(root=root, path=normalized))
|
|
1059
|
+
companions.extend(_requirements_lock_support_patterns_for_path(root=root, path=normalized))
|
|
1060
|
+
return list(dict.fromkeys(companions))
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def companion_generated_paths_for(path: str, *, root: Path | None = None) -> list[str]:
|
|
1064
|
+
return _companion_patterns_for_path(path, root=root)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def companion_expansions_for_patterns(
|
|
1068
|
+
patterns: list[str],
|
|
1069
|
+
*,
|
|
1070
|
+
root: Path | None = None,
|
|
1071
|
+
) -> list[ScopeCompanionExpansion]:
|
|
1072
|
+
expansions: list[ScopeCompanionExpansion] = []
|
|
1073
|
+
seen: set[str] = set()
|
|
1074
|
+
for pattern in patterns:
|
|
1075
|
+
normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
|
|
1076
|
+
if not normalized:
|
|
1077
|
+
continue
|
|
1078
|
+
for companion in _companion_patterns_for_path(normalized, root=root):
|
|
1079
|
+
key = _scope_pattern_identity_key(companion).casefold()
|
|
1080
|
+
if key in seen:
|
|
1081
|
+
continue
|
|
1082
|
+
seen.add(key)
|
|
1083
|
+
expansions.append(
|
|
1084
|
+
ScopeCompanionExpansion(
|
|
1085
|
+
source_path=normalized,
|
|
1086
|
+
companion_path=companion,
|
|
1087
|
+
reason_code="ecosystem_companion",
|
|
1088
|
+
evidence=f"{companion} is a repo-aware companion for {normalized}",
|
|
1089
|
+
)
|
|
1090
|
+
)
|
|
1091
|
+
return expansions
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _rust_entrypoint_support_patterns_for_path(*, root: Path, path: str) -> list[str]:
|
|
1095
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
1096
|
+
if not normalized or not _is_rust_source_file_path(normalized):
|
|
1097
|
+
return []
|
|
1098
|
+
pure_path = PurePosixPath(normalized)
|
|
1099
|
+
if pure_path.name in _RUST_ENTRYPOINT_FILENAMES:
|
|
1100
|
+
return []
|
|
1101
|
+
|
|
1102
|
+
lookup_dir = _rust_scope_lookup_dir(root=root, path=normalized)
|
|
1103
|
+
if lookup_dir is None:
|
|
1104
|
+
return []
|
|
1105
|
+
|
|
1106
|
+
manifest_dir = _nearest_cargo_manifest_dir(root=root, path=lookup_dir / "_scope.rs")
|
|
1107
|
+
if manifest_dir is None:
|
|
1108
|
+
return []
|
|
1109
|
+
try:
|
|
1110
|
+
manifest_rel = manifest_dir.relative_to(root.resolve()).as_posix()
|
|
1111
|
+
except ValueError:
|
|
1112
|
+
return []
|
|
1113
|
+
|
|
1114
|
+
support_patterns: list[str] = []
|
|
1115
|
+
for filename in _RUST_ENTRYPOINT_FILENAMES:
|
|
1116
|
+
host_path = manifest_dir / "src" / filename
|
|
1117
|
+
if not host_path.is_file():
|
|
1118
|
+
continue
|
|
1119
|
+
rel_path = PurePosixPath("src", filename)
|
|
1120
|
+
if manifest_rel not in {"", "."}:
|
|
1121
|
+
rel_path = PurePosixPath(manifest_rel) / rel_path
|
|
1122
|
+
support_patterns.append(rel_path.as_posix())
|
|
1123
|
+
return support_patterns
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _support_file_patterns_for_path(path: str, *, root: Path | None = None) -> list[str]:
|
|
1127
|
+
normalized = normalize_repo_path_entry(path, allow_extensionless_file=True)
|
|
1128
|
+
if not normalized:
|
|
1129
|
+
return []
|
|
1130
|
+
|
|
1131
|
+
support_patterns: list[str] = []
|
|
1132
|
+
if not _has_glob(normalized) and _is_python_file_path(normalized):
|
|
1133
|
+
pure_path = PurePosixPath(normalized)
|
|
1134
|
+
parent = pure_path.parent.as_posix()
|
|
1135
|
+
if parent not in {"", "."}:
|
|
1136
|
+
if pure_path.name != "__init__.py" and _supports_python_package_init(pure_path):
|
|
1137
|
+
support_patterns.append(f"{parent}/__init__.py")
|
|
1138
|
+
if _is_python_test_file(pure_path):
|
|
1139
|
+
support_patterns.append(f"{parent}/conftest.py")
|
|
1140
|
+
|
|
1141
|
+
if root is not None:
|
|
1142
|
+
support_patterns.extend(_companion_patterns_for_path(normalized, root=root))
|
|
1143
|
+
support_patterns.extend(
|
|
1144
|
+
_rust_entrypoint_support_patterns_for_path(root=root, path=normalized)
|
|
1145
|
+
)
|
|
1146
|
+
return support_patterns
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _expand_support_file_patterns(patterns: list[str], *, root: Path | None = None) -> list[str]:
|
|
1150
|
+
seen: set[str] = set()
|
|
1151
|
+
expanded: list[str] = []
|
|
1152
|
+
for pattern in patterns:
|
|
1153
|
+
normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
|
|
1154
|
+
if not normalized:
|
|
1155
|
+
continue
|
|
1156
|
+
identity_key = _scope_pattern_identity_key(normalized)
|
|
1157
|
+
if identity_key not in seen:
|
|
1158
|
+
seen.add(identity_key)
|
|
1159
|
+
expanded.append(normalized)
|
|
1160
|
+
for support_pattern in _support_file_patterns_for_path(normalized, root=root):
|
|
1161
|
+
support_identity_key = _scope_pattern_identity_key(support_pattern)
|
|
1162
|
+
if support_identity_key in seen:
|
|
1163
|
+
continue
|
|
1164
|
+
seen.add(support_identity_key)
|
|
1165
|
+
expanded.append(support_pattern)
|
|
1166
|
+
return expanded
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _is_root_readme_alias(value: str) -> bool:
|
|
1170
|
+
normalized = _normalize_path(value).rstrip("/")
|
|
1171
|
+
return "/" not in normalized and normalized in _README_ALIAS_FILENAMES
|
|
1172
|
+
|
|
1173
|
+
|
|
1174
|
+
def _is_root_readme_alias_match(*, path: str, pattern: str) -> bool:
|
|
1175
|
+
return _is_root_readme_alias(path) and _is_root_readme_alias(pattern)
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def _scope_pattern_identity_key(value: str) -> str:
|
|
1179
|
+
normalized = _normalize_path(value).rstrip("/")
|
|
1180
|
+
if _is_root_readme_alias(normalized):
|
|
1181
|
+
return "__readme_alias__"
|
|
1182
|
+
return normalized
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def ancestor_directory_scope_patterns(
|
|
1186
|
+
patterns: list[str], *, root: Path | None = None
|
|
1187
|
+
) -> list[str]:
|
|
1188
|
+
seen: set[str] = set()
|
|
1189
|
+
ancestors: list[str] = []
|
|
1190
|
+
for pattern in _expand_support_file_patterns(patterns, root=root):
|
|
1191
|
+
normalized = normalize_repo_path_entry(pattern, allow_extensionless_file=True)
|
|
1192
|
+
if not normalized or _has_glob(normalized):
|
|
1193
|
+
continue
|
|
1194
|
+
parent = PurePosixPath(normalized).parent.as_posix()
|
|
1195
|
+
while parent not in {"", "."}:
|
|
1196
|
+
if parent not in seen:
|
|
1197
|
+
seen.add(parent)
|
|
1198
|
+
ancestors.append(parent)
|
|
1199
|
+
parent = PurePosixPath(parent).parent.as_posix()
|
|
1200
|
+
return ancestors
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def is_non_material_untracked_path(path: str) -> bool:
|
|
1204
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1205
|
+
if not normalized:
|
|
1206
|
+
return False
|
|
1207
|
+
pure = PurePosixPath(normalized)
|
|
1208
|
+
if any(part.endswith(".egg-info") for part in pure.parts):
|
|
1209
|
+
return True
|
|
1210
|
+
if len(pure.parts) != 1:
|
|
1211
|
+
return False
|
|
1212
|
+
name = pure.name.casefold()
|
|
1213
|
+
return (
|
|
1214
|
+
name in _NON_MATERIAL_ROOT_SCRATCH_FILENAMES
|
|
1215
|
+
or any(name.endswith(suffix) for suffix in _NON_MATERIAL_ROOT_SCRATCH_SUFFIXES)
|
|
1216
|
+
or _NON_MATERIAL_ROOT_SCRATCH_RE.fullmatch(name) is not None
|
|
1217
|
+
or _NON_MATERIAL_ROOT_STATE_FILE_RE.fullmatch(name) is not None
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def is_untracked_source_path(path: str, *, root: Path | None = None) -> bool:
|
|
1222
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1223
|
+
if (
|
|
1224
|
+
not normalized
|
|
1225
|
+
or is_non_material_untracked_path(normalized)
|
|
1226
|
+
or is_runtime_artifact_path(normalized, root=root)
|
|
1227
|
+
):
|
|
1228
|
+
return False
|
|
1229
|
+
return classify_path(normalized).kind in _UNTRACKED_SOURCE_KINDS
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def is_existing_test_layout_path(path: str) -> bool:
|
|
1233
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1234
|
+
if not normalized or normalized.startswith(("/", "../")):
|
|
1235
|
+
return False
|
|
1236
|
+
parsed = PurePosixPath(normalized)
|
|
1237
|
+
filename = parsed.name.casefold()
|
|
1238
|
+
directory_parts = {part.casefold() for part in parsed.parts[:-1]}
|
|
1239
|
+
return bool(
|
|
1240
|
+
directory_parts & {"tests", "testing"}
|
|
1241
|
+
or (filename.startswith("test_") and filename.endswith(".py"))
|
|
1242
|
+
or filename.endswith("_test.py")
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _is_known_alysis_scratch_path(path: str) -> bool:
|
|
1247
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1248
|
+
if not normalized:
|
|
1249
|
+
return False
|
|
1250
|
+
pure = PurePosixPath(normalized)
|
|
1251
|
+
if len(pure.parts) != 1:
|
|
1252
|
+
return False
|
|
1253
|
+
name = pure.name.casefold()
|
|
1254
|
+
return (
|
|
1255
|
+
name in _NON_MATERIAL_ROOT_SCRATCH_FILENAMES
|
|
1256
|
+
or any(name.endswith(suffix) for suffix in _NON_MATERIAL_ROOT_SCRATCH_SUFFIXES)
|
|
1257
|
+
or _NON_MATERIAL_ROOT_SCRATCH_RE.fullmatch(name) is not None
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _unique_artifact_destination(artifact_dir: Path, filename: str) -> Path:
|
|
1262
|
+
candidate = artifact_dir / filename
|
|
1263
|
+
if not candidate.exists():
|
|
1264
|
+
return candidate
|
|
1265
|
+
stem = candidate.stem
|
|
1266
|
+
suffix = candidate.suffix
|
|
1267
|
+
for index in range(1, 1000):
|
|
1268
|
+
indexed = artifact_dir / f"{stem}.{index}{suffix}"
|
|
1269
|
+
if not indexed.exists():
|
|
1270
|
+
return indexed
|
|
1271
|
+
return artifact_dir / f"{stem}.overflow{suffix}"
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def relocate_known_scratch_artifacts(
|
|
1275
|
+
*,
|
|
1276
|
+
root: Path,
|
|
1277
|
+
artifact_dir: Path,
|
|
1278
|
+
) -> list[ScopeViolationDiagnostic]:
|
|
1279
|
+
diagnostics: list[ScopeViolationDiagnostic] = []
|
|
1280
|
+
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
1281
|
+
for rel in _git_untracked_leaf_files(root, include_non_material=True):
|
|
1282
|
+
normalized = _normalize_path(rel).rstrip("/")
|
|
1283
|
+
if not _is_known_alysis_scratch_path(normalized):
|
|
1284
|
+
continue
|
|
1285
|
+
source = (root.resolve() / normalized).resolve()
|
|
1286
|
+
try:
|
|
1287
|
+
source.relative_to(root.resolve())
|
|
1288
|
+
except ValueError:
|
|
1289
|
+
continue
|
|
1290
|
+
if not source.is_file():
|
|
1291
|
+
continue
|
|
1292
|
+
destination = _unique_artifact_destination(artifact_dir, PurePosixPath(normalized).name)
|
|
1293
|
+
try:
|
|
1294
|
+
shutil.move(os.fspath(source), os.fspath(destination))
|
|
1295
|
+
except OSError:
|
|
1296
|
+
continue
|
|
1297
|
+
diagnostics.append(
|
|
1298
|
+
ScopeViolationDiagnostic(
|
|
1299
|
+
path=normalized,
|
|
1300
|
+
classification=SCOPE_CLASS_SCRATCH_ARTIFACT,
|
|
1301
|
+
reason_code="moved_known_root_scratch_file",
|
|
1302
|
+
evidence=(
|
|
1303
|
+
f"Moved untracked diagnostic scratch file {normalized} to {destination.name}"
|
|
1304
|
+
),
|
|
1305
|
+
recommended_action="recorded_in_artifacts",
|
|
1306
|
+
allowed=True,
|
|
1307
|
+
)
|
|
1308
|
+
)
|
|
1309
|
+
return diagnostics
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def _forbidden_path_identity_keys_for_text(text: str) -> set[str]:
|
|
1313
|
+
return {
|
|
1314
|
+
_scope_pattern_identity_key(item.path).casefold()
|
|
1315
|
+
for item in extract_forbidden_repo_path_hint_records(text)
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _forbidden_path_records_for_task(task: dict[str, Any] | None) -> list[ForbiddenPathHint]:
|
|
1320
|
+
if not isinstance(task, dict):
|
|
1321
|
+
return []
|
|
1322
|
+
acceptance = task.get("acceptance_criteria") or []
|
|
1323
|
+
acceptance_items = acceptance if isinstance(acceptance, list) else []
|
|
1324
|
+
text = "\n".join(
|
|
1325
|
+
[
|
|
1326
|
+
str(task.get("title") or ""),
|
|
1327
|
+
str(task.get("description") or ""),
|
|
1328
|
+
*(str(item or "") for item in acceptance_items),
|
|
1329
|
+
]
|
|
1330
|
+
)
|
|
1331
|
+
return extract_forbidden_repo_path_hint_records(text)
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _path_matches_any_pattern(
|
|
1335
|
+
path: str,
|
|
1336
|
+
patterns: list[str],
|
|
1337
|
+
*,
|
|
1338
|
+
root: Path | None = None,
|
|
1339
|
+
) -> bool:
|
|
1340
|
+
return any(scope_path_matches_pattern(path, pattern, root=root) for pattern in patterns)
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _companion_source_for_path(
|
|
1344
|
+
path: str,
|
|
1345
|
+
expansions: list[ScopeCompanionExpansion],
|
|
1346
|
+
*,
|
|
1347
|
+
root: Path | None = None,
|
|
1348
|
+
) -> ScopeCompanionExpansion | None:
|
|
1349
|
+
for expansion in expansions:
|
|
1350
|
+
if scope_path_matches_pattern(path, expansion.companion_path, root=root):
|
|
1351
|
+
return expansion
|
|
1352
|
+
return None
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def is_protected_scope_path(path: str) -> bool:
|
|
1356
|
+
"""True for paths no task scope may cover: agent-internal state, VCS metadata, escapes.
|
|
1357
|
+
|
|
1358
|
+
A protected path is never amendable. It blocks in strict mode no matter how plausible
|
|
1359
|
+
the change looks, which is the point: scope triage may relax the *plan*, never the
|
|
1360
|
+
workspace boundary.
|
|
1361
|
+
"""
|
|
1362
|
+
cleaned = _normalize_path(path).rstrip("/")
|
|
1363
|
+
if not cleaned:
|
|
1364
|
+
return False
|
|
1365
|
+
if (
|
|
1366
|
+
cleaned.startswith("/")
|
|
1367
|
+
or cleaned == ".."
|
|
1368
|
+
or cleaned.startswith("../")
|
|
1369
|
+
or _WINDOWS_ABSOLUTE_PATH_RE.match(cleaned) is not None
|
|
1370
|
+
):
|
|
1371
|
+
return True
|
|
1372
|
+
if is_agent_internal_scope_path(cleaned):
|
|
1373
|
+
return True
|
|
1374
|
+
parts = PurePosixPath(cleaned).parts
|
|
1375
|
+
return bool(parts) and parts[0] in _PROTECTED_SCOPE_PREFIXES
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _static_glob_prefix_dir(pattern: str) -> str:
|
|
1379
|
+
"""Directory portion of ``pattern`` before its first globbed segment.
|
|
1380
|
+
|
|
1381
|
+
``src/**`` and ``src/*.py`` both anchor on ``src``; a bare ``**`` anchors on nothing
|
|
1382
|
+
and must not turn the whole repository into declared-scope territory.
|
|
1383
|
+
"""
|
|
1384
|
+
parts = PurePosixPath(pattern).parts
|
|
1385
|
+
static: list[str] = []
|
|
1386
|
+
for part in parts:
|
|
1387
|
+
if _has_glob(part):
|
|
1388
|
+
break
|
|
1389
|
+
static.append(part)
|
|
1390
|
+
if len(static) == len(parts):
|
|
1391
|
+
# No globbed segment at all: the last element is the declared leaf, not a dir.
|
|
1392
|
+
static = static[:-1]
|
|
1393
|
+
return PurePosixPath(*static).as_posix() if static else ""
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def declared_scope_directories(allowed_patterns: list[str]) -> set[str]:
|
|
1397
|
+
"""Directories the declared scope already reaches into.
|
|
1398
|
+
|
|
1399
|
+
A declared file implies its directory; a glob implies its static prefix. Both are the
|
|
1400
|
+
basis for calling a neighbouring path "adjacent" rather than unrelated.
|
|
1401
|
+
"""
|
|
1402
|
+
directories: set[str] = set()
|
|
1403
|
+
for pattern in allowed_patterns:
|
|
1404
|
+
normalized = _normalize_path(pattern or "").rstrip("/")
|
|
1405
|
+
if not normalized:
|
|
1406
|
+
continue
|
|
1407
|
+
directory = (
|
|
1408
|
+
_static_glob_prefix_dir(normalized)
|
|
1409
|
+
if _has_glob(normalized)
|
|
1410
|
+
else PurePosixPath(normalized).parent.as_posix()
|
|
1411
|
+
)
|
|
1412
|
+
if directory and directory not in {".", "/"}:
|
|
1413
|
+
directories.add(directory)
|
|
1414
|
+
return directories
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _path_is_under_declared_directory(path: str, directories: Collection[str]) -> str | None:
|
|
1418
|
+
parent = PurePosixPath(path).parent.as_posix()
|
|
1419
|
+
if parent in {"", "."}:
|
|
1420
|
+
return None
|
|
1421
|
+
for directory in directories:
|
|
1422
|
+
if parent == directory or parent.startswith(directory + "/"):
|
|
1423
|
+
return directory
|
|
1424
|
+
return None
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def _path_can_be_adjacent(path: str) -> bool:
|
|
1428
|
+
"""Shared guard: hidden, escaping and vendored/built paths are never adjacent."""
|
|
1429
|
+
if not path or path.startswith(".") or path.startswith("../"):
|
|
1430
|
+
return False
|
|
1431
|
+
pure = PurePosixPath(path)
|
|
1432
|
+
if any(part in _NON_ADJACENT_DIR_NAMES for part in pure.parts):
|
|
1433
|
+
return False
|
|
1434
|
+
return "." in pure.name
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def _likely_legitimate_missing_scope(path: str, *, allowed_patterns: list[str]) -> bool:
|
|
1438
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1439
|
+
if not _path_can_be_adjacent(normalized):
|
|
1440
|
+
return False
|
|
1441
|
+
extension = PurePosixPath(normalized).name.rsplit(".", 1)[1].casefold()
|
|
1442
|
+
if extension not in _INFERRED_FILE_EXTENSIONS:
|
|
1443
|
+
return False
|
|
1444
|
+
return (
|
|
1445
|
+
_path_is_under_declared_directory(normalized, declared_scope_directories(allowed_patterns))
|
|
1446
|
+
is not None
|
|
1447
|
+
)
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def _filename_stem(name: str) -> str:
|
|
1451
|
+
return name.rsplit(".", 1)[0] if "." in name else name
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _test_filename_subjects(name: str) -> set[str]:
|
|
1455
|
+
"""Stems a test filename could be testing (``test_mod.py`` -> ``{mod}``)."""
|
|
1456
|
+
stem = _filename_stem(name)
|
|
1457
|
+
subjects: set[str] = set()
|
|
1458
|
+
for prefix in _TEST_FILENAME_STEM_PREFIXES:
|
|
1459
|
+
if stem.casefold().startswith(prefix):
|
|
1460
|
+
subjects.add(stem[len(prefix) :])
|
|
1461
|
+
for suffix in _TEST_FILENAME_STEM_SUFFIXES:
|
|
1462
|
+
if stem.casefold().endswith(suffix):
|
|
1463
|
+
subjects.add(stem[: -len(suffix)])
|
|
1464
|
+
return {item.casefold() for item in subjects if item}
|
|
1465
|
+
|
|
1466
|
+
|
|
1467
|
+
def _sibling_test_source(path: str, *, declared_files: Collection[str]) -> str | None:
|
|
1468
|
+
"""Declared file this path is a test for, if it reads as one."""
|
|
1469
|
+
subjects = _test_filename_subjects(PurePosixPath(path).name)
|
|
1470
|
+
if not subjects:
|
|
1471
|
+
return None
|
|
1472
|
+
for declared in declared_files:
|
|
1473
|
+
declared_stem = _filename_stem(PurePosixPath(declared).name).casefold()
|
|
1474
|
+
if declared_stem and declared_stem in subjects:
|
|
1475
|
+
return declared
|
|
1476
|
+
return None
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _generated_artifact_source(path: str, *, declared_files: Collection[str]) -> str | None:
|
|
1480
|
+
"""Declared file this path is a generated sibling artifact of, if any."""
|
|
1481
|
+
pure = PurePosixPath(path)
|
|
1482
|
+
name = pure.name
|
|
1483
|
+
if name in _MANAGED_LOCKFILE_NAMES:
|
|
1484
|
+
return None
|
|
1485
|
+
looks_generated = name.casefold().endswith(_GENERATED_ARTIFACT_NAME_SUFFIXES) or any(
|
|
1486
|
+
marker in name.casefold() for marker in _GENERATED_ARTIFACT_NAME_MARKERS
|
|
1487
|
+
)
|
|
1488
|
+
if not looks_generated:
|
|
1489
|
+
return None
|
|
1490
|
+
for declared in declared_files:
|
|
1491
|
+
declared_pure = PurePosixPath(declared)
|
|
1492
|
+
if declared_pure.parent != pure.parent or declared_pure.name == name:
|
|
1493
|
+
continue
|
|
1494
|
+
stem = _filename_stem(declared_pure.name)
|
|
1495
|
+
if stem and name.startswith(stem) and name[len(stem) : len(stem) + 1] in {".", "_", "-"}:
|
|
1496
|
+
return declared
|
|
1497
|
+
return None
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _declared_scope_files(allowed_patterns: list[str]) -> list[str]:
|
|
1501
|
+
return [
|
|
1502
|
+
_normalize_path(pattern).rstrip("/")
|
|
1503
|
+
for pattern in allowed_patterns
|
|
1504
|
+
if pattern and not _has_glob(pattern) and "." in PurePosixPath(pattern).name
|
|
1505
|
+
]
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _adjacent_scope_classification(
|
|
1509
|
+
path: str,
|
|
1510
|
+
*,
|
|
1511
|
+
allowed_patterns: list[str],
|
|
1512
|
+
scope_directories: Collection[str],
|
|
1513
|
+
declared_files: Collection[str],
|
|
1514
|
+
new_paths: Collection[str] | None,
|
|
1515
|
+
) -> tuple[str, str, str | None] | None:
|
|
1516
|
+
"""Classify ``path`` as adjacent to the declared scope, or return None.
|
|
1517
|
+
|
|
1518
|
+
Returns ``(reason_code, evidence, source_path)``. Rule order matters: the test and
|
|
1519
|
+
generated-artifact rules hold for edits as well as creations, while "new file in a
|
|
1520
|
+
declared directory" only holds for files the task actually created -- editing an
|
|
1521
|
+
existing neighbouring module is a scope question, not a bookkeeping gap.
|
|
1522
|
+
"""
|
|
1523
|
+
if not _path_can_be_adjacent(path):
|
|
1524
|
+
return None
|
|
1525
|
+
|
|
1526
|
+
test_source = _sibling_test_source(path, declared_files=declared_files)
|
|
1527
|
+
if test_source is not None:
|
|
1528
|
+
return (
|
|
1529
|
+
SCOPE_ADJACENT_SIBLING_TEST,
|
|
1530
|
+
f"{path} is a sibling test file for declared scope path {test_source}",
|
|
1531
|
+
test_source,
|
|
1532
|
+
)
|
|
1533
|
+
|
|
1534
|
+
artifact_source = _generated_artifact_source(path, declared_files=declared_files)
|
|
1535
|
+
if artifact_source is not None:
|
|
1536
|
+
return (
|
|
1537
|
+
SCOPE_ADJACENT_GENERATED_ARTIFACT,
|
|
1538
|
+
f"{path} is a generated artifact of declared scope path {artifact_source}",
|
|
1539
|
+
artifact_source,
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
covering_directory = _path_is_under_declared_directory(path, scope_directories)
|
|
1543
|
+
if covering_directory is None:
|
|
1544
|
+
return None
|
|
1545
|
+
extension = PurePosixPath(path).name.rsplit(".", 1)[1].casefold()
|
|
1546
|
+
if extension not in _INFERRED_FILE_EXTENSIONS:
|
|
1547
|
+
return None
|
|
1548
|
+
if new_paths is not None and path not in new_paths:
|
|
1549
|
+
return None
|
|
1550
|
+
created = "created" if new_paths is not None else "wrote"
|
|
1551
|
+
return (
|
|
1552
|
+
SCOPE_ADJACENT_NEW_FILE_IN_SCOPE_DIR,
|
|
1553
|
+
f"{path} was {created} under {covering_directory}/, already covered by write_scope "
|
|
1554
|
+
f"{allowed_patterns or ['(none)']}",
|
|
1555
|
+
None,
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def suggested_scope_pattern_for(path: str) -> str:
|
|
1560
|
+
"""Directory-level glob covering ``path`` (the path itself when it sits at the root).
|
|
1561
|
+
|
|
1562
|
+
Directory globs are what the planner is asked to write (see plan_validation R3/R4), so
|
|
1563
|
+
they are also what a scope-fix suggestion offers.
|
|
1564
|
+
"""
|
|
1565
|
+
normalized = _normalize_path(path).rstrip("/")
|
|
1566
|
+
if not normalized:
|
|
1567
|
+
return ""
|
|
1568
|
+
parent = PurePosixPath(normalized).parent.as_posix()
|
|
1569
|
+
if parent in {"", "."}:
|
|
1570
|
+
return normalized
|
|
1571
|
+
return f"{parent}/**"
|
|
1572
|
+
|
|
1573
|
+
|
|
1574
|
+
def suggested_scope_patterns_for(paths: Collection[str], *, limit: int = 20) -> list[str]:
|
|
1575
|
+
"""Deduplicated directory-level globs covering ``paths``, in first-seen order."""
|
|
1576
|
+
patterns: list[str] = []
|
|
1577
|
+
seen: set[str] = set()
|
|
1578
|
+
for path in paths:
|
|
1579
|
+
pattern = suggested_scope_pattern_for(path)
|
|
1580
|
+
if not pattern or pattern in seen:
|
|
1581
|
+
continue
|
|
1582
|
+
seen.add(pattern)
|
|
1583
|
+
patterns.append(pattern)
|
|
1584
|
+
if len(patterns) >= limit:
|
|
1585
|
+
break
|
|
1586
|
+
return patterns
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def apply_scope_amendments(
|
|
1590
|
+
task: dict[str, Any], amendments: Collection[ScopeAmendment]
|
|
1591
|
+
) -> list[str]:
|
|
1592
|
+
"""Add amendment patterns to ``task['write_scope']``; return what was actually added.
|
|
1593
|
+
|
|
1594
|
+
The task dict is the plan's own task object, so mutating it here is what makes the
|
|
1595
|
+
amendment outlive the run once the plan is saved.
|
|
1596
|
+
"""
|
|
1597
|
+
if not amendments:
|
|
1598
|
+
return []
|
|
1599
|
+
existing_raw = task.get("write_scope")
|
|
1600
|
+
existing = (
|
|
1601
|
+
[str(item).strip() for item in existing_raw] if isinstance(existing_raw, list) else []
|
|
1602
|
+
)
|
|
1603
|
+
existing = [item for item in existing if item]
|
|
1604
|
+
known = {_scope_pattern_identity_key(item).casefold() for item in existing}
|
|
1605
|
+
added: list[str] = []
|
|
1606
|
+
for amendment in amendments:
|
|
1607
|
+
pattern = _normalize_path(amendment.pattern).rstrip("/")
|
|
1608
|
+
identity = _scope_pattern_identity_key(pattern).casefold()
|
|
1609
|
+
if not pattern or identity in known:
|
|
1610
|
+
continue
|
|
1611
|
+
known.add(identity)
|
|
1612
|
+
existing.append(pattern)
|
|
1613
|
+
added.append(pattern)
|
|
1614
|
+
if added:
|
|
1615
|
+
task["write_scope"] = existing
|
|
1616
|
+
return added
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def describe_scope_violations(
|
|
1620
|
+
diagnostics: Collection[ScopeViolationDiagnostic],
|
|
1621
|
+
*,
|
|
1622
|
+
limit: int = 20,
|
|
1623
|
+
) -> list[str]:
|
|
1624
|
+
"""One ``path (triage/classification): evidence`` line per blocking diagnostic.
|
|
1625
|
+
|
|
1626
|
+
Bounded, because this ends up inside an error string that ends up inside a report: a
|
|
1627
|
+
task that rewrote a thousand files should not produce a thousand-line failure message.
|
|
1628
|
+
"""
|
|
1629
|
+
blocking = [item for item in diagnostics if not item.allowed]
|
|
1630
|
+
lines = [
|
|
1631
|
+
f"{item.path} ({item.triage}/{item.classification}): {item.evidence}"
|
|
1632
|
+
for item in blocking[:limit]
|
|
1633
|
+
]
|
|
1634
|
+
if len(blocking) > limit:
|
|
1635
|
+
lines.append(f"+{len(blocking) - limit} more")
|
|
1636
|
+
return lines
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def assess_scope_changes(
|
|
1640
|
+
changed_files: list[str],
|
|
1641
|
+
allowed_patterns: list[str],
|
|
1642
|
+
*,
|
|
1643
|
+
task: dict[str, Any] | None = None,
|
|
1644
|
+
root: Path | None = None,
|
|
1645
|
+
extra_diagnostics: list[ScopeViolationDiagnostic] | None = None,
|
|
1646
|
+
amend_adjacent: bool = False,
|
|
1647
|
+
new_paths: Collection[str] | None = None,
|
|
1648
|
+
) -> ScopeAssessment:
|
|
1649
|
+
"""Classify every change against the declared scope.
|
|
1650
|
+
|
|
1651
|
+
With ``amend_adjacent`` set, changes that are plainly adjacent to the declared scope --
|
|
1652
|
+
a new file in a directory the scope already reaches, a sibling test file, a generated
|
|
1653
|
+
artifact of a declared file -- stop blocking and become :class:`ScopeAmendment` records
|
|
1654
|
+
the caller applies to the task. Protected paths block regardless, and genuinely
|
|
1655
|
+
unrelated paths keep blocking with a suggested scope patch attached.
|
|
1656
|
+
|
|
1657
|
+
``new_paths`` narrows the "new file in a declared directory" rule to files the task
|
|
1658
|
+
actually created; pass ``None`` when creation cannot be determined and the rule falls
|
|
1659
|
+
back to covering edits too.
|
|
1660
|
+
"""
|
|
1661
|
+
expanded_patterns = _expand_support_file_patterns(allowed_patterns, root=root)
|
|
1662
|
+
ancestor_dirs = set(ancestor_directory_scope_patterns(allowed_patterns, root=root))
|
|
1663
|
+
companion_expansions = companion_expansions_for_patterns(allowed_patterns, root=root)
|
|
1664
|
+
companion_patterns = [item.companion_path for item in companion_expansions]
|
|
1665
|
+
forbidden_records = _forbidden_path_records_for_task(task)
|
|
1666
|
+
forbidden_by_identity = {
|
|
1667
|
+
_scope_pattern_identity_key(item.path).casefold(): item for item in forbidden_records
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
normalized: list[str] = []
|
|
1671
|
+
seen: set[str] = set()
|
|
1672
|
+
for raw in changed_files:
|
|
1673
|
+
rel = _normalize_path(raw).rstrip("/")
|
|
1674
|
+
if not rel or rel in seen or is_runtime_artifact_path(rel, root=root):
|
|
1675
|
+
continue
|
|
1676
|
+
seen.add(rel)
|
|
1677
|
+
normalized.append(rel)
|
|
1678
|
+
|
|
1679
|
+
scope_directories = declared_scope_directories(allowed_patterns)
|
|
1680
|
+
declared_files = _declared_scope_files(allowed_patterns)
|
|
1681
|
+
normalized_new_paths = (
|
|
1682
|
+
{_normalize_path(item).rstrip("/") for item in new_paths} if new_paths is not None else None
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
diagnostics: list[ScopeViolationDiagnostic] = list(extra_diagnostics or [])
|
|
1686
|
+
blocking_paths: list[str] = []
|
|
1687
|
+
effective_changed_files: list[str] = []
|
|
1688
|
+
in_scope_paths: list[str] = []
|
|
1689
|
+
adjacent_paths: list[str] = []
|
|
1690
|
+
amendments: list[ScopeAmendment] = []
|
|
1691
|
+
for rel in normalized:
|
|
1692
|
+
identity_key = _scope_pattern_identity_key(rel).casefold()
|
|
1693
|
+
forbidden_record = forbidden_by_identity.get(identity_key)
|
|
1694
|
+
if forbidden_record is not None:
|
|
1695
|
+
diagnostics.append(
|
|
1696
|
+
ScopeViolationDiagnostic(
|
|
1697
|
+
path=rel,
|
|
1698
|
+
classification=SCOPE_CLASS_FORBIDDEN,
|
|
1699
|
+
reason_code=forbidden_record.reason_code,
|
|
1700
|
+
evidence=forbidden_record.evidence,
|
|
1701
|
+
recommended_action="reject_hard",
|
|
1702
|
+
allowed=False,
|
|
1703
|
+
)
|
|
1704
|
+
)
|
|
1705
|
+
blocking_paths.append(rel)
|
|
1706
|
+
effective_changed_files.append(rel)
|
|
1707
|
+
continue
|
|
1708
|
+
if is_protected_scope_path(rel):
|
|
1709
|
+
diagnostics.append(
|
|
1710
|
+
ScopeViolationDiagnostic(
|
|
1711
|
+
path=rel,
|
|
1712
|
+
classification=SCOPE_CLASS_PROTECTED,
|
|
1713
|
+
reason_code="protected_or_system_path",
|
|
1714
|
+
evidence=(
|
|
1715
|
+
f"{rel} is a protected agent-internal, version-control or "
|
|
1716
|
+
"out-of-workspace path that no task scope may cover"
|
|
1717
|
+
),
|
|
1718
|
+
recommended_action="reject_hard",
|
|
1719
|
+
allowed=False,
|
|
1720
|
+
)
|
|
1721
|
+
)
|
|
1722
|
+
blocking_paths.append(rel)
|
|
1723
|
+
effective_changed_files.append(rel)
|
|
1724
|
+
continue
|
|
1725
|
+
if rel in ancestor_dirs:
|
|
1726
|
+
effective_changed_files.append(rel)
|
|
1727
|
+
continue
|
|
1728
|
+
companion = _companion_source_for_path(rel, companion_expansions, root=root)
|
|
1729
|
+
if companion is not None or _path_matches_any_pattern(rel, companion_patterns, root=root):
|
|
1730
|
+
source_path = companion.source_path if companion is not None else None
|
|
1731
|
+
diagnostics.append(
|
|
1732
|
+
ScopeViolationDiagnostic(
|
|
1733
|
+
path=rel,
|
|
1734
|
+
classification=SCOPE_CLASS_EXPECTED_COMPANION,
|
|
1735
|
+
reason_code="allowed_ecosystem_companion",
|
|
1736
|
+
evidence=(
|
|
1737
|
+
companion.evidence
|
|
1738
|
+
if companion is not None
|
|
1739
|
+
else f"{rel} matches an ecosystem companion rule"
|
|
1740
|
+
),
|
|
1741
|
+
recommended_action="allow_and_record",
|
|
1742
|
+
allowed=True,
|
|
1743
|
+
source_path=source_path,
|
|
1744
|
+
)
|
|
1745
|
+
)
|
|
1746
|
+
effective_changed_files.append(rel)
|
|
1747
|
+
continue
|
|
1748
|
+
if _path_matches_any_pattern(rel, expanded_patterns, root=root):
|
|
1749
|
+
in_scope_paths.append(rel)
|
|
1750
|
+
effective_changed_files.append(rel)
|
|
1751
|
+
continue
|
|
1752
|
+
adjacency = _adjacent_scope_classification(
|
|
1753
|
+
rel,
|
|
1754
|
+
allowed_patterns=allowed_patterns,
|
|
1755
|
+
scope_directories=scope_directories,
|
|
1756
|
+
declared_files=declared_files,
|
|
1757
|
+
new_paths=normalized_new_paths,
|
|
1758
|
+
)
|
|
1759
|
+
if adjacency is not None and amend_adjacent:
|
|
1760
|
+
reason_code, evidence, source_path = adjacency
|
|
1761
|
+
amendments.append(
|
|
1762
|
+
ScopeAmendment(
|
|
1763
|
+
path=rel,
|
|
1764
|
+
# The exact path, not the directory glob: an amendment grants only what
|
|
1765
|
+
# the task actually earned. The glob is offered as planner advice.
|
|
1766
|
+
pattern=rel,
|
|
1767
|
+
reason_code=reason_code,
|
|
1768
|
+
evidence=evidence,
|
|
1769
|
+
suggested_pattern=suggested_scope_pattern_for(rel),
|
|
1770
|
+
source_path=source_path,
|
|
1771
|
+
)
|
|
1772
|
+
)
|
|
1773
|
+
adjacent_paths.append(rel)
|
|
1774
|
+
diagnostics.append(
|
|
1775
|
+
ScopeViolationDiagnostic(
|
|
1776
|
+
path=rel,
|
|
1777
|
+
classification=SCOPE_CLASS_ADJACENT,
|
|
1778
|
+
reason_code=reason_code,
|
|
1779
|
+
evidence=evidence,
|
|
1780
|
+
recommended_action="amend_scope_and_continue",
|
|
1781
|
+
allowed=True,
|
|
1782
|
+
source_path=source_path,
|
|
1783
|
+
suggested_pattern=suggested_scope_pattern_for(rel),
|
|
1784
|
+
)
|
|
1785
|
+
)
|
|
1786
|
+
effective_changed_files.append(rel)
|
|
1787
|
+
continue
|
|
1788
|
+
if _likely_legitimate_missing_scope(rel, allowed_patterns=allowed_patterns):
|
|
1789
|
+
classification = SCOPE_CLASS_LIKELY_MISSING_SCOPE
|
|
1790
|
+
reason_code = "same_area_code_path_outside_scope"
|
|
1791
|
+
recommended_action = "create_scope_delta_proposal"
|
|
1792
|
+
else:
|
|
1793
|
+
classification = SCOPE_CLASS_DANGEROUS_UNRELATED
|
|
1794
|
+
reason_code = "path_not_covered_by_task_scope"
|
|
1795
|
+
recommended_action = "reject_and_replan"
|
|
1796
|
+
diagnostics.append(
|
|
1797
|
+
ScopeViolationDiagnostic(
|
|
1798
|
+
path=rel,
|
|
1799
|
+
classification=classification,
|
|
1800
|
+
reason_code=reason_code,
|
|
1801
|
+
evidence=f"{rel} is not covered by allowed scope {allowed_patterns or ['(none)']}",
|
|
1802
|
+
recommended_action=recommended_action,
|
|
1803
|
+
allowed=False,
|
|
1804
|
+
suggested_pattern=suggested_scope_pattern_for(rel),
|
|
1805
|
+
)
|
|
1806
|
+
)
|
|
1807
|
+
blocking_paths.append(rel)
|
|
1808
|
+
effective_changed_files.append(rel)
|
|
1809
|
+
|
|
1810
|
+
return ScopeAssessment(
|
|
1811
|
+
ok=not blocking_paths,
|
|
1812
|
+
blocking_paths=blocking_paths,
|
|
1813
|
+
diagnostics=diagnostics,
|
|
1814
|
+
effective_changed_files=effective_changed_files,
|
|
1815
|
+
expanded_allowed_scope=expanded_patterns,
|
|
1816
|
+
companion_expansions=companion_expansions,
|
|
1817
|
+
in_scope_paths=in_scope_paths,
|
|
1818
|
+
adjacent_paths=adjacent_paths,
|
|
1819
|
+
amendments=amendments,
|
|
1820
|
+
# Protected paths are deliberately absent: no scope patch may legitimise them.
|
|
1821
|
+
suggested_scope_patterns=suggested_scope_patterns_for(
|
|
1822
|
+
[
|
|
1823
|
+
item.path
|
|
1824
|
+
for item in diagnostics
|
|
1825
|
+
if not item.allowed and item.triage == SCOPE_TRIAGE_UNRELATED
|
|
1826
|
+
]
|
|
1827
|
+
),
|
|
1828
|
+
)
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def check_scope(
|
|
1832
|
+
changed_files: list[str],
|
|
1833
|
+
allowed_patterns: list[str],
|
|
1834
|
+
*,
|
|
1835
|
+
root: Path | None = None,
|
|
1836
|
+
) -> tuple[bool, list[str]]:
|
|
1837
|
+
assessment = assess_scope_changes(changed_files, allowed_patterns, root=root)
|
|
1838
|
+
return assessment.ok, assessment.blocking_paths
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _git_untracked_leaf_files(root: Path, *, include_non_material: bool = False) -> list[str]:
|
|
1842
|
+
if shutil.which("git") is None:
|
|
1843
|
+
return []
|
|
1844
|
+
proc = _run_git_capture(
|
|
1845
|
+
root,
|
|
1846
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
1847
|
+
)
|
|
1848
|
+
if proc is None:
|
|
1849
|
+
return []
|
|
1850
|
+
if proc.returncode != 0:
|
|
1851
|
+
return []
|
|
1852
|
+
|
|
1853
|
+
stdout = proc.stdout
|
|
1854
|
+
raw_stdout = (
|
|
1855
|
+
stdout.encode("utf-8", errors="surrogateescape")
|
|
1856
|
+
if isinstance(stdout, str)
|
|
1857
|
+
else (stdout or b"")
|
|
1858
|
+
)
|
|
1859
|
+
seen: set[str] = set()
|
|
1860
|
+
changed: list[str] = []
|
|
1861
|
+
for item in raw_stdout.split(b"\0"):
|
|
1862
|
+
if not item:
|
|
1863
|
+
continue
|
|
1864
|
+
rel = _normalize_path(item.decode("utf-8", errors="surrogateescape").strip('"'))
|
|
1865
|
+
if not rel or rel in seen:
|
|
1866
|
+
continue
|
|
1867
|
+
if not include_non_material and is_non_material_untracked_path(rel):
|
|
1868
|
+
continue
|
|
1869
|
+
seen.add(rel)
|
|
1870
|
+
changed.append(rel)
|
|
1871
|
+
return changed
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def list_untracked_packaging_metadata_paths(root: Path) -> list[str]:
|
|
1875
|
+
return [
|
|
1876
|
+
rel
|
|
1877
|
+
for rel in _git_untracked_leaf_files(root, include_non_material=True)
|
|
1878
|
+
if is_non_material_untracked_path(rel)
|
|
1879
|
+
]
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def list_changed_files_including_untracked(root: Path) -> list[str]:
|
|
1883
|
+
if shutil.which("git") is None:
|
|
1884
|
+
return []
|
|
1885
|
+
proc = _run_git_capture(root, ["status", "--porcelain"], text=True)
|
|
1886
|
+
if proc is None:
|
|
1887
|
+
return []
|
|
1888
|
+
if proc.returncode != 0:
|
|
1889
|
+
return []
|
|
1890
|
+
seen: set[str] = set()
|
|
1891
|
+
changed: list[str] = []
|
|
1892
|
+
for line in proc.stdout.splitlines():
|
|
1893
|
+
if len(line) < 4:
|
|
1894
|
+
continue
|
|
1895
|
+
if line.startswith("?? "):
|
|
1896
|
+
continue
|
|
1897
|
+
rel = line[3:].strip()
|
|
1898
|
+
if " -> " in rel:
|
|
1899
|
+
rel = rel.split(" -> ", 1)[1].strip()
|
|
1900
|
+
rel = _normalize_path(rel.strip('"'))
|
|
1901
|
+
if not rel or rel in seen:
|
|
1902
|
+
continue
|
|
1903
|
+
seen.add(rel)
|
|
1904
|
+
changed.append(rel)
|
|
1905
|
+
for rel in _git_untracked_leaf_files(root):
|
|
1906
|
+
if rel in seen:
|
|
1907
|
+
continue
|
|
1908
|
+
seen.add(rel)
|
|
1909
|
+
changed.append(rel)
|
|
1910
|
+
return changed
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def resolve_workspace_git_base(root: Path) -> str | None:
|
|
1914
|
+
"""Return the current commit for use as a stable turn-local diff base."""
|
|
1915
|
+
|
|
1916
|
+
if shutil.which("git") is None:
|
|
1917
|
+
return None
|
|
1918
|
+
head = _run_git_capture(
|
|
1919
|
+
root,
|
|
1920
|
+
["rev-parse", "--verify", "HEAD^{commit}"],
|
|
1921
|
+
text=True,
|
|
1922
|
+
)
|
|
1923
|
+
if head is None or head.returncode != 0:
|
|
1924
|
+
return None
|
|
1925
|
+
value = head.stdout.strip()
|
|
1926
|
+
return value or None
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def inspect_workspace_git_diff(
|
|
1930
|
+
root: Path,
|
|
1931
|
+
*,
|
|
1932
|
+
base_ref: str | None = None,
|
|
1933
|
+
) -> WorkspaceGitDiffState:
|
|
1934
|
+
"""Inspect tracked diff paths plus non-scratch untracked files.
|
|
1935
|
+
|
|
1936
|
+
``available=False`` distinguishes an unavailable/non-git workspace from a
|
|
1937
|
+
successfully inspected clean worktree.
|
|
1938
|
+
"""
|
|
1939
|
+
|
|
1940
|
+
if shutil.which("git") is None:
|
|
1941
|
+
return WorkspaceGitDiffState(available=False)
|
|
1942
|
+
|
|
1943
|
+
inside_worktree = _run_git_capture(
|
|
1944
|
+
root,
|
|
1945
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
1946
|
+
text=True,
|
|
1947
|
+
)
|
|
1948
|
+
if (
|
|
1949
|
+
inside_worktree is None
|
|
1950
|
+
or inside_worktree.returncode != 0
|
|
1951
|
+
or inside_worktree.stdout.strip().casefold() != "true"
|
|
1952
|
+
):
|
|
1953
|
+
return WorkspaceGitDiffState(available=False)
|
|
1954
|
+
|
|
1955
|
+
requested_base = str(base_ref or "").strip()
|
|
1956
|
+
head = _run_git_capture(
|
|
1957
|
+
root,
|
|
1958
|
+
["rev-parse", "--verify", requested_base or "HEAD"],
|
|
1959
|
+
text=True,
|
|
1960
|
+
)
|
|
1961
|
+
if head is None:
|
|
1962
|
+
return WorkspaceGitDiffState(available=False)
|
|
1963
|
+
|
|
1964
|
+
tracked_procs: list[subprocess.CompletedProcess[Any] | None]
|
|
1965
|
+
if head.returncode == 0:
|
|
1966
|
+
tracked_procs = [
|
|
1967
|
+
_run_git_capture(
|
|
1968
|
+
root,
|
|
1969
|
+
[
|
|
1970
|
+
"diff",
|
|
1971
|
+
"--name-only",
|
|
1972
|
+
"--no-renames",
|
|
1973
|
+
"-z",
|
|
1974
|
+
requested_base or "HEAD",
|
|
1975
|
+
"--",
|
|
1976
|
+
],
|
|
1977
|
+
)
|
|
1978
|
+
]
|
|
1979
|
+
else:
|
|
1980
|
+
tracked_procs = [
|
|
1981
|
+
_run_git_capture(root, ["ls-files", "--cached", "-z"]),
|
|
1982
|
+
_run_git_capture(
|
|
1983
|
+
root,
|
|
1984
|
+
["diff", "--name-only", "--no-renames", "-z", "--"],
|
|
1985
|
+
),
|
|
1986
|
+
]
|
|
1987
|
+
untracked_proc = _run_git_capture(
|
|
1988
|
+
root,
|
|
1989
|
+
["ls-files", "--others", "--exclude-standard", "-z"],
|
|
1990
|
+
)
|
|
1991
|
+
if (
|
|
1992
|
+
untracked_proc is None
|
|
1993
|
+
or untracked_proc.returncode != 0
|
|
1994
|
+
or any(proc is None or proc.returncode != 0 for proc in tracked_procs)
|
|
1995
|
+
):
|
|
1996
|
+
return WorkspaceGitDiffState(available=False)
|
|
1997
|
+
|
|
1998
|
+
def _nul_paths(proc: subprocess.CompletedProcess[Any]) -> list[str]:
|
|
1999
|
+
stdout = proc.stdout
|
|
2000
|
+
raw_stdout = (
|
|
2001
|
+
stdout.encode("utf-8", errors="surrogateescape")
|
|
2002
|
+
if isinstance(stdout, str)
|
|
2003
|
+
else (stdout or b"")
|
|
2004
|
+
)
|
|
2005
|
+
return [
|
|
2006
|
+
normalized
|
|
2007
|
+
for item in raw_stdout.split(b"\0")
|
|
2008
|
+
if item
|
|
2009
|
+
if (
|
|
2010
|
+
normalized := _normalize_path(
|
|
2011
|
+
item.decode("utf-8", errors="surrogateescape").strip('"')
|
|
2012
|
+
)
|
|
2013
|
+
)
|
|
2014
|
+
]
|
|
2015
|
+
|
|
2016
|
+
tracked_paths = sorted(
|
|
2017
|
+
{path for proc in tracked_procs if proc is not None for path in _nul_paths(proc)}
|
|
2018
|
+
)
|
|
2019
|
+
untracked_source_paths = sorted(
|
|
2020
|
+
{path for path in _nul_paths(untracked_proc) if is_untracked_source_path(path, root=root)}
|
|
2021
|
+
)
|
|
2022
|
+
return WorkspaceGitDiffState(
|
|
2023
|
+
available=True,
|
|
2024
|
+
tracked_diff_paths=tuple(tracked_paths),
|
|
2025
|
+
untracked_source_paths=tuple(untracked_source_paths),
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def inspect_existing_test_edits(
|
|
2030
|
+
root: Path,
|
|
2031
|
+
*,
|
|
2032
|
+
base_ref: str | None = None,
|
|
2033
|
+
) -> ExistingTestEditsState:
|
|
2034
|
+
"""Return edits to test files that existed at the requested base commit.
|
|
2035
|
+
|
|
2036
|
+
Added files are intentionally excluded so agents may add regression coverage without
|
|
2037
|
+
rewriting the repository's existing test expectations.
|
|
2038
|
+
"""
|
|
2039
|
+
|
|
2040
|
+
if shutil.which("git") is None:
|
|
2041
|
+
return ExistingTestEditsState(available=False)
|
|
2042
|
+
|
|
2043
|
+
inside_worktree = _run_git_capture(
|
|
2044
|
+
root,
|
|
2045
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
2046
|
+
text=True,
|
|
2047
|
+
)
|
|
2048
|
+
if (
|
|
2049
|
+
inside_worktree is None
|
|
2050
|
+
or inside_worktree.returncode != 0
|
|
2051
|
+
or inside_worktree.stdout.strip().casefold() != "true"
|
|
2052
|
+
):
|
|
2053
|
+
return ExistingTestEditsState(available=False)
|
|
2054
|
+
|
|
2055
|
+
requested_base = str(base_ref or "").strip()
|
|
2056
|
+
head = _run_git_capture(
|
|
2057
|
+
root,
|
|
2058
|
+
["rev-parse", "--verify", requested_base or "HEAD"],
|
|
2059
|
+
text=True,
|
|
2060
|
+
)
|
|
2061
|
+
if head is None or head.returncode != 0:
|
|
2062
|
+
return ExistingTestEditsState(available=False)
|
|
2063
|
+
|
|
2064
|
+
diff = _run_git_capture(
|
|
2065
|
+
root,
|
|
2066
|
+
[
|
|
2067
|
+
"diff",
|
|
2068
|
+
"--numstat",
|
|
2069
|
+
"--no-renames",
|
|
2070
|
+
"--diff-filter=MDTUXB",
|
|
2071
|
+
"-z",
|
|
2072
|
+
requested_base or "HEAD",
|
|
2073
|
+
"--",
|
|
2074
|
+
],
|
|
2075
|
+
)
|
|
2076
|
+
if diff is None or diff.returncode != 0:
|
|
2077
|
+
return ExistingTestEditsState(available=False)
|
|
2078
|
+
|
|
2079
|
+
stdout = diff.stdout
|
|
2080
|
+
raw_stdout = (
|
|
2081
|
+
stdout.encode("utf-8", errors="surrogateescape")
|
|
2082
|
+
if isinstance(stdout, str)
|
|
2083
|
+
else (stdout or b"")
|
|
2084
|
+
)
|
|
2085
|
+
edits: list[ExistingTestEdit] = []
|
|
2086
|
+
for raw_record in raw_stdout.split(b"\0"):
|
|
2087
|
+
if not raw_record:
|
|
2088
|
+
continue
|
|
2089
|
+
parts = raw_record.split(b"\t", 2)
|
|
2090
|
+
if len(parts) != 3:
|
|
2091
|
+
continue
|
|
2092
|
+
added_raw, deleted_raw, path_raw = parts
|
|
2093
|
+
path = _normalize_path(path_raw.decode("utf-8", errors="surrogateescape").strip('"'))
|
|
2094
|
+
if not is_existing_test_layout_path(path):
|
|
2095
|
+
continue
|
|
2096
|
+
|
|
2097
|
+
def _line_count(value: bytes) -> int | None:
|
|
2098
|
+
try:
|
|
2099
|
+
return int(value)
|
|
2100
|
+
except ValueError:
|
|
2101
|
+
return None
|
|
2102
|
+
|
|
2103
|
+
edits.append(
|
|
2104
|
+
ExistingTestEdit(
|
|
2105
|
+
path=path,
|
|
2106
|
+
added_lines=_line_count(added_raw),
|
|
2107
|
+
deleted_lines=_line_count(deleted_raw),
|
|
2108
|
+
)
|
|
2109
|
+
)
|
|
2110
|
+
|
|
2111
|
+
return ExistingTestEditsState(
|
|
2112
|
+
available=True,
|
|
2113
|
+
edits=tuple(sorted(edits, key=lambda edit: edit.path)),
|
|
2114
|
+
)
|
|
2115
|
+
|
|
2116
|
+
|
|
2117
|
+
def restore_existing_test_paths(
|
|
2118
|
+
root: Path,
|
|
2119
|
+
*,
|
|
2120
|
+
base_ref: str,
|
|
2121
|
+
paths: Collection[str],
|
|
2122
|
+
) -> bool:
|
|
2123
|
+
"""Restore selected tracked test paths from a verified base commit."""
|
|
2124
|
+
|
|
2125
|
+
normalized_paths = sorted(
|
|
2126
|
+
{
|
|
2127
|
+
normalized
|
|
2128
|
+
for raw_path in paths
|
|
2129
|
+
if (normalized := _normalize_path(str(raw_path)).rstrip("/"))
|
|
2130
|
+
and is_existing_test_layout_path(normalized)
|
|
2131
|
+
}
|
|
2132
|
+
)
|
|
2133
|
+
requested_base = str(base_ref or "").strip()
|
|
2134
|
+
if not requested_base or not normalized_paths or shutil.which("git") is None:
|
|
2135
|
+
return False
|
|
2136
|
+
verified_base = _run_git_capture(
|
|
2137
|
+
root,
|
|
2138
|
+
["rev-parse", "--verify", f"{requested_base}^{{commit}}"],
|
|
2139
|
+
text=True,
|
|
2140
|
+
)
|
|
2141
|
+
if verified_base is None or verified_base.returncode != 0:
|
|
2142
|
+
return False
|
|
2143
|
+
restore = _run_git_capture(
|
|
2144
|
+
root,
|
|
2145
|
+
["checkout", verified_base.stdout.strip(), "--", *normalized_paths],
|
|
2146
|
+
text=True,
|
|
2147
|
+
)
|
|
2148
|
+
return restore is not None and restore.returncode == 0
|