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.
- {strictcli-0.29.0 → strictcli-0.30.1}/PKG-INFO +1 -1
- {strictcli-0.29.0 → strictcli-0.30.1}/package-lock.json +2 -2
- {strictcli-0.29.0 → strictcli-0.30.1}/package.json +1 -1
- {strictcli-0.29.0 → strictcli-0.30.1}/pyproject.toml +1 -1
- {strictcli-0.29.0 → strictcli-0.30.1}/strictcli/__init__.py +126 -1
- strictcli-0.30.1/tests/test_coverage.py +257 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/uv.lock +1 -1
- {strictcli-0.29.0 → strictcli-0.30.1}/.claude/settings.json +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/ci-pypi.yml +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/ci.yml +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.github/workflows/publish.yml +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.gitignore +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.npmignore +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.rlsbl/config.json +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/.strictcli/schema.json +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/CLAUDE.md +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/LICENSE +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/README.md +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/index.js +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/postinstall.js +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/strictcli/py.typed +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/conftest.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_arg_default.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_at_prefix.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_auto_version.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_call.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_command.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_discovery.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_provider.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_public_api.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_runner.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_schema.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_check_types.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_choices.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_choices_none.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_command_tags.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_compound_types.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_fields.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_file_path.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_context.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_dependencies.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_deprecated.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_dump_schema.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_e2e.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_env.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_exit_codes.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_flag_sets.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_format.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_type.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_float_vectors.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_global_flags.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_help.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_hermetic.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_infra_env.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_int_type.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_invoke.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_mcp.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_mutex.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_nesting.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_parser.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_passthrough.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_provenance.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_registration.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_repeatable.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_tagdsl.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_toml_loading.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_tool_export.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_typed_args.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_unique.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_utilities.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_validate.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_variadic.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/tests/test_visibility.py +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/todo/.defer/deferred.md +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/todo/.done/original-idea.md +0 -0
- {strictcli-0.29.0 → strictcli-0.30.1}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strictcli",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.30.1",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT"
|
|
12
12
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "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()
|
|
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
|