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.
- verifaied-0.1.0.dev29/LICENSE +21 -0
- verifaied-0.1.0.dev28/README.md → verifaied-0.1.0.dev29/PKG-INFO +102 -9
- verifaied-0.1.0.dev28/PKG-INFO → verifaied-0.1.0.dev29/README.md +62 -27
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/pyproject.toml +5 -4
- verifaied-0.1.0.dev29/src/verifaied/analyzer.py +208 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/cli.py +183 -1
- verifaied-0.1.0.dev29/src/verifaied/prompts.py +193 -0
- verifaied-0.1.0.dev29/tests/test_check.py +316 -0
- verifaied-0.1.0.dev29/tests/test_prompt_parity.py +91 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/.gitignore +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/__init__.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/__main__.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/client.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/config.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/src/verifaied/uploader.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/__init__.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_cli.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_client.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_config.py +0 -0
- {verifaied-0.1.0.dev28 → verifaied-0.1.0.dev29}/tests/test_uploader.py +0 -0
- {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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
###
|
|
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.
|
|
4
|
-
description = "
|
|
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 = {
|
|
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="
|
|
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
|
|
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
|