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.
- py_wlcli-0.8.0.dist-info/METADATA +139 -0
- py_wlcli-0.8.0.dist-info/RECORD +320 -0
- py_wlcli-0.8.0.dist-info/WHEEL +4 -0
- py_wlcli-0.8.0.dist-info/entry_points.txt +2 -0
- py_wlcli-0.8.0.dist-info/licenses/LICENSE +21 -0
- wl_cli/__init__.py +27 -0
- wl_cli/__main__.py +130 -0
- wl_cli/capabilities.py +79 -0
- wl_cli/cc/__init__.py +5 -0
- wl_cli/cc/checks.py +1019 -0
- wl_cli/cc/cli.py +370 -0
- wl_cli/cc/mutate.py +1350 -0
- wl_cli/cli_click.py +156 -0
- wl_cli/commands/__init__.py +84 -0
- wl_cli/commands/base.py +80 -0
- wl_cli/commands/buildcommands/__init__.py +1 -0
- wl_cli/commands/buildcommands/build.py +71 -0
- wl_cli/commands/buildcommands/build_utils.py +47 -0
- wl_cli/commands/buildcommands/builders/__init__.py +18 -0
- wl_cli/commands/buildcommands/builders/cleanup.py +35 -0
- wl_cli/commands/buildcommands/builders/coordinator.py +122 -0
- wl_cli/commands/buildcommands/builders/environment.py +81 -0
- wl_cli/commands/buildcommands/builders/rust_builder.py +39 -0
- wl_cli/commands/buildcommands/builders/stubs.py +58 -0
- wl_cli/commands/buildcommands/dist.py +326 -0
- wl_cli/commands/buildcommands/test.py +89 -0
- wl_cli/commands/clean/__init__.py +1 -0
- wl_cli/commands/clean/build.py +151 -0
- wl_cli/commands/clean/clean.py +294 -0
- wl_cli/commands/clean/clean_utils.py +77 -0
- wl_cli/commands/clean/core.py +236 -0
- wl_cli/commands/clean/dead_code/__init__.py +0 -0
- wl_cli/commands/clean/dead_code/dead_code_command.py +70 -0
- wl_cli/commands/clean/dead_code/detector.py +161 -0
- wl_cli/commands/clean/dead_code/fixer.py +176 -0
- wl_cli/commands/clean/dead_code/reporter.py +295 -0
- wl_cli/commands/clean/dead_code/utils.py +0 -0
- wl_cli/commands/clean/lfs.py +215 -0
- wl_cli/commands/clean/python.py +81 -0
- wl_cli/commands/clean/rust.py +91 -0
- wl_cli/commands/clean/utils/__init__.py +34 -0
- wl_cli/commands/clean/venv.py +91 -0
- wl_cli/commands/cleanup.py +73 -0
- wl_cli/commands/click_base.py +79 -0
- wl_cli/commands/config/__init__.py +1 -0
- wl_cli/commands/config/config.py +291 -0
- wl_cli/commands/debug/__init__.py +6 -0
- wl_cli/commands/debug/debug.py +309 -0
- wl_cli/commands/debug/debug_cache.py +86 -0
- wl_cli/commands/debug/debug_subagent.py +156 -0
- wl_cli/commands/debug/output_formatter.py +165 -0
- wl_cli/commands/debug/source_hasher.py +92 -0
- wl_cli/commands/format/__init__.py +13 -0
- wl_cli/commands/format/format_cache_adapter.py +125 -0
- wl_cli/commands/format/format_command.py +197 -0
- wl_cli/commands/format/format_coordinator.py +191 -0
- wl_cli/commands/format/format_executor.py +470 -0
- wl_cli/commands/format/format_outcome.py +44 -0
- wl_cli/commands/format/format_path_handler.py +1016 -0
- wl_cli/commands/format/format_progress.py +62 -0
- wl_cli/commands/format/format_reporter.py +31 -0
- wl_cli/commands/format/python_formatter.py +506 -0
- wl_cli/commands/format/rust_formatter.py +106 -0
- wl_cli/commands/gate/__init__.py +1 -0
- wl_cli/commands/gate/gate_command.py +862 -0
- wl_cli/commands/gate/preflight.py +445 -0
- wl_cli/commands/git/__init__.py +1 -0
- wl_cli/commands/git/config.py +169 -0
- wl_cli/commands/git/git_facts.py +60 -0
- wl_cli/commands/git/git_flow.py +86 -0
- wl_cli/commands/git/hook.py +112 -0
- wl_cli/commands/git/policy.py +216 -0
- wl_cli/commands/git/push_update.py +82 -0
- wl_cli/commands/initenv/__init__.py +1 -0
- wl_cli/commands/initenv/common/__init__.py +23 -0
- wl_cli/commands/initenv/common/check_git_bash.py +78 -0
- wl_cli/commands/initenv/common/error_handler.py +68 -0
- wl_cli/commands/initenv/common/exceptions.py +52 -0
- wl_cli/commands/initenv/components/__init__.py +4 -0
- wl_cli/commands/initenv/components/cargo_sync.py +240 -0
- wl_cli/commands/initenv/components/config_manager.py +106 -0
- wl_cli/commands/initenv/components/directory_utils.py +71 -0
- wl_cli/commands/initenv/components/git/__init__.py +9 -0
- wl_cli/commands/initenv/components/git/branch_creator.py +143 -0
- wl_cli/commands/initenv/components/git/git_flow_interface.py +24 -0
- wl_cli/commands/initenv/components/git/git_flow_manager.py +159 -0
- wl_cli/commands/initenv/components/git/git_lfs_manager.py +272 -0
- wl_cli/commands/initenv/components/git/git_utility.py +254 -0
- wl_cli/commands/initenv/components/git/gitignore_manager.py +53 -0
- wl_cli/commands/initenv/components/git_initializer.py +212 -0
- wl_cli/commands/initenv/components/i18n_manager.py +104 -0
- wl_cli/commands/initenv/components/log_manager.py +241 -0
- wl_cli/commands/initenv/components/project_structure/__init__.py +23 -0
- wl_cli/commands/initenv/components/project_structure/config_generator.py +360 -0
- wl_cli/commands/initenv/components/project_structure/directory_creator.py +184 -0
- wl_cli/commands/initenv/components/project_structure/hook_source.py +110 -0
- wl_cli/commands/initenv/components/project_structure/hooks_manager.py +995 -0
- wl_cli/commands/initenv/components/project_structure/readme_handler.py +56 -0
- wl_cli/commands/initenv/components/project_structure/rust_files_handler.py +30 -0
- wl_cli/commands/initenv/components/project_structure/setup_handler.py +103 -0
- wl_cli/commands/initenv/components/project_structure/sop_generator.py +73 -0
- wl_cli/commands/initenv/components/project_structure/template_copier.py +104 -0
- wl_cli/commands/initenv/components/project_structure/template_manager.py +195 -0
- wl_cli/commands/initenv/components/pyproject_generator.py +194 -0
- wl_cli/commands/initenv/components/rust_initializer.py +145 -0
- wl_cli/commands/initenv/components/symlink_cleaner.py +99 -0
- wl_cli/commands/initenv/components/symlink_manager.py +52 -0
- wl_cli/commands/initenv/components/venv_manager.py +118 -0
- wl_cli/commands/initenv/initenv.py +169 -0
- wl_cli/commands/initenv/services/__init__.py +11 -0
- wl_cli/commands/initenv/services/environment_initializer.py +288 -0
- wl_cli/commands/initenv/services/git_lfs_initializer.py +26 -0
- wl_cli/commands/initenv/services/project_structure_initializer.py +52 -0
- wl_cli/commands/lfs/README.md +221 -0
- wl_cli/commands/lfs/__init__.py +5 -0
- wl_cli/commands/lfs/cli.py +106 -0
- wl_cli/commands/lfs/config.py +164 -0
- wl_cli/commands/lfs/hook.py +61 -0
- wl_cli/commands/lfs/initializer.py +531 -0
- wl_cli/commands/lfs/manager.py +95 -0
- wl_cli/commands/lfs/policy.py +165 -0
- wl_cli/commands/lfs/watcher.py +294 -0
- wl_cli/commands/lint/__init__.py +1 -0
- wl_cli/commands/lint/lint_command.py +297 -0
- wl_cli/commands/lint/lint_executor.py +409 -0
- wl_cli/commands/lint/lint_reporter.py +579 -0
- wl_cli/commands/lint/lint_run.py +258 -0
- wl_cli/commands/lint/mypy_config.py +90 -0
- wl_cli/commands/registry.py +103 -0
- wl_cli/commands/self/__init__.py +7 -0
- wl_cli/commands/self/common/__init__.py +8 -0
- wl_cli/commands/self/common/capabilities.py +30 -0
- wl_cli/commands/self/common/config.py +27 -0
- wl_cli/commands/self/common/version_cache.py +85 -0
- wl_cli/commands/self/handlers/__init__.py +19 -0
- wl_cli/commands/self/handlers/base_handler.py +28 -0
- wl_cli/commands/self/handlers/install_handler.py +85 -0
- wl_cli/commands/self/handlers/status_handler.py +66 -0
- wl_cli/commands/self/handlers/uninstall_handler.py +58 -0
- wl_cli/commands/self/handlers/update_handler.py +102 -0
- wl_cli/commands/self/handlers/version_handler.py +16 -0
- wl_cli/commands/self/self_command.py +237 -0
- wl_cli/commands/self/services/__init__.py +15 -0
- wl_cli/commands/self/services/installation_manager.py +187 -0
- wl_cli/commands/self/services/local_installer.py +165 -0
- wl_cli/commands/self/services/pypi_updater.py +278 -0
- wl_cli/commands/self/services/windows_update.py +174 -0
- wl_cli/commands/testcommands/__init__.py +8 -0
- wl_cli/commands/testcommands/base_dependency_analyzer.py +198 -0
- wl_cli/commands/testcommands/batch_processor.py +278 -0
- wl_cli/commands/testcommands/cache_manager.py +1156 -0
- wl_cli/commands/testcommands/coverage_analyzer.py +955 -0
- wl_cli/commands/testcommands/coverage_cache_manager.py +183 -0
- wl_cli/commands/testcommands/progress_manager.py +79 -0
- wl_cli/commands/testcommands/python_env_detector.py +207 -0
- wl_cli/commands/testcommands/report_generator.py +101 -0
- wl_cli/commands/testcommands/single_test_executor.py +250 -0
- wl_cli/commands/testcommands/speed_checker.py +118 -0
- wl_cli/commands/testcommands/test_command_builder.py +165 -0
- wl_cli/commands/testcommands/test_commands.py +786 -0
- wl_cli/commands/testcommands/test_reporter.py +181 -0
- wl_cli/commands/testcommands/test_result_handler.py +96 -0
- wl_cli/commands/testcommands/test_runner.py +1343 -0
- wl_cli/commands/testcommands/test_utils.py +197 -0
- wl_cli/commands/testcommands/utils/__init__.py +17 -0
- wl_cli/commands/testcommands/utils/coverage_parser.py +98 -0
- wl_cli/commands/testcommands/utils/env_info.py +36 -0
- wl_cli/commands/testcommands/utils/junit_parser.py +75 -0
- wl_cli/commands/testcommands/utils/markdown_utils.py +20 -0
- wl_cli/commands/testcommands/utils/project_root.py +61 -0
- wl_cli/commands/update/__init__.py +9 -0
- wl_cli/commands/update/services/__init__.py +21 -0
- wl_cli/commands/update/services/backup_manager.py +207 -0
- wl_cli/commands/update/services/component_manager.py +83 -0
- wl_cli/commands/update/services/conflict_resolver.py +189 -0
- wl_cli/commands/update/services/vendors_updater.py +85 -0
- wl_cli/commands/update/services/version_tracker.py +127 -0
- wl_cli/commands/update/update_command.py +270 -0
- wl_cli/commands/update/utils/__init__.py +3 -0
- wl_cli/commands/version/__init__.py +9 -0
- wl_cli/commands/version/version_command.py +155 -0
- wl_cli/commands/workflow.py +477 -0
- wl_cli/env/__init__.py +5 -0
- wl_cli/env/cli.py +167 -0
- wl_cli/env_status.py +157 -0
- wl_cli/envelope.py +91 -0
- wl_cli/exceptions.py +85 -0
- wl_cli/lib/__init__.py +3 -0
- wl_cli/lib/rust_utils.py +35 -0
- wl_cli/mcp/__init__.py +6 -0
- wl_cli/mcp/capabilities.py +19 -0
- wl_cli/mcp/cli.py +22 -0
- wl_cli/mcp/gitlab.py +553 -0
- wl_cli/mcp/k8s.py +344 -0
- wl_cli/mcp/protocol.py +159 -0
- wl_cli/platform.py +139 -0
- wl_cli/quality/__init__.py +18 -0
- wl_cli/utils/__init__.py +7 -0
- wl_cli/utils/cache.py +491 -0
- wl_cli/utils/cache_ledger.py +355 -0
- wl_cli/utils/cache_store.py +546 -0
- wl_cli/utils/config/__init__.py +27 -0
- wl_cli/utils/config/config_loader.py +163 -0
- wl_cli/utils/config/core.py +862 -0
- wl_cli/utils/config/defaults.py +142 -0
- wl_cli/utils/config/env_exporter.py +86 -0
- wl_cli/utils/config/env_parser.py +108 -0
- wl_cli/utils/config/validation.py +280 -0
- wl_cli/utils/error_handler.py +165 -0
- wl_cli/utils/file_copier.py +56 -0
- wl_cli/utils/file_operations.py +506 -0
- wl_cli/utils/language_utils.py +122 -0
- wl_cli/utils/lazy_import.py +240 -0
- wl_cli/utils/log_levels.py +35 -0
- wl_cli/utils/log_rotators.py +74 -0
- wl_cli/utils/logging.py +458 -0
- wl_cli/utils/output_context.py +142 -0
- wl_cli/utils/performance/__init__.py +1 -0
- wl_cli/utils/performance/decorators.py +111 -0
- wl_cli/utils/performance/exporter.py +38 -0
- wl_cli/utils/performance/models.py +16 -0
- wl_cli/utils/performance/monitor.py +161 -0
- wl_cli/utils/performance_monitor.py +32 -0
- wl_cli/utils/platform_adapter.py +131 -0
- wl_cli/utils/pre_commit.py +266 -0
- wl_cli/utils/pre_commit_config.py +72 -0
- wl_cli/utils/root.py +521 -0
- wl_cli/utils/root_fallback.py +256 -0
- wl_cli/utils/run_evidence.py +236 -0
- wl_cli/utils/structured_logger.py +18 -0
- wl_cli/utils/structured_logging/__init__.py +19 -0
- wl_cli/utils/structured_logging/config.py +337 -0
- wl_cli/utils/structured_logging/core.py +177 -0
- wl_cli/utils/structured_logging/env.py +56 -0
- wl_cli/utils/structured_logging/filtering.py +75 -0
- wl_cli/utils/structured_logging/filters.py +55 -0
- wl_cli/utils/structured_logging/formatters.py +251 -0
- wl_cli/utils/structured_logging/handlers.py +308 -0
- wl_cli/utils/structured_logging/logger.py +354 -0
- wl_cli/utils/subprocess.py +71 -0
- wl_cli/utils/subprocess_utils.py +260 -0
- wl_cli/utils/test_utils/__init__.py +5 -0
- wl_cli/utils/test_utils/test_filter.py +36 -0
- wl_cli/utils/uv_run.py +79 -0
- wl_cli/utils/uv_tool.py +117 -0
- wl_cli/utils/uv_tree.py +71 -0
- wl_cli/utils/uv_workspace.py +174 -0
- wl_cli/utils/version/__init__.py +15 -0
- wl_cli/utils/version/pypi_version_checker.py +95 -0
- wl_cli/utils/version/version_comparator.py +133 -0
- wl_cli/utils/version/version_detectors.py +312 -0
- wl_cli/utils/version/version_service.py +131 -0
- wl_cli/utils/version/version_updater.py +198 -0
- wl_cli/utils/wl_dir_updater.py +1440 -0
- wl_cli/utils/workspace_detector/__init__.py +28 -0
- wl_cli/utils/workspace_detector/core.py +161 -0
- wl_cli/utils/workspace_detector/detection_strategies.py +107 -0
- wl_cli/utils/workspace_detector/exceptions.py +5 -0
- wl_cli/utils/workspace_detector/types.py +17 -0
- wl_cli/utils/workspace_detector/venv_resolver.py +84 -0
- wl_cli/vendors/config/.codespellrc +2 -0
- wl_cli/vendors/config/dependencies.toml +10 -0
- wl_cli/vendors/config/mypy.ini +62 -0
- wl_cli/vendors/config/pyproject_defaults.toml +28 -0
- wl_cli/vendors/config/template_config.toml +24 -0
- wl_cli/vendors/config/tool_configs.toml +76 -0
- wl_cli/vendors/git/.gitignore +122 -0
- wl_cli/vendors/git/.pre-commit-config.yaml +37 -0
- wl_cli/vendors/hooks/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/post-update.sample +8 -0
- wl_cli/vendors/hooks/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/pre-commit +45 -0
- wl_cli/vendors/hooks/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/pre-push.sample +60 -0
- wl_cli/vendors/hooks/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/push-to-checkout.sample +78 -0
- wl_cli/vendors/hooks/sendemail-validate.sample +77 -0
- wl_cli/vendors/hooks/unix/applypatch-msg.sample +15 -0
- wl_cli/vendors/hooks/unix/check_lockfile.py +58 -0
- wl_cli/vendors/hooks/unix/check_lockfile.sh +36 -0
- wl_cli/vendors/hooks/unix/commit-msg.sample +24 -0
- wl_cli/vendors/hooks/unix/fsmonitor-watchman.sample +174 -0
- wl_cli/vendors/hooks/unix/post-update.sample +8 -0
- wl_cli/vendors/hooks/unix/pre-applypatch.sample +14 -0
- wl_cli/vendors/hooks/unix/pre-commit +190 -0
- wl_cli/vendors/hooks/unix/pre-commit.legacy +31 -0
- wl_cli/vendors/hooks/unix/pre-commit.py +81 -0
- wl_cli/vendors/hooks/unix/pre-commit.sample +49 -0
- wl_cli/vendors/hooks/unix/pre-merge-commit.sample +13 -0
- wl_cli/vendors/hooks/unix/pre-push +417 -0
- wl_cli/vendors/hooks/unix/pre-push.sample +53 -0
- wl_cli/vendors/hooks/unix/pre-rebase.sample +169 -0
- wl_cli/vendors/hooks/unix/pre-receive.sample +24 -0
- wl_cli/vendors/hooks/unix/prepare-commit-msg.sample +42 -0
- wl_cli/vendors/hooks/update.sample +128 -0
- wl_cli/vendors/hooks/win/work.bat +63 -0
- wl_cli/vendors/project/.gitignore +90 -0
- wl_cli/vendors/project/.python-version +1 -0
- wl_cli/vendors/project/LICENSE +21 -0
- wl_cli/vendors/project/README.md +0 -0
- wl_cli/vendors/project/pyproject.toml +27 -0
- wl_cli/vendors/project/pyproject_no_workspace.toml +16 -0
- wl_cli/vendors/project/pyproject_workspace.toml +14 -0
- wl_cli/vendors/readme/README.md +120 -0
- wl_cli/vendors/rust/Cargo.toml +14 -0
- wl_cli/vendors/rust/lib/__init__.py +3 -0
- wl_cli/vendors/rust/lib/rust_utils.py +31 -0
- wl_cli/vendors/rust/src/lib.rs +13 -0
- wl_cli/vendors/rust/src/main.rs +3 -0
- wl_cli/vendors/rust/tests/test_rust_fallback.py +40 -0
- wl_cli/vendors/sop_doc/git-dev-sop.md +220 -0
- wl_cli/vendors/sop_doc/industrial-standard-sop.md +145 -0
- wl_cli/vendors/sop_doc/python-sop.md +342 -0
- 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)
|