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 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,2 @@
1
+ [console_scripts]
2
+ didrun = didrun.cli:main
@@ -0,0 +1 @@
1
+ didrun
didrun-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+