strictcli 0.29.0__tar.gz → 0.30.1__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 (87) hide show
  1. {strictcli-0.29.0 → strictcli-0.30.1}/PKG-INFO +1 -1
  2. {strictcli-0.29.0 → strictcli-0.30.1}/package-lock.json +2 -2
  3. {strictcli-0.29.0 → strictcli-0.30.1}/package.json +1 -1
  4. {strictcli-0.29.0 → strictcli-0.30.1}/pyproject.toml +1 -1
  5. {strictcli-0.29.0 → strictcli-0.30.1}/strictcli/__init__.py +126 -1
  6. strictcli-0.30.1/tests/test_coverage.py +257 -0
  7. {strictcli-0.29.0 → strictcli-0.30.1}/uv.lock +1 -1
  8. {strictcli-0.29.0 → strictcli-0.30.1}/.claude/settings.json +0 -0
  9. {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/ci-pypi.yml +0 -0
  10. {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/ci.yml +0 -0
  11. {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/publish.yml +0 -0
  12. {strictcli-0.29.0 → strictcli-0.30.1}/.gitignore +0 -0
  13. {strictcli-0.29.0 → strictcli-0.30.1}/.npmignore +0 -0
  14. {strictcli-0.29.0 → strictcli-0.30.1}/.rlsbl/config.json +0 -0
  15. {strictcli-0.29.0 → strictcli-0.30.1}/.rlsbl/managed-files.json +0 -0
  16. {strictcli-0.29.0 → strictcli-0.30.1}/.strictcli/schema.json +0 -0
  17. {strictcli-0.29.0 → strictcli-0.30.1}/CLAUDE.md +0 -0
  18. {strictcli-0.29.0 → strictcli-0.30.1}/LICENSE +0 -0
  19. {strictcli-0.29.0 → strictcli-0.30.1}/README.md +0 -0
  20. {strictcli-0.29.0 → strictcli-0.30.1}/index.js +0 -0
  21. {strictcli-0.29.0 → strictcli-0.30.1}/postinstall.js +0 -0
  22. {strictcli-0.29.0 → strictcli-0.30.1}/strictcli/py.typed +0 -0
  23. {strictcli-0.29.0 → strictcli-0.30.1}/tests/conftest.py +0 -0
  24. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_arg_default.py +0 -0
  25. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_arg_default_validation.py +0 -0
  26. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_at_prefix.py +0 -0
  27. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_auto_version.py +0 -0
  28. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_call.py +0 -0
  29. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_command.py +0 -0
  30. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_discovery.py +0 -0
  31. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_provider.py +0 -0
  32. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_public_api.py +0 -0
  33. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_runner.py +0 -0
  34. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_schema.py +0 -0
  35. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_types.py +0 -0
  36. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_choices.py +0 -0
  37. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_choices_none.py +0 -0
  38. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_command_help_suggestion.py +0 -0
  39. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_command_tags.py +0 -0
  40. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_compound_types.py +0 -0
  41. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config.py +0 -0
  42. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_fields.py +0 -0
  43. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_file_path.py +0 -0
  44. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_set_bugs.py +0 -0
  45. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_context.py +0 -0
  46. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_deep_nesting.py +0 -0
  47. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_dependencies.py +0 -0
  48. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_deprecated.py +0 -0
  49. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_dump_schema.py +0 -0
  50. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_e2e.py +0 -0
  51. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_env.py +0 -0
  52. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_exit_codes.py +0 -0
  53. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_flag_sets.py +0 -0
  54. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_format.py +0 -0
  55. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_type.py +0 -0
  56. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_vectors.py +0 -0
  57. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_global_flag_conflict_position.py +0 -0
  58. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_global_flags.py +0 -0
  59. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_help.py +0 -0
  60. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_hermetic.py +0 -0
  61. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_infra_env.py +0 -0
  62. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_int_type.py +0 -0
  63. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_invoke.py +0 -0
  64. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_keyword_flags.py +0 -0
  65. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_mcp.py +0 -0
  66. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_mutex.py +0 -0
  67. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_nesting.py +0 -0
  68. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_parser.py +0 -0
  69. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_passthrough.py +0 -0
  70. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_provenance.py +0 -0
  71. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_provenance_phase2.py +0 -0
  72. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_registration.py +0 -0
  73. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_repeatable.py +0 -0
  74. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_reserved_global_flags.py +0 -0
  75. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_tagdsl.py +0 -0
  76. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_toml_loading.py +0 -0
  77. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_tool_export.py +0 -0
  78. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_typed_args.py +0 -0
  79. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_unique.py +0 -0
  80. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_utilities.py +0 -0
  81. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_validate.py +0 -0
  82. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_variadic.py +0 -0
  83. {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_visibility.py +0 -0
  84. {strictcli-0.29.0 → strictcli-0.30.1}/todo/.defer/deferred.md +0 -0
  85. {strictcli-0.29.0 → strictcli-0.30.1}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  86. {strictcli-0.29.0 → strictcli-0.30.1}/todo/.done/original-idea.md +0 -0
  87. {strictcli-0.29.0 → strictcli-0.30.1}/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.29.0
3
+ Version: 0.30.1
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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.29.0",
9
+ "version": "0.30.1",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
4
4
  "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.29.0"
7
+ version = "0.30.1"
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.29.0"
5
+ __version__ = "0.30.1"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -2677,6 +2677,7 @@ class App:
2677
2677
  handshake_env: dict[str, str] | None = None
2678
2678
  checks_path: str | Path | None = None
2679
2679
  checks_embed: bytes | None = None
2680
+ test_coverage: bool = False
2680
2681
  flags: list[Flag] = field(default_factory=list)
2681
2682
  _commands: dict[str, Command] = field(default_factory=dict)
2682
2683
  _groups: dict[str, Group] = field(default_factory=dict)
@@ -2797,6 +2798,20 @@ class App:
2797
2798
  # Config parse error (for config show to pick up)
2798
2799
  self._config_parse_err: str | None = None
2799
2800
 
2801
+ # Test-coverage instrumentation. When enabled, every test() and call()
2802
+ # invocation records which command was dispatched so a check can verify
2803
+ # that every command in the app's surface has been exercised.
2804
+ self._coverage_shard_counter: int = 0
2805
+ self._coverage_shard_path: str | None = None
2806
+ self._last_resolved_path: list[str] = []
2807
+ if self.test_coverage:
2808
+ self._coverage_shard_path = os.path.join(
2809
+ ".strictcli", "coverage",
2810
+ f"{os.getpid()}-{{n}}.jsonl",
2811
+ )
2812
+ os.makedirs(os.path.join(".strictcli", "coverage"), exist_ok=True)
2813
+ self.register_check_provider(self._test_coverage_provider)
2814
+
2800
2815
  def _validate_flag_infra_marker(self, f: Flag) -> None:
2801
2816
  """Panic if a flag's default is a RelativeToRoot marker referencing an
2802
2817
  undeclared root. Called at registration for construction-time errors."""
@@ -2817,6 +2832,107 @@ class App:
2817
2832
  handshakes=set(self._handshake_envs),
2818
2833
  )
2819
2834
 
2835
+ def _record_coverage(self, cmd_path: str) -> None:
2836
+ """Append a coverage record for the resolved command path.
2837
+
2838
+ Each test() or call() invocation writes one JSONL line to a per-process
2839
+ shard file. The shard counter increments on each write to keep files
2840
+ small and unique (handles pytest-xdist where multiple workers share a
2841
+ PID namespace within the coverage directory).
2842
+ """
2843
+ if self._coverage_shard_path is None:
2844
+ return
2845
+ path = self._coverage_shard_path.format(n=self._coverage_shard_counter)
2846
+ os.makedirs(os.path.dirname(path), exist_ok=True)
2847
+ with open(path, "a", encoding="utf-8") as f:
2848
+ f.write(json.dumps({"command": cmd_path}) + "\n")
2849
+
2850
+ def _collect_all_command_paths(self) -> set[str]:
2851
+ """Enumerate all non-deprecated leaf command paths as dotted strings."""
2852
+ paths: set[str] = set()
2853
+
2854
+ for name in self._commands:
2855
+ paths.add(name)
2856
+
2857
+ def _walk_group(group: Group, prefix: list[str]) -> None:
2858
+ for cmd_name in group.commands:
2859
+ paths.add(".".join(prefix + [cmd_name]))
2860
+ for sub_name, sub_group in group._groups.items():
2861
+ _walk_group(sub_group, prefix + [sub_name])
2862
+
2863
+ for group_name, group in self._groups.items():
2864
+ _walk_group(group, [group_name])
2865
+
2866
+ return paths
2867
+
2868
+ def _test_coverage_provider(self) -> list[CheckSpec]:
2869
+ """Built-in check provider for cli-test-coverage.
2870
+
2871
+ Registered automatically when test_coverage=True. Merges per-process
2872
+ shard files into the canonical manifest, compares against the schema's
2873
+ command surface (minus deprecated), and hard-FAILs listing every command
2874
+ with zero coverage.
2875
+ """
2876
+ def impl(ctx: CheckContext, reporter: "ErrorReporter") -> "_CheckOutcome":
2877
+ coverage_dir = os.path.join(".strictcli", "coverage")
2878
+ manifest_path = os.path.join(".strictcli", "test-coverage.json")
2879
+
2880
+ # Merge shards
2881
+ covered: set[str] = set()
2882
+ if os.path.isdir(coverage_dir):
2883
+ for fname in os.listdir(coverage_dir):
2884
+ if not fname.endswith(".jsonl"):
2885
+ continue
2886
+ fpath = os.path.join(coverage_dir, fname)
2887
+ with open(fpath, encoding="utf-8") as f:
2888
+ for line in f:
2889
+ line = line.strip()
2890
+ if not line:
2891
+ continue
2892
+ entry = json.loads(line)
2893
+ if "command" in entry:
2894
+ covered.add(entry["command"])
2895
+
2896
+ if not covered:
2897
+ reporter.error("stale or empty manifest")
2898
+ return reporter.found(
2899
+ "no coverage data: .strictcli/coverage/ contains no shard files"
2900
+ )
2901
+
2902
+ # Write canonical manifest
2903
+ manifest = sorted(covered)
2904
+ with open(manifest_path, "w", encoding="utf-8") as f:
2905
+ json.dump(manifest, f, indent=2)
2906
+ f.write("\n")
2907
+
2908
+ # Compare against command surface (exclude the framework-injected
2909
+ # check command -- it is not a user command)
2910
+ all_commands = self._collect_all_command_paths()
2911
+ all_commands.discard("check")
2912
+ uncovered = sorted(all_commands - covered)
2913
+
2914
+ if uncovered:
2915
+ for cmd in uncovered:
2916
+ reporter.error(f"no test coverage for command: {cmd}")
2917
+ return reporter.found(
2918
+ f"{len(uncovered)} command(s) with zero test coverage"
2919
+ )
2920
+ return reporter.passed(
2921
+ f"all {len(all_commands)} commands have test coverage"
2922
+ )
2923
+
2924
+ return [
2925
+ error_check_spec(
2926
+ name="cli-test-coverage",
2927
+ tags=["test"],
2928
+ fast=True,
2929
+ pure=True,
2930
+ needs_network=False,
2931
+ depends_on=[],
2932
+ impl=impl,
2933
+ ),
2934
+ ]
2935
+
2820
2936
  @property
2821
2937
  def config_file_path(self) -> str:
2822
2938
  """Return the resolved config file path for this app."""
@@ -4111,6 +4227,7 @@ class App:
4111
4227
  raise _HelpRequested(target=self)
4112
4228
 
4113
4229
  cmd, rest, path = self._resolve_command(remaining)
4230
+ self._last_resolved_path = path
4114
4231
 
4115
4232
  # Check for command-level --help/-h anywhere in remaining tokens
4116
4233
  # (but not after "--" separator, which makes everything literal)
@@ -4751,6 +4868,10 @@ class App:
4751
4868
  stderr_buf.write(f"try '{prefix} --help'\n")
4752
4869
  exit_code = 1
4753
4870
  else:
4871
+ # Record test-coverage hit (command-level only).
4872
+ if self.test_coverage:
4873
+ cmd_path = ".".join(self._last_resolved_path + [cmd.name])
4874
+ self._record_coverage(cmd_path)
4754
4875
  # Store sources for function handlers that need provenance info
4755
4876
  self._last_sources = sources
4756
4877
  with contextlib.redirect_stdout(stdout_buf), contextlib.redirect_stderr(stderr_buf):
@@ -4805,6 +4926,10 @@ class App:
4805
4926
  path_segments = command_path.split(".")
4806
4927
  cmd, _rest, _path = self._resolve_command(path_segments)
4807
4928
 
4929
+ # Record test-coverage hit (command-level only).
4930
+ if self.test_coverage:
4931
+ self._record_coverage(command_path)
4932
+
4808
4933
  # Passthrough commands: forward raw args to the passthrough handler
4809
4934
  if cmd.passthrough is not None:
4810
4935
  raw_args = kwargs.get("_args", [])
@@ -0,0 +1,257 @@
1
+ """Tests for the cli-test-coverage mechanism.
2
+
3
+ Verifies that:
4
+ - test_coverage=True enables recording of command hits
5
+ - test() and call() both record to per-process shard files
6
+ - The cli-test-coverage check merges shards, compares against the command
7
+ surface, and FAILs listing uncovered commands
8
+ - Full coverage produces a PASS
9
+ - Empty/stale manifest is a hard error
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ import strictcli
20
+
21
+
22
+ @dataclass
23
+ class SimpleCtx:
24
+ project_root: Path
25
+
26
+
27
+ def _make_app(tmp_path):
28
+ """Build a 3-command app with test_coverage enabled, rooted in tmp_path."""
29
+ os.chdir(tmp_path)
30
+ app = strictcli.App(
31
+ name="coverapp", version="1.0.0", help="coverage test app",
32
+ test_coverage=True,
33
+ )
34
+
35
+ @app.command(name="deploy", help="deploy the app")
36
+ def cmd_deploy(ctx, **_kw):
37
+ pass
38
+
39
+ @app.command(name="status", help="show status")
40
+ def cmd_status(ctx, **_kw):
41
+ pass
42
+
43
+ @app.command(name="build", help="build the app")
44
+ def cmd_build(ctx, **_kw):
45
+ pass
46
+
47
+ app.set_check_context(lambda: SimpleCtx(project_root=tmp_path))
48
+ return app
49
+
50
+
51
+ def _make_grouped_app(tmp_path):
52
+ """Build an app with grouped commands for dotted-path coverage."""
53
+ os.chdir(tmp_path)
54
+ app = strictcli.App(
55
+ name="grpapp", version="1.0.0", help="grouped coverage test",
56
+ test_coverage=True,
57
+ )
58
+
59
+ grp = app.group("infra", help="infrastructure commands")
60
+
61
+ @grp.command(name="deploy", help="deploy infra")
62
+ def cmd_infra_deploy(ctx, **_kw):
63
+ pass
64
+
65
+ @grp.command(name="teardown", help="tear down infra")
66
+ def cmd_infra_teardown(ctx, **_kw):
67
+ pass
68
+
69
+ @app.command(name="status", help="show status")
70
+ def cmd_status(ctx, **_kw):
71
+ pass
72
+
73
+ app.set_check_context(lambda: SimpleCtx(project_root=tmp_path))
74
+ return app
75
+
76
+
77
+ class TestCoverageRecording:
78
+ def test_test_creates_shard_file(self, tmp_path):
79
+ app = _make_app(tmp_path)
80
+ app.test(["deploy"])
81
+
82
+ coverage_dir = tmp_path / ".strictcli" / "coverage"
83
+ assert coverage_dir.is_dir()
84
+ shards = list(coverage_dir.glob("*.jsonl"))
85
+ assert len(shards) >= 1
86
+
87
+ entries = []
88
+ for shard in shards:
89
+ for line in shard.read_text().strip().splitlines():
90
+ entries.append(json.loads(line))
91
+
92
+ commands = {e["command"] for e in entries}
93
+ assert "deploy" in commands
94
+
95
+ def test_call_records_coverage(self, tmp_path):
96
+ app = _make_app(tmp_path)
97
+ app.call("status")
98
+
99
+ coverage_dir = tmp_path / ".strictcli" / "coverage"
100
+ shards = list(coverage_dir.glob("*.jsonl"))
101
+ assert len(shards) >= 1
102
+
103
+ entries = []
104
+ for shard in shards:
105
+ for line in shard.read_text().strip().splitlines():
106
+ entries.append(json.loads(line))
107
+
108
+ commands = {e["command"] for e in entries}
109
+ assert "status" in commands
110
+
111
+ def test_multiple_calls_accumulate(self, tmp_path):
112
+ app = _make_app(tmp_path)
113
+ app.test(["deploy"])
114
+ app.test(["status"])
115
+ app.call("build")
116
+
117
+ coverage_dir = tmp_path / ".strictcli" / "coverage"
118
+ entries = []
119
+ for shard in coverage_dir.glob("*.jsonl"):
120
+ for line in shard.read_text().strip().splitlines():
121
+ entries.append(json.loads(line))
122
+
123
+ commands = {e["command"] for e in entries}
124
+ assert commands == {"deploy", "status", "build"}
125
+
126
+ def test_grouped_command_dotted_path(self, tmp_path):
127
+ app = _make_grouped_app(tmp_path)
128
+ app.test(["infra", "deploy"])
129
+
130
+ coverage_dir = tmp_path / ".strictcli" / "coverage"
131
+ entries = []
132
+ for shard in coverage_dir.glob("*.jsonl"):
133
+ for line in shard.read_text().strip().splitlines():
134
+ entries.append(json.loads(line))
135
+
136
+ commands = {e["command"] for e in entries}
137
+ assert "infra.deploy" in commands
138
+
139
+
140
+ class TestCoverageCheck:
141
+ def test_partial_coverage_fails_naming_uncovered(self, tmp_path):
142
+ """One command tested out of 3 -> check FAILs naming the untested two."""
143
+ app = _make_app(tmp_path)
144
+ # Only test one command
145
+ app.test(["deploy"])
146
+
147
+ results, _, code = app.run_checks(
148
+ SimpleCtx(project_root=tmp_path),
149
+ run_all=True,
150
+ )
151
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
152
+ assert cov_result.status == "fail"
153
+ # Should list the two uncovered commands
154
+ problem_texts = [p.text for p in cov_result.problems]
155
+ uncovered_cmds = set()
156
+ for text in problem_texts:
157
+ if "no test coverage for command:" in text:
158
+ cmd = text.split("no test coverage for command: ")[1]
159
+ uncovered_cmds.add(cmd)
160
+ assert "status" in uncovered_cmds
161
+ assert "build" in uncovered_cmds
162
+ assert "deploy" not in uncovered_cmds
163
+
164
+ def test_full_coverage_passes(self, tmp_path):
165
+ """All 3 commands tested -> check PASSes."""
166
+ app = _make_app(tmp_path)
167
+ app.test(["deploy"])
168
+ app.test(["status"])
169
+ app.test(["build"])
170
+
171
+ results, _, code = app.run_checks(
172
+ SimpleCtx(project_root=tmp_path),
173
+ run_all=True,
174
+ )
175
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
176
+ assert cov_result.status == "pass"
177
+
178
+ def test_empty_coverage_is_hard_error(self, tmp_path):
179
+ """No shard files -> check FAILs with stale/empty manifest error."""
180
+ app = _make_app(tmp_path)
181
+ # Don't run any test() or call() -- no shards exist
182
+
183
+ results, _, code = app.run_checks(
184
+ SimpleCtx(project_root=tmp_path),
185
+ run_all=True,
186
+ )
187
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
188
+ assert cov_result.status == "fail"
189
+ assert "stale or empty" in cov_result.message or any(
190
+ "stale or empty" in p.text for p in cov_result.problems
191
+ )
192
+
193
+ def test_manifest_written_on_check(self, tmp_path):
194
+ """The check writes .strictcli/test-coverage.json with covered commands."""
195
+ app = _make_app(tmp_path)
196
+ app.test(["deploy"])
197
+ app.test(["status"])
198
+ app.test(["build"])
199
+
200
+ app.run_checks(
201
+ SimpleCtx(project_root=tmp_path),
202
+ run_all=True,
203
+ )
204
+
205
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
206
+ assert manifest_path.is_file()
207
+ manifest = json.loads(manifest_path.read_text())
208
+ assert sorted(manifest) == ["build", "deploy", "status"]
209
+
210
+ def test_grouped_commands_coverage(self, tmp_path):
211
+ """Grouped commands use dotted paths in coverage tracking."""
212
+ app = _make_grouped_app(tmp_path)
213
+ # Test all commands
214
+ app.test(["infra", "deploy"])
215
+ app.test(["infra", "teardown"])
216
+ app.test(["status"])
217
+
218
+ results, _, code = app.run_checks(
219
+ SimpleCtx(project_root=tmp_path),
220
+ run_all=True,
221
+ )
222
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
223
+ assert cov_result.status == "pass"
224
+
225
+ def test_grouped_partial_coverage_fails(self, tmp_path):
226
+ """Missing a grouped command -> check FAILs naming it."""
227
+ app = _make_grouped_app(tmp_path)
228
+ app.test(["infra", "deploy"])
229
+ app.test(["status"])
230
+ # infra.teardown is not tested
231
+
232
+ results, _, code = app.run_checks(
233
+ SimpleCtx(project_root=tmp_path),
234
+ run_all=True,
235
+ )
236
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
237
+ assert cov_result.status == "fail"
238
+ problem_texts = [p.text for p in cov_result.problems]
239
+ assert any("infra.teardown" in t for t in problem_texts)
240
+
241
+
242
+ class TestCoverageDisabled:
243
+ def test_no_recording_when_disabled(self, tmp_path):
244
+ """test_coverage=False (default) produces no shard files."""
245
+ os.chdir(tmp_path)
246
+ app = strictcli.App(
247
+ name="nocover", version="1.0.0", help="no coverage",
248
+ )
249
+
250
+ @app.command(name="greet", help="say hello")
251
+ def cmd_greet(ctx, **_kw):
252
+ pass
253
+
254
+ app.test(["greet"])
255
+
256
+ coverage_dir = tmp_path / ".strictcli" / "coverage"
257
+ assert not coverage_dir.exists()
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.29.0"
235
+ version = "0.30.1"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes