py-wlcli 0.8.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 (320) hide show
  1. py_wlcli-0.8.0.dist-info/METADATA +139 -0
  2. py_wlcli-0.8.0.dist-info/RECORD +320 -0
  3. py_wlcli-0.8.0.dist-info/WHEEL +4 -0
  4. py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
  5. py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
  6. wl_cli/__init__.py +27 -0
  7. wl_cli/__main__.py +130 -0
  8. wl_cli/capabilities.py +79 -0
  9. wl_cli/cc/__init__.py +5 -0
  10. wl_cli/cc/checks.py +1019 -0
  11. wl_cli/cc/cli.py +370 -0
  12. wl_cli/cc/mutate.py +1350 -0
  13. wl_cli/cli_click.py +156 -0
  14. wl_cli/commands/__init__.py +84 -0
  15. wl_cli/commands/base.py +80 -0
  16. wl_cli/commands/buildcommands/__init__.py +1 -0
  17. wl_cli/commands/buildcommands/build.py +71 -0
  18. wl_cli/commands/buildcommands/build_utils.py +47 -0
  19. wl_cli/commands/buildcommands/builders/__init__.py +18 -0
  20. wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
  21. wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
  22. wl_cli/commands/buildcommands/builders/environment.py +81 -0
  23. wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
  24. wl_cli/commands/buildcommands/builders/stubs.py +58 -0
  25. wl_cli/commands/buildcommands/dist.py +326 -0
  26. wl_cli/commands/buildcommands/test.py +89 -0
  27. wl_cli/commands/clean/__init__.py +1 -0
  28. wl_cli/commands/clean/build.py +151 -0
  29. wl_cli/commands/clean/clean.py +294 -0
  30. wl_cli/commands/clean/clean_utils.py +77 -0
  31. wl_cli/commands/clean/core.py +236 -0
  32. wl_cli/commands/clean/dead_code/__init__.py +0 -0
  33. wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
  34. wl_cli/commands/clean/dead_code/detector.py +161 -0
  35. wl_cli/commands/clean/dead_code/fixer.py +176 -0
  36. wl_cli/commands/clean/dead_code/reporter.py +295 -0
  37. wl_cli/commands/clean/dead_code/utils.py +0 -0
  38. wl_cli/commands/clean/lfs.py +215 -0
  39. wl_cli/commands/clean/python.py +81 -0
  40. wl_cli/commands/clean/rust.py +91 -0
  41. wl_cli/commands/clean/utils/__init__.py +34 -0
  42. wl_cli/commands/clean/venv.py +91 -0
  43. wl_cli/commands/cleanup.py +73 -0
  44. wl_cli/commands/click_base.py +79 -0
  45. wl_cli/commands/config/__init__.py +1 -0
  46. wl_cli/commands/config/config.py +291 -0
  47. wl_cli/commands/debug/__init__.py +6 -0
  48. wl_cli/commands/debug/debug.py +309 -0
  49. wl_cli/commands/debug/debug_cache.py +86 -0
  50. wl_cli/commands/debug/debug_subagent.py +156 -0
  51. wl_cli/commands/debug/output_formatter.py +165 -0
  52. wl_cli/commands/debug/source_hasher.py +92 -0
  53. wl_cli/commands/format/__init__.py +13 -0
  54. wl_cli/commands/format/format_cache_adapter.py +125 -0
  55. wl_cli/commands/format/format_command.py +197 -0
  56. wl_cli/commands/format/format_coordinator.py +191 -0
  57. wl_cli/commands/format/format_executor.py +470 -0
  58. wl_cli/commands/format/format_outcome.py +44 -0
  59. wl_cli/commands/format/format_path_handler.py +1016 -0
  60. wl_cli/commands/format/format_progress.py +62 -0
  61. wl_cli/commands/format/format_reporter.py +31 -0
  62. wl_cli/commands/format/python_formatter.py +506 -0
  63. wl_cli/commands/format/rust_formatter.py +106 -0
  64. wl_cli/commands/gate/__init__.py +1 -0
  65. wl_cli/commands/gate/gate_command.py +862 -0
  66. wl_cli/commands/gate/preflight.py +445 -0
  67. wl_cli/commands/git/__init__.py +1 -0
  68. wl_cli/commands/git/config.py +169 -0
  69. wl_cli/commands/git/git_facts.py +60 -0
  70. wl_cli/commands/git/git_flow.py +86 -0
  71. wl_cli/commands/git/hook.py +112 -0
  72. wl_cli/commands/git/policy.py +216 -0
  73. wl_cli/commands/git/push_update.py +82 -0
  74. wl_cli/commands/initenv/__init__.py +1 -0
  75. wl_cli/commands/initenv/common/__init__.py +23 -0
  76. wl_cli/commands/initenv/common/check_git_bash.py +78 -0
  77. wl_cli/commands/initenv/common/error_handler.py +68 -0
  78. wl_cli/commands/initenv/common/exceptions.py +52 -0
  79. wl_cli/commands/initenv/components/__init__.py +4 -0
  80. wl_cli/commands/initenv/components/cargo_sync.py +240 -0
  81. wl_cli/commands/initenv/components/config_manager.py +106 -0
  82. wl_cli/commands/initenv/components/directory_utils.py +71 -0
  83. wl_cli/commands/initenv/components/git/__init__.py +9 -0
  84. wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
  85. wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
  86. wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
  87. wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
  88. wl_cli/commands/initenv/components/git/git_utility.py +254 -0
  89. wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
  90. wl_cli/commands/initenv/components/git_initializer.py +212 -0
  91. wl_cli/commands/initenv/components/i18n_manager.py +104 -0
  92. wl_cli/commands/initenv/components/log_manager.py +241 -0
  93. wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
  94. wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
  95. wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
  96. wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
  97. wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
  98. wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
  99. wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
  100. wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
  101. wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
  102. wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
  103. wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
  104. wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
  105. wl_cli/commands/initenv/components/rust_initializer.py +145 -0
  106. wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
  107. wl_cli/commands/initenv/components/symlink_manager.py +52 -0
  108. wl_cli/commands/initenv/components/venv_manager.py +118 -0
  109. wl_cli/commands/initenv/initenv.py +169 -0
  110. wl_cli/commands/initenv/services/__init__.py +11 -0
  111. wl_cli/commands/initenv/services/environment_initializer.py +288 -0
  112. wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
  113. wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
  114. wl_cli/commands/lfs/README.md +221 -0
  115. wl_cli/commands/lfs/__init__.py +5 -0
  116. wl_cli/commands/lfs/cli.py +106 -0
  117. wl_cli/commands/lfs/config.py +164 -0
  118. wl_cli/commands/lfs/hook.py +61 -0
  119. wl_cli/commands/lfs/initializer.py +531 -0
  120. wl_cli/commands/lfs/manager.py +95 -0
  121. wl_cli/commands/lfs/policy.py +165 -0
  122. wl_cli/commands/lfs/watcher.py +294 -0
  123. wl_cli/commands/lint/__init__.py +1 -0
  124. wl_cli/commands/lint/lint_command.py +297 -0
  125. wl_cli/commands/lint/lint_executor.py +409 -0
  126. wl_cli/commands/lint/lint_reporter.py +579 -0
  127. wl_cli/commands/lint/lint_run.py +258 -0
  128. wl_cli/commands/lint/mypy_config.py +90 -0
  129. wl_cli/commands/registry.py +103 -0
  130. wl_cli/commands/self/__init__.py +7 -0
  131. wl_cli/commands/self/common/__init__.py +8 -0
  132. wl_cli/commands/self/common/capabilities.py +30 -0
  133. wl_cli/commands/self/common/config.py +27 -0
  134. wl_cli/commands/self/common/version_cache.py +85 -0
  135. wl_cli/commands/self/handlers/__init__.py +19 -0
  136. wl_cli/commands/self/handlers/base_handler.py +28 -0
  137. wl_cli/commands/self/handlers/install_handler.py +85 -0
  138. wl_cli/commands/self/handlers/status_handler.py +66 -0
  139. wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
  140. wl_cli/commands/self/handlers/update_handler.py +102 -0
  141. wl_cli/commands/self/handlers/version_handler.py +16 -0
  142. wl_cli/commands/self/self_command.py +237 -0
  143. wl_cli/commands/self/services/__init__.py +15 -0
  144. wl_cli/commands/self/services/installation_manager.py +187 -0
  145. wl_cli/commands/self/services/local_installer.py +165 -0
  146. wl_cli/commands/self/services/pypi_updater.py +278 -0
  147. wl_cli/commands/self/services/windows_update.py +174 -0
  148. wl_cli/commands/testcommands/__init__.py +8 -0
  149. wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
  150. wl_cli/commands/testcommands/batch_processor.py +278 -0
  151. wl_cli/commands/testcommands/cache_manager.py +1156 -0
  152. wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
  153. wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
  154. wl_cli/commands/testcommands/progress_manager.py +79 -0
  155. wl_cli/commands/testcommands/python_env_detector.py +207 -0
  156. wl_cli/commands/testcommands/report_generator.py +101 -0
  157. wl_cli/commands/testcommands/single_test_executor.py +250 -0
  158. wl_cli/commands/testcommands/speed_checker.py +118 -0
  159. wl_cli/commands/testcommands/test_command_builder.py +165 -0
  160. wl_cli/commands/testcommands/test_commands.py +786 -0
  161. wl_cli/commands/testcommands/test_reporter.py +181 -0
  162. wl_cli/commands/testcommands/test_result_handler.py +96 -0
  163. wl_cli/commands/testcommands/test_runner.py +1343 -0
  164. wl_cli/commands/testcommands/test_utils.py +197 -0
  165. wl_cli/commands/testcommands/utils/__init__.py +17 -0
  166. wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
  167. wl_cli/commands/testcommands/utils/env_info.py +36 -0
  168. wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
  169. wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
  170. wl_cli/commands/testcommands/utils/project_root.py +61 -0
  171. wl_cli/commands/update/__init__.py +9 -0
  172. wl_cli/commands/update/services/__init__.py +21 -0
  173. wl_cli/commands/update/services/backup_manager.py +207 -0
  174. wl_cli/commands/update/services/component_manager.py +83 -0
  175. wl_cli/commands/update/services/conflict_resolver.py +189 -0
  176. wl_cli/commands/update/services/vendors_updater.py +85 -0
  177. wl_cli/commands/update/services/version_tracker.py +127 -0
  178. wl_cli/commands/update/update_command.py +270 -0
  179. wl_cli/commands/update/utils/__init__.py +3 -0
  180. wl_cli/commands/version/__init__.py +9 -0
  181. wl_cli/commands/version/version_command.py +155 -0
  182. wl_cli/commands/workflow.py +477 -0
  183. wl_cli/env/__init__.py +5 -0
  184. wl_cli/env/cli.py +167 -0
  185. wl_cli/env_status.py +157 -0
  186. wl_cli/envelope.py +91 -0
  187. wl_cli/exceptions.py +85 -0
  188. wl_cli/lib/__init__.py +3 -0
  189. wl_cli/lib/rust_utils.py +35 -0
  190. wl_cli/mcp/__init__.py +6 -0
  191. wl_cli/mcp/capabilities.py +19 -0
  192. wl_cli/mcp/cli.py +22 -0
  193. wl_cli/mcp/gitlab.py +553 -0
  194. wl_cli/mcp/k8s.py +344 -0
  195. wl_cli/mcp/protocol.py +159 -0
  196. wl_cli/platform.py +139 -0
  197. wl_cli/quality/__init__.py +18 -0
  198. wl_cli/utils/__init__.py +7 -0
  199. wl_cli/utils/cache.py +491 -0
  200. wl_cli/utils/cache_ledger.py +355 -0
  201. wl_cli/utils/cache_store.py +546 -0
  202. wl_cli/utils/config/__init__.py +27 -0
  203. wl_cli/utils/config/config_loader.py +163 -0
  204. wl_cli/utils/config/core.py +862 -0
  205. wl_cli/utils/config/defaults.py +142 -0
  206. wl_cli/utils/config/env_exporter.py +86 -0
  207. wl_cli/utils/config/env_parser.py +108 -0
  208. wl_cli/utils/config/validation.py +280 -0
  209. wl_cli/utils/error_handler.py +165 -0
  210. wl_cli/utils/file_copier.py +56 -0
  211. wl_cli/utils/file_operations.py +506 -0
  212. wl_cli/utils/language_utils.py +122 -0
  213. wl_cli/utils/lazy_import.py +240 -0
  214. wl_cli/utils/log_levels.py +35 -0
  215. wl_cli/utils/log_rotators.py +74 -0
  216. wl_cli/utils/logging.py +458 -0
  217. wl_cli/utils/output_context.py +142 -0
  218. wl_cli/utils/performance/__init__.py +1 -0
  219. wl_cli/utils/performance/decorators.py +111 -0
  220. wl_cli/utils/performance/exporter.py +38 -0
  221. wl_cli/utils/performance/models.py +16 -0
  222. wl_cli/utils/performance/monitor.py +161 -0
  223. wl_cli/utils/performance_monitor.py +32 -0
  224. wl_cli/utils/platform_adapter.py +131 -0
  225. wl_cli/utils/pre_commit.py +266 -0
  226. wl_cli/utils/pre_commit_config.py +72 -0
  227. wl_cli/utils/root.py +521 -0
  228. wl_cli/utils/root_fallback.py +256 -0
  229. wl_cli/utils/run_evidence.py +236 -0
  230. wl_cli/utils/structured_logger.py +18 -0
  231. wl_cli/utils/structured_logging/__init__.py +19 -0
  232. wl_cli/utils/structured_logging/config.py +337 -0
  233. wl_cli/utils/structured_logging/core.py +177 -0
  234. wl_cli/utils/structured_logging/env.py +56 -0
  235. wl_cli/utils/structured_logging/filtering.py +75 -0
  236. wl_cli/utils/structured_logging/filters.py +55 -0
  237. wl_cli/utils/structured_logging/formatters.py +251 -0
  238. wl_cli/utils/structured_logging/handlers.py +308 -0
  239. wl_cli/utils/structured_logging/logger.py +354 -0
  240. wl_cli/utils/subprocess.py +71 -0
  241. wl_cli/utils/subprocess_utils.py +260 -0
  242. wl_cli/utils/test_utils/__init__.py +5 -0
  243. wl_cli/utils/test_utils/test_filter.py +36 -0
  244. wl_cli/utils/uv_run.py +79 -0
  245. wl_cli/utils/uv_tool.py +117 -0
  246. wl_cli/utils/uv_tree.py +71 -0
  247. wl_cli/utils/uv_workspace.py +174 -0
  248. wl_cli/utils/version/__init__.py +15 -0
  249. wl_cli/utils/version/pypi_version_checker.py +95 -0
  250. wl_cli/utils/version/version_comparator.py +133 -0
  251. wl_cli/utils/version/version_detectors.py +312 -0
  252. wl_cli/utils/version/version_service.py +131 -0
  253. wl_cli/utils/version/version_updater.py +198 -0
  254. wl_cli/utils/wl_dir_updater.py +1440 -0
  255. wl_cli/utils/workspace_detector/__init__.py +28 -0
  256. wl_cli/utils/workspace_detector/core.py +161 -0
  257. wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
  258. wl_cli/utils/workspace_detector/exceptions.py +5 -0
  259. wl_cli/utils/workspace_detector/types.py +17 -0
  260. wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
  261. wl_cli/vendors/config/.codespellrc +2 -0
  262. wl_cli/vendors/config/dependencies.toml +10 -0
  263. wl_cli/vendors/config/mypy.ini +62 -0
  264. wl_cli/vendors/config/pyproject_defaults.toml +28 -0
  265. wl_cli/vendors/config/template_config.toml +24 -0
  266. wl_cli/vendors/config/tool_configs.toml +76 -0
  267. wl_cli/vendors/git/.gitignore +122 -0
  268. wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
  269. wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
  270. wl_cli/vendors/hooks/commit-msg.sample +24 -0
  271. wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
  272. wl_cli/vendors/hooks/post-update.sample +8 -0
  273. wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
  274. wl_cli/vendors/hooks/pre-commit +45 -0
  275. wl_cli/vendors/hooks/pre-commit.legacy +31 -0
  276. wl_cli/vendors/hooks/pre-commit.sample +49 -0
  277. wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
  278. wl_cli/vendors/hooks/pre-push.sample +60 -0
  279. wl_cli/vendors/hooks/pre-rebase.sample +169 -0
  280. wl_cli/vendors/hooks/pre-receive.sample +24 -0
  281. wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
  282. wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
  283. wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
  284. wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
  285. wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
  286. wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
  287. wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
  288. wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
  289. wl_cli/vendors/hooks/unix/post-update.sample +8 -0
  290. wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
  291. wl_cli/vendors/hooks/unix/pre-commit +190 -0
  292. wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
  293. wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
  294. wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
  295. wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
  296. wl_cli/vendors/hooks/unix/pre-push +417 -0
  297. wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
  298. wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
  299. wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
  300. wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
  301. wl_cli/vendors/hooks/update.sample +128 -0
  302. wl_cli/vendors/hooks/win/work.bat +63 -0
  303. wl_cli/vendors/project/.gitignore +90 -0
  304. wl_cli/vendors/project/.python-version +1 -0
  305. wl_cli/vendors/project/LICENSE +21 -0
  306. wl_cli/vendors/project/README.md +0 -0
  307. wl_cli/vendors/project/pyproject.toml +27 -0
  308. wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
  309. wl_cli/vendors/project/pyproject_workspace.toml +14 -0
  310. wl_cli/vendors/readme/README.md +120 -0
  311. wl_cli/vendors/rust/Cargo.toml +14 -0
  312. wl_cli/vendors/rust/lib/__init__.py +3 -0
  313. wl_cli/vendors/rust/lib/rust_utils.py +31 -0
  314. wl_cli/vendors/rust/src/lib.rs +13 -0
  315. wl_cli/vendors/rust/src/main.rs +3 -0
  316. wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
  317. wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
  318. wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
  319. wl_cli/vendors/sop_doc/python-sop.md +342 -0
  320. wl_cli/vendors/sop_doc/rust-readme.md +219 -0
