didrun 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.
- didrun-0.1.0/LICENSE +21 -0
- didrun-0.1.0/PKG-INFO +207 -0
- didrun-0.1.0/README.md +191 -0
- didrun-0.1.0/pyproject.toml +34 -0
- didrun-0.1.0/python/didrun/__init__.py +25 -0
- didrun-0.1.0/python/didrun/cli.py +150 -0
- didrun-0.1.0/python/didrun/core.py +151 -0
- didrun-0.1.0/python/didrun/evidence.py +202 -0
- didrun-0.1.0/python/didrun.egg-info/PKG-INFO +207 -0
- didrun-0.1.0/python/didrun.egg-info/SOURCES.txt +12 -0
- didrun-0.1.0/python/didrun.egg-info/dependency_links.txt +1 -0
- didrun-0.1.0/python/didrun.egg-info/entry_points.txt +2 -0
- didrun-0.1.0/python/didrun.egg-info/top_level.txt +1 -0
- didrun-0.1.0/setup.cfg +4 -0
didrun-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seth Wheeler
|
|
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.
|
didrun-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: didrun
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An exit code cannot tell you whether anything happened. Did it RUN, did it FAIL, and was the failure the RIGHT one.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ci,exit-code,subprocess,evidence,no-tests,zero-tests,empty-glob,test-runner,smoke-test,guard,verification,false-positive,silent-failure,zero-dependency
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# `didrun`
|
|
18
|
+
|
|
19
|
+
An exit code cannot tell you whether anything happened.
|
|
20
|
+
|
|
21
|
+
`0` means **"I did not fail."** A suite of ten thousand assertions and a suite that
|
|
22
|
+
collected nothing both report it, and no amount of reading the number harder will
|
|
23
|
+
separate them. That is not a nuisance — it is how a check silently stops checking and
|
|
24
|
+
nobody finds out for a year.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install didrun # the Python half
|
|
28
|
+
npm install -g didrun # the JavaScript half
|
|
29
|
+
|
|
30
|
+
didrun --expect-count "(\d+) passed" -- pytest tests/
|
|
31
|
+
didrun --expect "^ok " -- go test ./...
|
|
32
|
+
didrun --wrote coverage/lcov.info -- npm run coverage
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Both halves ship the same command, the same flags and the same exit codes. A CI file
|
|
36
|
+
should not have to ask which one is installed, and `python/tests/test_parity.py` asserts
|
|
37
|
+
the vocabulary they share — the four state names, the two exit codes, and that both
|
|
38
|
+
classify the same run identically.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
$ go test ./...
|
|
42
|
+
? x [no test files]
|
|
43
|
+
$ echo $?
|
|
44
|
+
0 # <- this is the whole problem
|
|
45
|
+
|
|
46
|
+
$ didrun --expect "^ok " -- go test ./...
|
|
47
|
+
? x [no test files]
|
|
48
|
+
|
|
49
|
+
[didrun] DID NOT RUN — there is no evidence this command did anything (exit 0, 296ms)
|
|
50
|
+
-- output matches /^ok /: nothing in output matched /^ok /
|
|
51
|
+
|
|
52
|
+
It exited 0. That is the failure: a check that stopped checking reports exactly this.
|
|
53
|
+
$ echo $?
|
|
54
|
+
3
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The rule
|
|
58
|
+
|
|
59
|
+
> A check must answer separately whether it **RAN**, whether it **FAILED**, and whether
|
|
60
|
+
> the failure was the **RIGHT** one. Collapsing any two of those three is how every
|
|
61
|
+
> defect in this family happens.
|
|
62
|
+
|
|
63
|
+
So this returns four states rather than a number:
|
|
64
|
+
|
|
65
|
+
| state | means | exit |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `did-not-run` | no evidence the command did anything — **regardless of exit 0** | **3** |
|
|
68
|
+
| `ran-and-passed` | evidence found, exit 0 | 0 |
|
|
69
|
+
| `ran-and-failed` | evidence found, non-zero exit, failure looked right | the command's own code |
|
|
70
|
+
| `ran-and-failed-wrongly` | it failed, but not the way you said it would | **4** |
|
|
71
|
+
|
|
72
|
+
The fourth exists because *"it failed"* is not *"my check caught something"*. A suite
|
|
73
|
+
that dies on a syntax error fails; so does one that caught your mutation. Scoring those
|
|
74
|
+
alike is the difference between a harness that works and one that reports success for a
|
|
75
|
+
file it never parsed.
|
|
76
|
+
|
|
77
|
+
`did-not-run` gets its own exit code and never borrows the command's — including when
|
|
78
|
+
the command itself failed. *"Your tests failed"* and *"you have no tests"* send you to
|
|
79
|
+
different places.
|
|
80
|
+
|
|
81
|
+
## Read this first: some runners already answer question one
|
|
82
|
+
|
|
83
|
+
Let them. Checked, not assumed:
|
|
84
|
+
|
|
85
|
+
| | answers "did it run"? |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `pytest` | **yes** — exits 5 when it collects nothing |
|
|
88
|
+
| `jest`, `vitest` | **yes** — fail by default when no test matches (`--passWithNoTests` is the decision this tool exists to argue with) |
|
|
89
|
+
| `go test ./...` | **no** — prints `[no test files]` and exits **0**. Measured, not assumed |
|
|
90
|
+
| linters given a glob that matched nothing | generally no |
|
|
91
|
+
| any shell step in any CI file | no notion of the question at all |
|
|
92
|
+
|
|
93
|
+
If your runner is in the first two rows, you may not need this. It is for the rest.
|
|
94
|
+
|
|
95
|
+
## Evidence
|
|
96
|
+
|
|
97
|
+
At least one predicate is required. **With none, `run()` throws and the CLI exits 2** —
|
|
98
|
+
a tool that silently degrades into forwarding the exit code is the thing it is replacing.
|
|
99
|
+
|
|
100
|
+
| flag | evidence |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `--expect REGEX` | combined output matches |
|
|
103
|
+
| `--expect-stdout` / `--expect-stderr` | one stream matches |
|
|
104
|
+
| `--expect-count REGEX` | the first capture group is a count, `>= --min` (default 1) |
|
|
105
|
+
| `--wrote PATH` | the file was actually written **during this run** |
|
|
106
|
+
| `--took-at-least MS` | a duration floor (weak — prefer a count) |
|
|
107
|
+
|
|
108
|
+
### `--expect-count` is the one that matters
|
|
109
|
+
|
|
110
|
+
The thing that makes a green run meaningless is almost always a **zero**, not an
|
|
111
|
+
absence: `0 passed`, `Ran 0 tests`, `0 files checked`. A pattern that only asks whether
|
|
112
|
+
the *line* was printed is satisfied by exactly the run it was meant to catch, because
|
|
113
|
+
the runner cheerfully prints its zero. Both behaviours are pinned by one test:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// `0 passed in 0.01s`, exit 0
|
|
117
|
+
count(/(\d+) passed/) -> did-not-run "reported 0"
|
|
118
|
+
matches(/\d+ passed/) -> ran-and-passed // fooled, as advertised
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `--wrote` is not "the file exists"
|
|
122
|
+
|
|
123
|
+
A `junit.xml` left over from yesterday exists, and a runner that never started leaves it
|
|
124
|
+
exactly where it was. The file must be **created, changed, or rewritten during the run**
|
|
125
|
+
— a byte-for-byte identical artefact is reported as the stale thing it is.
|
|
126
|
+
|
|
127
|
+
## API
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from didrun import run, report, exit_code_for, evidence
|
|
131
|
+
|
|
132
|
+
result = run(["pytest", "tests/"],
|
|
133
|
+
evidence=[evidence.count(r"(\d+) passed"), evidence.wrote("junit.xml")],
|
|
134
|
+
expect_failure=r"AssertionError", # when it fails, it must fail THIS way
|
|
135
|
+
timeout=600) # killed and still classified
|
|
136
|
+
|
|
137
|
+
result.state # "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
138
|
+
result.checks # every predicate, with what it looked for and what it found
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { run, report, exitCodeFor, evidence } from "didrun";
|
|
143
|
+
|
|
144
|
+
const result = await run(["pytest", "tests/"], {
|
|
145
|
+
evidence: [evidence.count(/(\d+) passed/), evidence.wrote("junit.xml")],
|
|
146
|
+
expectFailure: /AssertionError/, // when it fails, it must fail THIS way
|
|
147
|
+
timeout: 600_000, // killed and still classified
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
result.state // "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
151
|
+
result.checks // every predicate, with what it looked for and what it found
|
|
152
|
+
process.exitCode = exitCodeFor(result);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Every predicate must hold — `every`, not `some`. The report prints what was looked for
|
|
156
|
+
**and what was found, including when everything passed**, because a check whose output
|
|
157
|
+
is only a verdict is one nobody can audit.
|
|
158
|
+
|
|
159
|
+
A timeout is classified rather than swallowed: a killed check is a check that did not
|
|
160
|
+
finish, never one that passed.
|
|
161
|
+
|
|
162
|
+
## Prior art
|
|
163
|
+
|
|
164
|
+
Swept across **both registries** on mechanism nouns — the first pass queried npm only,
|
|
165
|
+
and npm-only sweeping is what nearly missed `crosshair` elsewhere in this line of work.
|
|
166
|
+
|
|
167
|
+
On npm, `keywords:no-tests` returns one package (a CRA template) and `keywords:ci-guard`
|
|
168
|
+
returns zero — dead tags, so wrong bucket names rather than open fields. `test-silence`
|
|
169
|
+
inventories *skipped* tests from git history; `jest-fail-on-console` and
|
|
170
|
+
`cypress-fail-fast` change what a runner does *while* it runs.
|
|
171
|
+
|
|
172
|
+
On PyPI, searched by name across the full 881,198-entry index plus web search, three
|
|
173
|
+
neighbours are real and none of them is this:
|
|
174
|
+
|
|
175
|
+
- **`pytest-custom-exit-code`** changes *pytest's* exit code when nothing is collected.
|
|
176
|
+
That is one runner answering question one for itself — the thing this wraps, not a
|
|
177
|
+
replacement for it.
|
|
178
|
+
- **`evidence-gate`** audits GitHub Actions **evidence bundles** after the fact: whether
|
|
179
|
+
an audit trail is complete and temporally bounded. A different question, downstream.
|
|
180
|
+
- **`ranit`** reports which functions *in your diff* were executed by nothing, by
|
|
181
|
+
intersecting a coverage database with the git diff. Function granularity via coverage,
|
|
182
|
+
not an arbitrary command with a supplied predicate — and the closest thing in spirit
|
|
183
|
+
to this anywhere.
|
|
184
|
+
|
|
185
|
+
Nothing found wraps an arbitrary command and asks whether it did anything.
|
|
186
|
+
|
|
187
|
+
## Limits
|
|
188
|
+
|
|
189
|
+
- **Evidence is only as good as the pattern you supply.** This does not know what your
|
|
190
|
+
command should print; it makes you say so, once, where the next person can read it.
|
|
191
|
+
- `--took-at-least` cannot prove work happened. It is marked `[weak]` in the report and
|
|
192
|
+
it is here because a suite that takes a minute finishing in 9ms is a real signal.
|
|
193
|
+
- It does not parse junit/TAP. `--wrote` plus `--expect-count` covers most of what that
|
|
194
|
+
would buy.
|
|
195
|
+
- Zero dependencies in either half. Node ≥ 18, Python ≥ 3.9.
|
|
196
|
+
|
|
197
|
+
## Tests
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
npm test # 22
|
|
201
|
+
python3 -m unittest discover -s python/tests # 21, four of them the cross-half contract
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
22 JavaScript tests and 21 Python tests, no dependencies in either half. Six mutations to the source — never reporting `did-not-run`,
|
|
205
|
+
treating it as success, accepting one predicate instead of all, allowing a run with no
|
|
206
|
+
evidence, ignoring the count floor, and accepting a stale artefact — were each caught by
|
|
207
|
+
the test that should catch them.
|
didrun-0.1.0/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# `didrun`
|
|
2
|
+
|
|
3
|
+
An exit code cannot tell you whether anything happened.
|
|
4
|
+
|
|
5
|
+
`0` means **"I did not fail."** A suite of ten thousand assertions and a suite that
|
|
6
|
+
collected nothing both report it, and no amount of reading the number harder will
|
|
7
|
+
separate them. That is not a nuisance — it is how a check silently stops checking and
|
|
8
|
+
nobody finds out for a year.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pip install didrun # the Python half
|
|
12
|
+
npm install -g didrun # the JavaScript half
|
|
13
|
+
|
|
14
|
+
didrun --expect-count "(\d+) passed" -- pytest tests/
|
|
15
|
+
didrun --expect "^ok " -- go test ./...
|
|
16
|
+
didrun --wrote coverage/lcov.info -- npm run coverage
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Both halves ship the same command, the same flags and the same exit codes. A CI file
|
|
20
|
+
should not have to ask which one is installed, and `python/tests/test_parity.py` asserts
|
|
21
|
+
the vocabulary they share — the four state names, the two exit codes, and that both
|
|
22
|
+
classify the same run identically.
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
$ go test ./...
|
|
26
|
+
? x [no test files]
|
|
27
|
+
$ echo $?
|
|
28
|
+
0 # <- this is the whole problem
|
|
29
|
+
|
|
30
|
+
$ didrun --expect "^ok " -- go test ./...
|
|
31
|
+
? x [no test files]
|
|
32
|
+
|
|
33
|
+
[didrun] DID NOT RUN — there is no evidence this command did anything (exit 0, 296ms)
|
|
34
|
+
-- output matches /^ok /: nothing in output matched /^ok /
|
|
35
|
+
|
|
36
|
+
It exited 0. That is the failure: a check that stopped checking reports exactly this.
|
|
37
|
+
$ echo $?
|
|
38
|
+
3
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## The rule
|
|
42
|
+
|
|
43
|
+
> A check must answer separately whether it **RAN**, whether it **FAILED**, and whether
|
|
44
|
+
> the failure was the **RIGHT** one. Collapsing any two of those three is how every
|
|
45
|
+
> defect in this family happens.
|
|
46
|
+
|
|
47
|
+
So this returns four states rather than a number:
|
|
48
|
+
|
|
49
|
+
| state | means | exit |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `did-not-run` | no evidence the command did anything — **regardless of exit 0** | **3** |
|
|
52
|
+
| `ran-and-passed` | evidence found, exit 0 | 0 |
|
|
53
|
+
| `ran-and-failed` | evidence found, non-zero exit, failure looked right | the command's own code |
|
|
54
|
+
| `ran-and-failed-wrongly` | it failed, but not the way you said it would | **4** |
|
|
55
|
+
|
|
56
|
+
The fourth exists because *"it failed"* is not *"my check caught something"*. A suite
|
|
57
|
+
that dies on a syntax error fails; so does one that caught your mutation. Scoring those
|
|
58
|
+
alike is the difference between a harness that works and one that reports success for a
|
|
59
|
+
file it never parsed.
|
|
60
|
+
|
|
61
|
+
`did-not-run` gets its own exit code and never borrows the command's — including when
|
|
62
|
+
the command itself failed. *"Your tests failed"* and *"you have no tests"* send you to
|
|
63
|
+
different places.
|
|
64
|
+
|
|
65
|
+
## Read this first: some runners already answer question one
|
|
66
|
+
|
|
67
|
+
Let them. Checked, not assumed:
|
|
68
|
+
|
|
69
|
+
| | answers "did it run"? |
|
|
70
|
+
|---|---|
|
|
71
|
+
| `pytest` | **yes** — exits 5 when it collects nothing |
|
|
72
|
+
| `jest`, `vitest` | **yes** — fail by default when no test matches (`--passWithNoTests` is the decision this tool exists to argue with) |
|
|
73
|
+
| `go test ./...` | **no** — prints `[no test files]` and exits **0**. Measured, not assumed |
|
|
74
|
+
| linters given a glob that matched nothing | generally no |
|
|
75
|
+
| any shell step in any CI file | no notion of the question at all |
|
|
76
|
+
|
|
77
|
+
If your runner is in the first two rows, you may not need this. It is for the rest.
|
|
78
|
+
|
|
79
|
+
## Evidence
|
|
80
|
+
|
|
81
|
+
At least one predicate is required. **With none, `run()` throws and the CLI exits 2** —
|
|
82
|
+
a tool that silently degrades into forwarding the exit code is the thing it is replacing.
|
|
83
|
+
|
|
84
|
+
| flag | evidence |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `--expect REGEX` | combined output matches |
|
|
87
|
+
| `--expect-stdout` / `--expect-stderr` | one stream matches |
|
|
88
|
+
| `--expect-count REGEX` | the first capture group is a count, `>= --min` (default 1) |
|
|
89
|
+
| `--wrote PATH` | the file was actually written **during this run** |
|
|
90
|
+
| `--took-at-least MS` | a duration floor (weak — prefer a count) |
|
|
91
|
+
|
|
92
|
+
### `--expect-count` is the one that matters
|
|
93
|
+
|
|
94
|
+
The thing that makes a green run meaningless is almost always a **zero**, not an
|
|
95
|
+
absence: `0 passed`, `Ran 0 tests`, `0 files checked`. A pattern that only asks whether
|
|
96
|
+
the *line* was printed is satisfied by exactly the run it was meant to catch, because
|
|
97
|
+
the runner cheerfully prints its zero. Both behaviours are pinned by one test:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
// `0 passed in 0.01s`, exit 0
|
|
101
|
+
count(/(\d+) passed/) -> did-not-run "reported 0"
|
|
102
|
+
matches(/\d+ passed/) -> ran-and-passed // fooled, as advertised
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `--wrote` is not "the file exists"
|
|
106
|
+
|
|
107
|
+
A `junit.xml` left over from yesterday exists, and a runner that never started leaves it
|
|
108
|
+
exactly where it was. The file must be **created, changed, or rewritten during the run**
|
|
109
|
+
— a byte-for-byte identical artefact is reported as the stale thing it is.
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from didrun import run, report, exit_code_for, evidence
|
|
115
|
+
|
|
116
|
+
result = run(["pytest", "tests/"],
|
|
117
|
+
evidence=[evidence.count(r"(\d+) passed"), evidence.wrote("junit.xml")],
|
|
118
|
+
expect_failure=r"AssertionError", # when it fails, it must fail THIS way
|
|
119
|
+
timeout=600) # killed and still classified
|
|
120
|
+
|
|
121
|
+
result.state # "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
122
|
+
result.checks # every predicate, with what it looked for and what it found
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
import { run, report, exitCodeFor, evidence } from "didrun";
|
|
127
|
+
|
|
128
|
+
const result = await run(["pytest", "tests/"], {
|
|
129
|
+
evidence: [evidence.count(/(\d+) passed/), evidence.wrote("junit.xml")],
|
|
130
|
+
expectFailure: /AssertionError/, // when it fails, it must fail THIS way
|
|
131
|
+
timeout: 600_000, // killed and still classified
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
result.state // "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
135
|
+
result.checks // every predicate, with what it looked for and what it found
|
|
136
|
+
process.exitCode = exitCodeFor(result);
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Every predicate must hold — `every`, not `some`. The report prints what was looked for
|
|
140
|
+
**and what was found, including when everything passed**, because a check whose output
|
|
141
|
+
is only a verdict is one nobody can audit.
|
|
142
|
+
|
|
143
|
+
A timeout is classified rather than swallowed: a killed check is a check that did not
|
|
144
|
+
finish, never one that passed.
|
|
145
|
+
|
|
146
|
+
## Prior art
|
|
147
|
+
|
|
148
|
+
Swept across **both registries** on mechanism nouns — the first pass queried npm only,
|
|
149
|
+
and npm-only sweeping is what nearly missed `crosshair` elsewhere in this line of work.
|
|
150
|
+
|
|
151
|
+
On npm, `keywords:no-tests` returns one package (a CRA template) and `keywords:ci-guard`
|
|
152
|
+
returns zero — dead tags, so wrong bucket names rather than open fields. `test-silence`
|
|
153
|
+
inventories *skipped* tests from git history; `jest-fail-on-console` and
|
|
154
|
+
`cypress-fail-fast` change what a runner does *while* it runs.
|
|
155
|
+
|
|
156
|
+
On PyPI, searched by name across the full 881,198-entry index plus web search, three
|
|
157
|
+
neighbours are real and none of them is this:
|
|
158
|
+
|
|
159
|
+
- **`pytest-custom-exit-code`** changes *pytest's* exit code when nothing is collected.
|
|
160
|
+
That is one runner answering question one for itself — the thing this wraps, not a
|
|
161
|
+
replacement for it.
|
|
162
|
+
- **`evidence-gate`** audits GitHub Actions **evidence bundles** after the fact: whether
|
|
163
|
+
an audit trail is complete and temporally bounded. A different question, downstream.
|
|
164
|
+
- **`ranit`** reports which functions *in your diff* were executed by nothing, by
|
|
165
|
+
intersecting a coverage database with the git diff. Function granularity via coverage,
|
|
166
|
+
not an arbitrary command with a supplied predicate — and the closest thing in spirit
|
|
167
|
+
to this anywhere.
|
|
168
|
+
|
|
169
|
+
Nothing found wraps an arbitrary command and asks whether it did anything.
|
|
170
|
+
|
|
171
|
+
## Limits
|
|
172
|
+
|
|
173
|
+
- **Evidence is only as good as the pattern you supply.** This does not know what your
|
|
174
|
+
command should print; it makes you say so, once, where the next person can read it.
|
|
175
|
+
- `--took-at-least` cannot prove work happened. It is marked `[weak]` in the report and
|
|
176
|
+
it is here because a suite that takes a minute finishing in 9ms is a real signal.
|
|
177
|
+
- It does not parse junit/TAP. `--wrote` plus `--expect-count` covers most of what that
|
|
178
|
+
would buy.
|
|
179
|
+
- Zero dependencies in either half. Node ≥ 18, Python ≥ 3.9.
|
|
180
|
+
|
|
181
|
+
## Tests
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
npm test # 22
|
|
185
|
+
python3 -m unittest discover -s python/tests # 21, four of them the cross-half contract
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
22 JavaScript tests and 21 Python tests, no dependencies in either half. Six mutations to the source — never reporting `did-not-run`,
|
|
189
|
+
treating it as success, accepting one predicate instead of all, allowing a run with no
|
|
190
|
+
evidence, ignoring the count floor, and accepting a stale artefact — were each caught by
|
|
191
|
+
the test that should catch them.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "didrun"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "An exit code cannot tell you whether anything happened. Did it RUN, did it FAIL, and was the failure the RIGHT one."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = [
|
|
13
|
+
"ci", "exit-code", "subprocess", "evidence", "no-tests", "zero-tests",
|
|
14
|
+
"empty-glob", "test-runner", "smoke-test", "guard", "verification",
|
|
15
|
+
"false-positive", "silent-failure", "zero-dependency",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
]
|
|
24
|
+
# LAYER 0. No in-network dependencies: this is a root, like assay-checks and lexindex,
|
|
25
|
+
# and `tools/depgraph.py` in the reckoner tree asserts the graph stays acyclic.
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
didrun = "didrun.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
# The Python half lives in `python/` beside `js/`, and is still imported as `didrun`.
|
|
33
|
+
package-dir = { "" = "python" }
|
|
34
|
+
packages = ["didrun"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
r"""didrun — an exit code cannot tell you whether anything happened.
|
|
2
|
+
|
|
3
|
+
from didrun import run, evidence
|
|
4
|
+
result = run(["pytest", "tests/"], evidence=[evidence.count(r"(\d+) passed")])
|
|
5
|
+
result.state # "did-not-run" | "ran-and-passed" | "ran-and-failed" | ...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from . import evidence
|
|
9
|
+
from .core import (
|
|
10
|
+
DID_NOT_RUN,
|
|
11
|
+
EXIT_DID_NOT_RUN,
|
|
12
|
+
EXIT_WRONG_FAILURE,
|
|
13
|
+
RAN_AND_FAILED,
|
|
14
|
+
RAN_AND_FAILED_WRONGLY,
|
|
15
|
+
RAN_AND_PASSED,
|
|
16
|
+
Result,
|
|
17
|
+
exit_code_for,
|
|
18
|
+
report,
|
|
19
|
+
run,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = ["run", "report", "exit_code_for", "evidence", "Result",
|
|
23
|
+
"DID_NOT_RUN", "RAN_AND_PASSED", "RAN_AND_FAILED", "RAN_AND_FAILED_WRONGLY",
|
|
24
|
+
"EXIT_DID_NOT_RUN", "EXIT_WRONG_FAILURE"]
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""`didrun` — run a command and say whether it actually did anything.
|
|
2
|
+
|
|
3
|
+
didrun --expect-count "(\\d+) passed" -- pytest tests/
|
|
4
|
+
didrun --expect "^ok " -- go test ./...
|
|
5
|
+
didrun --wrote coverage.xml -- coverage run -m pytest
|
|
6
|
+
|
|
7
|
+
Exit codes: 0 ran and passed · 3 DID NOT RUN · 4 failed the wrong way · otherwise the
|
|
8
|
+
command's own status.
|
|
9
|
+
|
|
10
|
+
THE FLAGS AND THE EXIT CODES ARE THE JAVASCRIPT HALF'S, deliberately. A CI file should
|
|
11
|
+
not have to ask which half is installed, and `python/tests/test_parity.py` asserts the
|
|
12
|
+
vocabulary the two share.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import evidence as ev
|
|
21
|
+
from .core import EXIT_DID_NOT_RUN, exit_code_for, report, run
|
|
22
|
+
|
|
23
|
+
USAGE = """didrun — an exit code cannot tell you whether anything happened.
|
|
24
|
+
|
|
25
|
+
didrun [evidence...] [options] -- COMMAND...
|
|
26
|
+
|
|
27
|
+
Evidence (at least one is required — without it there is nothing to add to the
|
|
28
|
+
exit code, and this refuses rather than pretending):
|
|
29
|
+
--expect REGEX combined output must match
|
|
30
|
+
--expect-stdout REGEX stdout must match
|
|
31
|
+
--expect-stderr REGEX stderr must match
|
|
32
|
+
--expect-count REGEX first capture group is a count, and must be >= --min
|
|
33
|
+
--min N the floor for --expect-count (default 1)
|
|
34
|
+
--wrote PATH the file must have been written during this run
|
|
35
|
+
--took-at-least MS a floor on the duration (weak; prefer a count)
|
|
36
|
+
|
|
37
|
+
Options:
|
|
38
|
+
--expect-failure REGEX when it fails, the output must match this or the
|
|
39
|
+
failure is scored as the WRONG one (exit 4)
|
|
40
|
+
--timeout SECONDS kill the command and classify anyway
|
|
41
|
+
--quiet only print on a bad verdict
|
|
42
|
+
--json print the result as JSON
|
|
43
|
+
-h, --help
|
|
44
|
+
|
|
45
|
+
Exit: 0 ran and passed · 3 did not run · 4 failed the wrong way ·
|
|
46
|
+
otherwise the command's own status.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv=None):
|
|
51
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
52
|
+
if not argv or "-h" in argv or "--help" in argv:
|
|
53
|
+
sys.stderr.write(USAGE)
|
|
54
|
+
return 0 if argv else 2
|
|
55
|
+
if "--" not in argv:
|
|
56
|
+
sys.stderr.write("didrun: put the command after `--`\n")
|
|
57
|
+
return 2
|
|
58
|
+
split = argv.index("--")
|
|
59
|
+
flags, command = argv[:split], argv[split + 1:]
|
|
60
|
+
if not command:
|
|
61
|
+
sys.stderr.write("didrun: nothing to run after `--`\n")
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
predicates, counts = [], []
|
|
65
|
+
expect_failure = timeout = None
|
|
66
|
+
minimum, quiet, as_json = 1, False, False
|
|
67
|
+
|
|
68
|
+
i = 0
|
|
69
|
+
while i < len(flags):
|
|
70
|
+
flag = flags[i]
|
|
71
|
+
|
|
72
|
+
def value():
|
|
73
|
+
nonlocal i
|
|
74
|
+
i += 1
|
|
75
|
+
if i >= len(flags):
|
|
76
|
+
raise SystemExit(f"didrun: {flag} needs a value")
|
|
77
|
+
return flags[i]
|
|
78
|
+
|
|
79
|
+
if flag == "--expect":
|
|
80
|
+
predicates.append(ev.matches(value(), "output"))
|
|
81
|
+
elif flag == "--expect-stdout":
|
|
82
|
+
predicates.append(ev.matches(value(), "stdout"))
|
|
83
|
+
elif flag == "--expect-stderr":
|
|
84
|
+
predicates.append(ev.matches(value(), "stderr"))
|
|
85
|
+
elif flag == "--expect-count":
|
|
86
|
+
counts.append(value())
|
|
87
|
+
elif flag == "--min":
|
|
88
|
+
minimum = int(value())
|
|
89
|
+
elif flag == "--wrote":
|
|
90
|
+
predicates.append(ev.wrote(value()))
|
|
91
|
+
elif flag == "--took-at-least":
|
|
92
|
+
predicates.append(ev.took_at_least(int(value())))
|
|
93
|
+
elif flag == "--expect-failure":
|
|
94
|
+
expect_failure = value()
|
|
95
|
+
elif flag == "--timeout":
|
|
96
|
+
timeout = float(value())
|
|
97
|
+
elif flag == "--quiet":
|
|
98
|
+
quiet = True
|
|
99
|
+
elif flag == "--json":
|
|
100
|
+
as_json = True
|
|
101
|
+
else:
|
|
102
|
+
sys.stderr.write(f"didrun: unknown option {flag}\n")
|
|
103
|
+
return 2
|
|
104
|
+
i += 1
|
|
105
|
+
|
|
106
|
+
# `--min` is applied after the loop so it works however it was ordered on the
|
|
107
|
+
# command line. A flag that silently means different things depending on where you
|
|
108
|
+
# put it is a flag that will be wrong in somebody's CI file.
|
|
109
|
+
for pattern in counts:
|
|
110
|
+
predicates.append(ev.count(pattern, minimum=minimum))
|
|
111
|
+
|
|
112
|
+
if not predicates:
|
|
113
|
+
sys.stderr.write(
|
|
114
|
+
"didrun: give at least one piece of evidence (--expect, --expect-count,\n"
|
|
115
|
+
" --wrote, --took-at-least).\n"
|
|
116
|
+
" Without one, this can only report the exit code — which is the\n"
|
|
117
|
+
" thing it exists to stop you trusting.\n"
|
|
118
|
+
)
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
result = run(command, evidence=predicates, expect_failure=expect_failure,
|
|
123
|
+
timeout=timeout)
|
|
124
|
+
except (OSError, FileNotFoundError) as exc:
|
|
125
|
+
sys.stderr.write(f"didrun: cannot run {command[0]!r} ({exc})\n")
|
|
126
|
+
return 2
|
|
127
|
+
|
|
128
|
+
if as_json:
|
|
129
|
+
json.dump({
|
|
130
|
+
"command": result.command,
|
|
131
|
+
"state": result.state,
|
|
132
|
+
"code": result.code,
|
|
133
|
+
"duration_ms": result.duration_ms,
|
|
134
|
+
"killed": result.killed,
|
|
135
|
+
"checks": [{"name": c.name, "satisfied": c.satisfied,
|
|
136
|
+
"detail": c.detail, "weak": c.weak} for c in result.checks],
|
|
137
|
+
}, sys.stdout, indent=2, sort_keys=True)
|
|
138
|
+
sys.stdout.write("\n")
|
|
139
|
+
else:
|
|
140
|
+
# The command's own output first, then the verdict, so the verdict is the last
|
|
141
|
+
# thing on the screen.
|
|
142
|
+
sys.stdout.write(result.stdout)
|
|
143
|
+
sys.stderr.write(result.stderr)
|
|
144
|
+
if not quiet or not result.ok:
|
|
145
|
+
sys.stderr.write("\n[didrun] " + report(result) + "\n")
|
|
146
|
+
return exit_code_for(result)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__": # pragma: no cover
|
|
150
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""didrun — an exit code cannot tell you whether anything happened.
|
|
2
|
+
|
|
3
|
+
`0` means "I did not fail". A suite of ten thousand assertions and a suite that
|
|
4
|
+
collected nothing both report it, and no amount of reading the number harder will
|
|
5
|
+
separate them. That is not a nuisance: it is how a check silently stops checking and
|
|
6
|
+
nobody finds out for a year.
|
|
7
|
+
|
|
8
|
+
The rule this implements is one sentence:
|
|
9
|
+
|
|
10
|
+
A check must answer separately whether it RAN, whether it FAILED, and whether the
|
|
11
|
+
failure was the RIGHT one. Collapsing any two of those three is how every defect in
|
|
12
|
+
this family happens.
|
|
13
|
+
|
|
14
|
+
WHAT THIS IS NOT FOR. Several runners already answer the first question for themselves
|
|
15
|
+
and you should let them: `pytest` exits 5 when it collects nothing, and `jest` and
|
|
16
|
+
`vitest` fail by default when no test matches. Reach for this where nothing answers it:
|
|
17
|
+
`go test ./...` prints `[no test files]` and exits 0 — verified, not assumed — as do most
|
|
18
|
+
linters given a glob that matched nothing, and every shell step ever written.
|
|
19
|
+
|
|
20
|
+
THE STATE NAMES AND EXIT CODES ARE SHARED WITH THE JAVASCRIPT HALF, deliberately. A CI
|
|
21
|
+
file branches on these, and it should not have to ask which half is installed.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
|
|
31
|
+
from . import evidence
|
|
32
|
+
from .evidence import Check
|
|
33
|
+
|
|
34
|
+
DID_NOT_RUN = "did-not-run"
|
|
35
|
+
RAN_AND_PASSED = "ran-and-passed"
|
|
36
|
+
RAN_AND_FAILED = "ran-and-failed"
|
|
37
|
+
RAN_AND_FAILED_WRONGLY = "ran-and-failed-wrongly"
|
|
38
|
+
|
|
39
|
+
# DID-NOT-RUN GETS ITS OWN EXIT CODE and never borrows the command's. Folding it into 1
|
|
40
|
+
# would put "your tests failed" and "you have no tests" in the same bucket, which are the
|
|
41
|
+
# two states this whole module exists to keep apart.
|
|
42
|
+
EXIT_DID_NOT_RUN = 3
|
|
43
|
+
EXIT_WRONG_FAILURE = 4
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Result:
|
|
48
|
+
command: list
|
|
49
|
+
stdout: str
|
|
50
|
+
stderr: str
|
|
51
|
+
code: int
|
|
52
|
+
duration_ms: int
|
|
53
|
+
killed: bool = False
|
|
54
|
+
checks: list = field(default_factory=list)
|
|
55
|
+
state: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self):
|
|
59
|
+
return self.state == RAN_AND_PASSED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def run(command, evidence=(), expect_failure=None, timeout=None, cwd=None, env=None):
|
|
63
|
+
"""Run `command`, gather evidence, and classify.
|
|
64
|
+
|
|
65
|
+
`evidence` is a sequence of predicates from `didrun.evidence`; at least one is
|
|
66
|
+
required. REFUSING IS THE POINT: with none this could only forward the exit code,
|
|
67
|
+
which is the behaviour it is replacing, and a tool that silently degrades into the
|
|
68
|
+
thing it exists to fix is worse than no tool.
|
|
69
|
+
"""
|
|
70
|
+
if not command:
|
|
71
|
+
raise TypeError("run() needs a command as a list of arguments")
|
|
72
|
+
predicates = list(evidence)
|
|
73
|
+
if not predicates:
|
|
74
|
+
raise TypeError(
|
|
75
|
+
"run() needs at least one evidence predicate; without one it can only "
|
|
76
|
+
"report the exit code, which is what it exists to stop you trusting"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
before = [p.before() for p in predicates]
|
|
80
|
+
started = time.monotonic()
|
|
81
|
+
killed = False
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(command, capture_output=True, text=True,
|
|
84
|
+
timeout=timeout, cwd=cwd, env=env)
|
|
85
|
+
code, out, err = proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
86
|
+
except subprocess.TimeoutExpired as expired:
|
|
87
|
+
# `subprocess.run` has already killed the child by the time this is caught. A
|
|
88
|
+
# timed-out check is a check that did not finish rather than one that passed.
|
|
89
|
+
killed = True
|
|
90
|
+
code = 124 # the conventional timeout status, as `timeout(1)` uses
|
|
91
|
+
out = (expired.stdout or b"").decode(errors="replace") if expired.stdout else ""
|
|
92
|
+
err = (expired.stderr or b"").decode(errors="replace") if expired.stderr else ""
|
|
93
|
+
duration = int((time.monotonic() - started) * 1000)
|
|
94
|
+
|
|
95
|
+
result = Result(command=list(command), stdout=out, stderr=err, code=code,
|
|
96
|
+
duration_ms=duration, killed=killed)
|
|
97
|
+
result.checks = [p.check(result, b) for p, b in zip(predicates, before)]
|
|
98
|
+
ran = all(c.satisfied for c in result.checks)
|
|
99
|
+
|
|
100
|
+
if not ran:
|
|
101
|
+
result.state = DID_NOT_RUN
|
|
102
|
+
elif code == 0 and not killed:
|
|
103
|
+
result.state = RAN_AND_PASSED
|
|
104
|
+
elif expect_failure:
|
|
105
|
+
pattern = (expect_failure if isinstance(expect_failure, re.Pattern)
|
|
106
|
+
else re.compile(expect_failure))
|
|
107
|
+
result.state = (RAN_AND_FAILED if pattern.search(out + err)
|
|
108
|
+
else RAN_AND_FAILED_WRONGLY)
|
|
109
|
+
else:
|
|
110
|
+
result.state = RAN_AND_FAILED
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def exit_code_for(result):
|
|
115
|
+
"""The process exit code this result should produce."""
|
|
116
|
+
if result.state == DID_NOT_RUN:
|
|
117
|
+
return EXIT_DID_NOT_RUN
|
|
118
|
+
if result.state == RAN_AND_FAILED_WRONGLY:
|
|
119
|
+
return EXIT_WRONG_FAILURE
|
|
120
|
+
if result.state == RAN_AND_PASSED:
|
|
121
|
+
return 0
|
|
122
|
+
# A real failure keeps the command's own status, so an existing pipeline that reads
|
|
123
|
+
# it keeps working.
|
|
124
|
+
return 1 if result.code == 0 else result.code
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def report(result):
|
|
128
|
+
"""What to print. It always says what was looked for, including when all passed."""
|
|
129
|
+
verdict = {
|
|
130
|
+
DID_NOT_RUN: "DID NOT RUN — there is no evidence this command did anything",
|
|
131
|
+
RAN_AND_PASSED: "ran, and passed",
|
|
132
|
+
RAN_AND_FAILED: "ran, and failed",
|
|
133
|
+
RAN_AND_FAILED_WRONGLY:
|
|
134
|
+
"FAILED THE WRONG WAY — it failed, but not for the reason you named",
|
|
135
|
+
}[result.state]
|
|
136
|
+
lines = [f"{verdict} (exit {result.code}"
|
|
137
|
+
f"{', killed' if result.killed else ''}, {result.duration_ms}ms)"]
|
|
138
|
+
for c in result.checks:
|
|
139
|
+
mark = " ok " if c.satisfied else " -- "
|
|
140
|
+
weak = " [weak]" if c.weak and c.satisfied else ""
|
|
141
|
+
lines.append(f"{mark}{c.name}: {c.detail}{weak}")
|
|
142
|
+
if result.state == DID_NOT_RUN and result.code == 0:
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append(" It exited 0. That is the failure: a check that stopped checking "
|
|
145
|
+
"reports exactly this.")
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["run", "report", "exit_code_for", "evidence", "Result", "Check",
|
|
150
|
+
"DID_NOT_RUN", "RAN_AND_PASSED", "RAN_AND_FAILED", "RAN_AND_FAILED_WRONGLY",
|
|
151
|
+
"EXIT_DID_NOT_RUN", "EXIT_WRONG_FAILURE"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The evidence predicates: how a command proves it did something.
|
|
2
|
+
|
|
3
|
+
THIS IS THE HALF AN EXIT CODE CANNOT PROVIDE. `0` means "I did not fail", which is what
|
|
4
|
+
a run of ten thousand assertions and a run of nothing both report. Separating them needs
|
|
5
|
+
a second signal, and the only general one is something the command produced: a line of
|
|
6
|
+
output, a count in that output, a file it wrote, or time it spent.
|
|
7
|
+
|
|
8
|
+
Each predicate answers a `Check` rather than a boolean, because "no evidence" is a
|
|
9
|
+
message somebody has to act on and `False` is not one. The detail is what gets printed,
|
|
10
|
+
so it says what was looked for AND what was there.
|
|
11
|
+
|
|
12
|
+
Kept deliberately in step with `js/src/evidence.js`: the names, the wording of the
|
|
13
|
+
details and the semantics are one contract, so a CI file that moves between the two
|
|
14
|
+
halves does not change meaning. `python/tests/test_parity.py` asserts the parts that can
|
|
15
|
+
be asserted.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Check:
|
|
28
|
+
"""One predicate's answer: did it hold, and what was actually there."""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
satisfied: bool
|
|
32
|
+
detail: str
|
|
33
|
+
weak: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pick(result, where):
|
|
37
|
+
if where == "stdout":
|
|
38
|
+
return result.stdout
|
|
39
|
+
if where == "stderr":
|
|
40
|
+
return result.stderr
|
|
41
|
+
return result.stdout + result.stderr
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _trim(text, limit=60):
|
|
45
|
+
one = " ".join(str(text).split())
|
|
46
|
+
return one[:limit] + "…" if len(one) > limit else one
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _compile(pattern):
|
|
50
|
+
return pattern if isinstance(pattern, re.Pattern) else re.compile(pattern)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Predicate:
|
|
54
|
+
"""A named question about a finished run. `before()` runs first when it needs to."""
|
|
55
|
+
|
|
56
|
+
weak = False
|
|
57
|
+
|
|
58
|
+
def before(self):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def check(self, result, before): # pragma: no cover - overridden
|
|
62
|
+
raise NotImplementedError
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Matches(Predicate):
|
|
66
|
+
"""A regex the output must match. The bluntest evidence, and often enough."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, pattern, where="output"):
|
|
69
|
+
self.re = _compile(pattern)
|
|
70
|
+
self.where = where
|
|
71
|
+
self.name = f"{where} matches {self.re.pattern}"
|
|
72
|
+
|
|
73
|
+
def check(self, result, before):
|
|
74
|
+
text = _pick(result, self.where)
|
|
75
|
+
found = self.re.search(text)
|
|
76
|
+
if found:
|
|
77
|
+
return Check(self.name, True, f"matched {_trim(found.group(0))!r}")
|
|
78
|
+
empty = "" if text.strip() else f" — {self.where} was empty"
|
|
79
|
+
return Check(self.name, False,
|
|
80
|
+
f"nothing in {self.where} matched {self.re.pattern}{empty}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Count(Predicate):
|
|
84
|
+
"""A regex whose first group is a COUNT, which must be at least `minimum`.
|
|
85
|
+
|
|
86
|
+
THE PREDICATE WORTH REACHING FOR. What makes a green run meaningless is almost
|
|
87
|
+
always a zero rather than an absence: `0 passed`, `Ran 0 tests`, `0 files checked`.
|
|
88
|
+
A pattern that only asked whether the LINE was printed is satisfied by exactly the
|
|
89
|
+
run it is meant to catch, because the runner cheerfully prints its zero.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, pattern, minimum=1, where="output"):
|
|
93
|
+
self.re = _compile(pattern)
|
|
94
|
+
self.minimum = minimum
|
|
95
|
+
self.where = where
|
|
96
|
+
self.name = f"{where} reports at least {minimum} via {self.re.pattern}"
|
|
97
|
+
|
|
98
|
+
def check(self, result, before):
|
|
99
|
+
text = _pick(result, self.where)
|
|
100
|
+
found = self.re.search(text)
|
|
101
|
+
if not found:
|
|
102
|
+
return Check(self.name, False,
|
|
103
|
+
f"nothing in {self.where} matched {self.re.pattern}, so no "
|
|
104
|
+
f"count was reported at all")
|
|
105
|
+
raw = found.group(1) if found.groups() else found.group(0)
|
|
106
|
+
digits = re.sub(r"[^\d-]", "", str(raw))
|
|
107
|
+
try:
|
|
108
|
+
n = int(digits)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return Check(self.name, False,
|
|
111
|
+
f"matched {_trim(found.group(0))!r} but no number could be "
|
|
112
|
+
f"read from it")
|
|
113
|
+
if n >= self.minimum:
|
|
114
|
+
return Check(self.name, True, f"{n} (needed {self.minimum})")
|
|
115
|
+
return Check(self.name, False,
|
|
116
|
+
f"reported {n}, which is below {self.minimum} — "
|
|
117
|
+
f"{_trim(found.group(0))!r}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Wrote(Predicate):
|
|
121
|
+
"""A file the command must have written DURING the run.
|
|
122
|
+
|
|
123
|
+
"Exists" is not the check. A junit.xml left over from yesterday exists, and a runner
|
|
124
|
+
that never started leaves it exactly where it was.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, path):
|
|
128
|
+
self.path = path
|
|
129
|
+
self.name = f"{path} was written during the run"
|
|
130
|
+
|
|
131
|
+
def _snapshot(self):
|
|
132
|
+
try:
|
|
133
|
+
stat = os.stat(self.path)
|
|
134
|
+
with open(self.path, "rb") as fh:
|
|
135
|
+
data = fh.read()
|
|
136
|
+
return {"exists": True, "size": stat.st_size, "mtime": stat.st_mtime,
|
|
137
|
+
"digest": hashlib.sha256(data).hexdigest()}
|
|
138
|
+
except OSError:
|
|
139
|
+
return {"exists": False, "size": 0, "mtime": 0, "digest": None}
|
|
140
|
+
|
|
141
|
+
def before(self):
|
|
142
|
+
return self._snapshot()
|
|
143
|
+
|
|
144
|
+
def check(self, result, before):
|
|
145
|
+
after = self._snapshot()
|
|
146
|
+
if not after["exists"]:
|
|
147
|
+
return Check(self.name, False,
|
|
148
|
+
f"{self.path} existed before the run and is gone"
|
|
149
|
+
if before["exists"] else f"{self.path} was never created")
|
|
150
|
+
if not before["exists"]:
|
|
151
|
+
return Check(self.name, True,
|
|
152
|
+
f"{self.path} was created ({after['size']} bytes)")
|
|
153
|
+
if after["digest"] != before["digest"]:
|
|
154
|
+
return Check(self.name, True,
|
|
155
|
+
f"{self.path} changed ({before['size']} -> {after['size']} bytes)")
|
|
156
|
+
if after["mtime"] > before["mtime"]:
|
|
157
|
+
return Check(self.name, True,
|
|
158
|
+
f"{self.path} was rewritten with the same content")
|
|
159
|
+
return Check(self.name, False,
|
|
160
|
+
f"{self.path} is byte for byte what it was before the run — a "
|
|
161
|
+
f"stale artefact from an earlier run looks exactly like this")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TookAtLeast(Predicate):
|
|
165
|
+
"""A floor on how long the command took.
|
|
166
|
+
|
|
167
|
+
THE WEAKEST PREDICATE HERE, and it is included with that said out loud. It cannot
|
|
168
|
+
prove work happened; it can only catch a suite that takes a minute finishing in nine
|
|
169
|
+
milliseconds. Prefer a count when the command will give you one.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
weak = True
|
|
173
|
+
|
|
174
|
+
def __init__(self, ms):
|
|
175
|
+
self.ms = ms
|
|
176
|
+
self.name = f"the command took at least {ms}ms"
|
|
177
|
+
|
|
178
|
+
def check(self, result, before):
|
|
179
|
+
if result.duration_ms >= self.ms:
|
|
180
|
+
return Check(self.name, True, f"{result.duration_ms}ms", weak=True)
|
|
181
|
+
return Check(self.name, False,
|
|
182
|
+
f"finished in {result.duration_ms}ms, under the {self.ms}ms floor",
|
|
183
|
+
weak=True)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Exits(Predicate):
|
|
187
|
+
"""The command's exit code must be one of `codes`."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, codes):
|
|
190
|
+
self.codes = {codes} if isinstance(codes, int) else set(codes)
|
|
191
|
+
self.name = f"exit code is one of {', '.join(str(c) for c in sorted(self.codes))}"
|
|
192
|
+
|
|
193
|
+
def check(self, result, before):
|
|
194
|
+
return Check(self.name, result.code in self.codes, f"exited {result.code}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# Short constructors, so a caller writes `evidence.count(...)` in either language.
|
|
198
|
+
matches = Matches
|
|
199
|
+
count = Count
|
|
200
|
+
wrote = Wrote
|
|
201
|
+
took_at_least = TookAtLeast
|
|
202
|
+
exits = Exits
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: didrun
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An exit code cannot tell you whether anything happened. Did it RUN, did it FAIL, and was the failure the RIGHT one.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ci,exit-code,subprocess,evidence,no-tests,zero-tests,empty-glob,test-runner,smoke-test,guard,verification,false-positive,silent-failure,zero-dependency
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# `didrun`
|
|
18
|
+
|
|
19
|
+
An exit code cannot tell you whether anything happened.
|
|
20
|
+
|
|
21
|
+
`0` means **"I did not fail."** A suite of ten thousand assertions and a suite that
|
|
22
|
+
collected nothing both report it, and no amount of reading the number harder will
|
|
23
|
+
separate them. That is not a nuisance — it is how a check silently stops checking and
|
|
24
|
+
nobody finds out for a year.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install didrun # the Python half
|
|
28
|
+
npm install -g didrun # the JavaScript half
|
|
29
|
+
|
|
30
|
+
didrun --expect-count "(\d+) passed" -- pytest tests/
|
|
31
|
+
didrun --expect "^ok " -- go test ./...
|
|
32
|
+
didrun --wrote coverage/lcov.info -- npm run coverage
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Both halves ship the same command, the same flags and the same exit codes. A CI file
|
|
36
|
+
should not have to ask which one is installed, and `python/tests/test_parity.py` asserts
|
|
37
|
+
the vocabulary they share — the four state names, the two exit codes, and that both
|
|
38
|
+
classify the same run identically.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
$ go test ./...
|
|
42
|
+
? x [no test files]
|
|
43
|
+
$ echo $?
|
|
44
|
+
0 # <- this is the whole problem
|
|
45
|
+
|
|
46
|
+
$ didrun --expect "^ok " -- go test ./...
|
|
47
|
+
? x [no test files]
|
|
48
|
+
|
|
49
|
+
[didrun] DID NOT RUN — there is no evidence this command did anything (exit 0, 296ms)
|
|
50
|
+
-- output matches /^ok /: nothing in output matched /^ok /
|
|
51
|
+
|
|
52
|
+
It exited 0. That is the failure: a check that stopped checking reports exactly this.
|
|
53
|
+
$ echo $?
|
|
54
|
+
3
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## The rule
|
|
58
|
+
|
|
59
|
+
> A check must answer separately whether it **RAN**, whether it **FAILED**, and whether
|
|
60
|
+
> the failure was the **RIGHT** one. Collapsing any two of those three is how every
|
|
61
|
+
> defect in this family happens.
|
|
62
|
+
|
|
63
|
+
So this returns four states rather than a number:
|
|
64
|
+
|
|
65
|
+
| state | means | exit |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `did-not-run` | no evidence the command did anything — **regardless of exit 0** | **3** |
|
|
68
|
+
| `ran-and-passed` | evidence found, exit 0 | 0 |
|
|
69
|
+
| `ran-and-failed` | evidence found, non-zero exit, failure looked right | the command's own code |
|
|
70
|
+
| `ran-and-failed-wrongly` | it failed, but not the way you said it would | **4** |
|
|
71
|
+
|
|
72
|
+
The fourth exists because *"it failed"* is not *"my check caught something"*. A suite
|
|
73
|
+
that dies on a syntax error fails; so does one that caught your mutation. Scoring those
|
|
74
|
+
alike is the difference between a harness that works and one that reports success for a
|
|
75
|
+
file it never parsed.
|
|
76
|
+
|
|
77
|
+
`did-not-run` gets its own exit code and never borrows the command's — including when
|
|
78
|
+
the command itself failed. *"Your tests failed"* and *"you have no tests"* send you to
|
|
79
|
+
different places.
|
|
80
|
+
|
|
81
|
+
## Read this first: some runners already answer question one
|
|
82
|
+
|
|
83
|
+
Let them. Checked, not assumed:
|
|
84
|
+
|
|
85
|
+
| | answers "did it run"? |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `pytest` | **yes** — exits 5 when it collects nothing |
|
|
88
|
+
| `jest`, `vitest` | **yes** — fail by default when no test matches (`--passWithNoTests` is the decision this tool exists to argue with) |
|
|
89
|
+
| `go test ./...` | **no** — prints `[no test files]` and exits **0**. Measured, not assumed |
|
|
90
|
+
| linters given a glob that matched nothing | generally no |
|
|
91
|
+
| any shell step in any CI file | no notion of the question at all |
|
|
92
|
+
|
|
93
|
+
If your runner is in the first two rows, you may not need this. It is for the rest.
|
|
94
|
+
|
|
95
|
+
## Evidence
|
|
96
|
+
|
|
97
|
+
At least one predicate is required. **With none, `run()` throws and the CLI exits 2** —
|
|
98
|
+
a tool that silently degrades into forwarding the exit code is the thing it is replacing.
|
|
99
|
+
|
|
100
|
+
| flag | evidence |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `--expect REGEX` | combined output matches |
|
|
103
|
+
| `--expect-stdout` / `--expect-stderr` | one stream matches |
|
|
104
|
+
| `--expect-count REGEX` | the first capture group is a count, `>= --min` (default 1) |
|
|
105
|
+
| `--wrote PATH` | the file was actually written **during this run** |
|
|
106
|
+
| `--took-at-least MS` | a duration floor (weak — prefer a count) |
|
|
107
|
+
|
|
108
|
+
### `--expect-count` is the one that matters
|
|
109
|
+
|
|
110
|
+
The thing that makes a green run meaningless is almost always a **zero**, not an
|
|
111
|
+
absence: `0 passed`, `Ran 0 tests`, `0 files checked`. A pattern that only asks whether
|
|
112
|
+
the *line* was printed is satisfied by exactly the run it was meant to catch, because
|
|
113
|
+
the runner cheerfully prints its zero. Both behaviours are pinned by one test:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// `0 passed in 0.01s`, exit 0
|
|
117
|
+
count(/(\d+) passed/) -> did-not-run "reported 0"
|
|
118
|
+
matches(/\d+ passed/) -> ran-and-passed // fooled, as advertised
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### `--wrote` is not "the file exists"
|
|
122
|
+
|
|
123
|
+
A `junit.xml` left over from yesterday exists, and a runner that never started leaves it
|
|
124
|
+
exactly where it was. The file must be **created, changed, or rewritten during the run**
|
|
125
|
+
— a byte-for-byte identical artefact is reported as the stale thing it is.
|
|
126
|
+
|
|
127
|
+
## API
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from didrun import run, report, exit_code_for, evidence
|
|
131
|
+
|
|
132
|
+
result = run(["pytest", "tests/"],
|
|
133
|
+
evidence=[evidence.count(r"(\d+) passed"), evidence.wrote("junit.xml")],
|
|
134
|
+
expect_failure=r"AssertionError", # when it fails, it must fail THIS way
|
|
135
|
+
timeout=600) # killed and still classified
|
|
136
|
+
|
|
137
|
+
result.state # "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
138
|
+
result.checks # every predicate, with what it looked for and what it found
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
import { run, report, exitCodeFor, evidence } from "didrun";
|
|
143
|
+
|
|
144
|
+
const result = await run(["pytest", "tests/"], {
|
|
145
|
+
evidence: [evidence.count(/(\d+) passed/), evidence.wrote("junit.xml")],
|
|
146
|
+
expectFailure: /AssertionError/, // when it fails, it must fail THIS way
|
|
147
|
+
timeout: 600_000, // killed and still classified
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
result.state // "did-not-run" | "ran-and-passed" | "ran-and-failed" | "ran-and-failed-wrongly"
|
|
151
|
+
result.checks // every predicate, with what it looked for and what it found
|
|
152
|
+
process.exitCode = exitCodeFor(result);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Every predicate must hold — `every`, not `some`. The report prints what was looked for
|
|
156
|
+
**and what was found, including when everything passed**, because a check whose output
|
|
157
|
+
is only a verdict is one nobody can audit.
|
|
158
|
+
|
|
159
|
+
A timeout is classified rather than swallowed: a killed check is a check that did not
|
|
160
|
+
finish, never one that passed.
|
|
161
|
+
|
|
162
|
+
## Prior art
|
|
163
|
+
|
|
164
|
+
Swept across **both registries** on mechanism nouns — the first pass queried npm only,
|
|
165
|
+
and npm-only sweeping is what nearly missed `crosshair` elsewhere in this line of work.
|
|
166
|
+
|
|
167
|
+
On npm, `keywords:no-tests` returns one package (a CRA template) and `keywords:ci-guard`
|
|
168
|
+
returns zero — dead tags, so wrong bucket names rather than open fields. `test-silence`
|
|
169
|
+
inventories *skipped* tests from git history; `jest-fail-on-console` and
|
|
170
|
+
`cypress-fail-fast` change what a runner does *while* it runs.
|
|
171
|
+
|
|
172
|
+
On PyPI, searched by name across the full 881,198-entry index plus web search, three
|
|
173
|
+
neighbours are real and none of them is this:
|
|
174
|
+
|
|
175
|
+
- **`pytest-custom-exit-code`** changes *pytest's* exit code when nothing is collected.
|
|
176
|
+
That is one runner answering question one for itself — the thing this wraps, not a
|
|
177
|
+
replacement for it.
|
|
178
|
+
- **`evidence-gate`** audits GitHub Actions **evidence bundles** after the fact: whether
|
|
179
|
+
an audit trail is complete and temporally bounded. A different question, downstream.
|
|
180
|
+
- **`ranit`** reports which functions *in your diff* were executed by nothing, by
|
|
181
|
+
intersecting a coverage database with the git diff. Function granularity via coverage,
|
|
182
|
+
not an arbitrary command with a supplied predicate — and the closest thing in spirit
|
|
183
|
+
to this anywhere.
|
|
184
|
+
|
|
185
|
+
Nothing found wraps an arbitrary command and asks whether it did anything.
|
|
186
|
+
|
|
187
|
+
## Limits
|
|
188
|
+
|
|
189
|
+
- **Evidence is only as good as the pattern you supply.** This does not know what your
|
|
190
|
+
command should print; it makes you say so, once, where the next person can read it.
|
|
191
|
+
- `--took-at-least` cannot prove work happened. It is marked `[weak]` in the report and
|
|
192
|
+
it is here because a suite that takes a minute finishing in 9ms is a real signal.
|
|
193
|
+
- It does not parse junit/TAP. `--wrote` plus `--expect-count` covers most of what that
|
|
194
|
+
would buy.
|
|
195
|
+
- Zero dependencies in either half. Node ≥ 18, Python ≥ 3.9.
|
|
196
|
+
|
|
197
|
+
## Tests
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
npm test # 22
|
|
201
|
+
python3 -m unittest discover -s python/tests # 21, four of them the cross-half contract
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
22 JavaScript tests and 21 Python tests, no dependencies in either half. Six mutations to the source — never reporting `did-not-run`,
|
|
205
|
+
treating it as success, accepting one predicate instead of all, allowing a run with no
|
|
206
|
+
evidence, ignoring the count floor, and accepting a stale artefact — were each caught by
|
|
207
|
+
the test that should catch them.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
python/didrun/__init__.py
|
|
5
|
+
python/didrun/cli.py
|
|
6
|
+
python/didrun/core.py
|
|
7
|
+
python/didrun/evidence.py
|
|
8
|
+
python/didrun.egg-info/PKG-INFO
|
|
9
|
+
python/didrun.egg-info/SOURCES.txt
|
|
10
|
+
python/didrun.egg-info/dependency_links.txt
|
|
11
|
+
python/didrun.egg-info/entry_points.txt
|
|
12
|
+
python/didrun.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
didrun
|
didrun-0.1.0/setup.cfg
ADDED