strictcli 0.42.0__tar.gz → 0.43.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 (110) hide show
  1. {strictcli-0.42.0 → strictcli-0.43.0}/PKG-INFO +6 -6
  2. {strictcli-0.42.0 → strictcli-0.43.0}/README.md +2 -2
  3. {strictcli-0.42.0 → strictcli-0.43.0}/pyproject.toml +4 -4
  4. {strictcli-0.42.0 → strictcli-0.43.0}/strictcli/__init__.py +389 -33
  5. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_effects_bypass_check.py +67 -1
  6. strictcli-0.43.0/tests/test_retired_choices.py +388 -0
  7. {strictcli-0.42.0 → strictcli-0.43.0}/uv.lock +1 -1
  8. {strictcli-0.42.0 → strictcli-0.43.0}/.claude/settings.json +0 -0
  9. {strictcli-0.42.0 → strictcli-0.43.0}/.github/workflows/ci.yml +0 -0
  10. {strictcli-0.42.0 → strictcli-0.43.0}/.github/workflows/publish.yml +0 -0
  11. {strictcli-0.42.0 → strictcli-0.43.0}/.gitignore +0 -0
  12. {strictcli-0.42.0 → strictcli-0.43.0}/.rlsbl/config.json +0 -0
  13. {strictcli-0.42.0 → strictcli-0.43.0}/.rlsbl/lint/python.toml +0 -0
  14. {strictcli-0.42.0 → strictcli-0.43.0}/.rlsbl/managed-files.json +0 -0
  15. {strictcli-0.42.0 → strictcli-0.43.0}/CLAUDE.md +0 -0
  16. {strictcli-0.42.0 → strictcli-0.43.0}/LICENSE +0 -0
  17. {strictcli-0.42.0 → strictcli-0.43.0}/scripts/add_effect_classification.py +0 -0
  18. {strictcli-0.42.0 → strictcli-0.43.0}/scripts/add_forwarding_declaration.py +0 -0
  19. {strictcli-0.42.0 → strictcli-0.43.0}/scripts/migrate_presence.py +0 -0
  20. {strictcli-0.42.0 → strictcli-0.43.0}/strictcli/py.typed +0 -0
  21. {strictcli-0.42.0 → strictcli-0.43.0}/tests/conftest.py +0 -0
  22. {strictcli-0.42.0 → strictcli-0.43.0}/tests/flagship_app.py +0 -0
  23. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_arg_decorator_order.py +0 -0
  24. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_argv_value_order.py +0 -0
  27. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_at_prefix.py +0 -0
  28. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_auto_version.py +0 -0
  29. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_call.py +0 -0
  30. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_command.py +0 -0
  31. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_discovery.py +0 -0
  32. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_provider.py +0 -0
  33. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_choice_records.py +0 -0
  38. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_choices.py +0 -0
  39. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_choices_none.py +0 -0
  40. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_claimed_rendering.py +0 -0
  41. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_classification.py +0 -0
  42. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_command_help_suggestion.py +0 -0
  43. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_command_tags.py +0 -0
  44. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_compound_types.py +0 -0
  45. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_config.py +0 -0
  46. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_config_fields.py +0 -0
  47. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_config_file_path.py +0 -0
  48. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_config_set_bugs.py +0 -0
  49. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_confirm.py +0 -0
  50. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_connection_env.py +0 -0
  51. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_constraints.py +0 -0
  52. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_context.py +0 -0
  53. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_coverage.py +0 -0
  54. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_deep_nesting.py +0 -0
  55. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_deprecated.py +0 -0
  56. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_dry_run_unsupported.py +0 -0
  57. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_dump_schema.py +0 -0
  58. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_e2e.py +0 -0
  59. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_effects.py +0 -0
  60. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_env.py +0 -0
  61. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_exit_codes.py +0 -0
  62. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_flag_sets.py +0 -0
  63. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_flagship_preview.py +0 -0
  64. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_flat_pre_typed.py +0 -0
  65. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_float_format.py +0 -0
  66. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_float_type.py +0 -0
  67. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_float_vectors.py +0 -0
  68. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_global_flag_conflict_position.py +0 -0
  69. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_global_flags.py +0 -0
  70. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_guard_v2.py +0 -0
  71. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_help.py +0 -0
  72. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_hermetic.py +0 -0
  73. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_infra_env.py +0 -0
  74. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_int_type.py +0 -0
  75. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_invoke.py +0 -0
  76. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_keyword_flags.py +0 -0
  77. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_machine_mode.py +0 -0
  78. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_mcp.py +0 -0
  79. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_member_spelling.py +0 -0
  80. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_nesting.py +0 -0
  81. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_owns_stdout.py +0 -0
  82. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_parser.py +0 -0
  83. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_passthrough.py +0 -0
  84. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_payload_schema.py +0 -0
  85. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_presence.py +0 -0
  86. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_provenance.py +0 -0
  87. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_provenance_phase2.py +0 -0
  88. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_record_pre_typed.py +0 -0
  89. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_registration.py +0 -0
  90. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_repeatable.py +0 -0
  91. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_reserved_global_flags.py +0 -0
  92. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_reserved_quartet.py +0 -0
  93. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_selectors.py +0 -0
  94. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_tagdsl.py +0 -0
  95. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_toml_loading.py +0 -0
  96. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_tool_export.py +0 -0
  97. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_trace_store.py +0 -0
  98. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_typed_args.py +0 -0
  99. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_ulid_vectors.py +0 -0
  100. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_unique.py +0 -0
  101. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_update.py +0 -0
  102. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_utilities.py +0 -0
  103. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_validate.py +0 -0
  104. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_value_sweep_order.py +0 -0
  105. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_variadic.py +0 -0
  106. {strictcli-0.42.0 → strictcli-0.43.0}/tests/test_visibility.py +0 -0
  107. {strictcli-0.42.0 → strictcli-0.43.0}/todo/.defer/deferred.md +0 -0
  108. {strictcli-0.42.0 → strictcli-0.43.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  109. {strictcli-0.42.0 → strictcli-0.43.0}/todo/.done/original-idea.md +0 -0
  110. {strictcli-0.42.0 → strictcli-0.43.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: strictcli
3
- Version: 0.42.0
3
+ Version: 0.43.0
4
4
  Summary: A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)
5
5
  Project-URL: Homepage, https://smmh.dev/strictcli/
6
6
  Project-URL: Documentation, https://smmh.dev/strictcli/
7
- Project-URL: Repository, https://github.com/smm-h/strictcli
8
- Project-URL: Issues, https://github.com/smm-h/strictcli/issues
9
- Project-URL: Changelog, https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md
7
+ Project-URL: Repository, https://github.com/stricttools/strictcli
8
+ Project-URL: Issues, https://github.com/stricttools/strictcli/issues
9
+ Project-URL: Changelog, https://github.com/stricttools/strictcli/blob/main/CHANGELOG.md
10
10
  Author-email: "S. M. Hosseini" <m.hosseini@veliu.com>
11
11
  License-Expression: MIT
12
12
  License-File: LICENSE
@@ -657,8 +657,8 @@ paths have no TTY contract, so a consequential command is dispatched directly.
657
657
 
658
658
  ## See also
659
659
 
660
- - [strictcli monorepo](https://github.com/smm-h/strictcli) -- conformance tests, Go implementation, and project documentation
661
- - [Go implementation](https://github.com/smm-h/strictcli/tree/main/go) -- same semantics, functional options API
660
+ - [strictcli monorepo](https://github.com/stricttools/strictcli) -- conformance tests, Go implementation, and project documentation
661
+ - [Go implementation](https://github.com/stricttools/strictcli/tree/main/go) -- same semantics, functional options API
662
662
 
663
663
  ## License
664
664
 
@@ -633,8 +633,8 @@ paths have no TTY contract, so a consequential command is dispatched directly.
633
633
 
634
634
  ## See also
635
635
 
636
- - [strictcli monorepo](https://github.com/smm-h/strictcli) -- conformance tests, Go implementation, and project documentation
637
- - [Go implementation](https://github.com/smm-h/strictcli/tree/main/go) -- same semantics, functional options API
636
+ - [strictcli monorepo](https://github.com/stricttools/strictcli) -- conformance tests, Go implementation, and project documentation
637
+ - [Go implementation](https://github.com/stricttools/strictcli/tree/main/go) -- same semantics, functional options API
638
638
 
639
639
  ## License
640
640
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.42.0"
7
+ version = "0.43.0"
8
8
  description = "A CLI framework for the Era of Agents: nothing is inferred, everything is declared. First-class support for Go, Python, and TypeScript (Python implementation)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -42,6 +42,6 @@ testpaths = ["tests"]
42
42
  [project.urls]
43
43
  Homepage = "https://smmh.dev/strictcli/"
44
44
  Documentation = "https://smmh.dev/strictcli/"
45
- Repository = "https://github.com/smm-h/strictcli"
46
- Issues = "https://github.com/smm-h/strictcli/issues"
47
- Changelog = "https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md"
45
+ Repository = "https://github.com/stricttools/strictcli"
46
+ Issues = "https://github.com/stricttools/strictcli/issues"
47
+ Changelog = "https://github.com/stricttools/strictcli/blob/main/CHANGELOG.md"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.42.0"
5
+ __version__ = "0.43.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet",
@@ -15,6 +15,8 @@ __all__ = [
15
15
  # The scoped-selector construct (contract §24)
16
16
  "Choice", "choice", "choice_flag", "sub_flag", "sub_choice_flag",
17
17
  "member_value", "provided",
18
+ # Retired choices: the value-level twin of the deprecated-command construct
19
+ "RetiredChoice",
18
20
  "Grant", "EffectFailed", "Unsettled", "Completed", "Spawned", "Response",
19
21
  "PROC_MUTATE", "PROC_SPAWN", "FILE_WRITE", "NET_MUTATE",
20
22
  "flag", "arg",
@@ -2796,36 +2798,89 @@ def _open_is_write_mode(node) -> bool:
2796
2798
  return any(ch in mode for ch in ("w", "a", "x", "+"))
2797
2799
 
2798
2800
 
2801
+ class _BypassRootNotAWorkTree(Exception):
2802
+ """The project root handed to the lint is not inside a git work tree."""
2803
+
2804
+
2805
+ def _msg_effects_bypass_not_a_work_tree(root: str) -> str:
2806
+ """Message template: the lint's input rule, refused at its one door."""
2807
+ return (
2808
+ f"effects-bypass: project root '{root}' is not a git work tree; "
2809
+ f"the check reads only repository-owned files"
2810
+ )
2811
+
2812
+
2813
+ def _bypass_path_is_skipped(rel: str) -> bool:
2814
+ """True when a repository-owned path sits under a skipped directory.
2815
+
2816
+ git lists tracked files wherever they are, including the directories the
2817
+ analyser never reads -- a vendored tree, a committed ``testdata`` or
2818
+ ``build`` directory, a dot-directory's contents. The skip list therefore
2819
+ still applies, now as a filter over git's answer rather than as a walk
2820
+ pruner.
2821
+ """
2822
+ # git reports forward slashes on every platform, so the split is on "/"
2823
+ # rather than os.sep.
2824
+ return any(
2825
+ part in _BYPASS_SKIP_DIRS or part.startswith(".")
2826
+ for part in rel.split("/")[:-1]
2827
+ )
2828
+
2829
+
2830
+ def _bypass_repo_files(root: Path) -> list[str]:
2831
+ """The repository-owned Python files under ``root``, relative to it.
2832
+
2833
+ The input set is what git reports: tracked files plus untracked files
2834
+ ``.gitignore`` does not exclude. A release-blocking check reads only inputs
2835
+ the repository owns, so a gitignored scratch file -- present on one machine
2836
+ and absent from a fresh clone -- can never decide the verdict. There is no
2837
+ filesystem-walk fallback: a root outside a work tree is refused.
2838
+ """
2839
+ try:
2840
+ proc = subprocess.run(
2841
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
2842
+ cwd=str(root),
2843
+ capture_output=True,
2844
+ check=False,
2845
+ )
2846
+ except OSError as exc:
2847
+ raise _BypassRootNotAWorkTree(
2848
+ _msg_effects_bypass_not_a_work_tree(str(root))
2849
+ ) from exc
2850
+ if proc.returncode != 0:
2851
+ raise _BypassRootNotAWorkTree(
2852
+ _msg_effects_bypass_not_a_work_tree(str(root))
2853
+ )
2854
+ listed = proc.stdout.decode("utf-8", "replace").split("\0")
2855
+ return sorted({
2856
+ rel for rel in listed
2857
+ if rel.endswith(".py") and not _bypass_path_is_skipped(rel)
2858
+ })
2859
+
2860
+
2799
2861
  def _scan_effects_bypasses(root: Path) -> list[tuple]:
2800
2862
  """Find direct effect calls REACHABLE FROM a registered command handler.
2801
2863
 
2802
2864
  Returns ``(relative_path, lineno, function_name, target)`` tuples, in file
2803
- then line order. See :func:`_bypass_reachable_functions` for the scope rule.
2865
+ then line order. See :func:`_bypass_reachable_functions` for the scope rule
2866
+ and :func:`_bypass_repo_files` for the input rule. Raises
2867
+ :class:`_BypassRootNotAWorkTree` when the root is not inside a git work
2868
+ tree.
2804
2869
  """
2805
2870
  findings: list[tuple] = []
2806
- if not root.is_dir():
2807
- return findings
2808
- for dirpath, dirnames, filenames in os.walk(root):
2809
- dirnames[:] = sorted(
2810
- d for d in dirnames
2811
- if d not in _BYPASS_SKIP_DIRS and not d.startswith(".")
2812
- )
2813
- for fname in sorted(filenames):
2814
- if not fname.endswith(".py"):
2815
- continue
2816
- path = os.path.join(dirpath, fname)
2817
- try:
2818
- tree = ast.parse(Path(path).read_text(encoding="utf-8"), filename=path)
2819
- except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
2820
- # A file the analyser cannot read is not evidence of a bypass.
2821
- continue
2822
- rel = os.path.relpath(path, root)
2823
- reachable = _bypass_reachable_functions(tree)
2824
- if not reachable:
2825
- continue
2826
- _bypass_walk(tree, [], reachable, findings, rel,
2827
- _bypass_effects_aliases(tree),
2828
- _bypass_import_bindings(tree))
2871
+ for rel in _bypass_repo_files(root):
2872
+ path = os.path.join(str(root), rel)
2873
+ try:
2874
+ tree = ast.parse(Path(path).read_text(encoding="utf-8"), filename=path)
2875
+ except (OSError, SyntaxError, UnicodeDecodeError, ValueError):
2876
+ # A file the analyser cannot read is not evidence of a bypass.
2877
+ continue
2878
+ reachable = _bypass_reachable_functions(tree)
2879
+ if not reachable:
2880
+ continue
2881
+ _bypass_walk(tree, [], reachable, findings, rel,
2882
+ _bypass_effects_aliases(tree),
2883
+ _bypass_import_bindings(tree))
2829
2884
  return findings
2830
2885
 
2831
2886
 
@@ -4604,6 +4659,7 @@ _SCOPE_FIELD_KEY = "strictcli_scope"
4604
4659
  _RECORD_SOURCES_ATTR = "__strictcli_sources__"
4605
4660
 
4606
4661
  _RECORD_SPELLING = "Choice(<value>, help=...)"
4662
+ _RETIRED_RECORD_SPELLING = 'RetiredChoice(<value>, message="<message>")'
4607
4663
  _SELECTOR_SPELLING = "choice_flag(...)"
4608
4664
  _MEMBER_SELECTOR_SPELLING = 'choice_flag(..., elect_by="member-flags")'
4609
4665
  # The payload-carrying member's own declaration, which is where its short goes:
@@ -4636,6 +4692,223 @@ class Choice:
4636
4692
  _require_non_empty_str(self.help, "help", "Choice")
4637
4693
 
4638
4694
 
4695
+ @dataclass(frozen=True)
4696
+ class RetiredChoice:
4697
+ """One retired spelling of a value flag or positional arg.
4698
+
4699
+ The value-level twin of ``app.deprecate``: a value the declaration used to
4700
+ accept, plus the message that names its replacement. A retired spelling is
4701
+ refused at parse time ahead of the invalid-value check, and it is NOT a
4702
+ choice -- help never lists it, and the published ``value_schema`` enum and
4703
+ the MCP projection derived from it carry the live set only.
4704
+
4705
+ The message is mandatory and non-empty, like every other message the
4706
+ framework prints on a declaration's behalf. There is no exhaustiveness
4707
+ story to tell: a handler never receives a retired value, so no closed set
4708
+ reaches a delivery site.
4709
+ """
4710
+
4711
+ value: object
4712
+ message: str = field(kw_only=True)
4713
+
4714
+
4715
+ def _raise_retired_choices_entry_not_record(surface: str, name: str, index: int):
4716
+ """Message template: a bare ``retired_choices=`` entry.
4717
+
4718
+ Python-only. Go's variadic ``RetiredChoices(...RetiredChoiceValue)`` and
4719
+ TypeScript's record type refuse a bare value at compile time, so neither
4720
+ sibling has an input that could produce this line. It mirrors the
4721
+ bare-choice refusal, which is the same mis-declaration one keyword over.
4722
+ """
4723
+ raise ValueError(
4724
+ f'{surface} "{name}": retired_choices entry {index} is a bare value: '
4725
+ f"declare it as {_RETIRED_RECORD_SPELLING}"
4726
+ )
4727
+
4728
+
4729
+ def _resolve_retired_choices(
4730
+ surface: str, name: str, entries: object,
4731
+ ) -> tuple["RetiredChoice", ...]:
4732
+ """Validate a ``retired_choices=`` list's entry SHAPE.
4733
+
4734
+ The per-entry RULES (a live spelling, a duplicate, an empty message) are
4735
+ checked by ``_validate_retired_choices`` once the live choices are known.
4736
+ """
4737
+ if not isinstance(entries, list):
4738
+ _raise_retired_choices_entry_not_record(surface, name, 0)
4739
+ records: list[RetiredChoice] = []
4740
+ for i, entry in enumerate(entries):
4741
+ if not isinstance(entry, RetiredChoice):
4742
+ _raise_retired_choices_entry_not_record(surface, name, i)
4743
+ records.append(entry)
4744
+ return tuple(records)
4745
+
4746
+
4747
+ # The registration-time templates, twinned per surface the way Go's err* and
4748
+ # TypeScript's err* functions are. Python could parameterize the Flag/Arg
4749
+ # prefix into one template, and does elsewhere; here it inlines both so the
4750
+ # three implementations share one signature per rule and the parity manifest
4751
+ # carries no entry for the family at all.
4752
+
4753
+
4754
+ def _raise_flag_retired_choice_is_live(name: str, value: str):
4755
+ raise ValueError(
4756
+ f'Flag "{name}": retired choice \'{value}\' is also a live choice: '
4757
+ f"a value is live or retired, never both"
4758
+ )
4759
+
4760
+
4761
+ def _raise_arg_retired_choice_is_live(name: str, value: str):
4762
+ raise ValueError(
4763
+ f'Arg "{name}": retired choice \'{value}\' is also a live choice: '
4764
+ f"a value is live or retired, never both"
4765
+ )
4766
+
4767
+
4768
+ def _raise_flag_retired_choice_duplicate(name: str, value: str):
4769
+ raise ValueError(f'Flag "{name}": retired choice \'{value}\' is declared twice')
4770
+
4771
+
4772
+ def _raise_arg_retired_choice_duplicate(name: str, value: str):
4773
+ raise ValueError(f'Arg "{name}": retired choice \'{value}\' is declared twice')
4774
+
4775
+
4776
+ def _raise_flag_retired_choice_message_empty(name: str, value: str):
4777
+ raise ValueError(
4778
+ f'Flag "{name}": retired choice \'{value}\': message must be a non-empty string'
4779
+ )
4780
+
4781
+
4782
+ def _raise_arg_retired_choice_message_empty(name: str, value: str):
4783
+ raise ValueError(
4784
+ f'Arg "{name}": retired choice \'{value}\': message must be a non-empty string'
4785
+ )
4786
+
4787
+
4788
+ def _raise_flag_retired_choices_incompatible_bool(name: str):
4789
+ raise ValueError(f'Flag "{name}": retired choices are incompatible with type=bool')
4790
+
4791
+
4792
+ def _raise_arg_retired_choices_incompatible_bool(name: str):
4793
+ raise ValueError(f'Arg "{name}": retired choices are incompatible with type=bool')
4794
+
4795
+
4796
+ def _raise_flag_default_is_retired_choice(name: str, value: str):
4797
+ raise ValueError(f'Flag "{name}": default \'{value}\' is a retired choice')
4798
+
4799
+
4800
+ def _raise_arg_default_is_retired_choice(name: str, value: str):
4801
+ raise ValueError(f'Arg "{name}": default \'{value}\' is a retired choice')
4802
+
4803
+
4804
+ def _raise_flag_retired_choice_type_mismatch(name: str, value: str, type_name: str):
4805
+ raise ValueError(
4806
+ f'Flag "{name}": retired choice \'{value}\' is not of type {type_name}'
4807
+ )
4808
+
4809
+
4810
+ def _raise_arg_retired_choice_type_mismatch(name: str, value: str, type_name: str):
4811
+ raise ValueError(
4812
+ f'Arg "{name}": retired choice \'{value}\' is not of type {type_name}'
4813
+ )
4814
+
4815
+
4816
+ def _raise_flag_retired_choices_require_choices(name: str):
4817
+ raise ValueError(f'Flag "{name}": retired choices require choices')
4818
+
4819
+
4820
+ def _raise_arg_retired_choices_require_choices(name: str):
4821
+ raise ValueError(f'Arg "{name}": retired choices require choices')
4822
+
4823
+
4824
+ _RETIRED_CHOICE_TEMPLATES = {
4825
+ "Flag": (
4826
+ _raise_flag_retired_choice_is_live,
4827
+ _raise_flag_retired_choice_duplicate,
4828
+ _raise_flag_retired_choice_message_empty,
4829
+ _raise_flag_retired_choices_incompatible_bool,
4830
+ _raise_flag_default_is_retired_choice,
4831
+ _raise_flag_retired_choices_require_choices,
4832
+ _raise_flag_retired_choice_type_mismatch,
4833
+ ),
4834
+ "Arg": (
4835
+ _raise_arg_retired_choice_is_live,
4836
+ _raise_arg_retired_choice_duplicate,
4837
+ _raise_arg_retired_choice_message_empty,
4838
+ _raise_arg_retired_choices_incompatible_bool,
4839
+ _raise_arg_default_is_retired_choice,
4840
+ _raise_arg_retired_choices_require_choices,
4841
+ _raise_arg_retired_choice_type_mismatch,
4842
+ ),
4843
+ }
4844
+
4845
+
4846
+ def _validate_retired_choices(
4847
+ surface: str,
4848
+ name: str,
4849
+ retired: tuple["RetiredChoice", ...] | None,
4850
+ choices: list | None,
4851
+ item_type: type,
4852
+ has_default: bool,
4853
+ default: object,
4854
+ ) -> None:
4855
+ """The registration-time guards, one set over both surfaces.
4856
+
4857
+ Every sentence names the CONCEPT ("retired choice") rather than this
4858
+ language's spelling of the declaration, so all three implementations share
4859
+ one signature per rule.
4860
+ """
4861
+ if retired is None:
4862
+ return
4863
+ (
4864
+ is_live, duplicate, message_empty,
4865
+ incompatible_bool, default_is_retired, require_choices, type_mismatch,
4866
+ ) = _RETIRED_CHOICE_TEMPLATES[surface]
4867
+ # The bool refusal comes first so the declaration is named by what it got
4868
+ # wrong: choices are already incompatible with bool, and reporting the
4869
+ # missing choices instead would send a reader to add a declaration the
4870
+ # framework would then refuse for the same reason.
4871
+ if item_type is bool:
4872
+ incompatible_bool(name)
4873
+ if choices is None:
4874
+ require_choices(name)
4875
+ seen: list = []
4876
+ for rc in retired:
4877
+ formatted = _format_value_for_error(rc.value)
4878
+ # The type check comes first: a value of the wrong type can never match
4879
+ # at parse time, so the declaration is dead however it compares against
4880
+ # the live set or against its siblings.
4881
+ if not isinstance(rc.value, item_type):
4882
+ type_mismatch(name, formatted, item_type.__name__)
4883
+ if not isinstance(rc.message, str) or not rc.message.strip():
4884
+ message_empty(name, formatted)
4885
+ if rc.value in choices:
4886
+ is_live(name, formatted)
4887
+ if rc.value in seen:
4888
+ duplicate(name, formatted)
4889
+ seen.append(rc.value)
4890
+ if has_default and default is not None:
4891
+ for rc in retired:
4892
+ if rc.value == default:
4893
+ default_is_retired(name, _format_value_for_error(default))
4894
+
4895
+
4896
+ def _retired_choice_message(
4897
+ value: object, retired: tuple["RetiredChoice", ...] | None,
4898
+ ) -> tuple[str, bool]:
4899
+ """The message declared for a retired spelling, and whether it is retired.
4900
+
4901
+ Retired lists are short and ordered, so the scan mirrors the choices one
4902
+ rather than building a map.
4903
+ """
4904
+ if retired is None:
4905
+ return "", False
4906
+ for rc in retired:
4907
+ if value == rc.value and type(value) is type(rc.value):
4908
+ return rc.message, True
4909
+ return "", False
4910
+
4911
+
4639
4912
  def _raise_choices_entry_not_record(surface: str, name: str, index: int):
4640
4913
  """Message template: a bare `choices=` entry (contract §12.13)."""
4641
4914
  raise ValueError(
@@ -4761,6 +5034,10 @@ class Flag:
4761
5034
  # value list so per-entry help survives to help rendering. Set by
4762
5035
  # __post_init__ from `choices`, never by the caller.
4763
5036
  choice_records: tuple["Choice", ...] | None = None
5037
+ # The spellings this flag USED to accept, each carrying the message that
5038
+ # names its replacement. A retired value is refused at parse time; it is
5039
+ # not a choice, so help and the published value_schema never name it.
5040
+ retired_choices: list | None = None
4764
5041
 
4765
5042
  def __post_init__(self) -> None:
4766
5043
  _require_non_empty_str(self.help, "help", "Flag")
@@ -4943,6 +5220,20 @@ class Flag:
4943
5220
  f'Flag "{self.name}": type=float requires a float default, '
4944
5221
  f"got {type(self.default).__name__!r}"
4945
5222
  )
5223
+ # Retired choices. The entry SHAPE is resolved first, then the rules --
5224
+ # after the live choices are validated, so a declaration that got its
5225
+ # choices wrong is told that first, and BEFORE the default-in-choices
5226
+ # check, so a default naming a retired spelling is answered by the
5227
+ # sentence that names the reason.
5228
+ if self.retired_choices is not None:
5229
+ self.retired_choices = _resolve_retired_choices(
5230
+ "Flag", self.name, self.retired_choices,
5231
+ )
5232
+ _validate_retired_choices(
5233
+ "Flag", self.name, self.retired_choices, self.choices,
5234
+ self.item_type if self.compound == "list" else self.type,
5235
+ self.presence == _PRESENCE_DEFAULT, self.default,
5236
+ )
4946
5237
  # Validate default is in choices. The check applies to declared VALUES
4947
5238
  # only: a required or optional flag has no value to check, and absence
4948
5239
  # is never matched against choices (§23.5).
@@ -4979,6 +5270,8 @@ class Arg:
4979
5270
  item_type: type | None = None
4980
5271
  # The declared `choices=` records (contract §24.2), same as a flag's.
4981
5272
  choice_records: tuple["Choice", ...] | None = None
5273
+ # The arg twin of Flag.retired_choices.
5274
+ retired_choices: list | None = None
4982
5275
 
4983
5276
  def __post_init__(self) -> None:
4984
5277
  _require_non_empty_str(self.help, "help", "Arg")
@@ -5075,6 +5368,17 @@ class Arg:
5075
5368
  f'Arg "{self.name}": type=str requires a str default, '
5076
5369
  f"got {type(self.default).__name__!r}"
5077
5370
  )
5371
+ # Retired choices, ahead of the default-in-choices check for the reason
5372
+ # stated at the flag surface.
5373
+ if self.retired_choices is not None:
5374
+ self.retired_choices = _resolve_retired_choices(
5375
+ "Arg", self.name, self.retired_choices,
5376
+ )
5377
+ _validate_retired_choices(
5378
+ "Arg", self.name, self.retired_choices, self.choices,
5379
+ self.item_type if self.compound == "list" else self.type,
5380
+ self.presence == _PRESENCE_DEFAULT, self.default,
5381
+ )
5078
5382
  # Validate default is in choices -- declared VALUES only (§23.5)
5079
5383
  if self.choices is not None and self.presence == _PRESENCE_DEFAULT:
5080
5384
  if self.default not in self.choices:
@@ -9228,7 +9532,9 @@ class App:
9228
9532
  ctx.effects", so a leaf the handle could not carry must never be a
9229
9533
  finding: the handle's closed method set has no in-process-observe
9230
9534
  method, which is why ``platform.system()`` is exempt while
9231
- ``os.system(...)`` is not (see :data:`_BYPASS_PROCESS_OS_ONLY`);
9535
+ ``os.system(...)`` is not (see :data:`_BYPASS_PROCESS_OS_ONLY`). It
9536
+ reads only repository-owned files (see :func:`_bypass_repo_files`)
9537
+ and refuses a project root outside a git work tree;
9232
9538
  - ``observe-allowlist-breadth`` (warn) surfaces short
9233
9539
  ``proc_observe_allowlist`` prefixes, which authorize real execution
9234
9540
  under ``--dry-run``;
@@ -9237,7 +9543,13 @@ class App:
9237
9543
  themselves consequential.
9238
9544
  """
9239
9545
  def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
9240
- findings = _scan_effects_bypasses(Path(ctx.project_root))
9546
+ try:
9547
+ findings = _scan_effects_bypasses(Path(ctx.project_root))
9548
+ except _BypassRootNotAWorkTree as exc:
9549
+ # The input rule is part of the verdict: a root the repository
9550
+ # does not own is refused here, never scanned another way.
9551
+ reporter.error(str(exc))
9552
+ return reporter.found(str(exc))
9241
9553
  for rel, lineno, func_name, target in findings:
9242
9554
  reporter.error(
9243
9555
  f"{rel}:{lineno}: {func_name} calls {target} directly; "
@@ -11419,7 +11731,10 @@ class App:
11419
11731
  # Validate choices for global flags
11420
11732
  for f in self._global_flags:
11421
11733
  if f.name in cli_set:
11422
- _validate_choices(f.name, cli_set[f.name], f.repeatable, f.choices)
11734
+ _validate_choices(
11735
+ f.name, cli_set[f.name], f.repeatable, f.choices,
11736
+ f.retired_choices,
11737
+ )
11423
11738
 
11424
11739
  return cli_set, global_sources, remaining
11425
11740
 
@@ -12734,6 +13049,7 @@ def _validate_choices(
12734
13049
  val: object,
12735
13050
  repeatable: bool,
12736
13051
  choices: list | None,
13052
+ retired: tuple["RetiredChoice", ...] | None = None,
12737
13053
  *,
12738
13054
  is_arg: bool = False,
12739
13055
  ) -> None:
@@ -12745,12 +13061,26 @@ def _validate_choices(
12745
13061
  A None value is exempt from validation: None only arises when the flag or
12746
13062
  arg was not passed (an unset mutex flag, or default=None on an arg) -- a
12747
13063
  CLI-supplied value is never None.
13064
+
13065
+ A RETIRED spelling is checked first, so a reader who typed a value that
13066
+ used to work is told what replaced it instead of being handed the list it
13067
+ is missing from. Every source that reaches this funnel today -- command
13068
+ line, env var, config file, and the programmatic doors -- takes the retired
13069
+ refusal for free; nothing new is resolved here.
12748
13070
  """
12749
- if choices is None or val is None:
13071
+ if (choices is None and retired is None) or val is None:
12750
13072
  return
12751
13073
  vals = val if repeatable else [val]
12752
13074
  for v in vals:
12753
- if v not in choices:
13075
+ message, is_retired = _retired_choice_message(v, retired)
13076
+ if is_retired:
13077
+ v_str = _format_value_for_error(v)
13078
+ if is_arg:
13079
+ raise _ParseError(
13080
+ f"argument '{name}': value '{v_str}' retired: {message}"
13081
+ )
13082
+ raise _ParseError(f"--{name}: value '{v_str}' retired: {message}")
13083
+ if choices is not None and v not in choices:
12754
13084
  choices_str = ", ".join(
12755
13085
  _format_float_canonical(c) if isinstance(c, float) else str(c)
12756
13086
  for c in choices
@@ -12913,7 +13243,10 @@ def _validate_and_build_kwargs(
12913
13243
  # Step 5.5: validate choices
12914
13244
  for f in cmd.flags:
12915
13245
  if store.has(f.name):
12916
- _validate_choices(f.name, store[f.name], f.repeatable, f.choices)
13246
+ _validate_choices(
13247
+ f.name, store[f.name], f.repeatable, f.choices,
13248
+ f.retired_choices,
13249
+ )
12917
13250
 
12918
13251
  # Step 5.6: custom validation. It runs on a SUPPLIED value only: never on
12919
13252
  # absence, and never on a declared default (§23.5's validate row).
@@ -12996,7 +13329,8 @@ def _validate_and_build_kwargs(
12996
13329
  for a in cmd.args:
12997
13330
  if a.name in arg_values:
12998
13331
  _validate_choices(
12999
- a.name, arg_values[a.name], a.variadic, a.choices, is_arg=True,
13332
+ a.name, arg_values[a.name], a.variadic, a.choices,
13333
+ a.retired_choices, is_arg=True,
13000
13334
  )
13001
13335
 
13002
13336
  # Step 7: build kwargs dict (command flags only)
@@ -13773,7 +14107,7 @@ def _check_scoped_value(
13773
14107
  set and its callback both apply, exactly as they do on the root surface
13774
14108
  (steps 5.5 and 5.6). Only a declared default escapes ``validate``.
13775
14109
  """
13776
- _validate_choices(f.name, value, f.repeatable, f.choices)
14110
+ _validate_choices(f.name, value, f.repeatable, f.choices, f.retired_choices)
13777
14111
  if f.validate is not None and value is not None:
13778
14112
  for v in (value if f.repeatable else [value]):
13779
14113
  try:
@@ -15606,6 +15940,7 @@ def flag(
15606
15940
  prefixed: bool = True,
15607
15941
  negatable: object = _MISSING,
15608
15942
  choices: list | None = None,
15943
+ retired_choices: list | None = None,
15609
15944
  validate: Callable | None = None,
15610
15945
  repeatable: bool = False,
15611
15946
  unique: object = _MISSING,
@@ -15629,6 +15964,7 @@ def flag(
15629
15964
  prefixed=prefixed,
15630
15965
  negatable=negatable,
15631
15966
  choices=choices,
15967
+ retired_choices=retired_choices,
15632
15968
  validate=validate,
15633
15969
  repeatable=repeatable,
15634
15970
  unique=unique,
@@ -15654,6 +15990,7 @@ def arg(
15654
15990
  variadic: bool = False,
15655
15991
  type: type = str,
15656
15992
  choices: list | None = None,
15993
+ retired_choices: list | None = None,
15657
15994
  ) -> Callable[[F], F]:
15658
15995
  """Module-level decorator to attach an Arg to a command handler."""
15659
15996
 
@@ -15661,6 +15998,7 @@ def arg(
15661
15998
  a = Arg(
15662
15999
  name=name, help=help, presence=presence, default=default,
15663
16000
  variadic=variadic, type=type, choices=choices,
16001
+ retired_choices=retired_choices,
15664
16002
  )
15665
16003
  if not hasattr(func, "_strictcli_args"):
15666
16004
  func._strictcli_args = []
@@ -16859,6 +17197,8 @@ def _serialize_flag(f: Flag) -> dict:
16859
17197
  d["prefixed"] = False
16860
17198
  if f.choice_records is not None:
16861
17199
  d["choices"] = _serialize_choice_records(f.choice_records)
17200
+ if f.retired_choices:
17201
+ d["retired_choices"] = _serialize_retired_choices(f.retired_choices)
16862
17202
  if f.unique is True:
16863
17203
  d["unique"] = True
16864
17204
  # Per-flag conflict mode: serialized only when explicitly set. Absence
@@ -16880,6 +17220,20 @@ def _serialize_flag(f: Flag) -> dict:
16880
17220
  return d
16881
17221
 
16882
17222
 
17223
+ def _serialize_retired_choices(retired: tuple["RetiredChoice", ...]) -> dict:
17224
+ """A declaration's retired spellings, as a map from spelling to message.
17225
+
17226
+ SORTED ascending by key -- the treatment a group's `deprecated` map already
17227
+ gets, and for the same reason: a keyed object whose declaration order no
17228
+ implementation is required to retain has sort order as its only reachable
17229
+ canon. The key is the spelling rendered through the error-value formatter,
17230
+ so an int, a float and a string key identically in all three
17231
+ implementations. The map is omitted entirely when nothing is retired.
17232
+ """
17233
+ messages = {_format_value_for_error(rc.value): rc.message for rc in retired}
17234
+ return {key: messages[key] for key in sorted(messages)}
17235
+
17236
+
16883
17237
  def _serialize_choice_object(c: "_ChoiceSpec") -> dict:
16884
17238
  """One choice of one selector: `name`, `help`, and its scope (§25.6).
16885
17239
 
@@ -16982,6 +17336,8 @@ def _serialize_arg(a: Arg) -> dict:
16982
17336
  d["variadic"] = a.variadic
16983
17337
  if a.choice_records is not None:
16984
17338
  d["choices"] = _serialize_choice_records(a.choice_records)
17339
+ if a.retired_choices:
17340
+ d["retired_choices"] = _serialize_retired_choices(a.retired_choices)
16985
17341
  return d
16986
17342
 
16987
17343