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/repo_scan.py
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .file_classification import (
|
|
13
|
+
BROAD_SOURCE_EXTENSIONS,
|
|
14
|
+
CODE_SCAN_SKIP_DIR_NAMES,
|
|
15
|
+
SOURCE_EXTENSIONS_BY_LANGUAGE,
|
|
16
|
+
source_extensions_for_languages,
|
|
17
|
+
)
|
|
18
|
+
from .runtime_artifacts import RUNTIME_ARTIFACT_DIR_NAMES
|
|
19
|
+
from .verification_command_analysis import analyze_verification_command
|
|
20
|
+
from .workspace_context import WorkspaceContext
|
|
21
|
+
from .workspace_provisioning import detect_declared_test_runner, ini_has_section
|
|
22
|
+
|
|
23
|
+
REPO_SCAN_SCHEMA_VERSION = 1
|
|
24
|
+
_MAX_TOP_LEVEL_ENTRIES = 18
|
|
25
|
+
_MAX_README_EXCERPTS = 2
|
|
26
|
+
_MAX_EXCERPT_BYTES = 8192
|
|
27
|
+
_MAX_EXCERPT_CHARS = 1200
|
|
28
|
+
_MAX_EXCERPT_LINES = 24
|
|
29
|
+
_MAX_SUMMARY_FILES = 4
|
|
30
|
+
_MAX_SUMMARY_COMMANDS = 3
|
|
31
|
+
_MAX_MANIFEST_SCAN_DEPTH = 4
|
|
32
|
+
_MAX_MANIFEST_SCAN_DIRS = 160
|
|
33
|
+
_MAX_PYTHON_TEST_SIGNAL_DIRS = 64
|
|
34
|
+
_MAX_PYTHON_TEST_SIGNAL_FILES = 256
|
|
35
|
+
_MAX_REPRESENTATIVE_SOURCE_FILES = 12
|
|
36
|
+
_MAX_SOURCE_DISCOVERY_DIRS = 96
|
|
37
|
+
_MAX_SOURCE_DISCOVERY_DEPTH = 4
|
|
38
|
+
_SKIP_TOP_LEVEL_NAMES = {
|
|
39
|
+
".git",
|
|
40
|
+
".hg",
|
|
41
|
+
".svn",
|
|
42
|
+
"build",
|
|
43
|
+
"dist",
|
|
44
|
+
"node_modules",
|
|
45
|
+
"target",
|
|
46
|
+
"venv",
|
|
47
|
+
} | set(RUNTIME_ARTIFACT_DIR_NAMES)
|
|
48
|
+
_SKIP_RECURSIVE_NAMES = (
|
|
49
|
+
_SKIP_TOP_LEVEL_NAMES
|
|
50
|
+
| set(CODE_SCAN_SKIP_DIR_NAMES)
|
|
51
|
+
| {
|
|
52
|
+
".idea",
|
|
53
|
+
".mypy_cache",
|
|
54
|
+
".pytest_cache",
|
|
55
|
+
".ruff_cache",
|
|
56
|
+
".tox",
|
|
57
|
+
".venv",
|
|
58
|
+
"__pycache__",
|
|
59
|
+
"coverage",
|
|
60
|
+
"vendor",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
_MANIFEST_SPECS: tuple[tuple[str, str], ...] = (
|
|
64
|
+
("pyproject.toml", "python"),
|
|
65
|
+
("requirements.txt", "python"),
|
|
66
|
+
("setup.py", "python"),
|
|
67
|
+
("setup.cfg", "python"),
|
|
68
|
+
("pytest.ini", "python"),
|
|
69
|
+
("tox.ini", "python"),
|
|
70
|
+
("package.json", "node"),
|
|
71
|
+
("pnpm-lock.yaml", "node"),
|
|
72
|
+
("yarn.lock", "node"),
|
|
73
|
+
("package-lock.json", "node"),
|
|
74
|
+
("bun.lockb", "node"),
|
|
75
|
+
("tsconfig.json", "typescript"),
|
|
76
|
+
("pom.xml", "java"),
|
|
77
|
+
("go.mod", "go"),
|
|
78
|
+
("Cargo.toml", "rust"),
|
|
79
|
+
("Makefile", "build"),
|
|
80
|
+
("justfile", "build"),
|
|
81
|
+
("Dockerfile", "docker"),
|
|
82
|
+
("docker-compose.yml", "docker"),
|
|
83
|
+
("docker-compose.yaml", "docker"),
|
|
84
|
+
("compose.yml", "docker"),
|
|
85
|
+
("compose.yaml", "docker"),
|
|
86
|
+
)
|
|
87
|
+
_RECURSIVE_MANIFEST_FILENAMES = {
|
|
88
|
+
"package.json",
|
|
89
|
+
"package-lock.json",
|
|
90
|
+
"pnpm-lock.yaml",
|
|
91
|
+
"yarn.lock",
|
|
92
|
+
"bun.lockb",
|
|
93
|
+
"tsconfig.json",
|
|
94
|
+
"pom.xml",
|
|
95
|
+
}
|
|
96
|
+
_README_NAMES = ("README.md", "README.rst", "README.txt", "README")
|
|
97
|
+
_MAKE_TARGET_RE = re.compile(r"^([A-Za-z0-9_.-]+)\s*::?\s*$")
|
|
98
|
+
_SOURCE_EXTENSIONS_BY_LANGUAGE = SOURCE_EXTENSIONS_BY_LANGUAGE
|
|
99
|
+
_BROAD_SOURCE_EXTENSIONS = BROAD_SOURCE_EXTENSIONS
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class RepoScanResult:
|
|
104
|
+
schema_version: int
|
|
105
|
+
workspace_root: str
|
|
106
|
+
focus_relpath: str
|
|
107
|
+
workspace_kind: str
|
|
108
|
+
git_root: str | None
|
|
109
|
+
has_head_commit: bool
|
|
110
|
+
current_branch: str | None
|
|
111
|
+
top_level_entries: list[dict[str, str]]
|
|
112
|
+
manifests: list[dict[str, str]]
|
|
113
|
+
readme_paths: list[str]
|
|
114
|
+
readme_excerpts: list[dict[str, str]]
|
|
115
|
+
conventions_path: str | None
|
|
116
|
+
conventions_excerpt: str | None
|
|
117
|
+
language_hints: list[str]
|
|
118
|
+
package_hints: list[str]
|
|
119
|
+
likely_test_commands: list[str]
|
|
120
|
+
observed_paths: list[str]
|
|
121
|
+
|
|
122
|
+
def to_dict(self) -> dict[str, Any]:
|
|
123
|
+
return asdict(self)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_dict(cls, raw: dict[str, Any]) -> RepoScanResult:
|
|
127
|
+
return cls(
|
|
128
|
+
schema_version=int(raw.get("schema_version") or 0),
|
|
129
|
+
workspace_root=str(raw.get("workspace_root") or ""),
|
|
130
|
+
focus_relpath=str(raw.get("focus_relpath") or "."),
|
|
131
|
+
workspace_kind=str(raw.get("workspace_kind") or ""),
|
|
132
|
+
git_root=(None if raw.get("git_root") in (None, "") else str(raw.get("git_root"))),
|
|
133
|
+
has_head_commit=bool(raw.get("has_head_commit", False)),
|
|
134
|
+
current_branch=(
|
|
135
|
+
None if raw.get("current_branch") in (None, "") else str(raw.get("current_branch"))
|
|
136
|
+
),
|
|
137
|
+
top_level_entries=_list_of_dicts(raw.get("top_level_entries")),
|
|
138
|
+
manifests=_list_of_dicts(raw.get("manifests")),
|
|
139
|
+
readme_paths=_list_of_strings(raw.get("readme_paths")),
|
|
140
|
+
readme_excerpts=_list_of_dicts(raw.get("readme_excerpts")),
|
|
141
|
+
conventions_path=(
|
|
142
|
+
None
|
|
143
|
+
if raw.get("conventions_path") in (None, "")
|
|
144
|
+
else str(raw.get("conventions_path"))
|
|
145
|
+
),
|
|
146
|
+
conventions_excerpt=(
|
|
147
|
+
None
|
|
148
|
+
if raw.get("conventions_excerpt") in (None, "")
|
|
149
|
+
else str(raw.get("conventions_excerpt"))
|
|
150
|
+
),
|
|
151
|
+
language_hints=_list_of_strings(raw.get("language_hints")),
|
|
152
|
+
package_hints=_list_of_strings(raw.get("package_hints")),
|
|
153
|
+
likely_test_commands=_list_of_strings(raw.get("likely_test_commands")),
|
|
154
|
+
observed_paths=_list_of_strings(raw.get("observed_paths")),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def scan_workspace(*, context: WorkspaceContext) -> RepoScanResult:
|
|
159
|
+
root = context.workspace_root
|
|
160
|
+
search_dirs = _search_dirs(context)
|
|
161
|
+
top_level_entries = _collect_top_level_entries(root)
|
|
162
|
+
manifests = _collect_manifests(root=root, search_dirs=search_dirs)
|
|
163
|
+
readme_paths = _collect_readmes(root=root, search_dirs=search_dirs)
|
|
164
|
+
readme_excerpts = [
|
|
165
|
+
{"path": rel_path, "excerpt": excerpt}
|
|
166
|
+
for rel_path, excerpt in (
|
|
167
|
+
_excerpt_entry(root=root, path=root / rel_path)
|
|
168
|
+
for rel_path in readme_paths[:_MAX_README_EXCERPTS]
|
|
169
|
+
)
|
|
170
|
+
if excerpt
|
|
171
|
+
]
|
|
172
|
+
conventions_path = _find_conventions_path(root=root, search_dirs=search_dirs)
|
|
173
|
+
conventions_excerpt = None
|
|
174
|
+
if conventions_path is not None:
|
|
175
|
+
conventions_excerpt = _read_text_excerpt(root / conventions_path)
|
|
176
|
+
language_hints = _infer_language_hints(manifests=manifests)
|
|
177
|
+
representative_source_paths = _collect_representative_source_paths(
|
|
178
|
+
root=root,
|
|
179
|
+
search_dirs=search_dirs,
|
|
180
|
+
language_hints=language_hints,
|
|
181
|
+
)
|
|
182
|
+
package_hints = _infer_package_hints(root=root, manifests=manifests)
|
|
183
|
+
likely_test_commands = _infer_test_commands(
|
|
184
|
+
root=root,
|
|
185
|
+
search_dirs=search_dirs,
|
|
186
|
+
manifests=manifests,
|
|
187
|
+
)
|
|
188
|
+
observed_paths = _observed_paths(
|
|
189
|
+
top_level_entries=top_level_entries,
|
|
190
|
+
manifests=manifests,
|
|
191
|
+
readme_paths=readme_paths,
|
|
192
|
+
conventions_path=conventions_path,
|
|
193
|
+
representative_source_paths=representative_source_paths,
|
|
194
|
+
)
|
|
195
|
+
return RepoScanResult(
|
|
196
|
+
schema_version=REPO_SCAN_SCHEMA_VERSION,
|
|
197
|
+
workspace_root=os.fspath(root),
|
|
198
|
+
focus_relpath=context.focus_relpath,
|
|
199
|
+
workspace_kind=context.workspace_kind,
|
|
200
|
+
git_root=(os.fspath(context.git_root) if context.git_root is not None else None),
|
|
201
|
+
has_head_commit=context.has_head_commit,
|
|
202
|
+
current_branch=context.current_branch,
|
|
203
|
+
top_level_entries=top_level_entries,
|
|
204
|
+
manifests=manifests,
|
|
205
|
+
readme_paths=readme_paths,
|
|
206
|
+
readme_excerpts=readme_excerpts,
|
|
207
|
+
conventions_path=conventions_path,
|
|
208
|
+
conventions_excerpt=conventions_excerpt,
|
|
209
|
+
language_hints=language_hints,
|
|
210
|
+
package_hints=package_hints,
|
|
211
|
+
likely_test_commands=likely_test_commands,
|
|
212
|
+
observed_paths=observed_paths,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def render_repo_scan_markdown(scan: RepoScanResult) -> str:
|
|
217
|
+
branch_label = scan.current_branch or ("(no HEAD)" if not scan.has_head_commit else "(unknown)")
|
|
218
|
+
lines = [
|
|
219
|
+
"# Workspace Summary",
|
|
220
|
+
"",
|
|
221
|
+
f"- Workspace Root: `{scan.workspace_root}`",
|
|
222
|
+
f"- Focus Directory: `{scan.focus_relpath or '.'}`",
|
|
223
|
+
f"- Workspace Kind: `{scan.workspace_kind}`",
|
|
224
|
+
f"- Git Root: `{scan.git_root or '(none)'}`",
|
|
225
|
+
f"- Current Branch: `{branch_label}`",
|
|
226
|
+
"",
|
|
227
|
+
"## Important Files",
|
|
228
|
+
"",
|
|
229
|
+
]
|
|
230
|
+
important_files: list[str] = []
|
|
231
|
+
important_files.extend(item.get("path", "") for item in scan.manifests[:8])
|
|
232
|
+
important_files.extend(scan.readme_paths[:4])
|
|
233
|
+
if scan.conventions_path:
|
|
234
|
+
important_files.append(scan.conventions_path)
|
|
235
|
+
important_files.extend(_representative_observed_source_paths(scan.observed_paths)[:8])
|
|
236
|
+
important_files = _unique_nonempty(important_files)
|
|
237
|
+
if important_files:
|
|
238
|
+
for rel_path in important_files:
|
|
239
|
+
lines.append(f"- `{rel_path}`")
|
|
240
|
+
else:
|
|
241
|
+
lines.append("- (none)")
|
|
242
|
+
|
|
243
|
+
lines.extend(["", "## Language Hints", ""])
|
|
244
|
+
if scan.language_hints:
|
|
245
|
+
for hint in scan.language_hints:
|
|
246
|
+
lines.append(f"- `{hint}`")
|
|
247
|
+
else:
|
|
248
|
+
lines.append("- (none)")
|
|
249
|
+
|
|
250
|
+
lines.extend(["", "## Likely Verification Commands", ""])
|
|
251
|
+
if scan.likely_test_commands:
|
|
252
|
+
for command in scan.likely_test_commands:
|
|
253
|
+
lines.append(f"- `{command}`")
|
|
254
|
+
else:
|
|
255
|
+
lines.append("- (none)")
|
|
256
|
+
|
|
257
|
+
if scan.readme_excerpts:
|
|
258
|
+
lines.extend(["", "## README Excerpts", ""])
|
|
259
|
+
for item in scan.readme_excerpts:
|
|
260
|
+
rel_path = str(item.get("path") or "").strip() or "(unknown)"
|
|
261
|
+
excerpt = str(item.get("excerpt") or "").strip()
|
|
262
|
+
lines.append(f"### `{rel_path}`")
|
|
263
|
+
lines.append("")
|
|
264
|
+
lines.append(excerpt or "(empty)")
|
|
265
|
+
lines.append("")
|
|
266
|
+
|
|
267
|
+
if scan.conventions_path:
|
|
268
|
+
lines.extend(["## CONVENTIONS.md Excerpt", ""])
|
|
269
|
+
lines.append(f"`{scan.conventions_path}`")
|
|
270
|
+
lines.append("")
|
|
271
|
+
lines.append(scan.conventions_excerpt or "(empty)")
|
|
272
|
+
lines.append("")
|
|
273
|
+
|
|
274
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def render_repo_scan_summary_lines(scan: RepoScanResult) -> list[str]:
|
|
278
|
+
branch_detail = (
|
|
279
|
+
scan.current_branch
|
|
280
|
+
if scan.current_branch
|
|
281
|
+
else ("no HEAD" if not scan.has_head_commit else "unknown branch")
|
|
282
|
+
)
|
|
283
|
+
lines = [
|
|
284
|
+
f"Workspace root: {scan.workspace_root}",
|
|
285
|
+
f"Focus directory: {scan.focus_relpath or '.'}",
|
|
286
|
+
f"Workspace kind: {scan.workspace_kind} ({branch_detail})",
|
|
287
|
+
]
|
|
288
|
+
signals = _unique_nonempty(
|
|
289
|
+
[item.get("path", "") for item in scan.manifests[:_MAX_SUMMARY_FILES]]
|
|
290
|
+
+ scan.readme_paths[:_MAX_SUMMARY_FILES]
|
|
291
|
+
+ ([scan.conventions_path] if scan.conventions_path else [])
|
|
292
|
+
)
|
|
293
|
+
if signals:
|
|
294
|
+
lines.append(f"Important files: {', '.join(signals[:_MAX_SUMMARY_FILES])}")
|
|
295
|
+
if scan.likely_test_commands:
|
|
296
|
+
lines.append(
|
|
297
|
+
"Likely verify: " + ", ".join(scan.likely_test_commands[:_MAX_SUMMARY_COMMANDS])
|
|
298
|
+
)
|
|
299
|
+
return lines
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _list_of_strings(raw: Any) -> list[str]:
|
|
303
|
+
if not isinstance(raw, list):
|
|
304
|
+
return []
|
|
305
|
+
out: list[str] = []
|
|
306
|
+
for item in raw:
|
|
307
|
+
text = str(item or "").strip()
|
|
308
|
+
if text:
|
|
309
|
+
out.append(text)
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _list_of_dicts(raw: Any) -> list[dict[str, str]]:
|
|
314
|
+
if not isinstance(raw, list):
|
|
315
|
+
return []
|
|
316
|
+
out: list[dict[str, str]] = []
|
|
317
|
+
for item in raw:
|
|
318
|
+
if not isinstance(item, dict):
|
|
319
|
+
continue
|
|
320
|
+
out.append({str(key): str(value) for key, value in item.items() if value is not None})
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _search_dirs(context: WorkspaceContext) -> list[Path]:
|
|
325
|
+
dirs = [context.workspace_root]
|
|
326
|
+
if context.focus_path != context.workspace_root:
|
|
327
|
+
dirs.append(context.focus_path)
|
|
328
|
+
return dirs
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _collect_top_level_entries(root: Path) -> list[dict[str, str]]:
|
|
332
|
+
entries: list[dict[str, str]] = []
|
|
333
|
+
for candidate in sorted(root.iterdir(), key=lambda path: path.name.casefold()):
|
|
334
|
+
if candidate.name in _SKIP_TOP_LEVEL_NAMES:
|
|
335
|
+
continue
|
|
336
|
+
kind = "dir" if candidate.is_dir() else ("file" if candidate.is_file() else "other")
|
|
337
|
+
entries.append({"path": candidate.name, "kind": kind})
|
|
338
|
+
if len(entries) >= _MAX_TOP_LEVEL_ENTRIES:
|
|
339
|
+
break
|
|
340
|
+
return entries
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _source_extensions_for_languages(language_hints: list[str]) -> set[str]:
|
|
344
|
+
return source_extensions_for_languages(language_hints)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _collect_representative_source_paths(
|
|
348
|
+
*,
|
|
349
|
+
root: Path,
|
|
350
|
+
search_dirs: list[Path],
|
|
351
|
+
language_hints: list[str],
|
|
352
|
+
) -> list[str]:
|
|
353
|
+
extensions = _source_extensions_for_languages(language_hints)
|
|
354
|
+
seen: set[str] = set()
|
|
355
|
+
out: list[str] = []
|
|
356
|
+
seen_bases: set[Path] = set()
|
|
357
|
+
for base_dir in search_dirs:
|
|
358
|
+
try:
|
|
359
|
+
resolved_base = base_dir.resolve()
|
|
360
|
+
except OSError:
|
|
361
|
+
continue
|
|
362
|
+
if resolved_base in seen_bases:
|
|
363
|
+
continue
|
|
364
|
+
seen_bases.add(resolved_base)
|
|
365
|
+
visited_dirs = 0
|
|
366
|
+
try:
|
|
367
|
+
walker = os.walk(base_dir, topdown=True)
|
|
368
|
+
except OSError:
|
|
369
|
+
continue
|
|
370
|
+
for current_dir, dirnames, filenames in walker:
|
|
371
|
+
visited_dirs += 1
|
|
372
|
+
if visited_dirs > _MAX_SOURCE_DISCOVERY_DIRS:
|
|
373
|
+
dirnames[:] = []
|
|
374
|
+
break
|
|
375
|
+
current_path = Path(current_dir)
|
|
376
|
+
try:
|
|
377
|
+
rel_to_base = current_path.resolve().relative_to(resolved_base)
|
|
378
|
+
except (OSError, ValueError):
|
|
379
|
+
dirnames[:] = []
|
|
380
|
+
continue
|
|
381
|
+
depth = 0 if rel_to_base == Path(".") else len(rel_to_base.parts)
|
|
382
|
+
dirnames[:] = sorted(
|
|
383
|
+
(
|
|
384
|
+
name
|
|
385
|
+
for name in dirnames
|
|
386
|
+
if name not in _SKIP_RECURSIVE_NAMES and not name.startswith(".")
|
|
387
|
+
),
|
|
388
|
+
key=str.casefold,
|
|
389
|
+
)
|
|
390
|
+
if depth >= _MAX_SOURCE_DISCOVERY_DEPTH:
|
|
391
|
+
dirnames[:] = []
|
|
392
|
+
for filename in sorted(filenames, key=str.casefold):
|
|
393
|
+
if filename.startswith("."):
|
|
394
|
+
continue
|
|
395
|
+
candidate = current_path / filename
|
|
396
|
+
if candidate.suffix.casefold() not in extensions:
|
|
397
|
+
continue
|
|
398
|
+
try:
|
|
399
|
+
rel_path = _relpath(root, candidate)
|
|
400
|
+
except (OSError, ValueError):
|
|
401
|
+
continue
|
|
402
|
+
if rel_path in seen:
|
|
403
|
+
continue
|
|
404
|
+
seen.add(rel_path)
|
|
405
|
+
out.append(rel_path)
|
|
406
|
+
if len(out) >= _MAX_REPRESENTATIVE_SOURCE_FILES:
|
|
407
|
+
return out
|
|
408
|
+
return out
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _collect_manifests(*, root: Path, search_dirs: list[Path]) -> list[dict[str, str]]:
|
|
412
|
+
manifests: list[dict[str, str]] = []
|
|
413
|
+
seen: set[str] = set()
|
|
414
|
+
for directory in search_dirs:
|
|
415
|
+
for filename, kind in _MANIFEST_SPECS:
|
|
416
|
+
candidate = directory / filename
|
|
417
|
+
if not candidate.exists() or not candidate.is_file():
|
|
418
|
+
continue
|
|
419
|
+
rel_path = _relpath(root, candidate)
|
|
420
|
+
if rel_path in seen:
|
|
421
|
+
continue
|
|
422
|
+
seen.add(rel_path)
|
|
423
|
+
manifests.append({"path": rel_path, "kind": kind})
|
|
424
|
+
for rel_path, kind in _walk_nested_manifests(root=root, base_dir=directory):
|
|
425
|
+
if rel_path in seen:
|
|
426
|
+
continue
|
|
427
|
+
seen.add(rel_path)
|
|
428
|
+
manifests.append({"path": rel_path, "kind": kind})
|
|
429
|
+
manifests.sort(key=lambda item: item["path"].casefold())
|
|
430
|
+
return manifests
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _walk_nested_manifests(*, root: Path, base_dir: Path) -> list[tuple[str, str]]:
|
|
434
|
+
results: list[tuple[str, str]] = []
|
|
435
|
+
visited_dirs = 0
|
|
436
|
+
manifest_kind_map = dict(_MANIFEST_SPECS)
|
|
437
|
+
try:
|
|
438
|
+
walker = os.walk(base_dir, topdown=True)
|
|
439
|
+
except OSError:
|
|
440
|
+
return results
|
|
441
|
+
|
|
442
|
+
for current_dir, dirnames, filenames in walker:
|
|
443
|
+
current_path = Path(current_dir)
|
|
444
|
+
try:
|
|
445
|
+
rel_to_base = current_path.resolve().relative_to(base_dir.resolve())
|
|
446
|
+
except ValueError:
|
|
447
|
+
dirnames[:] = []
|
|
448
|
+
continue
|
|
449
|
+
depth = 0 if rel_to_base == Path(".") else len(rel_to_base.parts)
|
|
450
|
+
dirnames[:] = sorted(
|
|
451
|
+
(
|
|
452
|
+
name
|
|
453
|
+
for name in dirnames
|
|
454
|
+
if name not in _SKIP_RECURSIVE_NAMES and not name.startswith(".git")
|
|
455
|
+
),
|
|
456
|
+
key=str.casefold,
|
|
457
|
+
)
|
|
458
|
+
if depth >= _MAX_MANIFEST_SCAN_DEPTH:
|
|
459
|
+
dirnames[:] = []
|
|
460
|
+
visited_dirs += 1
|
|
461
|
+
if visited_dirs > _MAX_MANIFEST_SCAN_DIRS:
|
|
462
|
+
break
|
|
463
|
+
for filename in sorted(filenames, key=str.casefold):
|
|
464
|
+
if filename not in _RECURSIVE_MANIFEST_FILENAMES:
|
|
465
|
+
continue
|
|
466
|
+
candidate = current_path / filename
|
|
467
|
+
if not candidate.is_file():
|
|
468
|
+
continue
|
|
469
|
+
kind = manifest_kind_map.get(filename)
|
|
470
|
+
if not kind:
|
|
471
|
+
continue
|
|
472
|
+
results.append((_relpath(root, candidate), kind))
|
|
473
|
+
return results
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _collect_readmes(*, root: Path, search_dirs: list[Path]) -> list[str]:
|
|
477
|
+
readmes: list[str] = []
|
|
478
|
+
seen: set[str] = set()
|
|
479
|
+
for directory in search_dirs:
|
|
480
|
+
for candidate in sorted(directory.iterdir(), key=lambda path: path.name.casefold()):
|
|
481
|
+
if not candidate.is_file():
|
|
482
|
+
continue
|
|
483
|
+
if candidate.name.casefold() not in {name.casefold() for name in _README_NAMES}:
|
|
484
|
+
continue
|
|
485
|
+
rel_path = _relpath(root, candidate)
|
|
486
|
+
if rel_path in seen:
|
|
487
|
+
continue
|
|
488
|
+
seen.add(rel_path)
|
|
489
|
+
readmes.append(rel_path)
|
|
490
|
+
return readmes
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _find_conventions_path(*, root: Path, search_dirs: list[Path]) -> str | None:
|
|
494
|
+
for directory in reversed(search_dirs):
|
|
495
|
+
candidate = directory / "CONVENTIONS.md"
|
|
496
|
+
if candidate.exists() and candidate.is_file():
|
|
497
|
+
return _relpath(root, candidate)
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _excerpt_entry(*, root: Path, path: Path) -> tuple[str, str]:
|
|
502
|
+
return _relpath(root, path), _read_text_excerpt(path)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _read_text_excerpt(path: Path) -> str:
|
|
506
|
+
try:
|
|
507
|
+
with path.open("rb") as fh:
|
|
508
|
+
raw = fh.read(_MAX_EXCERPT_BYTES)
|
|
509
|
+
except OSError:
|
|
510
|
+
return ""
|
|
511
|
+
if not raw:
|
|
512
|
+
return ""
|
|
513
|
+
text = raw.decode("utf-8", errors="replace")
|
|
514
|
+
lines = text.splitlines()
|
|
515
|
+
excerpt = "\n".join(lines[:_MAX_EXCERPT_LINES]).strip()
|
|
516
|
+
if len(excerpt) > _MAX_EXCERPT_CHARS:
|
|
517
|
+
excerpt = excerpt[:_MAX_EXCERPT_CHARS].rstrip() + "..."
|
|
518
|
+
return excerpt
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _infer_language_hints(*, manifests: list[dict[str, str]]) -> list[str]:
|
|
522
|
+
kinds = {str(item.get("kind") or "") for item in manifests}
|
|
523
|
+
hints: list[str] = []
|
|
524
|
+
if "python" in kinds:
|
|
525
|
+
hints.append("python")
|
|
526
|
+
if "node" in kinds:
|
|
527
|
+
hints.append("javascript")
|
|
528
|
+
if any(
|
|
529
|
+
item.get("path") == "tsconfig.json" or item.get("path", "").endswith("/tsconfig.json")
|
|
530
|
+
for item in manifests
|
|
531
|
+
):
|
|
532
|
+
hints.append("typescript")
|
|
533
|
+
if "go" in kinds:
|
|
534
|
+
hints.append("go")
|
|
535
|
+
if "rust" in kinds:
|
|
536
|
+
hints.append("rust")
|
|
537
|
+
if "java" in kinds:
|
|
538
|
+
hints.append("java")
|
|
539
|
+
if "docker" in kinds:
|
|
540
|
+
hints.append("docker")
|
|
541
|
+
return hints
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _infer_package_hints(*, root: Path, manifests: list[dict[str, str]]) -> list[str]:
|
|
545
|
+
manifest_paths = {item.get("path", ""): item.get("kind", "") for item in manifests}
|
|
546
|
+
hints: list[str] = []
|
|
547
|
+
pyproject_path = _find_manifest_path(manifests, "pyproject.toml")
|
|
548
|
+
if pyproject_path is not None:
|
|
549
|
+
tool_names = _pyproject_tool_names(root / pyproject_path)
|
|
550
|
+
if "poetry" in tool_names:
|
|
551
|
+
hints.append("poetry")
|
|
552
|
+
if "uv" in tool_names:
|
|
553
|
+
hints.append("uv")
|
|
554
|
+
if "hatch" in tool_names:
|
|
555
|
+
hints.append("hatch")
|
|
556
|
+
if "setuptools" in tool_names:
|
|
557
|
+
hints.append("setuptools")
|
|
558
|
+
if not tool_names:
|
|
559
|
+
hints.append("python")
|
|
560
|
+
elif any(kind == "python" for kind in manifest_paths.values()):
|
|
561
|
+
hints.append("python")
|
|
562
|
+
|
|
563
|
+
package_json_path = _find_manifest_path(manifests, "package.json")
|
|
564
|
+
if package_json_path is not None:
|
|
565
|
+
hints.append(
|
|
566
|
+
_node_package_manager(
|
|
567
|
+
root=root, package_json_path=package_json_path, manifests=manifests
|
|
568
|
+
)
|
|
569
|
+
)
|
|
570
|
+
elif any(kind == "node" for kind in manifest_paths.values()):
|
|
571
|
+
hints.append("node")
|
|
572
|
+
|
|
573
|
+
if any(kind == "go" for kind in manifest_paths.values()):
|
|
574
|
+
hints.append("go-mod")
|
|
575
|
+
if any(kind == "rust" for kind in manifest_paths.values()):
|
|
576
|
+
hints.append("cargo")
|
|
577
|
+
if any(kind == "java" for kind in manifest_paths.values()):
|
|
578
|
+
hints.append("maven")
|
|
579
|
+
if _find_manifest_path(manifests, "Makefile") is not None:
|
|
580
|
+
hints.append("make")
|
|
581
|
+
if _find_manifest_path(manifests, "justfile") is not None:
|
|
582
|
+
hints.append("just")
|
|
583
|
+
if any(kind == "docker" for kind in manifest_paths.values()):
|
|
584
|
+
hints.append("docker")
|
|
585
|
+
return _unique_nonempty(hints)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _infer_test_commands(
|
|
589
|
+
*,
|
|
590
|
+
root: Path,
|
|
591
|
+
search_dirs: list[Path],
|
|
592
|
+
manifests: list[dict[str, str]],
|
|
593
|
+
) -> list[str]:
|
|
594
|
+
commands: list[str] = []
|
|
595
|
+
|
|
596
|
+
makefile_path = _find_manifest_path(manifests, "Makefile")
|
|
597
|
+
if makefile_path is not None:
|
|
598
|
+
target = _preferred_make_target(root / makefile_path)
|
|
599
|
+
if target is not None:
|
|
600
|
+
commands.append(f"make {target}")
|
|
601
|
+
|
|
602
|
+
justfile_path = _find_manifest_path(manifests, "justfile")
|
|
603
|
+
if justfile_path is not None:
|
|
604
|
+
target = _preferred_make_target(root / justfile_path)
|
|
605
|
+
if target is not None:
|
|
606
|
+
commands.append(f"just {target}")
|
|
607
|
+
|
|
608
|
+
for package_json_path in _prefer_leaf_service_manifests(
|
|
609
|
+
_find_manifest_paths(manifests, "package.json")
|
|
610
|
+
):
|
|
611
|
+
package_path = root / package_json_path
|
|
612
|
+
if _package_json_has_real_test_script(package_path):
|
|
613
|
+
commands.append(
|
|
614
|
+
_node_test_command(
|
|
615
|
+
root=root,
|
|
616
|
+
package_json_path=package_json_path,
|
|
617
|
+
manifests=manifests,
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
for script_name in _package_json_safe_verification_scripts(package_path):
|
|
621
|
+
commands.append(
|
|
622
|
+
_node_script_command(
|
|
623
|
+
root=root,
|
|
624
|
+
package_json_path=package_json_path,
|
|
625
|
+
manifests=manifests,
|
|
626
|
+
script_name=script_name,
|
|
627
|
+
)
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
for pom_path in _prefer_leaf_service_manifests(_find_manifest_paths(manifests, "pom.xml")):
|
|
631
|
+
commands.append(_maven_test_command(root=root, pom_path=pom_path))
|
|
632
|
+
|
|
633
|
+
if _find_manifest_path(manifests, "go.mod") is not None:
|
|
634
|
+
commands.append("go test ./...")
|
|
635
|
+
|
|
636
|
+
if _find_manifest_path(manifests, "Cargo.toml") is not None:
|
|
637
|
+
commands.append("cargo test")
|
|
638
|
+
|
|
639
|
+
python_test_command = _python_test_command(
|
|
640
|
+
root=root,
|
|
641
|
+
search_dirs=search_dirs,
|
|
642
|
+
manifests=manifests,
|
|
643
|
+
)
|
|
644
|
+
if python_test_command is not None:
|
|
645
|
+
commands.append(python_test_command)
|
|
646
|
+
commands.extend(_python_configured_check_commands(root=root, manifests=manifests))
|
|
647
|
+
|
|
648
|
+
# Doctest over README/docs files is deliberately never inferred: it asserts
|
|
649
|
+
# that the prose examples still render, not that the code works, so it is
|
|
650
|
+
# never an authoritative verification surface for a change to the code.
|
|
651
|
+
|
|
652
|
+
return _unique_nonempty(commands)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _preferred_make_target(path: Path) -> str | None:
|
|
656
|
+
excerpt = _read_text_excerpt(path)
|
|
657
|
+
if not excerpt:
|
|
658
|
+
return None
|
|
659
|
+
targets: list[str] = []
|
|
660
|
+
for line in excerpt.splitlines():
|
|
661
|
+
if line.startswith("\t") or line.startswith(" "):
|
|
662
|
+
continue
|
|
663
|
+
match = _MAKE_TARGET_RE.match(line.strip())
|
|
664
|
+
if match:
|
|
665
|
+
targets.append(match.group(1))
|
|
666
|
+
for candidate in ("test", "check", "verify"):
|
|
667
|
+
if candidate in targets:
|
|
668
|
+
return candidate
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _likely_has_python_tests(
|
|
673
|
+
*,
|
|
674
|
+
root: Path,
|
|
675
|
+
search_dirs: list[Path],
|
|
676
|
+
manifests: list[dict[str, str]],
|
|
677
|
+
) -> bool:
|
|
678
|
+
return _python_test_command(root=root, search_dirs=search_dirs, manifests=manifests) is not None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _python_test_command(
|
|
682
|
+
*,
|
|
683
|
+
root: Path,
|
|
684
|
+
search_dirs: list[Path],
|
|
685
|
+
manifests: list[dict[str, str]],
|
|
686
|
+
) -> str | None:
|
|
687
|
+
declared_runner = detect_declared_test_runner(root)
|
|
688
|
+
for directory in _python_test_search_dirs(root=root, search_dirs=search_dirs):
|
|
689
|
+
if _directory_has_python_test_layout_signal(directory, allow_tests_dir_hint=False):
|
|
690
|
+
rel_path = _relpath(root, directory)
|
|
691
|
+
if declared_runner is None and _directory_declares_unittest_suite(directory):
|
|
692
|
+
if rel_path in {"", "."}:
|
|
693
|
+
return "python -m unittest discover"
|
|
694
|
+
return f"python -m unittest discover -s {shlex.quote(rel_path)}"
|
|
695
|
+
if rel_path in {"", "."}:
|
|
696
|
+
return "pytest -q"
|
|
697
|
+
quoted = shlex.quote(rel_path)
|
|
698
|
+
return f"PYTHONPATH={quoted} pytest -q {quoted}"
|
|
699
|
+
return None
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _python_configured_check_commands(
|
|
703
|
+
*,
|
|
704
|
+
root: Path,
|
|
705
|
+
manifests: list[dict[str, str]],
|
|
706
|
+
) -> list[str]:
|
|
707
|
+
declared: list[str] = []
|
|
708
|
+
pyproject_path = _find_manifest_path(manifests, "pyproject.toml")
|
|
709
|
+
if pyproject_path is not None:
|
|
710
|
+
tool_names = _pyproject_tool_names(root / pyproject_path)
|
|
711
|
+
if "ruff" in tool_names:
|
|
712
|
+
declared.append("ruff check .")
|
|
713
|
+
if "mypy" in tool_names:
|
|
714
|
+
declared.append("mypy .")
|
|
715
|
+
|
|
716
|
+
setup_cfg_path = _find_manifest_path(manifests, "setup.cfg")
|
|
717
|
+
if setup_cfg_path is not None:
|
|
718
|
+
config_text = _read_bounded_config(root / setup_cfg_path)
|
|
719
|
+
if config_text is not None and ini_has_section(config_text, "ruff"):
|
|
720
|
+
declared.append("ruff check .")
|
|
721
|
+
if config_text is not None and ini_has_section(config_text, "mypy"):
|
|
722
|
+
declared.append("mypy .")
|
|
723
|
+
|
|
724
|
+
safe: list[str] = []
|
|
725
|
+
for command in _unique_nonempty(declared):
|
|
726
|
+
analysis = analyze_verification_command(
|
|
727
|
+
command,
|
|
728
|
+
trusted=True,
|
|
729
|
+
workspace_root=root,
|
|
730
|
+
)
|
|
731
|
+
if analysis.is_valid_verifier:
|
|
732
|
+
safe.append(command)
|
|
733
|
+
return safe
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _read_bounded_config(path: Path) -> str | None:
|
|
737
|
+
try:
|
|
738
|
+
with path.open("rb") as handle:
|
|
739
|
+
raw = handle.read(_MAX_EXCERPT_BYTES)
|
|
740
|
+
except OSError:
|
|
741
|
+
return None
|
|
742
|
+
return raw.decode("utf-8", errors="replace")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _directory_declares_unittest_suite(directory: Path) -> bool:
|
|
746
|
+
seen_dirs = 0
|
|
747
|
+
seen_files = 0
|
|
748
|
+
saw_unittest = False
|
|
749
|
+
try:
|
|
750
|
+
for current_root, dirnames, filenames in os.walk(directory):
|
|
751
|
+
seen_dirs += 1
|
|
752
|
+
if seen_dirs > _MAX_PYTHON_TEST_SIGNAL_DIRS:
|
|
753
|
+
return False
|
|
754
|
+
dirnames[:] = sorted(
|
|
755
|
+
[name for name in dirnames if name not in _SKIP_RECURSIVE_NAMES],
|
|
756
|
+
key=str.casefold,
|
|
757
|
+
)
|
|
758
|
+
for filename in sorted(filenames, key=str.casefold):
|
|
759
|
+
if not _is_unittest_discovery_signal_file(filename):
|
|
760
|
+
continue
|
|
761
|
+
seen_files += 1
|
|
762
|
+
if seen_files > _MAX_PYTHON_TEST_SIGNAL_FILES:
|
|
763
|
+
return False
|
|
764
|
+
text = _read_bounded_config(Path(current_root) / filename)
|
|
765
|
+
if text is None:
|
|
766
|
+
continue
|
|
767
|
+
if re.search(r"(?m)^(?:async\s+)?def\s+test_", text) or re.search(
|
|
768
|
+
r"(?m)^\s*(?:from\s+pytest\s+import\b|import\s+pytest\b)",
|
|
769
|
+
text,
|
|
770
|
+
):
|
|
771
|
+
return False
|
|
772
|
+
if re.search(
|
|
773
|
+
r"(?m)^\s*(?:from\s+unittest\s+import\b|import\s+unittest\b)",
|
|
774
|
+
text,
|
|
775
|
+
):
|
|
776
|
+
saw_unittest = True
|
|
777
|
+
except OSError:
|
|
778
|
+
return False
|
|
779
|
+
return saw_unittest
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def detect_fallback_test_commands(*, root: Path) -> list[str]:
|
|
783
|
+
"""Last-resort runner detection for a workspace with no usable command.
|
|
784
|
+
|
|
785
|
+
Deliberately *not* part of the primary inference above, which stays
|
|
786
|
+
conservative on purpose: a repo that declares pytest but ships no tests
|
|
787
|
+
should not advertise a verification contract it cannot honour. This chain is
|
|
788
|
+
consulted only once the selected command has turned out to be unusable, when
|
|
789
|
+
the alternative is running with no verification surface at all -- there, a
|
|
790
|
+
runner the repo declares (or one ``unittest`` can discover) is strictly
|
|
791
|
+
better than nothing, and a runner that collects zero tests is reported as
|
|
792
|
+
skipped rather than passed.
|
|
793
|
+
"""
|
|
794
|
+
workspace_root = Path(root)
|
|
795
|
+
if detect_declared_test_runner(workspace_root) is not None:
|
|
796
|
+
return ["pytest -q"]
|
|
797
|
+
if _directory_has_python_test_layout_signal(workspace_root, allow_tests_dir_hint=False):
|
|
798
|
+
return ["pytest -q"]
|
|
799
|
+
if _directory_has_unittest_discovery_signal(workspace_root):
|
|
800
|
+
return ["python -m unittest discover"]
|
|
801
|
+
return []
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _python_test_search_dirs(*, root: Path, search_dirs: list[Path]) -> list[Path]:
|
|
805
|
+
focused: list[Path] = []
|
|
806
|
+
workspace_roots: list[Path] = []
|
|
807
|
+
for directory in search_dirs:
|
|
808
|
+
rel_path = _relpath(root, directory)
|
|
809
|
+
if rel_path in {"", "."}:
|
|
810
|
+
workspace_roots.append(directory)
|
|
811
|
+
else:
|
|
812
|
+
focused.append(directory)
|
|
813
|
+
return [*focused, *workspace_roots]
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _directory_has_python_test_layout_signal(
|
|
817
|
+
directory: Path,
|
|
818
|
+
*,
|
|
819
|
+
allow_tests_dir_hint: bool,
|
|
820
|
+
) -> bool:
|
|
821
|
+
try:
|
|
822
|
+
for candidate in sorted(directory.iterdir(), key=lambda path: path.name.casefold()):
|
|
823
|
+
if candidate.is_file():
|
|
824
|
+
if _is_python_test_signal_file(candidate.name):
|
|
825
|
+
return True
|
|
826
|
+
continue
|
|
827
|
+
if not candidate.is_dir() or candidate.name != "tests":
|
|
828
|
+
continue
|
|
829
|
+
if allow_tests_dir_hint:
|
|
830
|
+
return True
|
|
831
|
+
if _tests_dir_has_python_signal(candidate):
|
|
832
|
+
return True
|
|
833
|
+
except OSError:
|
|
834
|
+
return False
|
|
835
|
+
return False
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _is_python_test_signal_file(name: str) -> bool:
|
|
839
|
+
lowered = str(name or "").strip().casefold()
|
|
840
|
+
if lowered == "conftest.py":
|
|
841
|
+
return True
|
|
842
|
+
return lowered.endswith(".py") and (lowered.startswith("test_") or lowered.endswith("_test.py"))
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _is_unittest_discovery_signal_file(name: str) -> bool:
|
|
846
|
+
"""Mirror ``unittest discover``'s own default pattern, ``test*.py``.
|
|
847
|
+
|
|
848
|
+
Deliberately the runner's rule rather than a hand-written guess at what a
|
|
849
|
+
test module looks like: the point of this layer is "would ``unittest``
|
|
850
|
+
collect anything here", and only ``unittest``'s pattern answers that. A
|
|
851
|
+
helper module that happens to match collects zero tests, which the verifier
|
|
852
|
+
already reports as skipped rather than passed.
|
|
853
|
+
"""
|
|
854
|
+
lowered = str(name or "").strip().casefold()
|
|
855
|
+
return lowered.startswith("test") and lowered.endswith(".py")
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _directory_has_unittest_discovery_signal(directory: Path) -> bool:
|
|
859
|
+
try:
|
|
860
|
+
for candidate in sorted(directory.iterdir(), key=lambda path: path.name.casefold()):
|
|
861
|
+
if candidate.is_file():
|
|
862
|
+
if _is_unittest_discovery_signal_file(candidate.name):
|
|
863
|
+
return True
|
|
864
|
+
continue
|
|
865
|
+
if not candidate.is_dir() or candidate.name != "tests":
|
|
866
|
+
continue
|
|
867
|
+
if _tests_dir_has_python_signal(candidate, match=_is_unittest_discovery_signal_file):
|
|
868
|
+
return True
|
|
869
|
+
except OSError:
|
|
870
|
+
return False
|
|
871
|
+
return False
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _tests_dir_has_python_signal(
|
|
875
|
+
tests_dir: Path,
|
|
876
|
+
*,
|
|
877
|
+
match: Callable[[str], bool] = _is_python_test_signal_file,
|
|
878
|
+
) -> bool:
|
|
879
|
+
seen_dirs = 0
|
|
880
|
+
seen_files = 0
|
|
881
|
+
try:
|
|
882
|
+
for current_root, dirnames, filenames in os.walk(tests_dir):
|
|
883
|
+
seen_dirs += 1
|
|
884
|
+
if seen_dirs > _MAX_PYTHON_TEST_SIGNAL_DIRS:
|
|
885
|
+
break
|
|
886
|
+
dirnames[:] = sorted(
|
|
887
|
+
[name for name in dirnames if name not in _SKIP_RECURSIVE_NAMES],
|
|
888
|
+
key=str.casefold,
|
|
889
|
+
)
|
|
890
|
+
for filename in sorted(filenames, key=str.casefold):
|
|
891
|
+
seen_files += 1
|
|
892
|
+
if seen_files > _MAX_PYTHON_TEST_SIGNAL_FILES:
|
|
893
|
+
return False
|
|
894
|
+
if match(filename):
|
|
895
|
+
return True
|
|
896
|
+
_ = current_root
|
|
897
|
+
except OSError:
|
|
898
|
+
return False
|
|
899
|
+
return False
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _package_json_has_real_test_script(path: Path) -> bool:
|
|
903
|
+
payload = _read_json_object(path)
|
|
904
|
+
if payload is None:
|
|
905
|
+
return False
|
|
906
|
+
scripts = payload.get("scripts")
|
|
907
|
+
if not isinstance(scripts, dict):
|
|
908
|
+
return False
|
|
909
|
+
raw = scripts.get("test")
|
|
910
|
+
if not isinstance(raw, str):
|
|
911
|
+
return False
|
|
912
|
+
script = raw.strip()
|
|
913
|
+
if not script:
|
|
914
|
+
return False
|
|
915
|
+
lowered = script.casefold()
|
|
916
|
+
if "no test specified" in lowered:
|
|
917
|
+
return False
|
|
918
|
+
return True
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _package_json_safe_verification_scripts(path: Path) -> list[str]:
|
|
922
|
+
payload = _read_json_object(path)
|
|
923
|
+
scripts = payload.get("scripts") if payload is not None else None
|
|
924
|
+
if not isinstance(scripts, dict):
|
|
925
|
+
return []
|
|
926
|
+
candidate_names = [name for name in ("check", "lint", "typecheck") if name in scripts]
|
|
927
|
+
candidate_names.extend(
|
|
928
|
+
sorted(
|
|
929
|
+
(str(name) for name in scripts if isinstance(name, str) and name.startswith("test:")),
|
|
930
|
+
key=str.casefold,
|
|
931
|
+
)
|
|
932
|
+
)
|
|
933
|
+
safe_names: list[str] = []
|
|
934
|
+
for name in candidate_names:
|
|
935
|
+
script = scripts.get(name)
|
|
936
|
+
if not isinstance(script, str) or not script.strip():
|
|
937
|
+
continue
|
|
938
|
+
analysis = analyze_verification_command(
|
|
939
|
+
script,
|
|
940
|
+
trusted=True,
|
|
941
|
+
workspace_root=path.parent,
|
|
942
|
+
)
|
|
943
|
+
if analysis.is_valid_verifier:
|
|
944
|
+
safe_names.append(name)
|
|
945
|
+
return safe_names
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _node_package_manager(
|
|
949
|
+
*,
|
|
950
|
+
root: Path,
|
|
951
|
+
package_json_path: str,
|
|
952
|
+
manifests: list[dict[str, str]],
|
|
953
|
+
) -> str:
|
|
954
|
+
payload = _read_json_object(root / package_json_path)
|
|
955
|
+
if payload is not None:
|
|
956
|
+
raw_manager = payload.get("packageManager")
|
|
957
|
+
if isinstance(raw_manager, str):
|
|
958
|
+
lowered = raw_manager.strip().casefold()
|
|
959
|
+
if lowered.startswith("pnpm@"):
|
|
960
|
+
return "pnpm"
|
|
961
|
+
if lowered.startswith("yarn@"):
|
|
962
|
+
return "yarn"
|
|
963
|
+
if lowered.startswith("bun@"):
|
|
964
|
+
return "bun"
|
|
965
|
+
if lowered.startswith("npm@"):
|
|
966
|
+
return "npm"
|
|
967
|
+
manifest_paths = {item.get("path", "") for item in manifests}
|
|
968
|
+
package_dir = PurePosixPath(package_json_path).parent
|
|
969
|
+
search_dirs = [package_dir, *package_dir.parents]
|
|
970
|
+
for candidate_dir in search_dirs:
|
|
971
|
+
prefix = "" if str(candidate_dir) in {"", "."} else f"{candidate_dir.as_posix()}/"
|
|
972
|
+
if f"{prefix}pnpm-lock.yaml" in manifest_paths:
|
|
973
|
+
return "pnpm"
|
|
974
|
+
if f"{prefix}yarn.lock" in manifest_paths:
|
|
975
|
+
return "yarn"
|
|
976
|
+
if f"{prefix}bun.lockb" in manifest_paths:
|
|
977
|
+
return "bun"
|
|
978
|
+
if f"{prefix}package-lock.json" in manifest_paths:
|
|
979
|
+
return "npm"
|
|
980
|
+
return "npm"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _node_test_command(
|
|
984
|
+
*,
|
|
985
|
+
root: Path,
|
|
986
|
+
package_json_path: str,
|
|
987
|
+
manifests: list[dict[str, str]],
|
|
988
|
+
) -> str:
|
|
989
|
+
manager = _node_package_manager(
|
|
990
|
+
root=root, package_json_path=package_json_path, manifests=manifests
|
|
991
|
+
)
|
|
992
|
+
package_dir = PurePosixPath(package_json_path).parent.as_posix()
|
|
993
|
+
if package_dir in {"", "."}:
|
|
994
|
+
return f"{manager} test"
|
|
995
|
+
quoted_dir = shlex.quote(package_dir)
|
|
996
|
+
if manager == "pnpm":
|
|
997
|
+
return f"pnpm --dir {quoted_dir} test"
|
|
998
|
+
if manager == "yarn":
|
|
999
|
+
return f"yarn --cwd {quoted_dir} test"
|
|
1000
|
+
if manager == "bun":
|
|
1001
|
+
return f"bun --cwd {quoted_dir} test"
|
|
1002
|
+
return f"npm --prefix {quoted_dir} test"
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def _node_script_command(
|
|
1006
|
+
*,
|
|
1007
|
+
root: Path,
|
|
1008
|
+
package_json_path: str,
|
|
1009
|
+
manifests: list[dict[str, str]],
|
|
1010
|
+
script_name: str,
|
|
1011
|
+
) -> str:
|
|
1012
|
+
manager = _node_package_manager(
|
|
1013
|
+
root=root,
|
|
1014
|
+
package_json_path=package_json_path,
|
|
1015
|
+
manifests=manifests,
|
|
1016
|
+
)
|
|
1017
|
+
quoted_script = shlex.quote(script_name)
|
|
1018
|
+
package_dir = PurePosixPath(package_json_path).parent.as_posix()
|
|
1019
|
+
if package_dir in {"", "."}:
|
|
1020
|
+
return f"{manager} run {quoted_script}"
|
|
1021
|
+
quoted_dir = shlex.quote(package_dir)
|
|
1022
|
+
if manager == "pnpm":
|
|
1023
|
+
return f"pnpm --dir {quoted_dir} run {quoted_script}"
|
|
1024
|
+
if manager == "yarn":
|
|
1025
|
+
return f"yarn --cwd {quoted_dir} run {quoted_script}"
|
|
1026
|
+
if manager == "bun":
|
|
1027
|
+
return f"bun --cwd {quoted_dir} run {quoted_script}"
|
|
1028
|
+
return f"npm --prefix {quoted_dir} run {quoted_script}"
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _maven_test_command(*, root: Path, pom_path: str) -> str:
|
|
1032
|
+
wrapper = _find_maven_wrapper(root=root, pom_path=pom_path)
|
|
1033
|
+
if pom_path == "pom.xml":
|
|
1034
|
+
return f"{wrapper} test" if wrapper else "mvn test"
|
|
1035
|
+
quoted_pom = shlex.quote(pom_path)
|
|
1036
|
+
return f"{wrapper} -f {quoted_pom} test" if wrapper else f"mvn -f {quoted_pom} test"
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _find_maven_wrapper(*, root: Path, pom_path: str) -> str | None:
|
|
1040
|
+
pom_dir = PurePosixPath(pom_path).parent
|
|
1041
|
+
candidate_paths: list[PurePosixPath] = []
|
|
1042
|
+
if str(pom_dir) not in {"", "."}:
|
|
1043
|
+
candidate_paths.append(pom_dir / "mvnw")
|
|
1044
|
+
candidate_paths.append(PurePosixPath("mvnw"))
|
|
1045
|
+
for rel_path in candidate_paths:
|
|
1046
|
+
candidate = root / rel_path.as_posix()
|
|
1047
|
+
if candidate.exists() and candidate.is_file():
|
|
1048
|
+
rendered = rel_path.as_posix()
|
|
1049
|
+
return rendered if rendered.startswith("./") else f"./{rendered}"
|
|
1050
|
+
return None
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _pyproject_tool_names(path: Path) -> set[str]:
|
|
1054
|
+
try:
|
|
1055
|
+
import tomllib
|
|
1056
|
+
except ImportError: # pragma: no cover
|
|
1057
|
+
return set()
|
|
1058
|
+
|
|
1059
|
+
try:
|
|
1060
|
+
with path.open("rb") as fh:
|
|
1061
|
+
raw = fh.read(_MAX_EXCERPT_BYTES)
|
|
1062
|
+
except OSError:
|
|
1063
|
+
return set()
|
|
1064
|
+
try:
|
|
1065
|
+
payload = tomllib.loads(raw.decode("utf-8", errors="replace"))
|
|
1066
|
+
except Exception:
|
|
1067
|
+
return set()
|
|
1068
|
+
tool = payload.get("tool")
|
|
1069
|
+
if not isinstance(tool, dict):
|
|
1070
|
+
return set()
|
|
1071
|
+
return {str(key).strip().casefold() for key in tool if str(key).strip()}
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _read_json_object(path: Path) -> dict[str, Any] | None:
|
|
1075
|
+
try:
|
|
1076
|
+
with path.open("rb") as fh:
|
|
1077
|
+
raw = fh.read(_MAX_EXCERPT_BYTES)
|
|
1078
|
+
except OSError:
|
|
1079
|
+
return None
|
|
1080
|
+
try:
|
|
1081
|
+
parsed = json.loads(raw.decode("utf-8", errors="replace"))
|
|
1082
|
+
except json.JSONDecodeError:
|
|
1083
|
+
return None
|
|
1084
|
+
if not isinstance(parsed, dict):
|
|
1085
|
+
return None
|
|
1086
|
+
return parsed
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _find_manifest_path(manifests: list[dict[str, str]], filename: str) -> str | None:
|
|
1090
|
+
suffix = f"/{filename}"
|
|
1091
|
+
for item in manifests:
|
|
1092
|
+
rel_path = str(item.get("path") or "")
|
|
1093
|
+
if rel_path == filename or rel_path.endswith(suffix):
|
|
1094
|
+
return rel_path
|
|
1095
|
+
return None
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _find_manifest_paths(manifests: list[dict[str, str]], filename: str) -> list[str]:
|
|
1099
|
+
suffix = f"/{filename}"
|
|
1100
|
+
matches: list[str] = []
|
|
1101
|
+
for item in manifests:
|
|
1102
|
+
rel_path = str(item.get("path") or "")
|
|
1103
|
+
if rel_path == filename or rel_path.endswith(suffix):
|
|
1104
|
+
matches.append(rel_path)
|
|
1105
|
+
return matches
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _prefer_leaf_service_manifests(paths: list[str]) -> list[str]:
|
|
1109
|
+
if not paths:
|
|
1110
|
+
return []
|
|
1111
|
+
nested = [path for path in paths if "/" in path]
|
|
1112
|
+
return nested if nested else paths
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
def _observed_paths(
|
|
1116
|
+
*,
|
|
1117
|
+
top_level_entries: list[dict[str, str]],
|
|
1118
|
+
manifests: list[dict[str, str]],
|
|
1119
|
+
readme_paths: list[str],
|
|
1120
|
+
conventions_path: str | None,
|
|
1121
|
+
representative_source_paths: list[str],
|
|
1122
|
+
) -> list[str]:
|
|
1123
|
+
observed = _unique_nonempty(
|
|
1124
|
+
[item.get("path", "") for item in top_level_entries]
|
|
1125
|
+
+ [item.get("path", "") for item in manifests]
|
|
1126
|
+
+ readme_paths
|
|
1127
|
+
+ ([conventions_path] if conventions_path else [])
|
|
1128
|
+
+ representative_source_paths
|
|
1129
|
+
)
|
|
1130
|
+
return observed
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def _representative_observed_source_paths(paths: list[str]) -> list[str]:
|
|
1134
|
+
return [
|
|
1135
|
+
path for path in paths if PurePosixPath(path).suffix.casefold() in _BROAD_SOURCE_EXTENSIONS
|
|
1136
|
+
]
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
def _relpath(root: Path, path: Path) -> str:
|
|
1140
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def _unique_nonempty(values: list[str | None]) -> list[str]:
|
|
1144
|
+
out: list[str] = []
|
|
1145
|
+
seen: set[str] = set()
|
|
1146
|
+
for raw in values:
|
|
1147
|
+
value = str(raw or "").strip()
|
|
1148
|
+
if not value or value in seen:
|
|
1149
|
+
continue
|
|
1150
|
+
seen.add(value)
|
|
1151
|
+
out.append(value)
|
|
1152
|
+
return out
|