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
alysis_code/tools/fs.py
ADDED
|
@@ -0,0 +1,1456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import errno
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from fnmatch import fnmatch
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..file_classification import derived_artifact_reason
|
|
15
|
+
from ..git_safe import build_git_process_env
|
|
16
|
+
from ..runtime_artifacts import RUNTIME_ARTIFACT_DIR_NAMES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FsError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StaleFileError(FsError):
|
|
24
|
+
"""A guarded mutation no longer matches the file state it was prepared from."""
|
|
25
|
+
|
|
26
|
+
code = "stale_file"
|
|
27
|
+
|
|
28
|
+
def __init__(self, path: str) -> None:
|
|
29
|
+
self.path = path
|
|
30
|
+
super().__init__(
|
|
31
|
+
f"stale_file: {path} changed after the operation was prepared; "
|
|
32
|
+
"the file was not modified"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_DEFAULT_IGNORE_DIRS = {
|
|
37
|
+
".git",
|
|
38
|
+
".venv",
|
|
39
|
+
"venv",
|
|
40
|
+
"dist",
|
|
41
|
+
"build",
|
|
42
|
+
".idea",
|
|
43
|
+
".vscode",
|
|
44
|
+
} | set(RUNTIME_ARTIFACT_DIR_NAMES)
|
|
45
|
+
_DEFAULT_READ_LINES_MAX_LINES = 200
|
|
46
|
+
# Byte ceiling for fs_read_lines: far above any sane 200-line source window,
|
|
47
|
+
# far below what a single minified/generated line can inject into the context.
|
|
48
|
+
_DEFAULT_READ_LINES_MAX_BYTES = 48_000
|
|
49
|
+
# Derived artifacts smaller than this are returned whole; the stub would not
|
|
50
|
+
# save anything meaningful.
|
|
51
|
+
_DERIVED_ARTIFACT_STUB_MIN_BYTES = 2_048
|
|
52
|
+
_DERIVED_ARTIFACT_HEAD_BYTES = 1_000
|
|
53
|
+
_DERIVED_ARTIFACT_NOTE = (
|
|
54
|
+
"Content withheld by default: this is a machine-generated artifact whose "
|
|
55
|
+
"full text rarely informs a task relative to its size. The head sample "
|
|
56
|
+
"above is provided for orientation. If this artifact's contents are "
|
|
57
|
+
"genuinely the subject of the task, re-call fs_read with "
|
|
58
|
+
"allow_derived=true, or use fs_read_lines for a bounded range."
|
|
59
|
+
)
|
|
60
|
+
_FS_EDIT_OPERATIONS = {
|
|
61
|
+
"replace_exact",
|
|
62
|
+
"insert_before_exact",
|
|
63
|
+
"insert_after_exact",
|
|
64
|
+
"replace_lines",
|
|
65
|
+
"insert_before_line",
|
|
66
|
+
"insert_after_line",
|
|
67
|
+
"append",
|
|
68
|
+
"prepend",
|
|
69
|
+
}
|
|
70
|
+
_FS_EDIT_OPERATION_ALIASES = {
|
|
71
|
+
"replace": "replace_exact",
|
|
72
|
+
}
|
|
73
|
+
_DEFAULT_FS_READ_MAX_BYTES = 12_000
|
|
74
|
+
_DEFAULT_FS_LIST_MAX_RESULTS = 150
|
|
75
|
+
_GIT_PROBE_TIMEOUT_S = 2.0
|
|
76
|
+
_SAFE_ENV_TEMPLATE_SUFFIXES = (
|
|
77
|
+
".example",
|
|
78
|
+
".sample",
|
|
79
|
+
".template",
|
|
80
|
+
".dist",
|
|
81
|
+
".defaults",
|
|
82
|
+
)
|
|
83
|
+
_PRIVATE_KEY_SUFFIXES = (".key", ".pem", ".p12", ".pfx", ".ppk", ".jks", ".keystore")
|
|
84
|
+
_PRIVATE_KEY_NAMES = {
|
|
85
|
+
"id_dsa",
|
|
86
|
+
"id_ecdsa",
|
|
87
|
+
"id_ed25519",
|
|
88
|
+
"id_rsa",
|
|
89
|
+
}
|
|
90
|
+
_CREDENTIAL_FILE_NAMES = {
|
|
91
|
+
".git-credentials",
|
|
92
|
+
".netrc",
|
|
93
|
+
".npmrc",
|
|
94
|
+
".pypirc",
|
|
95
|
+
"application_default_credentials.json",
|
|
96
|
+
"auth.json",
|
|
97
|
+
"credentials",
|
|
98
|
+
"credentials.json",
|
|
99
|
+
"credentials.tfrc.json",
|
|
100
|
+
"dockerconfigjson",
|
|
101
|
+
"kubeconfig",
|
|
102
|
+
"netrc",
|
|
103
|
+
"service-account.json",
|
|
104
|
+
"service_account.json",
|
|
105
|
+
}
|
|
106
|
+
_SENSITIVE_DIRECTORY_NAMES = {
|
|
107
|
+
".aws",
|
|
108
|
+
".azure",
|
|
109
|
+
".docker",
|
|
110
|
+
".git",
|
|
111
|
+
".gnupg",
|
|
112
|
+
".kube",
|
|
113
|
+
".ssh",
|
|
114
|
+
".alysis",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class SensitivePathClassification:
|
|
120
|
+
sensitive: bool
|
|
121
|
+
category: str | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class FilePrecondition:
|
|
126
|
+
path: str
|
|
127
|
+
exists: bool
|
|
128
|
+
content_sha256: str | None
|
|
129
|
+
identity_sha256: str | None
|
|
130
|
+
mode: int | None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class PreparedFsWrite:
|
|
135
|
+
root_obj: Path
|
|
136
|
+
path: str
|
|
137
|
+
path_obj: Path
|
|
138
|
+
content: str
|
|
139
|
+
precondition: FilePrecondition
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class PreparedFsEdit:
|
|
144
|
+
root_obj: Path
|
|
145
|
+
path: str
|
|
146
|
+
path_obj: Path
|
|
147
|
+
original_content: str
|
|
148
|
+
updated_content: str
|
|
149
|
+
applied_edits: int
|
|
150
|
+
precondition: FilePrecondition
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def original_content_sha256(self) -> str:
|
|
154
|
+
return str(self.precondition.content_sha256 or "")
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def original_file_identity_sha256(self) -> str:
|
|
158
|
+
return str(self.precondition.identity_sha256 or "")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def classify_sensitive_path(path: str | os.PathLike[str]) -> SensitivePathClassification:
|
|
162
|
+
"""Classify credential-bearing paths without opening or inspecting their content."""
|
|
163
|
+
|
|
164
|
+
normalized = os.fspath(path).replace("\\", "/").strip("/")
|
|
165
|
+
parts = [part.casefold() for part in normalized.split("/") if part]
|
|
166
|
+
if not parts:
|
|
167
|
+
return SensitivePathClassification(False)
|
|
168
|
+
name = parts[-1]
|
|
169
|
+
|
|
170
|
+
if any(part in _SENSITIVE_DIRECTORY_NAMES for part in parts):
|
|
171
|
+
return SensitivePathClassification(True, "credential_directory")
|
|
172
|
+
|
|
173
|
+
if name == ".env" or name.startswith(".env."):
|
|
174
|
+
if name.endswith(_SAFE_ENV_TEMPLATE_SUFFIXES):
|
|
175
|
+
return SensitivePathClassification(False)
|
|
176
|
+
return SensitivePathClassification(True, "environment_file")
|
|
177
|
+
|
|
178
|
+
if name in _PRIVATE_KEY_NAMES or (
|
|
179
|
+
name.endswith(_PRIVATE_KEY_SUFFIXES) and not name.endswith(".pub")
|
|
180
|
+
):
|
|
181
|
+
return SensitivePathClassification(True, "private_key")
|
|
182
|
+
|
|
183
|
+
if name in _CREDENTIAL_FILE_NAMES:
|
|
184
|
+
return SensitivePathClassification(True, "credential_file")
|
|
185
|
+
if name.startswith(("service-account-", "service_account_")) and name.endswith(".json"):
|
|
186
|
+
return SensitivePathClassification(True, "credential_file")
|
|
187
|
+
if name.startswith(("secret.", "secrets.")) or name in {
|
|
188
|
+
"secret",
|
|
189
|
+
"secrets",
|
|
190
|
+
"secrets.json",
|
|
191
|
+
"secrets.yaml",
|
|
192
|
+
"secrets.yml",
|
|
193
|
+
}:
|
|
194
|
+
return SensitivePathClassification(True, "secret_file")
|
|
195
|
+
if len(parts) >= 2 and parts[-2:] == [".aws", "credentials"]:
|
|
196
|
+
return SensitivePathClassification(True, "credential_file")
|
|
197
|
+
if len(parts) >= 2 and parts[-2:] == [".docker", "config.json"]:
|
|
198
|
+
return SensitivePathClassification(True, "credential_file")
|
|
199
|
+
if len(parts) >= 2 and parts[-2:] == [".azure", "accesstokens.json"]:
|
|
200
|
+
return SensitivePathClassification(True, "credential_file")
|
|
201
|
+
return SensitivePathClassification(False)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _content_sha256(data: bytes) -> str:
|
|
205
|
+
return hashlib.sha256(data).hexdigest()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _identity_sha256(path_stat: os.stat_result) -> str:
|
|
209
|
+
identity = ":".join(
|
|
210
|
+
str(value)
|
|
211
|
+
for value in (
|
|
212
|
+
getattr(path_stat, "st_dev", 0),
|
|
213
|
+
getattr(path_stat, "st_ino", 0),
|
|
214
|
+
path_stat.st_size,
|
|
215
|
+
getattr(path_stat, "st_mtime_ns", int(path_stat.st_mtime * 1_000_000_000)),
|
|
216
|
+
getattr(path_stat, "st_ctime_ns", int(path_stat.st_ctime * 1_000_000_000)),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return hashlib.sha256(identity.encode("ascii", errors="strict")).hexdigest()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _snapshot_existing_file(path_obj: Path, user_path: str) -> tuple[bytes, FilePrecondition]:
|
|
223
|
+
try:
|
|
224
|
+
with path_obj.open("rb") as handle:
|
|
225
|
+
before = os.fstat(handle.fileno())
|
|
226
|
+
if not stat.S_ISREG(before.st_mode):
|
|
227
|
+
raise FsError(f"Is not a regular file: {user_path}")
|
|
228
|
+
data = handle.read()
|
|
229
|
+
after = os.fstat(handle.fileno())
|
|
230
|
+
except FileNotFoundError as exc:
|
|
231
|
+
raise FsError(f"Not found: {user_path}") from exc
|
|
232
|
+
if _identity_sha256(before) != _identity_sha256(after):
|
|
233
|
+
raise StaleFileError(user_path)
|
|
234
|
+
return data, FilePrecondition(
|
|
235
|
+
path=user_path,
|
|
236
|
+
exists=True,
|
|
237
|
+
content_sha256=_content_sha256(data),
|
|
238
|
+
identity_sha256=_identity_sha256(after),
|
|
239
|
+
mode=stat.S_IMODE(after.st_mode),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def capture_file_precondition(*, root: Path, path: str) -> FilePrecondition:
|
|
244
|
+
path_obj = _resolve_under_root(root, path)
|
|
245
|
+
try:
|
|
246
|
+
data, precondition = _snapshot_existing_file(path_obj, path)
|
|
247
|
+
except FsError:
|
|
248
|
+
if path_obj.exists():
|
|
249
|
+
raise
|
|
250
|
+
return FilePrecondition(
|
|
251
|
+
path=path,
|
|
252
|
+
exists=False,
|
|
253
|
+
content_sha256=None,
|
|
254
|
+
identity_sha256=None,
|
|
255
|
+
mode=None,
|
|
256
|
+
)
|
|
257
|
+
_ = data
|
|
258
|
+
return precondition
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def assert_file_precondition(*, root: Path, precondition: FilePrecondition) -> None:
|
|
262
|
+
path_obj = _resolve_under_root(root, precondition.path)
|
|
263
|
+
if not precondition.exists:
|
|
264
|
+
if path_obj.exists():
|
|
265
|
+
raise StaleFileError(precondition.path)
|
|
266
|
+
return
|
|
267
|
+
try:
|
|
268
|
+
_data, current = _snapshot_existing_file(path_obj, precondition.path)
|
|
269
|
+
except FsError as exc:
|
|
270
|
+
if isinstance(exc, StaleFileError):
|
|
271
|
+
raise
|
|
272
|
+
raise StaleFileError(precondition.path) from exc
|
|
273
|
+
if (
|
|
274
|
+
current.content_sha256 != precondition.content_sha256
|
|
275
|
+
or current.identity_sha256 != precondition.identity_sha256
|
|
276
|
+
):
|
|
277
|
+
raise StaleFileError(precondition.path)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _resolve_under_root(root: Path, user_path: str) -> Path:
|
|
281
|
+
root_abs = root.resolve()
|
|
282
|
+
p = (root_abs / user_path).resolve()
|
|
283
|
+
try:
|
|
284
|
+
p.relative_to(root_abs)
|
|
285
|
+
except ValueError as e:
|
|
286
|
+
raise FsError(f"Path escapes root: {user_path}") from e
|
|
287
|
+
return p
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _count_text_lines(path: Path) -> int:
|
|
291
|
+
_total = 0
|
|
292
|
+
with path.open("r", encoding="utf-8", errors="replace", newline="") as fh:
|
|
293
|
+
for _total, _line in enumerate(fh, start=1):
|
|
294
|
+
pass
|
|
295
|
+
return _total
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _truncated_line_ranges(
|
|
299
|
+
*,
|
|
300
|
+
start_line: int,
|
|
301
|
+
end_line: int,
|
|
302
|
+
total_lines: int,
|
|
303
|
+
next_max_lines: int,
|
|
304
|
+
) -> dict[str, Any]:
|
|
305
|
+
next_start = end_line + 1
|
|
306
|
+
next_range = None
|
|
307
|
+
if next_start <= total_lines:
|
|
308
|
+
next_range = {
|
|
309
|
+
"start_line": next_start,
|
|
310
|
+
"end_line": min(total_lines, next_start + max(1, next_max_lines) - 1),
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
"total_lines": total_lines,
|
|
314
|
+
"returned_range": {"start_line": start_line, "end_line": end_line},
|
|
315
|
+
"next_range": next_range,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fs_read(
|
|
320
|
+
*,
|
|
321
|
+
root: Path,
|
|
322
|
+
path: str,
|
|
323
|
+
max_bytes: int = _DEFAULT_FS_READ_MAX_BYTES,
|
|
324
|
+
allow_derived: bool = False,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
p = _resolve_under_root(root, path)
|
|
327
|
+
if not p.exists():
|
|
328
|
+
raise FsError(f"Not found: {path}")
|
|
329
|
+
if p.is_dir():
|
|
330
|
+
raise FsError(f"Is a directory: {path}")
|
|
331
|
+
|
|
332
|
+
if not allow_derived:
|
|
333
|
+
derived_reason = derived_artifact_reason(path)
|
|
334
|
+
if derived_reason is not None:
|
|
335
|
+
try:
|
|
336
|
+
size_bytes: int | None = p.stat().st_size
|
|
337
|
+
except OSError:
|
|
338
|
+
size_bytes = None
|
|
339
|
+
if size_bytes is None or size_bytes > _DERIVED_ARTIFACT_STUB_MIN_BYTES:
|
|
340
|
+
# The head sample honors an explicit smaller max_bytes: the
|
|
341
|
+
# caller's ceiling bounds every read shape, stub included.
|
|
342
|
+
with p.open("rb") as fh:
|
|
343
|
+
head = fh.read(min(_DERIVED_ARTIFACT_HEAD_BYTES, max(0, max_bytes)))
|
|
344
|
+
content = head.decode("utf-8", errors="ignore")
|
|
345
|
+
returned_end_line = content.count("\n")
|
|
346
|
+
if content and not content.endswith("\n"):
|
|
347
|
+
returned_end_line += 1
|
|
348
|
+
# ``truncated`` is always True for a stub so downstream caches
|
|
349
|
+
# never treat the head sample as the complete file content.
|
|
350
|
+
result = {
|
|
351
|
+
"path": path,
|
|
352
|
+
"content": content,
|
|
353
|
+
"truncated": True,
|
|
354
|
+
"bytes_read": len(head),
|
|
355
|
+
"max_bytes": max_bytes,
|
|
356
|
+
"derived_artifact": True,
|
|
357
|
+
"derived_artifact_reason": derived_reason,
|
|
358
|
+
"size_bytes": size_bytes,
|
|
359
|
+
"note": _DERIVED_ARTIFACT_NOTE,
|
|
360
|
+
}
|
|
361
|
+
if content and not content.endswith("\n"):
|
|
362
|
+
result["line_clipped"] = True
|
|
363
|
+
result.update(
|
|
364
|
+
_truncated_line_ranges(
|
|
365
|
+
start_line=1,
|
|
366
|
+
end_line=returned_end_line,
|
|
367
|
+
total_lines=_count_text_lines(p),
|
|
368
|
+
next_max_lines=_DEFAULT_READ_LINES_MAX_LINES,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
return result
|
|
372
|
+
|
|
373
|
+
# Read only what we need (+1 byte lookahead for truncation detection).
|
|
374
|
+
with p.open("rb") as fh:
|
|
375
|
+
data = fh.read(max_bytes + 1)
|
|
376
|
+
truncated = len(data) > max_bytes
|
|
377
|
+
if truncated:
|
|
378
|
+
data = data[:max_bytes]
|
|
379
|
+
text = data.decode("utf-8", errors="replace")
|
|
380
|
+
result = {
|
|
381
|
+
"path": path,
|
|
382
|
+
"content": text,
|
|
383
|
+
"truncated": truncated,
|
|
384
|
+
"bytes_read": len(data),
|
|
385
|
+
"max_bytes": max_bytes,
|
|
386
|
+
}
|
|
387
|
+
if truncated:
|
|
388
|
+
if text and not text.endswith("\n"):
|
|
389
|
+
result["line_clipped"] = True
|
|
390
|
+
returned_end_line = text.count("\n")
|
|
391
|
+
if text and not text.endswith("\n"):
|
|
392
|
+
returned_end_line += 1
|
|
393
|
+
result.update(
|
|
394
|
+
_truncated_line_ranges(
|
|
395
|
+
start_line=1,
|
|
396
|
+
end_line=returned_end_line,
|
|
397
|
+
total_lines=_count_text_lines(p),
|
|
398
|
+
next_max_lines=_DEFAULT_READ_LINES_MAX_LINES,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def fs_read_lines(
|
|
405
|
+
*,
|
|
406
|
+
root: Path,
|
|
407
|
+
path: str,
|
|
408
|
+
start_line: int,
|
|
409
|
+
end_line: int | None = None,
|
|
410
|
+
max_lines: int = _DEFAULT_READ_LINES_MAX_LINES,
|
|
411
|
+
include_line_numbers: bool = True,
|
|
412
|
+
max_bytes: int = _DEFAULT_READ_LINES_MAX_BYTES,
|
|
413
|
+
) -> dict[str, Any]:
|
|
414
|
+
if start_line < 1:
|
|
415
|
+
raise FsError(f"Invalid start_line: {start_line} (must be >= 1)")
|
|
416
|
+
if end_line is not None and end_line < start_line:
|
|
417
|
+
raise FsError(
|
|
418
|
+
f"Invalid line range: end_line ({end_line}) must be >= start_line ({start_line})"
|
|
419
|
+
)
|
|
420
|
+
if max_lines < 1:
|
|
421
|
+
raise FsError(f"Invalid max_lines: {max_lines} (must be >= 1)")
|
|
422
|
+
if max_bytes < 1:
|
|
423
|
+
raise FsError(f"Invalid max_bytes: {max_bytes} (must be >= 1)")
|
|
424
|
+
|
|
425
|
+
p = _resolve_under_root(root, path)
|
|
426
|
+
if not p.exists():
|
|
427
|
+
raise FsError(f"Not found: {path}")
|
|
428
|
+
if p.is_dir():
|
|
429
|
+
raise FsError(f"Is a directory: {path}")
|
|
430
|
+
|
|
431
|
+
requested_end_line = end_line
|
|
432
|
+
effective_end_line = start_line + max_lines - 1
|
|
433
|
+
if requested_end_line is not None:
|
|
434
|
+
effective_end_line = min(effective_end_line, requested_end_line)
|
|
435
|
+
|
|
436
|
+
content_lines: list[str] = []
|
|
437
|
+
actual_end_line = start_line - 1
|
|
438
|
+
total_lines: int | None = None
|
|
439
|
+
lines_seen = 0
|
|
440
|
+
truncated = False
|
|
441
|
+
bytes_used = 0
|
|
442
|
+
byte_truncated = False
|
|
443
|
+
line_clipped = False
|
|
444
|
+
|
|
445
|
+
# Stream forward to the requested window and only report total_lines when
|
|
446
|
+
# we naturally reach EOF, so focused range reads stay cheap on large files.
|
|
447
|
+
# A byte ceiling bounds the result even when individual lines are enormous
|
|
448
|
+
# (minified bundles, generated single-line files) so one line can never
|
|
449
|
+
# flood the caller's context.
|
|
450
|
+
with p.open("r", encoding="utf-8", errors="replace", newline="") as fh:
|
|
451
|
+
for lineno, raw_line in enumerate(fh, start=1):
|
|
452
|
+
lines_seen = lineno
|
|
453
|
+
if lineno < start_line:
|
|
454
|
+
continue
|
|
455
|
+
if lineno > effective_end_line:
|
|
456
|
+
truncated = requested_end_line is None or lineno <= requested_end_line
|
|
457
|
+
break
|
|
458
|
+
|
|
459
|
+
piece = f"{lineno}: {raw_line}" if include_line_numbers else raw_line
|
|
460
|
+
piece_bytes = len(piece.encode("utf-8"))
|
|
461
|
+
if bytes_used + piece_bytes > max_bytes:
|
|
462
|
+
if not content_lines:
|
|
463
|
+
# Even the first requested line exceeds the ceiling: return
|
|
464
|
+
# a clipped head of it rather than nothing. Drop any partial
|
|
465
|
+
# trailing character instead of decoding it with a
|
|
466
|
+
# replacement glyph, so the clipped text never re-encodes to
|
|
467
|
+
# more than the advertised max_bytes.
|
|
468
|
+
clipped = piece.encode("utf-8")[:max_bytes]
|
|
469
|
+
content_lines.append(clipped.decode("utf-8", errors="ignore"))
|
|
470
|
+
actual_end_line = lineno
|
|
471
|
+
line_clipped = True
|
|
472
|
+
byte_truncated = True
|
|
473
|
+
truncated = True
|
|
474
|
+
break
|
|
475
|
+
bytes_used += piece_bytes
|
|
476
|
+
actual_end_line = lineno
|
|
477
|
+
content_lines.append(piece)
|
|
478
|
+
else:
|
|
479
|
+
total_lines = lines_seen
|
|
480
|
+
|
|
481
|
+
if lines_seen < start_line:
|
|
482
|
+
raise FsError(f"Start line {start_line} is beyond end of file ({lines_seen} lines): {path}")
|
|
483
|
+
|
|
484
|
+
if truncated and total_lines is None:
|
|
485
|
+
total_lines = _count_text_lines(p)
|
|
486
|
+
|
|
487
|
+
result: dict[str, Any] = {
|
|
488
|
+
"path": path,
|
|
489
|
+
"start_line": start_line,
|
|
490
|
+
"end_line": actual_end_line,
|
|
491
|
+
"total_lines": total_lines,
|
|
492
|
+
"content": "".join(content_lines),
|
|
493
|
+
"truncated": truncated,
|
|
494
|
+
}
|
|
495
|
+
if byte_truncated:
|
|
496
|
+
result["byte_truncated"] = True
|
|
497
|
+
result["max_bytes"] = max_bytes
|
|
498
|
+
result["note"] = (
|
|
499
|
+
"Byte ceiling reached before the requested line range completed. "
|
|
500
|
+
"Request a narrower range, or raise max_bytes if the full range is "
|
|
501
|
+
"genuinely required."
|
|
502
|
+
)
|
|
503
|
+
if line_clipped:
|
|
504
|
+
result["line_clipped"] = True
|
|
505
|
+
if truncated:
|
|
506
|
+
result.update(
|
|
507
|
+
_truncated_line_ranges(
|
|
508
|
+
start_line=start_line,
|
|
509
|
+
end_line=actual_end_line,
|
|
510
|
+
total_lines=int(total_lines or 0),
|
|
511
|
+
next_max_lines=max_lines,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return result
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _atomic_replace_text(
|
|
518
|
+
path_obj: Path,
|
|
519
|
+
content: str,
|
|
520
|
+
*,
|
|
521
|
+
root: Path,
|
|
522
|
+
precondition: FilePrecondition,
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Durably stage text and commit it without clobbering a racing writer.
|
|
525
|
+
|
|
526
|
+
``os.replace`` is atomic, but it is not compare-and-swap: a writer can
|
|
527
|
+
change the destination after our final comparison and before the replace.
|
|
528
|
+
For an existing file we therefore move the destination to a private sibling,
|
|
529
|
+
verify the *displaced* bytes, and install the staged inode with a no-clobber
|
|
530
|
+
hard link. A racing version is either restored or left at the public path;
|
|
531
|
+
it is never overwritten by the prepared content.
|
|
532
|
+
"""
|
|
533
|
+
|
|
534
|
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
535
|
+
fd = -1
|
|
536
|
+
temp_path: Path | None = None
|
|
537
|
+
for _attempt in range(20):
|
|
538
|
+
candidate = path_obj.parent / f".{path_obj.name}.{secrets.token_hex(8)}.tmp"
|
|
539
|
+
try:
|
|
540
|
+
fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
541
|
+
except FileExistsError:
|
|
542
|
+
continue
|
|
543
|
+
temp_path = candidate
|
|
544
|
+
break
|
|
545
|
+
if temp_path is None or fd < 0:
|
|
546
|
+
raise FsError(f"Could not create temporary file for: {precondition.path}")
|
|
547
|
+
|
|
548
|
+
try:
|
|
549
|
+
if precondition.mode is not None:
|
|
550
|
+
os.chmod(temp_path, precondition.mode)
|
|
551
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle:
|
|
552
|
+
fd = -1
|
|
553
|
+
handle.write(content)
|
|
554
|
+
handle.flush()
|
|
555
|
+
os.fsync(handle.fileno())
|
|
556
|
+
# Keep the cheap early comparison for a useful stale error before any
|
|
557
|
+
# namespace mutation. The displaced-file comparison in
|
|
558
|
+
# ``_commit_staged_regular_file`` closes the check/replace race.
|
|
559
|
+
assert_file_precondition(root=root, precondition=precondition)
|
|
560
|
+
current_path = _resolve_under_root(root, precondition.path)
|
|
561
|
+
if os.path.normcase(os.fspath(current_path)) != os.path.normcase(os.fspath(path_obj)):
|
|
562
|
+
raise StaleFileError(precondition.path)
|
|
563
|
+
_commit_staged_regular_file(
|
|
564
|
+
staged_path=temp_path,
|
|
565
|
+
target_path=path_obj,
|
|
566
|
+
precondition=precondition,
|
|
567
|
+
)
|
|
568
|
+
temp_path = None
|
|
569
|
+
finally:
|
|
570
|
+
if fd >= 0:
|
|
571
|
+
os.close(fd)
|
|
572
|
+
if temp_path is not None:
|
|
573
|
+
try:
|
|
574
|
+
temp_path.unlink()
|
|
575
|
+
except FileNotFoundError:
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _commit_staged_regular_file(
|
|
580
|
+
*,
|
|
581
|
+
staged_path: Path,
|
|
582
|
+
target_path: Path,
|
|
583
|
+
precondition: FilePrecondition,
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Install ``staged_path`` iff ``target_path`` still matches ``precondition``."""
|
|
586
|
+
|
|
587
|
+
displaced_path = _displace_regular_file_if_matches(
|
|
588
|
+
target_path=target_path,
|
|
589
|
+
precondition=precondition,
|
|
590
|
+
)
|
|
591
|
+
if displaced_path is None:
|
|
592
|
+
try:
|
|
593
|
+
_link_regular_file_no_replace(staged_path, target_path)
|
|
594
|
+
except FileExistsError as exc:
|
|
595
|
+
raise StaleFileError(precondition.path) from exc
|
|
596
|
+
staged_path.unlink()
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
try:
|
|
600
|
+
_link_regular_file_no_replace(staged_path, target_path)
|
|
601
|
+
except FileExistsError as exc:
|
|
602
|
+
# Another writer claimed the public name after we moved aside the
|
|
603
|
+
# expected version. Their version wins; the prepared write fails.
|
|
604
|
+
displaced_path.unlink(missing_ok=True)
|
|
605
|
+
raise StaleFileError(precondition.path) from exc
|
|
606
|
+
except OSError:
|
|
607
|
+
if not _restore_displaced_no_replace(displaced_path, target_path):
|
|
608
|
+
raise FsError(
|
|
609
|
+
f"Unable to install or safely restore {precondition.path}; "
|
|
610
|
+
f"the previous version remains at {displaced_path.name}"
|
|
611
|
+
) from None
|
|
612
|
+
raise
|
|
613
|
+
staged_path.unlink()
|
|
614
|
+
displaced_path.unlink()
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _displace_regular_file_if_matches(
|
|
618
|
+
*, target_path: Path, precondition: FilePrecondition
|
|
619
|
+
) -> Path | None:
|
|
620
|
+
"""Move a matching file aside, restoring it when the displaced bytes are stale."""
|
|
621
|
+
|
|
622
|
+
if not precondition.exists:
|
|
623
|
+
if os.path.lexists(target_path):
|
|
624
|
+
raise StaleFileError(precondition.path)
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
displaced_path = _reserve_displaced_path(target_path)
|
|
628
|
+
target_was_displaced = False
|
|
629
|
+
try:
|
|
630
|
+
try:
|
|
631
|
+
os.replace(target_path, displaced_path)
|
|
632
|
+
target_was_displaced = True
|
|
633
|
+
except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as exc:
|
|
634
|
+
raise StaleFileError(precondition.path) from exc
|
|
635
|
+
if _displaced_file_matches(displaced_path, precondition):
|
|
636
|
+
return displaced_path
|
|
637
|
+
if not _restore_displaced_no_replace(displaced_path, target_path):
|
|
638
|
+
raise FsError(
|
|
639
|
+
f"A concurrent edit was preserved at {precondition.path}; "
|
|
640
|
+
f"the displaced version remains at {displaced_path.name}"
|
|
641
|
+
)
|
|
642
|
+
raise StaleFileError(precondition.path)
|
|
643
|
+
finally:
|
|
644
|
+
if not target_was_displaced:
|
|
645
|
+
displaced_path.unlink(missing_ok=True)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _reserve_displaced_path(target_path: Path) -> Path:
|
|
649
|
+
for _attempt in range(20):
|
|
650
|
+
candidate = target_path.parent / (f".{target_path.name}.{secrets.token_hex(8)}.displaced")
|
|
651
|
+
try:
|
|
652
|
+
fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
653
|
+
except FileExistsError:
|
|
654
|
+
continue
|
|
655
|
+
os.close(fd)
|
|
656
|
+
return candidate
|
|
657
|
+
raise FsError(f"Could not reserve a displaced-file path for: {target_path.name}")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _displaced_file_matches(path: Path, precondition: FilePrecondition) -> bool:
|
|
661
|
+
try:
|
|
662
|
+
data, current = _snapshot_existing_file(path, precondition.path)
|
|
663
|
+
except (FsError, OSError):
|
|
664
|
+
return False
|
|
665
|
+
# Rename metadata is not stable across all supported filesystems, so compare
|
|
666
|
+
# the durable semantics that matter: exact bytes, regular-file type, and mode.
|
|
667
|
+
return (
|
|
668
|
+
current.content_sha256 == precondition.content_sha256
|
|
669
|
+
and current.mode == precondition.mode
|
|
670
|
+
and _content_sha256(data) == precondition.content_sha256
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _link_regular_file_no_replace(source: Path, destination: Path) -> None:
|
|
675
|
+
"""Atomically publish a same-directory regular inode without replacement."""
|
|
676
|
+
|
|
677
|
+
os.link(source, destination, follow_symlinks=False)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _stage_bytes_sibling(target: Path, data: bytes, *, mode: int | None) -> Path:
|
|
681
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
682
|
+
fd = -1
|
|
683
|
+
staged: Path | None = None
|
|
684
|
+
for _attempt in range(20):
|
|
685
|
+
candidate = target.parent / f".{target.name}.{secrets.token_hex(8)}.tmp"
|
|
686
|
+
try:
|
|
687
|
+
fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o666)
|
|
688
|
+
except FileExistsError:
|
|
689
|
+
continue
|
|
690
|
+
staged = candidate
|
|
691
|
+
break
|
|
692
|
+
if staged is None or fd < 0:
|
|
693
|
+
raise FsError(f"Could not create temporary file for: {target.name}")
|
|
694
|
+
try:
|
|
695
|
+
if mode is not None:
|
|
696
|
+
os.chmod(staged, mode)
|
|
697
|
+
with os.fdopen(fd, "wb") as stream:
|
|
698
|
+
fd = -1
|
|
699
|
+
stream.write(data)
|
|
700
|
+
stream.flush()
|
|
701
|
+
os.fsync(stream.fileno())
|
|
702
|
+
return staged
|
|
703
|
+
except Exception:
|
|
704
|
+
if fd >= 0:
|
|
705
|
+
os.close(fd)
|
|
706
|
+
staged.unlink(missing_ok=True)
|
|
707
|
+
raise
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _publish_regular_move_no_replace(source: Path, destination: Path, *, mode: int | None) -> None:
|
|
711
|
+
try:
|
|
712
|
+
_link_regular_file_no_replace(source, destination)
|
|
713
|
+
except FileExistsError:
|
|
714
|
+
raise
|
|
715
|
+
except OSError as exc:
|
|
716
|
+
if exc.errno != errno.EXDEV:
|
|
717
|
+
raise
|
|
718
|
+
# A cross-device move cannot share an inode. Stage a verified byte copy
|
|
719
|
+
# on the destination filesystem and publish that name without clobber.
|
|
720
|
+
data = source.read_bytes()
|
|
721
|
+
staged = _stage_bytes_sibling(destination, data, mode=mode)
|
|
722
|
+
try:
|
|
723
|
+
_link_regular_file_no_replace(staged, destination)
|
|
724
|
+
finally:
|
|
725
|
+
staged.unlink(missing_ok=True)
|
|
726
|
+
source.unlink()
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _restore_displaced_no_replace(displaced: Path, target: Path) -> bool:
|
|
730
|
+
"""Restore a displaced entry only while the public name remains unclaimed."""
|
|
731
|
+
|
|
732
|
+
try:
|
|
733
|
+
info = displaced.lstat()
|
|
734
|
+
if stat.S_ISREG(info.st_mode):
|
|
735
|
+
_link_regular_file_no_replace(displaced, target)
|
|
736
|
+
elif stat.S_ISLNK(info.st_mode):
|
|
737
|
+
os.symlink(os.readlink(displaced), target)
|
|
738
|
+
else:
|
|
739
|
+
return False
|
|
740
|
+
except FileExistsError:
|
|
741
|
+
return False
|
|
742
|
+
except OSError:
|
|
743
|
+
return False
|
|
744
|
+
displaced.unlink()
|
|
745
|
+
return True
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def prepare_fs_write(*, root: Path, path: str, content: str) -> PreparedFsWrite:
|
|
749
|
+
path_obj = _resolve_under_root(root, path)
|
|
750
|
+
if path_obj.exists() and path_obj.is_dir():
|
|
751
|
+
raise FsError(f"Is a directory: {path}")
|
|
752
|
+
return PreparedFsWrite(
|
|
753
|
+
root_obj=root.resolve(),
|
|
754
|
+
path=path,
|
|
755
|
+
path_obj=path_obj,
|
|
756
|
+
content=content,
|
|
757
|
+
precondition=capture_file_precondition(root=root, path=path),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def write_prepared_fs_write(prepared: PreparedFsWrite, *, root: Path) -> dict[str, Any]:
|
|
762
|
+
_atomic_replace_text(
|
|
763
|
+
prepared.path_obj,
|
|
764
|
+
prepared.content,
|
|
765
|
+
root=root,
|
|
766
|
+
precondition=prepared.precondition,
|
|
767
|
+
)
|
|
768
|
+
return {
|
|
769
|
+
"path": prepared.path,
|
|
770
|
+
"bytes": len(prepared.content.encode("utf-8")),
|
|
771
|
+
"created": not prepared.precondition.exists,
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def fs_write(*, root: Path, path: str, content: str) -> dict[str, Any]:
|
|
776
|
+
prepared = prepare_fs_write(root=root, path=path, content=content)
|
|
777
|
+
result = write_prepared_fs_write(prepared, root=root)
|
|
778
|
+
# ``created`` distinguishes a brand-new file from an overwrite. Regression
|
|
779
|
+
# attribution uses it to mark a failing test the agent just authored this
|
|
780
|
+
# turn as signal (agent_authored) rather than a regression.
|
|
781
|
+
return result
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def fs_mkdir(
|
|
785
|
+
*,
|
|
786
|
+
root: Path,
|
|
787
|
+
path: str,
|
|
788
|
+
parents: bool = True,
|
|
789
|
+
exist_ok: bool = True,
|
|
790
|
+
) -> dict[str, Any]:
|
|
791
|
+
p = _resolve_under_root(root, path)
|
|
792
|
+
if p.exists():
|
|
793
|
+
if not p.is_dir():
|
|
794
|
+
raise FsError(f"Target exists as a file: {path}")
|
|
795
|
+
if not exist_ok:
|
|
796
|
+
raise FsError(f"Directory already exists and exist_ok is false: {path}")
|
|
797
|
+
return {
|
|
798
|
+
"path": path,
|
|
799
|
+
"created": False,
|
|
800
|
+
"already_exists": True,
|
|
801
|
+
"parents": bool(parents),
|
|
802
|
+
"exist_ok": bool(exist_ok),
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
try:
|
|
806
|
+
p.mkdir(parents=bool(parents), exist_ok=bool(exist_ok))
|
|
807
|
+
except FileExistsError as e:
|
|
808
|
+
raise FsError(f"Directory already exists and exist_ok is false: {path}") from e
|
|
809
|
+
except OSError as e:
|
|
810
|
+
raise FsError(str(e)) from e
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
"path": path,
|
|
814
|
+
"created": True,
|
|
815
|
+
"already_exists": False,
|
|
816
|
+
"parents": bool(parents),
|
|
817
|
+
"exist_ok": bool(exist_ok),
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _require_edit_string(edit: dict[str, Any], key: str, *, index: int, op: str) -> str:
|
|
822
|
+
value = edit.get(key)
|
|
823
|
+
if not isinstance(value, str):
|
|
824
|
+
raise FsError(f"Edit {index} ({op}) requires string field: {key}")
|
|
825
|
+
return value
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _require_edit_line_number(edit: dict[str, Any], key: str, *, index: int, op: str) -> int:
|
|
829
|
+
value = edit.get(key)
|
|
830
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
831
|
+
raise FsError(f"Edit {index} ({op}) requires integer field: {key}")
|
|
832
|
+
if value < 1:
|
|
833
|
+
raise FsError(f"Edit {index} ({op}) {key} must be >= 1")
|
|
834
|
+
return value
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _optional_expected_match_count(edit: dict[str, Any], *, index: int, op: str) -> int | None:
|
|
838
|
+
value = edit.get("expected_match_count")
|
|
839
|
+
if value is None:
|
|
840
|
+
return None
|
|
841
|
+
if not isinstance(value, int) or value < 0:
|
|
842
|
+
raise FsError(f"Edit {index} ({op}) expected_match_count must be a non-negative integer")
|
|
843
|
+
return value
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _count_matches(content: str, target: str, *, index: int, op: str) -> int:
|
|
847
|
+
if not target:
|
|
848
|
+
raise FsError(f"Edit {index} ({op}) requires a non-empty target")
|
|
849
|
+
return content.count(target)
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def _validate_match_count(
|
|
853
|
+
*,
|
|
854
|
+
count: int,
|
|
855
|
+
expected_count: int | None,
|
|
856
|
+
index: int,
|
|
857
|
+
op: str,
|
|
858
|
+
) -> None:
|
|
859
|
+
if expected_count is None:
|
|
860
|
+
if count == 1:
|
|
861
|
+
return
|
|
862
|
+
if count == 0:
|
|
863
|
+
raise FsError(f"Edit {index} ({op}) target matched 0 times; expected exactly 1")
|
|
864
|
+
raise FsError(
|
|
865
|
+
f"Edit {index} ({op}) target matched {count} times; expected exactly 1. "
|
|
866
|
+
"Set expected_match_count to allow this."
|
|
867
|
+
)
|
|
868
|
+
if count != expected_count:
|
|
869
|
+
raise FsError(
|
|
870
|
+
f"Edit {index} ({op}) target matched {count} times; expected {expected_count}"
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _content_lines(content: str) -> list[str]:
|
|
875
|
+
return content.splitlines(keepends=True)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _validate_line_range(
|
|
879
|
+
*,
|
|
880
|
+
lines: list[str],
|
|
881
|
+
start_line: int,
|
|
882
|
+
end_line: int,
|
|
883
|
+
index: int,
|
|
884
|
+
op: str,
|
|
885
|
+
) -> None:
|
|
886
|
+
total_lines = len(lines)
|
|
887
|
+
if end_line < start_line:
|
|
888
|
+
raise FsError(
|
|
889
|
+
f"Edit {index} ({op}) end_line ({end_line}) must be >= start_line ({start_line})"
|
|
890
|
+
)
|
|
891
|
+
if start_line > total_lines:
|
|
892
|
+
raise FsError(
|
|
893
|
+
f"Edit {index} ({op}) start_line {start_line} is beyond end of file "
|
|
894
|
+
f"({total_lines} lines)"
|
|
895
|
+
)
|
|
896
|
+
if end_line > total_lines:
|
|
897
|
+
raise FsError(
|
|
898
|
+
f"Edit {index} ({op}) end_line {end_line} is beyond end of file ({total_lines} lines)"
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _line_selection(lines: list[str], *, start_line: int, end_line: int) -> str:
|
|
903
|
+
return "".join(lines[start_line - 1 : end_line])
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _canonical_line_text(text: str) -> str:
|
|
907
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _validate_expected_old(
|
|
911
|
+
*,
|
|
912
|
+
selected: str,
|
|
913
|
+
edit: dict[str, Any],
|
|
914
|
+
index: int,
|
|
915
|
+
op: str,
|
|
916
|
+
) -> None:
|
|
917
|
+
expected_old = edit.get("expected_old")
|
|
918
|
+
if expected_old is None:
|
|
919
|
+
return
|
|
920
|
+
if not isinstance(expected_old, str):
|
|
921
|
+
raise FsError(f"Edit {index} ({op}) expected_old must be a string when provided")
|
|
922
|
+
if selected != expected_old and _canonical_line_text(selected) != _canonical_line_text(
|
|
923
|
+
expected_old
|
|
924
|
+
):
|
|
925
|
+
selected_preview = selected[:500].replace("\n", "\\n")
|
|
926
|
+
expected_preview = expected_old[:500].replace("\n", "\\n")
|
|
927
|
+
raise FsError(
|
|
928
|
+
f"Edit {index} ({op}) selected line text did not match expected_old. "
|
|
929
|
+
f"selected={selected_preview!r} expected={expected_preview!r}"
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _apply_line_edit(content: str, edit: dict[str, Any], *, index: int, op: str) -> str:
|
|
934
|
+
lines = _content_lines(content)
|
|
935
|
+
total_lines = len(lines)
|
|
936
|
+
|
|
937
|
+
if op == "replace_lines":
|
|
938
|
+
start_line = _require_edit_line_number(edit, "start_line", index=index, op=op)
|
|
939
|
+
end_line = _require_edit_line_number(edit, "end_line", index=index, op=op)
|
|
940
|
+
_validate_line_range(
|
|
941
|
+
lines=lines,
|
|
942
|
+
start_line=start_line,
|
|
943
|
+
end_line=end_line,
|
|
944
|
+
index=index,
|
|
945
|
+
op=op,
|
|
946
|
+
)
|
|
947
|
+
selected = _line_selection(lines, start_line=start_line, end_line=end_line)
|
|
948
|
+
_validate_expected_old(selected=selected, edit=edit, index=index, op=op)
|
|
949
|
+
replacement = _require_edit_string(edit, "replacement", index=index, op=op)
|
|
950
|
+
return "".join(lines[: start_line - 1]) + replacement + "".join(lines[end_line:])
|
|
951
|
+
|
|
952
|
+
line = _require_edit_line_number(edit, "line", index=index, op=op)
|
|
953
|
+
if line > total_lines:
|
|
954
|
+
raise FsError(
|
|
955
|
+
f"Edit {index} ({op}) line {line} is beyond end of file ({total_lines} lines)"
|
|
956
|
+
)
|
|
957
|
+
insert_content = _require_edit_string(edit, "content", index=index, op=op)
|
|
958
|
+
if op == "insert_before_line":
|
|
959
|
+
return "".join(lines[: line - 1]) + insert_content + "".join(lines[line - 1 :])
|
|
960
|
+
return "".join(lines[:line]) + insert_content + "".join(lines[line:])
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _apply_single_fs_edit(content: str, edit: dict[str, Any], *, index: int) -> str:
|
|
964
|
+
raw_op = edit.get("op")
|
|
965
|
+
if not isinstance(raw_op, str):
|
|
966
|
+
raise FsError(f"Edit {index} is missing required string field: op")
|
|
967
|
+
op = _FS_EDIT_OPERATION_ALIASES.get(raw_op.strip(), raw_op.strip())
|
|
968
|
+
if op not in _FS_EDIT_OPERATIONS:
|
|
969
|
+
allowed = ", ".join(sorted(_FS_EDIT_OPERATIONS))
|
|
970
|
+
raise FsError(f"Edit {index} has unsupported op: {op!r}. Expected one of: {allowed}")
|
|
971
|
+
|
|
972
|
+
if op == "append":
|
|
973
|
+
return content + _require_edit_string(edit, "content", index=index, op=op)
|
|
974
|
+
if op == "prepend":
|
|
975
|
+
return _require_edit_string(edit, "content", index=index, op=op) + content
|
|
976
|
+
if op in {"replace_lines", "insert_before_line", "insert_after_line"}:
|
|
977
|
+
return _apply_line_edit(content, edit, index=index, op=op)
|
|
978
|
+
|
|
979
|
+
target = _require_edit_string(edit, "target", index=index, op=op)
|
|
980
|
+
replacement: str | None = None
|
|
981
|
+
insert_content: str | None = None
|
|
982
|
+
if op == "replace_exact":
|
|
983
|
+
replacement = _require_edit_string(edit, "replacement", index=index, op=op)
|
|
984
|
+
else:
|
|
985
|
+
insert_content = _require_edit_string(edit, "content", index=index, op=op)
|
|
986
|
+
expected_count = _optional_expected_match_count(edit, index=index, op=op)
|
|
987
|
+
count = _count_matches(content, target, index=index, op=op)
|
|
988
|
+
_validate_match_count(count=count, expected_count=expected_count, index=index, op=op)
|
|
989
|
+
if count == 0:
|
|
990
|
+
return content
|
|
991
|
+
|
|
992
|
+
if op == "replace_exact":
|
|
993
|
+
assert replacement is not None
|
|
994
|
+
return content.replace(target, replacement)
|
|
995
|
+
|
|
996
|
+
if op == "insert_before_exact":
|
|
997
|
+
assert insert_content is not None
|
|
998
|
+
return content.replace(target, insert_content + target)
|
|
999
|
+
assert insert_content is not None
|
|
1000
|
+
return content.replace(target, target + insert_content)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def prepare_fs_edit(*, root: Path, path: str, edits: list[dict[str, Any]]) -> PreparedFsEdit:
|
|
1004
|
+
if not isinstance(edits, list) or not edits:
|
|
1005
|
+
raise FsError("edits must be a non-empty array of edit objects")
|
|
1006
|
+
|
|
1007
|
+
path_obj = _resolve_under_root(root, path)
|
|
1008
|
+
if not path_obj.exists():
|
|
1009
|
+
raise FsError(f"Not found: {path}")
|
|
1010
|
+
if path_obj.is_dir():
|
|
1011
|
+
raise FsError(f"Is a directory: {path}")
|
|
1012
|
+
|
|
1013
|
+
original_bytes, precondition = _snapshot_existing_file(path_obj, path)
|
|
1014
|
+
original_content = original_bytes.decode("utf-8", errors="replace")
|
|
1015
|
+
updated_content = original_content
|
|
1016
|
+
for index, raw_edit in enumerate(edits, start=1):
|
|
1017
|
+
if not isinstance(raw_edit, dict):
|
|
1018
|
+
raise FsError(f"Edit {index} must be an object")
|
|
1019
|
+
updated_content = _apply_single_fs_edit(updated_content, raw_edit, index=index)
|
|
1020
|
+
|
|
1021
|
+
return PreparedFsEdit(
|
|
1022
|
+
root_obj=root.resolve(),
|
|
1023
|
+
path=path,
|
|
1024
|
+
path_obj=path_obj,
|
|
1025
|
+
original_content=original_content,
|
|
1026
|
+
updated_content=updated_content,
|
|
1027
|
+
applied_edits=len(edits),
|
|
1028
|
+
precondition=precondition,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def write_prepared_fs_edit(prepared: PreparedFsEdit, *, root: Path | None = None) -> dict[str, Any]:
|
|
1033
|
+
effective_root = (root or prepared.root_obj).resolve()
|
|
1034
|
+
_atomic_replace_text(
|
|
1035
|
+
prepared.path_obj,
|
|
1036
|
+
prepared.updated_content,
|
|
1037
|
+
root=effective_root,
|
|
1038
|
+
precondition=prepared.precondition,
|
|
1039
|
+
)
|
|
1040
|
+
return {
|
|
1041
|
+
"path": prepared.path,
|
|
1042
|
+
"applied_edits": prepared.applied_edits,
|
|
1043
|
+
"changed": prepared.updated_content != prepared.original_content,
|
|
1044
|
+
"bytes": len(prepared.updated_content.encode("utf-8")),
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def fs_edit(*, root: Path, path: str, edits: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1049
|
+
prepared = prepare_fs_edit(root=root, path=path, edits=edits)
|
|
1050
|
+
return write_prepared_fs_edit(prepared, root=root)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _require_existing_file(path_obj: Path, user_path: str) -> None:
|
|
1054
|
+
if not path_obj.exists():
|
|
1055
|
+
raise FsError(f"Not found: {user_path}")
|
|
1056
|
+
if path_obj.is_dir():
|
|
1057
|
+
raise FsError(f"Is a directory: {user_path}")
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _prepare_destination_file(
|
|
1061
|
+
destination_obj: Path, destination_path: str, *, overwrite: bool
|
|
1062
|
+
) -> bool:
|
|
1063
|
+
overwritten = False
|
|
1064
|
+
if destination_obj.exists():
|
|
1065
|
+
if destination_obj.is_dir():
|
|
1066
|
+
raise FsError(f"Destination is a directory: {destination_path}")
|
|
1067
|
+
if not overwrite:
|
|
1068
|
+
raise FsError(f"Destination exists and overwrite is false: {destination_path}")
|
|
1069
|
+
overwritten = True
|
|
1070
|
+
destination_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
1071
|
+
return overwritten
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def fs_move(
|
|
1075
|
+
*,
|
|
1076
|
+
root: Path,
|
|
1077
|
+
source_path: str,
|
|
1078
|
+
destination_path: str,
|
|
1079
|
+
overwrite: bool = False,
|
|
1080
|
+
source_precondition: FilePrecondition | None = None,
|
|
1081
|
+
destination_precondition: FilePrecondition | None = None,
|
|
1082
|
+
) -> dict[str, Any]:
|
|
1083
|
+
source_obj = _resolve_under_root(root, source_path)
|
|
1084
|
+
destination_obj = _resolve_under_root(root, destination_path)
|
|
1085
|
+
if source_obj == destination_obj:
|
|
1086
|
+
raise FsError(f"Source and destination are the same: {source_path}")
|
|
1087
|
+
if source_precondition is not None:
|
|
1088
|
+
assert_file_precondition(root=root, precondition=source_precondition)
|
|
1089
|
+
if destination_precondition is not None:
|
|
1090
|
+
assert_file_precondition(root=root, precondition=destination_precondition)
|
|
1091
|
+
_require_existing_file(source_obj, source_path)
|
|
1092
|
+
overwritten = _prepare_destination_file(
|
|
1093
|
+
destination_obj,
|
|
1094
|
+
destination_path,
|
|
1095
|
+
overwrite=overwrite,
|
|
1096
|
+
)
|
|
1097
|
+
size = source_obj.stat().st_size
|
|
1098
|
+
# Parent creation and destination validation can take observable time.
|
|
1099
|
+
# These checks reject the common approval race cheaply; the displacement
|
|
1100
|
+
# transaction below verifies the actual inode removed from each name.
|
|
1101
|
+
if source_precondition is not None:
|
|
1102
|
+
assert_file_precondition(root=root, precondition=source_precondition)
|
|
1103
|
+
if destination_precondition is not None:
|
|
1104
|
+
assert_file_precondition(root=root, precondition=destination_precondition)
|
|
1105
|
+
effective_source = source_precondition or capture_file_precondition(root=root, path=source_path)
|
|
1106
|
+
effective_destination = destination_precondition or capture_file_precondition(
|
|
1107
|
+
root=root, path=destination_path
|
|
1108
|
+
)
|
|
1109
|
+
source_displaced = _displace_regular_file_if_matches(
|
|
1110
|
+
target_path=source_obj,
|
|
1111
|
+
precondition=effective_source,
|
|
1112
|
+
)
|
|
1113
|
+
assert source_displaced is not None
|
|
1114
|
+
destination_displaced: Path | None = None
|
|
1115
|
+
try:
|
|
1116
|
+
destination_displaced = _displace_regular_file_if_matches(
|
|
1117
|
+
target_path=destination_obj,
|
|
1118
|
+
precondition=effective_destination,
|
|
1119
|
+
)
|
|
1120
|
+
# Detect writes through an already-open source handle before publishing
|
|
1121
|
+
# the displaced inode at its new name.
|
|
1122
|
+
if not _displaced_file_matches(source_displaced, effective_source):
|
|
1123
|
+
raise StaleFileError(source_path)
|
|
1124
|
+
_publish_regular_move_no_replace(
|
|
1125
|
+
source_displaced,
|
|
1126
|
+
destination_obj,
|
|
1127
|
+
mode=effective_source.mode,
|
|
1128
|
+
)
|
|
1129
|
+
except Exception as exc:
|
|
1130
|
+
source_restored = _restore_displaced_no_replace(source_displaced, source_obj)
|
|
1131
|
+
destination_restored = True
|
|
1132
|
+
if destination_displaced is not None:
|
|
1133
|
+
destination_restored = _restore_displaced_no_replace(
|
|
1134
|
+
destination_displaced, destination_obj
|
|
1135
|
+
)
|
|
1136
|
+
if not source_restored or not destination_restored:
|
|
1137
|
+
raise FsError(
|
|
1138
|
+
"The move encountered concurrent changes; newer public content was preserved "
|
|
1139
|
+
"and a displaced recovery copy remains beside the affected file."
|
|
1140
|
+
) from exc
|
|
1141
|
+
raise
|
|
1142
|
+
if destination_displaced is not None:
|
|
1143
|
+
destination_displaced.unlink(missing_ok=True)
|
|
1144
|
+
return {
|
|
1145
|
+
"source_path": source_path,
|
|
1146
|
+
"destination_path": destination_path,
|
|
1147
|
+
"moved": True,
|
|
1148
|
+
"overwritten": overwritten,
|
|
1149
|
+
"bytes": size,
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def fs_copy(
|
|
1154
|
+
*,
|
|
1155
|
+
root: Path,
|
|
1156
|
+
source_path: str,
|
|
1157
|
+
destination_path: str,
|
|
1158
|
+
overwrite: bool = False,
|
|
1159
|
+
source_precondition: FilePrecondition | None = None,
|
|
1160
|
+
destination_precondition: FilePrecondition | None = None,
|
|
1161
|
+
) -> dict[str, Any]:
|
|
1162
|
+
source_obj = _resolve_under_root(root, source_path)
|
|
1163
|
+
destination_obj = _resolve_under_root(root, destination_path)
|
|
1164
|
+
if source_obj == destination_obj:
|
|
1165
|
+
raise FsError(f"Source and destination are the same: {source_path}")
|
|
1166
|
+
if source_precondition is not None:
|
|
1167
|
+
assert_file_precondition(root=root, precondition=source_precondition)
|
|
1168
|
+
if destination_precondition is not None:
|
|
1169
|
+
assert_file_precondition(root=root, precondition=destination_precondition)
|
|
1170
|
+
_require_existing_file(source_obj, source_path)
|
|
1171
|
+
overwritten = _prepare_destination_file(
|
|
1172
|
+
destination_obj,
|
|
1173
|
+
destination_path,
|
|
1174
|
+
overwrite=overwrite,
|
|
1175
|
+
)
|
|
1176
|
+
source_data, current_source = _snapshot_existing_file(source_obj, source_path)
|
|
1177
|
+
effective_source = source_precondition or current_source
|
|
1178
|
+
if (
|
|
1179
|
+
current_source.content_sha256 != effective_source.content_sha256
|
|
1180
|
+
or current_source.identity_sha256 != effective_source.identity_sha256
|
|
1181
|
+
):
|
|
1182
|
+
raise StaleFileError(source_path)
|
|
1183
|
+
effective_destination = destination_precondition or capture_file_precondition(
|
|
1184
|
+
root=root, path=destination_path
|
|
1185
|
+
)
|
|
1186
|
+
if not overwrite and effective_destination.exists:
|
|
1187
|
+
raise FsError(f"Destination exists and overwrite is false: {destination_path}")
|
|
1188
|
+
# The copied bytes are a coherent snapshot. Re-check the source before the
|
|
1189
|
+
# destination commit so an approval-time source edit is reported as stale.
|
|
1190
|
+
assert_file_precondition(root=root, precondition=effective_source)
|
|
1191
|
+
staged = _stage_bytes_sibling(
|
|
1192
|
+
destination_obj,
|
|
1193
|
+
source_data,
|
|
1194
|
+
mode=effective_source.mode,
|
|
1195
|
+
)
|
|
1196
|
+
try:
|
|
1197
|
+
assert_file_precondition(root=root, precondition=effective_destination)
|
|
1198
|
+
_commit_staged_regular_file(
|
|
1199
|
+
staged_path=staged,
|
|
1200
|
+
target_path=destination_obj,
|
|
1201
|
+
precondition=effective_destination,
|
|
1202
|
+
)
|
|
1203
|
+
finally:
|
|
1204
|
+
staged.unlink(missing_ok=True)
|
|
1205
|
+
return {
|
|
1206
|
+
"source_path": source_path,
|
|
1207
|
+
"destination_path": destination_path,
|
|
1208
|
+
"copied": True,
|
|
1209
|
+
"overwritten": overwritten,
|
|
1210
|
+
"bytes": len(source_data),
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def fs_delete(
|
|
1215
|
+
*,
|
|
1216
|
+
root: Path,
|
|
1217
|
+
path: str,
|
|
1218
|
+
precondition: FilePrecondition | None = None,
|
|
1219
|
+
) -> dict[str, Any]:
|
|
1220
|
+
path_obj = _resolve_under_root(root, path)
|
|
1221
|
+
if precondition is not None:
|
|
1222
|
+
assert_file_precondition(root=root, precondition=precondition)
|
|
1223
|
+
_require_existing_file(path_obj, path)
|
|
1224
|
+
size = path_obj.stat().st_size
|
|
1225
|
+
effective_precondition = precondition or capture_file_precondition(root=root, path=path)
|
|
1226
|
+
displaced = _displace_regular_file_if_matches(
|
|
1227
|
+
target_path=path_obj,
|
|
1228
|
+
precondition=effective_precondition,
|
|
1229
|
+
)
|
|
1230
|
+
assert displaced is not None
|
|
1231
|
+
if not _displaced_file_matches(displaced, effective_precondition):
|
|
1232
|
+
if not _restore_displaced_no_replace(displaced, path_obj):
|
|
1233
|
+
raise FsError(
|
|
1234
|
+
f"A concurrent edit was preserved at {path}; "
|
|
1235
|
+
f"the displaced version remains at {displaced.name}"
|
|
1236
|
+
)
|
|
1237
|
+
raise StaleFileError(path)
|
|
1238
|
+
if os.path.lexists(path_obj):
|
|
1239
|
+
# A writer recreated the name after the matching version was removed.
|
|
1240
|
+
# Preserve the new file and fail rather than reporting a clean delete.
|
|
1241
|
+
displaced.unlink(missing_ok=True)
|
|
1242
|
+
raise StaleFileError(path)
|
|
1243
|
+
displaced.unlink()
|
|
1244
|
+
return {
|
|
1245
|
+
"path": path,
|
|
1246
|
+
"deleted": True,
|
|
1247
|
+
"bytes": size,
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _find_git_marker_root(path: Path, *, boundary: Path) -> Path | None:
|
|
1252
|
+
boundary_abs = boundary.resolve()
|
|
1253
|
+
for candidate in (path, *path.parents):
|
|
1254
|
+
candidate_abs = candidate.resolve()
|
|
1255
|
+
try:
|
|
1256
|
+
candidate_abs.relative_to(boundary_abs)
|
|
1257
|
+
except ValueError:
|
|
1258
|
+
return None
|
|
1259
|
+
if (candidate_abs / ".git").exists():
|
|
1260
|
+
return candidate_abs
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _git_repo_root(root: Path, *, boundary: Path) -> Path | None:
|
|
1265
|
+
marker_root = _find_git_marker_root(root, boundary=boundary)
|
|
1266
|
+
if marker_root is None:
|
|
1267
|
+
return None
|
|
1268
|
+
try:
|
|
1269
|
+
cp = subprocess.run(
|
|
1270
|
+
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
|
1271
|
+
check=False,
|
|
1272
|
+
capture_output=True,
|
|
1273
|
+
text=True,
|
|
1274
|
+
env=build_git_process_env(),
|
|
1275
|
+
timeout=_GIT_PROBE_TIMEOUT_S,
|
|
1276
|
+
)
|
|
1277
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1278
|
+
return None
|
|
1279
|
+
if cp.returncode != 0:
|
|
1280
|
+
return None
|
|
1281
|
+
raw_root = cp.stdout.strip()
|
|
1282
|
+
if not raw_root:
|
|
1283
|
+
return marker_root
|
|
1284
|
+
try:
|
|
1285
|
+
repo_root = Path(raw_root).resolve()
|
|
1286
|
+
repo_root.relative_to(boundary.resolve())
|
|
1287
|
+
except (OSError, ValueError):
|
|
1288
|
+
return marker_root
|
|
1289
|
+
return repo_root
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _git_check_ignored(repo_root: Path, rel_paths: list[str]) -> set[str]:
|
|
1293
|
+
if not rel_paths:
|
|
1294
|
+
return set()
|
|
1295
|
+
try:
|
|
1296
|
+
cp = subprocess.run(
|
|
1297
|
+
["git", "-C", str(repo_root), "check-ignore", "--stdin"],
|
|
1298
|
+
input="\n".join(rel_paths) + "\n",
|
|
1299
|
+
check=False,
|
|
1300
|
+
capture_output=True,
|
|
1301
|
+
text=True,
|
|
1302
|
+
env=build_git_process_env(),
|
|
1303
|
+
timeout=_GIT_PROBE_TIMEOUT_S,
|
|
1304
|
+
)
|
|
1305
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1306
|
+
return set()
|
|
1307
|
+
if cp.returncode not in (0, 1): # 1 means "no matches"
|
|
1308
|
+
return _fallback_gitignore_ignored_untracked(repo_root, rel_paths)
|
|
1309
|
+
ignored = {Path(line.strip()).as_posix() for line in cp.stdout.splitlines() if line.strip()}
|
|
1310
|
+
if not ignored:
|
|
1311
|
+
ignored = _fallback_gitignore_ignored_untracked(repo_root, rel_paths)
|
|
1312
|
+
return ignored
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _git_tracked_paths(repo_root: Path, rel_paths: list[str]) -> set[str]:
|
|
1316
|
+
if not rel_paths:
|
|
1317
|
+
return set()
|
|
1318
|
+
try:
|
|
1319
|
+
cp = subprocess.run(
|
|
1320
|
+
["git", "-C", str(repo_root), "ls-files", "-z", "--", *rel_paths],
|
|
1321
|
+
check=False,
|
|
1322
|
+
capture_output=True,
|
|
1323
|
+
text=True,
|
|
1324
|
+
env=build_git_process_env(),
|
|
1325
|
+
timeout=_GIT_PROBE_TIMEOUT_S,
|
|
1326
|
+
)
|
|
1327
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1328
|
+
return set()
|
|
1329
|
+
if cp.returncode != 0:
|
|
1330
|
+
return set()
|
|
1331
|
+
return {Path(item).as_posix() for item in cp.stdout.split("\0") if item}
|
|
1332
|
+
|
|
1333
|
+
|
|
1334
|
+
def _fallback_gitignore_ignored_untracked(repo_root: Path, rel_paths: list[str]) -> set[str]:
|
|
1335
|
+
ignored = _fallback_gitignore_ignored(repo_root, rel_paths)
|
|
1336
|
+
if ignored:
|
|
1337
|
+
ignored.difference_update(_git_tracked_paths(repo_root, rel_paths))
|
|
1338
|
+
return ignored
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _fallback_gitignore_ignored(repo_root: Path, rel_paths: list[str]) -> set[str]:
|
|
1342
|
+
gitignore = repo_root / ".gitignore"
|
|
1343
|
+
try:
|
|
1344
|
+
raw_patterns = gitignore.read_text(encoding="utf-8").splitlines()
|
|
1345
|
+
except OSError:
|
|
1346
|
+
return set()
|
|
1347
|
+
|
|
1348
|
+
ignored: set[str] = set()
|
|
1349
|
+
normalized_paths = [Path(rel).as_posix() for rel in rel_paths]
|
|
1350
|
+
for raw in raw_patterns:
|
|
1351
|
+
pattern = raw.strip()
|
|
1352
|
+
if not pattern or pattern.startswith("#"):
|
|
1353
|
+
continue
|
|
1354
|
+
negated = pattern.startswith("!")
|
|
1355
|
+
if negated:
|
|
1356
|
+
pattern = pattern[1:].strip()
|
|
1357
|
+
if not pattern or pattern.endswith("/"):
|
|
1358
|
+
continue
|
|
1359
|
+
pattern = pattern.lstrip("/").replace("\\", "/")
|
|
1360
|
+
for rel in normalized_paths:
|
|
1361
|
+
name = rel.rsplit("/", 1)[-1]
|
|
1362
|
+
matched = fnmatch(rel, pattern) or ("/" not in pattern and fnmatch(name, pattern))
|
|
1363
|
+
if not matched:
|
|
1364
|
+
continue
|
|
1365
|
+
if negated:
|
|
1366
|
+
ignored.discard(rel)
|
|
1367
|
+
else:
|
|
1368
|
+
ignored.add(rel)
|
|
1369
|
+
return ignored
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def fs_list(
|
|
1373
|
+
*,
|
|
1374
|
+
root: Path,
|
|
1375
|
+
root_path: str = ".",
|
|
1376
|
+
globs: list[str] | None = None,
|
|
1377
|
+
ignore: list[str] | None = None,
|
|
1378
|
+
max_results: int = _DEFAULT_FS_LIST_MAX_RESULTS,
|
|
1379
|
+
) -> dict[str, Any]:
|
|
1380
|
+
base = _resolve_under_root(root, root_path)
|
|
1381
|
+
patterns = globs or ["**/*"]
|
|
1382
|
+
ignore_set = set(ignore or [])
|
|
1383
|
+
|
|
1384
|
+
repo_root = _git_repo_root(base, boundary=root)
|
|
1385
|
+
entries: list[dict[str, Any]] = []
|
|
1386
|
+
truncated = False
|
|
1387
|
+
batch_size = max(256, max_results or 0)
|
|
1388
|
+
pending: list[tuple[Path, str, str | None]] = []
|
|
1389
|
+
|
|
1390
|
+
def _flush_pending() -> bool:
|
|
1391
|
+
nonlocal truncated
|
|
1392
|
+
ignored_by_git: set[str] = set()
|
|
1393
|
+
if repo_root:
|
|
1394
|
+
rels = [rel_git for _, _, rel_git in pending if rel_git]
|
|
1395
|
+
ignored_by_git = _git_check_ignored(repo_root, rels)
|
|
1396
|
+
|
|
1397
|
+
# Count visibility after gitignore filtering so returned entries fill the
|
|
1398
|
+
# visible result window and `truncated` only reflects hidden extra visible files.
|
|
1399
|
+
for path_obj, rel, rel_git in pending:
|
|
1400
|
+
if rel_git and rel_git in ignored_by_git:
|
|
1401
|
+
continue
|
|
1402
|
+
if len(entries) >= max_results:
|
|
1403
|
+
truncated = True
|
|
1404
|
+
return True
|
|
1405
|
+
try:
|
|
1406
|
+
size = path_obj.stat().st_size
|
|
1407
|
+
except OSError:
|
|
1408
|
+
size = None
|
|
1409
|
+
entries.append({"path": rel, "size": size})
|
|
1410
|
+
return False
|
|
1411
|
+
|
|
1412
|
+
for pat in patterns:
|
|
1413
|
+
for p in base.glob(pat):
|
|
1414
|
+
rel_path = p.relative_to(base)
|
|
1415
|
+
rel_parts = rel_path.parts
|
|
1416
|
+
if set(rel_parts) & _DEFAULT_IGNORE_DIRS:
|
|
1417
|
+
continue
|
|
1418
|
+
if any(seg in ignore_set for seg in rel_parts):
|
|
1419
|
+
continue
|
|
1420
|
+
if p.is_dir():
|
|
1421
|
+
continue
|
|
1422
|
+
|
|
1423
|
+
rel = rel_path.as_posix()
|
|
1424
|
+
rel_git: str | None = None
|
|
1425
|
+
if repo_root:
|
|
1426
|
+
try:
|
|
1427
|
+
rel_git_path = os.path.relpath(p, repo_root)
|
|
1428
|
+
except ValueError:
|
|
1429
|
+
rel_git = None
|
|
1430
|
+
else:
|
|
1431
|
+
if rel_git_path in {os.curdir, os.pardir} or rel_git_path.startswith(
|
|
1432
|
+
os.pardir + os.sep
|
|
1433
|
+
):
|
|
1434
|
+
rel_git = None
|
|
1435
|
+
else:
|
|
1436
|
+
rel_git = Path(rel_git_path).as_posix()
|
|
1437
|
+
|
|
1438
|
+
pending.append((p, rel, rel_git))
|
|
1439
|
+
if len(pending) >= batch_size:
|
|
1440
|
+
if _flush_pending():
|
|
1441
|
+
pending.clear()
|
|
1442
|
+
break
|
|
1443
|
+
pending.clear()
|
|
1444
|
+
if truncated:
|
|
1445
|
+
break
|
|
1446
|
+
|
|
1447
|
+
if not truncated and pending:
|
|
1448
|
+
_flush_pending()
|
|
1449
|
+
|
|
1450
|
+
return {
|
|
1451
|
+
"root": os.fspath(base),
|
|
1452
|
+
"entries": entries,
|
|
1453
|
+
"truncated": truncated,
|
|
1454
|
+
"returned_count": len(entries),
|
|
1455
|
+
"max_results": max_results,
|
|
1456
|
+
}
|