strictcli 0.35.4__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.4 → strictcli-0.36.0}/PKG-INFO +1 -1
- {strictcli-0.35.4 → strictcli-0.36.0}/pyproject.toml +1 -1
- {strictcli-0.35.4 → strictcli-0.36.0}/strictcli/__init__.py +181 -31
- strictcli-0.36.0/tests/test_confirm.py +349 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_effects.py +1 -1
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_effects_bypass_check.py +132 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_reserved_quartet.py +57 -13
- {strictcli-0.35.4 → strictcli-0.36.0}/uv.lock +1 -1
- strictcli-0.35.4/tests/test_confirm.py +0 -191
- {strictcli-0.35.4 → strictcli-0.36.0}/.claude/settings.json +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.gitignore +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/config.json +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.rlsbl/version +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/.strictcli/schema.json +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/CLAUDE.md +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/LICENSE +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/README.md +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/strictcli/py.typed +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/conftest.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/flagship_app.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_call.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_command.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_check_types.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_choices.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_classification.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_connection_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_context.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_coverage.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_e2e.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_format.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_type.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_guard_v2.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_help.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_int_type.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_invoke.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_mcp.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_mutex.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_nesting.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_parser.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_provenance.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_registration.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_unique.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_utilities.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_validate.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_variadic.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/tests/test_visibility.py +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.35.4 → strictcli-0.36.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.35.4 → 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:
|
|
@@ -686,7 +687,7 @@ def _msg_dry_run_aborted(step: int, cmd: str) -> str:
|
|
|
686
687
|
|
|
687
688
|
def _msg_confirm_prompt(cmd_path: str) -> str:
|
|
688
689
|
"""The confirm prompt. A prompt, not an error, but parity is still checked."""
|
|
689
|
-
return f"about to run
|
|
690
|
+
return f"about to run consequential command '{cmd_path}'. Proceed? [y/N] "
|
|
690
691
|
|
|
691
692
|
|
|
692
693
|
def _strip_confirm_line(answer: str) -> str:
|
|
@@ -707,13 +708,35 @@ def _strip_confirm_line(answer: str) -> str:
|
|
|
707
708
|
|
|
708
709
|
|
|
709
710
|
def _msg_confirm_non_interactive() -> str:
|
|
710
|
-
return
|
|
711
|
+
return (
|
|
712
|
+
"error: stdin is not interactive; pass --approve-consequential to confirm"
|
|
713
|
+
)
|
|
711
714
|
|
|
712
715
|
|
|
713
716
|
def _msg_confirm_declined() -> str:
|
|
714
717
|
return "aborted"
|
|
715
718
|
|
|
716
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
|
+
|
|
717
740
|
def _observe_allowlist_breadth_warning(binary: str) -> str:
|
|
718
741
|
"""The `observe-allowlist-breadth` warning (contract §6.2).
|
|
719
742
|
|
|
@@ -3005,9 +3028,15 @@ _NON_BOOL_SCALAR_TYPES = (str, int, float)
|
|
|
3005
3028
|
# names are unaffected by this ban, and the four flags themselves have no short
|
|
3006
3029
|
# forms.
|
|
3007
3030
|
_RESERVED_FRAMEWORK_FLAG_NAMES = frozenset({
|
|
3008
|
-
"dry-run", "
|
|
3031
|
+
"dry-run", "approve-consequential", "quiet", "verbose",
|
|
3009
3032
|
})
|
|
3010
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
|
+
|
|
3011
3040
|
# Names reserved by the framework for global flags. The pre-existing set is
|
|
3012
3041
|
# also what a SHORT flag name is checked against (the framework quartet bans
|
|
3013
3042
|
# long names only).
|
|
@@ -3020,7 +3049,7 @@ _RESERVED_GLOBAL_FLAG_NAMES = _RESERVED_GLOBAL_SHORT_NAMES | _RESERVED_FRAMEWORK
|
|
|
3020
3049
|
# argv token -> pre-scan result key for the reserved quartet.
|
|
3021
3050
|
_RESERVED_QUARTET_TOKENS = {
|
|
3022
3051
|
"--dry-run": "dry_run",
|
|
3023
|
-
"--
|
|
3052
|
+
"--approve-consequential": "approve_consequential",
|
|
3024
3053
|
"--quiet": "quiet",
|
|
3025
3054
|
"--verbose": "verbose",
|
|
3026
3055
|
}
|
|
@@ -3030,7 +3059,20 @@ def _raise_flag_name_reserved_by_framework(name: str):
|
|
|
3030
3059
|
"""Message template: a flag name collides with the reserved quartet."""
|
|
3031
3060
|
raise ValueError(
|
|
3032
3061
|
f"flag name '{name}' is reserved by the framework "
|
|
3033
|
-
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"
|
|
3034
3076
|
)
|
|
3035
3077
|
|
|
3036
3078
|
|
|
@@ -3181,6 +3223,8 @@ class Flag:
|
|
|
3181
3223
|
)
|
|
3182
3224
|
if self.name in _RESERVED_FRAMEWORK_FLAG_NAMES:
|
|
3183
3225
|
_raise_flag_name_reserved_by_framework(self.name)
|
|
3226
|
+
if self.name in _BANNED_FLAG_NAMES:
|
|
3227
|
+
_raise_flag_name_yes_banned()
|
|
3184
3228
|
if self.name.startswith("no-"):
|
|
3185
3229
|
raise ValueError(
|
|
3186
3230
|
f"flag '{self.name}': names starting with 'no-' are "
|
|
@@ -3650,6 +3694,20 @@ def _raise_deprecated_command_effect(name: str):
|
|
|
3650
3694
|
)
|
|
3651
3695
|
|
|
3652
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
|
+
|
|
3653
3711
|
@dataclass(frozen=True)
|
|
3654
3712
|
class Command:
|
|
3655
3713
|
"""A leaf command with a handler."""
|
|
@@ -3658,6 +3716,11 @@ class Command:
|
|
|
3658
3716
|
help: str
|
|
3659
3717
|
handler: Callable | None
|
|
3660
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
|
|
3661
3724
|
flags: tuple[Flag, ...] = ()
|
|
3662
3725
|
args: tuple[Arg, ...] = ()
|
|
3663
3726
|
flag_sets: tuple[FlagSet, ...] = ()
|
|
@@ -3679,6 +3742,8 @@ class Command:
|
|
|
3679
3742
|
_require_non_empty_str(self.help, "help", "Command")
|
|
3680
3743
|
if self.effect not in _EFFECT_VALUES:
|
|
3681
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)
|
|
3682
3747
|
for tag in self.tags:
|
|
3683
3748
|
if not _IDENTIFIER_RE.fullmatch(tag):
|
|
3684
3749
|
raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
|
|
@@ -3764,6 +3829,7 @@ class Group:
|
|
|
3764
3829
|
*,
|
|
3765
3830
|
help: str,
|
|
3766
3831
|
effect: str | None = None,
|
|
3832
|
+
consequential: bool = False,
|
|
3767
3833
|
args: list[Arg] | None = None,
|
|
3768
3834
|
flag_sets: list[FlagSet] | None = None,
|
|
3769
3835
|
mutex: list[MutexGroup] | None = None,
|
|
@@ -3785,6 +3851,7 @@ class Group:
|
|
|
3785
3851
|
)
|
|
3786
3852
|
cmd = _build_and_validate_command(
|
|
3787
3853
|
name, help=help, effect=effect,
|
|
3854
|
+
consequential=consequential,
|
|
3788
3855
|
handler=func, args=args, flag_sets=flag_sets, mutex=mutex,
|
|
3789
3856
|
dependencies=dependencies,
|
|
3790
3857
|
env_prefix=self.env_prefix,
|
|
@@ -4322,6 +4389,10 @@ class App:
|
|
|
4322
4389
|
# bans the quartet first); kept so the global-flag validation
|
|
4323
4390
|
# path carries the same message for any other construction route.
|
|
4324
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()
|
|
4325
4396
|
if f.name in _RESERVED_GLOBAL_FLAG_NAMES:
|
|
4326
4397
|
raise ValueError(
|
|
4327
4398
|
f'global flag name "{f.name}" is reserved'
|
|
@@ -4338,7 +4409,7 @@ class App:
|
|
|
4338
4409
|
# Framework-owned reserved quartet, extracted by the pre-scan and
|
|
4339
4410
|
# delivered on the Context (never as handler kwargs).
|
|
4340
4411
|
self._last_dry_run: bool = False
|
|
4341
|
-
self.
|
|
4412
|
+
self._last_approve_consequential: bool = False
|
|
4342
4413
|
self._last_quiet: bool = False
|
|
4343
4414
|
self._last_verbose: bool = False
|
|
4344
4415
|
|
|
@@ -4557,6 +4628,24 @@ class App:
|
|
|
4557
4628
|
|
|
4558
4629
|
return paths
|
|
4559
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
|
+
|
|
4560
4649
|
def _test_coverage_provider(self) -> list[CheckSpec]:
|
|
4561
4650
|
"""Built-in check provider for cli-test-coverage.
|
|
4562
4651
|
|
|
@@ -4681,17 +4770,20 @@ class App:
|
|
|
4681
4770
|
]
|
|
4682
4771
|
|
|
4683
4772
|
def _effects_bypass_provider(self) -> list[CheckSpec]:
|
|
4684
|
-
"""Built-in check provider for the
|
|
4773
|
+
"""Built-in check provider for the three effects-regime lints.
|
|
4685
4774
|
|
|
4686
4775
|
Registered whenever the check system turns on, so a consumer that
|
|
4687
|
-
adopts checks at all gets
|
|
4776
|
+
adopts checks at all gets all three without a TOML declaration:
|
|
4688
4777
|
|
|
4689
4778
|
- ``effects-bypass`` (error) fails on any direct process,
|
|
4690
4779
|
filesystem-mutation or network call REACHABLE FROM A REGISTERED
|
|
4691
4780
|
COMMAND HANDLER;
|
|
4692
4781
|
- ``observe-allowlist-breadth`` (warn) surfaces short
|
|
4693
4782
|
``proc_observe_allowlist`` prefixes, which authorize real execution
|
|
4694
|
-
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.
|
|
4695
4787
|
"""
|
|
4696
4788
|
def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
|
|
4697
4789
|
findings = _scan_effects_bypasses(Path(ctx.project_root))
|
|
@@ -4722,6 +4814,34 @@ class App:
|
|
|
4722
4814
|
"no single-token proc_observe_allowlist prefixes"
|
|
4723
4815
|
)
|
|
4724
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
|
+
|
|
4725
4845
|
return [
|
|
4726
4846
|
error_check_spec(
|
|
4727
4847
|
name="effects-bypass",
|
|
@@ -4741,6 +4861,15 @@ class App:
|
|
|
4741
4861
|
depends_on=[],
|
|
4742
4862
|
impl=breadth_impl,
|
|
4743
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
|
+
),
|
|
4744
4873
|
]
|
|
4745
4874
|
|
|
4746
4875
|
@property
|
|
@@ -5343,6 +5472,7 @@ class App:
|
|
|
5343
5472
|
*,
|
|
5344
5473
|
help: str,
|
|
5345
5474
|
effect: str | None = None,
|
|
5475
|
+
consequential: bool = False,
|
|
5346
5476
|
args: list[Arg] | None = None,
|
|
5347
5477
|
flag_sets: list[FlagSet] | None = None,
|
|
5348
5478
|
mutex: list[MutexGroup] | None = None,
|
|
@@ -5362,6 +5492,7 @@ class App:
|
|
|
5362
5492
|
name,
|
|
5363
5493
|
help=help,
|
|
5364
5494
|
effect=effect,
|
|
5495
|
+
consequential=consequential,
|
|
5365
5496
|
handler=func,
|
|
5366
5497
|
args=args,
|
|
5367
5498
|
flag_sets=flag_sets,
|
|
@@ -5469,20 +5600,23 @@ class App:
|
|
|
5469
5600
|
if name in flag_params
|
|
5470
5601
|
}
|
|
5471
5602
|
|
|
5472
|
-
def
|
|
5603
|
+
def _confirm_consequential(self, cmd: "Command", cmd_path: str) -> None:
|
|
5473
5604
|
"""The framework-owned confirm protocol.
|
|
5474
5605
|
|
|
5475
|
-
Fires before dispatching a
|
|
5476
|
-
when neither --dry-run nor
|
|
5477
|
-
|
|
5478
|
-
|
|
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.
|
|
5479
5613
|
|
|
5480
|
-
A
|
|
5481
|
-
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.
|
|
5482
5616
|
"""
|
|
5483
|
-
if cmd.
|
|
5617
|
+
if not cmd.consequential:
|
|
5484
5618
|
return
|
|
5485
|
-
if self._last_dry_run or self.
|
|
5619
|
+
if self._last_dry_run or self._last_approve_consequential:
|
|
5486
5620
|
return
|
|
5487
5621
|
if not sys.stdin.isatty():
|
|
5488
5622
|
print(_msg_confirm_non_interactive(), file=sys.stderr)
|
|
@@ -6040,7 +6174,7 @@ class App:
|
|
|
6040
6174
|
"""Pre-scan for the framework-owned reserved flags.
|
|
6041
6175
|
|
|
6042
6176
|
Handles --dump-schema, --mcp, --config, --hermetic and the effects-regime
|
|
6043
|
-
quartet --dry-run/--
|
|
6177
|
+
quartet --dry-run/--approve-consequential/--quiet/--verbose.
|
|
6044
6178
|
|
|
6045
6179
|
Two regions, two rulesets (contract §7.2, amended):
|
|
6046
6180
|
|
|
@@ -6053,7 +6187,7 @@ class App:
|
|
|
6053
6187
|
pre-command-only. See _scan_command_region_quartet.
|
|
6054
6188
|
|
|
6055
6189
|
Returns a dict with keys: dump_schema, serve_mcp, hermetic, config_path,
|
|
6056
|
-
dry_run,
|
|
6190
|
+
dry_run, approve_consequential, quiet, verbose, err, cleaned_argv.
|
|
6057
6191
|
"""
|
|
6058
6192
|
# Build a set of known global flag tokens with value-taking info
|
|
6059
6193
|
known_flags: dict[str, bool] = {} # token -> takes_value
|
|
@@ -6183,7 +6317,7 @@ class App:
|
|
|
6183
6317
|
) -> None:
|
|
6184
6318
|
"""Recognize the reserved quartet in the command region of argv.
|
|
6185
6319
|
|
|
6186
|
-
Contract §7.2 (amended 2026-08-04):
|
|
6320
|
+
Contract §7.2 (amended 2026-08-04): the quartet's four tokens are
|
|
6187
6321
|
recognized ANYWHERE in argv, exactly like --help/-h, because their
|
|
6188
6322
|
applicability is per-command -- requiring them before the command name
|
|
6189
6323
|
was backwards. Only the quartet is recognized here; --hermetic,
|
|
@@ -6268,7 +6402,9 @@ class App:
|
|
|
6268
6402
|
|
|
6269
6403
|
# Record the reserved quartet for the dispatch ctx.
|
|
6270
6404
|
self._last_dry_run = bool(pre_scan.get("dry_run"))
|
|
6271
|
-
self.
|
|
6405
|
+
self._last_approve_consequential = bool(
|
|
6406
|
+
pre_scan.get("approve_consequential")
|
|
6407
|
+
)
|
|
6272
6408
|
self._last_quiet = bool(pre_scan.get("quiet"))
|
|
6273
6409
|
self._last_verbose = bool(pre_scan.get("verbose"))
|
|
6274
6410
|
|
|
@@ -6900,12 +7036,13 @@ class App:
|
|
|
6900
7036
|
ctx = Context(
|
|
6901
7037
|
stdout=sys.stdout, stderr=sys.stderr, sources=sources,
|
|
6902
7038
|
infra=self._infra_access(self._last_hermetic),
|
|
6903
|
-
dry_run=self._last_dry_run,
|
|
7039
|
+
dry_run=self._last_dry_run,
|
|
7040
|
+
approve_consequential=self._last_approve_consequential,
|
|
6904
7041
|
quiet=self._last_quiet, verbose=self._last_verbose,
|
|
6905
7042
|
effects=effects,
|
|
6906
7043
|
)
|
|
6907
7044
|
# The confirm protocol fires only on the real CLI path.
|
|
6908
|
-
self.
|
|
7045
|
+
self._confirm_consequential(cmd, cmd_path)
|
|
6909
7046
|
# The would-do log renders on EVERY exit path out of the dispatch,
|
|
6910
7047
|
# not just the normal return: the operator asked for a preview and
|
|
6911
7048
|
# the effects were recorded, so a handler that unwinds through
|
|
@@ -7000,7 +7137,8 @@ class App:
|
|
|
7000
7137
|
ctx = Context(
|
|
7001
7138
|
stdout=stdout_buf, stderr=stderr_buf, sources=sources,
|
|
7002
7139
|
infra=self._infra_access(self._last_hermetic),
|
|
7003
|
-
dry_run=self._last_dry_run,
|
|
7140
|
+
dry_run=self._last_dry_run,
|
|
7141
|
+
approve_consequential=self._last_approve_consequential,
|
|
7004
7142
|
quiet=self._last_quiet, verbose=self._last_verbose,
|
|
7005
7143
|
effects=self._arm_effects(
|
|
7006
7144
|
cmd, cmd_path, dry_run=self._last_dry_run,
|
|
@@ -8119,6 +8257,7 @@ def _build_and_validate_command(
|
|
|
8119
8257
|
*,
|
|
8120
8258
|
help: str,
|
|
8121
8259
|
effect: str | None,
|
|
8260
|
+
consequential: bool = False,
|
|
8122
8261
|
handler: Callable,
|
|
8123
8262
|
args: list[Arg] | None,
|
|
8124
8263
|
flag_sets: list[FlagSet] | None,
|
|
@@ -8156,6 +8295,11 @@ def _build_and_validate_command(
|
|
|
8156
8295
|
if effect not in _EFFECT_VALUES:
|
|
8157
8296
|
_raise_command_effect_invalid(name, effect)
|
|
8158
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
|
+
|
|
8159
8303
|
resolved_grants = _validate_grants(name, grants)
|
|
8160
8304
|
|
|
8161
8305
|
# Declared forwarding: the reason is mandatory and non-empty.
|
|
@@ -8213,6 +8357,7 @@ def _build_and_validate_command(
|
|
|
8213
8357
|
help=help,
|
|
8214
8358
|
handler=None,
|
|
8215
8359
|
effect=effect,
|
|
8360
|
+
consequential=consequential,
|
|
8216
8361
|
passthrough=passthrough,
|
|
8217
8362
|
tags=effective_tags,
|
|
8218
8363
|
hidden=hidden,
|
|
@@ -8478,6 +8623,7 @@ def _build_and_validate_command(
|
|
|
8478
8623
|
help=help,
|
|
8479
8624
|
handler=handler,
|
|
8480
8625
|
effect=effect,
|
|
8626
|
+
consequential=consequential,
|
|
8481
8627
|
flags=tuple(all_flags),
|
|
8482
8628
|
args=tuple(all_args),
|
|
8483
8629
|
flag_sets=tuple(resolved_flag_sets),
|
|
@@ -9533,6 +9679,10 @@ def _serialize_command(cmd: Command) -> dict:
|
|
|
9533
9679
|
# to omit against.
|
|
9534
9680
|
"effect": cmd.effect,
|
|
9535
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
|
|
9536
9686
|
if cmd.passthrough is not None:
|
|
9537
9687
|
d["passthrough"] = True
|
|
9538
9688
|
flags = [_serialize_flag(f) for f in cmd.flags]
|