strictcli 0.35.3__tar.gz → 0.36.0__tar.gz
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.
- {strictcli-0.35.3 → strictcli-0.36.0}/PKG-INFO +1 -1
- {strictcli-0.35.3 → strictcli-0.36.0}/pyproject.toml +1 -1
- {strictcli-0.35.3 → strictcli-0.36.0}/strictcli/__init__.py +237 -42
- strictcli-0.36.0/tests/test_confirm.py +349 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_effects.py +181 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_effects_bypass_check.py +132 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_reserved_quartet.py +57 -13
- {strictcli-0.35.3 → strictcli-0.36.0}/uv.lock +1 -1
- strictcli-0.35.3/tests/test_confirm.py +0 -191
- {strictcli-0.35.3 → strictcli-0.36.0}/.claude/settings.json +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.gitignore +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/config.json +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/version +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/.strictcli/schema.json +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/CLAUDE.md +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/LICENSE +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/README.md +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/strictcli/py.typed +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/conftest.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/flagship_app.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_call.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_command.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_types.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_choices.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_classification.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_connection_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_context.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_coverage.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_e2e.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_format.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_type.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_guard_v2.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_help.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_int_type.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_invoke.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_mcp.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_mutex.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_nesting.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_parser.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_provenance.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_registration.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_unique.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_utilities.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_validate.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_variadic.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_visibility.py +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.36.0"
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
|
|
@@ -248,7 +248,8 @@ class Context:
|
|
|
248
248
|
"""
|
|
249
249
|
|
|
250
250
|
def __init__(self, stdout=None, stderr=None, sources=None, infra=None,
|
|
251
|
-
*, dry_run: bool = False,
|
|
251
|
+
*, dry_run: bool = False,
|
|
252
|
+
approve_consequential: bool = False,
|
|
252
253
|
quiet: bool = False, verbose: bool = False,
|
|
253
254
|
effects: "_Effects | None" = None):
|
|
254
255
|
self._stdout = stdout or sys.stdout
|
|
@@ -256,7 +257,7 @@ class Context:
|
|
|
256
257
|
self._sources = sources or {} # flag-name -> source label (cli/env/config/default/implied/infra)
|
|
257
258
|
self._infra = infra # _InfraAccess | None
|
|
258
259
|
self._dry_run = dry_run
|
|
259
|
-
self.
|
|
260
|
+
self._approve_consequential = approve_consequential
|
|
260
261
|
self._quiet = quiet
|
|
261
262
|
self._verbose = verbose
|
|
262
263
|
self._effects = effects
|
|
@@ -267,9 +268,9 @@ class Context:
|
|
|
267
268
|
return self._dry_run
|
|
268
269
|
|
|
269
270
|
@property
|
|
270
|
-
def
|
|
271
|
-
"""True when the framework-owned ``--
|
|
272
|
-
return self.
|
|
271
|
+
def approve_consequential(self) -> bool:
|
|
272
|
+
"""True when the framework-owned ``--approve-consequential`` flag was passed."""
|
|
273
|
+
return self._approve_consequential
|
|
273
274
|
|
|
274
275
|
@property
|
|
275
276
|
def quiet(self) -> bool:
|
|
@@ -674,9 +675,19 @@ def _msg_dry_run_truncated(step: int, cmd: str, brand: str) -> str:
|
|
|
674
675
|
)
|
|
675
676
|
|
|
676
677
|
|
|
678
|
+
def _msg_dry_run_aborted(step: int, cmd: str) -> str:
|
|
679
|
+
"""The aborted-preview marker. Same shape and prefix as the truncation
|
|
680
|
+
error above: both say the preview ended before the handler finished, and
|
|
681
|
+
they differ only in why and in what the reader may conclude."""
|
|
682
|
+
return (
|
|
683
|
+
f"error: dry-run preview ends at step {step}: {cmd} aborted — "
|
|
684
|
+
f"the preview above may be incomplete"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
|
|
677
688
|
def _msg_confirm_prompt(cmd_path: str) -> str:
|
|
678
689
|
"""The confirm prompt. A prompt, not an error, but parity is still checked."""
|
|
679
|
-
return f"about to run
|
|
690
|
+
return f"about to run consequential command '{cmd_path}'. Proceed? [y/N] "
|
|
680
691
|
|
|
681
692
|
|
|
682
693
|
def _strip_confirm_line(answer: str) -> str:
|
|
@@ -697,13 +708,35 @@ def _strip_confirm_line(answer: str) -> str:
|
|
|
697
708
|
|
|
698
709
|
|
|
699
710
|
def _msg_confirm_non_interactive() -> str:
|
|
700
|
-
return
|
|
711
|
+
return (
|
|
712
|
+
"error: stdin is not interactive; pass --approve-consequential to confirm"
|
|
713
|
+
)
|
|
701
714
|
|
|
702
715
|
|
|
703
716
|
def _msg_confirm_declined() -> str:
|
|
704
717
|
return "aborted"
|
|
705
718
|
|
|
706
719
|
|
|
720
|
+
def _consequential_grant_warning(cmd_path: str, grant: str, kind: str) -> str:
|
|
721
|
+
"""The `consequential-grant-agreement` warning (contract §8.1, §11).
|
|
722
|
+
|
|
723
|
+
A grant exists so a reviewer reading a preview sees WHY a dangerous step is
|
|
724
|
+
there (§6.1) -- the same judgement ``consequential`` makes. When the grant's
|
|
725
|
+
kind is one that leaves this process (``proc_mutate`` runs another program,
|
|
726
|
+
``net_mutate`` changes remote state), the two declarations should almost
|
|
727
|
+
always agree. They can legitimately disagree, so this is a warning: making
|
|
728
|
+
it an error would push consumers to declare ``consequential`` reflexively
|
|
729
|
+
to clear a gate, which is exactly the reflex the declaration exists to end.
|
|
730
|
+
"""
|
|
731
|
+
return (
|
|
732
|
+
f"command '{cmd_path}' declares grant '{grant}' (kind {kind}) but is "
|
|
733
|
+
f"not consequential: a {kind} effect leaves this process and the "
|
|
734
|
+
f"framework cannot walk it back, and the grant already says the step "
|
|
735
|
+
f"is worth explaining. Declare the command consequential, or drop the "
|
|
736
|
+
f"grant if the step is routine."
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
707
740
|
def _observe_allowlist_breadth_warning(binary: str) -> str:
|
|
708
741
|
"""The `observe-allowlist-breadth` warning (contract §6.2).
|
|
709
742
|
|
|
@@ -2995,9 +3028,15 @@ _NON_BOOL_SCALAR_TYPES = (str, int, float)
|
|
|
2995
3028
|
# names are unaffected by this ban, and the four flags themselves have no short
|
|
2996
3029
|
# forms.
|
|
2997
3030
|
_RESERVED_FRAMEWORK_FLAG_NAMES = frozenset({
|
|
2998
|
-
"dry-run", "
|
|
3031
|
+
"dry-run", "approve-consequential", "quiet", "verbose",
|
|
2999
3032
|
})
|
|
3000
3033
|
|
|
3034
|
+
# `yes` is NOT a framework flag any more -- it was replaced by
|
|
3035
|
+
# --approve-consequential (contract §7.1) -- but it stays banned so nobody
|
|
3036
|
+
# reintroduces a private --yes meaning the same thing. Its ban message points
|
|
3037
|
+
# at the replacement.
|
|
3038
|
+
_BANNED_FLAG_NAMES = frozenset({"yes"})
|
|
3039
|
+
|
|
3001
3040
|
# Names reserved by the framework for global flags. The pre-existing set is
|
|
3002
3041
|
# also what a SHORT flag name is checked against (the framework quartet bans
|
|
3003
3042
|
# long names only).
|
|
@@ -3010,7 +3049,7 @@ _RESERVED_GLOBAL_FLAG_NAMES = _RESERVED_GLOBAL_SHORT_NAMES | _RESERVED_FRAMEWORK
|
|
|
3010
3049
|
# argv token -> pre-scan result key for the reserved quartet.
|
|
3011
3050
|
_RESERVED_QUARTET_TOKENS = {
|
|
3012
3051
|
"--dry-run": "dry_run",
|
|
3013
|
-
"--
|
|
3052
|
+
"--approve-consequential": "approve_consequential",
|
|
3014
3053
|
"--quiet": "quiet",
|
|
3015
3054
|
"--verbose": "verbose",
|
|
3016
3055
|
}
|
|
@@ -3020,7 +3059,20 @@ def _raise_flag_name_reserved_by_framework(name: str):
|
|
|
3020
3059
|
"""Message template: a flag name collides with the reserved quartet."""
|
|
3021
3060
|
raise ValueError(
|
|
3022
3061
|
f"flag name '{name}' is reserved by the framework "
|
|
3023
|
-
f"(dry-run,
|
|
3062
|
+
f"(dry-run, approve-consequential, quiet, verbose)"
|
|
3063
|
+
)
|
|
3064
|
+
|
|
3065
|
+
|
|
3066
|
+
def _raise_flag_name_yes_banned():
|
|
3067
|
+
"""Message template: a flag named `yes` is banned outright.
|
|
3068
|
+
|
|
3069
|
+
`yes` owns no framework flag any more, but a private --yes would restate
|
|
3070
|
+
--approve-consequential in a spelling that IS muscle memory -- which is
|
|
3071
|
+
exactly what the rename removed.
|
|
3072
|
+
"""
|
|
3073
|
+
raise ValueError(
|
|
3074
|
+
"flag name 'yes' is banned by the framework: "
|
|
3075
|
+
"the confirmation skip is --approve-consequential"
|
|
3024
3076
|
)
|
|
3025
3077
|
|
|
3026
3078
|
|
|
@@ -3171,6 +3223,8 @@ class Flag:
|
|
|
3171
3223
|
)
|
|
3172
3224
|
if self.name in _RESERVED_FRAMEWORK_FLAG_NAMES:
|
|
3173
3225
|
_raise_flag_name_reserved_by_framework(self.name)
|
|
3226
|
+
if self.name in _BANNED_FLAG_NAMES:
|
|
3227
|
+
_raise_flag_name_yes_banned()
|
|
3174
3228
|
if self.name.startswith("no-"):
|
|
3175
3229
|
raise ValueError(
|
|
3176
3230
|
f"flag '{self.name}': names starting with 'no-' are "
|
|
@@ -3640,6 +3694,20 @@ def _raise_deprecated_command_effect(name: str):
|
|
|
3640
3694
|
)
|
|
3641
3695
|
|
|
3642
3696
|
|
|
3697
|
+
def _raise_command_read_only_consequential(name: str):
|
|
3698
|
+
"""A read_only command cannot be consequential (contract §8.1).
|
|
3699
|
+
|
|
3700
|
+
Classification answers "should a dry run record rather than execute?";
|
|
3701
|
+
``consequential`` answers "are these effects worth interrupting someone
|
|
3702
|
+
for?". A command that changes nothing has no effects to weigh, so the two
|
|
3703
|
+
declarations cannot both hold.
|
|
3704
|
+
"""
|
|
3705
|
+
raise ValueError(
|
|
3706
|
+
f'command "{name}": a read_only command cannot be consequential '
|
|
3707
|
+
f'(a command that changes nothing has nothing to confirm)'
|
|
3708
|
+
)
|
|
3709
|
+
|
|
3710
|
+
|
|
3643
3711
|
@dataclass(frozen=True)
|
|
3644
3712
|
class Command:
|
|
3645
3713
|
"""A leaf command with a handler."""
|
|
@@ -3648,6 +3716,11 @@ class Command:
|
|
|
3648
3716
|
help: str
|
|
3649
3717
|
handler: Callable | None
|
|
3650
3718
|
effect: str
|
|
3719
|
+
# Declared per-command (contract §8.1). NOT mandatory -- absence means
|
|
3720
|
+
# "not consequential". It is a property of the COMMAND, deliberately not
|
|
3721
|
+
# named after the framework's reaction to it, so other behaviours can hang
|
|
3722
|
+
# off it later. Today the framework prompts for exactly these commands.
|
|
3723
|
+
consequential: bool = False
|
|
3651
3724
|
flags: tuple[Flag, ...] = ()
|
|
3652
3725
|
args: tuple[Arg, ...] = ()
|
|
3653
3726
|
flag_sets: tuple[FlagSet, ...] = ()
|
|
@@ -3669,6 +3742,8 @@ class Command:
|
|
|
3669
3742
|
_require_non_empty_str(self.help, "help", "Command")
|
|
3670
3743
|
if self.effect not in _EFFECT_VALUES:
|
|
3671
3744
|
_raise_command_effect_invalid(self.name, self.effect)
|
|
3745
|
+
if self.consequential and self.effect == EFFECT_READ_ONLY:
|
|
3746
|
+
_raise_command_read_only_consequential(self.name)
|
|
3672
3747
|
for tag in self.tags:
|
|
3673
3748
|
if not _IDENTIFIER_RE.fullmatch(tag):
|
|
3674
3749
|
raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
|
|
@@ -3754,6 +3829,7 @@ class Group:
|
|
|
3754
3829
|
*,
|
|
3755
3830
|
help: str,
|
|
3756
3831
|
effect: str | None = None,
|
|
3832
|
+
consequential: bool = False,
|
|
3757
3833
|
args: list[Arg] | None = None,
|
|
3758
3834
|
flag_sets: list[FlagSet] | None = None,
|
|
3759
3835
|
mutex: list[MutexGroup] | None = None,
|
|
@@ -3775,6 +3851,7 @@ class Group:
|
|
|
3775
3851
|
)
|
|
3776
3852
|
cmd = _build_and_validate_command(
|
|
3777
3853
|
name, help=help, effect=effect,
|
|
3854
|
+
consequential=consequential,
|
|
3778
3855
|
handler=func, args=args, flag_sets=flag_sets, mutex=mutex,
|
|
3779
3856
|
dependencies=dependencies,
|
|
3780
3857
|
env_prefix=self.env_prefix,
|
|
@@ -4312,6 +4389,10 @@ class App:
|
|
|
4312
4389
|
# bans the quartet first); kept so the global-flag validation
|
|
4313
4390
|
# path carries the same message for any other construction route.
|
|
4314
4391
|
_raise_flag_name_reserved_by_framework(f.name)
|
|
4392
|
+
if f.name in _BANNED_FLAG_NAMES:
|
|
4393
|
+
# Likewise unreachable through Flag(); kept for parity with the
|
|
4394
|
+
# quartet's own belt-and-braces check on this path.
|
|
4395
|
+
_raise_flag_name_yes_banned()
|
|
4315
4396
|
if f.name in _RESERVED_GLOBAL_FLAG_NAMES:
|
|
4316
4397
|
raise ValueError(
|
|
4317
4398
|
f'global flag name "{f.name}" is reserved'
|
|
@@ -4328,7 +4409,7 @@ class App:
|
|
|
4328
4409
|
# Framework-owned reserved quartet, extracted by the pre-scan and
|
|
4329
4410
|
# delivered on the Context (never as handler kwargs).
|
|
4330
4411
|
self._last_dry_run: bool = False
|
|
4331
|
-
self.
|
|
4412
|
+
self._last_approve_consequential: bool = False
|
|
4332
4413
|
self._last_quiet: bool = False
|
|
4333
4414
|
self._last_verbose: bool = False
|
|
4334
4415
|
|
|
@@ -4547,6 +4628,24 @@ class App:
|
|
|
4547
4628
|
|
|
4548
4629
|
return paths
|
|
4549
4630
|
|
|
4631
|
+
def _collect_all_commands(self) -> list[tuple[str, "Command"]]:
|
|
4632
|
+
"""Enumerate (dotted path, Command) pairs in registration order."""
|
|
4633
|
+
out: list[tuple[str, Command]] = []
|
|
4634
|
+
|
|
4635
|
+
for name, cmd in self._commands.items():
|
|
4636
|
+
out.append((name, cmd))
|
|
4637
|
+
|
|
4638
|
+
def _walk_group(group: Group, prefix: list[str]) -> None:
|
|
4639
|
+
for cmd_name, cmd in group.commands.items():
|
|
4640
|
+
out.append((".".join(prefix + [cmd_name]), cmd))
|
|
4641
|
+
for sub_name, sub_group in group._groups.items():
|
|
4642
|
+
_walk_group(sub_group, prefix + [sub_name])
|
|
4643
|
+
|
|
4644
|
+
for group_name, group in self._groups.items():
|
|
4645
|
+
_walk_group(group, [group_name])
|
|
4646
|
+
|
|
4647
|
+
return out
|
|
4648
|
+
|
|
4550
4649
|
def _test_coverage_provider(self) -> list[CheckSpec]:
|
|
4551
4650
|
"""Built-in check provider for cli-test-coverage.
|
|
4552
4651
|
|
|
@@ -4671,17 +4770,20 @@ class App:
|
|
|
4671
4770
|
]
|
|
4672
4771
|
|
|
4673
4772
|
def _effects_bypass_provider(self) -> list[CheckSpec]:
|
|
4674
|
-
"""Built-in check provider for the
|
|
4773
|
+
"""Built-in check provider for the three effects-regime lints.
|
|
4675
4774
|
|
|
4676
4775
|
Registered whenever the check system turns on, so a consumer that
|
|
4677
|
-
adopts checks at all gets
|
|
4776
|
+
adopts checks at all gets all three without a TOML declaration:
|
|
4678
4777
|
|
|
4679
4778
|
- ``effects-bypass`` (error) fails on any direct process,
|
|
4680
4779
|
filesystem-mutation or network call REACHABLE FROM A REGISTERED
|
|
4681
4780
|
COMMAND HANDLER;
|
|
4682
4781
|
- ``observe-allowlist-breadth`` (warn) surfaces short
|
|
4683
4782
|
``proc_observe_allowlist`` prefixes, which authorize real execution
|
|
4684
|
-
under ``--dry-run
|
|
4783
|
+
under ``--dry-run``;
|
|
4784
|
+
- ``consequential-grant-agreement`` (warn) surfaces commands that
|
|
4785
|
+
declare a process- or network-mutating grant but do not declare
|
|
4786
|
+
themselves consequential.
|
|
4685
4787
|
"""
|
|
4686
4788
|
def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
|
|
4687
4789
|
findings = _scan_effects_bypasses(Path(ctx.project_root))
|
|
@@ -4712,6 +4814,34 @@ class App:
|
|
|
4712
4814
|
"no single-token proc_observe_allowlist prefixes"
|
|
4713
4815
|
)
|
|
4714
4816
|
|
|
4817
|
+
def grant_agreement_impl(ctx: CheckContext,
|
|
4818
|
+
reporter: "WarnReporter") -> "_CheckOutcome":
|
|
4819
|
+
# Only the kinds that leave this process. A file_write or a
|
|
4820
|
+
# proc_spawn is local and ordinarily recoverable; a proc_mutate
|
|
4821
|
+
# runs another program and a net_mutate changes remote state, and
|
|
4822
|
+
# neither can be walked back by the framework. Widening this to
|
|
4823
|
+
# every grant kind would re-create the noise the consequential
|
|
4824
|
+
# declaration exists to remove.
|
|
4825
|
+
escaping = (PROC_MUTATE, NET_MUTATE)
|
|
4826
|
+
found = 0
|
|
4827
|
+
for cmd_path, cmd in self._collect_all_commands():
|
|
4828
|
+
if cmd.consequential:
|
|
4829
|
+
continue
|
|
4830
|
+
for grant in cmd.grants:
|
|
4831
|
+
if grant.kind not in escaping:
|
|
4832
|
+
continue
|
|
4833
|
+
found += 1
|
|
4834
|
+
reporter.warn(_consequential_grant_warning(
|
|
4835
|
+
cmd_path, grant.name, grant.kind,
|
|
4836
|
+
))
|
|
4837
|
+
if found:
|
|
4838
|
+
return reporter.found(
|
|
4839
|
+
f"{found} grant(s) on non-consequential command(s)"
|
|
4840
|
+
)
|
|
4841
|
+
return reporter.passed(
|
|
4842
|
+
"every escaping grant sits on a consequential command"
|
|
4843
|
+
)
|
|
4844
|
+
|
|
4715
4845
|
return [
|
|
4716
4846
|
error_check_spec(
|
|
4717
4847
|
name="effects-bypass",
|
|
@@ -4731,6 +4861,15 @@ class App:
|
|
|
4731
4861
|
depends_on=[],
|
|
4732
4862
|
impl=breadth_impl,
|
|
4733
4863
|
),
|
|
4864
|
+
warn_check_spec(
|
|
4865
|
+
name="consequential-grant-agreement",
|
|
4866
|
+
tags=["effects", "quality"],
|
|
4867
|
+
fast=True,
|
|
4868
|
+
pure=True,
|
|
4869
|
+
needs_network=False,
|
|
4870
|
+
depends_on=[],
|
|
4871
|
+
impl=grant_agreement_impl,
|
|
4872
|
+
),
|
|
4734
4873
|
]
|
|
4735
4874
|
|
|
4736
4875
|
@property
|
|
@@ -5333,6 +5472,7 @@ class App:
|
|
|
5333
5472
|
*,
|
|
5334
5473
|
help: str,
|
|
5335
5474
|
effect: str | None = None,
|
|
5475
|
+
consequential: bool = False,
|
|
5336
5476
|
args: list[Arg] | None = None,
|
|
5337
5477
|
flag_sets: list[FlagSet] | None = None,
|
|
5338
5478
|
mutex: list[MutexGroup] | None = None,
|
|
@@ -5352,6 +5492,7 @@ class App:
|
|
|
5352
5492
|
name,
|
|
5353
5493
|
help=help,
|
|
5354
5494
|
effect=effect,
|
|
5495
|
+
consequential=consequential,
|
|
5355
5496
|
handler=func,
|
|
5356
5497
|
args=args,
|
|
5357
5498
|
flag_sets=flag_sets,
|
|
@@ -5459,20 +5600,23 @@ class App:
|
|
|
5459
5600
|
if name in flag_params
|
|
5460
5601
|
}
|
|
5461
5602
|
|
|
5462
|
-
def
|
|
5603
|
+
def _confirm_consequential(self, cmd: "Command", cmd_path: str) -> None:
|
|
5463
5604
|
"""The framework-owned confirm protocol.
|
|
5464
5605
|
|
|
5465
|
-
Fires before dispatching a
|
|
5466
|
-
when neither --dry-run nor
|
|
5467
|
-
|
|
5468
|
-
|
|
5606
|
+
Fires before dispatching a command that DECLARES ITSELF consequential,
|
|
5607
|
+
on the real CLI path, when neither --dry-run nor
|
|
5608
|
+
--approve-consequential was passed. A plain ``mutating`` command never
|
|
5609
|
+
prompts: classification answers "should a dry run record rather than
|
|
5610
|
+
execute?", which is a different question from "are these effects worth
|
|
5611
|
+
interrupting someone for?". Never fires on the programmatic paths
|
|
5612
|
+
(test/call/_invoke/MCP), which have no TTY contract and would hang.
|
|
5469
5613
|
|
|
5470
|
-
A
|
|
5471
|
-
what is about to happen, not more.
|
|
5614
|
+
A consequential PASSTHROUGH is not exempt: the framework knows LESS
|
|
5615
|
+
about what is about to happen, not more.
|
|
5472
5616
|
"""
|
|
5473
|
-
if cmd.
|
|
5617
|
+
if not cmd.consequential:
|
|
5474
5618
|
return
|
|
5475
|
-
if self._last_dry_run or self.
|
|
5619
|
+
if self._last_dry_run or self._last_approve_consequential:
|
|
5476
5620
|
return
|
|
5477
5621
|
if not sys.stdin.isatty():
|
|
5478
5622
|
print(_msg_confirm_non_interactive(), file=sys.stderr)
|
|
@@ -5508,6 +5652,29 @@ class App:
|
|
|
5508
5652
|
"""Start a new dispatch: reset the structured effect log."""
|
|
5509
5653
|
self._effect_log = _EffectLog()
|
|
5510
5654
|
|
|
5655
|
+
def _render_dry_log(self, cmd_path: str, *, aborted: bool) -> None:
|
|
5656
|
+
"""Write the would-do log for a dry run. No-op outside dry mode.
|
|
5657
|
+
|
|
5658
|
+
Called on every exit path out of a dispatch, so a handler that leaves
|
|
5659
|
+
through ``sys.exit`` or an exception still shows the preview it was
|
|
5660
|
+
asked for. The log always goes to stdout and is never suppressed by
|
|
5661
|
+
``--quiet``: it is dry mode's primary output.
|
|
5662
|
+
|
|
5663
|
+
``aborted`` marks a dispatch that did not finish. The log is still
|
|
5664
|
+
written -- the recorded effects are owed either way -- and the marker
|
|
5665
|
+
that follows it on stderr says the reader cannot assume the list is
|
|
5666
|
+
the whole preview. The truncation path (which ends the preview for its
|
|
5667
|
+
own pinned reason) renders itself and never comes through here.
|
|
5668
|
+
"""
|
|
5669
|
+
if not self._last_dry_run:
|
|
5670
|
+
return
|
|
5671
|
+
print(self._effect_log.render())
|
|
5672
|
+
if aborted:
|
|
5673
|
+
print(
|
|
5674
|
+
_msg_dry_run_aborted(self._effect_log.next_seq(), cmd_path),
|
|
5675
|
+
file=sys.stderr,
|
|
5676
|
+
)
|
|
5677
|
+
|
|
5511
5678
|
def _record_cache_write(self, path: str) -> None:
|
|
5512
5679
|
"""Record a framework-blessed CACHE_WRITE.
|
|
5513
5680
|
|
|
@@ -6007,7 +6174,7 @@ class App:
|
|
|
6007
6174
|
"""Pre-scan for the framework-owned reserved flags.
|
|
6008
6175
|
|
|
6009
6176
|
Handles --dump-schema, --mcp, --config, --hermetic and the effects-regime
|
|
6010
|
-
quartet --dry-run/--
|
|
6177
|
+
quartet --dry-run/--approve-consequential/--quiet/--verbose.
|
|
6011
6178
|
|
|
6012
6179
|
Two regions, two rulesets (contract §7.2, amended):
|
|
6013
6180
|
|
|
@@ -6020,7 +6187,7 @@ class App:
|
|
|
6020
6187
|
pre-command-only. See _scan_command_region_quartet.
|
|
6021
6188
|
|
|
6022
6189
|
Returns a dict with keys: dump_schema, serve_mcp, hermetic, config_path,
|
|
6023
|
-
dry_run,
|
|
6190
|
+
dry_run, approve_consequential, quiet, verbose, err, cleaned_argv.
|
|
6024
6191
|
"""
|
|
6025
6192
|
# Build a set of known global flag tokens with value-taking info
|
|
6026
6193
|
known_flags: dict[str, bool] = {} # token -> takes_value
|
|
@@ -6150,7 +6317,7 @@ class App:
|
|
|
6150
6317
|
) -> None:
|
|
6151
6318
|
"""Recognize the reserved quartet in the command region of argv.
|
|
6152
6319
|
|
|
6153
|
-
Contract §7.2 (amended 2026-08-04):
|
|
6320
|
+
Contract §7.2 (amended 2026-08-04): the quartet's four tokens are
|
|
6154
6321
|
recognized ANYWHERE in argv, exactly like --help/-h, because their
|
|
6155
6322
|
applicability is per-command -- requiring them before the command name
|
|
6156
6323
|
was backwards. Only the quartet is recognized here; --hermetic,
|
|
@@ -6235,7 +6402,9 @@ class App:
|
|
|
6235
6402
|
|
|
6236
6403
|
# Record the reserved quartet for the dispatch ctx.
|
|
6237
6404
|
self._last_dry_run = bool(pre_scan.get("dry_run"))
|
|
6238
|
-
self.
|
|
6405
|
+
self._last_approve_consequential = bool(
|
|
6406
|
+
pre_scan.get("approve_consequential")
|
|
6407
|
+
)
|
|
6239
6408
|
self._last_quiet = bool(pre_scan.get("quiet"))
|
|
6240
6409
|
self._last_verbose = bool(pre_scan.get("verbose"))
|
|
6241
6410
|
|
|
@@ -6867,26 +7036,38 @@ class App:
|
|
|
6867
7036
|
ctx = Context(
|
|
6868
7037
|
stdout=sys.stdout, stderr=sys.stderr, sources=sources,
|
|
6869
7038
|
infra=self._infra_access(self._last_hermetic),
|
|
6870
|
-
dry_run=self._last_dry_run,
|
|
7039
|
+
dry_run=self._last_dry_run,
|
|
7040
|
+
approve_consequential=self._last_approve_consequential,
|
|
6871
7041
|
quiet=self._last_quiet, verbose=self._last_verbose,
|
|
6872
7042
|
effects=effects,
|
|
6873
7043
|
)
|
|
6874
7044
|
# The confirm protocol fires only on the real CLI path.
|
|
6875
|
-
self.
|
|
7045
|
+
self._confirm_consequential(cmd, cmd_path)
|
|
7046
|
+
# The would-do log renders on EVERY exit path out of the dispatch,
|
|
7047
|
+
# not just the normal return: the operator asked for a preview and
|
|
7048
|
+
# the effects were recorded, so a handler that unwinds through
|
|
7049
|
+
# sys.exit or an exception still owes them the list. The clause set
|
|
7050
|
+
# below is exhaustive by construction -- BaseException is the root
|
|
7051
|
+
# of the hierarchy, so no unwind can slip past it.
|
|
6876
7052
|
try:
|
|
6877
7053
|
if cmd.passthrough is not None:
|
|
6878
7054
|
result = cmd.passthrough.handler(ctx, cmd.name, data, self._last_global_values)
|
|
6879
7055
|
else:
|
|
6880
7056
|
result = cmd.handler(ctx, **data)
|
|
7057
|
+
exit_code, out_data = _interpret_handler_return(result)
|
|
7058
|
+
if out_data is not _MISSING:
|
|
7059
|
+
print(json.dumps(out_data, default=str, separators=(",", ":")))
|
|
6881
7060
|
except _DryRunTruncated as trunc:
|
|
6882
7061
|
print(trunc.log.render())
|
|
6883
7062
|
print(trunc.message, file=sys.stderr)
|
|
6884
7063
|
sys.exit(1)
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
7064
|
+
except SystemExit:
|
|
7065
|
+
self._render_dry_log(cmd_path, aborted=False)
|
|
7066
|
+
raise
|
|
7067
|
+
except BaseException:
|
|
7068
|
+
self._render_dry_log(cmd_path, aborted=True)
|
|
7069
|
+
raise
|
|
7070
|
+
self._render_dry_log(cmd_path, aborted=False)
|
|
6890
7071
|
sys.exit(exit_code)
|
|
6891
7072
|
|
|
6892
7073
|
def test(self, argv: list[str]) -> Result:
|
|
@@ -6945,9 +7126,9 @@ class App:
|
|
|
6945
7126
|
exit_code = 1
|
|
6946
7127
|
else:
|
|
6947
7128
|
self._begin_dispatch()
|
|
7129
|
+
cmd_path = ".".join(self._last_resolved_path + [cmd.name])
|
|
6948
7130
|
# Record test-coverage hit (command-level only).
|
|
6949
7131
|
if self.test_coverage:
|
|
6950
|
-
cmd_path = ".".join(self._last_resolved_path + [cmd.name])
|
|
6951
7132
|
self._record_coverage(cmd_path)
|
|
6952
7133
|
# Store sources for function handlers that need provenance info
|
|
6953
7134
|
self._last_sources = sources
|
|
@@ -6956,12 +7137,11 @@ class App:
|
|
|
6956
7137
|
ctx = Context(
|
|
6957
7138
|
stdout=stdout_buf, stderr=stderr_buf, sources=sources,
|
|
6958
7139
|
infra=self._infra_access(self._last_hermetic),
|
|
6959
|
-
dry_run=self._last_dry_run,
|
|
7140
|
+
dry_run=self._last_dry_run,
|
|
7141
|
+
approve_consequential=self._last_approve_consequential,
|
|
6960
7142
|
quiet=self._last_quiet, verbose=self._last_verbose,
|
|
6961
7143
|
effects=self._arm_effects(
|
|
6962
|
-
cmd,
|
|
6963
|
-
".".join(self._last_resolved_path + [cmd.name]),
|
|
6964
|
-
dry_run=self._last_dry_run,
|
|
7144
|
+
cmd, cmd_path, dry_run=self._last_dry_run,
|
|
6965
7145
|
),
|
|
6966
7146
|
)
|
|
6967
7147
|
if cmd.passthrough is not None:
|
|
@@ -6974,14 +7154,17 @@ class App:
|
|
|
6974
7154
|
if out_data is not _MISSING:
|
|
6975
7155
|
result_data = out_data
|
|
6976
7156
|
print(json.dumps(out_data, default=str, separators=(",", ":")))
|
|
6977
|
-
|
|
6978
|
-
print(self._effect_log.render())
|
|
7157
|
+
self._render_dry_log(cmd_path, aborted=False)
|
|
6979
7158
|
except _DryRunTruncated as trunc:
|
|
6980
7159
|
print(trunc.log.render())
|
|
6981
7160
|
print(trunc.message, file=sys.stderr)
|
|
6982
7161
|
exit_code = 1
|
|
6983
7162
|
except SystemExit as e:
|
|
6984
7163
|
exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
|
|
7164
|
+
self._render_dry_log(cmd_path, aborted=False)
|
|
7165
|
+
except BaseException:
|
|
7166
|
+
self._render_dry_log(cmd_path, aborted=True)
|
|
7167
|
+
raise
|
|
6985
7168
|
|
|
6986
7169
|
return Result(
|
|
6987
7170
|
stdout=stdout_buf.getvalue(),
|
|
@@ -8074,6 +8257,7 @@ def _build_and_validate_command(
|
|
|
8074
8257
|
*,
|
|
8075
8258
|
help: str,
|
|
8076
8259
|
effect: str | None,
|
|
8260
|
+
consequential: bool = False,
|
|
8077
8261
|
handler: Callable,
|
|
8078
8262
|
args: list[Arg] | None,
|
|
8079
8263
|
flag_sets: list[FlagSet] | None,
|
|
@@ -8111,6 +8295,11 @@ def _build_and_validate_command(
|
|
|
8111
8295
|
if effect not in _EFFECT_VALUES:
|
|
8112
8296
|
_raise_command_effect_invalid(name, effect)
|
|
8113
8297
|
|
|
8298
|
+
# A read_only command cannot be consequential: it changes nothing, so
|
|
8299
|
+
# there is nothing to interrupt anyone for (contract §8.1).
|
|
8300
|
+
if consequential and effect == EFFECT_READ_ONLY:
|
|
8301
|
+
_raise_command_read_only_consequential(name)
|
|
8302
|
+
|
|
8114
8303
|
resolved_grants = _validate_grants(name, grants)
|
|
8115
8304
|
|
|
8116
8305
|
# Declared forwarding: the reason is mandatory and non-empty.
|
|
@@ -8168,6 +8357,7 @@ def _build_and_validate_command(
|
|
|
8168
8357
|
help=help,
|
|
8169
8358
|
handler=None,
|
|
8170
8359
|
effect=effect,
|
|
8360
|
+
consequential=consequential,
|
|
8171
8361
|
passthrough=passthrough,
|
|
8172
8362
|
tags=effective_tags,
|
|
8173
8363
|
hidden=hidden,
|
|
@@ -8433,6 +8623,7 @@ def _build_and_validate_command(
|
|
|
8433
8623
|
help=help,
|
|
8434
8624
|
handler=handler,
|
|
8435
8625
|
effect=effect,
|
|
8626
|
+
consequential=consequential,
|
|
8436
8627
|
flags=tuple(all_flags),
|
|
8437
8628
|
args=tuple(all_args),
|
|
8438
8629
|
flag_sets=tuple(resolved_flag_sets),
|
|
@@ -9488,6 +9679,10 @@ def _serialize_command(cmd: Command) -> dict:
|
|
|
9488
9679
|
# to omit against.
|
|
9489
9680
|
"effect": cmd.effect,
|
|
9490
9681
|
}
|
|
9682
|
+
# Omitted when false: consequential is NOT mandatory, and absence means
|
|
9683
|
+
# "not consequential" (contract §8.1, §13).
|
|
9684
|
+
if cmd.consequential:
|
|
9685
|
+
d["consequential"] = True
|
|
9491
9686
|
if cmd.passthrough is not None:
|
|
9492
9687
|
d["passthrough"] = True
|
|
9493
9688
|
flags = [_serialize_flag(f) for f in cmd.flags]
|