tewip-ai 0.26.0 → 0.27.0
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.
- package/README.md +58 -2
- package/dist/cli/help.js +10 -1
- package/dist/cli/tewip.js +14 -0
- package/dist/engine/attest/attest.js +5 -2
- package/dist/engine/prove/mutants.js +148 -0
- package/dist/engine/prove/prove-command.js +152 -0
- package/dist/engine/prove/prove.js +179 -0
- package/dist/engine/prove/reaches.js +102 -0
- package/dist/engine/pursue/act.js +1 -0
- package/dist/engine/pursue/policy.js +14 -0
- package/dist/engine/pursue/pursue-command.js +23 -1
- package/dist/engine/quality/frameworks.js +33 -0
- package/dist/engine/quality/write-verify.js +12 -1
- package/dist/engine/review/coverage.js +27 -4
- package/dist/engine/review/review-command.js +5 -1
- package/dist/engine/vouch/changed.js +58 -0
- package/dist/mcp/server.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -71,6 +71,13 @@ On every run, the action:
|
|
|
71
71
|
- **Developers:** posts one diagnosis comment on the pull request and keeps it updated. For each failure it says whether the change likely caused it, why, and what to do next.
|
|
72
72
|
- **SDETs:** writes the triage board into the job summary.
|
|
73
73
|
- **DevOps:** collects environment failures (server errors, connection resets, outages) in **one** GitHub issue, and each new run adds a single grouped comment to it.
|
|
74
|
+
- **The tests in the pull request:** on a PR it reads the test files the change touched and says which
|
|
75
|
+
of them could never fail - an unfinished expectation, a value compared with itself, a test that
|
|
76
|
+
discards its own failure, one that never runs. It says nothing when they are all sound, and it says
|
|
77
|
+
it **even on a green run**, because "all tests passed" is the exact sentence that needs qualifying
|
|
78
|
+
when one of them cannot fail. Needs `fetch-depth: 0` on `actions/checkout` so the diff can be read;
|
|
79
|
+
without it the section is skipped rather than guessed at. Any such test also enters the attestation
|
|
80
|
+
as a refusal, so a release decision taken from the bundle cannot rest on a test that proves nothing.
|
|
74
81
|
- **Evidence, every run:** the Action writes an attestation into its artifact - the claims with
|
|
75
82
|
their evidence and which layer decided each, what Tewip **refused** to decide, and what it did
|
|
76
83
|
not check at all. Nothing enters it that Tewip did not verify itself, so a release decision can
|
|
@@ -496,6 +503,54 @@ Joins the test plans Tewip wrote to the tests that cover them and the runs that
|
|
|
496
503
|
scenario counts as covered only when a real test can be named, and passing only when a real run
|
|
497
504
|
attempted it; everything else says what is missing. No model, no cost.
|
|
498
505
|
|
|
506
|
+
## Would your tests catch a bug? (Phase 23)
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
tewip prove src/pricing.js # or --diff main...HEAD for what a change touched
|
|
510
|
+
tewip prove src/pricing.js --strict # exits non-zero when something goes unnoticed
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Coverage says a line ran. It does not say anybody checked it. `prove` settles that by **execution**:
|
|
514
|
+
it breaks your code in small, deliberate ways - a boundary moved by one, a condition inverted, an
|
|
515
|
+
`and` that became an `or`, a returned flag flipped, a pattern loosened - runs the tests that reach
|
|
516
|
+
that file, and reports every change **none of them noticed**.
|
|
517
|
+
|
|
518
|
+
```
|
|
519
|
+
4 change(s) were made to your code and the covering tests were run against each. 3 went unnoticed.
|
|
520
|
+
|
|
521
|
+
- line 2: a boundary moved by one
|
|
522
|
+
- was: if (total >= 100 && isMember) return 0.2;
|
|
523
|
+
- became: if (total > 100 && isMember) return 0.2;
|
|
524
|
+
- line 9: a sign reversed
|
|
525
|
+
- was: return total - total * off;
|
|
526
|
+
- became: return total + total * off;
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
Both of those are real: the tests used 150 and 10 and never the boundary itself, and the test calling
|
|
530
|
+
`finalPrice` asserted nothing at all. Neither gap is visible in a coverage report, because both lines
|
|
531
|
+
were covered.
|
|
532
|
+
|
|
533
|
+
It refuses more often than it reports, and the refusals are the reason the reports are worth reading:
|
|
534
|
+
|
|
535
|
+
- **Your file is put back exactly as it was**, byte for byte, line endings included, in a `finally` -
|
|
536
|
+
and the restore is verified, not assumed. A restore that did not work is an error, not a shrug.
|
|
537
|
+
- **Nothing that can hang is touched.** Loop headers are never changed: a flipped comparison in a
|
|
538
|
+
`while` does not fail a test, it runs forever.
|
|
539
|
+
- **A red suite proves nothing.** Every covering test is run untouched first. Ones that do not pass are
|
|
540
|
+
set aside by name, and if none pass, it says so and stops - a test that was already failing would
|
|
541
|
+
otherwise appear to "catch" every change.
|
|
542
|
+
- **It checks that your tests actually load the file.** If breaking the file outright does not fail a
|
|
543
|
+
single test, they are reading an installed copy or a build output, and it says that instead of
|
|
544
|
+
reporting a page of holes that do not exist. This caught a real case on the first Python project it
|
|
545
|
+
met, where `import humanize` resolved to a different checkout entirely.
|
|
546
|
+
- **Strings and comments are never changed**, because changing a message is not a bug a test should be
|
|
547
|
+
expected to catch.
|
|
548
|
+
|
|
549
|
+
It follows imports, so a file reached *through* another module is still proved - in a layered codebase
|
|
550
|
+
that is most of them. It needs a unit test framework (jest, vitest, mocha, `node --test`, pytest, go
|
|
551
|
+
test, maven, gradle, dotnet), calls no model, and costs nothing but time. It is also an MCP tool, so a
|
|
552
|
+
coding agent can ask whether the tests it is relying on would have stopped its own mistake.
|
|
553
|
+
|
|
499
554
|
## Could these tests ever fail? (Phase 22)
|
|
500
555
|
|
|
501
556
|
```bash
|
|
@@ -540,8 +595,9 @@ tewip pursue "keep the checkout flow green" --act
|
|
|
540
595
|
|
|
541
596
|
Give Tewip a goal and it says what it would do next, and why, from what it can actually see. The
|
|
542
597
|
order is fixed, published and testable - **not chosen by a model**: a real bug first, then the
|
|
543
|
-
environment, then flakiness, then anything failing it cannot explain, then a
|
|
544
|
-
then is the goal worth attesting. Deciding the same state twice gives
|
|
598
|
+
environment, then flakiness, then anything failing it cannot explain, then a test that cannot fail,
|
|
599
|
+
then a coverage gap, and only then is the goal worth attesting. Deciding the same state twice gives
|
|
600
|
+
the same answer.
|
|
545
601
|
|
|
546
602
|
With `--act` it carries the decision out, and this is where the refusals matter most:
|
|
547
603
|
|
package/dist/cli/help.js
CHANGED
|
@@ -105,11 +105,20 @@ Usage:
|
|
|
105
105
|
green" - and it says what it would do next and why, from what
|
|
106
106
|
it can actually see. The order is fixed and written down: a
|
|
107
107
|
real bug first, then the environment, then flakiness, then a
|
|
108
|
-
coverage gap. It decides; it does
|
|
108
|
+
test that cannot fail, then a coverage gap. It decides; it does
|
|
109
|
+
not act unless you ask. With
|
|
109
110
|
--act it carries out the two safe ones - writing a missing test,
|
|
110
111
|
or attesting a green goal - by running the command that already
|
|
111
112
|
proves them. It will not touch a real bug, and it writes what it
|
|
112
113
|
did or refused to .tewip/actions.jsonl.
|
|
114
|
+
tewip prove <file> [--diff main...HEAD] [--limit 6] [--budget 600] [--strict]
|
|
115
|
+
What would your tests NOT catch? Breaks your code in small,
|
|
116
|
+
deliberate ways - a boundary moved by one, a condition inverted,
|
|
117
|
+
an and that became an or - runs the tests that import that file,
|
|
118
|
+
and reports every change nobody noticed. The line was covered; it
|
|
119
|
+
was not checked, and no coverage number can tell you that. Every
|
|
120
|
+
file is put back exactly as it was. Slow by nature, and it needs
|
|
121
|
+
a unit test framework. No model, no cost.
|
|
113
122
|
tewip vouch [<paths>] [--strict] [--out vouch.md] [--json vouch.json]
|
|
114
123
|
Could these tests ever fail? Reads tests somebody else wrote -
|
|
115
124
|
an agent, a contractor, last year's team - and names the ones
|
package/dist/cli/tewip.js
CHANGED
|
@@ -185,6 +185,20 @@ async function main() {
|
|
|
185
185
|
runPursue({ root: process.cwd(), goal, act: args.includes('--act'), name: flag(args, 'name'), out: flag(args, 'out') });
|
|
186
186
|
return;
|
|
187
187
|
}
|
|
188
|
+
case 'prove': {
|
|
189
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--diff', '--limit', '--budget'].includes(args[i - 1] ?? ''));
|
|
190
|
+
const { runProve } = await import('../engine/prove/prove-command.js');
|
|
191
|
+
runProve({
|
|
192
|
+
root: process.cwd(),
|
|
193
|
+
files: paths,
|
|
194
|
+
diff: flag(args, 'diff'),
|
|
195
|
+
limit: Number(flag(args, 'limit') ?? '6'),
|
|
196
|
+
budgetSeconds: Number(flag(args, 'budget') ?? '600'),
|
|
197
|
+
out: flag(args, 'out'),
|
|
198
|
+
strict: args.includes('--strict'),
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
188
202
|
case 'vouch': {
|
|
189
203
|
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--json'].includes(args[i - 1] ?? ''));
|
|
190
204
|
const { runVouch } = await import('../engine/vouch/run-vouch.js');
|
|
@@ -11,9 +11,12 @@ function evidenceOf(record) {
|
|
|
11
11
|
* The bundle for one run. `records` and `run` are what Tewip itself wrote when it read the report,
|
|
12
12
|
* so everything here is traceable; nothing is inferred and nothing is re-judged.
|
|
13
13
|
*/
|
|
14
|
-
export function attestRun(project, run, records, { checked, notChecked, tool, engine }) {
|
|
14
|
+
export function attestRun(project, run, records, { checked, notChecked, tool, engine, refusals: given = [], }) {
|
|
15
15
|
const claims = [];
|
|
16
|
-
|
|
16
|
+
// Refusals the caller already established - a test in this change that cannot fail, for instance -
|
|
17
|
+
// belong here beside the ones the records produce. They are the same kind of statement: something
|
|
18
|
+
// this bundle will not vouch for.
|
|
19
|
+
const refusals = [...given];
|
|
17
20
|
for (const record of records) {
|
|
18
21
|
const diagnosis = record.diagnosis;
|
|
19
22
|
const category = diagnosis?.category ?? 'UNKNOWN';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Phase 23: small, deliberate breakages of real code, to find out what the tests would not notice.
|
|
2
|
+
//
|
|
3
|
+
// `vouch` reads a test and says whether it *could* fail. This asks the harder question, and answers
|
|
4
|
+
// it by execution rather than by reading: break the code the test covers, run the test, and see. A
|
|
5
|
+
// change nobody notices is a hole in the suite that no coverage percentage will ever show, because
|
|
6
|
+
// the line was covered - it just was not checked.
|
|
7
|
+
//
|
|
8
|
+
// Three rules shape what may be changed, and all three exist to keep the answer trustworthy:
|
|
9
|
+
//
|
|
10
|
+
// 1. **Only code.** Strings and comments are skipped, reusing the same mask `vouch` uses. Changing a
|
|
11
|
+
// word in a message is not a bug a test should be expected to catch.
|
|
12
|
+
// 2. **Nothing that can hang.** Loop headers are never touched. A flipped comparison in a `while`
|
|
13
|
+
// condition does not fail a test, it runs forever, and a tool that hangs a developer's machine
|
|
14
|
+
// gets uninstalled. This is the same lesson as the fork bomb in Phase 21, learned once.
|
|
15
|
+
// 3. **One change at a time**, always restored. A mutant is a question, not an edit.
|
|
16
|
+
import { textRanges } from '../vouch/tests-in.js';
|
|
17
|
+
/** A line that could loop: never touched, because a wrong comparison there hangs instead of failing. */
|
|
18
|
+
const COULD_LOOP = /\b(for|while|do|loop|repeat|until)\b\s*[({:]|\bfor\b.*\bin\b|\.\s*(forEach|map|filter|reduce|while)\s*\(/;
|
|
19
|
+
/** A line that only reports, logs or types: changing it says nothing about behaviour. */
|
|
20
|
+
const NOT_BEHAVIOUR = /^\s*(\/\/|#|\*|\/\*|@|import\b|from\b|export\s+(type|interface)\b|use\s|package\b|using\b|require\b|const\s+\w+\s*=\s*require)|console\s*\.|print\s*\(|println|log(ger)?\s*\.|process\.(stdout|stderr)|fmt\s*\.\s*Print/;
|
|
21
|
+
/**
|
|
22
|
+
* The changes worth making, in the order they are worth making them. Each is a mistake a person or an
|
|
23
|
+
* agent really makes: a boundary off by one, a condition inverted, an `and` that should be an `or`, a
|
|
24
|
+
* sign the wrong way round, a flag returned the wrong way.
|
|
25
|
+
*/
|
|
26
|
+
const OPERATORS = [
|
|
27
|
+
{ kind: 'a boundary moved by one', find: /(?<![<>=!])<=(?!=)/, swap: () => '<' },
|
|
28
|
+
{ kind: 'a boundary moved by one', find: /(?<![<>=!])>=(?!=)/, swap: () => '>' },
|
|
29
|
+
{ kind: 'a boundary moved by one', find: /(?<![<>=!+-])<(?![<=])/, swap: () => '<=' },
|
|
30
|
+
{ kind: 'a boundary moved by one', find: /(?<![<>=!+-])>(?![>=])/, swap: () => '>=' },
|
|
31
|
+
{ kind: 'a comparison inverted', find: /===/, swap: () => '!==' },
|
|
32
|
+
{ kind: 'a comparison inverted', find: /(?<![!=<>])==(?!=)/, swap: () => '!=' },
|
|
33
|
+
{ kind: 'a comparison inverted', find: /!==/, swap: () => '===' },
|
|
34
|
+
{ kind: 'a comparison inverted', find: /(?<![!=<>])!=(?!=)/, swap: () => '==' },
|
|
35
|
+
{ kind: 'an "and" that became an "or"', find: /&&/, swap: () => '||' },
|
|
36
|
+
{ kind: 'an "or" that became an "and"', find: /\|\|/, swap: () => '&&' },
|
|
37
|
+
{ kind: 'a condition inverted', find: /\bif\s*\(\s*!/, swap: (m) => m.replace('!', '') },
|
|
38
|
+
{ kind: 'a sign reversed', find: /(?<=[\w)\]]\s?)\+(?=\s?[\w(])/, swap: () => '-' },
|
|
39
|
+
{ kind: 'a sign reversed', find: /(?<=[\w)\]]\s?)-(?=\s?[\w(])/, swap: () => '+' },
|
|
40
|
+
{ kind: 'a returned flag flipped', find: /\breturn\s+true\b/, swap: () => 'return false' },
|
|
41
|
+
{ kind: 'a returned flag flipped', find: /\breturn\s+false\b/, swap: () => 'return true' },
|
|
42
|
+
{ kind: 'a returned value emptied', find: /\breturn\s+(?!null|undefined|None|nil|true|false|;|$)[\w.[\]]+\s*;?\s*$/, swap: (m) => (m.trimEnd().endsWith(';') ? 'return null;' : 'return null') },
|
|
43
|
+
];
|
|
44
|
+
/**
|
|
45
|
+
* Is this position inside a regular expression literal?
|
|
46
|
+
*
|
|
47
|
+
* Only a rough reading, and it only decides what a change is *called*, never whether it is safe: a
|
|
48
|
+
* slash that opens a pattern follows an operator, a bracket or a keyword, never a value, which is what
|
|
49
|
+
* separates `/re/` from division.
|
|
50
|
+
*/
|
|
51
|
+
function inPattern(line, at) {
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < line.length) {
|
|
54
|
+
if (line[i] !== '/') {
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const before = line.slice(0, i).trimEnd();
|
|
59
|
+
const opens = before === '' || /[=(,:[!&|?{;]$|\b(return|typeof|case|of|in|and|or|not)$/.test(before);
|
|
60
|
+
if (!opens) {
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Find the closing slash, ignoring one escaped by a backslash and one inside a character class.
|
|
65
|
+
let cls = false;
|
|
66
|
+
for (let j = i + 1; j < line.length; j += 1) {
|
|
67
|
+
const ch = line[j];
|
|
68
|
+
if (ch === String.fromCharCode(92)) {
|
|
69
|
+
j += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ch === '[')
|
|
73
|
+
cls = true;
|
|
74
|
+
else if (ch === ']')
|
|
75
|
+
cls = false;
|
|
76
|
+
else if (ch === '/' && !cls) {
|
|
77
|
+
if (at > i && at < j)
|
|
78
|
+
return true;
|
|
79
|
+
i = j + 1;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
if (j === line.length - 1)
|
|
83
|
+
return false; // never closed on this line
|
|
84
|
+
}
|
|
85
|
+
if (i < line.length && line[i] === '/')
|
|
86
|
+
i += 1;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The mutants worth trying for this file, spread over the file rather than taken from the top: five
|
|
92
|
+
* changes to one function say much less about a suite than five changes in five places.
|
|
93
|
+
*/
|
|
94
|
+
export function mutantsFor(text, limit = 6) {
|
|
95
|
+
const ranges = textRanges(text);
|
|
96
|
+
const lines = text.split(String.fromCharCode(10));
|
|
97
|
+
const offsets = [];
|
|
98
|
+
let seen = 0;
|
|
99
|
+
for (const line of lines) {
|
|
100
|
+
offsets.push(seen);
|
|
101
|
+
seen += line.length + 1;
|
|
102
|
+
}
|
|
103
|
+
const found = [];
|
|
104
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
105
|
+
const line = lines[i];
|
|
106
|
+
if (line.trim().length < 3 || COULD_LOOP.test(line) || NOT_BEHAVIOUR.test(line))
|
|
107
|
+
continue;
|
|
108
|
+
for (const operator of OPERATORS) {
|
|
109
|
+
const hit = operator.find.exec(line);
|
|
110
|
+
if (!hit)
|
|
111
|
+
continue;
|
|
112
|
+
const at = offsets[i] + (hit.index ?? 0);
|
|
113
|
+
// Inside a string or a comment: not behaviour, and often not even syntax once changed.
|
|
114
|
+
if (ranges.some(([from, to]) => at > from && at < to))
|
|
115
|
+
continue;
|
|
116
|
+
const now = line.slice(0, hit.index) + operator.swap(hit[0]) + line.slice((hit.index ?? 0) + hit[0].length);
|
|
117
|
+
if (now === line)
|
|
118
|
+
continue;
|
|
119
|
+
// A change inside a regular expression is worth making - loosening a pattern is exactly how one
|
|
120
|
+
// silently stops matching what it was written for - but it is not a boundary or a comparison, and
|
|
121
|
+
// calling it one makes an otherwise good finding look like a bug in the tool.
|
|
122
|
+
const kind = inPattern(line, hit.index ?? 0) ? 'a pattern loosened' : operator.kind;
|
|
123
|
+
found.push({ line: i + 1, was: line.trim(), now: now.trim(), kind });
|
|
124
|
+
break; // one change per line keeps each answer about one thing
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return spread(found, limit);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Takes at most `limit`, as far apart in the file as they can be. A suite that catches everything in
|
|
131
|
+
* one function and nothing in the rest of the file should not look like a suite that catches
|
|
132
|
+
* everything, and taking the first six mutants in order is how that happens.
|
|
133
|
+
*/
|
|
134
|
+
function spread(all, limit) {
|
|
135
|
+
if (all.length <= limit)
|
|
136
|
+
return all;
|
|
137
|
+
const step = all.length / limit;
|
|
138
|
+
return Array.from({ length: limit }, (unused, i) => all[Math.floor(i * step)]);
|
|
139
|
+
}
|
|
140
|
+
/** The file with this one mutant applied, ready to be written and then put back. */
|
|
141
|
+
export function applyMutant(text, mutant) {
|
|
142
|
+
const lines = text.split(String.fromCharCode(10));
|
|
143
|
+
const line = lines[mutant.line - 1];
|
|
144
|
+
if (line === undefined || line.trim() !== mutant.was)
|
|
145
|
+
throw new Error(`line ${mutant.line} is not what the mutant was made from, so nothing was written`);
|
|
146
|
+
lines[mutant.line - 1] = line.replace(mutant.was, mutant.now);
|
|
147
|
+
return lines.join(String.fromCharCode(10));
|
|
148
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// `tewip prove <file>`: what your tests would not catch, shown by breaking the code and watching.
|
|
2
|
+
//
|
|
3
|
+
// The report is written for the reader who has to decide whether to trust a suite. It leads with what
|
|
4
|
+
// went unnoticed, quotes the exact change, names the test that caught each one that was caught, and
|
|
5
|
+
// says what the answer cost in seconds - because this is the one command here that is slow, and a
|
|
6
|
+
// number somebody waited four minutes for should say how long it took.
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
9
|
+
import { changeSummary } from '../quality/grounding.js';
|
|
10
|
+
import { chooseFramework, detectFrameworks } from '../quality/frameworks.js';
|
|
11
|
+
import { coverageFor, testFiles } from '../review/coverage.js';
|
|
12
|
+
import { importingTests, proveFile } from './prove.js';
|
|
13
|
+
import { testsReaching } from './reaches.js';
|
|
14
|
+
const SOURCE = /\.(js|jsx|mjs|cjs|ts|tsx|mts|cts|py|java|kt|go|cs|rb|php)$/i;
|
|
15
|
+
const IS_TEST = /(^|[\\/])(tests?|spec|specs|__tests__|e2e)[\\/]|[._-](test|tests|spec|specs)\.[a-z]+$|(^|[\\/])test_[^\\/]+\.py$|_test\.(go|py|rb)$|Tests?\.(java|kt|cs)$/i;
|
|
16
|
+
/** The source files to prove: the ones named, or the ones a change touched. */
|
|
17
|
+
export function filesToProve(cmd) {
|
|
18
|
+
if (cmd.files.length > 0)
|
|
19
|
+
return cmd.files.map((f) => f.split('\\').join('/'));
|
|
20
|
+
if (!cmd.diff)
|
|
21
|
+
return [];
|
|
22
|
+
const change = changeSummary(cmd.root, cmd.diff);
|
|
23
|
+
if (!change)
|
|
24
|
+
return [];
|
|
25
|
+
return change.files.map((f) => f.split('\\').join('/')).filter((f) => SOURCE.test(f) && !IS_TEST.test(f) && existsSync(resolve(cmd.root, f)));
|
|
26
|
+
}
|
|
27
|
+
export function renderProof(proofs) {
|
|
28
|
+
const tried = proofs.flatMap((p) => p.tried);
|
|
29
|
+
const unnoticed = tried.filter((t) => t.fate === 'unnoticed');
|
|
30
|
+
const noticed = tried.filter((t) => t.fate === 'noticed');
|
|
31
|
+
const invalid = tried.filter((t) => t.fate === 'did-not-compile');
|
|
32
|
+
const broke = tried.filter((t) => t.fate === 'runner-broke');
|
|
33
|
+
const seconds = proofs.reduce((total, p) => total + p.seconds, 0);
|
|
34
|
+
const lines = [
|
|
35
|
+
'# What your tests would not catch',
|
|
36
|
+
'',
|
|
37
|
+
tried.length === 0
|
|
38
|
+
? 'Nothing was tried. See below for why.'
|
|
39
|
+
: `${tried.length} change(s) were made to your code and the covering tests were run against each. **${unnoticed.length} went unnoticed.** ${noticed.length} were caught. Took ${seconds}s.`,
|
|
40
|
+
'',
|
|
41
|
+
];
|
|
42
|
+
if (unnoticed.length > 0) {
|
|
43
|
+
lines.push(`## Unnoticed (${unnoticed.length})`, '', 'Each of these is a change to your code that every test covering it still passed. The line was', 'covered; it was not checked.', '');
|
|
44
|
+
for (const proof of proofs) {
|
|
45
|
+
const gaps = proof.tried.filter((t) => t.fate === 'unnoticed');
|
|
46
|
+
if (gaps.length === 0)
|
|
47
|
+
continue;
|
|
48
|
+
lines.push(`### ${proof.file}`, '');
|
|
49
|
+
for (const gap of gaps) {
|
|
50
|
+
lines.push(`- **line ${gap.mutant.line}: ${gap.mutant.kind}**`);
|
|
51
|
+
lines.push(` - was: \`${gap.mutant.was.slice(0, 120)}\``);
|
|
52
|
+
lines.push(` - became: \`${gap.mutant.now.slice(0, 120)}\``);
|
|
53
|
+
}
|
|
54
|
+
lines.push('', `Run by: ${proof.covering.join(', ')}`, '');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (noticed.length > 0) {
|
|
58
|
+
lines.push(`## Caught (${noticed.length})`, '', 'These your tests would have stopped.', '');
|
|
59
|
+
for (const good of noticed)
|
|
60
|
+
lines.push(`- line ${good.mutant.line}: ${good.mutant.kind} — caught by \`${good.noticedBy}\``);
|
|
61
|
+
lines.push('');
|
|
62
|
+
}
|
|
63
|
+
const blocked = proofs.filter((p) => p.blocked);
|
|
64
|
+
if (blocked.length > 0) {
|
|
65
|
+
lines.push(`## Nothing could be proved here (${blocked.length})`, '');
|
|
66
|
+
for (const p of blocked)
|
|
67
|
+
lines.push(`- **${p.file}**: ${p.blocked}`);
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
70
|
+
if (invalid.length > 0 || broke.length > 0 || proofs.some((p) => p.notTried > 0 || p.setAside.length > 0)) {
|
|
71
|
+
lines.push('## Not counted', '');
|
|
72
|
+
if (invalid.length > 0)
|
|
73
|
+
lines.push(`- ${invalid.length} change(s) did not compile, so they say nothing about the tests. That is Tewip's fault, not yours.`);
|
|
74
|
+
for (const p of proofs)
|
|
75
|
+
for (const aside of p.setAside)
|
|
76
|
+
lines.push(`- \`${aside.test}\` was set aside: ${aside.why}. It covers ${p.file}, so it may well notice things the tests above did not.`);
|
|
77
|
+
if (broke.length > 0)
|
|
78
|
+
lines.push(`- ${broke.length} run(s) broke in the environment rather than the tests.`);
|
|
79
|
+
const left = proofs.reduce((total, p) => total + p.notTried, 0);
|
|
80
|
+
if (left > 0)
|
|
81
|
+
lines.push(`- ${left} change(s) were never tried: the time budget ran out. Raise it with \`--budget <seconds>\`.`);
|
|
82
|
+
lines.push('');
|
|
83
|
+
}
|
|
84
|
+
lines.push('---', '', 'Every line above was proved by running your own tests against your own code, one small change at', 'a time, and every file was put back exactly as it was. No model was called.', '', 'What this does not say: that the unnoticed changes are bugs, or that the caught ones mean the', 'suite is good. It says only which of these particular mistakes your tests would have stopped -', 'which is more than a coverage percentage can tell you, and less than a proof that the code works.');
|
|
85
|
+
return lines.join(String.fromCharCode(10));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One file, proved, with nothing printed: what the MCP server and any other caller use. `runProve`
|
|
89
|
+
* prints as it goes because a person waiting minutes deserves to see progress; over a protocol that
|
|
90
|
+
* speaks on stdout, printing would corrupt the stream.
|
|
91
|
+
*/
|
|
92
|
+
export function proveOne(root, file, opts = {}) {
|
|
93
|
+
const framework = chooseFramework(detectFrameworks(root), undefined, false);
|
|
94
|
+
if (!framework)
|
|
95
|
+
return 'No unit test framework was found here (jest, vitest, mocha, node --test, pytest, go test, maven, gradle, dotnet). Proving works by running your tests, so it needs one.';
|
|
96
|
+
if (!existsSync(resolve(root, file)))
|
|
97
|
+
return `No such file here: ${file}`;
|
|
98
|
+
const covering = coveringTests(root, file, testFiles(root));
|
|
99
|
+
return renderProof([proveFile(root, file, covering.tests, framework, { limit: opts.limit, budgetSeconds: opts.budgetSeconds })]);
|
|
100
|
+
}
|
|
101
|
+
/** The tests that could notice a change here: the ones that import the file, else the ones that reach it. */
|
|
102
|
+
function coveringTests(root, file, known) {
|
|
103
|
+
const direct = importingTests(root, coverageFor(root, [file], known), file);
|
|
104
|
+
if (direct.length > 0)
|
|
105
|
+
return { tests: direct, how: 'import it directly' };
|
|
106
|
+
const reached = testsReaching(root, file, known.map((t) => t.split(String.fromCharCode(92)).join('/')));
|
|
107
|
+
return { tests: reached.map((r) => r.testFile), how: reached.length > 0 ? `reach it through ${reached[0].steps} import(s), via ${reached[0].via}` : 'import it' };
|
|
108
|
+
}
|
|
109
|
+
export function runProve(cmd) {
|
|
110
|
+
const files = filesToProve(cmd);
|
|
111
|
+
if (files.length === 0) {
|
|
112
|
+
throw new Error('name a source file to prove, or pass --diff main...HEAD to prove what a change touched');
|
|
113
|
+
}
|
|
114
|
+
// A unit framework, never a browser one: proving needs dozens of runs, and a browser run costs
|
|
115
|
+
// minutes. Browser tests are where `tewip fix` and the drift lab work instead.
|
|
116
|
+
const framework = chooseFramework(detectFrameworks(cmd.root), undefined, false);
|
|
117
|
+
if (!framework) {
|
|
118
|
+
throw new Error('no unit test framework was found here (jest, vitest, mocha, pytest, go test, maven, gradle, dotnet). Proving needs one, because it works by running your tests.');
|
|
119
|
+
}
|
|
120
|
+
const known = testFiles(cmd.root);
|
|
121
|
+
const proofs = [];
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
if (!existsSync(resolve(cmd.root, file))) {
|
|
124
|
+
proofs.push({ file, covering: [], tried: [], blocked: 'no such file here', setAside: [], notTried: 0, seconds: 0 });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Nothing importing it by name is the normal case in a layered codebase, not the absence of
|
|
128
|
+
// tests, so the import chain is followed outwards before anything is concluded.
|
|
129
|
+
const { tests: covering, how } = coveringTests(cmd.root, file, known);
|
|
130
|
+
console.error(`${file}: ${covering.length} test file(s) ${how}`);
|
|
131
|
+
proofs.push(proveFile(cmd.root, file, covering, framework, {
|
|
132
|
+
limit: cmd.limit,
|
|
133
|
+
budgetSeconds: cmd.budgetSeconds,
|
|
134
|
+
onStep: (message) => console.error(` ${message}`),
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
const report = renderProof(proofs);
|
|
138
|
+
console.log(report);
|
|
139
|
+
if (cmd.out) {
|
|
140
|
+
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
141
|
+
writeFileSync(resolve(cmd.out), report + String.fromCharCode(10), 'utf8');
|
|
142
|
+
}
|
|
143
|
+
const unnoticed = proofs.flatMap((p) => p.tried).filter((t) => t.fate === 'unnoticed').length;
|
|
144
|
+
if (cmd.strict && unnoticed > 0) {
|
|
145
|
+
console.error(String.fromCharCode(10) + `${unnoticed} change(s) to your code went unnoticed by the tests covering it.`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
return { proofs, unnoticed };
|
|
149
|
+
}
|
|
150
|
+
/** Kept so a caller that only wants the numbers does not have to parse the report. */
|
|
151
|
+
export const readSource = (root, file) => readFileSync(resolve(root, file), 'utf8');
|
|
152
|
+
export const shortName = (root, file) => relative(root, resolve(root, file)).split('\\').join('/');
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Running the mutants: break one thing, run the tests that cover it, put the file back.
|
|
2
|
+
//
|
|
3
|
+
// This is the only part of Tewip that writes to a file the developer did not ask it to touch, so the
|
|
4
|
+
// restoring is not a detail, it is the feature. The rules:
|
|
5
|
+
//
|
|
6
|
+
// - the original bytes are held in memory before anything is written;
|
|
7
|
+
// - the file is put back in a `finally`, whatever happened, including a crash or a timeout;
|
|
8
|
+
// - after putting it back, the bytes are compared, and a mismatch is reported loudly rather than
|
|
9
|
+
// shrugged off, because the alternative is leaving somebody's code broken on disk;
|
|
10
|
+
// - the tests are run unmutated first. If they do not pass as they are, nothing is proved and
|
|
11
|
+
// nothing is mutated at all.
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join, relative, resolve } from 'node:path';
|
|
15
|
+
import { runTest } from '../quality/write-verify.js';
|
|
16
|
+
import { applyMutant, mutantsFor } from './mutants.js';
|
|
17
|
+
/**
|
|
18
|
+
* Does this mutated file still parse?
|
|
19
|
+
*
|
|
20
|
+
* The first version judged this by looking for words like "SyntaxError" or "no such file" in the test
|
|
21
|
+
* output, and it was wrong on the first real project it met: express's own static-file tests print
|
|
22
|
+
* ENOENT messages when they pass, so four valid mutants in `lib/utils.js` were thrown away as "did
|
|
23
|
+
* not compile" and the report claimed nothing could be shown. Reading a test's output to guess why it
|
|
24
|
+
* failed is precisely the habit this project exists to replace.
|
|
25
|
+
*
|
|
26
|
+
* Every operator here is syntax-preserving by construction, so a parse failure should be impossible.
|
|
27
|
+
* Where the language has a cheap exact check, it is used anyway - being sure costs 40 milliseconds.
|
|
28
|
+
*/
|
|
29
|
+
function stillParses(full) {
|
|
30
|
+
if (!/\.(js|cjs|mjs)$/i.test(full))
|
|
31
|
+
return true;
|
|
32
|
+
const checked = spawnSync(process.execPath, ['--check', full], { encoding: 'utf8', timeout: 20_000 });
|
|
33
|
+
return checked.status === 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Proves what the tests covering `file` would and would not catch.
|
|
37
|
+
*
|
|
38
|
+
* `covering` is decided by the caller, from which tests really import the file: a test matched only
|
|
39
|
+
* by its name might not exercise the code at all, and running it would produce a confident answer
|
|
40
|
+
* about nothing.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Do the tests actually load this file?
|
|
44
|
+
*
|
|
45
|
+
* They may not, and the first Python project this met proved it: in a src-layout repository `import
|
|
46
|
+
* humanize` resolved to a different checkout entirely, so editing `src/humanize/filesize.py` changed
|
|
47
|
+
* nothing the tests ran. Every mutant survived and the report said five changes went unnoticed - a
|
|
48
|
+
* confident, completely false accusation against a suite that is in fact thorough.
|
|
49
|
+
*
|
|
50
|
+
* So before any survival is believed, the file is broken on purpose in a way nothing can survive, and
|
|
51
|
+
* a covering test is run. If it still passes, the tests are reading some other copy - an installed
|
|
52
|
+
* package, a build output, a stale bundle - and the honest answer is to say so and stop. This is the
|
|
53
|
+
* same control the write gate uses: a check that cannot fail proves nothing, and neither does a test
|
|
54
|
+
* that cannot see the code.
|
|
55
|
+
*/
|
|
56
|
+
function testsLoadIt(root, full, original, test, framework, runMs) {
|
|
57
|
+
const control = /\.py$/i.test(full)
|
|
58
|
+
? `raise RuntimeError("tewip control")${String.fromCharCode(10)}${original}`
|
|
59
|
+
: /\.(js|cjs|mjs|ts|tsx|mts|cts|jsx)$/i.test(full)
|
|
60
|
+
? `throw new Error("tewip control");${String.fromCharCode(10)}${original}`
|
|
61
|
+
: `!!tewip-control!!${String.fromCharCode(10)}${original}`;
|
|
62
|
+
try {
|
|
63
|
+
writeFileSync(full, control, 'utf8');
|
|
64
|
+
return !runTest(framework, resolve(root, test), runMs).passed;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
restore(full, original);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function proveFile(root, file, covering, framework, opts = {}) {
|
|
71
|
+
const started = Date.now();
|
|
72
|
+
const limit = opts.limit ?? 6;
|
|
73
|
+
const budget = (opts.budgetSeconds ?? 600) * 1000;
|
|
74
|
+
const runMs = (opts.runSeconds ?? 180) * 1000;
|
|
75
|
+
const say = opts.onStep ?? (() => { });
|
|
76
|
+
const full = resolve(root, file);
|
|
77
|
+
const proof = { file, covering, tried: [], blocked: '', setAside: [], notTried: 0, seconds: 0 };
|
|
78
|
+
if (covering.length === 0) {
|
|
79
|
+
proof.blocked = 'no test imports this file, so no test could notice any change to it. That is the finding: this code is not covered by anything Tewip can name.';
|
|
80
|
+
return finish(proof, started);
|
|
81
|
+
}
|
|
82
|
+
const original = readFileSync(full, 'utf8');
|
|
83
|
+
const mutants = mutantsFor(original, limit);
|
|
84
|
+
if (mutants.length === 0) {
|
|
85
|
+
proof.blocked = 'nothing in this file could be changed in a way a test should be expected to catch - no comparison, no condition, no returned value outside of strings and comments';
|
|
86
|
+
return finish(proof, started);
|
|
87
|
+
}
|
|
88
|
+
// A test has to pass before it can be asked to notice anything, or a red suite turns into "your
|
|
89
|
+
// tests catch nothing", which is the worst kind of lie this command could tell.
|
|
90
|
+
//
|
|
91
|
+
// One bad file does not stop the answer, though. In python-humanize six test files cover
|
|
92
|
+
// filesize.py and one of them needs a pytest plugin that is not installed - refusing the whole
|
|
93
|
+
// question over that told the developer nothing, while the other five were ready to answer it. So
|
|
94
|
+
// the ones that cannot run are set aside by name, and only an empty list blocks.
|
|
95
|
+
const usable = [];
|
|
96
|
+
for (const test of covering) {
|
|
97
|
+
say(`checking ${test} passes as it is`);
|
|
98
|
+
const before = runTest(framework, resolve(root, test), runMs);
|
|
99
|
+
if (before.passed)
|
|
100
|
+
usable.push(test);
|
|
101
|
+
else
|
|
102
|
+
proof.setAside.push({ test, why: before.environment ? `the runner broke: ${firstLine(before.output)}` : before.skipped ? 'every test in it was skipped' : 'it does not pass as it is' });
|
|
103
|
+
}
|
|
104
|
+
if (usable.length === 0) {
|
|
105
|
+
proof.blocked = `none of the ${covering.length} test file(s) covering this pass as they are, so none can be asked whether they would notice a change. Fix the run first.`;
|
|
106
|
+
return finish(proof, started);
|
|
107
|
+
}
|
|
108
|
+
proof.covering = usable;
|
|
109
|
+
// The control: break the file outright and check that a covering test notices. Without this, a file
|
|
110
|
+
// the tests never load would produce a full report of things they "failed to catch".
|
|
111
|
+
say('checking the tests really load this file');
|
|
112
|
+
if (!testsLoadIt(root, full, original, usable[0], framework, runMs)) {
|
|
113
|
+
proof.blocked = `${usable[0]} passes even when this file is made unusable, so the tests are not loading the file that was edited - they are reading an installed copy or a build output. Nothing here can be proved until they run against this source (for a Python src layout, install it in editable mode: pip install -e .).`;
|
|
114
|
+
return finish(proof, started);
|
|
115
|
+
}
|
|
116
|
+
// Only the tests that pass may judge a mutant. A test that is already failing fails for every
|
|
117
|
+
// change too, which would have it "catch" everything - the exact false confidence this command
|
|
118
|
+
// exists to remove. It reported five catches by a set-aside file once; never again.
|
|
119
|
+
for (const mutant of mutants) {
|
|
120
|
+
if (Date.now() - started > budget) {
|
|
121
|
+
proof.notTried = mutants.length - proof.tried.length;
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
const at = Date.now();
|
|
125
|
+
say(`${mutant.kind}, line ${mutant.line}`);
|
|
126
|
+
let fate = 'unnoticed';
|
|
127
|
+
let noticedBy = '';
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(full, applyMutant(original, mutant), 'utf8');
|
|
130
|
+
if (!stillParses(full)) {
|
|
131
|
+
fate = 'did-not-compile';
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
for (const test of usable) {
|
|
135
|
+
const result = runTest(framework, resolve(root, test), runMs);
|
|
136
|
+
if (result.environment) {
|
|
137
|
+
fate = 'runner-broke';
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
// A failing test is a test that noticed, whatever it printed. An exception thrown by the
|
|
141
|
+
// changed code is the suite catching the change, not an excuse to discard the mutant.
|
|
142
|
+
if (!result.passed) {
|
|
143
|
+
fate = 'noticed';
|
|
144
|
+
noticedBy = test;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
fate = 'did-not-compile';
|
|
152
|
+
noticedBy = error instanceof Error ? error.message.slice(0, 120) : '';
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
restore(full, original);
|
|
156
|
+
}
|
|
157
|
+
proof.tried.push({ mutant, fate, noticedBy, seconds: Math.round((Date.now() - at) / 100) / 10 });
|
|
158
|
+
}
|
|
159
|
+
return finish(proof, started);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Puts the file back and checks that it really went back. Anything less would mean this command can
|
|
163
|
+
* leave somebody's source broken, which would make it unusable however good its answers were.
|
|
164
|
+
*/
|
|
165
|
+
function restore(full, original) {
|
|
166
|
+
writeFileSync(full, original, 'utf8');
|
|
167
|
+
if (readFileSync(full, 'utf8') !== original) {
|
|
168
|
+
throw new Error(`${full} could not be put back as it was. Check it before doing anything else: git checkout -- "${full}"`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const finish = (proof, started) => ({ ...proof, seconds: Math.round((Date.now() - started) / 1000) });
|
|
172
|
+
const firstLine = (output) => output.split(String.fromCharCode(10)).map((l) => l.trim()).find(Boolean)?.slice(0, 160) ?? '';
|
|
173
|
+
/** Which of the tests that cover a file really import it, and so could notice a change. */
|
|
174
|
+
export function importingTests(root, coverage, file) {
|
|
175
|
+
const wanted = file.split('\\').join('/');
|
|
176
|
+
return coverage
|
|
177
|
+
.filter((c) => c.how !== 'name' && c.covers.some((covered) => covered.split('\\').join('/') === wanted))
|
|
178
|
+
.map((c) => relative(root, join(root, c.testFile)).split('\\').join('/'));
|
|
179
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Which tests reach a file, following imports further than one step.
|
|
2
|
+
//
|
|
3
|
+
// This exists because the first honest run of `tewip prove` on Tewip's own code answered "no test
|
|
4
|
+
// imports this file, so nothing could notice a change" - about a file with 25 tests behind it. They
|
|
5
|
+
// import `vouch.ts`, which imports `assertions.ts`, and one step of import-following cannot see that.
|
|
6
|
+
// In any codebase with layers, most files are reached through another module, so a prover that only
|
|
7
|
+
// understands direct imports says "not covered" about nearly everything and is worthless.
|
|
8
|
+
//
|
|
9
|
+
// The chain is followed outwards from the file, not inwards from the tests, because the answer needed
|
|
10
|
+
// is "who would notice if this changed" and that is what a reverse import graph gives.
|
|
11
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, relative, resolve } from 'node:path';
|
|
13
|
+
import { importNames, importsIt } from '../review/coverage.js';
|
|
14
|
+
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.tewip', '.next']);
|
|
15
|
+
const SOURCE = /\.(?:[cm]?[jt]sx?|py|go|java|kt|cs|rb)$/i;
|
|
16
|
+
/** Every source file here, so "who imports this" can be answered without a build system. */
|
|
17
|
+
function sourceFiles(root, limit = 6000) {
|
|
18
|
+
const out = [];
|
|
19
|
+
const walk = (dir) => {
|
|
20
|
+
if (out.length >= limit)
|
|
21
|
+
return;
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = readdirSync(dir);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
const path = join(dir, entry);
|
|
31
|
+
let info;
|
|
32
|
+
try {
|
|
33
|
+
info = statSync(path);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (info.isDirectory()) {
|
|
39
|
+
if (!SKIP_DIR.has(entry))
|
|
40
|
+
walk(path);
|
|
41
|
+
}
|
|
42
|
+
else if (SOURCE.test(entry))
|
|
43
|
+
out.push(path);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
walk(root);
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The tests that reach `file`, directly or through up to `depth` modules.
|
|
51
|
+
*
|
|
52
|
+
* Every hop is a real import found in the source, so this stays a fact rather than a guess - the
|
|
53
|
+
* distinction the rest of Tewip keeps. It stops at the first depth that finds anything: a test one
|
|
54
|
+
* step away is a better witness than fifty tests four steps away, and running fifty test files to
|
|
55
|
+
* answer one question is how a command becomes too slow to use.
|
|
56
|
+
*/
|
|
57
|
+
export function testsReaching(root, file, tests, depth = 3) {
|
|
58
|
+
const all = sourceFiles(root);
|
|
59
|
+
const text = new Map();
|
|
60
|
+
const read = (path) => {
|
|
61
|
+
if (!text.has(path)) {
|
|
62
|
+
try {
|
|
63
|
+
text.set(path, readFileSync(path, 'utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
text.set(path, '');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return text.get(path);
|
|
70
|
+
};
|
|
71
|
+
const rel = (path) => relative(root, path).split(String.fromCharCode(92)).join('/');
|
|
72
|
+
const testSet = new Set(tests.map((t) => rel(resolve(root, t))));
|
|
73
|
+
const seen = new Set([rel(resolve(root, file))]);
|
|
74
|
+
// What the chain is: layer 0 is the file, layer 1 the modules importing it, and so on.
|
|
75
|
+
let layer = [file];
|
|
76
|
+
for (let step = 1; step <= depth; step += 1) {
|
|
77
|
+
const names = layer.flatMap((f) => (existsSync(resolve(root, f)) ? importNames(root, f) : []));
|
|
78
|
+
if (names.length === 0)
|
|
79
|
+
break;
|
|
80
|
+
const found = [];
|
|
81
|
+
const next = [];
|
|
82
|
+
for (const path of all) {
|
|
83
|
+
const where = rel(path);
|
|
84
|
+
if (seen.has(where))
|
|
85
|
+
continue;
|
|
86
|
+
if (!importsIt(read(path), names))
|
|
87
|
+
continue;
|
|
88
|
+
seen.add(where);
|
|
89
|
+
if (testSet.has(where))
|
|
90
|
+
found.push({ testFile: where, steps: step, via: layer.length === 1 ? layer[0] : `${layer[0]} and ${layer.length - 1} more` });
|
|
91
|
+
else
|
|
92
|
+
next.push(where);
|
|
93
|
+
}
|
|
94
|
+
// Something at this distance can see the change: stop, rather than dragging in the whole tree.
|
|
95
|
+
if (found.length > 0)
|
|
96
|
+
return found;
|
|
97
|
+
if (next.length === 0)
|
|
98
|
+
break;
|
|
99
|
+
layer = next;
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
@@ -23,6 +23,7 @@ export const cannotAct = {
|
|
|
23
23
|
'rerun-environment': 'a re-run belongs to whatever runs the tests, not to Tewip',
|
|
24
24
|
'settle-flaky': 'settling a flaky test changes a test file, which Tewip does only when asked directly with `tewip fix`',
|
|
25
25
|
'triage-undecided': 'Tewip cannot say why these failed, so it has nothing it could safely do about them',
|
|
26
|
+
'triage-vacuous-test': 'a test that cannot fail is for a person to decide about: making it check something changes what the test means, and guessing at what it was meant to check is how a suite quietly stops testing anything',
|
|
26
27
|
'cover-gap': '',
|
|
27
28
|
attest: '',
|
|
28
29
|
'stand-down': 'there is nothing here that matches the goal',
|
|
@@ -62,6 +62,20 @@ export function decide(goal, seen) {
|
|
|
62
62
|
changesFiles: false,
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
// Nothing is failing, so the goal looks met. Before believing that, ask whether the tests that
|
|
66
|
+
// say so could ever have said otherwise. This comes before the coverage gap on purpose: a missing
|
|
67
|
+
// test is honest about being missing, while a test that cannot fail reads as coverage and is
|
|
68
|
+
// counted as coverage, so it is the more misleading of the two.
|
|
69
|
+
const vacuous = seen.vacuous ?? [];
|
|
70
|
+
if (vacuous.length > 0) {
|
|
71
|
+
return {
|
|
72
|
+
act: 'triage-vacuous-test',
|
|
73
|
+
next: `put ${vacuous.length} test(s) that cannot fail in front of a person: ${list(vacuous.map((v) => v.name))}`,
|
|
74
|
+
why: `nothing the goal depends on is failing, but ${vacuous.length === 1 ? 'one of the tests saying so could never have said otherwise' : `${vacuous.length} of the tests saying so could never have said otherwise`}, so the green result does not mean what it looks like`,
|
|
75
|
+
evidence: vacuous.slice(0, 5).map((v) => `${v.name} (${v.file}:${v.line}): ${v.why}`),
|
|
76
|
+
changesFiles: false,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
65
79
|
if (seen.uncovered.length > 0) {
|
|
66
80
|
const first = seen.uncovered[0];
|
|
67
81
|
return {
|
|
@@ -10,6 +10,8 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
10
10
|
import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
|
|
11
11
|
import { alreadyTried, cannotAct, planFor } from './act.js';
|
|
12
12
|
import { decide } from './policy.js';
|
|
13
|
+
import { CANNOT_FAIL } from '../vouch/vouch.js';
|
|
14
|
+
import { vouchAll } from '../vouch/vouch-command.js';
|
|
13
15
|
const words = (text) => (text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((w) => !['the', 'and', 'for', 'this', 'that', 'with', 'keep', 'green', 'sprint', 'flow', 'test', 'tests'].includes(w));
|
|
14
16
|
/** Does this test or scenario belong to the goal? Deterministic, so a person can predict it. */
|
|
15
17
|
function about(goal, text) {
|
|
@@ -56,8 +58,28 @@ export function observe(root, goal, only) {
|
|
|
56
58
|
passing: attempted.length - failing.length,
|
|
57
59
|
noHistory: runs.length === 0,
|
|
58
60
|
runId: newest?.runId ?? '',
|
|
61
|
+
vacuous: vacuousTests(root, wanted),
|
|
59
62
|
};
|
|
60
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Tests covering this goal that could never fail, read from the files themselves.
|
|
66
|
+
*
|
|
67
|
+
* Only the verdicts that mean a test truly cannot fail are collected. A test that compares no value
|
|
68
|
+
* is left out here on purpose: it fails when the code throws, it is often meant to, and stopping an
|
|
69
|
+
* agent's whole goal over one would make this branch something people route around.
|
|
70
|
+
*/
|
|
71
|
+
function vacuousTests(root, goal) {
|
|
72
|
+
try {
|
|
73
|
+
const found = vouchAll(root, ['.']);
|
|
74
|
+
return found
|
|
75
|
+
.flatMap((file) => file.judgements)
|
|
76
|
+
.filter((j) => CANNOT_FAIL.includes(j.verdict) && about(goal, `${j.file} ${j.name}`))
|
|
77
|
+
.map((j) => ({ name: j.name, file: j.file, line: j.line, why: j.why }));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return []; // an unreadable tree is not a tree full of worthless tests
|
|
81
|
+
}
|
|
82
|
+
}
|
|
61
83
|
/** Scenarios of a kept plan that no attempted test can be matched to, worst priority first. */
|
|
62
84
|
function uncoveredScenarios(root, goal, attempted) {
|
|
63
85
|
const dir = join(root, '.tewip', 'plans');
|
|
@@ -108,7 +130,7 @@ export function renderPursuit(goal, seen, decision, acting) {
|
|
|
108
130
|
? acting
|
|
109
131
|
? 'This changes files, so it runs through the gate that proves a change before keeping it: anything unproved is thrown away.'
|
|
110
132
|
: 'This would change files, so Tewip has not done it. Run the command it names, or pass `--act`.'
|
|
111
|
-
: 'Tewip has not done anything: this is a decision, and the action is yours.', '', 'The order of these decisions is fixed and written down, not chosen by a model: a real bug first,', 'then the environment, then flakiness, then a coverage gap, and only then is a goal worth attesting.');
|
|
133
|
+
: 'Tewip has not done anything: this is a decision, and the action is yours.', '', 'The order of these decisions is fixed and written down, not chosen by a model: a real bug first,', 'then the environment, then flakiness, then anything failing it cannot explain, then a test that', 'cannot fail, then a coverage gap, and only then is a goal worth attesting.');
|
|
112
134
|
return lines.join('\n');
|
|
113
135
|
}
|
|
114
136
|
/** The audit file the team owns: every action Tewip took on its own, and every one it refused. */
|
|
@@ -116,8 +116,41 @@ function jsFrameworks(dir, deps) {
|
|
|
116
116
|
out.push({ id, label, browser, cwd: dir, testDir: example ? dirname(example) : join(dir, 'test'), example, fileFor: sameSuffix(example, '.test.ts', /\.(spec|test)\.[mc]?[jt]sx?$/), command: (f) => `${run} ${q(relative(dir, f).replaceAll('\\', '/'))}`, suite: run });
|
|
117
117
|
break;
|
|
118
118
|
}
|
|
119
|
+
// Mocha, and Node's own test runner. Both were missing, and their absence was not a small gap:
|
|
120
|
+
// express and a great deal of the JavaScript world run on Mocha, `node --test` needs no dependency
|
|
121
|
+
// at all and is what this project's own suite uses - so Tewip could not write, fix or prove a test
|
|
122
|
+
// in its own repository. A runner is only "supported" if it is detected.
|
|
123
|
+
if (out.length === 0) {
|
|
124
|
+
const anyTest = testFiles(dir, /\.(spec|test)\.[mc]?[jt]sx?$|[\/]test[\/].*\.[mc]?js$/)[0] ?? null;
|
|
125
|
+
const mocha = /"mocha"\s*:/.test(deps);
|
|
126
|
+
const nodeTest = !mocha && (/node\s+--test/.test(deps) || (anyTest !== null && /require\(['"]node:test['"]\)|from\s+['"]node:test['"]/.test(readIfThere(anyTest))));
|
|
127
|
+
if (mocha || nodeTest) {
|
|
128
|
+
const id = mocha ? 'mocha' : 'node';
|
|
129
|
+
const run = mocha ? 'npx mocha' : 'node --test';
|
|
130
|
+
out.push({
|
|
131
|
+
id,
|
|
132
|
+
label: mocha ? 'Mocha (JavaScript/TypeScript)' : "Node's own test runner (JavaScript/TypeScript)",
|
|
133
|
+
browser: false,
|
|
134
|
+
cwd: dir,
|
|
135
|
+
testDir: anyTest ? dirname(anyTest) : join(dir, 'test'),
|
|
136
|
+
example: anyTest,
|
|
137
|
+
fileFor: sameSuffix(anyTest, '.test.js', /\.(spec|test)\.[mc]?[jt]sx?$/),
|
|
138
|
+
command: (f) => `${run} ${q(relative(dir, f).split(String.fromCharCode(92)).join('/'))}`,
|
|
139
|
+
suite: run,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
119
143
|
return out;
|
|
120
144
|
}
|
|
145
|
+
/** A file's text, or nothing: used only to recognise a runner from how a test imports it. */
|
|
146
|
+
function readIfThere(file) {
|
|
147
|
+
try {
|
|
148
|
+
return readFileSync(file, 'utf8').slice(0, 4000);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return '';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
121
154
|
/** The project's own Python (its virtual environment) when it has one, else the one on the PATH. */
|
|
122
155
|
function pythonOf(dir) {
|
|
123
156
|
for (const env of ['.venv', 'venv', 'env']) {
|
|
@@ -34,6 +34,17 @@ function pageAtFailure(output, cwd) {
|
|
|
34
34
|
* run is tried again, and if it keeps breaking, the environment is named, never the test.
|
|
35
35
|
*/
|
|
36
36
|
const RUNNER_BROKE = /has not captured in \d+ ?ms|failed \d+ times \(timeout\)\. Giving up|Disconnected[, ].{0,40}because no message|No captured browser|EADDRINUSE|Timed out waiting \d+ms from config\.webServer|webServer.{0,60}(?:exited|timed out)|ENOSPC|spawnSync .* ETIMEDOUT/i;
|
|
37
|
+
/**
|
|
38
|
+
* The environment for a test run. NODE_TEST_CONTEXT is removed: a `node --test` child that inherits
|
|
39
|
+
* it reports as a subtest of whatever spawned it instead of by its own exit code, so a failing run
|
|
40
|
+
* looks like a passing one. That has already cost this project once, in the auto-fix gate, and it
|
|
41
|
+
* matters wherever Tewip runs a test runner from inside one.
|
|
42
|
+
*/
|
|
43
|
+
function childEnv(extra) {
|
|
44
|
+
const env = { ...process.env, ...extra };
|
|
45
|
+
delete env.NODE_TEST_CONTEXT;
|
|
46
|
+
return env;
|
|
47
|
+
}
|
|
37
48
|
export function runTest(framework, file, timeoutMs = 180_000) {
|
|
38
49
|
let result = runOnce(framework, file, timeoutMs);
|
|
39
50
|
for (let retry = 0; retry < 2 && !result.passed && RUNNER_BROKE.test(result.output); retry++)
|
|
@@ -43,7 +54,7 @@ export function runTest(framework, file, timeoutMs = 180_000) {
|
|
|
43
54
|
return result;
|
|
44
55
|
}
|
|
45
56
|
function runOnce(framework, file, timeoutMs) {
|
|
46
|
-
const r = spawnSync(framework.command(file), { cwd: framework.cwd, env:
|
|
57
|
+
const r = spawnSync(framework.command(file), { cwd: framework.cwd, env: childEnv(framework.env), shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 20 * 1024 * 1024 });
|
|
47
58
|
// Colour codes are noise to a reader and would stop a quoted line from matching.
|
|
48
59
|
const output = `${r.stdout ?? ''}\n${r.stderr ?? ''}${r.error ? `\n${r.error.message}` : ''}`.replace(/\u001b\[[0-9;]*[A-Za-z]/g, '').trim();
|
|
49
60
|
if (r.status === 0 && onlySkipped(output))
|
|
@@ -8,6 +8,29 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
8
8
|
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
9
9
|
const TEST_FILE = /(?:\.|_)(?:spec|test)\.[cm]?[jt]sx?$|(?:^|[\\/])test_[^\\/]+\.py$|_test\.py$|_test\.go$|Test(?:s)?\.(?:java|kt|cs)$|\.cy\.[jt]sx?$|\.feature$|\.robot$/i;
|
|
10
10
|
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.tewip', '.next']);
|
|
11
|
+
/**
|
|
12
|
+
* A test file that does not say so in its name.
|
|
13
|
+
*
|
|
14
|
+
* Mocha's convention - and most of the JavaScript world before ~2018 - is `test/utils.js`, with no
|
|
15
|
+
* suffix at all. Requiring `.test.js` meant every Mocha project looked like a project with no tests:
|
|
16
|
+
* express has 88 of them, and Tewip reported that nothing covered any file in it. The name is checked
|
|
17
|
+
* first because it is free; only a file already sitting in a test folder is opened to be sure.
|
|
18
|
+
*/
|
|
19
|
+
function declaresTests(dir, path) {
|
|
20
|
+
const where = dir.split(String.fromCharCode(92)).join('/');
|
|
21
|
+
if (!/(?:^|\/)(?:tests?|spec|specs|__tests__)(?:\/|$)/i.test(where))
|
|
22
|
+
return false;
|
|
23
|
+
if (/(?:^|\/)(?:fixtures?|support|helpers?|mocks?|__mocks__|data|snapshots?)(?:\/|$)/i.test(where))
|
|
24
|
+
return false;
|
|
25
|
+
if (!/\.(?:[cm]?[jt]sx?|py)$/i.test(path))
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
return /\b(?:describe|it|test|context|suite)\s*\(|^\s*def\s+test_/m.test(readFileSync(path, 'utf8').slice(0, 8000));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
11
34
|
/** Every test file in the project. */
|
|
12
35
|
export function testFiles(root, limit = 4000) {
|
|
13
36
|
const out = [];
|
|
@@ -34,7 +57,7 @@ export function testFiles(root, limit = 4000) {
|
|
|
34
57
|
if (!SKIP_DIR.has(entry))
|
|
35
58
|
walk(path);
|
|
36
59
|
}
|
|
37
|
-
else if (TEST_FILE.test(entry))
|
|
60
|
+
else if (TEST_FILE.test(entry) || declaresTests(dir, path))
|
|
38
61
|
out.push(path);
|
|
39
62
|
}
|
|
40
63
|
};
|
|
@@ -47,7 +70,7 @@ export function testFiles(root, limit = 4000) {
|
|
|
47
70
|
*/
|
|
48
71
|
export const isCode = (file) => /\.(?:[cm]?[jt]sx?|py|rb|go|java|kt|cs|php|rs|vue|svelte|scala|swift|sql)$/i.test(file) && !/(?:^|[\/])(?:package-lock\.json|yarn\.lock)$/i.test(file);
|
|
49
72
|
/** The module names a file can be imported by: its path, its name, and its folder's front door. */
|
|
50
|
-
function importNames(root, changed) {
|
|
73
|
+
export function importNames(root, changed) {
|
|
51
74
|
const full = resolve(root, changed);
|
|
52
75
|
const noExt = full.slice(0, full.length - extname(full).length);
|
|
53
76
|
const names = new Set([basename(noExt), relative(root, noExt).split(sep).join('/')]);
|
|
@@ -94,8 +117,8 @@ function packageNames(root, changed) {
|
|
|
94
117
|
}
|
|
95
118
|
return [...new Set(out)].filter(Boolean);
|
|
96
119
|
}
|
|
97
|
-
/** Does this
|
|
98
|
-
function importsIt(source, names) {
|
|
120
|
+
/** Does this source import that file? Exported so the prover can follow imports further. */
|
|
121
|
+
export function importsIt(source, names) {
|
|
99
122
|
for (const name of names) {
|
|
100
123
|
const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
101
124
|
const patterns = [
|
|
@@ -8,6 +8,7 @@ import { coverageFor, uncovered } from './coverage.js';
|
|
|
8
8
|
import { renderReview, reviewChange } from './review.js';
|
|
9
9
|
import { changedExpectations } from './expectations.js';
|
|
10
10
|
import { changedAgreements } from './agreements.js';
|
|
11
|
+
import { changedTestLines, vouchChanged } from '../vouch/changed.js';
|
|
11
12
|
/** Every failure Tewip has diagnosed in this project, from the history it keeps. */
|
|
12
13
|
function history(root, only) {
|
|
13
14
|
const dir = join(root, '.tewip', 'history');
|
|
@@ -33,7 +34,10 @@ export async function runReview(cmd) {
|
|
|
33
34
|
const framework = chooseFramework(detectFrameworks(cmd.root), undefined, false) ?? detectFrameworks(cmd.root)[0] ?? null;
|
|
34
35
|
const ownsThem = framework ? review.runFirst.some((f) => resolve(cmd.root, f).startsWith(resolve(framework.testDir))) : false;
|
|
35
36
|
const runCommand = framework && ownsThem ? (chosen) => chosen.map((f) => framework.command(resolve(cmd.root, f))).join(' && ') : undefined;
|
|
36
|
-
|
|
37
|
+
// Whether the tests in this change could ever fail. Costs nothing, calls no model, and is the one
|
|
38
|
+
// question an agent-written diff most needs asked of it.
|
|
39
|
+
const vouched = changedTestLines(vouchChanged(cmd.root, files));
|
|
40
|
+
const report = [renderReview(review, runCommand), ...(vouched.length > 0 ? ['', ...vouched] : [])].join(String.fromCharCode(10));
|
|
37
41
|
console.log(report);
|
|
38
42
|
if (past.length === 0) {
|
|
39
43
|
console.log('\nNo failure history yet, so the "what has gone wrong here before" part is empty. It fills itself: run `tewip explain` or `tewip report` on your CI reports and every later review knows more.');
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Vouching for the tests a change touched, rather than for a whole repository.
|
|
2
|
+
//
|
|
3
|
+
// This is where the question actually pays off. Nobody runs a checker over 5,000 existing tests, but
|
|
4
|
+
// every pull request now arrives with tests an agent wrote, and the reviewer has minutes. So the
|
|
5
|
+
// review and the CI comment ask only about the test files in the diff, and say nothing at all when
|
|
6
|
+
// there is nothing to say - a section that appears on every PR to report "all fine" is a section
|
|
7
|
+
// people learn to scroll past.
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { CANNOT_FAIL, vouchFile } from './vouch.js';
|
|
11
|
+
export function vouchChanged(root, changed) {
|
|
12
|
+
const out = { files: 0, read: 0, cannotFail: [], crashOnly: [] };
|
|
13
|
+
for (const file of changed) {
|
|
14
|
+
const full = join(root, file);
|
|
15
|
+
if (!existsSync(full))
|
|
16
|
+
continue; // deleted in this change, so there is nothing to read
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = readFileSync(full, 'utf8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const vouched = vouchFile(file.split('\\').join('/'), text);
|
|
25
|
+
if (vouched.judgements.length === 0)
|
|
26
|
+
continue;
|
|
27
|
+
out.files += 1;
|
|
28
|
+
out.read += vouched.judgements.length;
|
|
29
|
+
out.cannotFail.push(...vouched.judgements.filter((j) => CANNOT_FAIL.includes(j.verdict)));
|
|
30
|
+
out.crashOnly.push(...vouched.judgements.filter((j) => j.verdict === 'crash-only'));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The section for a review or a PR comment. Empty when every test in the change can fail: silence is
|
|
36
|
+
* the right report, and it keeps the finding worth reading when it does appear.
|
|
37
|
+
*/
|
|
38
|
+
export function changedTestLines(seen) {
|
|
39
|
+
if (seen.cannotFail.length === 0 && seen.crashOnly.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
const lines = [`## Tests in this change that cannot fail (${seen.cannotFail.length} of ${seen.read})`, ''];
|
|
42
|
+
if (seen.cannotFail.length === 0)
|
|
43
|
+
lines.length = 0;
|
|
44
|
+
for (const j of seen.cannotFail.slice(0, 10)) {
|
|
45
|
+
lines.push(`- **${j.name}** — \`${j.file}:${j.line}\``);
|
|
46
|
+
if (j.evidence)
|
|
47
|
+
lines.push(` - \`${j.evidence.replace(/`/g, "'")}\``);
|
|
48
|
+
lines.push(` - ${j.why}`);
|
|
49
|
+
}
|
|
50
|
+
if (seen.cannotFail.length > 10)
|
|
51
|
+
lines.push(`- …and ${seen.cannotFail.length - 10} more`);
|
|
52
|
+
if (seen.cannotFail.length > 0)
|
|
53
|
+
lines.push('', 'A test that cannot fail still passes and still counts as coverage.', '');
|
|
54
|
+
if (seen.crashOnly.length > 0) {
|
|
55
|
+
lines.push(`## Tests in this change that compare no value (${seen.crashOnly.length})`, '', 'These fail if the code throws and pass whatever it returns. Sometimes that is the intention.', '', ...seen.crashOnly.slice(0, 8).map((j) => `- ${j.name} — \`${j.file}:${j.line}\``), ...(seen.crashOnly.length > 8 ? [`- …and ${seen.crashOnly.length - 8} more`] : []), '');
|
|
56
|
+
}
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -137,4 +137,22 @@ server.registerTool('vouch_tests', {
|
|
|
137
137
|
const { vouchReport } = await import('../engine/vouch/run-vouch.js');
|
|
138
138
|
return vouchReport(root, paths && paths.length > 0 ? paths : ['.']).report;
|
|
139
139
|
}));
|
|
140
|
+
server.registerTool('prove_tests_catch_bugs', {
|
|
141
|
+
title: 'Would the tests catch a bug here?',
|
|
142
|
+
description: "Breaks one source file in small, deliberate ways - a boundary moved by one, a condition inverted, an and that became an or, a returned flag flipped - runs the tests that reach that file, and reports every change none of them noticed. This is execution, not opinion: the line was covered, it just was not checked. Slow by nature (each change means a real test run, so expect minutes) and it needs a unit test framework. The file is always put back exactly as it was, and if the tests turn out not to load the file at all it says so instead of reporting false holes. Use it after writing or changing code, to find out whether the tests you are relying on would have stopped a mistake.",
|
|
143
|
+
inputSchema: {
|
|
144
|
+
project,
|
|
145
|
+
file: z.string().describe('One source file, relative to the project, e.g. src/pricing.js'),
|
|
146
|
+
limit: z.number().optional().describe('How many changes to try (default 6). More is a better answer and a longer wait.'),
|
|
147
|
+
budgetSeconds: z.number().optional().describe('Total seconds to spend before stopping and saying how many were left (default 600)'),
|
|
148
|
+
},
|
|
149
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
150
|
+
}, ({ project: root, file, limit, budgetSeconds }) => answer(async () => {
|
|
151
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
152
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
153
|
+
// runProve prints its report; over MCP the report is the return value and printing would corrupt
|
|
154
|
+
// the protocol, so the pieces are called directly.
|
|
155
|
+
const { proveOne } = await import('../engine/prove/prove-command.js');
|
|
156
|
+
return proveOne(root, file, { limit, budgetSeconds });
|
|
157
|
+
}));
|
|
140
158
|
await server.connect(new StdioServerTransport());
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-failures",
|