tewip-ai 0.66.0 → 0.67.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.
|
@@ -96,6 +96,18 @@ function inPattern(line, at) {
|
|
|
96
96
|
* changes to one function say much less about a suite than five changes in five places.
|
|
97
97
|
*/
|
|
98
98
|
export function mutantsFor(text, limit = 6) {
|
|
99
|
+
return spread(candidateMutants(text), limit);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Every change this file allows, before any sampling.
|
|
103
|
+
*
|
|
104
|
+
* The caller needs this number, not only the sample. `prove engine/verdict/verdict.ts --limit 6`
|
|
105
|
+
* used to report "6 change(s) were made... 0 went unnoticed", and the file allows 20: eight of the
|
|
106
|
+
* twelve it never tried were in code the tests had never been asked about. A clean report that
|
|
107
|
+
* silently covers a third of the file reads exactly like a clean report that covers all of it,
|
|
108
|
+
* which is the one thing this project will not print.
|
|
109
|
+
*/
|
|
110
|
+
export function candidateMutants(text) {
|
|
99
111
|
const ranges = textRanges(text);
|
|
100
112
|
const lines = text.split(String.fromCharCode(10));
|
|
101
113
|
const offsets = [];
|
|
@@ -128,14 +140,14 @@ export function mutantsFor(text, limit = 6) {
|
|
|
128
140
|
break; // one change per line keeps each answer about one thing
|
|
129
141
|
}
|
|
130
142
|
}
|
|
131
|
-
return
|
|
143
|
+
return found;
|
|
132
144
|
}
|
|
133
145
|
/**
|
|
134
146
|
* Takes at most `limit`, as far apart in the file as they can be. A suite that catches everything in
|
|
135
147
|
* one function and nothing in the rest of the file should not look like a suite that catches
|
|
136
148
|
* everything, and taking the first six mutants in order is how that happens.
|
|
137
149
|
*/
|
|
138
|
-
function spread(all, limit) {
|
|
150
|
+
export function spread(all, limit) {
|
|
139
151
|
if (all.length <= limit)
|
|
140
152
|
return all;
|
|
141
153
|
const step = all.length / limit;
|
|
@@ -24,6 +24,36 @@ export function filesToProve(cmd) {
|
|
|
24
24
|
return [];
|
|
25
25
|
return change.files.map((f) => f.split('\\').join('/')).filter((f) => SOURCE.test(f) && !IS_TEST.test(f) && existsSync(resolve(cmd.root, f)));
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* " of the 20" - the part of the headline that keeps a clean answer honest.
|
|
29
|
+
*
|
|
30
|
+
* `--limit` samples the file, so "6 change(s) were made... 0 went unnoticed" was true of six lines
|
|
31
|
+
* and read as true of the file. Found on Tewip's own `engine/verdict/verdict.ts`: a report of six
|
|
32
|
+
* caught out of six, in a file that allows twenty. Silent when everything the file allows was tried,
|
|
33
|
+
* because then there is nothing to warn about.
|
|
34
|
+
*/
|
|
35
|
+
function ofHowMany(proofs, tried) {
|
|
36
|
+
const found = proofs.reduce((total, p) => total + p.found, 0);
|
|
37
|
+
return found > tried ? ` of the ${found}` : '';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The two versions of a line, cut so that the difference between them is visible.
|
|
41
|
+
*
|
|
42
|
+
* Both used to be truncated at 120 characters from the start, which on a long line put the change
|
|
43
|
+
* past the cut and printed an identical pair. Found on Tewip's own `verdict.ts:112`, where a real
|
|
44
|
+
* hole - nothing tests the "and 4 more" count - was reported as `was: X` / `became: X`. A reader
|
|
45
|
+
* seeing that concludes the tool is broken, and stops reading the findings that are right.
|
|
46
|
+
*/
|
|
47
|
+
export function aroundTheChange(was, now, width = 120) {
|
|
48
|
+
if (was.length <= width && now.length <= width)
|
|
49
|
+
return [was, now];
|
|
50
|
+
let at = 0;
|
|
51
|
+
while (at < was.length && at < now.length && was[at] === now[at])
|
|
52
|
+
at += 1;
|
|
53
|
+
const from = Math.max(0, at - Math.floor(width / 3));
|
|
54
|
+
const cut = (text) => (from > 0 ? '...' : '') + text.slice(from, from + width) + (from + width < text.length ? '...' : '');
|
|
55
|
+
return [cut(was), cut(now)];
|
|
56
|
+
}
|
|
27
57
|
export function renderProof(proofs) {
|
|
28
58
|
const tried = proofs.flatMap((p) => p.tried);
|
|
29
59
|
const unnoticed = tried.filter((t) => t.fate === 'unnoticed');
|
|
@@ -36,7 +66,7 @@ export function renderProof(proofs) {
|
|
|
36
66
|
'',
|
|
37
67
|
tried.length === 0
|
|
38
68
|
? '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.`,
|
|
69
|
+
: `${tried.length}${ofHowMany(proofs, 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
70
|
'',
|
|
41
71
|
];
|
|
42
72
|
if (unnoticed.length > 0) {
|
|
@@ -47,9 +77,10 @@ export function renderProof(proofs) {
|
|
|
47
77
|
continue;
|
|
48
78
|
lines.push(`### ${proof.file}`, '');
|
|
49
79
|
for (const gap of gaps) {
|
|
80
|
+
const [was, now] = aroundTheChange(gap.mutant.was, gap.mutant.now);
|
|
50
81
|
lines.push(`- **line ${gap.mutant.line}: ${gap.mutant.kind}**`);
|
|
51
|
-
lines.push(` - was: \`${
|
|
52
|
-
lines.push(` - became: \`${
|
|
82
|
+
lines.push(` - was: \`${was}\``);
|
|
83
|
+
lines.push(` - became: \`${now}\``);
|
|
53
84
|
}
|
|
54
85
|
lines.push('', `Run by: ${proof.covering.join(', ')}`, '');
|
|
55
86
|
}
|
|
@@ -67,7 +98,8 @@ export function renderProof(proofs) {
|
|
|
67
98
|
lines.push(`- **${p.file}**: ${p.blocked}`);
|
|
68
99
|
lines.push('');
|
|
69
100
|
}
|
|
70
|
-
|
|
101
|
+
const neverSampled = proofs.filter((p) => p.found > p.tried.length + p.notTried);
|
|
102
|
+
if (invalid.length > 0 || broke.length > 0 || neverSampled.length > 0 || proofs.some((p) => p.notTried > 0 || p.setAside.length > 0)) {
|
|
71
103
|
lines.push('## Not counted', '');
|
|
72
104
|
if (invalid.length > 0)
|
|
73
105
|
lines.push(`- ${invalid.length} change(s) did not compile, so they say nothing about the tests. That is Tewip's fault, not yours.`);
|
|
@@ -79,6 +111,9 @@ export function renderProof(proofs) {
|
|
|
79
111
|
const left = proofs.reduce((total, p) => total + p.notTried, 0);
|
|
80
112
|
if (left > 0)
|
|
81
113
|
lines.push(`- ${left} change(s) were never tried: the time budget ran out. Raise it with \`--budget <seconds>\`.`);
|
|
114
|
+
for (const p of neverSampled) {
|
|
115
|
+
lines.push(`- **${p.file}** allows ${p.found} change(s) and ${p.tried.length + p.notTried} were taken, spread across the file. Nothing above says anything about the other ${p.found - p.tried.length - p.notTried}. To ask about all of them: \`--limit ${p.found}\`.`);
|
|
116
|
+
}
|
|
82
117
|
lines.push('');
|
|
83
118
|
}
|
|
84
119
|
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.');
|
|
@@ -121,7 +156,7 @@ export function runProve(cmd) {
|
|
|
121
156
|
const proofs = [];
|
|
122
157
|
for (const file of files) {
|
|
123
158
|
if (!existsSync(resolve(cmd.root, file))) {
|
|
124
|
-
proofs.push({ file, covering: [], tried: [], blocked: 'no such file here', setAside: [], notTried: 0, seconds: 0 });
|
|
159
|
+
proofs.push({ file, covering: [], tried: [], blocked: 'no such file here', setAside: [], notTried: 0, found: 0, seconds: 0 });
|
|
125
160
|
continue;
|
|
126
161
|
}
|
|
127
162
|
// Nothing importing it by name is the normal case in a layered codebase, not the absence of
|
|
@@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
13
13
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
14
|
import { join, relative, resolve } from 'node:path';
|
|
15
15
|
import { runTest } from '../quality/write-verify.js';
|
|
16
|
-
import { applyMutant,
|
|
16
|
+
import { applyMutant, candidateMutants, spread } from './mutants.js';
|
|
17
17
|
/**
|
|
18
18
|
* Does this mutated file still parse?
|
|
19
19
|
*
|
|
@@ -74,14 +74,18 @@ export function proveFile(root, file, covering, framework, opts = {}) {
|
|
|
74
74
|
const runMs = (opts.runSeconds ?? 180) * 1000;
|
|
75
75
|
const say = opts.onStep ?? (() => { });
|
|
76
76
|
const full = resolve(root, file);
|
|
77
|
-
const proof = { file, covering, tried: [], blocked: '', setAside: [], notTried: 0, seconds: 0 };
|
|
77
|
+
const proof = { file, covering, tried: [], blocked: '', setAside: [], notTried: 0, found: 0, seconds: 0 };
|
|
78
78
|
if (covering.length === 0) {
|
|
79
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
80
|
return finish(proof, started);
|
|
81
81
|
}
|
|
82
82
|
const original = readFileSync(full, 'utf8');
|
|
83
83
|
const stopGuarding = guardAgainstBeingKilled(full, original);
|
|
84
|
-
|
|
84
|
+
// The whole set first, then the sample: `found` is what the file allows and `mutants` is what
|
|
85
|
+
// there was time to ask about, and the report has to be able to tell the reader the difference.
|
|
86
|
+
const candidates = candidateMutants(original);
|
|
87
|
+
const mutants = spread(candidates, limit);
|
|
88
|
+
proof.found = candidates.length;
|
|
85
89
|
if (mutants.length === 0) {
|
|
86
90
|
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';
|
|
87
91
|
return finish(proof, started, stopGuarding);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|