nullcov 0.1.0__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.
- nullcov-0.1.0/.github/workflows/ci.yml +61 -0
- nullcov-0.1.0/.gitignore +16 -0
- nullcov-0.1.0/LICENSE +21 -0
- nullcov-0.1.0/PKG-INFO +270 -0
- nullcov-0.1.0/README.md +226 -0
- nullcov-0.1.0/examples/refund_agent/README.md +82 -0
- nullcov-0.1.0/examples/refund_agent/agent.py +137 -0
- nullcov-0.1.0/examples/refund_agent/incidents.json +38 -0
- nullcov-0.1.0/examples/refund_agent/nullcov.toml +12 -0
- nullcov-0.1.0/examples/refund_agent/test_refund_agent.py +112 -0
- nullcov-0.1.0/pyproject.toml +42 -0
- nullcov-0.1.0/src/nullcov/__init__.py +47 -0
- nullcov-0.1.0/src/nullcov/alignment.py +194 -0
- nullcov-0.1.0/src/nullcov/coverage.py +200 -0
- nullcov-0.1.0/src/nullcov/langgraph/__init__.py +26 -0
- nullcov-0.1.0/src/nullcov/langgraph/topology.py +208 -0
- nullcov-0.1.0/src/nullcov/plugin.py +274 -0
- nullcov-0.1.0/src/nullcov/puremode.py +148 -0
- nullcov-0.1.0/src/nullcov/report.py +137 -0
- nullcov-0.1.0/src/nullcov/taxonomy.py +191 -0
- nullcov-0.1.0/tests/test_alignment.py +115 -0
- nullcov-0.1.0/tests/test_coverage.py +113 -0
- nullcov-0.1.0/tests/test_langgraph_topology.py +149 -0
- nullcov-0.1.0/tests/test_plugin.py +351 -0
- nullcov-0.1.0/tests/test_puremode.py +103 -0
- nullcov-0.1.0/tests/test_taxonomy.py +123 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
fail-fast: false
|
|
13
|
+
matrix:
|
|
14
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
|
|
23
|
+
- name: Install
|
|
24
|
+
run: pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
# nullcov gates its own suite. An unexercised declared layer fails the
|
|
27
|
+
# build, which is the behaviour the library exists to produce.
|
|
28
|
+
- name: Test
|
|
29
|
+
run: pytest --nullcov-strict --nullcov-json nullcov-report.json
|
|
30
|
+
|
|
31
|
+
- name: Upload coverage report
|
|
32
|
+
if: always()
|
|
33
|
+
uses: actions/upload-artifact@v4
|
|
34
|
+
with:
|
|
35
|
+
name: nullcov-report-py${{ matrix.python-version }}
|
|
36
|
+
path: nullcov-report.json
|
|
37
|
+
if-no-files-found: ignore
|
|
38
|
+
|
|
39
|
+
example:
|
|
40
|
+
runs-on: ubuntu-latest
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
|
|
44
|
+
- uses: actions/setup-python@v5
|
|
45
|
+
with:
|
|
46
|
+
python-version: "3.12"
|
|
47
|
+
|
|
48
|
+
- name: Install
|
|
49
|
+
run: pip install -e ".[dev]"
|
|
50
|
+
|
|
51
|
+
# The example is deliberately incomplete: it has an untested escalation
|
|
52
|
+
# layer and a real boundary bug behind it. Strict mode is expected to
|
|
53
|
+
# fail here, so a passing run would mean the gate stopped working.
|
|
54
|
+
- name: Example demonstrates the gate
|
|
55
|
+
working-directory: examples/refund_agent
|
|
56
|
+
run: |
|
|
57
|
+
if pytest --nullcov-strict --nullcov-incidents incidents.json; then
|
|
58
|
+
echo "::error::The example was expected to fail the nullcov gate."
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
echo "Gate correctly rejected the run."
|
nullcov-0.1.0/.gitignore
ADDED
nullcov-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shay
|
|
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.
|
nullcov-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nullcov
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Honest coverage for agent scaffolds: unexercised layers report unknown, never 100%.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Shay
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Keywords: agents,coverage,evaluation,llm,pytest,testing
|
|
28
|
+
Classifier: Development Status :: 3 - Alpha
|
|
29
|
+
Classifier: Framework :: Pytest
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
+
Classifier: Topic :: Software Development :: Testing
|
|
36
|
+
Requires-Python: >=3.11
|
|
37
|
+
Requires-Dist: pytest>=8.0
|
|
38
|
+
Provides-Extra: dev
|
|
39
|
+
Requires-Dist: langgraph>=0.2; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
41
|
+
Provides-Extra: langgraph
|
|
42
|
+
Requires-Dist: langgraph>=0.2; extra == 'langgraph'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# nullcov
|
|
46
|
+
|
|
47
|
+
**Your agent test suite reports 94%. This tells you which part of that number is a lie.**
|
|
48
|
+
|
|
49
|
+
Conventional coverage divides passing cases by total cases. A layer of your agent
|
|
50
|
+
that nobody wrote a case for contributes nothing to either side of that division —
|
|
51
|
+
so it vanishes from the summary entirely. The suite looks strongest exactly where
|
|
52
|
+
it is blindest.
|
|
53
|
+
|
|
54
|
+
nullcov reports those layers as `unknown`. Never `100%`, never silently dropped.
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
============================= nullcov ==============================
|
|
58
|
+
[ok ] routing 3 slice(s), 41 case(s)
|
|
59
|
+
[ok ] schema 2 slice(s), 18 case(s)
|
|
60
|
+
[????] escalation no cases -- coverage unknown, not 100%
|
|
61
|
+
[FAIL] safety 1 slice(s), 6 case(s), 1 failing
|
|
62
|
+
[----] reasoning generative -- not scored, by design
|
|
63
|
+
|
|
64
|
+
65 case(s) ran, 1 failing (98.5% of executed cases passed).
|
|
65
|
+
1 declared layer(s) have no coverage. The number above says nothing about them.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Why this exists
|
|
69
|
+
|
|
70
|
+
In a June 2026 survey of 157 enterprises (VentureBeat VB Pulse), **half had shipped
|
|
71
|
+
an agent that passed their own evaluations and still failed in front of a customer.**
|
|
72
|
+
Only 5% fully trusted the evaluations they were shipping on. When asked what was
|
|
73
|
+
wrong with those evaluations, the most common answer was not "not enough coverage" —
|
|
74
|
+
it was **poor alignment with real-world outcomes**.
|
|
75
|
+
|
|
76
|
+
The same pattern shows up in the research. A production agent team
|
|
77
|
+
([arXiv:2606.11686](https://arxiv.org/abs/2606.11686)) documented a confirmation-gate
|
|
78
|
+
bug that caused roughly half of confirmed orders to silently not execute — while
|
|
79
|
+
their aggregate quality metric barely moved. Under controlled fault injection,
|
|
80
|
+
end-to-end pass rates dropped 1.7–5.9% while layer-isolated slices dropped 25–91%.
|
|
81
|
+
|
|
82
|
+
One aggregate number hides a broken layer. That is the problem nullcov is built for.
|
|
83
|
+
|
|
84
|
+
## Install
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install nullcov
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Use
|
|
91
|
+
|
|
92
|
+
**1. Declare your layers.** In `pyproject.toml` (or a `nullcov.toml`):
|
|
93
|
+
|
|
94
|
+
```toml
|
|
95
|
+
[tool.nullcov]
|
|
96
|
+
layers = ["routing", "schema", "escalation", "safety"]
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
This declaration is the point. Without a statement of what *should* exist, an
|
|
100
|
+
untested layer is indistinguishable from a layer that does not exist, and no tool
|
|
101
|
+
can tell you the difference. Keeping it in version control means deleting a layer
|
|
102
|
+
to make a report go green is a diff a reviewer sees — not a number that quietly
|
|
103
|
+
improves.
|
|
104
|
+
|
|
105
|
+
Layers that are genuinely generative are declared as such and are never scored:
|
|
106
|
+
|
|
107
|
+
```toml
|
|
108
|
+
[tool.nullcov.layers]
|
|
109
|
+
routing = { description = "picks the next node" }
|
|
110
|
+
reasoning = { deterministic = false }
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**2. Attach tests to layers.**
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
import nullcov
|
|
117
|
+
|
|
118
|
+
@nullcov.case(layer="escalation", slice_id="high_value_requires_human")
|
|
119
|
+
def test_high_value_transactions_go_to_a_human():
|
|
120
|
+
with nullcov.pure_mode():
|
|
121
|
+
result = run_scaffold(graph, {"amount": 1200})
|
|
122
|
+
assert result.next_node == "human_review"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**3. Run.**
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pytest --nullcov
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Add `--nullcov-strict` to make it a CI gate. An unexercised declared layer fails
|
|
132
|
+
the run, because a suite that cannot speak to a layer has not verified it.
|
|
133
|
+
|
|
134
|
+
## Pure mode
|
|
135
|
+
|
|
136
|
+
`pure_mode()` blocks outbound network connections at the socket layer for the
|
|
137
|
+
duration of a block. It is not a stub you have to remember to install — it holds
|
|
138
|
+
regardless of which SDK the agent reaches for, and raises `PureModeViolation`
|
|
139
|
+
naming the host if anything tries to escape.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
with nullcov.pure_mode() as report:
|
|
143
|
+
result = graph.invoke(state)
|
|
144
|
+
assert report.clean
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A test that passes under pure mode **provably** made zero model calls. Not by
|
|
148
|
+
convention, not because someone remembered to patch the client — because nothing
|
|
149
|
+
could reach the network. That makes it deterministic, free, and fast enough to run
|
|
150
|
+
on every commit. Loopback stays open, so local fixtures and recorded-cassette
|
|
151
|
+
servers still work.
|
|
152
|
+
|
|
153
|
+
## Reality alignment
|
|
154
|
+
|
|
155
|
+
Coverage tells you what you looked at. It cannot tell you whether you looked at the
|
|
156
|
+
right thing. Feed nullcov your production incidents and it separates two failures
|
|
157
|
+
that look identical on a dashboard:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
pytest --nullcov --nullcov-incidents incidents.json
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
reality alignment
|
|
165
|
+
29 attributed incident(s).
|
|
166
|
+
|
|
167
|
+
Blind spots -- production fails here and no case looks:
|
|
168
|
+
escalation: 11 incidents (38% of all attributed) and no coverage at all.
|
|
169
|
+
The suite cannot speak to this layer.
|
|
170
|
+
|
|
171
|
+
Misaligned -- cases pass here and production fails anyway:
|
|
172
|
+
routing: 9 incidents (31% of all attributed) despite 41 passing cases.
|
|
173
|
+
The cases pass and production still fails -- distrust these cases before
|
|
174
|
+
adding more.
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
**Blind spot**: no coverage. Write cases.
|
|
178
|
+
|
|
179
|
+
**Misalignment**: full, passing coverage — and production fails there anyway. More
|
|
180
|
+
cases of the same shape make this worse, not better. The existing cases are the
|
|
181
|
+
thing to distrust. This is the finding that explains a green pipeline and a
|
|
182
|
+
customer-visible outage on the same afternoon, and it is the reason this tool is
|
|
183
|
+
not just another eval runner.
|
|
184
|
+
|
|
185
|
+
`incidents.json` is a list of records — an export from your incident tracker, or a
|
|
186
|
+
handful of postmortem entries:
|
|
187
|
+
|
|
188
|
+
```json
|
|
189
|
+
[
|
|
190
|
+
{"incident_id": "INC-4471", "layer": "escalation", "severity": "sev2",
|
|
191
|
+
"summary": "refund over threshold auto-approved"}
|
|
192
|
+
]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Attribution is the one input nullcov cannot derive for itself, and the quality of
|
|
196
|
+
everything above depends on it.
|
|
197
|
+
|
|
198
|
+
## Structural coverage for LangGraph
|
|
199
|
+
|
|
200
|
+
Everything above depends on a human typing a truthful layer name. Nothing stops
|
|
201
|
+
that from drifting: add a node to the graph, forget to tag a test for it, and
|
|
202
|
+
it is invisible everywhere -- not `[????]`, not undeclared, just absent.
|
|
203
|
+
|
|
204
|
+
`nullcov.langgraph` removes the human from that specific step. It reads the
|
|
205
|
+
topology straight off your **compiled** graph object -- the actual nodes and
|
|
206
|
+
conditional branches LangGraph built, not a file you maintain by hand -- and
|
|
207
|
+
tells you which of them any test run ever actually traversed.
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
from nullcov.langgraph import CoverageAccumulator, GraphTopology
|
|
211
|
+
|
|
212
|
+
graph = build_graph() # your compiled LangGraph graph
|
|
213
|
+
accumulator = CoverageAccumulator(topology=GraphTopology.extract(graph))
|
|
214
|
+
|
|
215
|
+
# in your tests, run the graph through the accumulator instead of graph.invoke()
|
|
216
|
+
accumulator.record_run(graph, {"amount": 50, "reason": "wrong size"})
|
|
217
|
+
accumulator.record_run(graph, {"amount": 600, "reason": "wrong size"})
|
|
218
|
+
|
|
219
|
+
coverage = accumulator.coverage()
|
|
220
|
+
print(coverage.branch_rate()) # 0.5
|
|
221
|
+
print(coverage.untaken_branches) # {Branch("validate", "reject")}
|
|
222
|
+
print(coverage.unvisited_nodes) # {"reject"}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Run that against the example agent in this repo and it finds a real gap the
|
|
226
|
+
manual layer system misses entirely: `validate()` has a passing unit test for
|
|
227
|
+
an invalid payload, and `reject` is a fully-implemented node — but no test ever
|
|
228
|
+
runs the **compiled graph** end-to-end with a bad input, so the
|
|
229
|
+
`validate → reject` transition has never once fired in the test suite. A unit
|
|
230
|
+
test on the routing function proves the function is correct. It proves nothing
|
|
231
|
+
about whether the graph, as wired, ever takes that path.
|
|
232
|
+
|
|
233
|
+
We checked before building this: neither AWS Bedrock AgentCore Evaluations nor
|
|
234
|
+
`fasteval-langgraph` (Intuit) compute this. Both score individual test cases
|
|
235
|
+
against a trajectory the developer writes by hand — a real capability, but a
|
|
236
|
+
different one. Neither aggregates, across a whole suite, what fraction of the
|
|
237
|
+
graph's actual branch topology was ever exercised. `nullcov.langgraph` is
|
|
238
|
+
narrower than either of them and answers a question they don't ask.
|
|
239
|
+
|
|
240
|
+
Requires `langgraph` (`pip install nullcov[langgraph]`). This module is
|
|
241
|
+
LangGraph-specific by design — the ground truth it reads only exists because
|
|
242
|
+
`CompiledGraph.get_graph()` exposes it. CrewAI and AutoGen would each need
|
|
243
|
+
their own adapter reading their own internal representation; none exists yet.
|
|
244
|
+
|
|
245
|
+
## What this does not do
|
|
246
|
+
|
|
247
|
+
- **It does not score generative output.** LLM-as-judge is not statistically
|
|
248
|
+
calibrated — position, verbosity and self-enhancement bias are documented, and
|
|
249
|
+
calibrating judge confidence needs logprobs most providers do not expose. nullcov
|
|
250
|
+
declines to put a number on free-form reasoning rather than pretending.
|
|
251
|
+
- **It does not prove your agent is correct.** It proves which contracts held and,
|
|
252
|
+
more usefully, which ones nobody checked.
|
|
253
|
+
- **It does not replace your eval suite.** It tells you where that suite is blind.
|
|
254
|
+
|
|
255
|
+
## Prior art
|
|
256
|
+
|
|
257
|
+
nullcov leans on published work rather than inventing around it. The
|
|
258
|
+
scaffold/generative split and the coverage-honesty criterion come from
|
|
259
|
+
[arXiv:2606.11686](https://arxiv.org/abs/2606.11686). Deterministic record-and-replay
|
|
260
|
+
of agent I/O is solved by [agrepl](https://arxiv.org/abs/2607.16200) and
|
|
261
|
+
`vcr-langchain`; multi-agent tracing by
|
|
262
|
+
[MAESTRO](https://github.com/sands-lab/maestro). Signed action receipts exist in
|
|
263
|
+
`nobulex` and `protect-mcp`. If you need those, use those.
|
|
264
|
+
|
|
265
|
+
What nullcov adds is the honesty constraint on the aggregate, and the alignment
|
|
266
|
+
pass against production.
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT
|
nullcov-0.1.0/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# nullcov
|
|
2
|
+
|
|
3
|
+
**Your agent test suite reports 94%. This tells you which part of that number is a lie.**
|
|
4
|
+
|
|
5
|
+
Conventional coverage divides passing cases by total cases. A layer of your agent
|
|
6
|
+
that nobody wrote a case for contributes nothing to either side of that division —
|
|
7
|
+
so it vanishes from the summary entirely. The suite looks strongest exactly where
|
|
8
|
+
it is blindest.
|
|
9
|
+
|
|
10
|
+
nullcov reports those layers as `unknown`. Never `100%`, never silently dropped.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
============================= nullcov ==============================
|
|
14
|
+
[ok ] routing 3 slice(s), 41 case(s)
|
|
15
|
+
[ok ] schema 2 slice(s), 18 case(s)
|
|
16
|
+
[????] escalation no cases -- coverage unknown, not 100%
|
|
17
|
+
[FAIL] safety 1 slice(s), 6 case(s), 1 failing
|
|
18
|
+
[----] reasoning generative -- not scored, by design
|
|
19
|
+
|
|
20
|
+
65 case(s) ran, 1 failing (98.5% of executed cases passed).
|
|
21
|
+
1 declared layer(s) have no coverage. The number above says nothing about them.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Why this exists
|
|
25
|
+
|
|
26
|
+
In a June 2026 survey of 157 enterprises (VentureBeat VB Pulse), **half had shipped
|
|
27
|
+
an agent that passed their own evaluations and still failed in front of a customer.**
|
|
28
|
+
Only 5% fully trusted the evaluations they were shipping on. When asked what was
|
|
29
|
+
wrong with those evaluations, the most common answer was not "not enough coverage" —
|
|
30
|
+
it was **poor alignment with real-world outcomes**.
|
|
31
|
+
|
|
32
|
+
The same pattern shows up in the research. A production agent team
|
|
33
|
+
([arXiv:2606.11686](https://arxiv.org/abs/2606.11686)) documented a confirmation-gate
|
|
34
|
+
bug that caused roughly half of confirmed orders to silently not execute — while
|
|
35
|
+
their aggregate quality metric barely moved. Under controlled fault injection,
|
|
36
|
+
end-to-end pass rates dropped 1.7–5.9% while layer-isolated slices dropped 25–91%.
|
|
37
|
+
|
|
38
|
+
One aggregate number hides a broken layer. That is the problem nullcov is built for.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install nullcov
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
**1. Declare your layers.** In `pyproject.toml` (or a `nullcov.toml`):
|
|
49
|
+
|
|
50
|
+
```toml
|
|
51
|
+
[tool.nullcov]
|
|
52
|
+
layers = ["routing", "schema", "escalation", "safety"]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This declaration is the point. Without a statement of what *should* exist, an
|
|
56
|
+
untested layer is indistinguishable from a layer that does not exist, and no tool
|
|
57
|
+
can tell you the difference. Keeping it in version control means deleting a layer
|
|
58
|
+
to make a report go green is a diff a reviewer sees — not a number that quietly
|
|
59
|
+
improves.
|
|
60
|
+
|
|
61
|
+
Layers that are genuinely generative are declared as such and are never scored:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[tool.nullcov.layers]
|
|
65
|
+
routing = { description = "picks the next node" }
|
|
66
|
+
reasoning = { deterministic = false }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**2. Attach tests to layers.**
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import nullcov
|
|
73
|
+
|
|
74
|
+
@nullcov.case(layer="escalation", slice_id="high_value_requires_human")
|
|
75
|
+
def test_high_value_transactions_go_to_a_human():
|
|
76
|
+
with nullcov.pure_mode():
|
|
77
|
+
result = run_scaffold(graph, {"amount": 1200})
|
|
78
|
+
assert result.next_node == "human_review"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**3. Run.**
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pytest --nullcov
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Add `--nullcov-strict` to make it a CI gate. An unexercised declared layer fails
|
|
88
|
+
the run, because a suite that cannot speak to a layer has not verified it.
|
|
89
|
+
|
|
90
|
+
## Pure mode
|
|
91
|
+
|
|
92
|
+
`pure_mode()` blocks outbound network connections at the socket layer for the
|
|
93
|
+
duration of a block. It is not a stub you have to remember to install — it holds
|
|
94
|
+
regardless of which SDK the agent reaches for, and raises `PureModeViolation`
|
|
95
|
+
naming the host if anything tries to escape.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
with nullcov.pure_mode() as report:
|
|
99
|
+
result = graph.invoke(state)
|
|
100
|
+
assert report.clean
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A test that passes under pure mode **provably** made zero model calls. Not by
|
|
104
|
+
convention, not because someone remembered to patch the client — because nothing
|
|
105
|
+
could reach the network. That makes it deterministic, free, and fast enough to run
|
|
106
|
+
on every commit. Loopback stays open, so local fixtures and recorded-cassette
|
|
107
|
+
servers still work.
|
|
108
|
+
|
|
109
|
+
## Reality alignment
|
|
110
|
+
|
|
111
|
+
Coverage tells you what you looked at. It cannot tell you whether you looked at the
|
|
112
|
+
right thing. Feed nullcov your production incidents and it separates two failures
|
|
113
|
+
that look identical on a dashboard:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pytest --nullcov --nullcov-incidents incidents.json
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
reality alignment
|
|
121
|
+
29 attributed incident(s).
|
|
122
|
+
|
|
123
|
+
Blind spots -- production fails here and no case looks:
|
|
124
|
+
escalation: 11 incidents (38% of all attributed) and no coverage at all.
|
|
125
|
+
The suite cannot speak to this layer.
|
|
126
|
+
|
|
127
|
+
Misaligned -- cases pass here and production fails anyway:
|
|
128
|
+
routing: 9 incidents (31% of all attributed) despite 41 passing cases.
|
|
129
|
+
The cases pass and production still fails -- distrust these cases before
|
|
130
|
+
adding more.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Blind spot**: no coverage. Write cases.
|
|
134
|
+
|
|
135
|
+
**Misalignment**: full, passing coverage — and production fails there anyway. More
|
|
136
|
+
cases of the same shape make this worse, not better. The existing cases are the
|
|
137
|
+
thing to distrust. This is the finding that explains a green pipeline and a
|
|
138
|
+
customer-visible outage on the same afternoon, and it is the reason this tool is
|
|
139
|
+
not just another eval runner.
|
|
140
|
+
|
|
141
|
+
`incidents.json` is a list of records — an export from your incident tracker, or a
|
|
142
|
+
handful of postmortem entries:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
[
|
|
146
|
+
{"incident_id": "INC-4471", "layer": "escalation", "severity": "sev2",
|
|
147
|
+
"summary": "refund over threshold auto-approved"}
|
|
148
|
+
]
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Attribution is the one input nullcov cannot derive for itself, and the quality of
|
|
152
|
+
everything above depends on it.
|
|
153
|
+
|
|
154
|
+
## Structural coverage for LangGraph
|
|
155
|
+
|
|
156
|
+
Everything above depends on a human typing a truthful layer name. Nothing stops
|
|
157
|
+
that from drifting: add a node to the graph, forget to tag a test for it, and
|
|
158
|
+
it is invisible everywhere -- not `[????]`, not undeclared, just absent.
|
|
159
|
+
|
|
160
|
+
`nullcov.langgraph` removes the human from that specific step. It reads the
|
|
161
|
+
topology straight off your **compiled** graph object -- the actual nodes and
|
|
162
|
+
conditional branches LangGraph built, not a file you maintain by hand -- and
|
|
163
|
+
tells you which of them any test run ever actually traversed.
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
from nullcov.langgraph import CoverageAccumulator, GraphTopology
|
|
167
|
+
|
|
168
|
+
graph = build_graph() # your compiled LangGraph graph
|
|
169
|
+
accumulator = CoverageAccumulator(topology=GraphTopology.extract(graph))
|
|
170
|
+
|
|
171
|
+
# in your tests, run the graph through the accumulator instead of graph.invoke()
|
|
172
|
+
accumulator.record_run(graph, {"amount": 50, "reason": "wrong size"})
|
|
173
|
+
accumulator.record_run(graph, {"amount": 600, "reason": "wrong size"})
|
|
174
|
+
|
|
175
|
+
coverage = accumulator.coverage()
|
|
176
|
+
print(coverage.branch_rate()) # 0.5
|
|
177
|
+
print(coverage.untaken_branches) # {Branch("validate", "reject")}
|
|
178
|
+
print(coverage.unvisited_nodes) # {"reject"}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Run that against the example agent in this repo and it finds a real gap the
|
|
182
|
+
manual layer system misses entirely: `validate()` has a passing unit test for
|
|
183
|
+
an invalid payload, and `reject` is a fully-implemented node — but no test ever
|
|
184
|
+
runs the **compiled graph** end-to-end with a bad input, so the
|
|
185
|
+
`validate → reject` transition has never once fired in the test suite. A unit
|
|
186
|
+
test on the routing function proves the function is correct. It proves nothing
|
|
187
|
+
about whether the graph, as wired, ever takes that path.
|
|
188
|
+
|
|
189
|
+
We checked before building this: neither AWS Bedrock AgentCore Evaluations nor
|
|
190
|
+
`fasteval-langgraph` (Intuit) compute this. Both score individual test cases
|
|
191
|
+
against a trajectory the developer writes by hand — a real capability, but a
|
|
192
|
+
different one. Neither aggregates, across a whole suite, what fraction of the
|
|
193
|
+
graph's actual branch topology was ever exercised. `nullcov.langgraph` is
|
|
194
|
+
narrower than either of them and answers a question they don't ask.
|
|
195
|
+
|
|
196
|
+
Requires `langgraph` (`pip install nullcov[langgraph]`). This module is
|
|
197
|
+
LangGraph-specific by design — the ground truth it reads only exists because
|
|
198
|
+
`CompiledGraph.get_graph()` exposes it. CrewAI and AutoGen would each need
|
|
199
|
+
their own adapter reading their own internal representation; none exists yet.
|
|
200
|
+
|
|
201
|
+
## What this does not do
|
|
202
|
+
|
|
203
|
+
- **It does not score generative output.** LLM-as-judge is not statistically
|
|
204
|
+
calibrated — position, verbosity and self-enhancement bias are documented, and
|
|
205
|
+
calibrating judge confidence needs logprobs most providers do not expose. nullcov
|
|
206
|
+
declines to put a number on free-form reasoning rather than pretending.
|
|
207
|
+
- **It does not prove your agent is correct.** It proves which contracts held and,
|
|
208
|
+
more usefully, which ones nobody checked.
|
|
209
|
+
- **It does not replace your eval suite.** It tells you where that suite is blind.
|
|
210
|
+
|
|
211
|
+
## Prior art
|
|
212
|
+
|
|
213
|
+
nullcov leans on published work rather than inventing around it. The
|
|
214
|
+
scaffold/generative split and the coverage-honesty criterion come from
|
|
215
|
+
[arXiv:2606.11686](https://arxiv.org/abs/2606.11686). Deterministic record-and-replay
|
|
216
|
+
of agent I/O is solved by [agrepl](https://arxiv.org/abs/2607.16200) and
|
|
217
|
+
`vcr-langchain`; multi-agent tracing by
|
|
218
|
+
[MAESTRO](https://github.com/sands-lab/maestro). Signed action receipts exist in
|
|
219
|
+
`nobulex` and `protect-mcp`. If you need those, use those.
|
|
220
|
+
|
|
221
|
+
What nullcov adds is the honesty constraint on the aggregate, and the alignment
|
|
222
|
+
pass against production.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Example: a refund agent with a blind spot
|
|
2
|
+
|
|
3
|
+
A small LangGraph refund agent, a test suite a competent team would plausibly
|
|
4
|
+
write for it, and a real bug that the suite cannot see.
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pytest --nullcov --nullcov-incidents incidents.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
[????] escalation no cases -- coverage unknown, not 100%
|
|
14
|
+
[ok ] routing 1 slice(s), 2 case(s)
|
|
15
|
+
[ok ] safety 1 slice(s), 2 case(s)
|
|
16
|
+
[ok ] schema 2 slice(s), 5 case(s)
|
|
17
|
+
[----] summary generative -- not scored, by design
|
|
18
|
+
|
|
19
|
+
9 case(s) ran, 0 failing (100.0% of executed cases passed).
|
|
20
|
+
1 declared layer(s) have no coverage. The number above says nothing about them.
|
|
21
|
+
|
|
22
|
+
reality alignment
|
|
23
|
+
6 attributed incident(s).
|
|
24
|
+
|
|
25
|
+
Blind spots -- production fails here and no case looks:
|
|
26
|
+
escalation: 4 incidents (67% of all attributed) and no coverage at all.
|
|
27
|
+
The suite cannot speak to this layer.
|
|
28
|
+
|
|
29
|
+
Misaligned -- cases pass here and production fails anyway:
|
|
30
|
+
routing: 1 incidents (17% of all attributed) despite 2 passing cases.
|
|
31
|
+
safety: 1 incidents (17% of all attributed) despite 2 passing cases.
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Every test passes. A conventional runner would print `9 passed` and stop there.
|
|
35
|
+
|
|
36
|
+
## The bug
|
|
37
|
+
|
|
38
|
+
`decide()` in `agent.py` routes large refunds to a human:
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
if amount > AUTO_APPROVE_LIMIT: # should be >=
|
|
42
|
+
return {**state, "decision": "human_review"}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Policy says the limit itself requires approval. The comparison is strictly
|
|
46
|
+
greater-than, so a refund of exactly £500 is auto-approved:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
at limit (500.0) -> auto_approve <-- policy says human_review
|
|
50
|
+
above (500.01) -> human_review
|
|
51
|
+
below (499.99) -> auto_approve
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
An off-by-one on a boundary. End-to-end sampling almost never lands exactly on
|
|
55
|
+
it, and an aggregate pass rate will not notice when it does.
|
|
56
|
+
|
|
57
|
+
## What each half of the output tells you
|
|
58
|
+
|
|
59
|
+
**The blind spot** is the escalation layer: four of six production incidents,
|
|
60
|
+
and not one test case. Nobody decided that layer was safe — the person writing
|
|
61
|
+
the suite covered what they thought of, and the boundary rule was not it. That
|
|
62
|
+
is what `????` means, and why it is not `100%`.
|
|
63
|
+
|
|
64
|
+
**The misalignments** are routing and safety: cases pass, production fails
|
|
65
|
+
anyway. Those layers *are* tested. The tests are looking at the wrong thing.
|
|
66
|
+
Writing more cases in the same shape will not help; the existing ones are what
|
|
67
|
+
to distrust.
|
|
68
|
+
|
|
69
|
+
Half of enterprises surveyed in June 2026 had shipped an agent that passed their
|
|
70
|
+
own evaluations and still failed in front of a customer (VentureBeat VB Pulse,
|
|
71
|
+
n=157). Misalignment is the name for that, and it does not show up on a
|
|
72
|
+
coverage percentage.
|
|
73
|
+
|
|
74
|
+
## Fix it
|
|
75
|
+
|
|
76
|
+
Uncomment the last case in `test_refund_agent.py` and run again. The escalation
|
|
77
|
+
layer goes from `????` to `FAIL` — the tool stops saying "I do not know" and
|
|
78
|
+
starts saying "this is broken." Change `>` to `>=` in `agent.py` and it goes
|
|
79
|
+
green.
|
|
80
|
+
|
|
81
|
+
The point is the first transition, not the second. `????` to `FAIL` is a suite
|
|
82
|
+
learning to see. A conventional report never leaves `100%`.
|