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