tewip-ai 0.61.0 → 0.63.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
+ }
@@ -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
@@ -1,5 +1,5 @@
1
1
  // JSONL persistence shared by the reporter, the history script and future layers.
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { openSync, readSync, closeSync, fstatSync, appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  export const PROJECT_ROOT = join(import.meta.dirname, '..');
5
5
  export const DATA_DIR = join(PROJECT_ROOT, 'data');
@@ -13,23 +13,68 @@ export function appendJsonl(file, rows) {
13
13
  mkdirSync(dirname(file), { recursive: true });
14
14
  appendFileSync(file, rows.map((row) => JSON.stringify(row)).join('\n') + '\n', 'utf8');
15
15
  }
16
- /** Reads a JSONL file; missing file yields []. Malformed lines are skipped with a warning. */
16
+ /**
17
+ * Reads a JSONL file; missing file yields []. Malformed lines are skipped with a warning.
18
+ *
19
+ * Read in chunks rather than whole. `readFileSync(file, 'utf8')` builds one string, and a string in
20
+ * Node cannot exceed 0x1fffffe8 characters - about 512MB. These files only ever grow: the weekly
21
+ * shadow run appends every CI failure it harvests from a public project and keeps the lot, so that
22
+ * accuracy is measured against everything ever collected rather than one week of it. gutenberg's
23
+ * dataset crossed the limit and **every run since has died on this line**, for weeks, while the
24
+ * workflow's only symptom was a red tick nobody was looking at.
25
+ *
26
+ * A UTF-8 character can straddle a chunk boundary, so the last partial line is carried into the next
27
+ * read rather than decoded on its own - decoding a split character gives U+FFFD and a line that no
28
+ * longer parses.
29
+ */
17
30
  export function readJsonl(file) {
18
31
  if (!existsSync(file))
19
32
  return [];
20
33
  const rows = [];
21
- readFileSync(file, 'utf8')
22
- .split(/\r?\n/)
23
- .forEach((line, i) => {
24
- if (!line.trim())
25
- return;
26
- try {
27
- rows.push(JSON.parse(line));
34
+ const handle = openSync(file, 'r');
35
+ try {
36
+ const size = fstatSync(handle).size;
37
+ const buffer = Buffer.allocUnsafe(8 * 1024 * 1024);
38
+ let carry = Buffer.alloc(0);
39
+ let line = 0;
40
+ let offset = 0;
41
+ while (offset < size) {
42
+ const read = readSync(handle, buffer, 0, buffer.length, offset);
43
+ if (read <= 0)
44
+ break;
45
+ offset += read;
46
+ const chunk = Buffer.concat([carry, buffer.subarray(0, read)]);
47
+ let from = 0;
48
+ for (let i = 0; i < chunk.length; i += 1) {
49
+ if (chunk[i] !== 0x0a)
50
+ continue;
51
+ const text = chunk.subarray(from, i).toString('utf8').replace(/\r$/, '');
52
+ from = i + 1;
53
+ line += 1;
54
+ if (!text.trim())
55
+ continue;
56
+ try {
57
+ rows.push(JSON.parse(text));
58
+ }
59
+ catch {
60
+ console.warn(`[triage] skipping malformed line ${line} in ${file}`);
61
+ }
62
+ }
63
+ carry = chunk.subarray(from);
28
64
  }
29
- catch {
30
- console.warn(`[triage] skipping malformed line ${i + 1} in ${file}`);
65
+ const last = carry.toString('utf8').replace(/\r$/, '');
66
+ if (last.trim()) {
67
+ try {
68
+ rows.push(JSON.parse(last));
69
+ }
70
+ catch {
71
+ console.warn(`[triage] skipping malformed line ${line + 1} in ${file}`);
72
+ }
31
73
  }
32
- });
74
+ }
75
+ finally {
76
+ closeSync(handle);
77
+ }
33
78
  return rows;
34
79
  }
35
80
  export const readTriageRecords = (file = TRIAGE_FILE) => readJsonl(file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.61.0",
3
+ "version": "0.63.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": [