tewip-ai 0.17.0 → 0.17.1
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
|
@@ -288,11 +288,21 @@ someone agreed on, and this is the one thing in a review that the change's own d
|
|
|
288
288
|
account for: everything else reads the change's account of itself. It never passes judgement — tests
|
|
289
289
|
change for good reasons — it asks which of them the author intended.
|
|
290
290
|
|
|
291
|
-
|
|
291
|
+
Alongside it, **the decisions a change alters outside its tests**: a guard that refused something,
|
|
292
|
+
a value other code falls back on, a limit that decides behaviour under load, a version or runner
|
|
293
|
+
pinned on purpose. Same rule - only things that already existed, never additions - and the same
|
|
294
|
+
question rather than a verdict.
|
|
295
|
+
|
|
296
|
+
Both came out of a measurement rather than an idea. Of 18 real pull requests later found to have
|
|
292
297
|
broken something, the one Tewip's test plan missed was one whose *intention* was wrong: it changed
|
|
293
|
-
a freshness rule, changed the tests to match, and was reverted the next day.
|
|
294
|
-
|
|
295
|
-
|
|
298
|
+
a freshness rule, changed the tests to match, and was reverted the next day.
|
|
299
|
+
|
|
300
|
+
**What these checks are not is a predictor**, and the measurement says so plainly. On 14 pull
|
|
301
|
+
requests known to have caused a regression they speak up on 4; on 30 ordinary merged pull requests
|
|
302
|
+
from the same repositories, on 9 - 29% against 30%. A change that alters an agreement is not more
|
|
303
|
+
likely to be broken. What they do is put those alterations in front of a person: on the ordinary
|
|
304
|
+
set they surfaced 23 of them, among which a `raise SystemExit` guard that was deleted, a semaphore
|
|
305
|
+
dropped from 4 to 1, and a CI runner image moved. No model is called, so this costs nothing.
|
|
296
306
|
|
|
297
307
|
## Which flaky tests to quarantine, and what they cost (new)
|
|
298
308
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Agreements a change renegotiates outside the tests.
|
|
2
|
+
//
|
|
3
|
+
// The test version of this reads assertions, because a test is an agreement written down. It is not
|
|
4
|
+
// the only one. A default someone chose, a guard that refuses bad input, a version pinned on
|
|
5
|
+
// purpose, a permission deliberately kept narrow - each is a decision the project already made, and
|
|
6
|
+
// each can be undone in one line of a diff that reads like housekeeping. The pull request that
|
|
7
|
+
// prompted all of this shipped a wrong rule with its tests updated to match; the same shape appears
|
|
8
|
+
// when a timeout doubles or a `required` becomes optional and nobody says why.
|
|
9
|
+
//
|
|
10
|
+
// What this refuses to be is a linter. It reports only changes to things that were already decided
|
|
11
|
+
// - never additions, never new files - and it asks rather than judges. The test that keeps it
|
|
12
|
+
// honest is the noise measurement: run over eighteen real pull requests, it has to stay quiet on
|
|
13
|
+
// the ordinary ones or a team will learn to scroll past it.
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
const NOT_SOURCE = /(\.spec\.|\.test\.|_test\.|test_|\/tests?\/|\/e2e\/|\/cypress\/|\.md$|\.snap$|lock(file)?$|package-lock\.json$|yarn\.lock$|\.sum$)/i;
|
|
16
|
+
/** An exported or module-level constant, and a parameter's default: values other code relies on. */
|
|
17
|
+
const DEFAULT_VALUE = /^\s*(?:export\s+)?(?:const|let|var|final|static|public|private)?\s*([A-Z][A-Z0-9_]{2,}|[\w$]+)\s*(?::[^=]+)?=\s*(.+?);?\s*$/;
|
|
18
|
+
const PARAM_DEFAULT = /\b([\w$]+)\s*(?::[^=,)]+)?=\s*([^,)]+)[,)]/;
|
|
19
|
+
/** A refusal: the code declining to accept something. */
|
|
20
|
+
const GUARD = /\b(?:throw|raise|panic|abort|reject|assert|invariant|require)\b|\bif\s*\(![\w$.]+\)/;
|
|
21
|
+
/** Who is allowed to do what. */
|
|
22
|
+
const PERMISSION = /\b(?:permissions?|scope|role|allow[_-]?origin|cors|public|private|protected|readonly|auth|admin|policy|rls|chmod|0o?7[0-7][0-7])\b/i;
|
|
23
|
+
/**
|
|
24
|
+
* A version deliberately pinned - and, in the same family, the thing a job is deliberately run on.
|
|
25
|
+
* `runs-on: macos-14` carries no dotted version but is exactly the same kind of decision, and one
|
|
26
|
+
* real pull request in the measurement set bumped it to `macos-26` by accident and was reverted
|
|
27
|
+
* for it, which is how this case was found.
|
|
28
|
+
*/
|
|
29
|
+
const PIN = /["']?[~^>=<]*\d+\.\d+(\.\d+)?["']?/;
|
|
30
|
+
const RUNS_ON = /^\s*(?:-\s*)?(?:runs-on|image|container|uses|FROM|baseImage|node-version|python-version|go-version)\b\s*[:=]?\s*\S/i;
|
|
31
|
+
const PIN_FILE = /(package\.json|requirements[\w.-]*\.txt|pyproject\.toml|go\.mod|Gemfile|Cargo\.toml|build\.gradle|pom\.xml|Dockerfile|\.ya?ml)$/i;
|
|
32
|
+
/** Numbers that decide behaviour under load rather than logic. */
|
|
33
|
+
const LIMIT = /\b(?:timeout|retries|retry|max|min|limit|ttl|interval|delay|backoff|threshold|concurrency|workers|pool|buffer|size)\b/i;
|
|
34
|
+
/**
|
|
35
|
+
* Markup and styling, where the words this looks for mean something else entirely. Measured against
|
|
36
|
+
* 30 ordinary pull requests, `className="min-w-0 flex-1"` becoming `min-w-[9rem]` was reported as a
|
|
37
|
+
* limit that decides behaviour under load - which is the kind of noise that teaches people to
|
|
38
|
+
* scroll past a review.
|
|
39
|
+
*/
|
|
40
|
+
const MARKUP = /\bclassName\s*=|\bclass\s*=|\bstyle\s*=|<\/?[a-zA-Z][\w-]*[\s/>]|\.(?:css|scss|sass|less|html|svg)$/;
|
|
41
|
+
/**
|
|
42
|
+
* The words in a line, with identifiers pulled apart. `MAX_RETRIES` and `maxRetries` are the same
|
|
43
|
+
* decision as `max retries`, but an underscore is a word character, so a plain \bretries\b matches
|
|
44
|
+
* neither - which silently classified a retry count as an ordinary default.
|
|
45
|
+
*/
|
|
46
|
+
const words = (line) => line.replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2');
|
|
47
|
+
const clean = (line) => line.trim().slice(0, 150);
|
|
48
|
+
/** Pairs a removed line with the added line that replaced it, by position within the hunk. */
|
|
49
|
+
function pairs(removed, added) {
|
|
50
|
+
return removed.map((line, i) => [line, added[i] ?? '']);
|
|
51
|
+
}
|
|
52
|
+
/** Two lines that are the same statement with a different value: that is a decision being changed. */
|
|
53
|
+
function sameStatement(before, after) {
|
|
54
|
+
const shape = (s) => s.replace(/["'`][^"'`]*["'`]/g, 'S').replace(/\b\d+(\.\d+)*\b/g, 'N').replace(/\s+/g, ' ').trim();
|
|
55
|
+
return Boolean(after) && shape(before) === shape(after) && before.trim() !== after.trim();
|
|
56
|
+
}
|
|
57
|
+
function classify(file, before, after) {
|
|
58
|
+
const both = `${before} ${after}`;
|
|
59
|
+
if (MARKUP.test(both) || MARKUP.test(file))
|
|
60
|
+
return null;
|
|
61
|
+
if (!after) {
|
|
62
|
+
// Something was removed outright. Only a refusal is worth reporting: deleted code is the
|
|
63
|
+
// ordinary business of a diff, but a deleted guard changes what the system accepts.
|
|
64
|
+
return GUARD.test(before)
|
|
65
|
+
? { file, kind: 'guard', before: clean(before), after: '', why: 'a check that refused something was removed, so input the project used to reject is now accepted' }
|
|
66
|
+
: null;
|
|
67
|
+
}
|
|
68
|
+
if (!sameStatement(before, after))
|
|
69
|
+
return null;
|
|
70
|
+
if (GUARD.test(before) || GUARD.test(after)) {
|
|
71
|
+
return { file, kind: 'guard', before: clean(before), after: clean(after), why: 'the condition on which the code refuses something changed' };
|
|
72
|
+
}
|
|
73
|
+
if (PERMISSION.test(words(both))) {
|
|
74
|
+
return { file, kind: 'permission', before: clean(before), after: clean(after), why: 'who or what is allowed here changed' };
|
|
75
|
+
}
|
|
76
|
+
if (PIN_FILE.test(file) && (PIN.test(before) || RUNS_ON.test(before))) {
|
|
77
|
+
return {
|
|
78
|
+
file,
|
|
79
|
+
kind: 'pin',
|
|
80
|
+
before: clean(before),
|
|
81
|
+
after: clean(after),
|
|
82
|
+
why: RUNS_ON.test(before) ? 'what this runs on changed' : 'a version that was pinned deliberately moved',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (LIMIT.test(words(both))) {
|
|
86
|
+
return { file, kind: 'limit', before: clean(before), after: clean(after), why: 'a limit that decides behaviour under load changed' };
|
|
87
|
+
}
|
|
88
|
+
if (DEFAULT_VALUE.test(before) || PARAM_DEFAULT.test(before)) {
|
|
89
|
+
return { file, kind: 'default', before: clean(before), after: clean(after), why: 'a value other code falls back on changed' };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
function sourceDiff(root, range) {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Decisions this change alters, outside the tests. Additions and new files are never reported. */
|
|
102
|
+
export function changedAgreements(root, range, diff = sourceDiff(root, range)) {
|
|
103
|
+
const out = [];
|
|
104
|
+
let file = '';
|
|
105
|
+
let isNew = false;
|
|
106
|
+
let removed = [];
|
|
107
|
+
let added = [];
|
|
108
|
+
const flush = () => {
|
|
109
|
+
if (file && !isNew && !NOT_SOURCE.test(file)) {
|
|
110
|
+
for (const [before, after] of pairs(removed, added)) {
|
|
111
|
+
const found = classify(file, before, after);
|
|
112
|
+
if (found)
|
|
113
|
+
out.push(found);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
removed = [];
|
|
117
|
+
added = [];
|
|
118
|
+
};
|
|
119
|
+
for (const line of diff.split('\n')) {
|
|
120
|
+
if (line.startsWith('diff --git')) {
|
|
121
|
+
flush();
|
|
122
|
+
file = /b\/(\S+)/.exec(line)?.[1] ?? '';
|
|
123
|
+
isNew = false;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (line.startsWith('new file mode'))
|
|
127
|
+
isNew = true;
|
|
128
|
+
if (line.startsWith('@@'))
|
|
129
|
+
flush();
|
|
130
|
+
if (line.startsWith('-') && !line.startsWith('---'))
|
|
131
|
+
removed.push(line.slice(1));
|
|
132
|
+
else if (line.startsWith('+') && !line.startsWith('+++'))
|
|
133
|
+
added.push(line.slice(1));
|
|
134
|
+
}
|
|
135
|
+
flush();
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
const HEADING = {
|
|
139
|
+
guard: 'checks that refused something',
|
|
140
|
+
permission: 'who is allowed what',
|
|
141
|
+
pin: 'versions pinned on purpose',
|
|
142
|
+
limit: 'limits that decide behaviour under load',
|
|
143
|
+
default: 'values other code falls back on',
|
|
144
|
+
};
|
|
145
|
+
/** Sentences for a reviewer. As with the tests, this asks; it never decides. */
|
|
146
|
+
export function renderAgreements(agreements) {
|
|
147
|
+
if (agreements.length === 0)
|
|
148
|
+
return [];
|
|
149
|
+
const lines = ['## Decisions this change alters', ''];
|
|
150
|
+
const kinds = [...new Set(agreements.map((a) => a.kind))];
|
|
151
|
+
lines.push(`Outside the tests, this change alters ${agreements.length} thing(s) the project had already decided: ${kinds.map((k) => HEADING[k]).join(', ')}. Each may be exactly what was intended - the point is that a diff makes them look like housekeeping.`, '');
|
|
152
|
+
for (const agreement of agreements.slice(0, 10)) {
|
|
153
|
+
lines.push(`- **${agreement.file.split('/').at(-1)}** — ${agreement.why}`);
|
|
154
|
+
lines.push(` - was: \`${agreement.before}\``);
|
|
155
|
+
if (agreement.after)
|
|
156
|
+
lines.push(` - now: \`${agreement.after}\``);
|
|
157
|
+
}
|
|
158
|
+
lines.push('', 'Ask the author which of these the change needed.');
|
|
159
|
+
return lines;
|
|
160
|
+
}
|
|
@@ -7,6 +7,7 @@ import { readTriageRecords, datasetFiles } from '../../reporters/store.js';
|
|
|
7
7
|
import { coverageFor, uncovered } from './coverage.js';
|
|
8
8
|
import { renderReview, reviewChange } from './review.js';
|
|
9
9
|
import { changedExpectations } from './expectations.js';
|
|
10
|
+
import { changedAgreements } from './agreements.js';
|
|
10
11
|
/** Every failure Tewip has diagnosed in this project, from the history it keeps. */
|
|
11
12
|
function history(root, only) {
|
|
12
13
|
const dir = join(root, '.tewip', 'history');
|
|
@@ -26,7 +27,7 @@ export async function runReview(cmd) {
|
|
|
26
27
|
const files = change.files.map((f) => f.split('\\').join('/'));
|
|
27
28
|
const coverage = coverageFor(cmd.root, files);
|
|
28
29
|
const past = history(cmd.root, cmd.name);
|
|
29
|
-
const review = reviewChange(cmd.diff, files, coverage, past, uncovered(files, coverage), changedExpectations(cmd.root, cmd.diff));
|
|
30
|
+
const review = reviewChange(cmd.diff, files, coverage, past, uncovered(files, coverage), changedExpectations(cmd.root, cmd.diff), changedAgreements(cmd.root, cmd.diff));
|
|
30
31
|
// The command that runs just those tests - but only when the project's runner really owns them.
|
|
31
32
|
// Printing a Playwright command for a file Playwright never runs is worse than printing nothing.
|
|
32
33
|
const framework = chooseFramework(detectFrameworks(cmd.root), undefined, false) ?? detectFrameworks(cmd.root)[0] ?? null;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// which is why this costs nothing and why nothing else can produce it.
|
|
7
7
|
import { componentOf } from '../../outlets/items.js';
|
|
8
8
|
import { renderExpectations } from './expectations.js';
|
|
9
|
+
import { renderAgreements } from './agreements.js';
|
|
9
10
|
const fileOf = (record) => record.file ?? '';
|
|
10
11
|
const countBy = (xs, key) => {
|
|
11
12
|
const out = new Map();
|
|
@@ -14,7 +15,7 @@ const countBy = (xs, key) => {
|
|
|
14
15
|
return out;
|
|
15
16
|
};
|
|
16
17
|
/** The review, assembled from the change, what covers it, and what has gone wrong here before. */
|
|
17
|
-
export function reviewChange(range, changed, coverage, history, notCovered, expectations = []) {
|
|
18
|
+
export function reviewChange(range, changed, coverage, history, notCovered, expectations = [], agreements = []) {
|
|
18
19
|
const byFile = new Map();
|
|
19
20
|
for (const record of history) {
|
|
20
21
|
const file = fileOf(record).split('\\').join('/');
|
|
@@ -83,7 +84,10 @@ export function reviewChange(range, changed, coverage, history, notCovered, expe
|
|
|
83
84
|
const withdrawn = expectations.filter((e) => e.kind === 'withdrawn').length;
|
|
84
85
|
notes.unshift(`This change edits ${expectations.length} check(s) that were already agreed${withdrawn > 0 ? `, ${withdrawn} of them withdrawn outright` : ''}. Read those first: the change's description cannot tell you whether replacing them was intended.`);
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
+
if (agreements.length > 0) {
|
|
88
|
+
notes.unshift(`This change alters ${agreements.length} thing(s) the project had already decided outside its tests (${[...new Set(agreements.map((a) => a.kind))].join(', ')}). They are listed below with what they were.`);
|
|
89
|
+
}
|
|
90
|
+
return { range, files: changed, tests, uncovered: notCovered, expectations, agreements, areas: [...areas.values()].sort((a, b) => b.realBugs - a.realBugs || b.failures - a.failures), runFirst, notes };
|
|
87
91
|
}
|
|
88
92
|
const when = (at) => {
|
|
89
93
|
if (!at)
|
|
@@ -105,6 +109,9 @@ export function renderReview(review, runCommand) {
|
|
|
105
109
|
const renegotiated = renderExpectations(review.expectations);
|
|
106
110
|
if (renegotiated.length > 0)
|
|
107
111
|
lines.push(...renegotiated, '');
|
|
112
|
+
const altered = renderAgreements(review.agreements);
|
|
113
|
+
if (altered.length > 0)
|
|
114
|
+
lines.push(...altered, '');
|
|
108
115
|
lines.push('## Tests that cover this change', '');
|
|
109
116
|
if (review.tests.length === 0)
|
|
110
117
|
lines.push('None found.', '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|