tewip-ai 0.69.3 → 0.69.5
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.
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// and deliberately conservative: it is better to say "cannot say" to a real 4% regression than to
|
|
18
18
|
// cry wolf at noise, because the second one costs the team the tool.
|
|
19
19
|
import { MAIN_TIMING } from './k6.js';
|
|
20
|
+
import { show } from './run.js';
|
|
20
21
|
/** The fewest baseline runs from which run-to-run noise can be estimated at all. */
|
|
21
22
|
export const ENOUGH_BASELINES = 3;
|
|
22
23
|
/**
|
|
@@ -25,6 +26,22 @@ export const ENOUGH_BASELINES = 3;
|
|
|
25
26
|
* worth a person's afternoon.
|
|
26
27
|
*/
|
|
27
28
|
const SMALLEST_CHANGE = 0.05;
|
|
29
|
+
/**
|
|
30
|
+
* The smallest difference in milliseconds this will call a regression, whatever the percentage.
|
|
31
|
+
*
|
|
32
|
+
* Found by running the same unchanged page four times through real Lighthouse and comparing the
|
|
33
|
+
* fourth against the other three: **"1 metric is slower than the baseline runs can explain."** The
|
|
34
|
+
* metric was `server-response-time`, the difference was **2.8ms on an 11ms baseline** - +25%, which
|
|
35
|
+
* clears the 5% floor easily - and Lighthouse itself reports all four of those runs as *"Root
|
|
36
|
+
* document took 10 ms"*. Tewip was claiming a regression at a precision finer than the tool it read
|
|
37
|
+
* reports, on a page that had not changed.
|
|
38
|
+
*
|
|
39
|
+
* The number is not derived from theory and is not pretending to be: it sits between a false
|
|
40
|
+
* positive this was measured producing (2.8ms) and the smallest true positive the suite already
|
|
41
|
+
* requires it to catch (10ms on a 50ms baseline). A relative floor cannot separate those two - both
|
|
42
|
+
* are about +20% - which is why the floor here is absolute.
|
|
43
|
+
*/
|
|
44
|
+
const MS_NOISE_FLOOR = 5;
|
|
28
45
|
const median = (values) => {
|
|
29
46
|
const sorted = [...values].sort((a, b) => a - b);
|
|
30
47
|
const mid = Math.floor(sorted.length / 2);
|
|
@@ -37,13 +54,19 @@ const round = (n) => Math.round(n * 10) / 10;
|
|
|
37
54
|
* `baseline` is every stored value for the metric, oldest first. It is not averaged into a single
|
|
38
55
|
* number, because its spread is the only evidence available about what noise looks like here.
|
|
39
56
|
*/
|
|
40
|
-
export function compareMetric(metric, now, baseline) {
|
|
57
|
+
export function compareMetric(metric, now, baseline, unit = 'ms') {
|
|
41
58
|
const runs = baseline.length;
|
|
59
|
+
// Every number in `why` goes through the same formatter the rest of the report uses. These
|
|
60
|
+
// sentences used to end "ms" whatever the metric was, so a Lighthouse score of 100 was explained
|
|
61
|
+
// as **"1ms is within what 3 baseline runs already did on their own (1-1ms)"** - on the line
|
|
62
|
+
// directly below one reading `performance-score 100`. Two different numbers and a unit that does
|
|
63
|
+
// not exist, for the same measurement, in the same bullet.
|
|
64
|
+
const u = (n) => show({ value: n, unit, rating: null });
|
|
42
65
|
const before = runs > 0 ? median(baseline) : 0;
|
|
43
66
|
const low = runs > 0 ? Math.min(...baseline) : 0;
|
|
44
67
|
const high = runs > 0 ? Math.max(...baseline) : 0;
|
|
45
68
|
const spread = high - low;
|
|
46
|
-
const margin = Math.max(spread, before * SMALLEST_CHANGE);
|
|
69
|
+
const margin = Math.max(spread, before * SMALLEST_CHANGE, unit === 'ms' ? MS_NOISE_FLOOR : 0);
|
|
47
70
|
const base = {
|
|
48
71
|
metric,
|
|
49
72
|
now: round(now),
|
|
@@ -68,7 +91,7 @@ export function compareMetric(metric, now, baseline) {
|
|
|
68
91
|
return {
|
|
69
92
|
...base,
|
|
70
93
|
direction: 'slower',
|
|
71
|
-
why: `${
|
|
94
|
+
why: `${u(now)} is outside everything ${runs} baseline runs produced (${u(low)}-${u(high)}) ` +
|
|
72
95
|
`by more than their own spread, so the runs cannot explain it. That is ${percent > 0 ? '+' : ''}${percent}% on the middle run.`,
|
|
73
96
|
};
|
|
74
97
|
}
|
|
@@ -76,14 +99,14 @@ export function compareMetric(metric, now, baseline) {
|
|
|
76
99
|
return {
|
|
77
100
|
...base,
|
|
78
101
|
direction: 'faster',
|
|
79
|
-
why: `${
|
|
102
|
+
why: `${u(now)} is below everything ${runs} baseline runs produced (${u(low)}-${u(high)}) by more than their own spread.`,
|
|
80
103
|
};
|
|
81
104
|
}
|
|
82
105
|
return {
|
|
83
106
|
...base,
|
|
84
107
|
direction: 'cannot-say',
|
|
85
|
-
why: `${
|
|
86
|
-
`allowing ${
|
|
108
|
+
why: `${u(now)} is within what ${runs} baseline runs already did on their own (${u(low)}-${u(high)}, ` +
|
|
109
|
+
`allowing ${u(margin)} either side). A difference the baseline runs produce by themselves is not evidence about this change.`,
|
|
87
110
|
};
|
|
88
111
|
}
|
|
89
112
|
/** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
|
|
@@ -92,10 +92,18 @@ export function renderPerf(run, comparisons, label, baselineCount, recorded, man
|
|
|
92
92
|
}
|
|
93
93
|
for (const c of [...slower, ...faster, ...unsure]) {
|
|
94
94
|
const verdict = c.direction === 'slower' ? '**slower**' : c.direction === 'faster' ? 'faster' : 'cannot say';
|
|
95
|
+
// `show` and nothing else. This had a local formatter - `unit === 'ms' ? n + 'ms' : n` - which
|
|
96
|
+
// knew nothing about Lighthouse's 0-1 score, so one real report said **performance-score: 100**
|
|
97
|
+
// in the section above and **performance-score 1** here. Anyone reading the second number takes
|
|
98
|
+
// it for 1 out of 100: a perfect page reported as a catastrophic one, in the same report.
|
|
99
|
+
// `server-response-time` came out as 13ms above and 12.6ms here for the same reason.
|
|
95
100
|
const unit = run.metrics[c.metric]?.unit ?? 'ms';
|
|
96
|
-
const u = (n) => (unit
|
|
101
|
+
const u = (n) => show({ ...(run.metrics[c.metric] ?? { unit, rating: null }), value: n });
|
|
97
102
|
lines.push(`- \`${c.metric}\` ${u(c.now)} — ${verdict}`);
|
|
98
|
-
|
|
103
|
+
// With no baseline there is no range, and "baseline 0ms-0ms over 0 runs, middle 0ms" is four
|
|
104
|
+
// zeros that read as measurements. The line below already says there is nothing to compare to.
|
|
105
|
+
if (c.baselineRuns > 0)
|
|
106
|
+
lines.push(` - baseline ${u(c.baselineLow)}–${u(c.baselineHigh)} over ${c.baselineRuns} runs, middle ${u(c.before)}`);
|
|
99
107
|
lines.push(` - ${c.why}`);
|
|
100
108
|
}
|
|
101
109
|
lines.push('');
|
|
@@ -174,7 +182,7 @@ export function runPerf(cmd) {
|
|
|
174
182
|
}
|
|
175
183
|
}
|
|
176
184
|
const comparisons = metricsInOrder(run)
|
|
177
|
-
.map((metric) => compareMetric(metric, run.metrics[metric].value, baselineFor(history, metric)))
|
|
185
|
+
.map((metric) => compareMetric(metric, run.metrics[metric].value, baselineFor(history, metric), run.metrics[metric].unit))
|
|
178
186
|
// A metric that is zero now and was zero every time before says nothing, and printing
|
|
179
187
|
// "0ms is within 0-0ms" three times buries the real lines.
|
|
180
188
|
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
@@ -19,6 +19,54 @@ import { TEWIP_LABEL } from './jira-command.js';
|
|
|
19
19
|
import { trackerFromEnv, trackerHelp } from './trackers.js';
|
|
20
20
|
/** Bugs Tewip filed carry this as well, so a second run finds them and does not file again. */
|
|
21
21
|
export const BUG_LABEL = 'tewip-bug';
|
|
22
|
+
/**
|
|
23
|
+
* How sure Tewip has to be before it puts a person's name on a ticket.
|
|
24
|
+
*
|
|
25
|
+
* Named and exported because of what it collides with. Phase 55 capped a model-only diagnosis at
|
|
26
|
+
* **0.45** - the model is not reliable enough for its word alone to be acted on - and this bar has
|
|
27
|
+
* been 0.8 since Phase 10. The two numbers were set eight weeks apart and never compared, and
|
|
28
|
+
* together they mean **a bug only the model recognised can never be filed**, whatever it says.
|
|
29
|
+
*
|
|
30
|
+
* Measured over 1,964 real failures from five public projects (gutenberg, supabase, lxdui, penpot,
|
|
31
|
+
* jupyterlab) with the rules alone: **20 REAL_BUG verdicts, of which 1 reaches 0.8.** Four of the
|
|
32
|
+
* five projects produce none at all. On a real jest report of a plain value mismatch - "Expected: 4,
|
|
33
|
+
* Received: 3", which is as clear as a real bug gets - the verdict is REAL_BUG at 0.45 from the
|
|
34
|
+
* model, and `bugs` files nothing.
|
|
35
|
+
*
|
|
36
|
+
* The bar is not lowered here. A ticket opened on a model's unsupported word is the thing this
|
|
37
|
+
* project exists not to do, and 0.45 is the honest reading of how often that word is right. What
|
|
38
|
+
* changes is that the refusal now says which of the two reasons applies, because "none of them is a
|
|
39
|
+
* real bug Tewip is confident about" was being printed for twenty failures Tewip had called real
|
|
40
|
+
* bugs.
|
|
41
|
+
*/
|
|
42
|
+
export const CONFIDENT_ENOUGH = 0.8;
|
|
43
|
+
/**
|
|
44
|
+
* Why nothing was filed - the two cases told apart.
|
|
45
|
+
*
|
|
46
|
+
* The old sentence was one line for both: `${items.length} failure(s), and none of them is a real
|
|
47
|
+
* bug Tewip is confident about (${kinds})`, where `kinds` was every category present. On a real
|
|
48
|
+
* report it printed **"none of them is a real bug Tewip is confident about (REAL_BUG, ENV)"** -
|
|
49
|
+
* listing REAL_BUG inside the sentence denying there was one. A reader cannot tell from that whether
|
|
50
|
+
* Tewip found nothing or found something it would not act on, and those call for opposite next steps.
|
|
51
|
+
*/
|
|
52
|
+
export function whyNothingWasFiled(items, unsure) {
|
|
53
|
+
const kinds = [...new Set(items.filter((i) => i.diagnosis.category !== 'REAL_BUG').map((i) => i.diagnosis.category))];
|
|
54
|
+
if (unsure.length === 0) {
|
|
55
|
+
return (`${items.length} failure(s), and none of them looks like the application being wrong` +
|
|
56
|
+
`${kinds.length > 0 ? ` - they are ${kinds.join(', ')}` : ''}. Nothing was filed, which is the point.`);
|
|
57
|
+
}
|
|
58
|
+
const highest = Math.max(...unsure.map((i) => i.diagnosis.confidence));
|
|
59
|
+
const byModel = unsure.filter((i) => /AI|gpt|claude|model/i.test(i.source)).length;
|
|
60
|
+
return [
|
|
61
|
+
`${items.length} failure(s). ${unsure.length} of them Tewip reads as a real bug, and none of those reaches the ${CONFIDENT_ENOUGH.toFixed(2)} confidence it requires before opening a ticket in somebody's name - the highest is ${highest.toFixed(2)}.`,
|
|
62
|
+
byModel > 0
|
|
63
|
+
? `${byModel === unsure.length ? 'All' : `${byModel}`} of them came from the AI layer, which is capped at 0.45 on purpose: a model's unsupported word is not enough to file on. A person's correction on the failure in CI, or a rule that agrees, is what would make it enough.`
|
|
64
|
+
: '',
|
|
65
|
+
`Nothing was filed. These are listed in \`tewip explain\` with their evidence${kinds.length > 0 ? `, alongside ${kinds.join(', ')} failures that are deliberately never filed` : ''}.`,
|
|
66
|
+
]
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
22
70
|
const isBlobDir = (path) => statSync(path).isDirectory() && readdirSync(path).some((f) => /^report.*\.zip$/.test(f));
|
|
23
71
|
/** The bug report a developer actually wants: what failed, what it did, and why Tewip believes it. */
|
|
24
72
|
export function bugBody(item, runUrl) {
|
|
@@ -76,15 +124,15 @@ export async function runBugs(cmd) {
|
|
|
76
124
|
const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
|
|
77
125
|
// Only what Tewip itself calls a bug, and only when it is confident enough to put a person's name
|
|
78
126
|
// on it. Everything else is deliberately left out.
|
|
79
|
-
const bugs = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence >=
|
|
127
|
+
const bugs = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence >= CONFIDENT_ENOUGH);
|
|
128
|
+
const unsure = items.filter((i) => i.diagnosis.category === 'REAL_BUG' && i.diagnosis.confidence < CONFIDENT_ENOUGH);
|
|
80
129
|
const lines = [`Report: ${cmd.report}`, `Project: ${cmd.projectKey}`, ''];
|
|
81
130
|
if (items.length === 0) {
|
|
82
131
|
console.log('Every test in that report passed. Nothing to file.');
|
|
83
132
|
return [];
|
|
84
133
|
}
|
|
85
134
|
if (bugs.length === 0) {
|
|
86
|
-
|
|
87
|
-
console.log(`${items.length} failure(s), and none of them is a real bug Tewip is confident about (${kinds}). Nothing was filed, which is the point.`);
|
|
135
|
+
console.log(whyNothingWasFiled(items, unsure));
|
|
88
136
|
return [];
|
|
89
137
|
}
|
|
90
138
|
const tracker = chosen?.tracker.canWrite() ? chosen.tracker : null;
|
|
@@ -122,8 +170,17 @@ export async function runBugs(cmd) {
|
|
|
122
170
|
lines.push(` filed ${key}: ${summary}`);
|
|
123
171
|
filed++;
|
|
124
172
|
}
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
// `items.length - bugs.length` was described as "flaky tests, environment or test data", which is
|
|
174
|
+
// untrue of a REAL_BUG sitting below the bar: a bug Tewip is 0.45 sure of was being counted, in
|
|
175
|
+
// print, as a flaky test. And the `max` cap was silent - the same mistake `prove` made in Phase 58,
|
|
176
|
+
// where a sampled answer read as a whole one.
|
|
177
|
+
const notBugs = items.length - bugs.length - unsure.length;
|
|
178
|
+
const beyondCap = Math.max(0, bugs.length - cmd.max);
|
|
179
|
+
lines.push('', `${bugs.length} of ${items.length} failure(s) look like real bugs.` +
|
|
180
|
+
(notBugs > 0 ? ` ${notBugs} were flaky tests, environment or test data and were deliberately not filed.` : '') +
|
|
181
|
+
(unsure.length > 0 ? ` ${unsure.length} more look like real bugs but below the ${CONFIDENT_ENOUGH.toFixed(2)} confidence needed to file one, so they were not.` : ''));
|
|
182
|
+
if (beyondCap > 0)
|
|
183
|
+
lines.push(`${beyondCap} were past the --max ${cmd.max} limit and were not looked at. Raise it with \`--max ${bugs.length}\`.`);
|
|
127
184
|
lines.push(cmd.create ? `${filed} ticket(s) created in ${tracker?.name}. Tewip never moves, assigns or closes one.` : 'Nothing was created. Add --create to file them.');
|
|
128
185
|
console.log(lines.join('\n'));
|
|
129
186
|
return bugs;
|
package/dist/mcp/tools-prove.js
CHANGED
|
@@ -118,7 +118,7 @@ export function registerProofTools(server, { answer, readOnly, project }) {
|
|
|
118
118
|
const name = label ?? 'default';
|
|
119
119
|
const history = fromSameTool(storedRuns(root, name), run.source);
|
|
120
120
|
const comparisons = metricsInOrder(run)
|
|
121
|
-
.map((m) => compareMetric(m, run.metrics[m].value, baselineFor(history, m)))
|
|
121
|
+
.map((m) => compareMetric(m, run.metrics[m].value, baselineFor(history, m), run.metrics[m].unit))
|
|
122
122
|
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
123
123
|
return renderPerf(run, comparisons, name, history.length, false, manual);
|
|
124
124
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.69.
|
|
3
|
+
"version": "0.69.5",
|
|
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": [
|