strictcli 0.31.0__tar.gz → 0.32.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 (88) hide show
  1. strictcli-0.32.1/CLAUDE.md +26 -0
  2. {strictcli-0.31.0 → strictcli-0.32.1}/PKG-INFO +1 -1
  3. {strictcli-0.31.0 → strictcli-0.32.1}/pyproject.toml +1 -1
  4. {strictcli-0.31.0 → strictcli-0.32.1}/strictcli/__init__.py +88 -22
  5. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_coverage.py +122 -6
  6. {strictcli-0.31.0 → strictcli-0.32.1}/uv.lock +1 -1
  7. strictcli-0.31.0/CLAUDE.md +0 -20
  8. {strictcli-0.31.0 → strictcli-0.32.1}/.claude/settings.json +0 -0
  9. {strictcli-0.31.0 → strictcli-0.32.1}/.github/workflows/ci.yml +0 -0
  10. {strictcli-0.31.0 → strictcli-0.32.1}/.github/workflows/publish.yml +0 -0
  11. {strictcli-0.31.0 → strictcli-0.32.1}/.gitignore +0 -0
  12. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  13. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  14. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.gitignore +0 -0
  15. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  16. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/config.json +0 -0
  17. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/lint/python.toml +0 -0
  18. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/managed-files.json +0 -0
  19. {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/version +0 -0
  20. {strictcli-0.31.0 → strictcli-0.32.1}/.strictcli/schema.json +0 -0
  21. {strictcli-0.31.0 → strictcli-0.32.1}/LICENSE +0 -0
  22. {strictcli-0.31.0 → strictcli-0.32.1}/README.md +0 -0
  23. {strictcli-0.31.0 → strictcli-0.32.1}/strictcli/py.typed +0 -0
  24. {strictcli-0.31.0 → strictcli-0.32.1}/tests/conftest.py +0 -0
  25. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_arg_default.py +0 -0
  26. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_arg_default_validation.py +0 -0
  27. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_at_prefix.py +0 -0
  28. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_auto_version.py +0 -0
  29. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_call.py +0 -0
  30. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_command.py +0 -0
  31. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_discovery.py +0 -0
  32. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_provider.py +0 -0
  33. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_types.py +0 -0
  37. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_choices.py +0 -0
  38. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_choices_none.py +0 -0
  39. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_command_help_suggestion.py +0 -0
  40. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_command_tags.py +0 -0
  41. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_compound_types.py +0 -0
  42. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config.py +0 -0
  43. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_fields.py +0 -0
  44. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_file_path.py +0 -0
  45. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_set_bugs.py +0 -0
  46. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_context.py +0 -0
  47. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_deep_nesting.py +0 -0
  48. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_dependencies.py +0 -0
  49. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_deprecated.py +0 -0
  50. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_dump_schema.py +0 -0
  51. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_e2e.py +0 -0
  52. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_env.py +0 -0
  53. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_exit_codes.py +0 -0
  54. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_flag_sets.py +0 -0
  55. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_format.py +0 -0
  56. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_type.py +0 -0
  57. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_vectors.py +0 -0
  58. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_global_flag_conflict_position.py +0 -0
  59. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_global_flags.py +0 -0
  60. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_help.py +0 -0
  61. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_hermetic.py +0 -0
  62. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_infra_env.py +0 -0
  63. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_int_type.py +0 -0
  64. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_invoke.py +0 -0
  65. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_keyword_flags.py +0 -0
  66. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_mcp.py +0 -0
  67. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_mutex.py +0 -0
  68. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_nesting.py +0 -0
  69. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_parser.py +0 -0
  70. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_passthrough.py +0 -0
  71. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_provenance.py +0 -0
  72. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_provenance_phase2.py +0 -0
  73. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_registration.py +0 -0
  74. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_repeatable.py +0 -0
  75. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_reserved_global_flags.py +0 -0
  76. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_tagdsl.py +0 -0
  77. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_toml_loading.py +0 -0
  78. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_tool_export.py +0 -0
  79. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_typed_args.py +0 -0
  80. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_unique.py +0 -0
  81. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_utilities.py +0 -0
  82. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_validate.py +0 -0
  83. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_variadic.py +0 -0
  84. {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_visibility.py +0 -0
  85. {strictcli-0.31.0 → strictcli-0.32.1}/todo/.defer/deferred.md +0 -0
  86. {strictcli-0.31.0 → strictcli-0.32.1}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  87. {strictcli-0.31.0 → strictcli-0.32.1}/todo/.done/original-idea.md +0 -0
  88. {strictcli-0.31.0 → strictcli-0.32.1}/todo/.done/public-check-runner-api.md +0 -0
@@ -0,0 +1,26 @@
1
+ # strictcli (Python)
2
+
3
+ Python implementation of strictcli. Published to PyPI. Part of the strictcli
4
+ rlsbl monorepo as releasable `py-strictcli`.
5
+
6
+ ## Development
7
+
8
+ - `uv sync` then `uv run pytest` from this directory. Always use `uv`, never pip.
9
+ - Run the conformance suite after changes: `cd ../conformance && python run.py --target python`.
10
+ - CI (`ci-router.yml` at the repo root) runs the full pytest suite on every push touching `python/**`.
11
+
12
+ ## Release workflow
13
+
14
+ Releases go through [rlsbl](https://github.com/smm-h/rlsbl) monorepo releases.
15
+
16
+ - CHANGELOG.md is generated from JSONL changelog entries — never edit it by hand.
17
+ Add entries with `rlsbl changelog add` from inside `python/` after each commit
18
+ (they land in `.rlsbl-monorepo/releasables/py-strictcli/changes/unreleased.jsonl`).
19
+ - To release: from the repo root, run `rlsbl monorepo release init`, edit the
20
+ scaffolded release file (bump type, description, and context live in the file),
21
+ then run `rlsbl monorepo release run --no-allow-dirty --watch --yes`.
22
+ - Never publish or push manually — CI publishes to PyPI via Trusted Publishing.
23
+
24
+ ## Conventions
25
+
26
+ - No tokens or secrets in command-line arguments (use env vars or config files).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.31.0
3
+ Version: 0.32.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.31.0"
7
+ version = "0.32.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.31.0"
5
+ __version__ = "0.32.1"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -2803,13 +2803,25 @@ class App:
2803
2803
  # that every command in the app's surface has been exercised.
2804
2804
  self._coverage_shard_counter: int = 0
2805
2805
  self._coverage_shard_path: str | None = None
2806
+ self._coverage_dir: str | None = None
2807
+ self._coverage_manifest_path: str | None = None
2806
2808
  self._last_resolved_path: list[str] = []
2807
2809
  if self.test_coverage:
2810
+ # Anchor the coverage root to the cwd AT CONSTRUCTION TIME. Both the
2811
+ # recorder and the check provider use these absolute paths so that
2812
+ # tests which chdir still record into the repo, and a check evaluated
2813
+ # from a foreign cwd reads the app's own repo state.
2814
+ self._coverage_dir = os.path.abspath(
2815
+ os.path.join(".strictcli", "coverage")
2816
+ )
2817
+ self._coverage_manifest_path = os.path.abspath(
2818
+ os.path.join(".strictcli", "test-coverage.json")
2819
+ )
2808
2820
  self._coverage_shard_path = os.path.join(
2809
- ".strictcli", "coverage",
2821
+ self._coverage_dir,
2810
2822
  f"{os.getpid()}-{{n}}.jsonl",
2811
2823
  )
2812
- os.makedirs(os.path.join(".strictcli", "coverage"), exist_ok=True)
2824
+ os.makedirs(self._coverage_dir, exist_ok=True)
2813
2825
  self.register_check_provider(self._test_coverage_provider)
2814
2826
 
2815
2827
  def _validate_flag_infra_marker(self, f: Flag) -> None:
@@ -2868,18 +2880,67 @@ class App:
2868
2880
  def _test_coverage_provider(self) -> list[CheckSpec]:
2869
2881
  """Built-in check provider for cli-test-coverage.
2870
2882
 
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.
2883
+ Registered automatically when test_coverage=True. The verdict is derived
2884
+ from committed state: the covered set is the union of the committed
2885
+ manifest (.strictcli/test-coverage.json) and any per-process shard files
2886
+ merged from .strictcli/coverage/. Every live registered command path
2887
+ (minus the injected check command) must be present in that union to pass;
2888
+ otherwise the check fails naming each uncovered command.
2889
+
2890
+ Because the verdict reads the committed manifest, it is deterministic on
2891
+ every machine -- a machine that never ran the suite (no local shards)
2892
+ still gets a stable verdict from the committed manifest alone. Both the
2893
+ coverage dir and the manifest path are anchored to the App's
2894
+ construction-time cwd, so the check evaluated from a foreign cwd reads
2895
+ the app's own repo state.
2896
+
2897
+ The manifest is rewritten as the monotonic union of its prior contents
2898
+ and the freshly merged shards, but ONLY when that content actually
2899
+ changes -- a pure check must not dirty a byte-identical file. Accepted
2900
+ staleness: deleting a test leaves its command covered in the manifest
2901
+ until the manifest is deliberately regenerated (e.g. by removing it and
2902
+ re-running the suite), because the union never removes a command.
2875
2903
  """
2876
2904
  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")
2905
+ coverage_dir = self._coverage_dir
2906
+ manifest_path = self._coverage_manifest_path
2907
+
2908
+ # Subject-matter gating (the sanctioned skip class, mirroring
2909
+ # project-type gating): when the anchored coverage root holds NEITHER
2910
+ # a committed manifest NOR any shard files, this is not the app's own
2911
+ # development tree -- e.g. an installed app running its checks from a
2912
+ # foreign project's cwd, where the construction-anchored root points
2913
+ # at a directory with no coverage state. Report a visible SKIP instead
2914
+ # of failing with the app's entire command surface listed as
2915
+ # uncovered. When EITHER exists, behavior is unchanged: a partial
2916
+ # manifest still fails honestly, and an empty-manifest file present
2917
+ # still means "coverage configured but empty" = fail listing all.
2918
+ manifest_exists = bool(manifest_path) and os.path.isfile(manifest_path)
2919
+ shards_exist = bool(coverage_dir) and os.path.isdir(coverage_dir) and any(
2920
+ fname.endswith(".jsonl") for fname in os.listdir(coverage_dir)
2921
+ )
2922
+ if not manifest_exists and not shards_exist:
2923
+ anchor = os.path.dirname(manifest_path) if manifest_path else coverage_dir
2924
+ return reporter.skipped(
2925
+ f"no coverage state at {anchor} -- cli-test-coverage applies "
2926
+ "to the app's own development tree"
2927
+ )
2879
2928
 
2880
- # Merge shards
2881
2929
  covered: set[str] = set()
2882
- if os.path.isdir(coverage_dir):
2930
+
2931
+ # Seed from the committed manifest -- this is what makes the verdict
2932
+ # deterministic on machines that never ran the suite.
2933
+ if manifest_path and os.path.isfile(manifest_path):
2934
+ try:
2935
+ with open(manifest_path, encoding="utf-8") as f:
2936
+ data = json.load(f)
2937
+ if isinstance(data, list):
2938
+ covered.update(c for c in data if isinstance(c, str))
2939
+ except (json.JSONDecodeError, OSError):
2940
+ pass
2941
+
2942
+ # Merge shards (optional freshness input)
2943
+ if coverage_dir and os.path.isdir(coverage_dir):
2883
2944
  for fname in os.listdir(coverage_dir):
2884
2945
  if not fname.endswith(".jsonl"):
2885
2946
  continue
@@ -2893,17 +2954,22 @@ class App:
2893
2954
  if "command" in entry:
2894
2955
  covered.add(entry["command"])
2895
2956
 
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")
2957
+ # Rewrite the manifest as the monotonic union, but only when the
2958
+ # content actually changes (keeps a pure check from dirtying a
2959
+ # byte-identical file).
2960
+ if manifest_path and covered:
2961
+ new_content = json.dumps(sorted(covered), indent=2) + "\n"
2962
+ existing: str | None = None
2963
+ if os.path.isfile(manifest_path):
2964
+ try:
2965
+ with open(manifest_path, encoding="utf-8") as f:
2966
+ existing = f.read()
2967
+ except OSError:
2968
+ existing = None
2969
+ if existing != new_content:
2970
+ os.makedirs(os.path.dirname(manifest_path), exist_ok=True)
2971
+ with open(manifest_path, "w", encoding="utf-8") as f:
2972
+ f.write(new_content)
2907
2973
 
2908
2974
  # Compare against command surface (exclude the framework-injected
2909
2975
  # check command -- it is not a user command)
@@ -175,20 +175,49 @@ class TestCoverageCheck:
175
175
  cov_result = next(r for r in results if r.name == "cli-test-coverage")
176
176
  assert cov_result.status == "pass"
177
177
 
178
- def test_empty_coverage_is_hard_error(self, tmp_path):
179
- """No shard files -> check FAILs with stale/empty manifest error."""
178
+ def test_zero_coverage_state_skips(self, tmp_path):
179
+ """No shards and no manifest -> SKIP with a subject-matter reason.
180
+
181
+ Subject-matter gating: when the anchored coverage root holds NEITHER a
182
+ committed manifest NOR any shard files, this is not the app's own
183
+ development tree (e.g. an installed app running its checks from a foreign
184
+ project's cwd). The check reports a visible SKIP naming the anchored path
185
+ rather than failing with the whole command surface listed as uncovered.
186
+ """
180
187
  app = _make_app(tmp_path)
181
- # Don't run any test() or call() -- no shards exist
188
+ # Don't run any test() or call() -- no shards, no manifest
182
189
 
183
190
  results, _, code = app.run_checks(
184
191
  SimpleCtx(project_root=tmp_path),
185
192
  run_all=True,
186
193
  )
187
194
  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
195
+ assert cov_result.status == "skip"
196
+ assert "development tree" in cov_result.message
197
+ # Reason names the anchored .strictcli path, not the foreign cwd.
198
+ assert str(tmp_path / ".strictcli") in cov_result.message
199
+
200
+ def test_empty_manifest_file_present_fails_listing_all(self, tmp_path):
201
+ """An empty-manifest file present means "coverage configured but empty"
202
+ -> FAIL listing every command, NOT a skip. The skip class only triggers
203
+ when NEITHER a manifest NOR any shards exist."""
204
+ app = _make_app(tmp_path)
205
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
206
+ manifest_path.write_text("[]\n")
207
+
208
+ results, _, code = app.run_checks(
209
+ SimpleCtx(project_root=tmp_path),
210
+ run_all=True,
191
211
  )
212
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
213
+ assert cov_result.status == "fail"
214
+ problem_texts = [p.text for p in cov_result.problems]
215
+ uncovered = {
216
+ t.split("no test coverage for command: ")[1]
217
+ for t in problem_texts
218
+ if "no test coverage for command:" in t
219
+ }
220
+ assert uncovered == {"build", "deploy", "status"}
192
221
 
193
222
  def test_manifest_written_on_check(self, tmp_path):
194
223
  """The check writes .strictcli/test-coverage.json with covered commands."""
@@ -239,6 +268,93 @@ class TestCoverageCheck:
239
268
  assert any("infra.teardown" in t for t in problem_texts)
240
269
 
241
270
 
271
+ class TestCoverageChdirSafety:
272
+ def test_record_anchored_to_construction_cwd(self, tmp_path):
273
+ """test() records into the construction-time coverage dir, not the cwd
274
+ that a test happened to chdir into."""
275
+ app = _make_app(tmp_path) # constructs with cwd == tmp_path
276
+ other = tmp_path / "elsewhere"
277
+ other.mkdir()
278
+ os.chdir(other)
279
+
280
+ app.test(["deploy"])
281
+
282
+ construction_shards = list(
283
+ (tmp_path / ".strictcli" / "coverage").glob("*.jsonl")
284
+ )
285
+ assert construction_shards, "shard must land under the construction cwd"
286
+ foreign = other / ".strictcli" / "coverage"
287
+ assert not foreign.exists(), "must not record into the chdir'd cwd"
288
+
289
+
290
+ class TestManifestUnionVerdict:
291
+ def test_manifest_present_shards_absent_passes(self, tmp_path):
292
+ """Committed manifest covering every command -> deterministic PASS even
293
+ with no shard files (the machine never ran the suite)."""
294
+ app = _make_app(tmp_path)
295
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
296
+ manifest_path.write_text(
297
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
298
+ )
299
+
300
+ results, _, code = app.run_checks(
301
+ SimpleCtx(project_root=tmp_path),
302
+ run_all=True,
303
+ )
304
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
305
+ assert cov_result.status == "pass"
306
+
307
+ def test_check_from_foreign_cwd_reads_app_state(self, tmp_path):
308
+ """The check evaluated from a foreign cwd reads the app's own repo state
309
+ (anchored manifest), not the foreign directory."""
310
+ app = _make_app(tmp_path)
311
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
312
+ manifest_path.write_text(
313
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
314
+ )
315
+ other = tmp_path / "foreign"
316
+ other.mkdir()
317
+ os.chdir(other)
318
+
319
+ results, _, code = app.run_checks(
320
+ SimpleCtx(project_root=tmp_path),
321
+ run_all=True,
322
+ )
323
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
324
+ assert cov_result.status == "pass"
325
+
326
+ def test_manifest_union_is_monotonic(self, tmp_path):
327
+ """A run recording only a subset keeps prior commands covered (union)."""
328
+ app = _make_app(tmp_path)
329
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
330
+ manifest_path.write_text(
331
+ json.dumps(["build", "deploy", "status"], indent=2) + "\n"
332
+ )
333
+ app.test(["deploy"]) # only one recorded this run
334
+
335
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
336
+
337
+ manifest = json.loads(manifest_path.read_text())
338
+ assert sorted(manifest) == ["build", "deploy", "status"]
339
+
340
+ def test_manifest_not_rewritten_when_unchanged(self, tmp_path):
341
+ """A pure check must not dirty a byte-identical manifest."""
342
+ app = _make_app(tmp_path)
343
+ app.test(["deploy"])
344
+ app.test(["status"])
345
+ app.test(["build"])
346
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
347
+
348
+ manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
349
+ content1 = manifest_path.read_text()
350
+ mtime1 = manifest_path.stat().st_mtime_ns
351
+
352
+ app.run_checks(SimpleCtx(project_root=tmp_path), run_all=True)
353
+
354
+ assert manifest_path.read_text() == content1
355
+ assert manifest_path.stat().st_mtime_ns == mtime1
356
+
357
+
242
358
  class TestCoverageDisabled:
243
359
  def test_no_recording_when_disabled(self, tmp_path):
244
360
  """test_coverage=False (default) produces no shard files."""
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.31.0"
235
+ version = "0.32.1"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
@@ -1,20 +0,0 @@
1
- # strictcli
2
-
3
- ## Release workflow
4
-
5
- This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestration.
6
-
7
- - Update CHANGELOG.md with a `## X.Y.Z` entry describing changes
8
- - Run `rlsbl release [patch|minor|major]` to bump version and create a GitHub Release
9
- - CI handles publishing automatically via the publish workflow
10
- - Never publish manually — always use `rlsbl release`
11
- - Configure Trusted Publishing on pypi.org for automated PyPI releases
12
- - Use `rlsbl release --dry-run` to preview a release without making changes
13
-
14
- ## Conventions
15
-
16
- - No tokens or secrets in command-line arguments (use env vars or config files)
17
- - All file writes to shared state should be atomic (write to tmp, then rename)
18
- - External calls (APIs, CLI tools) must have timeouts and graceful fallbacks
19
- - Use `uv pip install -e .` for local development
20
- - CI runs smoke tests on every push; manual testing for UI/UX 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