verifaied 0.1.0.dev28__tar.gz → 0.1.0.dev29__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 (21) hide show
  1. verifaied-0.1.0.dev29/LICENSE +21 -0
  2. verifaied-0.1.0.dev28/README.md → verifaied-0.1.0.dev29/PKG-INFO +102 -9
  3. verifaied-0.1.0.dev28/PKG-INFO → verifaied-0.1.0.dev29/README.md +62 -27
  4. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/pyproject.toml +5 -4
  5. verifaied-0.1.0.dev29/src/verifaied/analyzer.py +208 -0
  6. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/cli.py +183 -1
  7. verifaied-0.1.0.dev29/src/verifaied/prompts.py +193 -0
  8. verifaied-0.1.0.dev29/tests/test_check.py +316 -0
  9. verifaied-0.1.0.dev29/tests/test_prompt_parity.py +91 -0
  10. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/.gitignore +0 -0
  11. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/__init__.py +0 -0
  12. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/__main__.py +0 -0
  13. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/client.py +0 -0
  14. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/config.py +0 -0
  15. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/uploader.py +0 -0
  16. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/__init__.py +0 -0
  17. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_cli.py +0 -0
  18. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_client.py +0 -0
  19. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_config.py +0 -0
  20. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_uploader.py +0 -0
  21. {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/uv.lock +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle Richards
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,56 @@
1
+ Metadata-Version: 2.4
2
+ Name: verifaied
3
+ Version: 0.1.0.dev29
4
+ Summary: Find what's untested in your Python code — locally, no account required
5
+ Project-URL: Homepage, https://pypi.org/project/verifaied/
6
+ Author: Kyle Richards
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Kyle Richards
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: agents,ai,coverage,llm,pytest,testing
30
+ Classifier: Development Status :: 3 - Alpha
31
+ Classifier: Environment :: Console
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Topic :: Software Development :: Testing
35
+ Requires-Python: >=3.10
36
+ Requires-Dist: httpx>=0.27.0
37
+ Requires-Dist: rich>=13.7.0
38
+ Requires-Dist: typer>=0.12.0
39
+ Description-Content-Type: text/markdown
40
+
1
41
  # verifaied
2
42
 
3
- CLI for uploading local pytest coverage to verifAIed so an LLM (or you) can see what's untested without waiting for CI.
43
+ Find what's untested in your Python code, so you (or your coding agent) can fix
44
+ it without waiting for CI.
45
+
46
+ Two verbs:
47
+
48
+ - **`verifaied check`** — runs entirely on your machine. No account, no API
49
+ token, no network call. Tells you which functions are untested or only
50
+ partially covered, and can hand you a ready-to-paste prompt for each one.
51
+ - **`verifaied upload`** — syncs the same coverage to the [verifAIed](https://verifaied.app)
52
+ app, which adds history, branch diffing against CI, a web UI, and AI-written
53
+ test prompts. Needs a free account.
4
54
 
5
55
  ## Install
6
56
 
@@ -9,12 +59,57 @@ uv tool install verifaied # or
9
59
  pipx install verifaied
10
60
  ```
11
61
 
12
- ## Configure
62
+ ## `verifaied check` — local, no account
63
+
64
+ ```bash
65
+ pytest --cov --cov-branch --cov-report=json
66
+ verifaied check
67
+ ```
68
+
69
+ ```
70
+ 12 covered 3 partial 2 untested (17 functions)
71
+
72
+ function file missing
73
+ ● apply_topup app/billing.py:41 42-47
74
+ ● refund app/billing.py:60 61-68
75
+ ◐ _resolve_plan app/plans.py:12 18
76
+ ```
77
+
78
+ Nothing leaves your machine — the whole analysis is a local AST pass over the
79
+ source `coverage.json` already points at.
13
80
 
14
- Set the API token (mint one from the verifAIed app). The CLI ships
15
- pointing at the hosted verifAIed API (`https://api.verifaied.app`) by
16
- default; point it elsewhere via `VERIFAIED_API_URL` if you're running
17
- the backend locally or self-hosting:
81
+ Add `--prompt` to get a concrete, ready-to-paste instruction for each one,
82
+ generated from the function's signature and its uncovered lines (no LLM, no
83
+ cost, unlimited):
84
+
85
+ ```bash
86
+ verifaied check --prompt
87
+ ```
88
+
89
+ ```
90
+ Write a pytest test for `apply_topup` in `app/billing.py`.
91
+
92
+ This function is currently **untested** — no line of it executes under the
93
+ existing suite.
94
+
95
+ What to do:
96
+ - Call `apply_topup(user_id, amount)`.
97
+ - Assert on the **return value** — compare it to the exact expected value.
98
+ - Cover the lines that never execute: **42-47**.
99
+ - It raises `ValueError` — cover that path with `pytest.raises(ValueError)`.
100
+ ```
101
+
102
+ Useful flags: `--limit N` (how many to list, `0` for all), `--root <path>` (the
103
+ directory the coverage paths are relative to — pytest's rootdir), and
104
+ `--fail-under <pct>`, which exits `3` when too few functions are fully covered,
105
+ so `check` works as a CI gate.
106
+
107
+ ## `verifaied upload` — sync to the app
108
+
109
+ Mint an API token from the verifAIed app (Settings → Tokens; a free account is
110
+ enough). The CLI ships pointing at the hosted API (`https://api.verifaied.app`);
111
+ point it elsewhere via `VERIFAIED_API_URL` if you're running the backend locally
112
+ or self-hosting:
18
113
 
19
114
  ```bash
20
115
  export VERIFAIED_API_TOKEN=vr_live_...
@@ -22,8 +117,6 @@ export VERIFAIED_API_TOKEN=vr_live_...
22
117
  export VERIFAIED_API_URL=http://localhost:8000 # local dev
23
118
  ```
24
119
 
25
- ## Use
26
-
27
120
  From inside any git repo you've connected to verifAIed:
28
121
 
29
122
  ```bash
@@ -76,7 +169,7 @@ expand **Recently deleted** under the branches grid, and use
76
169
  to the uploaded source — the per-card **Delete** is a soft-delete that
77
170
  keeps the data around for accidental-deletion recovery.
78
171
 
79
- ### Flags
172
+ ### `upload` flags
80
173
 
81
174
  - `--repo / -r <UUID|owner/name>` — repository to upload to. If omitted,
82
175
  the CLI auto-detects from the GitHub `origin` remote.
@@ -1,24 +1,16 @@
1
- Metadata-Version: 2.4
2
- Name: verifaied
3
- Version: 0.1.0.dev28
4
- Summary: Upload local pytest coverage to verifAIed for instant feedback
5
- Project-URL: Homepage, https://pypi.org/project/verifaied/
6
- Author: Kyle Richards
7
- License: MIT
8
- Keywords: ai,coverage,llm,pytest,testing
9
- Classifier: Development Status :: 3 - Alpha
10
- Classifier: Environment :: Console
11
- Classifier: Intended Audience :: Developers
12
- Classifier: Topic :: Software Development :: Testing
13
- Requires-Python: >=3.10
14
- Requires-Dist: httpx>=0.27.0
15
- Requires-Dist: rich>=13.7.0
16
- Requires-Dist: typer>=0.12.0
17
- Description-Content-Type: text/markdown
18
-
19
1
  # verifaied
20
2
 
21
- CLI for uploading local pytest coverage to verifAIed so an LLM (or you) can see what's untested without waiting for CI.
3
+ Find what's untested in your Python code, so you (or your coding agent) can fix
4
+ it without waiting for CI.
5
+
6
+ Two verbs:
7
+
8
+ - **`verifaied check`** — runs entirely on your machine. No account, no API
9
+ token, no network call. Tells you which functions are untested or only
10
+ partially covered, and can hand you a ready-to-paste prompt for each one.
11
+ - **`verifaied upload`** — syncs the same coverage to the [verifAIed](https://verifaied.app)
12
+ app, which adds history, branch diffing against CI, a web UI, and AI-written
13
+ test prompts. Needs a free account.
22
14
 
23
15
  ## Install
24
16
 
@@ -27,12 +19,57 @@ uv tool install verifaied # or
27
19
  pipx install verifaied
28
20
  ```
29
21
 
30
- ## Configure
22
+ ## `verifaied check` — local, no account
23
+
24
+ ```bash
25
+ pytest --cov --cov-branch --cov-report=json
26
+ verifaied check
27
+ ```
28
+
29
+ ```
30
+ 12 covered 3 partial 2 untested (17 functions)
31
+
32
+ function file missing
33
+ ● apply_topup app/billing.py:41 42-47
34
+ ● refund app/billing.py:60 61-68
35
+ ◐ _resolve_plan app/plans.py:12 18
36
+ ```
37
+
38
+ Nothing leaves your machine — the whole analysis is a local AST pass over the
39
+ source `coverage.json` already points at.
31
40
 
32
- Set the API token (mint one from the verifAIed app). The CLI ships
33
- pointing at the hosted verifAIed API (`https://api.verifaied.app`) by
34
- default; point it elsewhere via `VERIFAIED_API_URL` if you're running
35
- the backend locally or self-hosting:
41
+ Add `--prompt` to get a concrete, ready-to-paste instruction for each one,
42
+ generated from the function's signature and its uncovered lines (no LLM, no
43
+ cost, unlimited):
44
+
45
+ ```bash
46
+ verifaied check --prompt
47
+ ```
48
+
49
+ ```
50
+ Write a pytest test for `apply_topup` in `app/billing.py`.
51
+
52
+ This function is currently **untested** — no line of it executes under the
53
+ existing suite.
54
+
55
+ What to do:
56
+ - Call `apply_topup(user_id, amount)`.
57
+ - Assert on the **return value** — compare it to the exact expected value.
58
+ - Cover the lines that never execute: **42-47**.
59
+ - It raises `ValueError` — cover that path with `pytest.raises(ValueError)`.
60
+ ```
61
+
62
+ Useful flags: `--limit N` (how many to list, `0` for all), `--root <path>` (the
63
+ directory the coverage paths are relative to — pytest's rootdir), and
64
+ `--fail-under <pct>`, which exits `3` when too few functions are fully covered,
65
+ so `check` works as a CI gate.
66
+
67
+ ## `verifaied upload` — sync to the app
68
+
69
+ Mint an API token from the verifAIed app (Settings → Tokens; a free account is
70
+ enough). The CLI ships pointing at the hosted API (`https://api.verifaied.app`);
71
+ point it elsewhere via `VERIFAIED_API_URL` if you're running the backend locally
72
+ or self-hosting:
36
73
 
37
74
  ```bash
38
75
  export VERIFAIED_API_TOKEN=vr_live_...
@@ -40,8 +77,6 @@ export VERIFAIED_API_TOKEN=vr_live_...
40
77
  export VERIFAIED_API_URL=http://localhost:8000 # local dev
41
78
  ```
42
79
 
43
- ## Use
44
-
45
80
  From inside any git repo you've connected to verifAIed:
46
81
 
47
82
  ```bash
@@ -94,7 +129,7 @@ expand **Recently deleted** under the branches grid, and use
94
129
  to the uploaded source — the per-card **Delete** is a soft-delete that
95
130
  keeps the data around for accidental-deletion recovery.
96
131
 
97
- ### Flags
132
+ ### `upload` flags
98
133
 
99
134
  - `--repo / -r <UUID|owner/name>` — repository to upload to. If omitted,
100
135
  the CLI auto-detects from the GitHub `origin` remote.
@@ -1,16 +1,17 @@
1
1
  [project]
2
2
  name = "verifaied"
3
- version = "0.1.0.dev28"
4
- description = "Upload local pytest coverage to verifAIed for instant feedback"
3
+ version = "0.1.0.dev29"
4
+ description = "Find what's untested in your Python code — locally, no account required"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- license = { text = "MIT" }
7
+ license = { file = "LICENSE" }
8
8
  authors = [{ name = "Kyle Richards" }]
9
- keywords = ["pytest", "coverage", "testing", "ai", "llm"]
9
+ keywords = ["pytest", "coverage", "testing", "ai", "llm", "agents"]
10
10
  classifiers = [
11
11
  "Development Status :: 3 - Alpha",
12
12
  "Environment :: Console",
13
13
  "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
14
15
  "Topic :: Software Development :: Testing",
15
16
  ]
16
17
  dependencies = [
@@ -0,0 +1,208 @@
1
+ """Local, offline coverage analysis — the zero-account core.
2
+
3
+ This is what makes ``verifaied check`` work with no API token, no
4
+ network, and no account: everything needed to answer "what's untested in
5
+ my code" is already on disk after a pytest run. The hosted service adds
6
+ history, branch diffing against CI, the web UI, LLM prompts and team
7
+ surfaces — none of which this module needs.
8
+
9
+ It is a deliberate, self-contained reimplementation of the same AST +
10
+ coverage join the backend does, using nothing but the standard library.
11
+ Keeping it dependency-free (no SQLAlchemy, no FastAPI, no pydantic) is
12
+ the point: this is the piece that can be read, trusted, and run by
13
+ someone who has never heard of the hosted product.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ # Coverage states, matching the hosted product's vocabulary so the CLI
23
+ # and the web UI describe the same function the same way.
24
+ UNTESTED = "untested"
25
+ PARTIAL = "partial"
26
+ COVERED = "covered"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class FunctionResult:
31
+ """One function's coverage verdict."""
32
+
33
+ name: str
34
+ file_path: str
35
+ start_line: int
36
+ end_line: int
37
+ state: str
38
+ line_coverage_pct: float
39
+ missing_lines: list[int] = field(default_factory=list)
40
+ partial_branch_lines: list[int] = field(default_factory=list)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class CheckSummary:
45
+ """The whole run. ``files_missing`` names source files that coverage
46
+ referenced but we couldn't read — surfaced rather than swallowed, so
47
+ a wrong ``--root`` shows up as a loud "0 functions matched" instead of
48
+ a confident, empty, wrong answer."""
49
+
50
+ functions: list[FunctionResult]
51
+ files_missing: list[str] = field(default_factory=list)
52
+
53
+ @property
54
+ def untested(self) -> list[FunctionResult]:
55
+ return [f for f in self.functions if f.state == UNTESTED]
56
+
57
+ @property
58
+ def partial(self) -> list[FunctionResult]:
59
+ return [f for f in self.functions if f.state == PARTIAL]
60
+
61
+ @property
62
+ def covered(self) -> list[FunctionResult]:
63
+ return [f for f in self.functions if f.state == COVERED]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class _Extracted:
68
+ name: str
69
+ start_line: int
70
+ end_line: int
71
+ def_line: int
72
+
73
+
74
+ def _decorated_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
75
+ if not node.decorator_list:
76
+ return node.lineno
77
+ return min(d.lineno for d in node.decorator_list)
78
+
79
+
80
+ def extract_functions(source: str) -> list[_Extracted]:
81
+ """Every function/method/nested-def in the source, with dotted
82
+ qualified names (``Class.method``, ``outer.inner``).
83
+
84
+ The line range includes decorator lines, matching how the hosted
85
+ analyzer reports them — so ``@pytest.fixture`` above a ``def`` is part
86
+ of the function for display purposes. ``def_line`` is kept separately
87
+ because that's the line coverage attributes the signature to.
88
+ """
89
+ try:
90
+ tree = ast.parse(source)
91
+ except SyntaxError:
92
+ return []
93
+
94
+ out: list[_Extracted] = []
95
+
96
+ def walk(body: list[ast.stmt], prefix: str) -> None:
97
+ for node in body:
98
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
99
+ qn = f"{prefix}{node.name}" if prefix else node.name
100
+ out.append(
101
+ _Extracted(
102
+ name=qn,
103
+ start_line=_decorated_start(node),
104
+ end_line=node.end_lineno or node.lineno,
105
+ def_line=node.lineno,
106
+ )
107
+ )
108
+ walk(node.body, f"{qn}.")
109
+ elif isinstance(node, ast.ClassDef):
110
+ cn = f"{prefix}{node.name}." if prefix else f"{node.name}."
111
+ walk(node.body, cn)
112
+
113
+ walk(tree.body, "")
114
+ return out
115
+
116
+
117
+ def _state_for(executed: int, total: int, partial_branches: int) -> tuple[str, float]:
118
+ """Classify a function from its line counts.
119
+
120
+ Mirrors the hosted ``_metrics_from_coverage``: nothing executed →
121
+ untested; everything executed *and* no half-taken branch → covered;
122
+ anything else → partial. A function with no measurable statements
123
+ (an ``...`` stub, a pure overload) is reported covered rather than
124
+ untested — there is nothing there to test, and flagging it would be
125
+ noise that trains people to ignore the tool.
126
+ """
127
+ if total == 0:
128
+ return COVERED, 100.0
129
+ if executed == 0:
130
+ return UNTESTED, 0.0
131
+ pct = 100.0 * executed / total
132
+ if executed == total and partial_branches == 0:
133
+ return COVERED, 100.0
134
+ return PARTIAL, pct
135
+
136
+
137
+ def _partial_branch_lines(file_cov: dict) -> set[int]:
138
+ """Lines where a branch was taken one way but not the other.
139
+
140
+ ``coverage.json`` reports these as ``missing_branches`` entries
141
+ ``[source_line, destination_line]``; the source line is the one the
142
+ user has to write another case for. Absent when pytest-cov ran
143
+ without ``--cov-branch``, which is fine — the result is simply no
144
+ partial-branch detail, not a crash.
145
+ """
146
+ out: set[int] = set()
147
+ for entry in file_cov.get("missing_branches") or []:
148
+ if isinstance(entry, list) and entry:
149
+ src = entry[0]
150
+ if isinstance(src, int) and src > 0:
151
+ out.add(src)
152
+ return out
153
+
154
+
155
+ def analyze_coverage(
156
+ coverage_json: dict,
157
+ *,
158
+ root: Path,
159
+ ) -> CheckSummary:
160
+ """Join ``coverage.json`` against the source on disk.
161
+
162
+ ``coverage_json["files"]`` keys are paths relative to pytest's
163
+ rootdir; ``root`` is that rootdir. A file coverage mentions but we
164
+ cannot read (wrong ``--root``, file deleted since the run) is recorded
165
+ in ``files_missing`` rather than silently skipped.
166
+ """
167
+ files = coverage_json.get("files") or {}
168
+ results: list[FunctionResult] = []
169
+ missing: list[str] = []
170
+
171
+ for file_path, file_cov in sorted(files.items()):
172
+ source_path = root / file_path
173
+ try:
174
+ source = source_path.read_text(encoding="utf-8")
175
+ except (OSError, UnicodeDecodeError):
176
+ missing.append(file_path)
177
+ continue
178
+
179
+ executed = set(file_cov.get("executed_lines") or [])
180
+ missed = set(file_cov.get("missing_lines") or [])
181
+ partial_lines = _partial_branch_lines(file_cov)
182
+
183
+ for fn in extract_functions(source):
184
+ # Only lines coverage actually tracks count. Blank lines,
185
+ # comments and the decorator lines we folded into the range
186
+ # are not statements, so counting them would understate
187
+ # coverage for every decorated function.
188
+ span = range(fn.def_line, fn.end_line + 1)
189
+ fn_executed = {ln for ln in span if ln in executed}
190
+ fn_missed = {ln for ln in span if ln in missed}
191
+ total = len(fn_executed) + len(fn_missed)
192
+ fn_partial = sorted(ln for ln in span if ln in partial_lines)
193
+
194
+ state, pct = _state_for(len(fn_executed), total, len(fn_partial))
195
+ results.append(
196
+ FunctionResult(
197
+ name=fn.name,
198
+ file_path=file_path,
199
+ start_line=fn.start_line,
200
+ end_line=fn.end_line,
201
+ state=state,
202
+ line_coverage_pct=pct,
203
+ missing_lines=sorted(fn_missed),
204
+ partial_branch_lines=fn_partial,
205
+ )
206
+ )
207
+
208
+ return CheckSummary(functions=results, files_missing=missing)
@@ -11,6 +11,7 @@ fail builds on missing coverage:
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import json
14
15
  from pathlib import Path
15
16
  from uuid import UUID
16
17
 
@@ -19,6 +20,7 @@ from rich.console import Console
19
20
  from rich.table import Table
20
21
 
21
22
  from verifaied import __version__
23
+ from verifaied.analyzer import CheckSummary, FunctionResult, analyze_coverage
22
24
  from verifaied.client import (
23
25
  ApiError,
24
26
  RepoNotFoundError,
@@ -33,12 +35,14 @@ from verifaied.config import (
33
35
  resolved_api_token,
34
36
  resolved_api_url,
35
37
  )
38
+ from verifaied.prompts import build_prompt, format_line_ranges
36
39
  from verifaied.uploader import UploaderError, build_payload, detect_repo_slug
37
40
 
38
41
  app = typer.Typer(
39
42
  add_completion=False,
40
43
  no_args_is_help=True,
41
- help="Upload local pytest coverage to verifAIed.",
44
+ help="Coverage feedback for AI-written tests. `check` runs locally; "
45
+ "`upload` syncs to the hosted app.",
42
46
  )
43
47
 
44
48
  console = Console()
@@ -176,6 +180,184 @@ def upload_command(
176
180
  _render(result, api_url=resolved_url, repo_id=repo_id)
177
181
 
178
182
 
183
+ @app.command("check")
184
+ def check_command(
185
+ coverage: Path | None = typer.Option(
186
+ None,
187
+ "--coverage",
188
+ help="Path to the pytest-cov json report (default: ./coverage.json).",
189
+ ),
190
+ root: Path | None = typer.Option(
191
+ None,
192
+ "--root",
193
+ help="Root the coverage paths are relative to (default: cwd).",
194
+ ),
195
+ prompt: bool = typer.Option(
196
+ False,
197
+ "--prompt",
198
+ help=(
199
+ "Print a ready-to-paste test prompt for each untested function "
200
+ "instead of the summary table."
201
+ ),
202
+ ),
203
+ limit: int = typer.Option(
204
+ 10,
205
+ "--limit",
206
+ help="Max functions to list (or prompts to print). 0 for no limit.",
207
+ ),
208
+ fail_under: float | None = typer.Option(
209
+ None,
210
+ "--fail-under",
211
+ help=(
212
+ "Exit 3 if the percentage of fully-covered functions is below "
213
+ "this. For use as a CI gate."
214
+ ),
215
+ ),
216
+ ) -> None:
217
+ """What's untested in your code — **entirely local, no account needed**.
218
+
219
+ Reads ``coverage.json`` and your source, and tells you which functions
220
+ are untested or partially covered. Nothing leaves your machine: no API
221
+ token, no network call, no sign-up.
222
+
223
+ Run pytest with coverage first:
224
+
225
+ pytest --cov --cov-branch --cov-report=json
226
+
227
+ Then:
228
+
229
+ verifaied check
230
+ verifaied check --prompt # ready-to-paste prompts for your LLM
231
+
232
+ ``verifaied upload`` is the hosted counterpart — it adds history,
233
+ branch diffing against CI, the web UI and AI-written prompts.
234
+ """
235
+ coverage_path = coverage or Path("coverage.json")
236
+ root_path = root or Path.cwd()
237
+
238
+ try:
239
+ raw = coverage_path.read_text(encoding="utf-8")
240
+ except OSError as e:
241
+ err_console.print(
242
+ f"[red]error[/red]: cannot read {coverage_path} — run "
243
+ f"`pytest --cov --cov-report=json` first"
244
+ )
245
+ raise typer.Exit(code=1) from e
246
+
247
+ try:
248
+ coverage_json = json.loads(raw)
249
+ except json.JSONDecodeError as e:
250
+ err_console.print(f"[red]error[/red]: {coverage_path} is not valid JSON: {e}")
251
+ raise typer.Exit(code=1) from e
252
+
253
+ summary = analyze_coverage(coverage_json, root=root_path)
254
+
255
+ if summary.files_missing:
256
+ # Loud, not silent: an unreadable file almost always means --root
257
+ # is wrong, and the alternative is confidently reporting "0
258
+ # untested functions" for a codebase we never actually read.
259
+ shown = ", ".join(summary.files_missing[:3])
260
+ more = (
261
+ f" (+{len(summary.files_missing) - 3} more)"
262
+ if len(summary.files_missing) > 3
263
+ else ""
264
+ )
265
+ err_console.print(
266
+ f"[yellow]warning[/yellow]: could not read {len(summary.files_missing)} "
267
+ f"file(s) referenced by coverage: {shown}{more}. "
268
+ f"Is --root correct? (using: {root_path})"
269
+ )
270
+
271
+ if not summary.functions:
272
+ console.print(
273
+ "No functions found in the coverage report. "
274
+ "Check that --root points at pytest's rootdir."
275
+ )
276
+ raise typer.Exit(code=1)
277
+
278
+ targets = summary.untested + summary.partial
279
+ capped = targets if limit == 0 else targets[:limit]
280
+
281
+ if prompt:
282
+ _render_prompts(capped, root=root_path)
283
+ else:
284
+ _render_check(summary, capped, limit=limit)
285
+
286
+ if fail_under is not None:
287
+ pct = 100.0 * len(summary.covered) / len(summary.functions)
288
+ if pct < fail_under:
289
+ err_console.print(
290
+ f"[red]FAIL[/red]: {pct:.1f}% of functions fully covered, "
291
+ f"below --fail-under {fail_under:.1f}%"
292
+ )
293
+ raise typer.Exit(code=3)
294
+
295
+
296
+ def _render_check(
297
+ summary: CheckSummary, targets: list[FunctionResult], *, limit: int
298
+ ) -> None:
299
+ """Summary counts + a table of what to work on next."""
300
+ total = len(summary.functions)
301
+ console.print()
302
+ console.print(
303
+ f"[bold]{len(summary.covered)}[/bold] covered "
304
+ f"[yellow]{len(summary.partial)}[/yellow] partial "
305
+ f"[red]{len(summary.untested)}[/red] untested"
306
+ f" ([bold]{total}[/bold] functions)"
307
+ )
308
+
309
+ if not targets:
310
+ console.print("\n[green]Everything is fully covered.[/green]")
311
+ return
312
+
313
+ table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
314
+ table.add_column("")
315
+ table.add_column("function")
316
+ table.add_column("file")
317
+ table.add_column("missing", justify="right")
318
+
319
+ for fn in targets:
320
+ marker = "[red]●[/red]" if fn.state == "untested" else "[yellow]◐[/yellow]"
321
+ missing = format_line_ranges(fn.missing_lines) or "—"
322
+ table.add_row(marker, fn.name, f"{fn.file_path}:{fn.start_line}", missing)
323
+
324
+ console.print()
325
+ console.print(table)
326
+
327
+ hidden = len(summary.untested) + len(summary.partial) - len(targets)
328
+ if hidden > 0:
329
+ console.print(f"\n[dim]… and {hidden} more (--limit 0 to show all)[/dim]")
330
+ console.print(
331
+ "\n[dim]`verifaied check --prompt` for ready-to-paste test prompts.[/dim]"
332
+ )
333
+
334
+
335
+ def _render_prompts(targets: list[FunctionResult], *, root: Path) -> None:
336
+ """Print one deterministic prompt per function, separated so the whole
337
+ block can be piped straight into an LLM."""
338
+ if not targets:
339
+ console.print("[green]Everything is fully covered — nothing to prompt.[/green]")
340
+ return
341
+
342
+ # Read each source file once even when it holds several targets.
343
+ sources: dict[str, str] = {}
344
+ for fn in targets:
345
+ if fn.file_path in sources:
346
+ continue
347
+ try:
348
+ sources[fn.file_path] = (root / fn.file_path).read_text(encoding="utf-8")
349
+ except (OSError, UnicodeDecodeError):
350
+ sources[fn.file_path] = ""
351
+
352
+ for i, fn in enumerate(targets):
353
+ if i:
354
+ console.print("\n" + "─" * 60 + "\n")
355
+ # print() not console.print() — Rich would interpret [brackets] in
356
+ # the prompt body as markup and eat them. The prompt is meant to be
357
+ # copy-pasted verbatim.
358
+ print(build_prompt(fn, sources[fn.file_path]))
359
+
360
+
179
361
  def _resolve_repo(*, repo_input: str | None, api_url: str, token: str) -> UUID:
180
362
  """Turn the user's --repo value (or nothing) into a concrete UUID.
181
363
 
@@ -0,0 +1,193 @@
1
+ """Deterministic, no-LLM test prompts — rendered locally from the AST.
2
+
3
+ The offline twin of the hosted ``get_baseline_prompt``. Same idea: a
4
+ coverage percentage tells you nothing to *do*, so turn the AST plus the
5
+ missed lines into a concrete instruction ("call ``apply_topup(...)``,
6
+ assert the returned balance, cover lines 940-946, assert the
7
+ ``ValueError`` path").
8
+
9
+ No network, no account, no LLM, no cost. This is what makes
10
+ ``verifaied check`` useful on its own rather than an advert for the
11
+ hosted product.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+
18
+ from verifaied.analyzer import FunctionResult
19
+
20
+
21
+ def _find_node(
22
+ tree: ast.AST, qualname: str, start_line: int
23
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None] | None:
24
+ """Locate one function by ``(qualified name, decorated start line)``.
25
+
26
+ Both halves are needed. The name alone is ambiguous — ``Foo.bar`` is
27
+ either a method on class ``Foo`` or a closure inside function ``foo``,
28
+ and you cannot tell which by splitting on the dot. The line alone
29
+ isn't unique either. Together they are.
30
+ """
31
+ found: tuple[ast.FunctionDef | ast.AsyncFunctionDef, str | None] | None = None
32
+
33
+ def decorated_start(node: ast.FunctionDef | ast.AsyncFunctionDef) -> int:
34
+ if not node.decorator_list:
35
+ return node.lineno
36
+ return min(d.lineno for d in node.decorator_list)
37
+
38
+ def walk(body: list[ast.stmt], prefix: str, class_name: str | None) -> None:
39
+ nonlocal found
40
+ for node in body:
41
+ if found is not None:
42
+ return
43
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
44
+ qn = f"{prefix}{node.name}" if prefix else node.name
45
+ if qn == qualname and decorated_start(node) == start_line:
46
+ found = (node, class_name)
47
+ return
48
+ # A closure inside a method is not itself a method.
49
+ walk(node.body, f"{qn}.", None)
50
+ elif isinstance(node, ast.ClassDef):
51
+ cn = f"{prefix}{node.name}." if prefix else f"{node.name}."
52
+ walk(node.body, cn, node.name)
53
+
54
+ walk(getattr(tree, "body", []), "", None)
55
+ return found
56
+
57
+
58
+ def _params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
59
+ """Parameter names minus ``self``/``cls`` — the caller doesn't pass
60
+ those, so listing them in "what to pass" would be wrong."""
61
+ a = node.args
62
+ out = [x.arg for x in [*a.posonlyargs, *a.args] if x.arg not in ("self", "cls")]
63
+ if a.vararg:
64
+ out.append(f"*{a.vararg.arg}")
65
+ out.extend(x.arg for x in a.kwonlyargs)
66
+ if a.kwarg:
67
+ out.append(f"**{a.kwarg.arg}")
68
+ return out
69
+
70
+
71
+ def _raises(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
72
+ """Exception type names raised directly in the body. Bare re-raises
73
+ carry no type and are skipped. De-duplicated, source order — so the
74
+ output is byte-stable across runs."""
75
+ names: list[str] = []
76
+ for child in ast.walk(node):
77
+ if not isinstance(child, ast.Raise) or child.exc is None:
78
+ continue
79
+ exc = child.exc
80
+ if isinstance(exc, ast.Call):
81
+ exc = exc.func
82
+ name = (
83
+ exc.id
84
+ if isinstance(exc, ast.Name)
85
+ else exc.attr
86
+ if isinstance(exc, ast.Attribute)
87
+ else None
88
+ )
89
+ if name and name not in names:
90
+ names.append(name)
91
+ return names
92
+
93
+
94
+ def _returns_value(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
95
+ """Whether it returns something worth asserting on. A bare ``return``
96
+ doesn't count."""
97
+ return any(
98
+ isinstance(c, ast.Return) and c.value is not None for c in ast.walk(node)
99
+ )
100
+
101
+
102
+ def format_line_ranges(lines: list[int]) -> str:
103
+ """``[1,2,3,7]`` → ``"1-3, 7"``. Eighty comma-separated integers is
104
+ not something a human reads; ranges are."""
105
+ if not lines:
106
+ return ""
107
+ ordered = sorted(set(lines))
108
+ parts: list[str] = []
109
+ start = prev = ordered[0]
110
+ for n in ordered[1:]:
111
+ if n == prev + 1:
112
+ prev = n
113
+ continue
114
+ parts.append(str(start) if start == prev else f"{start}-{prev}")
115
+ start = prev = n
116
+ parts.append(str(start) if start == prev else f"{start}-{prev}")
117
+ return ", ".join(parts)
118
+
119
+
120
+ def build_prompt(fn: FunctionResult, source: str) -> str:
121
+ """Render the templated prompt for one function.
122
+
123
+ Pure and deterministic: same function at the same coverage always
124
+ produces the same bytes, so it can be diffed and cached. Degrades
125
+ gracefully — if the source no longer parses or the function has moved,
126
+ it still emits the coverage-derived instructions without the
127
+ signature detail rather than failing.
128
+ """
129
+ lines: list[str] = []
130
+ verb = "Write" if fn.state == "untested" else "Extend"
131
+ lines.append(f"{verb} pytest tests for `{fn.name}` in `{fn.file_path}`.")
132
+ lines.append("")
133
+ if fn.state == "untested":
134
+ lines.append(
135
+ "It is currently **untested** — not one line of it runs under the suite."
136
+ )
137
+ else:
138
+ lines.append(
139
+ f"It is **partially covered** ({fn.line_coverage_pct:.0f}% of lines)."
140
+ )
141
+
142
+ node_info = None
143
+ try:
144
+ node_info = _find_node(ast.parse(source), fn.name, fn.start_line)
145
+ except SyntaxError:
146
+ node_info = None
147
+
148
+ lines.append("")
149
+ lines.append("What to do:")
150
+
151
+ if node_info is not None:
152
+ node, class_name = node_info
153
+ sig_args = ast.unparse(node.args)
154
+ ret = f" -> {ast.unparse(node.returns)}" if node.returns else ""
155
+ kw = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
156
+ lines.append(f"- Target: `{kw} {node.name}({sig_args}){ret}`")
157
+
158
+ if class_name:
159
+ lines.append(
160
+ f"- Construct a `{class_name}` and call `.{node.name}(...)` on it."
161
+ )
162
+ else:
163
+ lines.append(f"- Call `{node.name}({', '.join(_params(node))})`.")
164
+
165
+ if isinstance(node, ast.AsyncFunctionDef):
166
+ lines.append("- It's `async` — `await` it under `pytest.mark.asyncio`.")
167
+
168
+ if _returns_value(node):
169
+ lines.append(
170
+ "- Assert on the **return value** — compare it to the exact "
171
+ "expected value, not just that it is truthy."
172
+ )
173
+
174
+ raised = _raises(node)
175
+ if raised:
176
+ joined = ", ".join(f"`{r}`" for r in raised)
177
+ lines.append(
178
+ f"- It raises {joined} — cover that path with "
179
+ f"`pytest.raises({raised[0]})`."
180
+ )
181
+
182
+ missing = format_line_ranges(fn.missing_lines)
183
+ if missing:
184
+ lines.append(f"- Cover the lines that never execute: **{missing}**.")
185
+
186
+ partial = format_line_ranges(fn.partial_branch_lines)
187
+ if partial:
188
+ lines.append(
189
+ f"- Branches taken only one way, on lines **{partial}** — add the "
190
+ "other case."
191
+ )
192
+
193
+ return "\n".join(lines)
@@ -0,0 +1,316 @@
1
+ """Tests for `verifaied check` — the local, zero-account analysis.
2
+
3
+ The defining property of this command is that it touches NOTHING outside
4
+ the working directory: no token, no network, no account. `pytest-httpx`
5
+ would fail the test if any HTTP request escaped, so the absence of a mock
6
+ in these tests is itself the assertion.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from typer.testing import CliRunner
15
+
16
+ from verifaied.analyzer import analyze_coverage, extract_functions
17
+ from verifaied.cli import app
18
+ from verifaied.prompts import build_prompt, format_line_ranges
19
+
20
+ runner = CliRunner()
21
+
22
+ SOURCE = """\
23
+ def covered(a):
24
+ return a + 1
25
+
26
+
27
+ def untested(a, b):
28
+ if a > b:
29
+ raise ValueError("nope")
30
+ return a - b
31
+
32
+
33
+ class Thing:
34
+ def method(self, x):
35
+ return x * 2
36
+ """
37
+
38
+
39
+ def _write(tmp_path: Path, coverage: dict) -> Path:
40
+ (tmp_path / "pkg").mkdir(exist_ok=True)
41
+ (tmp_path / "pkg" / "m.py").write_text(SOURCE)
42
+ cov = tmp_path / "coverage.json"
43
+ cov.write_text(json.dumps(coverage))
44
+ return cov
45
+
46
+
47
+ # `covered` = lines 1-2; `untested` = 5-8; `Thing.method` = 12-13.
48
+ FULL = {
49
+ "files": {
50
+ "pkg/m.py": {
51
+ "executed_lines": [1, 2, 5, 12, 13],
52
+ "missing_lines": [6, 7, 8],
53
+ "missing_branches": [[6, 8]],
54
+ }
55
+ }
56
+ }
57
+
58
+
59
+ def test_analyze_classifies_each_function(tmp_path: Path):
60
+ _write(tmp_path, FULL)
61
+ summary = analyze_coverage(FULL, root=tmp_path)
62
+
63
+ by_name = {f.name: f for f in summary.functions}
64
+ assert by_name["covered"].state == "covered"
65
+ assert by_name["Thing.method"].state == "covered"
66
+
67
+ untested = by_name["untested"]
68
+ # The `def` line executes at import time, so this is partial, not
69
+ # untested — and its missing lines are exactly the body.
70
+ assert untested.state == "partial"
71
+ assert untested.missing_lines == [6, 7, 8]
72
+ assert untested.partial_branch_lines == [6]
73
+
74
+
75
+ def test_analyze_reports_a_function_with_no_executed_lines_as_untested(
76
+ tmp_path: Path,
77
+ ):
78
+ coverage = {
79
+ "files": {
80
+ "pkg/m.py": {
81
+ "executed_lines": [1, 2],
82
+ "missing_lines": [5, 6, 7, 8, 12, 13],
83
+ }
84
+ }
85
+ }
86
+ _write(tmp_path, coverage)
87
+ summary = analyze_coverage(coverage, root=tmp_path)
88
+ by_name = {f.name: f for f in summary.functions}
89
+
90
+ assert by_name["untested"].state == "untested"
91
+ assert by_name["untested"].line_coverage_pct == 0.0
92
+ assert by_name["Thing.method"].state == "untested"
93
+
94
+
95
+ def test_analyze_records_unreadable_files_instead_of_silently_skipping(
96
+ tmp_path: Path,
97
+ ):
98
+ """A wrong --root must surface loudly. Silently reporting "0 untested
99
+ functions" for a codebase we never read would be a confidently wrong
100
+ answer — the worst possible failure for this tool."""
101
+ coverage = {"files": {"nope/gone.py": {"executed_lines": [1]}}}
102
+ summary = analyze_coverage(coverage, root=tmp_path)
103
+
104
+ assert summary.files_missing == ["nope/gone.py"]
105
+ assert summary.functions == []
106
+
107
+
108
+ def test_extract_functions_qualifies_methods_and_closures():
109
+ src = "class A:\n def m(self):\n def inner():\n pass\n"
110
+ names = [f.name for f in extract_functions(src)]
111
+ assert names == ["A.m", "A.m.inner"]
112
+
113
+
114
+ def test_extract_functions_on_broken_source_returns_empty():
115
+ assert extract_functions("def (:\n") == []
116
+
117
+
118
+ def test_format_line_ranges_collapses_runs():
119
+ assert format_line_ranges([1, 2, 3, 7]) == "1-3, 7"
120
+ assert format_line_ranges([]) == ""
121
+
122
+
123
+ def test_build_prompt_names_the_call_lines_and_raise(tmp_path: Path):
124
+ _write(tmp_path, FULL)
125
+ summary = analyze_coverage(FULL, root=tmp_path)
126
+ fn = next(f for f in summary.functions if f.name == "untested")
127
+
128
+ prompt = build_prompt(fn, SOURCE)
129
+
130
+ assert "`untested` in `pkg/m.py`" in prompt
131
+ assert "Call `untested(a, b)`" in prompt
132
+ assert "**6-8**" in prompt
133
+ assert "pytest.raises(ValueError)" in prompt
134
+ assert "Assert on the **return value**" in prompt
135
+
136
+
137
+ def test_build_prompt_for_a_method_says_construct_the_class(tmp_path: Path):
138
+ coverage = {"files": {"pkg/m.py": {"executed_lines": [12], "missing_lines": [13]}}}
139
+ _write(tmp_path, coverage)
140
+ summary = analyze_coverage(coverage, root=tmp_path)
141
+ fn = next(f for f in summary.functions if f.name == "Thing.method")
142
+
143
+ prompt = build_prompt(fn, SOURCE)
144
+
145
+ assert "Construct a `Thing` and call `.method(...)` on it." in prompt
146
+
147
+
148
+ def test_build_prompt_degrades_when_source_does_not_parse(tmp_path: Path):
149
+ _write(tmp_path, FULL)
150
+ summary = analyze_coverage(FULL, root=tmp_path)
151
+ fn = next(f for f in summary.functions if f.name == "untested")
152
+
153
+ prompt = build_prompt(fn, "def broken(:\n")
154
+
155
+ # Still actionable on the coverage facts even with no AST.
156
+ assert "**6-8**" in prompt
157
+ assert "Target:" not in prompt
158
+
159
+
160
+ # ---- the command ----
161
+
162
+
163
+ def test_check_runs_with_no_token_and_no_network(tmp_path: Path):
164
+ """The whole point: no VERIFAIED_API_TOKEN, no API URL, no HTTP mock.
165
+ If the command tried to reach the network, pytest-httpx would raise."""
166
+ _write(tmp_path, FULL)
167
+
168
+ result = runner.invoke(
169
+ app,
170
+ [
171
+ "check",
172
+ "--coverage",
173
+ str(tmp_path / "coverage.json"),
174
+ "--root",
175
+ str(tmp_path),
176
+ ],
177
+ )
178
+
179
+ assert result.exit_code == 0, result.output
180
+ assert "covered" in result.output
181
+ assert "untested" in result.output
182
+
183
+
184
+ def test_check_prompt_mode_prints_a_pasteable_prompt(tmp_path: Path):
185
+ _write(tmp_path, FULL)
186
+
187
+ result = runner.invoke(
188
+ app,
189
+ [
190
+ "check",
191
+ "--coverage",
192
+ str(tmp_path / "coverage.json"),
193
+ "--root",
194
+ str(tmp_path),
195
+ "--prompt",
196
+ ],
197
+ )
198
+
199
+ assert result.exit_code == 0, result.output
200
+ assert "pytest.raises(ValueError)" in result.output
201
+
202
+
203
+ def test_check_fail_under_exits_3_when_below_threshold(tmp_path: Path):
204
+ """The CI gate. Exit 3 is distinct from 1 (usage) and 2 (API) so a
205
+ pipeline can tell "coverage too low" from "the tool broke"."""
206
+ _write(tmp_path, FULL)
207
+
208
+ result = runner.invoke(
209
+ app,
210
+ [
211
+ "check",
212
+ "--coverage",
213
+ str(tmp_path / "coverage.json"),
214
+ "--root",
215
+ str(tmp_path),
216
+ "--fail-under",
217
+ "100",
218
+ ],
219
+ )
220
+
221
+ assert result.exit_code == 3
222
+ assert "FAIL" in result.output
223
+
224
+
225
+ def test_check_fail_under_passes_when_at_threshold(tmp_path: Path):
226
+ coverage = {
227
+ "files": {
228
+ "pkg/m.py": {
229
+ "executed_lines": [1, 2, 5, 6, 7, 8, 12, 13],
230
+ "missing_lines": [],
231
+ }
232
+ }
233
+ }
234
+ _write(tmp_path, coverage)
235
+
236
+ result = runner.invoke(
237
+ app,
238
+ [
239
+ "check",
240
+ "--coverage",
241
+ str(tmp_path / "coverage.json"),
242
+ "--root",
243
+ str(tmp_path),
244
+ "--fail-under",
245
+ "100",
246
+ ],
247
+ )
248
+
249
+ assert result.exit_code == 0, result.output
250
+ assert "Everything is fully covered" in result.output
251
+
252
+
253
+ def test_check_errors_when_coverage_file_is_missing(tmp_path: Path):
254
+ result = runner.invoke(app, ["check", "--coverage", str(tmp_path / "nope.json")])
255
+ assert result.exit_code == 1
256
+ assert "cannot read" in result.output
257
+
258
+
259
+ def test_check_errors_on_invalid_json(tmp_path: Path):
260
+ bad = tmp_path / "coverage.json"
261
+ bad.write_text("{not json")
262
+ result = runner.invoke(app, ["check", "--coverage", str(bad)])
263
+ assert result.exit_code == 1
264
+ assert "not valid JSON" in result.output
265
+
266
+
267
+ def test_check_errors_when_no_functions_matched(tmp_path: Path):
268
+ """An empty result almost always means --root is wrong. Exit non-zero
269
+ and say so rather than printing a cheerful, meaningless zero."""
270
+ cov = tmp_path / "coverage.json"
271
+ cov.write_text(json.dumps({"files": {}}))
272
+
273
+ result = runner.invoke(app, ["check", "--coverage", str(cov)])
274
+
275
+ assert result.exit_code == 1
276
+ assert "No functions found" in result.output
277
+
278
+
279
+ def test_check_warns_when_a_source_file_cannot_be_read(tmp_path: Path):
280
+ cov = tmp_path / "coverage.json"
281
+ cov.write_text(json.dumps({"files": {"nope/gone.py": {"executed_lines": [1]}}}))
282
+
283
+ result = runner.invoke(
284
+ app, ["check", "--coverage", str(cov), "--root", str(tmp_path)]
285
+ )
286
+
287
+ assert "could not read" in result.output
288
+ assert "--root" in result.output
289
+
290
+
291
+ def test_check_limit_caps_the_listing(tmp_path: Path):
292
+ coverage = {
293
+ "files": {
294
+ "pkg/m.py": {
295
+ "executed_lines": [1],
296
+ "missing_lines": [2, 5, 6, 7, 8, 12, 13],
297
+ }
298
+ }
299
+ }
300
+ _write(tmp_path, coverage)
301
+
302
+ result = runner.invoke(
303
+ app,
304
+ [
305
+ "check",
306
+ "--coverage",
307
+ str(tmp_path / "coverage.json"),
308
+ "--root",
309
+ str(tmp_path),
310
+ "--limit",
311
+ "1",
312
+ ],
313
+ )
314
+
315
+ assert result.exit_code == 0, result.output
316
+ assert "and 2 more" in result.output
@@ -0,0 +1,91 @@
1
+ """Parity guard: the CLI must describe a function the same way the backend does.
2
+
3
+ The CLI reimplements the deterministic prompt (`verifaied/prompts.py`) rather
4
+ than importing the backend — that independence is the whole point of the
5
+ zero-account wedge. The cost is that two implementations of one product promise
6
+ can drift silently, and a user who gets one instruction from `verifaied check`
7
+ and a different one from the web UI for the same function has been told the
8
+ product is inconsistent.
9
+
10
+ This reads the SAME fixture as
11
+ `backend/tests/logic/test_deterministic_prompt_parity.py` and asserts the same
12
+ substantive claims: the call line, the uncovered-line ranges, the
13
+ `pytest.raises` target, the class-construction instruction. Change one
14
+ generator and this fails until you change the other.
15
+
16
+ Skips (rather than fails) if the backend tree isn't present — the CLI is meant
17
+ to be extractable into its own repo, and this guard must not be what blocks that.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ from verifaied.analyzer import analyze_coverage
27
+ from verifaied.prompts import build_prompt
28
+
29
+ FIXTURE = (
30
+ Path(__file__).resolve().parents[2]
31
+ / "backend"
32
+ / "tests"
33
+ / "fixtures"
34
+ / "parity_sample.py"
35
+ )
36
+
37
+ pytestmark = pytest.mark.skipif(
38
+ not FIXTURE.exists(),
39
+ reason="backend tree not present — CLI is standalone here",
40
+ )
41
+
42
+
43
+ def _summary(tmp_path: Path, executed: list[int], missing: list[int], partial=None):
44
+ """Run the CLI analyzer over the shared fixture with synthetic coverage."""
45
+ (tmp_path / "billing.py").write_text(FIXTURE.read_text())
46
+ coverage = {
47
+ "files": {
48
+ "billing.py": {
49
+ "executed_lines": executed,
50
+ "missing_lines": missing,
51
+ "missing_branches": [[p, p + 2] for p in (partial or [])],
52
+ }
53
+ }
54
+ }
55
+ return analyze_coverage(coverage, root=tmp_path)
56
+
57
+
58
+ def _line_of(marker: str) -> int:
59
+ for i, line in enumerate(FIXTURE.read_text().splitlines(), 1):
60
+ if marker in line:
61
+ return i
62
+ raise AssertionError(f"{marker!r} not in the parity fixture")
63
+
64
+
65
+ def test_cli_prompt_for_the_parity_fixture_function(tmp_path: Path):
66
+ """Mirrors ``test_backend_prompt_for_the_parity_fixture_function``."""
67
+ def_line = _line_of("def apply_topup")
68
+ body = [def_line + 1, def_line + 2, def_line + 3, def_line + 4]
69
+ summary = _summary(
70
+ tmp_path, executed=[def_line], missing=body, partial=[def_line + 1]
71
+ )
72
+ fn = next(f for f in summary.functions if f.name == "apply_topup")
73
+
74
+ prompt = build_prompt(fn, FIXTURE.read_text())
75
+
76
+ assert "`apply_topup` in `billing.py`" in prompt
77
+ assert "Call `apply_topup(user_id, amount)`" in prompt
78
+ assert "pytest.raises(ValueError)" in prompt
79
+ assert "Assert on the **return value**" in prompt
80
+ assert f"**{def_line + 1}-{def_line + 4}**" in prompt
81
+
82
+
83
+ def test_cli_prompt_for_the_parity_fixture_method(tmp_path: Path):
84
+ """Mirrors ``test_backend_prompt_for_the_parity_fixture_method``."""
85
+ def_line = _line_of("def credit")
86
+ summary = _summary(tmp_path, executed=[def_line], missing=[def_line + 1])
87
+ fn = next(f for f in summary.functions if f.name == "Ledger.credit")
88
+
89
+ prompt = build_prompt(fn, FIXTURE.read_text())
90
+
91
+ assert "Construct a `Ledger` and call `.credit(...)` on it." in prompt
File without changes