@@ -0,0 +1,862 @@
1
+ """``wl gate`` - the single canonical local quality gate.
2
+
3
+ Before the chain starts, a step-0 preflight (see :mod:`.preflight`) checks that
4
+ the subjects this chain is about to execute are actually available -- the
5
+ executables PATH will resolve for it, and the plugins inside the interpreter its
6
+ test step will use. If not, the gate fails fast with a named, actionable error
7
+ and never enters the chain: its red can then only ever mean "your change is
8
+ broken", never "this machine is missing packages".
9
+
10
+ Two failure modes are kept apart (review N1). An *environment* gap -- a subject
11
+ preflight cannot find, or an executable that will not start at all -- exits
12
+ :data:`~.preflight.EXIT_ENV_MISSING` (78) and never produces a step line. A step
13
+ that *ran and was then killed by a signal* is a signalled step, not an
14
+ environment gap, and exits ``128 + signum`` instead: the step's own death can
15
+ well be caused by the change under test (an oversized suite OOM-killing its
16
+ runner), so it must not be read as "go fix the machine".
17
+
18
+ The two are told apart by :attr:`SubprocessResult.launch_error`, never by the
19
+ sign of the exit code: ``subprocess`` reports a signalled child as ``-signum``,
20
+ so SIGHUP (``-1``) would otherwise be indistinguishable from the launch-failure
21
+ sentinel and would be mislabelled as an environment gap (review F1).
22
+
23
+ Runs the repo's full local quality chain sequentially, stopping at the first
24
+ failure:
25
+
26
+ 1. ``ruff check`` (lint, excluding vendored assets)
27
+ 2. ``mypy`` over ``src/`` (the repo's own mypy contract and config)
28
+ 3. ``wl test -s --no-cache`` (the repo's pytest runner, which stops at the first
29
+ failure -- and never reuses a cached result: a cache hit did not exercise the
30
+ tree under test, so it is not a pass, #193). Its freshness verdict is read
31
+ from the run's structured evidence file (``--evidence-json``), never parsed
32
+ out of stdout (#212, ADR-0010)
33
+ 4. ``ruff format --check`` (format, excluding vendored assets)
34
+ 5. ``pre-commit run --all-files`` (the managed hook suite in
35
+ ``.wl/.pre-commit-config.yaml``, #193)
36
+
37
+ The command is a thin orchestrator: it does not reimplement any check. The
38
+ ruff/mypy command lines come from :class:`LintExecutor`, pytest is delegated to
39
+ the existing ``wl test`` subcommand, the hook suite is judged through
40
+ :class:`~wl_cli.utils.pre_commit.PreCommitRun`, and every step runs through the
41
+ shared :class:`SubprocessExecutor`.
42
+
43
+ Every leg runs in the deploy root, resolved **once** into a :class:`GateChain`
44
+ (see :meth:`GateChain.resolve`); the judging preflight and each executing step
45
+ then read that one value, so the two can never disagree about where the chain
46
+ runs (issue #110). The mypy leg is the one exception: a monorepo whose deploy
47
+ root has no ``src/`` would otherwise be red forever, so that leg alone moves to
48
+ the project root, and only :meth:`GateChain.resolve` decides that -- the anchor
49
+ is derived once, not re-derived per leg (see :func:`resolve_mypy_anchor`).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ import sys
56
+ import tempfile
57
+ from collections.abc import Callable, Sequence
58
+ from dataclasses import dataclass
59
+ from pathlib import Path
60
+
61
+ import click
62
+
63
+ from ...utils.pre_commit import PreCommitRun
64
+ from ...utils.root import Root, RootKind
65
+ from ...utils.root_fallback import current_root
66
+ from ...utils.run_evidence import TestRunEvidence
67
+ from ...utils.subprocess_utils import SubprocessExecutor, SubprocessResult
68
+ from ..lint.lint_executor import LintExecutor
69
+ from ..lint.mypy_config import mypy_config_file
70
+ from .preflight import (
71
+ EXIT_ENV_MISSING,
72
+ PreflightReport,
73
+ render_env_error,
74
+ render_launch_error,
75
+ render_signal_error,
76
+ run_preflight,
77
+ signal_exit_code,
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class GateStep:
83
+ """One named check in the gate chain.
84
+
85
+ *cwd* is where this one step runs -- an explicit path for **every** step,
86
+ never a ``None`` "use the chain root" convention. The chain root is resolved
87
+ once into a :class:`GateChain` and written onto each step by
88
+ :meth:`GateCommand.build_steps`, so a step's own record says where it runs
89
+ and no later reader has to agree with a *separate* variable about what
90
+ ``None`` meant.
91
+ """
92
+
93
+ name: str
94
+ command: list[str]
95
+ cwd: Path
96
+ probe_interpreter: bool = False
97
+ """True on the one step whose interpreter the step-0 preflight must probe.
98
+
99
+ The interpreter the chain's test runner will use is decided by the directory
100
+ that runner is launched in, so preflight reads *this* step's ``cwd`` -- the
101
+ very field :meth:`GateCommand.execute` hands the executor. The judged root
102
+ and the executed root are therefore the same field, not two values kept in
103
+ step by hand (issue #110).
104
+ """
105
+
106
+ capture: bool = False
107
+ """Whether the executor must capture this step's output instead of streaming it.
108
+
109
+ The pre-commit leg's *stdout* carries its verdict -- the hook blocks that
110
+ :class:`~wl_cli.utils.pre_commit.PreCommitRun` judges -- so it is captured.
111
+ The test leg keeps capture as well, so a long run is replayed as one block
112
+ after the step instead of interleaving with the ``[i/n]`` progress lines;
113
+ its verdict, however, is no longer read from stdout: it comes from the
114
+ structured evidence file named by :attr:`evidence_path` (#212, ADR-0010).
115
+ Every other leg streams its output live.
116
+ """
117
+
118
+ reject_cached_results: bool = False
119
+ """True on the test leg: a successful run that still reused cached results is
120
+ not a pass.
121
+
122
+ ``--no-cache`` is what keeps the leg fresh; this flag is the *verdict* that
123
+ makes the guarantee executable rather than aspirational. It is judged from
124
+ the run's structured evidence (:attr:`evidence_path`): ``cache_hits > 0``
125
+ is red even when the step exited 0, because a cache hit tested an older
126
+ tree and "all checks passed" after one would be a false green (#193,
127
+ #212). The evidence's ``no_cache_requested`` fact only chooses the wording
128
+ -- the runner ignoring its flag vs. the gate failing to pass it.
129
+ """
130
+
131
+ evidence_path: Path | None = None
132
+ """Where the test leg writes its structured run evidence (#212, ADR-0010).
133
+
134
+ :meth:`GateCommand.execute` creates this temporary path and passes it to
135
+ :meth:`GateCommand.build_steps`, which hands it to ``wl test`` as
136
+ ``--evidence-json``. ``execute`` reads it for the verdict and removes it in
137
+ a ``finally`` -- so one run's evidence can never be read by the next. A
138
+ missing, corrupt or unknown-version file is red, named by path: there is
139
+ deliberately no stdout fallback. Building the steps alone creates no file
140
+ (review M1): the path is an *input* here, never a side effect.
141
+ """
142
+
143
+ managed_config: Path | None = None
144
+ """Set on the pre-commit step: the managed config the run is judged against.
145
+
146
+ The step is *skipped* (never failed) when this file is absent, so a checkout
147
+ that was never ``wl init``-ed does not get a phantom red; the skip is a
148
+ visible ``- skipped`` line, never silence. When it exists it is passed to
149
+ ``pre-commit`` as ``--config``, so the run judges exactly the hooks wl
150
+ deploys -- not whatever ``.pre-commit-config.yaml`` happens to sit in the
151
+ user's directory.
152
+ """
153
+
154
+ @property
155
+ def is_skipped(self) -> bool:
156
+ """True when this step declares a managed config that is not there.
157
+
158
+ The **one** answer to "will this step run?", read by both readers that
159
+ must agree: :meth:`GateCommand.execute` (which prints the skip and moves
160
+ on) and :func:`~wl_cli.commands.gate.preflight.requirements_for` (which
161
+ must not demand the executable of a step that will never be launched).
162
+ Before rv193 those were two separate judgements, so a repo with no
163
+ managed hooks still had ``pre-commit`` listed as a required subject and
164
+ the gate exited 78 over a leg it was going to skip.
165
+ """
166
+ return self.managed_config is not None and not _is_file(self.managed_config)
167
+
168
+
169
+ #: How :class:`GateCommand` obtains its step-0 verdict. The default is the real
170
+ #: probe; tests inject a stub so the orchestration can be exercised without
171
+ #: reaching the machine (and without monkey-patching a module global).
172
+ PreflightRunner = Callable[[Sequence[GateStep]], PreflightReport]
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class GateChain:
177
+ """The roots one ``wl gate`` run is anchored on -- resolved once.
178
+
179
+ ``root`` is the **chain root**: the deploy root every regular step runs in.
180
+ ``mypy_root`` is where the mypy leg alone runs -- equal to ``root`` in the
181
+ regular layout, the project root in the monorepo fallback -- and
182
+ ``mypy_notice`` is the one line explaining a fallback that happened. Both
183
+ roots are explicit :class:`Root` values, so there is no bare path for a
184
+ later reader to re-resolve and no ``None`` for it to interpret.
185
+
186
+ :meth:`resolve` is the **only** place a gate root is computed. The judging
187
+ preflight and every executing step then read this one value, so "which root
188
+ the chain uses" cannot drift between them -- the drift that once let
189
+ preflight judge one interpreter while ``wl test`` ran under another (#110),
190
+ and the reason the mypy anchor used to be derived twice.
191
+ """
192
+
193
+ root: Root
194
+ mypy_root: Root
195
+ mypy_notice: str | None = None
196
+
197
+ @classmethod
198
+ def resolve(cls) -> GateChain:
199
+ """Resolve the chain root and the mypy anchor for the invocation CWD.
200
+
201
+ The deploy root comes from the one fallback policy (#160): ``wl gate``
202
+ is a single-root consumer, so it never builds a ``RootContext``.
203
+ """
204
+ root = current_root(RootKind.DEPLOY)
205
+ mypy_root, notice = resolve_mypy_anchor(root)
206
+ return cls(root=root, mypy_root=mypy_root, mypy_notice=notice)
207
+
208
+
209
+ def resolve_mypy_anchor(deploy_root: Root) -> tuple[Root, str | None]:
210
+ """The root the mypy leg anchors to, plus the notice if it is not *deploy_root*.
211
+
212
+ Every leg of the chain runs in the deploy root -- the git work-tree root,
213
+ where the repo's ``.wl`` (its ``mypy.ini`` baseline) and its ``src/``
214
+ contract live, and the directory CI runs the same commands from. The mypy
215
+ leg alone may move, and only when staying put would make the gate
216
+ permanently red for a reason the change cannot fix: a monorepo whose git
217
+ work-tree root is a bare container (no ``src/``, no ``.wl``) while each
218
+ package carries its own. ``mypy --config-file <vendored> src/`` there can
219
+ only ever report ``src: Cannot read file`` (exit 2) -- a red that never
220
+ clears, whatever the code, and one that says *less* than the pre-#95 signal
221
+ did (exit 1 pointing at a real type error inside the package).
222
+
223
+ So when the deploy root has no ``src/`` to check, the leg anchors at the
224
+ project root (``current_root(RootKind.PROJECT)``, the one fallback policy) --
225
+ the package the user is actually in -- and the returned notice says why and
226
+ where. The fallback
227
+ moves the root, never the contract: the scope stays ``src/`` and nothing
228
+ else, so it can never widen onto directories the change has nothing to do
229
+ with (review F0). When the project root *is* the deploy root (no ``src/``
230
+ anywhere) there is nothing to fall back to: the root is returned unchanged
231
+ and no notice is printed, so the leg keeps failing on its own terms exactly
232
+ as it did before this fallback existed.
233
+
234
+ This is the single derivation point for the anchor: :meth:`GateChain.resolve`
235
+ calls it once and both the mypy leg's command and the notices read the value
236
+ it stored, so the leg is never anchored at one root while it is *judged*
237
+ against another.
238
+
239
+ #193 adds the mirror-image case to the one above. When the deploy root *does*
240
+ carry ``src/`` but the invocation's project root is a **strict subdirectory**
241
+ of it that carries its own ``src/``, the deploy root's ``src/`` is the wrong
242
+ tree: in a monorepo whose git root has a (clean) ``src/`` and whose package
243
+ ``packages/pkgA`` has the type error, staying put judged ``mono2/src`` and
244
+ reported "all checks passed" over code the user never touched -- a false
245
+ green with no hint at all (issue #193, evidence 3). The leg therefore anchors
246
+ at that nested project root, and the notice says so and names both roots.
247
+
248
+ Only a *strict subdirectory* moves the anchor. A project root that is the
249
+ deploy root itself (the regular layout) or an **ancestor** of it (a workspace
250
+ ``.wl`` above a member repo) is not the package the user is in, and moving
251
+ there is exactly the #95/#96 regression these legs were fixed to stop.
252
+ """
253
+ project_root = current_root(RootKind.PROJECT)
254
+
255
+ if _has_src(deploy_root):
256
+ # Deploy root can honour the ``src/`` contract, so the only reason to
257
+ # move is a *nested* project that is a better anchor for this run.
258
+ if _is_strict_subdirectory(project_root.path, deploy_root.path) and _has_src(
259
+ project_root
260
+ ):
261
+ notice = (
262
+ f"wl gate: note: the project root {project_root.path} is a "
263
+ f"subdirectory of the gate root {deploy_root.path} and carries its "
264
+ f"own 'src/', so the mypy leg anchors there (its own .wl/mypy.ini "
265
+ f"and src/ apply); the other legs stay at {deploy_root.path}."
266
+ )
267
+ return project_root, notice
268
+ return deploy_root, None
269
+
270
+ if _same_path(project_root.path, deploy_root.path):
271
+ return deploy_root, None
272
+
273
+ notice = (
274
+ f"wl gate: note: the gate root {deploy_root.path} has no 'src/', so the "
275
+ f"mypy leg falls back to the project root {project_root.path} (its own "
276
+ f".wl/mypy.ini and src/ apply); the other legs stay at {deploy_root.path}."
277
+ )
278
+ return project_root, notice
279
+
280
+
281
+ def mypy_scope_notice(chain: GateChain) -> str | None:
282
+ """The line to print when the mypy leg cannot see the package you ran from.
283
+
284
+ The leg checks ``<mypy_root>/src/`` and nothing else (a contract, never a
285
+ widened scope -- see :meth:`GateCommand.build_steps`). That is right when the
286
+ leg's root *is* the package the user is in, and it is wrong -- silently -- in
287
+ the other direction: a git root that carries ``src/`` while the user stands
288
+ in a nested package whose ``src/`` the leg does not check. The gate then
289
+ reports green over a tree that never contained the change under test.
290
+
291
+ So the notice is **unconditional**: it does not depend on a fallback having
292
+ happened, only on the scope genuinely not covering the invocation's project
293
+ root. It is ``None`` in every layout where the leg does cover it:
294
+
295
+ * the regular layout -- the project root *is* the deploy root;
296
+ * an ancestor project root (a workspace ``.wl`` above a member repo) -- the
297
+ user is in the member repo, which is the leg's root;
298
+ * a nested project root the leg anchored at (the #193 rule in
299
+ :func:`resolve_mypy_anchor`) -- the leg checks exactly that package.
300
+
301
+ Printed on stderr so the parseable ``[i/n]`` progress on stdout is untouched.
302
+ """
303
+ project_root = current_root(RootKind.PROJECT)
304
+ if not _is_strict_subdirectory(project_root.path, chain.root.path):
305
+ return None
306
+ if _same_path(project_root.path, chain.mypy_root.path):
307
+ return None
308
+ return (
309
+ f"wl gate: note: the mypy leg checks {chain.mypy_root.src_dir()} and that "
310
+ f"scope does not include the package you ran from ({project_root.path}), "
311
+ f"so type errors there are NOT reported by this gate."
312
+ )
313
+
314
+
315
+ def _has_src(root: Root) -> bool:
316
+ """Whether *root* carries a ``src/`` directory, without letting I/O raise."""
317
+ try:
318
+ return root.src_dir().is_dir()
319
+ except OSError:
320
+ return False
321
+
322
+
323
+ def _is_strict_subdirectory(child: Path, parent: Path) -> bool:
324
+ """True when *child* lies strictly below *parent* (same directory is not)."""
325
+ try:
326
+ relative = child.resolve().relative_to(parent.resolve())
327
+ except (OSError, ValueError):
328
+ return False
329
+ return bool(relative.parts)
330
+
331
+
332
+ def _same_path(left: Path, right: Path) -> bool:
333
+ """Path equality that survives an unresolvable component."""
334
+ try:
335
+ return left.resolve() == right.resolve()
336
+ except OSError:
337
+ return left == right
338
+
339
+
340
+ def vendored_baseline_notice(mypy_root: Root) -> str | None:
341
+ """The line to print when the mypy leg cannot use *mypy_root*'s baseline.
342
+
343
+ ``mypy_config_file`` falls back to the template shipped with ``wl`` when the
344
+ gate root has no ``.wl/mypy.ini``. Falling back is fine; doing it *silently*
345
+ is not: the user is then judged against a baseline they never chose, and a
346
+ ``.wl/mypy.ini`` living somewhere else (a nested project root, say) looks
347
+ like it applied when it did not (review F1). Returns ``None`` when the
348
+ root's own baseline is there, so the regular layout prints nothing.
349
+
350
+ Takes the mypy leg's :class:`Root` -- the value :class:`GateChain` already
351
+ resolved -- rather than a bare path, so no caller has to rebuild a ``Root``
352
+ from a path to ask where that leg's baseline is.
353
+ """
354
+ baseline = mypy_root.mypy_ini()
355
+ if baseline.is_file():
356
+ return None
357
+ return (
358
+ f"wl gate: note: {baseline} does not exist, so the "
359
+ f"mypy leg uses the vendored template shipped with wl "
360
+ f"({mypy_config_file(mypy_root.path)}) instead."
361
+ "\nwl gate: a .wl/mypy.ini outside the gate root -- for example at a "
362
+ "nested project root -- is not part of this gate's contract."
363
+ )
364
+
365
+
366
+ class GateCommand:
367
+ """Run the local quality chain sequentially, stopping at first failure."""
368
+
369
+ def __init__(
370
+ self,
371
+ executor: SubprocessExecutor | None = None,
372
+ preflight: PreflightRunner | None = None,
373
+ ) -> None:
374
+ self.executor = executor if executor is not None else SubprocessExecutor()
375
+ # The step-0 probe is a collaborator, not a module global reached for
376
+ # mid-run: injecting it lets the orchestration below be tested without
377
+ # monkey-patching ``run_preflight``.
378
+ self.preflight: PreflightRunner = (
379
+ preflight if preflight is not None else run_preflight
380
+ )
381
+
382
+ def build_steps(
383
+ self,
384
+ chain: GateChain | None = None,
385
+ evidence_path: Path | None = None,
386
+ ) -> list[GateStep]:
387
+ """Build the ordered list of gate steps for *chain*.
388
+
389
+ Reuses the lint executor so the ruff and mypy command lines (including
390
+ the ``--config-file`` decision from ``mypy_config``) stay in one place.
391
+
392
+ *evidence_path* is the file the test leg's verdict will be read from
393
+ (#212). It is an **input**: this method is a pure description of the
394
+ chain -- tests, preflight and every "what would the gate run?" reader
395
+ call it -- so it must not create the file. Earlier it did, and every
396
+ build-only caller (one per test, 220 in a single suite) left an orphan
397
+ ``wl-gate-evidence-*.json`` in the system temp directory that only
398
+ ``execute``'s ``finally`` knew how to collect (review M1). Creation and
399
+ removal now both live in :meth:`execute`.
400
+
401
+ When *evidence_path* is ``None`` the returned test step carries no
402
+ evidence channel, which on purpose leaves it fail-closed at judging
403
+ time: :func:`_evidence_exit_code` refuses to vouch for a run whose
404
+ verdict it cannot read, rather than treating "no file" as a pass.
405
+
406
+ *chain* is the resolved root -- :meth:`GateChain.resolve` when omitted.
407
+ Every step is built against that one value, so each ``GateStep.cwd`` is
408
+ written out here and **no step carries a ``None``** for a later reader
409
+ to resolve against a second variable. The chain root (``chain.root``) is
410
+ where the repo's own ``.wl`` (including its ``mypy.ini`` baseline) is
411
+ deployed, where the repo's ``src/`` lives, and the directory CI runs the
412
+ same commands from. The project root is *not* interchangeable here -- in
413
+ the ``项目根 != 部署根`` layouts (#95 / #96) the two differ, and anchoring
414
+ at the project root silently ran the leg in the other tree: the baseline
415
+ was swapped for the vendored template and ``src/`` was either the wrong
416
+ package (a silent green over the wrong files) or absent, widening the
417
+ scope to the ``src/ tests/`` fallback (a red that says nothing about the
418
+ change under test -- exactly #98's complaint).
419
+
420
+ The mypy leg is the one exception, and only in the opposite direction:
421
+ when the deploy root has no ``src/`` to check, that leg anchors at
422
+ ``chain.mypy_root`` instead -- see :func:`resolve_mypy_anchor`, which
423
+ :meth:`GateChain.resolve` calls once and whose value is already on the
424
+ chain.
425
+ """
426
+ if chain is None:
427
+ chain = GateChain.resolve()
428
+ lint = LintExecutor()
429
+ return [
430
+ GateStep(
431
+ "ruff check",
432
+ lint.prepare_ruff_command(None, fix=False, quiet=False),
433
+ chain.root.path,
434
+ ),
435
+ # Scope is ``src/`` on purpose: it is the repo's own mypy contract
436
+ # (CI runs ``mypy --config-file .wl/mypy.ini src/`` with a baseline
437
+ # converged to 0 errors). LintExecutor's default is ``src/ tests/``,
438
+ # which reddens this leg on the repo's pre-existing ``tests/``
439
+ # typing debt -- a red that says nothing about the change under
440
+ # test, which is precisely what #98 is about.
441
+ GateStep(
442
+ "mypy",
443
+ # ``src/`` and nothing else. The gate must never fall back to
444
+ # LintExecutor's ``src/ tests/`` default: widening the scope
445
+ # would scan directories that have nothing to do with the
446
+ # change under test -- the false red review F0 is about. When
447
+ # the deploy root cannot satisfy ``src/`` the *root* moves (to
448
+ # the project root, per resolve_mypy_anchor) rather than the
449
+ # scope; the leg keeps checking ``src/`` and nothing else.
450
+ lint.prepare_mypy_command(
451
+ ["src/"],
452
+ quiet=False,
453
+ project_root=chain.mypy_root.path,
454
+ fallback_paths=("src/",),
455
+ ),
456
+ # The mypy leg's own root: equal to the chain root in the
457
+ # regular layout, the project root in the monorepo fallback.
458
+ chain.mypy_root.path,
459
+ ),
460
+ # The test leg's ``cwd`` is what decides which interpreter ``wl
461
+ # test`` resolves, so preflight probes the interpreter from this
462
+ # very field (``probe_interpreter``) rather than a root passed
463
+ # alongside the steps.
464
+ #
465
+ # ``--no-cache`` is not an optimisation knob here, it is what makes
466
+ # the leg a *test* of this tree (#193): with the default 24h result
467
+ # cache a broken ``conftest.py`` -- a file whose change re-runs
468
+ # nothing -- left ``wl gate`` green while the same tree failed
469
+ # ``pytest``. ``reject_cached_results`` is the verdict behind the
470
+ # flag: even if the flag is ever dropped, a run that reports cache
471
+ # hits cannot reach "all checks passed".
472
+ GateStep(
473
+ "pytest",
474
+ _pytest_command(evidence_path),
475
+ chain.root.path,
476
+ probe_interpreter=True,
477
+ capture=True,
478
+ reject_cached_results=True,
479
+ evidence_path=evidence_path,
480
+ ),
481
+ GateStep(
482
+ "ruff format --check",
483
+ ["ruff", "format", "--check", "."],
484
+ chain.root.path,
485
+ ),
486
+ # The managed hook suite (#193). Without it a green gate could be
487
+ # followed by a *rejected commit*: codespell, check-yaml, gitleaks
488
+ # and the lockfile check live only in the pre-commit config, so a
489
+ # green gate never meant "this commit is acceptable".
490
+ #
491
+ # The argv is the one ``run_pre_commit(files=None, config=<managed>,
492
+ # prefer_uv=False)`` builds -- ``pre-commit run --all-files --config
493
+ # <path>`` -- written out here so the step runs through the chain's
494
+ # one executor (and its output is judged by ``PreCommitRun`` below)
495
+ # rather than through a second, private subprocess wrapper. Bare
496
+ # ``pre-commit`` first matches ``wl lint``: the gate already runs
497
+ # inside the project environment and does not need uv to re-resolve
498
+ # it, and preflight resolves that same token from PATH.
499
+ #
500
+ # Read-only *verdict*: fixers (ruff-format, end-of-file-fixer,
501
+ # trailing-whitespace) rewrite files and exit non-zero, which a
502
+ # commit rejects too -- so a rewrite is reported as a step failure
503
+ # (``pre-commit rewrote files``), never as "all checks passed".
504
+ GateStep(
505
+ "pre-commit",
506
+ [
507
+ "pre-commit",
508
+ "run",
509
+ "--all-files",
510
+ "--config",
511
+ str(chain.root.wl_dir() / ".pre-commit-config.yaml"),
512
+ ],
513
+ chain.root.path,
514
+ capture=True,
515
+ managed_config=chain.root.wl_dir() / ".pre-commit-config.yaml",
516
+ ),
517
+ ]
518
+
519
+ def execute(self) -> int:
520
+ """Run the step-0 preflight, then every step in order.
521
+
522
+ Returns:
523
+ 0 when every step passes; :data:`EXIT_ENV_MISSING` when this machine
524
+ cannot run the chain at all (a required executable or plugin is
525
+ absent, or a step's executable cannot even be launched) -- in which
526
+ case no ``[i/n]`` step lines are produced; ``128 + signum`` when a
527
+ step ran and was then killed by a signal (reported as a signalled
528
+ step, never as an environment gap); 1 when the gate cannot create
529
+ its own evidence file (no verdict channel, so the chain is not
530
+ run); otherwise the failing step's own exit code.
531
+ """
532
+ # One resolution, read by every leg. ``build_steps`` writes it onto each
533
+ # step and preflight reads it back off the test step's own ``cwd``, so
534
+ # the judged root and the executed root are the same value by
535
+ # construction (issue #110).
536
+ chain = GateChain.resolve()
537
+ # The test leg's verdict channel (#212), owned by this call. Created
538
+ # here -- never in ``build_steps`` -- so a build-only caller leaves no
539
+ # orphan behind, and removed in the ``finally`` below whether the chain
540
+ # passed, failed early, or step-0 stopped it (review M1).
541
+ evidence_path = _create_evidence_file()
542
+ if evidence_path is None:
543
+ # No verdict channel means no verdict: refuse to run the chain
544
+ # rather than report "all checks passed" over a run nothing can
545
+ # vouch for. ``_create_evidence_file`` already said why on stderr.
546
+ return 1
547
+ steps = self.build_steps(chain, evidence_path)
548
+ try:
549
+ return self._execute_steps(steps, chain)
550
+ finally:
551
+ # Gate-owned scratch (#212): every evidence file goes, whether the
552
+ # chain passed, failed before the test leg ran, or step-0 preflight
553
+ # stopped it. A leftover would be read as a later run's verdict.
554
+ for step in steps:
555
+ _discard_evidence(step)
556
+
557
+ def _execute_steps(self, steps: list[GateStep], chain: GateChain) -> int:
558
+ """Judge *steps* over an already-resolved *chain*, in order."""
559
+ report = self.preflight(steps)
560
+ if not report.ok:
561
+ click.echo(render_env_error(report), err=True)
562
+ return EXIT_ENV_MISSING
563
+
564
+ # Say why the mypy leg left the gate root when it did (a monorepo whose
565
+ # git work-tree root has no ``src/``), and where it anchored instead.
566
+ # The notice was written by the single derivation on the chain.
567
+ if chain.mypy_notice is not None:
568
+ click.echo(chain.mypy_notice, err=True)
569
+
570
+ # Never swap the mypy baseline silently (review F1). Emitted on stderr
571
+ # so the parseable ``[i/n]`` progress on stdout is untouched. Judged
572
+ # against the mypy leg's own root -- the same value the leg runs in.
573
+ notice = vendored_baseline_notice(chain.mypy_root)
574
+ if notice is not None:
575
+ click.echo(notice, err=True)
576
+
577
+ # Unconditional (#193): say so whenever the mypy leg's scope cannot see
578
+ # the package this run was started from, whether or not a root moved.
579
+ scope_notice = mypy_scope_notice(chain)
580
+ if scope_notice is not None:
581
+ click.echo(scope_notice, err=True)
582
+
583
+ total = len(steps)
584
+ #: Steps that never ran, carried into the closing summary. A skip is not
585
+ #: a failure, but it is also not an unqualified pass (rv193/M1).
586
+ skipped: list[str] = []
587
+
588
+ for index, step in enumerate(steps, start=1):
589
+ click.echo(f"[{index}/{total}] {step.name} - running")
590
+ if step.is_skipped:
591
+ # Nothing to run: a checkout that was never ``wl init``-ed has no
592
+ # managed hooks. Skipped, never failed -- but said out loud, so
593
+ # the reader knows that part of the "canonical" gate was not
594
+ # applied here.
595
+ skipped.append(
596
+ f"{step.name} (no managed config at {step.managed_config})"
597
+ )
598
+ click.echo(
599
+ f"[{index}/{total}] {step.name} - skipped "
600
+ f"(no managed config at {step.managed_config})"
601
+ )
602
+ continue
603
+ result = self.executor.run(step.command, cwd=step.cwd, quiet=step.capture)
604
+ if step.capture:
605
+ _echo_captured_output(result)
606
+ failure = _failure_exit_code(index, total, step, result)
607
+ if failure is None:
608
+ failure = _evidence_exit_code(index, total, step)
609
+ if failure is not None:
610
+ return failure
611
+ click.echo(f"[{index}/{total}] {step.name} - ok")
612
+
613
+ if skipped:
614
+ # No failures, but not "all checks passed" either: what did not run
615
+ # is named in the closing line, so a green gate can never be read as
616
+ # "every check this project has was applied" (rv193/M1).
617
+ click.echo(f"wl gate: no failures; skipped: {'; '.join(skipped)}")
618
+ else:
619
+ click.echo("wl gate: all checks passed")
620
+ return 0
621
+
622
+
623
+ def _failure_exit_code(
624
+ index: int, total: int, step: GateStep, result: SubprocessResult
625
+ ) -> int | None:
626
+ """Report a failed step and return the code the gate must exit with.
627
+
628
+ ``None`` means the step passed. Every "which kind of red is this?" decision
629
+ lives here -- launch failure (an environment gap, never a step line),
630
+ signalled step (``128 + signum``), a pre-commit run whose only effect was
631
+ rewriting files, and the step's own exit code -- so :meth:`GateCommand.execute`
632
+ stays a plain loop over steps.
633
+ """
634
+ if result.success:
635
+ return None
636
+ if result.launch_error is not None:
637
+ # The executor's OSError path: the step's executable could not be
638
+ # started at all. That is an environment problem -- a FAILED line would
639
+ # be the phantom #98 exists to remove, and returning the raw sentinel
640
+ # would exit 255. Judged by the dedicated field, never by a bare ``-1``:
641
+ # a SIGHUP-killed child also reports ``-1`` (review F1).
642
+ click.echo(render_launch_error(step.name, step.command[0]), err=True)
643
+ return EXIT_ENV_MISSING
644
+ if result.returncode < 0:
645
+ # subprocess reports a child killed by signal N as -N. The step *ran*
646
+ # (PATH resolved it, it was launched) and was then signalled, so this is
647
+ # NOT the environment gap review N1 caught: it gets a signalled-step
648
+ # report and the shell's 128 + N code, never "PATH no longer resolves
649
+ # it" or "the chain was NOT run". SIGHUP (-1) reaches here too, because
650
+ # launch failure is now judged by ``launch_error``.
651
+ signum = -result.returncode
652
+ click.echo(
653
+ f"[{index}/{total}] {step.name} - FAILED (killed by signal {signum})"
654
+ )
655
+ click.echo(render_signal_error(step.name, step.command[0], signum), err=True)
656
+ return signal_exit_code(signum)
657
+ if step.managed_config is not None and _pre_commit_rewrote_files(
658
+ PreCommitRun(result)
659
+ ):
660
+ # Non-zero *only* because a hook rewrote files. A commit would reject
661
+ # that tree too, so this is a step failure -- and never a silent "all
662
+ # checks passed" over a tree the gate just modified.
663
+ click.echo(f"[{index}/{total}] {step.name} - FAILED (pre-commit rewrote files)")
664
+ click.echo(
665
+ "wl gate: the pre-commit step modified files (fixers such as "
666
+ "ruff-format / end-of-file-fixer / trailing-whitespace), so this tree "
667
+ "would be rejected by a commit; review and stage the rewrite or fix "
668
+ "the files yourself.",
669
+ err=True,
670
+ )
671
+ return 1
672
+ click.echo(f"[{index}/{total}] {step.name} - FAILED (exit {result.returncode})")
673
+ click.echo(f"wl gate: failed at step '{step.name}'")
674
+ return result.returncode
675
+
676
+
677
+ def _evidence_exit_code(index: int, total: int, step: GateStep) -> int | None:
678
+ """Red when the test leg's structured evidence is unusable or reports hits.
679
+
680
+ ``None`` means the file is present, parseable, of a known schema version,
681
+ and says no result came from the cache. Everything else is red, and the
682
+ message names the path -- there is deliberately no stdout fallback, so a
683
+ run the gate cannot observe is never a pass (ADR-0010, #212).
684
+
685
+ ``no_cache_requested`` does not change the verdict (a cache hit is red
686
+ either way); it only chooses the *wording*, so the user can tell "the
687
+ runner ignored ``--no-cache``" from "the gate did not pass it".
688
+ """
689
+ if not step.reject_cached_results:
690
+ return None
691
+
692
+ path = step.evidence_path
693
+ if path is None:
694
+ # Fail-closed and *named* (review L3): a step that must prove a fresh
695
+ # run but carries no path is a wiring bug in the gate, not a verdict
696
+ # about the tree. Reading through anyway produced "could not read the
697
+ # test run's evidence at None", which reads as a missing file the user
698
+ # could recreate -- and points at nothing.
699
+ click.echo(f"[{index}/{total}] {step.name} - FAILED (no evidence path)")
700
+ click.echo(
701
+ "wl gate: the test leg must prove it ran against this tree, but it "
702
+ "carries no evidence path, so there is no file to read a verdict "
703
+ "from. This is a wiring bug in the gate rather than a result of "
704
+ "your change; re-run 'wl gate' and, if it persists, report it.",
705
+ err=True,
706
+ )
707
+ return 1
708
+
709
+ evidence = TestRunEvidence.read(path)
710
+ if evidence is None:
711
+ click.echo(f"[{index}/{total}] {step.name} - FAILED (no usable run evidence)")
712
+ click.echo(
713
+ f"wl gate: could not read the test run's evidence at {path} "
714
+ "(missing, corrupt, or an unknown schema version). The test leg's "
715
+ "verdict comes from that file, so the gate cannot vouch for this "
716
+ "run; re-run 'wl gate' and, if it persists, check that 'wl test' "
717
+ "supports --evidence-json.",
718
+ err=True,
719
+ )
720
+ return 1
721
+
722
+ if evidence.cache_hits <= 0:
723
+ return None
724
+
725
+ click.echo(
726
+ f"[{index}/{total}] {step.name} - FAILED "
727
+ f"(cache reused {evidence.cache_hits} result(s) of "
728
+ f"{evidence.cache_total} file(s))"
729
+ )
730
+ if evidence.no_cache_requested:
731
+ cause = (
732
+ "the runner was asked for '--no-cache' but its evidence says "
733
+ "no_cache_requested=true, so the flag was ignored"
734
+ )
735
+ else:
736
+ cause = (
737
+ "the runner's evidence says no_cache_requested=false, so the gate's "
738
+ "own '--no-cache' never reached it"
739
+ )
740
+ click.echo(
741
+ f"wl gate: the test leg reused cached results, so it did not exercise "
742
+ f"the current tree; a cache hit is not a pass ({cause}). "
743
+ f"Evidence: {path}",
744
+ err=True,
745
+ )
746
+ return 1
747
+
748
+
749
+ def _pytest_command(evidence_path: Path | None) -> list[str]:
750
+ """The test leg's argv, carrying the evidence channel when there is one.
751
+
752
+ ``--evidence-json`` is appended only for a real path: naming ``None`` on a
753
+ command line would be a path the runner tries to write to, and the step's
754
+ verdict is read from ``GateStep.evidence_path`` anyway.
755
+ """
756
+ command = ["wl", "test", "-s", "--no-cache"]
757
+ if evidence_path is not None:
758
+ command += ["--evidence-json", str(evidence_path)]
759
+ return command
760
+
761
+
762
+ def _create_evidence_file() -> Path | None:
763
+ """Create the gate-owned evidence file, or ``None`` when it cannot be made.
764
+
765
+ The file is created eagerly -- an empty file rather than only a name --
766
+ because a runner that never writes it then leaves something the reader
767
+ rejects as corrupt: red, not "no opinion" (ADR-0010).
768
+
769
+ An unwritable or full temp directory is an environment failure at the
770
+ moment the gate is asked to run, so it is reported as one: a named,
771
+ actionable message on stderr and a ``None`` verdict for the caller to turn
772
+ into a non-zero exit. It must never surface as a traceback out of
773
+ ``mkstemp`` -- the gate refuses to run without its verdict channel, and
774
+ that refusal has to be readable (review M1).
775
+ """
776
+ name: str | None = None
777
+ try:
778
+ descriptor, name = tempfile.mkstemp(prefix="wl-gate-evidence-", suffix=".json")
779
+ os.close(descriptor)
780
+ except OSError as error:
781
+ if name is not None:
782
+ try:
783
+ Path(name).unlink()
784
+ except OSError:
785
+ pass
786
+ click.echo(
787
+ "wl gate: could not create the test leg's evidence file in "
788
+ f"{tempfile.gettempdir()} ({error}). The test leg's verdict comes "
789
+ "from that file, so the gate refuses to run without it; check that "
790
+ "TMPDIR is writable and has free space.",
791
+ err=True,
792
+ )
793
+ return None
794
+ return Path(name)
795
+
796
+
797
+ def _discard_evidence(step: GateStep) -> None:
798
+ """Remove the temporary evidence file this step owns, if it has one.
799
+
800
+ A file left behind would survive into a later run or a reused temp name and
801
+ be read as a fresh verdict, so the removal is unconditional and quiet -- a
802
+ ``FileNotFoundError`` here just means the runner never created it, which the
803
+ reader already treats as red.
804
+ """
805
+ if step.evidence_path is None:
806
+ return
807
+ try:
808
+ step.evidence_path.unlink()
809
+ except OSError:
810
+ pass
811
+
812
+
813
+ def _echo_captured_output(result: SubprocessResult) -> None:
814
+ """Replay a captured step's output, so capturing loses no information.
815
+
816
+ Steps that carry their verdict in stdout are run with ``quiet=True`` (the
817
+ executor then captures instead of streaming). Everything they said is echoed
818
+ here as one block, with the trailing newline preserved only when the stream
819
+ already had one -- so a step's own formatting survives unchanged.
820
+ """
821
+ if result.stdout:
822
+ click.echo(result.stdout, nl=not result.stdout.endswith("\n"))
823
+ if result.stderr:
824
+ click.echo(result.stderr, nl=not result.stderr.endswith("\n"), err=True)
825
+
826
+
827
+ def _pre_commit_rewrote_files(run: PreCommitRun) -> bool:
828
+ """True when a non-zero pre-commit run's only effect was rewriting files.
829
+
830
+ :attr:`PreCommitRun.hooks_failed` deliberately answers "a hook judged a
831
+ failure", and it excludes auto-fix blocks because formatting *expects* them.
832
+ A gate cannot: the tree it just rewrote is not the tree it was asked to
833
+ judge, and a commit would reject it. So the two are told apart here, using
834
+ the run's own blocks rather than re-parsing its exit code.
835
+ """
836
+ return bool(run.verdict_blocks) and not run.failed_blocks
837
+
838
+
839
+ def _is_file(path: Path) -> bool:
840
+ """``is_file`` without letting an unreadable component raise."""
841
+ try:
842
+ return path.is_file()
843
+ except OSError:
844
+ return False
845
+
846
+
847
+ @click.command()
848
+ def gate_command() -> None:
849
+ """Run the full local quality gate (ruff, mypy, pytest, format, hooks).
850
+
851
+ A step-0 preflight first checks that the executables the chain will run
852
+ (ruff, mypy, wl, pre-commit) and the pytest plugins its test step needs are
853
+ available. If any is missing the gate exits 78 (environment error) and the
854
+ chain is not run.
855
+
856
+ Exit codes: 0 all clear; 78 the environment cannot run the chain; 128 + N a
857
+ step ran and was killed by signal N (e.g. 137 = SIGKILL); otherwise the
858
+ failing step's own code.
859
+ """
860
+ exit_code = GateCommand().execute()
861
+ if exit_code != 0:
862
+ sys.exit(exit_code)