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.
Files changed (99) hide show
  1. {strictcli-0.35.3 → strictcli-0.36.0}/PKG-INFO +1 -1
  2. {strictcli-0.35.3 → strictcli-0.36.0}/pyproject.toml +1 -1
  3. {strictcli-0.35.3 → strictcli-0.36.0}/strictcli/__init__.py +237 -42
  4. strictcli-0.36.0/tests/test_confirm.py +349 -0
  5. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_effects.py +181 -0
  6. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_effects_bypass_check.py +132 -0
  7. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_reserved_quartet.py +57 -13
  8. {strictcli-0.35.3 → strictcli-0.36.0}/uv.lock +1 -1
  9. strictcli-0.35.3/tests/test_confirm.py +0 -191
  10. {strictcli-0.35.3 → strictcli-0.36.0}/.claude/settings.json +0 -0
  11. {strictcli-0.35.3 → strictcli-0.36.0}/.github/workflows/ci.yml +0 -0
  12. {strictcli-0.35.3 → strictcli-0.36.0}/.github/workflows/publish.yml +0 -0
  13. {strictcli-0.35.3 → strictcli-0.36.0}/.gitignore +0 -0
  14. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  15. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  16. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.gitignore +0 -0
  17. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  18. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/config.json +0 -0
  19. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/lint/python.toml +0 -0
  20. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/managed-files.json +0 -0
  21. {strictcli-0.35.3 → strictcli-0.36.0}/.rlsbl/version +0 -0
  22. {strictcli-0.35.3 → strictcli-0.36.0}/.strictcli/schema.json +0 -0
  23. {strictcli-0.35.3 → strictcli-0.36.0}/CLAUDE.md +0 -0
  24. {strictcli-0.35.3 → strictcli-0.36.0}/LICENSE +0 -0
  25. {strictcli-0.35.3 → strictcli-0.36.0}/README.md +0 -0
  26. {strictcli-0.35.3 → strictcli-0.36.0}/scripts/add_effect_classification.py +0 -0
  27. {strictcli-0.35.3 → strictcli-0.36.0}/scripts/add_forwarding_declaration.py +0 -0
  28. {strictcli-0.35.3 → strictcli-0.36.0}/strictcli/py.typed +0 -0
  29. {strictcli-0.35.3 → strictcli-0.36.0}/tests/conftest.py +0 -0
  30. {strictcli-0.35.3 → strictcli-0.36.0}/tests/flagship_app.py +0 -0
  31. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_arg_default.py +0 -0
  32. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_arg_default_validation.py +0 -0
  33. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_at_prefix.py +0 -0
  34. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_auto_version.py +0 -0
  35. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_call.py +0 -0
  36. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_command.py +0 -0
  37. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_discovery.py +0 -0
  38. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_provider.py +0 -0
  39. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_public_api.py +0 -0
  40. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_runner.py +0 -0
  41. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_schema.py +0 -0
  42. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_check_types.py +0 -0
  43. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_choices.py +0 -0
  44. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_choices_none.py +0 -0
  45. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_classification.py +0 -0
  46. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_command_help_suggestion.py +0 -0
  47. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_command_tags.py +0 -0
  48. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_compound_types.py +0 -0
  49. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config.py +0 -0
  50. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_fields.py +0 -0
  51. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_file_path.py +0 -0
  52. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_config_set_bugs.py +0 -0
  53. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_connection_env.py +0 -0
  54. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_context.py +0 -0
  55. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_coverage.py +0 -0
  56. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_deep_nesting.py +0 -0
  57. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_dependencies.py +0 -0
  58. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_deprecated.py +0 -0
  59. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_dump_schema.py +0 -0
  60. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_e2e.py +0 -0
  61. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_env.py +0 -0
  62. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_exit_codes.py +0 -0
  63. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_flag_sets.py +0 -0
  64. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_flagship_preview.py +0 -0
  65. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_format.py +0 -0
  66. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_type.py +0 -0
  67. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_float_vectors.py +0 -0
  68. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_global_flag_conflict_position.py +0 -0
  69. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_global_flags.py +0 -0
  70. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_guard_v2.py +0 -0
  71. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_help.py +0 -0
  72. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_hermetic.py +0 -0
  73. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_infra_env.py +0 -0
  74. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_int_type.py +0 -0
  75. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_invoke.py +0 -0
  76. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_keyword_flags.py +0 -0
  77. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_mcp.py +0 -0
  78. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_mutex.py +0 -0
  79. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_nesting.py +0 -0
  80. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_parser.py +0 -0
  81. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_passthrough.py +0 -0
  82. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_provenance.py +0 -0
  83. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_provenance_phase2.py +0 -0
  84. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_registration.py +0 -0
  85. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_repeatable.py +0 -0
  86. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_reserved_global_flags.py +0 -0
  87. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_tagdsl.py +0 -0
  88. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_toml_loading.py +0 -0
  89. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_tool_export.py +0 -0
  90. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_typed_args.py +0 -0
  91. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_unique.py +0 -0
  92. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_utilities.py +0 -0
  93. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_validate.py +0 -0
  94. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_variadic.py +0 -0
  95. {strictcli-0.35.3 → strictcli-0.36.0}/tests/test_visibility.py +0 -0
  96. {strictcli-0.35.3 → strictcli-0.36.0}/todo/.defer/deferred.md +0 -0
  97. {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  98. {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/original-idea.md +0 -0
  99. {strictcli-0.35.3 → strictcli-0.36.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.35.3
3
+ Version: 0.36.0
4
4
  Summary: A strict CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.35.3"
7
+ version = "0.36.0"
8
8
  description = "A strict CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.35.3"
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, yes: 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._yes = yes
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 yes(self) -> bool:
271
- """True when the framework-owned ``--yes`` flag was passed."""
272
- return self._yes
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 mutating command '{cmd_path}'. Proceed? [y/N] "
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 "error: stdin is not interactive; pass --yes to confirm"
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", "yes", "quiet", "verbose",
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
- "--yes": "yes",
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, yes, quiet, verbose)"
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._last_yes: bool = False
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 two effects-regime lints.
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 both without a TOML declaration:
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 _confirm_mutating(self, cmd: "Command", cmd_path: str) -> None:
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 ``mutating`` command on the real CLI path
5466
- when neither --dry-run nor --yes was passed. Never fires for read_only
5467
- commands, and never on the programmatic paths (test/call/_invoke/MCP),
5468
- which have no TTY contract and would hang.
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 mutating PASSTHROUGH is not exempt: the framework knows LESS about
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.effect != EFFECT_MUTATING:
5617
+ if not cmd.consequential:
5474
5618
  return
5475
- if self._last_dry_run or self._last_yes:
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/--yes/--quiet/--verbose.
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, yes, quiet, verbose, err, cleaned_argv.
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): --dry-run/--yes/--quiet/--verbose are
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._last_yes = bool(pre_scan.get("yes"))
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, yes=self._last_yes,
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._confirm_mutating(cmd, cmd_path)
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
- exit_code, out_data = _interpret_handler_return(result)
6886
- if out_data is not _MISSING:
6887
- print(json.dumps(out_data, default=str, separators=(",", ":")))
6888
- if self._last_dry_run:
6889
- print(self._effect_log.render())
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, yes=self._last_yes,
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
- if self._last_dry_run:
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]