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.
- strictcli-0.32.1/CLAUDE.md +26 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/PKG-INFO +1 -1
- {strictcli-0.31.0 → strictcli-0.32.1}/pyproject.toml +1 -1
- {strictcli-0.31.0 → strictcli-0.32.1}/strictcli/__init__.py +88 -22
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_coverage.py +122 -6
- {strictcli-0.31.0 → strictcli-0.32.1}/uv.lock +1 -1
- strictcli-0.31.0/CLAUDE.md +0 -20
- {strictcli-0.31.0 → strictcli-0.32.1}/.claude/settings.json +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.github/workflows/ci.yml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.github/workflows/publish.yml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.gitignore +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/config.json +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.rlsbl/version +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/.strictcli/schema.json +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/LICENSE +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/README.md +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/strictcli/py.typed +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/conftest.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_arg_default.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_at_prefix.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_auto_version.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_call.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_command.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_discovery.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_provider.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_public_api.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_runner.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_schema.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_check_types.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_choices.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_choices_none.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_command_tags.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_compound_types.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_fields.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_file_path.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_context.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_dependencies.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_deprecated.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_dump_schema.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_e2e.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_env.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_exit_codes.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_flag_sets.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_format.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_type.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_float_vectors.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_global_flags.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_help.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_hermetic.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_infra_env.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_int_type.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_invoke.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_mcp.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_mutex.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_nesting.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_parser.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_passthrough.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_provenance.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_registration.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_repeatable.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_tagdsl.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_toml_loading.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_tool_export.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_typed_args.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_unique.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_utilities.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_validate.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_variadic.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/tests/test_visibility.py +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/todo/.defer/deferred.md +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.31.0 → strictcli-0.32.1}/todo/.done/original-idea.md +0 -0
- {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).
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "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
|
-
|
|
2821
|
+
self._coverage_dir,
|
|
2810
2822
|
f"{os.getpid()}-{{n}}.jsonl",
|
|
2811
2823
|
)
|
|
2812
|
-
os.makedirs(
|
|
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.
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
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 =
|
|
2878
|
-
manifest_path =
|
|
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
|
-
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
)
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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
|
|
179
|
-
"""No
|
|
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
|
|
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 == "
|
|
189
|
-
assert "
|
|
190
|
-
|
|
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."""
|
strictcli-0.31.0/CLAUDE.md
DELETED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|