tewip-ai 0.60.0 → 0.62.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
CHANGED
|
@@ -23,6 +23,41 @@ Formerly *triage-agent*: the repository moved to `hmamut39/tewip`, and GitHub re
|
|
|
23
23
|
**Status: phases 1 to 14 are built; the two auto-fix phases (5 and 8) are the ones still in progress.** Tewip collects failures from any framework, diagnoses them with rules first and a model only for what rules cannot settle, and turns that into work for each role: a CI comment and merge gate, test plans and verified tests, a flake dossier, a PR-risk review, security scanning, tickets in Jira, Rally, GitHub Issues, Azure Boards or Linear, checks generated from a Figma design, specifications written for people, and - opt in, and only when proved - fixes to test or application code. It runs live in GitHub CI and answers questions from AI assistants through MCP. On fresh real Gutenberg CI failures, with the pipeline frozen before labelling, accuracy is 93.4%; across six real datasets it ranges from 86.1% to 99.7%, and the caveats are in [eval/README.md](eval/README.md).
|
|
24
24
|
|
|
25
25
|
|
|
26
|
+
## If your CI already writes a coverage report, point Tewip at it
|
|
27
|
+
|
|
28
|
+
Tewip decides what a test reaches by following imports, which is a fact rather than a name match -
|
|
29
|
+
and which cannot see a test that posts a GraphQL query, hits an HTTP route or renders a controller.
|
|
30
|
+
On a Django, Rails or Spring codebase that is most of the suite, so `tewip done` used to answer
|
|
31
|
+
*"cannot say"* to the question that matters most.
|
|
32
|
+
|
|
33
|
+
A coverage report settles it, and CI almost certainly produces one already. Nothing to configure:
|
|
34
|
+
|
|
35
|
+
| format | written by | found at |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `lcov.info` | JavaScript, TypeScript, Rust, Go, C++ | `coverage/lcov.info`, `lcov.info` |
|
|
38
|
+
| Cobertura `coverage.xml` | pytest-cov, coverlet (.NET), simplecov | `coverage.xml`, `coverage/coverage.xml` |
|
|
39
|
+
| JaCoCo | Java, Kotlin | `target/site/jacoco/jacoco.xml`, `build/reports/...` |
|
|
40
|
+
| Clover | PHPUnit | `clover.xml`, `build/logs/clover.xml` |
|
|
41
|
+
|
|
42
|
+
With one present, `done` stops guessing:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
### Is the changed code tested?
|
|
46
|
+
|
|
47
|
+
Yes: the suite executes saleor/graphql/utils/sorting.py.
|
|
48
|
+
|
|
49
|
+
_Decided by coverage.xml, which records what ran rather than what imports what._
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
and it will say the opposite just as plainly - *"is in coverage.xml with no line executed, so the
|
|
53
|
+
suite runs none of this"* is a **not ready**, not a shrug.
|
|
54
|
+
|
|
55
|
+
**What a coverage report cannot do.** It records which lines ran during the whole suite, not which
|
|
56
|
+
test ran them, so it answers *"is this code tested"* and not *"which tests would notice a change
|
|
57
|
+
here"*. `tewip pick` still narrows from imports and still refuses to narrow when it cannot show the
|
|
58
|
+
rest are unaffected. A report from a different checkout is ignored rather than believed: if the files
|
|
59
|
+
it names are not on disk, Tewip says nothing instead of reporting the whole change as untested.
|
|
60
|
+
|
|
26
61
|
## Running with no data leaving the building
|
|
27
62
|
|
|
28
63
|
A hospital, a bank or a government department usually cannot send source code to a hosted API, and
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// What the tests actually executed, read from a report the project already produces.
|
|
2
|
+
//
|
|
3
|
+
// Everything else in Tewip follows imports to decide what a test reaches, and Phases 46 and 52 found
|
|
4
|
+
// the same wall twice: a test that posts a GraphQL query, hits an HTTP route or renders a Rails
|
|
5
|
+
// controller never imports the code it exercises. `done` had to answer "cannot say" and `pick` had to
|
|
6
|
+
// refuse to narrow - honest both times, and useless to a Python or Rails team.
|
|
7
|
+
//
|
|
8
|
+
// A coverage report settles it, and CI already makes one. This reads the four formats that between
|
|
9
|
+
// them cover most of what runs anywhere:
|
|
10
|
+
//
|
|
11
|
+
// lcov.info JavaScript, TypeScript, C, C++, Rust, Go
|
|
12
|
+
// coverage.xml Cobertura: pytest-cov, Jacoco's cobertura output, coverlet for .NET, simplecov
|
|
13
|
+
// jacoco.xml Java and Kotlin
|
|
14
|
+
// clover.xml PHPUnit
|
|
15
|
+
//
|
|
16
|
+
// What a coverage report can and cannot say is worth being exact about. It records which lines ran
|
|
17
|
+
// during the whole suite - not which test ran them. So it answers "is this code executed by the
|
|
18
|
+
// tests at all", which is what `done` asks, and it does **not** answer "which tests would notice a
|
|
19
|
+
// change here", which is what `pick` asks. Only a per-test context report answers that, and this
|
|
20
|
+
// reads one when the project produced it.
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
22
|
+
import { join, relative, resolve } from 'node:path';
|
|
23
|
+
/** The names CI tools write, in the order worth trying. */
|
|
24
|
+
const REPORTS = [
|
|
25
|
+
'coverage/lcov.info',
|
|
26
|
+
'lcov.info',
|
|
27
|
+
'coverage.xml',
|
|
28
|
+
'coverage/coverage.xml',
|
|
29
|
+
'target/site/jacoco/jacoco.xml',
|
|
30
|
+
'build/reports/jacoco/test/jacocoTestReport.xml',
|
|
31
|
+
'jacoco.xml',
|
|
32
|
+
'clover.xml',
|
|
33
|
+
'coverage/clover.xml',
|
|
34
|
+
'build/logs/clover.xml',
|
|
35
|
+
];
|
|
36
|
+
const slash = (p) => p.split(String.fromCharCode(92)).join('/');
|
|
37
|
+
/** The coverage report this project has produced, or null when it has not produced one. */
|
|
38
|
+
export function findCoverage(root) {
|
|
39
|
+
for (const name of REPORTS) {
|
|
40
|
+
const path = join(root, name);
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
if (statSync(path).size > 64 * 1024 * 1024)
|
|
45
|
+
continue;
|
|
46
|
+
const text = readFileSync(path, 'utf8');
|
|
47
|
+
const parsed = name.endsWith('.info') ? readLcov(text, root) : readXml(text, root);
|
|
48
|
+
if (parsed.mentioned.size > 0)
|
|
49
|
+
return { from: slash(relative(root, path)), ...parsed };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/** LCOV: `SF:` opens a file, `DA:<line>,<hits>` records one line. */
|
|
58
|
+
export function readLcov(text, root = '') {
|
|
59
|
+
const executed = new Map();
|
|
60
|
+
const mentioned = new Set();
|
|
61
|
+
let file = '';
|
|
62
|
+
for (const raw of text.split(String.fromCharCode(10))) {
|
|
63
|
+
const line = raw.trim();
|
|
64
|
+
if (line.startsWith('SF:')) {
|
|
65
|
+
file = normalise(line.slice(3), root);
|
|
66
|
+
mentioned.add(file);
|
|
67
|
+
if (!executed.has(file))
|
|
68
|
+
executed.set(file, new Set());
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!file || !line.startsWith('DA:'))
|
|
72
|
+
continue;
|
|
73
|
+
const [at, hits] = line.slice(3).split(',');
|
|
74
|
+
if (Number(hits) > 0)
|
|
75
|
+
executed.get(file)?.add(Number(at));
|
|
76
|
+
}
|
|
77
|
+
return { executed, mentioned };
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Cobertura, JaCoCo and Clover in one reader, because all three say the same thing in the same
|
|
81
|
+
* shape: a filename attribute, then elements carrying a line number and a hit count.
|
|
82
|
+
*
|
|
83
|
+
* Read with regular expressions rather than an XML parser on purpose - these files reach hundreds of
|
|
84
|
+
* megabytes on a large suite, and nothing here needs the tree.
|
|
85
|
+
*/
|
|
86
|
+
export function readXml(text, root = '') {
|
|
87
|
+
const executed = new Map();
|
|
88
|
+
const mentioned = new Set();
|
|
89
|
+
// Cobertura nests <class filename="x">; JaCoCo has <sourcefile name="x"> inside <package name="y">;
|
|
90
|
+
// Clover has <file path="x">. Each opens a file and owns the line elements until the next one.
|
|
91
|
+
const opens = /<(?:class[^>]*filename|sourcefile[^>]*name|file[^>]*(?:path|name))\s*=\s*"([^"]+)"/g;
|
|
92
|
+
const starts = [];
|
|
93
|
+
for (const m of text.matchAll(opens))
|
|
94
|
+
starts.push({ at: m.index ?? 0, file: m[1] });
|
|
95
|
+
// JaCoCo names the package separately and the sourcefile without its directory, so the two are
|
|
96
|
+
// joined back together. Without this every file reads as a bare name that matches nothing.
|
|
97
|
+
const packages = [...text.matchAll(/<package[^>]*name\s*=\s*"([^"]+)"/g)].map((m) => ({ at: m.index ?? 0, name: m[1] }));
|
|
98
|
+
const packageFor = (at) => {
|
|
99
|
+
let found = '';
|
|
100
|
+
for (const p of packages) {
|
|
101
|
+
if (p.at > at)
|
|
102
|
+
break;
|
|
103
|
+
found = p.name;
|
|
104
|
+
}
|
|
105
|
+
return found;
|
|
106
|
+
};
|
|
107
|
+
for (const [i, start] of starts.entries()) {
|
|
108
|
+
const until = starts[i + 1]?.at ?? text.length;
|
|
109
|
+
const within = text.slice(start.at, until);
|
|
110
|
+
const inPackage = /<sourcefile/.test(text.slice(start.at, start.at + 12)) ? packageFor(start.at) : '';
|
|
111
|
+
const file = normalise(inPackage ? `${inPackage}/${start.file}` : start.file, root);
|
|
112
|
+
mentioned.add(file);
|
|
113
|
+
if (!executed.has(file))
|
|
114
|
+
executed.set(file, new Set());
|
|
115
|
+
// Cobertura: <line number="3" hits="1"/> JaCoCo: <line nr="3" ci="2"/> Clover: <line num="3" count="1"/>
|
|
116
|
+
for (const line of within.matchAll(/<line[^>]*\b(?:number|nr|num)\s*=\s*"(\d+)"[^>]*\b(?:hits|ci|count)\s*=\s*"(\d+)"/g)) {
|
|
117
|
+
if (Number(line[2]) > 0)
|
|
118
|
+
executed.get(file)?.add(Number(line[1]));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { executed, mentioned };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The report's path for a source file, as a path relative to the project and slashed.
|
|
125
|
+
*
|
|
126
|
+
* Reports disagree about this more than about anything else: lcov writes an absolute path, Cobertura
|
|
127
|
+
* a path relative to a `<source>` root it declares separately, JaCoCo a package and a bare file name.
|
|
128
|
+
* Everything here is reduced to the same shape so that a changed file from a git diff can be looked
|
|
129
|
+
* up without the caller knowing which tool wrote the report.
|
|
130
|
+
*/
|
|
131
|
+
function normalise(file, root) {
|
|
132
|
+
const path = slash(file).replace(/^\.\//, '');
|
|
133
|
+
if (!root)
|
|
134
|
+
return path;
|
|
135
|
+
const full = slash(resolve(root, path));
|
|
136
|
+
const base = slash(resolve(root));
|
|
137
|
+
return full.startsWith(`${base}/`) ? full.slice(base.length + 1) : path;
|
|
138
|
+
}
|
|
139
|
+
/** True when the report shows this file running at least one line. */
|
|
140
|
+
export function wasExecuted(coverage, file) {
|
|
141
|
+
return (coverage.executed.get(slash(file))?.size ?? 0) > 0;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether the report covers this project at all.
|
|
145
|
+
*
|
|
146
|
+
* A stale report from a different checkout, or one whose paths do not line up, would otherwise be
|
|
147
|
+
* read as "nothing in this change is tested" - which is the confident wrong answer this whole
|
|
148
|
+
* exercise exists to avoid. If none of the files it names exist on disk, it is not about this tree.
|
|
149
|
+
*/
|
|
150
|
+
export function describesThisProject(coverage, root) {
|
|
151
|
+
let found = 0;
|
|
152
|
+
let checked = 0;
|
|
153
|
+
for (const file of coverage.mentioned) {
|
|
154
|
+
if (checked >= 20)
|
|
155
|
+
break;
|
|
156
|
+
checked += 1;
|
|
157
|
+
if (existsSync(join(root, file)))
|
|
158
|
+
found += 1;
|
|
159
|
+
}
|
|
160
|
+
return checked > 0 && found / checked >= 0.5;
|
|
161
|
+
}
|
|
162
|
+
/** Any coverage report under the project, for a report that wants to say where it looked. */
|
|
163
|
+
export function reportsUnder(root, limit = 400) {
|
|
164
|
+
const out = [];
|
|
165
|
+
const walk = (dir, depth) => {
|
|
166
|
+
if (depth > 4 || out.length >= limit)
|
|
167
|
+
return;
|
|
168
|
+
let entries;
|
|
169
|
+
try {
|
|
170
|
+
entries = readdirSync(dir);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry === 'node_modules' || entry === '.git' || entry.startsWith('.'))
|
|
177
|
+
continue;
|
|
178
|
+
const full = join(dir, entry);
|
|
179
|
+
let info;
|
|
180
|
+
try {
|
|
181
|
+
info = statSync(full);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (info.isDirectory())
|
|
187
|
+
walk(full, depth + 1);
|
|
188
|
+
else if (/^(lcov\.info|coverage\.xml|jacoco.*\.xml|clover\.xml)$/i.test(entry))
|
|
189
|
+
out.push(slash(relative(root, full)));
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
walk(root, 0);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
@@ -115,8 +115,27 @@ function mentionsOf(root, file, tests) {
|
|
|
115
115
|
return out;
|
|
116
116
|
}
|
|
117
117
|
/** Does anything at all in the project refer to this file? Used only to tell "unknown" from "unreachable". */
|
|
118
|
+
/**
|
|
119
|
+
* Whether anything in the project refers to this file at all.
|
|
120
|
+
*
|
|
121
|
+
* This searched for the file name *with its extension*, which is a JavaScript habit: a JS import
|
|
122
|
+
* often writes `./sorting.js`, and nothing else does. Python writes `from ..utils.sorting import x`,
|
|
123
|
+
* Java writes `import com.x.Sorting`, C# writes `using X.Sorting`. In all of them the extension
|
|
124
|
+
* never appears, so the search found nothing and the file was taken to be referenced nowhere.
|
|
125
|
+
*
|
|
126
|
+
* That is the most dangerous answer this file can produce. "Nothing refers to it" is the one
|
|
127
|
+
* condition that lets selection proceed past a source file no test imports - and on saleor it let
|
|
128
|
+
* `pick` narrow 1,229 test files down to **one**, skipping every sorting test in the suite, for a
|
|
129
|
+
* change to the GraphQL sorting code that all of them run through. The command's own promise is to
|
|
130
|
+
* refuse to narrow when it cannot show the rest are unaffected, and here it could not.
|
|
131
|
+
*
|
|
132
|
+
* The extension comes off. A name like `utils` or `index` will now match half the repository, and
|
|
133
|
+
* that is the right direction to be wrong in: over-matching means "this is referenced", which stops
|
|
134
|
+
* the narrowing. Under-matching skips the tests that would have caught the change.
|
|
135
|
+
*/
|
|
118
136
|
function mentionedAnywhere(root, file) {
|
|
119
|
-
const
|
|
137
|
+
const base = file.split('/').pop() ?? '';
|
|
138
|
+
const named = base.replace(/\.[A-Za-z0-9]+$/, '') || base;
|
|
120
139
|
const where = spawnSync('git', ['grep', '-l', '--', named], { cwd: root, encoding: 'utf8', timeout: 60_000 });
|
|
121
140
|
const hits = (where.stdout ?? '')
|
|
122
141
|
.split(String.fromCharCode(10))
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// only collects: what changed, what covers it, whether those tests can fail, whether they would catch a
|
|
5
5
|
// bug, what is failing now, and what a kept plan says is still uncovered. Where a layer has nothing to
|
|
6
6
|
// say, it is left as "not checked" rather than filled in with a guess.
|
|
7
|
+
import { describesThisProject, findCoverage, wasExecuted } from '../coverage/read-coverage.js';
|
|
7
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
8
9
|
import { dirname, join, resolve } from 'node:path';
|
|
9
10
|
import { seal } from '../attest/seal.js';
|
|
@@ -74,6 +75,18 @@ export function gather(cmd) {
|
|
|
74
75
|
const changedSource = changed.filter((f) => SOURCE.test(f) && !IS_TEST.test(f) && existsSync(resolve(cmd.root, f)));
|
|
75
76
|
const changedTests = changed.filter((f) => IS_TEST.test(f) && existsSync(resolve(cmd.root, f)));
|
|
76
77
|
const known = testFiles(cmd.root);
|
|
78
|
+
// A coverage report the project already produces, if there is one. Imports cannot see a test that
|
|
79
|
+
// posts a GraphQL query, hits an HTTP route or renders a controller - and Phases 46 and 52 both
|
|
80
|
+
// ended at "cannot say" on every real Saleor pull request because of it. A coverage report records
|
|
81
|
+
// what ran, so it answers the question imports could not.
|
|
82
|
+
const report = findCoverage(cmd.root);
|
|
83
|
+
const coverage = report && describesThisProject(report, cmd.root)
|
|
84
|
+
? {
|
|
85
|
+
from: report.from,
|
|
86
|
+
executed: changedSource.filter((f) => wasExecuted(report, f)),
|
|
87
|
+
notExecuted: changedSource.filter((f) => report.mentioned.has(f.split('\\').join('/')) && !wasExecuted(report, f)),
|
|
88
|
+
}
|
|
89
|
+
: undefined;
|
|
77
90
|
const covering = [];
|
|
78
91
|
for (const file of changedSource) {
|
|
79
92
|
const direct = importingTests(cmd.root, coverageFor(cmd.root, [file], known), file);
|
|
@@ -107,6 +120,7 @@ export function gather(cmd) {
|
|
|
107
120
|
changedSource,
|
|
108
121
|
changedTests,
|
|
109
122
|
covering,
|
|
123
|
+
coverage,
|
|
110
124
|
cannotFail: vouched.cannotFail.map((j) => ({ name: j.name, file: j.file, line: j.line, why: j.why })),
|
|
111
125
|
crashOnly: vouched.crashOnly.length,
|
|
112
126
|
unnoticed: proved ? proved.proofs.flatMap((p) => p.tried.filter((t) => t.fate === 'unnoticed').map((t) => ({ file: p.file, line: t.mutant.line, kind: t.mutant.kind }))) : null,
|
|
@@ -25,6 +25,27 @@ export function decide(evidence) {
|
|
|
25
25
|
if (evidence.changedSource.length === 0) {
|
|
26
26
|
checks.push({ question: 'Is the changed code tested?', standing: 'not-applicable', answer: 'this change touches no source file', decidedBy: 'the diff' });
|
|
27
27
|
}
|
|
28
|
+
else if (evidence.coverage && evidence.coverage.notExecuted.length > 0) {
|
|
29
|
+
// The report names these files and never ran a line of them. That is not "cannot say" - it is
|
|
30
|
+
// the suite being measured and found not to reach this code.
|
|
31
|
+
checks.push({
|
|
32
|
+
question: 'Is the changed code tested?',
|
|
33
|
+
standing: 'not-met',
|
|
34
|
+
answer: `${evidence.coverage.notExecuted.slice(0, 3).join(', ')} ${evidence.coverage.notExecuted.length === 1 ? 'is' : 'are'} in ${evidence.coverage.from} with no line executed, so the suite runs none of this`,
|
|
35
|
+
decidedBy: `${evidence.coverage.from}, which records what ran rather than what imports what`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
else if (evidence.coverage && evidence.coverage.executed.length > 0) {
|
|
39
|
+
// Settles the question imports could not. Phases 46 and 52 both ended at "cannot say" here, on
|
|
40
|
+
// every real Saleor pull request, because a test that posts a GraphQL query never imports the
|
|
41
|
+
// resolver chain it exercises.
|
|
42
|
+
checks.push({
|
|
43
|
+
question: 'Is the changed code tested?',
|
|
44
|
+
standing: 'met',
|
|
45
|
+
answer: `the suite executes ${evidence.coverage.executed.slice(0, 3).join(', ')}${evidence.coverage.executed.length > 3 ? ` and ${evidence.coverage.executed.length - 3} more` : ''}`,
|
|
46
|
+
decidedBy: `${evidence.coverage.from}, which records what ran rather than what imports what`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
28
49
|
else if (evidence.covering.length === 0 && evidence.changedTests.length > 0) {
|
|
29
50
|
// Nothing *imports* the changed file, and the same change ships tests. Coverage here is followed
|
|
30
51
|
// through imports, which is a fact rather than a name match - and which cannot see a test that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.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": [
|