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,1403 @@
1
+ """Blast-radius regression gate (verification protocol, step 6).
2
+
3
+ Steps 2-5 all ask "is the change correct?". This step asks the question the
4
+ benchmark failures actually turned on: *what else did the change break?* A patch
5
+ that is correct in itself but edits shared code can take out hundreds of existing
6
+ tests, and the agent never notices because everything it chose to run still
7
+ passes.
8
+
9
+ The protocol adds a scope the agent did not choose:
10
+
11
+ 1. from the paths the change touches, select the tests likely affected -- the
12
+ name-mirror test, tests that statically import the touched modules, and tests
13
+ in/near the touched package;
14
+ 2. that scope must be run on the CLEAN tree (no pre-existing file modified yet).
15
+ Its failures are the baseline: they are not the patch's fault and are never
16
+ attributed to it;
17
+ 3. after the fix verifies, the same scope runs again. Failures present now but
18
+ not in the baseline are regressions the change introduced;
19
+ 4. regressions are repaired with the fix preserved -- and when a change breaks a
20
+ large number of baseline tests, the approach itself is over-broad, so the
21
+ directive escalates from "fix each failure" to "rewrite the patch narrowly";
22
+ 5. regressions that survive the repair budget are stated in the summary. A known
23
+ regression is never shipped silently.
24
+
25
+ Design invariants (identical in spirit to steps 2-5):
26
+
27
+ * Everything here is pure except ``build_repo_test_index``, the single bounded
28
+ filesystem walk (mirroring ``reproduction_first.surviving_repro_artifacts`` and
29
+ ``acceptance_contract``'s bounded probes). It never raises.
30
+ * The host never runs anything. It selects the scope, tells the agent the scope,
31
+ and observes what the agent actually ran -- the same observational contract the
32
+ rest of the verification protocol keeps.
33
+ * Baselines come only from runs that happened on the unpatched tree. No git state
34
+ is ever mutated to reconstruct one (the step-3 rule), and a run made after
35
+ product code changed is never graced into a baseline: the agent may already have
36
+ finished its fix by then, and crediting that run would mask exactly the breakage
37
+ this step exists to catch.
38
+ * Comparability is decided by what a run actually selected, not by command
39
+ equality: a clean whole-suite run is a universal baseline, and a failure whose
40
+ file the baseline never covered is reported ``unattributed`` rather than guessed
41
+ either way.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import math
47
+ import os
48
+ import re
49
+ from collections.abc import Iterable, Mapping, Sequence
50
+ from dataclasses import dataclass, field
51
+ from enum import IntEnum, StrEnum
52
+ from pathlib import Path, PurePosixPath
53
+ from typing import Any
54
+
55
+ from ..branding import env_get
56
+ from .regression_baseline import TestReport, node_id_file_path
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Kill-switch + policy (mirrors the evidence-v2 / regression / repro idiom)
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ def _blast_radius_gate_enabled(cfg: Any | None) -> bool:
64
+ """Kill-switch for the blast-radius regression gate (step 6).
65
+
66
+ ``ALYSIS_BLAST_RADIUS`` (off/0/false/no/disabled) wins over the config
67
+ value; default is on. When off, scope runs are still captured for telemetry
68
+ but the turn directives and the completion-gate policy revert to legacy.
69
+ """
70
+ env_value = env_get("ALYSIS_BLAST_RADIUS")
71
+ if env_value is not None:
72
+ normalized = str(env_value).strip().lower()
73
+ if normalized in {"off", "0", "false", "no", "disabled"}:
74
+ return False
75
+ if normalized in {"on", "1", "true", "yes", "enabled"}:
76
+ return True
77
+ return bool(getattr(cfg, "blast_radius_gate_enabled", True))
78
+
79
+
80
+ #: Default ceiling on how many test files one scope may name. The scope is a
81
+ #: safety net, not a full suite run; past this the runtime cost stops paying for
82
+ #: itself and the nearest tiers already carry the signal.
83
+ DEFAULT_MAX_SCOPE_FILES = 40
84
+ #: Default wall-clock ceiling for one scope run. Exceeding it shrinks the scope
85
+ #: (nearest tests kept) for the next run -- it never disables the gate.
86
+ DEFAULT_SCOPE_SECONDS_CAP = 300.0
87
+ #: Default count of newly-broken baseline tests past which the change is treated
88
+ #: as over-broad: the patch is rewritten narrowly instead of patched up per test.
89
+ DEFAULT_OVER_BROAD_THRESHOLD = 20
90
+ #: A shrunk scope never drops below this; shrinking must not become skipping.
91
+ MIN_SCOPE_FILES = 1
92
+ #: Ceilings that keep a long turn's recorded state small.
93
+ MAX_SCOPE_RUNS = 40
94
+ MAX_LISTED_IDS = 12
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class BlastRadiusPolicy:
99
+ """Resolved, clamped knobs for one turn."""
100
+
101
+ max_scope_files: int = DEFAULT_MAX_SCOPE_FILES
102
+ scope_seconds_cap: float = DEFAULT_SCOPE_SECONDS_CAP
103
+ over_broad_threshold: int = DEFAULT_OVER_BROAD_THRESHOLD
104
+
105
+ def as_payload(self) -> dict[str, Any]:
106
+ return {
107
+ "max_scope_files": self.max_scope_files,
108
+ "scope_seconds_cap": self.scope_seconds_cap,
109
+ "over_broad_threshold": self.over_broad_threshold,
110
+ }
111
+
112
+
113
+ def _positive_int(value: Any, fallback: int) -> int:
114
+ try:
115
+ parsed = int(value)
116
+ except (TypeError, ValueError):
117
+ return fallback
118
+ return parsed if parsed > 0 else fallback
119
+
120
+
121
+ def _positive_float(value: Any, fallback: float) -> float:
122
+ try:
123
+ parsed = float(value)
124
+ except (TypeError, ValueError):
125
+ return fallback
126
+ if not math.isfinite(parsed) or parsed <= 0:
127
+ return fallback
128
+ return parsed
129
+
130
+
131
+ def resolve_blast_radius_policy(cfg: Any | None) -> BlastRadiusPolicy:
132
+ """Read the knobs off the config, falling back to the defaults on anything odd."""
133
+ return BlastRadiusPolicy(
134
+ max_scope_files=_positive_int(
135
+ getattr(cfg, "blast_radius_max_scope_files", None), DEFAULT_MAX_SCOPE_FILES
136
+ ),
137
+ scope_seconds_cap=_positive_float(
138
+ getattr(cfg, "blast_radius_scope_seconds_cap", None), DEFAULT_SCOPE_SECONDS_CAP
139
+ ),
140
+ over_broad_threshold=_positive_int(
141
+ getattr(cfg, "blast_radius_over_broad_threshold", None),
142
+ DEFAULT_OVER_BROAD_THRESHOLD,
143
+ ),
144
+ )
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Paths, languages, test-file conventions
149
+ # ---------------------------------------------------------------------------
150
+
151
+ _IGNORED_DIR_NAMES = frozenset(
152
+ {
153
+ ".git",
154
+ ".hg",
155
+ ".svn",
156
+ ".venv",
157
+ "venv",
158
+ "env",
159
+ "node_modules",
160
+ "__pycache__",
161
+ ".mypy_cache",
162
+ ".ruff_cache",
163
+ ".pytest_cache",
164
+ ".tox",
165
+ ".nox",
166
+ ".eggs",
167
+ "site-packages",
168
+ "dist",
169
+ "build",
170
+ "target",
171
+ "vendor",
172
+ "htmlcov",
173
+ ".idea",
174
+ ".vscode",
175
+ ".alysis",
176
+ }
177
+ )
178
+
179
+ _PY_EXTENSIONS = frozenset({".py"})
180
+ _JS_EXTENSIONS = frozenset({".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"})
181
+ _GO_EXTENSIONS = frozenset({".go"})
182
+ #: Source roots stripped when synthesizing a fallback dotted module name for a
183
+ #: namespace package (one with no ``__init__.py`` to anchor the real name).
184
+ _SOURCE_ROOT_NAMES = frozenset({"src", "lib", "source", "app", "packages"})
185
+
186
+
187
+ class ScopeLanguage(StrEnum):
188
+ PYTHON = "python"
189
+ JAVASCRIPT = "javascript"
190
+ GO = "go"
191
+
192
+
193
+ _EXTENSION_LANGUAGES = {
194
+ **{ext: ScopeLanguage.PYTHON for ext in _PY_EXTENSIONS},
195
+ **{ext: ScopeLanguage.JAVASCRIPT for ext in _JS_EXTENSIONS},
196
+ **{ext: ScopeLanguage.GO for ext in _GO_EXTENSIONS},
197
+ }
198
+ #: Only the pytest/unittest family produces the per-test ids the step-3 parsers
199
+ #: read, so only a Python scope can be diffed test-by-test. Other languages still
200
+ #: get a scope (the advisory is useful) but never block the gate: a gate that
201
+ #: cannot read its own evidence must not pretend to have any.
202
+ _DIFFABLE_LANGUAGES = frozenset({ScopeLanguage.PYTHON})
203
+
204
+
205
+ def normalize_repo_path(path: str) -> str:
206
+ """Repo-relative, forward-slashed, leading ``./`` and trailing ``/`` removed."""
207
+ cleaned = str(path or "").strip().strip("`'\"").replace("\\", "/")
208
+ while cleaned.startswith("./"):
209
+ cleaned = cleaned[2:]
210
+ return cleaned.strip("/")
211
+
212
+
213
+ def path_language(path: str) -> ScopeLanguage | None:
214
+ suffix = PurePosixPath(normalize_repo_path(path)).suffix.casefold()
215
+ return _EXTENSION_LANGUAGES.get(suffix)
216
+
217
+
218
+ def is_test_file(path: str) -> bool:
219
+ """True when a path follows a test-file naming convention of a known runner.
220
+
221
+ Deliberately conventional rather than clever: ``test_x.py`` / ``x_test.py``
222
+ for Python, ``x.test.ts`` / ``x.spec.js`` (and ``__tests__/``) for JS/TS, and
223
+ ``x_test.go`` for Go. These are the shapes the runners themselves collect by.
224
+
225
+ Kept to plain string operations: the index walk calls this once per file in the
226
+ repo, and building a ``PurePosixPath`` per call is the difference between a
227
+ tolerable index and a visible stall.
228
+ """
229
+ normalized = normalize_repo_path(path).casefold()
230
+ if not normalized:
231
+ return False
232
+ head, _, name = normalized.rpartition("/")
233
+ dot = name.rfind(".")
234
+ if dot <= 0:
235
+ return False
236
+ suffix = name[dot:]
237
+ if suffix in _PY_EXTENSIONS:
238
+ return name.startswith("test_") or name.endswith("_test.py")
239
+ if suffix in _JS_EXTENSIONS:
240
+ if ".test." in name or ".spec." in name:
241
+ return True
242
+ return "__tests__" in head.split("/")
243
+ if suffix in _GO_EXTENSIONS:
244
+ return name.endswith("_test.go")
245
+ return False
246
+
247
+
248
+ def _parent_dir(path: str) -> str:
249
+ parent = PurePosixPath(normalize_repo_path(path)).parent.as_posix()
250
+ return "" if parent == "." else parent
251
+
252
+
253
+ def _shared_prefix_length(left: str, right: str) -> int:
254
+ left_parts = left.split("/")
255
+ right_parts = right.split("/")
256
+ shared = 0
257
+ for a, b in zip(left_parts, right_parts, strict=False):
258
+ if a != b:
259
+ break
260
+ shared += 1
261
+ return shared
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Static import scan
266
+ # ---------------------------------------------------------------------------
267
+
268
+ _PY_FROM_IMPORT_RE = re.compile(r"^[ \t]*from[ \t]+([.\w]+)[ \t]+import[ \t]+(.+)$", re.MULTILINE)
269
+ _PY_IMPORT_RE = re.compile(r"^[ \t]*import[ \t]+([.\w]+(?:[ \t]*,[ \t]*[.\w]+)*)", re.MULTILINE)
270
+ _PY_IMPORTED_NAME_RE = re.compile(r"[A-Za-z_]\w*")
271
+ _JS_IMPORT_RE = re.compile(
272
+ r"""(?:from|require|import)[ \t]*\(?[ \t]*['"]([^'"\n]+)['"]""",
273
+ )
274
+
275
+
276
+ def extract_python_import_tokens(text: str) -> frozenset[str]:
277
+ """Dotted module tokens a Python source text imports.
278
+
279
+ ``from a.b import c, d`` yields ``a.b``, ``a.b.c`` and ``a.b.d`` so a test that
280
+ imports a symbol *out of* the touched module still matches it. Relative imports
281
+ (``from . import x``) carry no absolute name and are skipped rather than guessed
282
+ at -- a wrong guess would put an unrelated test in the scope.
283
+ """
284
+ tokens: set[str] = set()
285
+ body = str(text or "")
286
+ for module, names in _PY_FROM_IMPORT_RE.findall(body):
287
+ if module.startswith("."):
288
+ continue
289
+ tokens.add(module)
290
+ head = names.split("#", 1)[0]
291
+ for name in _PY_IMPORTED_NAME_RE.findall(head):
292
+ if name not in {"as", "import"}:
293
+ tokens.add(f"{module}.{name}")
294
+ for group in _PY_IMPORT_RE.findall(body):
295
+ for raw in group.split(","):
296
+ module = raw.strip()
297
+ if module and not module.startswith("."):
298
+ tokens.add(module)
299
+ return frozenset(tokens)
300
+
301
+
302
+ def extract_js_import_tokens(text: str) -> frozenset[str]:
303
+ """Module specifiers a JS/TS source text imports (``import``/``require``)."""
304
+ return frozenset(
305
+ specifier.strip()
306
+ for specifier in _JS_IMPORT_RE.findall(str(text or ""))
307
+ if specifier.strip()
308
+ )
309
+
310
+
311
+ def extract_import_tokens(path: str, text: str) -> frozenset[str]:
312
+ language = path_language(path)
313
+ if language == ScopeLanguage.PYTHON:
314
+ return extract_python_import_tokens(text)
315
+ if language == ScopeLanguage.JAVASCRIPT:
316
+ return extract_js_import_tokens(text)
317
+ return frozenset()
318
+
319
+
320
+ def python_module_names(path: str, package_dirs: Iterable[str] = ()) -> tuple[str, ...]:
321
+ """Dotted names under which a Python file can be imported.
322
+
323
+ The authoritative name comes from walking up while each ancestor directory is
324
+ a package (holds ``__init__.py``), which is what makes a ``src/`` layout resolve
325
+ to ``pkg.mod`` rather than ``src.pkg.mod``. Two fallbacks are added for
326
+ namespace packages, which have no ``__init__.py`` to anchor the walk: the full
327
+ path dotted, and the path with a leading source-root component stripped.
328
+ """
329
+ normalized = normalize_repo_path(path)
330
+ pure = PurePosixPath(normalized)
331
+ if pure.suffix.casefold() != ".py":
332
+ return ()
333
+ parts = list(pure.with_suffix("").parts)
334
+ if parts and parts[-1] == "__init__":
335
+ parts = parts[:-1]
336
+ if not parts:
337
+ return ()
338
+ packages = {normalize_repo_path(item) for item in package_dirs}
339
+ dirs = parts[:-1]
340
+ root_index = len(dirs)
341
+ index = len(dirs)
342
+ while index > 0 and "/".join(dirs[:index]) in packages:
343
+ root_index = index - 1
344
+ index -= 1
345
+
346
+ candidates: list[list[str]] = [parts[root_index:], list(parts)]
347
+ stripped = list(parts)
348
+ while stripped and stripped[0] in _SOURCE_ROOT_NAMES:
349
+ stripped = stripped[1:]
350
+ if stripped:
351
+ candidates.append(stripped)
352
+
353
+ names: list[str] = []
354
+ for candidate in candidates:
355
+ if candidate and all(part.isidentifier() for part in candidate):
356
+ names.append(".".join(candidate))
357
+ return tuple(dict.fromkeys(names))
358
+
359
+
360
+ def python_import_matches(tokens: Iterable[str], module_names: Sequence[str]) -> bool:
361
+ """True when any import token refers to one of ``module_names``.
362
+
363
+ Equality and prefix matching are exact. The suffix fallback (an import token
364
+ ending in ``.<name>``) is what catches a namespace-package layout whose real
365
+ dotted root we could not anchor -- restricted to multi-component names, since a
366
+ bare ``utils`` would otherwise match every ``anything.utils`` in the repo.
367
+ """
368
+ token_set = {str(token).strip() for token in tokens if str(token).strip()}
369
+ if not token_set:
370
+ return False
371
+ for name in module_names:
372
+ if not name:
373
+ continue
374
+ suffix_ok = "." in name
375
+ for token in token_set:
376
+ if token == name or token.startswith(f"{name}."):
377
+ return True
378
+ if suffix_ok and token.endswith(f".{name}"):
379
+ return True
380
+ return False
381
+
382
+
383
+ def js_import_matches(*, importer: str, tokens: Iterable[str], target: str) -> bool:
384
+ """True when a JS/TS specifier resolves to ``target``.
385
+
386
+ Relative specifiers are resolved against the importing file's directory, which
387
+ is exact. Bare/aliased specifiers (``@/utils/foo``) fall back to a path-suffix
388
+ match after dropping the alias component.
389
+ """
390
+ target_path = normalize_repo_path(target)
391
+ if not target_path:
392
+ return False
393
+ target_stem = target_path.rsplit(".", 1)[0]
394
+ importer_dir = _parent_dir(importer)
395
+ for raw in tokens:
396
+ specifier = str(raw or "").strip()
397
+ if not specifier:
398
+ continue
399
+ if specifier.startswith("."):
400
+ base = f"{importer_dir}/{specifier}" if importer_dir else specifier
401
+ try:
402
+ resolved = normalize_repo_path(PurePosixPath(base).as_posix())
403
+ except ValueError: # pragma: no cover - defensive
404
+ continue
405
+ resolved = _collapse_relative(resolved)
406
+ if resolved and (resolved == target_stem or resolved == target_path):
407
+ return True
408
+ continue
409
+ cleaned = specifier.lstrip("@~").lstrip("/")
410
+ cleaned = cleaned.split("?", 1)[0]
411
+ if not cleaned or "/" not in cleaned:
412
+ continue
413
+ stem = cleaned.rsplit(".", 1)[0] if "." in cleaned.rsplit("/", 1)[-1] else cleaned
414
+ if target_stem.endswith(f"/{stem}") or target_stem == stem:
415
+ return True
416
+ return False
417
+
418
+
419
+ def _collapse_relative(path: str) -> str:
420
+ parts: list[str] = []
421
+ for part in path.split("/"):
422
+ if part in {"", "."}:
423
+ continue
424
+ if part == "..":
425
+ if parts:
426
+ parts.pop()
427
+ continue
428
+ parts.append(part)
429
+ return "/".join(parts)
430
+
431
+
432
+ # ---------------------------------------------------------------------------
433
+ # Repo index (the one filesystem-touching helper)
434
+ # ---------------------------------------------------------------------------
435
+
436
+ #: Bounds on the index walk. A repo larger than these is indexed partially and
437
+ #: says so (``truncated``) rather than silently selecting from half a repo.
438
+ MAX_INDEXED_FILES = 40_000
439
+ MAX_INDEXED_TEST_FILES = 4_000
440
+ MAX_IMPORT_SCANS = 1_500
441
+ MAX_IMPORT_SCAN_BYTES = 16_384
442
+
443
+
444
+ @dataclass(frozen=True)
445
+ class RepoTestIndex:
446
+ """A bounded snapshot of the repo's test surface, taken once per turn."""
447
+
448
+ test_files: tuple[str, ...] = ()
449
+ imports: Mapping[str, frozenset[str]] = field(default_factory=dict)
450
+ package_dirs: frozenset[str] = frozenset()
451
+ truncated: bool = False
452
+ import_scan_truncated: bool = False
453
+
454
+ @property
455
+ def empty(self) -> bool:
456
+ return not self.test_files
457
+
458
+ def as_payload(self) -> dict[str, Any]:
459
+ return {
460
+ "test_file_count": len(self.test_files),
461
+ "package_dir_count": len(self.package_dirs),
462
+ "truncated": self.truncated,
463
+ "import_scan_truncated": self.import_scan_truncated,
464
+ }
465
+
466
+
467
+ EMPTY_REPO_TEST_INDEX = RepoTestIndex()
468
+
469
+
470
+ def build_repo_test_index(
471
+ root: Path,
472
+ *,
473
+ max_files: int = MAX_INDEXED_FILES,
474
+ max_test_files: int = MAX_INDEXED_TEST_FILES,
475
+ max_import_scans: int = MAX_IMPORT_SCANS,
476
+ ) -> RepoTestIndex:
477
+ """Walk ``root`` once, collecting test files, their imports and package dirs.
478
+
479
+ Ignored trees are pruned rather than filtered afterwards: a ``.venv`` or
480
+ ``node_modules`` can hold more files than the whole repo, and descending into one
481
+ before discarding it would put a multi-second stall in the middle of a turn.
482
+
483
+ Bounded on every axis (files walked, test files kept, files whose imports are
484
+ read, bytes read per file) and never raises: an unreadable tree yields an empty
485
+ index and the gate simply does not apply. Imports are read from the head of each
486
+ file, where import statements live. Directory order is sorted, so the same repo
487
+ always yields the same index.
488
+ """
489
+ root_path = Path(root)
490
+ test_files: list[str] = []
491
+ package_dirs: set[str] = set()
492
+ scanned = 0
493
+ truncated = False
494
+ try:
495
+ walker = os.walk(root_path, onerror=None)
496
+ for current, dirnames, filenames in walker:
497
+ dirnames[:] = sorted(name for name in dirnames if name not in _IGNORED_DIR_NAMES)
498
+ try:
499
+ prefix = Path(current).relative_to(root_path).as_posix()
500
+ except ValueError:
501
+ dirnames[:] = []
502
+ continue
503
+ prefix = "" if prefix == "." else prefix
504
+ if scanned >= max_files:
505
+ truncated = True
506
+ break
507
+ for name in sorted(filenames):
508
+ if scanned >= max_files:
509
+ truncated = True
510
+ break
511
+ scanned += 1
512
+ relative = f"{prefix}/{name}" if prefix else name
513
+ if name == "__init__.py":
514
+ package_dirs.add(prefix)
515
+ if is_test_file(relative):
516
+ if len(test_files) >= max_test_files:
517
+ truncated = True
518
+ continue
519
+ test_files.append(relative)
520
+ except (OSError, ValueError):
521
+ return EMPTY_REPO_TEST_INDEX
522
+
523
+ imports: dict[str, frozenset[str]] = {}
524
+ import_scan_truncated = False
525
+ for relative in test_files:
526
+ if len(imports) >= max_import_scans:
527
+ import_scan_truncated = True
528
+ break
529
+ language = path_language(relative)
530
+ if language not in {ScopeLanguage.PYTHON, ScopeLanguage.JAVASCRIPT}:
531
+ continue
532
+ try:
533
+ with (root_path / relative).open("rb") as handle:
534
+ head = handle.read(MAX_IMPORT_SCAN_BYTES).decode("utf-8", "ignore")
535
+ except OSError:
536
+ continue
537
+ imports[relative] = extract_import_tokens(relative, head)
538
+
539
+ return RepoTestIndex(
540
+ test_files=tuple(test_files),
541
+ imports=imports,
542
+ package_dirs=frozenset(package_dirs),
543
+ truncated=truncated,
544
+ import_scan_truncated=import_scan_truncated,
545
+ )
546
+
547
+
548
+ # ---------------------------------------------------------------------------
549
+ # Scope selection (pure)
550
+ # ---------------------------------------------------------------------------
551
+
552
+
553
+ class ScopeTier(IntEnum):
554
+ """Proximity of a test file to the change. Lower is nearer."""
555
+
556
+ #: The touched file *is* a test file, or a test file named after it.
557
+ MIRROR = 0
558
+ #: The test file statically imports a touched module.
559
+ IMPORTER = 1
560
+ #: The test file sits in the same directory as a touched file.
561
+ SIBLING = 2
562
+ #: The test file is under the touched package, or its mirrored test package.
563
+ PACKAGE = 3
564
+
565
+
566
+ _TIER_REASONS = {
567
+ ScopeTier.MIRROR: "test file mirrors a touched source file",
568
+ ScopeTier.IMPORTER: "test file imports a touched module",
569
+ ScopeTier.SIBLING: "test file sits beside a touched file",
570
+ ScopeTier.PACKAGE: "test file is under a touched package",
571
+ }
572
+
573
+
574
+ @dataclass(frozen=True)
575
+ class ScopeEntry:
576
+ path: str
577
+ tier: ScopeTier
578
+ anchor: str = ""
579
+
580
+ @property
581
+ def reason(self) -> str:
582
+ return _TIER_REASONS.get(self.tier, "selected by proximity")
583
+
584
+ def as_payload(self) -> dict[str, Any]:
585
+ return {
586
+ "path": self.path,
587
+ "tier": int(self.tier),
588
+ "tier_name": self.tier.name.casefold(),
589
+ "anchor": self.anchor,
590
+ "reason": self.reason,
591
+ }
592
+
593
+
594
+ @dataclass(frozen=True)
595
+ class BlastRadiusScope:
596
+ """The test scope selected for one set of touched paths."""
597
+
598
+ entries: tuple[ScopeEntry, ...] = ()
599
+ language: ScopeLanguage | None = None
600
+ touched_paths: tuple[str, ...] = ()
601
+ dropped_for_cap: tuple[str, ...] = ()
602
+ dropped_for_runtime: tuple[str, ...] = ()
603
+ shrink_rounds: int = 0
604
+ index_truncated: bool = False
605
+
606
+ @property
607
+ def paths(self) -> tuple[str, ...]:
608
+ return tuple(entry.path for entry in self.entries)
609
+
610
+ @property
611
+ def empty(self) -> bool:
612
+ return not self.entries
613
+
614
+ @property
615
+ def diffable(self) -> bool:
616
+ """True when a run of this scope produces per-test ids the gate can diff."""
617
+ return bool(self.entries) and self.language in _DIFFABLE_LANGUAGES
618
+
619
+ def suggested_command(self) -> str:
620
+ """A concrete command for the scope, for the advisory text.
621
+
622
+ Advisory only: coverage is judged by which test files a run actually
623
+ selected, never by matching this string, so a project whose runner differs
624
+ can run the same files any way it likes.
625
+ """
626
+ if self.empty:
627
+ return ""
628
+ joined = " ".join(self.paths)
629
+ if self.language == ScopeLanguage.PYTHON:
630
+ return f"python -m pytest {joined} -q"
631
+ if self.language == ScopeLanguage.GO:
632
+ packages = sorted({f"./{_parent_dir(path)}".rstrip("/") or "." for path in self.paths})
633
+ return f"go test {' '.join(packages)}"
634
+ return joined
635
+
636
+ def as_payload(self) -> dict[str, Any]:
637
+ return {
638
+ "entries": [entry.as_payload() for entry in self.entries],
639
+ "paths": list(self.paths),
640
+ "language": self.language.value if self.language is not None else "",
641
+ "diffable": self.diffable,
642
+ "touched_paths": list(self.touched_paths),
643
+ "dropped_for_cap": list(self.dropped_for_cap),
644
+ "dropped_for_runtime": list(self.dropped_for_runtime),
645
+ "shrink_rounds": self.shrink_rounds,
646
+ "index_truncated": self.index_truncated,
647
+ "suggested_command": self.suggested_command(),
648
+ }
649
+
650
+
651
+ EMPTY_SCOPE = BlastRadiusScope()
652
+
653
+
654
+ def _mirror_names(touched: str) -> frozenset[str]:
655
+ pure = PurePosixPath(touched)
656
+ stem = pure.stem
657
+ suffix = pure.suffix.casefold()
658
+ if suffix in _PY_EXTENSIONS:
659
+ return frozenset({f"test_{stem}.py", f"{stem}_test.py"})
660
+ if suffix in _GO_EXTENSIONS:
661
+ return frozenset({f"{stem}_test.go"})
662
+ if suffix in _JS_EXTENSIONS:
663
+ return frozenset(
664
+ {f"{stem}.test{ext}" for ext in _JS_EXTENSIONS}
665
+ | {f"{stem}.spec{ext}" for ext in _JS_EXTENSIONS}
666
+ )
667
+ return frozenset()
668
+
669
+
670
+ def _package_relative_dir(touched: str, package_dirs: frozenset[str]) -> str:
671
+ """The touched file's directory, relative to the top of its package.
672
+
673
+ ``src/pkg/sub/mod.py`` -> ``pkg/sub``, so a mirrored ``tests/pkg/sub`` matches by
674
+ suffix without needing to know the project's test-directory convention.
675
+ """
676
+ directory = _parent_dir(touched)
677
+ if not directory:
678
+ return ""
679
+ parts = directory.split("/")
680
+ index = len(parts)
681
+ root_index = len(parts)
682
+ while index > 0 and "/".join(parts[:index]) in package_dirs:
683
+ root_index = index - 1
684
+ index -= 1
685
+ return "/".join(parts[root_index:])
686
+
687
+
688
+ def _tier_for(
689
+ *,
690
+ test_path: str,
691
+ touched: str,
692
+ index: RepoTestIndex,
693
+ module_names: Sequence[str],
694
+ mirror_names: frozenset[str],
695
+ package_relative_dir: str,
696
+ ) -> ScopeTier | None:
697
+ if test_path == touched:
698
+ return ScopeTier.MIRROR
699
+ test_name = PurePosixPath(test_path).name
700
+ if test_name in mirror_names:
701
+ return ScopeTier.MIRROR
702
+ tokens = index.imports.get(test_path)
703
+ if tokens:
704
+ language = path_language(touched)
705
+ if language == ScopeLanguage.PYTHON and module_names:
706
+ if python_import_matches(tokens, module_names):
707
+ return ScopeTier.IMPORTER
708
+ elif language == ScopeLanguage.JAVASCRIPT and js_import_matches(
709
+ importer=test_path, tokens=tokens, target=touched
710
+ ):
711
+ return ScopeTier.IMPORTER
712
+ touched_dir = _parent_dir(touched)
713
+ test_dir = _parent_dir(test_path)
714
+ if touched_dir and test_dir == touched_dir:
715
+ return ScopeTier.SIBLING
716
+ if touched_dir and test_dir.startswith(f"{touched_dir}/"):
717
+ return ScopeTier.PACKAGE
718
+ if package_relative_dir and (
719
+ test_dir == package_relative_dir or test_dir.endswith(f"/{package_relative_dir}")
720
+ ):
721
+ return ScopeTier.PACKAGE
722
+ return None
723
+
724
+
725
+ def _dominant_language(
726
+ ranked: Sequence[tuple[ScopeTier, str, str]],
727
+ ) -> ScopeLanguage | None:
728
+ """The language the scope is run as: most-represented, Python winning ties.
729
+
730
+ A scope has to be runnable by one runner, so a change spanning languages picks
731
+ one. Python wins ties because it is the only language whose runs the gate can
732
+ diff test-by-test.
733
+ """
734
+ counts: dict[ScopeLanguage, int] = {}
735
+ for _tier, path, _anchor in ranked:
736
+ language = path_language(path)
737
+ if language is not None:
738
+ counts[language] = counts.get(language, 0) + 1
739
+ if not counts:
740
+ return None
741
+ return min(
742
+ counts,
743
+ key=lambda language: (-counts[language], language != ScopeLanguage.PYTHON, language.value),
744
+ )
745
+
746
+
747
+ def select_blast_radius_scope(
748
+ *,
749
+ touched_paths: Iterable[str],
750
+ index: RepoTestIndex,
751
+ policy: BlastRadiusPolicy | None = None,
752
+ ) -> BlastRadiusScope:
753
+ """Select the tests likely affected by ``touched_paths``. Pure and deterministic.
754
+
755
+ Each candidate takes its *best* (nearest) tier over all touched paths, and the
756
+ scope is ordered nearest-first so the runtime cap and any later shrink both drop
757
+ the weakest evidence first.
758
+ """
759
+ resolved_policy = policy or BlastRadiusPolicy()
760
+ # Only source files in a language we understand have a blast radius we can
761
+ # reason about. A README or a data fixture would otherwise drag in every test
762
+ # sharing its directory on proximity alone, which is noise, not evidence.
763
+ touched = tuple(
764
+ dict.fromkeys(
765
+ normalized
766
+ for normalized in (normalize_repo_path(item) for item in touched_paths)
767
+ if normalized and path_language(normalized) is not None
768
+ )
769
+ )
770
+ if not touched or index.empty:
771
+ return BlastRadiusScope(touched_paths=touched, index_truncated=index.truncated)
772
+
773
+ best: dict[str, tuple[ScopeTier, str]] = {}
774
+ for item in touched:
775
+ module_names = python_module_names(item, index.package_dirs)
776
+ mirror_names = _mirror_names(item)
777
+ package_relative_dir = _package_relative_dir(item, index.package_dirs)
778
+ for test_path in index.test_files:
779
+ tier = _tier_for(
780
+ test_path=test_path,
781
+ touched=item,
782
+ index=index,
783
+ module_names=module_names,
784
+ mirror_names=mirror_names,
785
+ package_relative_dir=package_relative_dir,
786
+ )
787
+ if tier is None:
788
+ continue
789
+ current = best.get(test_path)
790
+ if current is None or tier < current[0]:
791
+ best[test_path] = (tier, item)
792
+
793
+ ranked = sorted(
794
+ ((tier, path, anchor) for path, (tier, anchor) in best.items()),
795
+ key=lambda item: (
796
+ int(item[0]),
797
+ -max((_shared_prefix_length(item[1], touch) for touch in touched), default=0),
798
+ item[1],
799
+ ),
800
+ )
801
+ language = _dominant_language(ranked)
802
+ ranked = [item for item in ranked if language is None or path_language(item[1]) == language]
803
+
804
+ kept = ranked[: resolved_policy.max_scope_files]
805
+ dropped = [path for _tier, path, _anchor in ranked[resolved_policy.max_scope_files :]]
806
+ return BlastRadiusScope(
807
+ entries=tuple(
808
+ ScopeEntry(path=path, tier=tier, anchor=anchor) for tier, path, anchor in kept
809
+ ),
810
+ language=language,
811
+ touched_paths=touched,
812
+ dropped_for_cap=tuple(dropped),
813
+ index_truncated=index.truncated,
814
+ )
815
+
816
+
817
+ def shrink_scope_once(scope: BlastRadiusScope) -> BlastRadiusScope | None:
818
+ """One shrink step, nearest tests kept. ``None`` when it cannot shrink further.
819
+
820
+ Shrinking drops the widest proximity tier present; when every entry shares one
821
+ tier there is nothing to drop by proximity, so it halves the list instead. It
822
+ never returns an empty scope -- a scope too slow to run whole still runs its
823
+ nearest test, because the alternative is shipping with no blast-radius evidence
824
+ at all.
825
+ """
826
+ if scope.empty or len(scope.entries) <= MIN_SCOPE_FILES:
827
+ return None
828
+ widest = max(entry.tier for entry in scope.entries)
829
+ kept = [entry for entry in scope.entries if entry.tier < widest]
830
+ if not kept:
831
+ kept = list(scope.entries[: max(MIN_SCOPE_FILES, len(scope.entries) // 2)])
832
+ kept_paths = {entry.path for entry in kept}
833
+ dropped = tuple(entry.path for entry in scope.entries if entry.path not in kept_paths)
834
+ return BlastRadiusScope(
835
+ entries=tuple(kept),
836
+ language=scope.language,
837
+ touched_paths=scope.touched_paths,
838
+ dropped_for_cap=scope.dropped_for_cap,
839
+ dropped_for_runtime=tuple(dict.fromkeys((*scope.dropped_for_runtime, *dropped))),
840
+ shrink_rounds=scope.shrink_rounds + 1,
841
+ index_truncated=scope.index_truncated,
842
+ )
843
+
844
+
845
+ def shrink_scope_for_runtime(
846
+ scope: BlastRadiusScope,
847
+ *,
848
+ observed_seconds: float,
849
+ policy: BlastRadiusPolicy | None = None,
850
+ ) -> BlastRadiusScope | None:
851
+ """Shrink an over-budget scope, nearest tests kept. ``None`` when no shrink is due."""
852
+ resolved_policy = policy or BlastRadiusPolicy()
853
+ if scope.empty or observed_seconds <= resolved_policy.scope_seconds_cap:
854
+ return None
855
+ return shrink_scope_once(scope)
856
+
857
+
858
+ def apply_scope_shrink_rounds(scope: BlastRadiusScope, rounds: int) -> BlastRadiusScope:
859
+ """Re-apply ``rounds`` shrink steps to a freshly selected scope.
860
+
861
+ The scope is re-selected whenever the change touches more files, which would
862
+ otherwise undo a shrink the runtime cap had already forced and quietly hand back
863
+ a scope known to be too slow. Carrying the round count forward keeps the cap's
864
+ decision in force across re-selection.
865
+ """
866
+ for _ in range(max(0, int(rounds))):
867
+ smaller = shrink_scope_once(scope)
868
+ if smaller is None:
869
+ break
870
+ scope = smaller
871
+ return scope
872
+
873
+
874
+ # ---------------------------------------------------------------------------
875
+ # Observed scope runs (facts)
876
+ # ---------------------------------------------------------------------------
877
+
878
+
879
+ class ScopePhase(StrEnum):
880
+ """When a test run happened relative to the first change to existing code."""
881
+
882
+ #: Ran while no pre-existing repo path had been modified -- the clean tree.
883
+ BASELINE = "baseline"
884
+ #: Ran after existing code had already been changed.
885
+ GATE = "gate"
886
+
887
+
888
+ _COMMAND_TOKEN_SPLIT_RE = re.compile(r"[\s;|&()<>]+")
889
+ _TOKEN_TRAILING_JUNK = ",;:'\"`)]}"
890
+ _SELECTOR_EXTENSIONS = frozenset(_PY_EXTENSIONS | _JS_EXTENSIONS | _GO_EXTENSIONS)
891
+
892
+
893
+ def command_path_selectors(command: str) -> tuple[str, ...]:
894
+ """The test paths/directories a command explicitly selected.
895
+
896
+ An empty result means the command named no paths -- a whole-suite run, which
897
+ covers every scope. Node-id suffixes (``file.py::test``) reduce to their file,
898
+ and flag values (``-k expr``, ``--maxfail=2``) are not paths so they drop out.
899
+ """
900
+ selectors: list[str] = []
901
+ for raw in _COMMAND_TOKEN_SPLIT_RE.split(str(command or "")):
902
+ token = raw.strip().strip("`'\"").rstrip(_TOKEN_TRAILING_JUNK)
903
+ if not token or token.startswith("-"):
904
+ continue
905
+ token = token.split("::", 1)[0]
906
+ normalized = normalize_repo_path(token)
907
+ if not normalized:
908
+ continue
909
+ pure = PurePosixPath(normalized)
910
+ if pure.suffix.casefold() in _SELECTOR_EXTENSIONS:
911
+ selectors.append(normalized)
912
+ elif "/" in normalized and "." not in pure.name:
913
+ # A directory selector (``pytest tests/unit``). A bare word is not
914
+ # treated as one: it is far more likely a subcommand or an -k value.
915
+ selectors.append(normalized)
916
+ return tuple(dict.fromkeys(selectors))
917
+
918
+
919
+ def selection_covers(selectors: Sequence[str], path: str) -> bool:
920
+ """True when ``path`` was inside what a run selected (no selectors = whole suite)."""
921
+ if not selectors:
922
+ return True
923
+ normalized = normalize_repo_path(path)
924
+ if not normalized:
925
+ return False
926
+ for selector in selectors:
927
+ clean = normalize_repo_path(selector)
928
+ if not clean:
929
+ continue
930
+ if normalized == clean or normalized.startswith(f"{clean}/"):
931
+ return True
932
+ return False
933
+
934
+
935
+ @dataclass(frozen=True)
936
+ class ScopeRun:
937
+ """One observed test run, with what it selected and when it happened."""
938
+
939
+ command: str
940
+ selectors: tuple[str, ...]
941
+ phase: ScopePhase
942
+ report: TestReport
943
+ duration_seconds: float | None = None
944
+
945
+ @property
946
+ def whole_suite(self) -> bool:
947
+ return not self.selectors
948
+
949
+ @property
950
+ def usable(self) -> bool:
951
+ """Only a run whose failing ids are fully known can be compared."""
952
+ return self.report.usable_as_baseline
953
+
954
+ def covers(self, paths: Iterable[str]) -> bool:
955
+ return all(selection_covers(self.selectors, path) for path in paths)
956
+
957
+ def as_payload(self) -> dict[str, Any]:
958
+ return {
959
+ "command": self.command,
960
+ "selectors": list(self.selectors),
961
+ "whole_suite": self.whole_suite,
962
+ "phase": self.phase.value,
963
+ "duration_seconds": self.duration_seconds,
964
+ "report": self.report.as_payload(),
965
+ }
966
+
967
+
968
+ def classify_scope_phase(
969
+ *,
970
+ touched_repo_paths: Iterable[str],
971
+ created_paths: Iterable[str],
972
+ ) -> ScopePhase:
973
+ """Baseline iff no pre-existing repo path has been modified yet.
974
+
975
+ Files the agent authored this turn are excluded: creating a new file changes no
976
+ existing behaviour, so a run made after writing a new test still observes the
977
+ unpatched tree. This is the same discriminator step 5 uses, and for the same
978
+ reason -- the edit generation counts the new file and would close the baseline
979
+ window before the agent ever got to use it.
980
+ """
981
+ created = {
982
+ normalized
983
+ for normalized in (normalize_repo_path(path) for path in created_paths)
984
+ if normalized
985
+ }
986
+ product = {
987
+ normalized
988
+ for normalized in (normalize_repo_path(path) for path in touched_repo_paths)
989
+ if normalized and normalized not in created
990
+ }
991
+ return ScopePhase.GATE if product else ScopePhase.BASELINE
992
+
993
+
994
+ # ---------------------------------------------------------------------------
995
+ # Assessment (pure)
996
+ # ---------------------------------------------------------------------------
997
+
998
+
999
+ class BlastRadiusStatus(StrEnum):
1000
+ """The blast-radius protocol's state at a decision point."""
1001
+
1002
+ #: Off, non-execute, nothing edited, or no diffable test surface near the change.
1003
+ NOT_APPLICABLE = "not_applicable"
1004
+ #: A scope exists but no post-fix run has covered it yet.
1005
+ GATE_MISSING = "gate_missing"
1006
+ #: The scope did run after the fix, but its output could not be parsed into
1007
+ #: per-test results. Honest degradation, not a deficit to nudge on: re-running
1008
+ #: the same runner would produce the same unreadable output.
1009
+ UNREADABLE = "unreadable"
1010
+ #: The scope ran after the fix, but no clean-tree run covers it, so its failures
1011
+ #: cannot be told apart from breakage that was already there.
1012
+ UNATTRIBUTED = "unattributed"
1013
+ #: The scope ran before and after the fix and broke nothing new.
1014
+ CLEAN = "clean"
1015
+ #: Tests that passed in the clean-tree baseline fail after the change.
1016
+ REGRESSED = "regressed"
1017
+
1018
+
1019
+ @dataclass(frozen=True)
1020
+ class BlastRadiusAssessment:
1021
+ """The mechanical state of the blast-radius gate for a turn."""
1022
+
1023
+ status: BlastRadiusStatus = BlastRadiusStatus.NOT_APPLICABLE
1024
+ applicable: bool = False
1025
+ scope: BlastRadiusScope = EMPTY_SCOPE
1026
+ new_failures: tuple[str, ...] = ()
1027
+ pre_existing: tuple[str, ...] = ()
1028
+ unattributed: tuple[str, ...] = ()
1029
+ repaired: tuple[str, ...] = ()
1030
+ agent_authored: tuple[str, ...] = ()
1031
+ baseline_command: str = ""
1032
+ gate_command: str = ""
1033
+ baseline_whole_suite: bool = False
1034
+ over_broad_threshold: int = DEFAULT_OVER_BROAD_THRESHOLD
1035
+
1036
+ @property
1037
+ def has_baseline(self) -> bool:
1038
+ return bool(self.baseline_command)
1039
+
1040
+ @property
1041
+ def regressed(self) -> bool:
1042
+ return self.status == BlastRadiusStatus.REGRESSED
1043
+
1044
+ @property
1045
+ def over_broad(self) -> bool:
1046
+ """The change broke so much that narrowing the patch beats fixing each test."""
1047
+ return len(self.new_failures) >= self.over_broad_threshold
1048
+
1049
+ @property
1050
+ def satisfied(self) -> bool:
1051
+ return self.status == BlastRadiusStatus.CLEAN
1052
+
1053
+ def as_payload(self) -> dict[str, Any]:
1054
+ return {
1055
+ "status": self.status.value,
1056
+ "applicable": self.applicable,
1057
+ "satisfied": self.satisfied,
1058
+ "over_broad": self.over_broad,
1059
+ "over_broad_threshold": self.over_broad_threshold,
1060
+ "new_failures": list(self.new_failures),
1061
+ "pre_existing": list(self.pre_existing),
1062
+ "unattributed": list(self.unattributed),
1063
+ "repaired": list(self.repaired),
1064
+ "agent_authored": list(self.agent_authored),
1065
+ "baseline_command": self.baseline_command,
1066
+ "gate_command": self.gate_command,
1067
+ "baseline_whole_suite": self.baseline_whole_suite,
1068
+ "has_baseline": self.has_baseline,
1069
+ "scope": self.scope.as_payload(),
1070
+ }
1071
+
1072
+
1073
+ def _created_components(paths: Iterable[str]) -> list[str]:
1074
+ return [
1075
+ normalized for normalized in (normalize_repo_path(path) for path in paths) if normalized
1076
+ ]
1077
+
1078
+
1079
+ def _join_commands(commands: Iterable[str]) -> str:
1080
+ """Render the baseline's command(s) for a message, bounded so it stays readable."""
1081
+ distinct = [command for command in dict.fromkeys(commands) if command]
1082
+ if not distinct:
1083
+ return ""
1084
+ rendered = ", ".join(distinct[:2])
1085
+ remaining = len(distinct) - min(len(distinct), 2)
1086
+ return f"{rendered} (+{remaining} more)" if remaining > 0 else rendered
1087
+
1088
+
1089
+ def _id_file_is_agent_created(test_id: str, created: Sequence[str]) -> bool:
1090
+ file_path = node_id_file_path(test_id)
1091
+ if not file_path:
1092
+ return False
1093
+ normalized = normalize_repo_path(file_path)
1094
+ # Exact match only, for the reason step 3 documents: a basename match would let
1095
+ # a created ``test_foo.py`` mask a genuine regression in ``tests/test_foo.py``.
1096
+ return bool(normalized) and any(normalized == item for item in created)
1097
+
1098
+
1099
+ def assess_blast_radius(
1100
+ *,
1101
+ scope: BlastRadiusScope,
1102
+ runs: Sequence[ScopeRun],
1103
+ applicable: bool,
1104
+ policy: BlastRadiusPolicy | None = None,
1105
+ agent_created_paths: Iterable[str] = (),
1106
+ ) -> BlastRadiusAssessment:
1107
+ """Diff the scope's post-fix run against its clean-tree baseline. Deterministic.
1108
+
1109
+ The comparison is by *coverage*, not by command equality. The gate run must cover
1110
+ the whole scope -- that is what "run the scope" means. The baseline does not: every
1111
+ clean-tree run observed the same unpatched tree, so their coverage and their
1112
+ failures compose into one baseline, and attribution is then decided per failing
1113
+ test against what that composite actually ran. A failure the baseline never
1114
+ covered is ``unattributed`` -- never guessed into either column.
1115
+ """
1116
+ resolved_policy = policy or BlastRadiusPolicy()
1117
+ common: dict[str, Any] = {
1118
+ "applicable": bool(applicable),
1119
+ "scope": scope,
1120
+ "over_broad_threshold": resolved_policy.over_broad_threshold,
1121
+ }
1122
+ if not applicable or scope.empty or not scope.diffable:
1123
+ return BlastRadiusAssessment(status=BlastRadiusStatus.NOT_APPLICABLE, **common)
1124
+
1125
+ scope_paths = scope.paths
1126
+ usable = [run for run in runs if run.usable]
1127
+ gate_runs = [run for run in usable if run.phase == ScopePhase.GATE and run.covers(scope_paths)]
1128
+ if not gate_runs:
1129
+ # Distinguish "never ran the scope" from "ran it, could not read the result".
1130
+ # Only the first is a deficit the agent can clear; nudging on the second
1131
+ # would loop forever against a runner whose output shape we cannot parse.
1132
+ unreadable = [
1133
+ run
1134
+ for run in runs
1135
+ if not run.usable and run.phase == ScopePhase.GATE and run.covers(scope_paths)
1136
+ ]
1137
+ if unreadable:
1138
+ return BlastRadiusAssessment(
1139
+ status=BlastRadiusStatus.UNREADABLE,
1140
+ gate_command=unreadable[-1].command,
1141
+ **common,
1142
+ )
1143
+ return BlastRadiusAssessment(status=BlastRadiusStatus.GATE_MISSING, **common)
1144
+ gate = gate_runs[-1]
1145
+
1146
+ baseline_runs = [run for run in usable if run.phase == ScopePhase.BASELINE]
1147
+ baseline_whole_suite = any(run.whole_suite for run in baseline_runs)
1148
+ baseline_selectors: tuple[str, ...] = (
1149
+ ()
1150
+ if baseline_whole_suite
1151
+ else tuple(dict.fromkeys(item for run in baseline_runs for item in run.selectors))
1152
+ )
1153
+ baseline_failing = frozenset(
1154
+ test_id for run in baseline_runs for test_id in run.report.failing_ids
1155
+ )
1156
+ created = _created_components(agent_created_paths)
1157
+
1158
+ new_failures: list[str] = []
1159
+ pre_existing: list[str] = []
1160
+ unattributed: list[str] = []
1161
+ agent_authored: list[str] = []
1162
+ for test_id in gate.report.failing_ids:
1163
+ if _id_file_is_agent_created(test_id, created):
1164
+ agent_authored.append(test_id)
1165
+ elif not baseline_runs:
1166
+ unattributed.append(test_id)
1167
+ elif test_id in baseline_failing:
1168
+ pre_existing.append(test_id)
1169
+ elif selection_covers(baseline_selectors, node_id_file_path(test_id) or ""):
1170
+ new_failures.append(test_id)
1171
+ else:
1172
+ unattributed.append(test_id)
1173
+
1174
+ gate_failing = frozenset(gate.report.failing_ids)
1175
+ repaired = [
1176
+ test_id
1177
+ for test_id in dict.fromkeys(
1178
+ item for run in baseline_runs for item in run.report.failing_ids
1179
+ )
1180
+ if test_id not in gate_failing
1181
+ and selection_covers(gate.selectors, node_id_file_path(test_id) or "")
1182
+ ]
1183
+
1184
+ if new_failures:
1185
+ status = BlastRadiusStatus.REGRESSED
1186
+ elif not baseline_runs or unattributed:
1187
+ status = BlastRadiusStatus.UNATTRIBUTED
1188
+ else:
1189
+ status = BlastRadiusStatus.CLEAN
1190
+
1191
+ return BlastRadiusAssessment(
1192
+ status=status,
1193
+ new_failures=tuple(new_failures),
1194
+ pre_existing=tuple(pre_existing),
1195
+ unattributed=tuple(unattributed),
1196
+ repaired=tuple(repaired),
1197
+ agent_authored=tuple(agent_authored),
1198
+ baseline_command=_join_commands(run.command for run in baseline_runs),
1199
+ gate_command=gate.command,
1200
+ baseline_whole_suite=baseline_whole_suite,
1201
+ **common,
1202
+ )
1203
+
1204
+
1205
+ def blast_radius_blocks_finalization(
1206
+ assessment: BlastRadiusAssessment,
1207
+ *,
1208
+ material_edit_count: int,
1209
+ ) -> bool:
1210
+ """True when the gate must not let this turn finalize yet.
1211
+
1212
+ Only two states block: a scope that was never run after the fix, and proven new
1213
+ failures. ``unattributed`` does not block here -- step 3's own unattributed stage
1214
+ already owns that case, and blocking twice for one fact would just burn repair
1215
+ rounds. A turn that changed nothing has no blast radius.
1216
+ """
1217
+ if not assessment.applicable or material_edit_count <= 0:
1218
+ return False
1219
+ return assessment.status in {BlastRadiusStatus.GATE_MISSING, BlastRadiusStatus.REGRESSED}
1220
+
1221
+
1222
+ # ---------------------------------------------------------------------------
1223
+ # Directives and advisories (agent-facing text)
1224
+ # ---------------------------------------------------------------------------
1225
+
1226
+
1227
+ BLAST_RADIUS_TURN_DIRECTIVE = (
1228
+ "Blast-radius protocol (a correct fix that breaks other tests is a failed task):\n"
1229
+ "- BEFORE you change any existing file, run the tests that cover the area you are "
1230
+ "about to touch (the module's own test file, and the tests around it) once, and keep "
1231
+ "their result. That run is your baseline: whatever already fails there is not yours, "
1232
+ "and I will not attribute it to your change. Without it I cannot tell your breakage "
1233
+ "apart from breakage that was already in the repo.\n"
1234
+ "- AFTER your fix verifies, run that same set again. Anything failing now that passed "
1235
+ "in the baseline is a regression you introduced, and it is part of your task.\n"
1236
+ "- Repair regressions by narrowing your change, not by widening it. If one change "
1237
+ "breaks a large number of previously passing tests, the approach itself is wrong: "
1238
+ "revert it and write a narrower patch rather than patching up each failing test.\n"
1239
+ "- Never delete, skip, or weaken an existing test to make it pass."
1240
+ )
1241
+
1242
+
1243
+ def build_blast_radius_scope_advisory(
1244
+ scope: BlastRadiusScope,
1245
+ *,
1246
+ has_baseline: bool,
1247
+ ) -> str:
1248
+ """The concrete scope, emitted once the first change to existing code lands."""
1249
+ if scope.empty:
1250
+ return ""
1251
+ command = scope.suggested_command()
1252
+ listed = ", ".join(scope.paths[:MAX_LISTED_IDS])
1253
+ extra = len(scope.paths) - min(len(scope.paths), MAX_LISTED_IDS)
1254
+ if extra > 0:
1255
+ listed += f" (+{extra} more)"
1256
+ lines = [
1257
+ "Blast-radius scope for your change: "
1258
+ + listed
1259
+ + ". These are the tests nearest what you touched - the ones that mirror it, "
1260
+ "import it, or sit in the same package.",
1261
+ ]
1262
+ if has_baseline:
1263
+ lines.append(
1264
+ "A clean-tree run already covers this scope, so I can attribute failures. "
1265
+ f"Re-run it after your fix (for example `{command}`) and make sure nothing "
1266
+ "that passed then fails now."
1267
+ )
1268
+ else:
1269
+ lines.append(
1270
+ "Nothing was run on the clean tree covering this scope, so failures here "
1271
+ "cannot yet be told apart from breakage that was already in the repo. Run it "
1272
+ f"after your fix anyway (for example `{command}`) and read the result against "
1273
+ "what you know about the repo - do not assume a failure is pre-existing."
1274
+ )
1275
+ lines.append(
1276
+ "Advisory only - this does not block your edit. Run any equivalent command; "
1277
+ "what matters is that these files are covered."
1278
+ )
1279
+ return " ".join(lines)
1280
+
1281
+
1282
+ def _format_ids(ids: Sequence[str]) -> str:
1283
+ listed = list(ids[:MAX_LISTED_IDS])
1284
+ rendered = ", ".join(listed)
1285
+ remaining = len(ids) - len(listed)
1286
+ if remaining > 0:
1287
+ rendered += f" (+{remaining} more)"
1288
+ return rendered
1289
+
1290
+
1291
+ def build_blast_radius_nudge_line(assessment: BlastRadiusAssessment) -> str:
1292
+ """The bounded repair nudge for the current blast-radius status."""
1293
+ if not assessment.applicable:
1294
+ return ""
1295
+ if assessment.status == BlastRadiusStatus.GATE_MISSING:
1296
+ command = assessment.scope.suggested_command()
1297
+ detail = f" (for example `{command}`)" if command else ""
1298
+ return (
1299
+ "- You have not run the tests around what you changed: "
1300
+ + _format_ids(assessment.scope.paths)
1301
+ + f". Run them now{detail} and confirm your change did not break them. A "
1302
+ "written explanation cannot clear this - only the run can."
1303
+ )
1304
+ if assessment.status != BlastRadiusStatus.REGRESSED:
1305
+ return ""
1306
+ baseline = assessment.baseline_command or "the clean-tree baseline"
1307
+ if assessment.over_broad:
1308
+ return (
1309
+ f"- Your change broke {len(assessment.new_failures)} tests that passed before it: "
1310
+ + _format_ids(assessment.new_failures)
1311
+ + f". That many failures from one change means the change itself is too broad, "
1312
+ f"not that each test needs fixing. Revert it and write a narrower patch that "
1313
+ f"touches only what the task requires, then re-run both your reproduction and "
1314
+ f"`{baseline}`. Do not edit, skip, or delete those tests."
1315
+ )
1316
+ return (
1317
+ "- Tests your change broke (they passed in the clean-tree baseline of "
1318
+ f"`{baseline}`): "
1319
+ + _format_ids(assessment.new_failures)
1320
+ + ". Fix them while keeping your fix intact - prefer narrowing your change over "
1321
+ "adding more of it - then re-run both your reproduction and this scope. Do not "
1322
+ "edit, skip, or delete those tests to make them pass."
1323
+ )
1324
+
1325
+
1326
+ _STATUS_SUMMARY_LINES = {
1327
+ BlastRadiusStatus.CLEAN: (
1328
+ "Blast radius: re-ran {count} nearby test file(s) after the fix{detail}; nothing "
1329
+ "that passed before it fails now."
1330
+ ),
1331
+ BlastRadiusStatus.REGRESSED: (
1332
+ "⛔ REGRESSIONS INTRODUCED — {n} test(s) that passed before my change now fail: "
1333
+ "{ids}. Baseline: `{baseline}`. I could not clear these within this run, so this "
1334
+ "result ships with KNOWN BREAKAGE outside the fix itself."
1335
+ ),
1336
+ BlastRadiusStatus.UNATTRIBUTED: (
1337
+ "⚠️ Blast radius: ran {count} nearby test file(s) after the fix{detail}, but with no "
1338
+ "clean-tree run covering them {attribution_detail}"
1339
+ ),
1340
+ BlastRadiusStatus.GATE_MISSING: (
1341
+ "⚠️ Blast radius: the tests around what I changed ({count} file(s)) were never run "
1342
+ "after the fix, so nothing confirms the change did not break them."
1343
+ ),
1344
+ BlastRadiusStatus.UNREADABLE: (
1345
+ "⚠️ Blast radius: the tests around what I changed ({count} file(s)) ran after the "
1346
+ "fix{detail}, but I could not read per-test results out of the runner's output, so "
1347
+ "I cannot say whether the change broke any of them."
1348
+ ),
1349
+ }
1350
+
1351
+
1352
+ def build_blast_radius_status_summary(assessment: BlastRadiusAssessment) -> str:
1353
+ """The visible blast-radius line appended to the summary.
1354
+
1355
+ Emitted for every applicable turn, satisfied or not: a clean result says so
1356
+ plainly, and an unresolved regression leads with the failures. Reporting success
1357
+ without naming what else the change touched is the failure mode this whole step
1358
+ exists to remove, so silence is never an option here.
1359
+ """
1360
+ if not assessment.applicable:
1361
+ return ""
1362
+ template = _STATUS_SUMMARY_LINES.get(assessment.status, "")
1363
+ if not template:
1364
+ return ""
1365
+ command = (assessment.gate_command or "").strip()
1366
+ unattributed_ids = assessment.new_failures or assessment.unattributed
1367
+ attribution_detail = (
1368
+ "I cannot tell whether these failures are mine: "
1369
+ f"{_format_ids(unattributed_ids)}. Their cause is UNATTRIBUTED — neither confirmed "
1370
+ "pre-existing nor confirmed a regression."
1371
+ if unattributed_ids
1372
+ else (
1373
+ "no failures were reported, but the clean result is UNATTRIBUTED because it "
1374
+ "cannot be compared with a baseline."
1375
+ )
1376
+ )
1377
+ line = template.format(
1378
+ count=len(assessment.scope.paths),
1379
+ detail=f" (`{command}`)" if command else "",
1380
+ n=len(assessment.new_failures),
1381
+ # Whichever column this status is about: proven breakage, else the failures
1382
+ # that could not be attributed.
1383
+ ids=_format_ids(unattributed_ids),
1384
+ attribution_detail=attribution_detail,
1385
+ baseline=assessment.baseline_command or "the clean-tree baseline",
1386
+ )
1387
+ if assessment.status == BlastRadiusStatus.REGRESSED and assessment.over_broad:
1388
+ line += (
1389
+ f" Breaking {len(assessment.new_failures)} previously passing tests means the "
1390
+ "change is over-broad and should be rewritten narrowly, not patched up test by "
1391
+ "test."
1392
+ )
1393
+ if assessment.scope.dropped_for_runtime:
1394
+ line += (
1395
+ f" Scope was shrunk to stay inside the runtime cap; "
1396
+ f"{len(assessment.scope.dropped_for_runtime)} further test file(s) were not run."
1397
+ )
1398
+ elif assessment.scope.dropped_for_cap:
1399
+ line += (
1400
+ f" Scope was capped; {len(assessment.scope.dropped_for_cap)} further test file(s) "
1401
+ "were not run."
1402
+ )
1403
+ return f"\n\n---\n{line}"