tewip-ai 0.36.0 → 0.37.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 +63 -9
- package/dist/cli/help.js +10 -0
- package/dist/cli/verbs-prove.js +18 -0
- package/dist/engine/perf/compare.js +97 -0
- package/dist/engine/perf/history.js +74 -0
- package/dist/engine/perf/k6.js +111 -0
- package/dist/engine/perf/perf-command.js +120 -0
- package/dist/mcp/tools-prove.js +27 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,17 +138,19 @@ and you always get the current version.
|
|
|
138
138
|
|
|
139
139
|
### The editor extension (VS Code, Cursor, Windsurf, VSCodium)
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
`npm install -g tewip-ai`.
|
|
141
|
+
**Open the Extensions pane and search for "Tewip".** It is in both registries, so that works in
|
|
142
|
+
VS Code, Cursor, Windsurf, VSCodium, Gitpod and Eclipse Theia:
|
|
144
143
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
dependencies*, and *What our corrections add up to*. The editor's AI assistant also picks Tewip up
|
|
148
|
-
as an MCP server with nothing to configure.
|
|
144
|
+
- **VS Code** — [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=hmamut39.tewip)
|
|
145
|
+
- **Cursor, Windsurf, VSCodium, Gitpod, Theia** — [Open VSX](https://open-vsx.org/extension/hmamut39/tewip)
|
|
149
146
|
|
|
150
|
-
|
|
151
|
-
|
|
147
|
+
Or install the `.vsix` attached to any [release](https://github.com/hmamut39/tewip/releases) with
|
|
148
|
+
**Extensions: Install from VSIX…**. Either way it needs the command line: `npm install -g tewip-ai`.
|
|
149
|
+
|
|
150
|
+
Press `Ctrl+Shift+P` and type "Tewip" for: *Is this work done?*, *Can everybody use this?*, *Could
|
|
151
|
+
these tests ever fail?*, *Would the tests catch a bug in this file?*, *Which tests could notice this
|
|
152
|
+
change?*, *What will this migration do to a live database?*, *Why did CI fail on this branch?*, and
|
|
153
|
+
more. The editor's AI assistant also picks Tewip up as an MCP server with nothing to configure.
|
|
152
154
|
|
|
153
155
|
### 2. From any AI assistant, through MCP (the whole IT team)
|
|
154
156
|
|
|
@@ -574,6 +576,58 @@ With `--json` the verdict is **sealed**: editing `not-ready` to `ready` in the f
|
|
|
574
576
|
fail, so a verdict that travels into a ticket or a release note can be checked rather than trusted. It is
|
|
575
577
|
also an MCP tool, so an agent can ask whether its own work is done before saying that it is.
|
|
576
578
|
|
|
579
|
+
## Is this change slower? (Phase 30, new)
|
|
580
|
+
|
|
581
|
+
```bash
|
|
582
|
+
tewip perf summary.json --record # on main: add this run to the baseline
|
|
583
|
+
tewip perf summary.json --strict # on a branch: fail the build on a real regression
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
Tewip has imported k6 results for a while and never had an opinion about them. Now it answers the
|
|
587
|
+
question every team asks before a release.
|
|
588
|
+
|
|
589
|
+
**The hard part is not the arithmetic.** "p95 went from 180ms to 210ms, +16%" is four lines of code
|
|
590
|
+
and worthless, because nobody knows whether 16% is the change or the laptop. Here are **four real k6
|
|
591
|
+
runs of code that did not change**:
|
|
592
|
+
|
|
593
|
+
```
|
|
594
|
+
run 1 p95 = 60.9ms
|
|
595
|
+
run 2 p95 = 60.8ms
|
|
596
|
+
run 3 p95 = 62.8ms
|
|
597
|
+
run 4 p95 = 61.4ms
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
A 3% spread from nothing at all. A tool that calls that a regression gets switched off.
|
|
601
|
+
|
|
602
|
+
So Tewip answers **two questions separately**, because they fail separately:
|
|
603
|
+
|
|
604
|
+
| | |
|
|
605
|
+
|---|---|
|
|
606
|
+
| **Did your own thresholds hold?** | You already wrote what "fast enough" means in your k6 script. Tewip reads the result instead of asking you to write a budget twice. |
|
|
607
|
+
| **Is it slower than it was?** | Answered **only** from three or more recorded baselines. Below that: *cannot say*. |
|
|
608
|
+
|
|
609
|
+
Given those three real baselines, a fourth identical run at 61.4ms is **not** called a regression —
|
|
610
|
+
and a genuine one is:
|
|
611
|
+
|
|
612
|
+
```
|
|
613
|
+
- `http_req_duration` p95 282.5ms — **slower**
|
|
614
|
+
- baseline 60.8ms–62.8ms over 3 runs, middle 60.9ms
|
|
615
|
+
- 282.5ms is outside everything 3 baseline runs produced (60.8-62.8ms) by more than
|
|
616
|
+
their own spread, so the runs cannot explain it. That is +364% on the middle run.
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
**Recording is deliberately not automatic.** A tool that files every run into the baseline ratchets:
|
|
620
|
+
today's regression becomes tomorrow's normal, the comparison keeps saying "within range", and a
|
|
621
|
+
service triples in latency over a quarter without one report ever being wrong. `--record` on main,
|
|
622
|
+
compare everywhere else.
|
|
623
|
+
|
|
624
|
+
**It never predicts a duration, a capacity, or what a user will feel.** It cannot see your production
|
|
625
|
+
hardware, your real data volume or your real traffic, and every report says so.
|
|
626
|
+
|
|
627
|
+
One more thing it refuses: if your summary came from `--new-machine-readable-summary`, that export
|
|
628
|
+
records **no threshold results at all** — the word appears in it only inside a copy of your script.
|
|
629
|
+
Tewip says "this export cannot tell me" rather than "nothing was breached".
|
|
630
|
+
|
|
577
631
|
## Can everybody use it? (Phase 28, new)
|
|
578
632
|
|
|
579
633
|
```bash
|
package/dist/cli/help.js
CHANGED
|
@@ -118,6 +118,16 @@ Usage:
|
|
|
118
118
|
is what was asked for covered. Answers ready, not ready, or cannot
|
|
119
119
|
say - and cannot say is never rounded up to ready. --json writes a
|
|
120
120
|
sealed verdict that can be checked later. No model, no cost.
|
|
121
|
+
tewip perf <k6-summary.json> [--record] [--label checkout] [--strict] [--out perf.md]
|
|
122
|
+
Is this change slower, and does it matter? Reads a k6 run and
|
|
123
|
+
answers two questions separately: did the thresholds you wrote
|
|
124
|
+
hold, and is this slower than it was. The second is answered only
|
|
125
|
+
from repeated baselines - with fewer than three it says cannot
|
|
126
|
+
say, because one run against one run cannot tell a regression
|
|
127
|
+
from a busy machine. --record adds a run to the baseline, and is
|
|
128
|
+
deliberately not automatic: a regression recorded by accident
|
|
129
|
+
becomes next week's normal. It never predicts a duration or a
|
|
130
|
+
capacity. No model, no cost.
|
|
121
131
|
tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
|
|
122
132
|
Can everybody use this? Reads your markup and names the barriers
|
|
123
133
|
that reading alone can prove - a picture with no alt text, a field
|
package/dist/cli/verbs-prove.js
CHANGED
|
@@ -20,6 +20,24 @@ export async function proveVerb(verb, args) {
|
|
|
20
20
|
});
|
|
21
21
|
return true;
|
|
22
22
|
}
|
|
23
|
+
case 'perf': {
|
|
24
|
+
const { runPerf } = await import('../engine/perf/perf-command.js');
|
|
25
|
+
const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label'].includes(args[i - 1] ?? ''));
|
|
26
|
+
if (paths.length === 0) {
|
|
27
|
+
console.error('tewip perf needs the JSON from a k6 run: tewip perf summary.json [--record] [--label checkout]');
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
runPerf({
|
|
32
|
+
root: process.cwd(),
|
|
33
|
+
summary: paths[0],
|
|
34
|
+
label: flag(args, 'label'),
|
|
35
|
+
record: args.includes('--record'),
|
|
36
|
+
out: flag(args, 'out'),
|
|
37
|
+
gate: args.includes('--strict'),
|
|
38
|
+
});
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
23
41
|
case 'access': {
|
|
24
42
|
const { runAccess } = await import('../engine/access/access-command.js');
|
|
25
43
|
const paths = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--out' && args[i - 1] !== '--report');
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Phase 30: deciding whether a run is actually slower.
|
|
2
|
+
//
|
|
3
|
+
// This is the whole phase. Reporting "p95 went from 180ms to 210ms, +16%" is trivial and worthless:
|
|
4
|
+
// nobody knows whether 16% is the change or the laptop. Performance numbers move between identical
|
|
5
|
+
// runs - a background process, a warm cache, a noisy CI runner - and a tool that calls every
|
|
6
|
+
// movement a regression gets muted within a week, which is the same failure mode as the
|
|
7
|
+
// accessibility scanners in Phase 28.
|
|
8
|
+
//
|
|
9
|
+
// So Tewip refuses unless it can tell the difference, and it can only tell the difference from
|
|
10
|
+
// **repeated baseline runs**. One run against one run says nothing, however large the gap looks,
|
|
11
|
+
// and Tewip says that rather than guessing. With three or more baselines there is an observed
|
|
12
|
+
// spread to compare against, and a change outside the baseline's own range by more than the width
|
|
13
|
+
// of that range is a change the runs themselves cannot explain.
|
|
14
|
+
//
|
|
15
|
+
// A k6 summary carries no raw samples - only avg, min, med, p90, p95, max - so a proper confidence
|
|
16
|
+
// interval is not available, and nothing here pretends otherwise. The test is deliberately crude
|
|
17
|
+
// and deliberately conservative: it is better to say "cannot say" to a real 4% regression than to
|
|
18
|
+
// cry wolf at noise, because the second one costs the team the tool.
|
|
19
|
+
import { MAIN_TIMING } from './k6.js';
|
|
20
|
+
/** The fewest baseline runs from which run-to-run noise can be estimated at all. */
|
|
21
|
+
export const ENOUGH_BASELINES = 3;
|
|
22
|
+
/**
|
|
23
|
+
* A change smaller than this is not called even when the baselines are unusually tight, because a
|
|
24
|
+
* handful of identical runs can make the observed spread implausibly small and a 1% move is not
|
|
25
|
+
* worth a person's afternoon.
|
|
26
|
+
*/
|
|
27
|
+
const SMALLEST_CHANGE = 0.05;
|
|
28
|
+
const median = (values) => {
|
|
29
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
30
|
+
const mid = Math.floor(sorted.length / 2);
|
|
31
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
32
|
+
};
|
|
33
|
+
const round = (n) => Math.round(n * 10) / 10;
|
|
34
|
+
/**
|
|
35
|
+
* Compares one metric of a candidate run against the same metric in the baseline runs.
|
|
36
|
+
*
|
|
37
|
+
* `baseline` is every stored value for the metric, oldest first. It is not averaged into a single
|
|
38
|
+
* number, because its spread is the only evidence available about what noise looks like here.
|
|
39
|
+
*/
|
|
40
|
+
export function compareMetric(metric, now, baseline) {
|
|
41
|
+
const runs = baseline.length;
|
|
42
|
+
const before = runs > 0 ? median(baseline) : 0;
|
|
43
|
+
const low = runs > 0 ? Math.min(...baseline) : 0;
|
|
44
|
+
const high = runs > 0 ? Math.max(...baseline) : 0;
|
|
45
|
+
const spread = high - low;
|
|
46
|
+
const margin = Math.max(spread, before * SMALLEST_CHANGE);
|
|
47
|
+
const base = {
|
|
48
|
+
metric,
|
|
49
|
+
now: round(now),
|
|
50
|
+
before: round(before),
|
|
51
|
+
baselineRuns: runs,
|
|
52
|
+
baselineLow: round(low),
|
|
53
|
+
baselineHigh: round(high),
|
|
54
|
+
margin: round(margin),
|
|
55
|
+
};
|
|
56
|
+
if (runs < ENOUGH_BASELINES) {
|
|
57
|
+
return {
|
|
58
|
+
...base,
|
|
59
|
+
direction: 'cannot-say',
|
|
60
|
+
why: runs === 0
|
|
61
|
+
? 'there is nothing to compare this against yet'
|
|
62
|
+
: `${runs} baseline ${runs === 1 ? 'run is' : 'runs are'} not enough to know what noise looks like here; ` +
|
|
63
|
+
`Tewip needs ${ENOUGH_BASELINES}. Two runs can differ by this much for reasons that have nothing to do with the code.`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const percent = before > 0 ? Math.round(((now - before) / before) * 100) : 0;
|
|
67
|
+
if (now > high + margin) {
|
|
68
|
+
return {
|
|
69
|
+
...base,
|
|
70
|
+
direction: 'slower',
|
|
71
|
+
why: `${round(now)}ms is outside everything ${runs} baseline runs produced (${round(low)}-${round(high)}ms) ` +
|
|
72
|
+
`by more than their own spread, so the runs cannot explain it. That is ${percent > 0 ? '+' : ''}${percent}% on the middle run.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (now < low - margin) {
|
|
76
|
+
return {
|
|
77
|
+
...base,
|
|
78
|
+
direction: 'faster',
|
|
79
|
+
why: `${round(now)}ms is below everything ${runs} baseline runs produced (${round(low)}-${round(high)}ms) by more than their own spread.`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
...base,
|
|
84
|
+
direction: 'cannot-say',
|
|
85
|
+
why: `${round(now)}ms is within what ${runs} baseline runs already did on their own (${round(low)}-${round(high)}ms, ` +
|
|
86
|
+
`allowing ${round(margin)}ms either side). A difference the baseline runs produce by themselves is not evidence about this change.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
|
|
90
|
+
export const ENOUGH_REQUESTS = 100;
|
|
91
|
+
/** Timings worth comparing, in the order a person would want them. */
|
|
92
|
+
export function timingsToCompare(run) {
|
|
93
|
+
const named = Object.keys(run.timings).filter((m) => !m.includes('{'));
|
|
94
|
+
const first = named.filter((m) => m === MAIN_TIMING);
|
|
95
|
+
const rest = named.filter((m) => m !== MAIN_TIMING && /duration|waiting|blocked/.test(m)).sort();
|
|
96
|
+
return [...first, ...rest];
|
|
97
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Phase 30: the stored baseline.
|
|
2
|
+
//
|
|
3
|
+
// Recording is deliberately **not** automatic, and that is the most important decision in this
|
|
4
|
+
// file. A tool that files every run it sees into the baseline ratchets: today's regression becomes
|
|
5
|
+
// tomorrow's normal, the comparison keeps saying "within range", and a service gets three times
|
|
6
|
+
// slower over a quarter without one report ever being wrong. Tewip records only when asked, so the
|
|
7
|
+
// intended use is `--record` on the main branch and a plain comparison everywhere else.
|
|
8
|
+
//
|
|
9
|
+
// The file is JSON Lines so that a run can be appended without reading or rewriting what is already
|
|
10
|
+
// there, and so a person can read it or delete a bad line with an editor.
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
const file = (root) => join(root, '.tewip', 'perf', 'runs.jsonl');
|
|
14
|
+
/** Everything recorded for this label, oldest first. */
|
|
15
|
+
export function storedRuns(root, label) {
|
|
16
|
+
const path = file(root);
|
|
17
|
+
if (!existsSync(path))
|
|
18
|
+
return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
21
|
+
const trimmed = line.trim();
|
|
22
|
+
if (trimmed === '')
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(trimmed);
|
|
26
|
+
// A line Tewip cannot read is skipped rather than throwing: one bad line written by a crashed
|
|
27
|
+
// run must not make the whole baseline unreadable.
|
|
28
|
+
if (parsed && typeof parsed.at === 'string' && parsed.label === label && parsed.timings)
|
|
29
|
+
out.push(parsed);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function recordRun(root, label, run, requests, gitSha) {
|
|
38
|
+
const stored = {
|
|
39
|
+
at: run.at,
|
|
40
|
+
label,
|
|
41
|
+
...(gitSha ? { gitSha } : {}),
|
|
42
|
+
timings: Object.fromEntries(Object.entries(run.timings).map(([m, v]) => [m, { p95: v.p95, med: v.med, avg: v.avg }])),
|
|
43
|
+
requests,
|
|
44
|
+
};
|
|
45
|
+
const path = file(root);
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
appendFileSync(path, `${JSON.stringify(stored)}\n`, 'utf8');
|
|
48
|
+
return stored;
|
|
49
|
+
}
|
|
50
|
+
/** Every stored p95 for one metric, oldest first, for the comparison to read the spread from. */
|
|
51
|
+
export function baselineFor(runs, metric) {
|
|
52
|
+
return runs.map((r) => r.timings[metric]?.p95).filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
53
|
+
}
|
|
54
|
+
/** The labels this project has recorded, so a typo in --label does not look like an empty history. */
|
|
55
|
+
export function knownLabels(root) {
|
|
56
|
+
const path = file(root);
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return [];
|
|
59
|
+
const labels = new Set();
|
|
60
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (trimmed === '')
|
|
63
|
+
continue;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(trimmed);
|
|
66
|
+
if (typeof parsed?.label === 'string')
|
|
67
|
+
labels.add(parsed.label);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...labels].sort();
|
|
74
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Phase 30: reading a k6 run.
|
|
2
|
+
//
|
|
3
|
+
// k6 writes two different summary shapes and Tewip reads both, because which one a team has depends
|
|
4
|
+
// on a flag they probably did not choose deliberately:
|
|
5
|
+
//
|
|
6
|
+
// --summary-export the classic shape: metrics as a map, percentiles keyed
|
|
7
|
+
// "p(95)", and - the part that matters - the result of every
|
|
8
|
+
// threshold the team wrote.
|
|
9
|
+
// --new-machine-readable-summary metrics as an array, percentiles keyed p95, and **no
|
|
10
|
+
// threshold results at all**.
|
|
11
|
+
//
|
|
12
|
+
// Both facts below were established by running k6 v2.3.0 against a real server rather than read
|
|
13
|
+
// from documentation, and both would have been got wrong from memory:
|
|
14
|
+
//
|
|
15
|
+
// 1. In the classic shape a threshold entry is `{"p(95)<120": true}` where **true means breached**.
|
|
16
|
+
// It reads like "the condition holds". It means the opposite. A run whose p95 was 282ms against
|
|
17
|
+
// a 120ms threshold is the one that produced `true`.
|
|
18
|
+
// 2. The machine-readable shape drops thresholds entirely. The word appears in the file only inside
|
|
19
|
+
// `config.script`, which is the source text of the test. So a team on that flag has no pass or
|
|
20
|
+
// fail recorded anywhere in their export, and Tewip has to say so rather than report "no
|
|
21
|
+
// threshold was breached", which would be a lie of omission.
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
24
|
+
function measurement(metric, values) {
|
|
25
|
+
return {
|
|
26
|
+
metric,
|
|
27
|
+
avg: num(values.avg),
|
|
28
|
+
min: num(values.min),
|
|
29
|
+
med: num(values.med),
|
|
30
|
+
// The two shapes disagree on how a percentile is spelled.
|
|
31
|
+
p90: num(values['p(90)'] ?? values.p90),
|
|
32
|
+
p95: num(values['p(95)'] ?? values.p95),
|
|
33
|
+
max: num(values.max),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** A trend metric is one with a median: a timing, rather than a counter or a rate. */
|
|
37
|
+
const isTrend = (v) => 'med' in v || 'p(95)' in v || 'p95' in v;
|
|
38
|
+
function classic(parsed, at) {
|
|
39
|
+
const metrics = (parsed.metrics ?? {});
|
|
40
|
+
const run = { tool: 'k6', format: 'classic', at, timings: {}, rates: {}, counts: {}, thresholds: [] };
|
|
41
|
+
for (const [name, values] of Object.entries(metrics)) {
|
|
42
|
+
if (typeof values !== 'object' || values === null)
|
|
43
|
+
continue;
|
|
44
|
+
for (const [expression, breached] of Object.entries((values.thresholds ?? {}))) {
|
|
45
|
+
// `true` is a breach. See the note at the top: this is the opposite of how it reads.
|
|
46
|
+
run.thresholds.push({ metric: name, expression, breached: breached === true });
|
|
47
|
+
}
|
|
48
|
+
if (isTrend(values))
|
|
49
|
+
run.timings[name] = measurement(name, values);
|
|
50
|
+
else if ('rate' in values || ('passes' in values && 'fails' in values)) {
|
|
51
|
+
const passes = num(values.passes);
|
|
52
|
+
const fails = num(values.fails);
|
|
53
|
+
run.rates[name] = { rate: num(values.rate ?? values.value), total: passes + fails };
|
|
54
|
+
}
|
|
55
|
+
else if ('count' in values)
|
|
56
|
+
run.counts[name] = num(values.count);
|
|
57
|
+
}
|
|
58
|
+
return run;
|
|
59
|
+
}
|
|
60
|
+
function machineReadable(parsed, at) {
|
|
61
|
+
const results = (parsed.results ?? {});
|
|
62
|
+
const metrics = Array.isArray(results.metrics) ? results.metrics : [];
|
|
63
|
+
// Not an empty list: this export records no threshold results, which is a different statement.
|
|
64
|
+
const run = { tool: 'k6', format: 'machine-readable', at, timings: {}, rates: {}, counts: {}, thresholds: null };
|
|
65
|
+
for (const entry of metrics) {
|
|
66
|
+
const name = typeof entry.name === 'string' ? entry.name : '';
|
|
67
|
+
const values = (entry.values ?? {});
|
|
68
|
+
if (name === '' || typeof values !== 'object' || values === null)
|
|
69
|
+
continue;
|
|
70
|
+
if (entry.type === 'trend')
|
|
71
|
+
run.timings[name] = measurement(name, values);
|
|
72
|
+
else if (entry.type === 'rate')
|
|
73
|
+
run.rates[name] = { rate: num(values.rate), total: num(values.total) };
|
|
74
|
+
else if (entry.type === 'counter' || 'count' in values)
|
|
75
|
+
run.counts[name] = num(values.count);
|
|
76
|
+
}
|
|
77
|
+
return run;
|
|
78
|
+
}
|
|
79
|
+
/** True of either shape k6 writes. */
|
|
80
|
+
function looksLikeK6Summary(v) {
|
|
81
|
+
if (typeof v !== 'object' || v === null)
|
|
82
|
+
return false;
|
|
83
|
+
const o = v;
|
|
84
|
+
if (typeof o.metrics === 'object' && o.metrics !== null && !Array.isArray(o.metrics))
|
|
85
|
+
return true;
|
|
86
|
+
const results = o.results;
|
|
87
|
+
return typeof results === 'object' && results !== null && Array.isArray(results.metrics);
|
|
88
|
+
}
|
|
89
|
+
export function readK6Summary(path) {
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error(`${path} is not JSON. Tewip reads the JSON k6 writes with --summary-export.`);
|
|
96
|
+
}
|
|
97
|
+
if (!looksLikeK6Summary(parsed)) {
|
|
98
|
+
throw new Error(`${path} is JSON, but not a k6 summary: Tewip looked for a "metrics" object or a "results.metrics" array. ` +
|
|
99
|
+
'Write one with: k6 run --summary-export=summary.json your-test.js');
|
|
100
|
+
}
|
|
101
|
+
const o = parsed;
|
|
102
|
+
const metadata = (o.metadata ?? {});
|
|
103
|
+
const at = typeof metadata.generated_at === 'string' ? metadata.generated_at : new Date().toISOString();
|
|
104
|
+
return Array.isArray(o.results?.metrics) ? machineReadable(o, at) : classic(o, at);
|
|
105
|
+
}
|
|
106
|
+
/** The metric a team means by "how slow is it", when they have not said which. */
|
|
107
|
+
export const MAIN_TIMING = 'http_req_duration';
|
|
108
|
+
/** How much work the run did. A verdict from a handful of requests is not worth printing. */
|
|
109
|
+
export function requestCount(run) {
|
|
110
|
+
return run.counts.http_reqs ?? run.rates.http_req_failed?.total ?? run.counts.iterations ?? 0;
|
|
111
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Phase 30: `tewip perf` - is this change slower, and does it matter?
|
|
2
|
+
//
|
|
3
|
+
// Two questions, answered separately because they fail separately:
|
|
4
|
+
//
|
|
5
|
+
// 1. **Did your own thresholds hold?** The team already wrote what "fast enough" means, in the k6
|
|
6
|
+
// script. Tewip reads the result rather than asking them to write a budget twice. When the
|
|
7
|
+
// export does not record thresholds - which the machine-readable one does not - the answer is
|
|
8
|
+
// "this export cannot tell me", never "nothing was breached".
|
|
9
|
+
// 2. **Is it slower than it was?** Answered only from repeated baseline runs, and refused when
|
|
10
|
+
// there are not enough of them, because one run against one run is not evidence.
|
|
11
|
+
//
|
|
12
|
+
// It never estimates a duration, a capacity or a user impact. It has no idea what the production
|
|
13
|
+
// hardware is, what the data volume is, or how many people use the endpoint, and a number invented
|
|
14
|
+
// for any of those is worse than silence.
|
|
15
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
import { MAIN_TIMING, readK6Summary, requestCount } from './k6.js';
|
|
18
|
+
import { compareMetric, ENOUGH_BASELINES, ENOUGH_REQUESTS, timingsToCompare } from './compare.js';
|
|
19
|
+
import { baselineFor, knownLabels, recordRun, storedRuns } from './history.js';
|
|
20
|
+
const ms = (n) => `${Math.round(n * 10) / 10}ms`;
|
|
21
|
+
export function renderPerf(run, comparisons, requests, label, baselineCount, recorded) {
|
|
22
|
+
const lines = ['# Tewip performance check', ''];
|
|
23
|
+
const breaches = (run.thresholds ?? []).filter((t) => t.breached);
|
|
24
|
+
const slower = comparisons.filter((c) => c.direction === 'slower');
|
|
25
|
+
const faster = comparisons.filter((c) => c.direction === 'faster');
|
|
26
|
+
const unsure = comparisons.filter((c) => c.direction === 'cannot-say');
|
|
27
|
+
lines.push(`Read a k6 run of **${requests} requests**, compared against **${baselineCount}** recorded baseline ${baselineCount === 1 ? 'run' : 'runs'} for \`${label}\`.`, '');
|
|
28
|
+
// Headline, in the order that decides what a person does next.
|
|
29
|
+
if (breaches.length > 0) {
|
|
30
|
+
lines.push(`**${breaches.length} of your own thresholds ${breaches.length === 1 ? 'was' : 'were'} breached.**`, '');
|
|
31
|
+
}
|
|
32
|
+
if (slower.length > 0)
|
|
33
|
+
lines.push(`**${slower.length} ${slower.length === 1 ? 'metric is' : 'metrics are'} slower than the baseline runs can explain.**`, '');
|
|
34
|
+
if (breaches.length === 0 && slower.length === 0) {
|
|
35
|
+
lines.push(run.thresholds === null
|
|
36
|
+
? '**No regression this baseline can detect.** Your thresholds are not in this export, so nothing is said about them.'
|
|
37
|
+
: '**No threshold breached, and no regression this baseline can detect.**', '');
|
|
38
|
+
}
|
|
39
|
+
lines.push('## Did your own thresholds hold?', '');
|
|
40
|
+
if (run.thresholds === null) {
|
|
41
|
+
lines.push('**Cannot say.** This summary came from `--new-machine-readable-summary`, and that export records no threshold ' +
|
|
42
|
+
'results at all - the word appears in it only inside the copy of your script. Tewip will not report "nothing was ' +
|
|
43
|
+
'breached" from a file that does not say. Re-run with plain `--summary-export` to get the answer.', '');
|
|
44
|
+
}
|
|
45
|
+
else if (run.thresholds.length === 0) {
|
|
46
|
+
lines.push('This run has no thresholds. Add them to your k6 script and the team decides what "fast enough" means, rather than Tewip.', '');
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
for (const t of run.thresholds) {
|
|
50
|
+
lines.push(`- ${t.breached ? '**breached**' : 'held'} — \`${t.metric}\`: \`${t.expression}\``);
|
|
51
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
}
|
|
54
|
+
lines.push('## Is it slower than it was?', '');
|
|
55
|
+
if (baselineCount < ENOUGH_BASELINES) {
|
|
56
|
+
lines.push(`**Cannot say.** There ${baselineCount === 1 ? 'is' : 'are'} ${baselineCount} recorded ${baselineCount === 1 ? 'run' : 'runs'} for \`${label}\`, ` +
|
|
57
|
+
`and Tewip needs ${ENOUGH_BASELINES} before it will call anything a regression. Performance numbers move between identical ` +
|
|
58
|
+
'runs, and without several of them there is no way to tell a change from the machine having a busy moment. ' +
|
|
59
|
+
`Record baselines on your main branch with \`tewip perf <summary.json> --record\`.`, '');
|
|
60
|
+
}
|
|
61
|
+
for (const c of [...slower, ...faster, ...unsure]) {
|
|
62
|
+
const verdict = c.direction === 'slower' ? '**slower**' : c.direction === 'faster' ? 'faster' : 'cannot say';
|
|
63
|
+
lines.push(`- \`${c.metric}\` p95 ${ms(c.now)} — ${verdict}`);
|
|
64
|
+
lines.push(` - baseline ${ms(c.baselineLow)}–${ms(c.baselineHigh)} over ${c.baselineRuns} runs, middle ${ms(c.before)}`);
|
|
65
|
+
lines.push(` - ${c.why}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
if (requests < ENOUGH_REQUESTS) {
|
|
69
|
+
lines.push(`**${requests} requests is thin.** A p95 from this few is a handful of data points and will move on its own. ` +
|
|
70
|
+
'Treat everything above as indicative and lengthen the run before deciding anything.', '');
|
|
71
|
+
}
|
|
72
|
+
lines.push('## What this does not tell you', '');
|
|
73
|
+
lines.push('- **Nothing about production.** This is the hardware the run happened on, with that data volume and that ' +
|
|
74
|
+
'concurrency. Tewip cannot see your real machines, your real data or your real traffic, and will not guess.', '- **No duration is predicted.** How long a real user waits depends on network, device and cache, none of which are here.', '- **Only what the script exercised.** An endpoint the test never called is not covered, however green this looks.', '- **Not capacity.** This says whether it changed, not how many users it would hold.', '');
|
|
75
|
+
if (recorded)
|
|
76
|
+
lines.push(`This run was added to the baseline for \`${label}\`.`, '');
|
|
77
|
+
else
|
|
78
|
+
lines.push(`This run was **not** recorded. Add \`--record\` on your main branch to make it part of the baseline; a regression recorded by accident becomes next week's normal.`, '');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
export function runPerf(cmd) {
|
|
82
|
+
const root = resolve(cmd.root);
|
|
83
|
+
const label = cmd.label ?? 'default';
|
|
84
|
+
const run = readK6Summary(resolve(root, cmd.summary));
|
|
85
|
+
const requests = requestCount(run);
|
|
86
|
+
const history = storedRuns(root, label);
|
|
87
|
+
if (history.length === 0) {
|
|
88
|
+
const known = knownLabels(root);
|
|
89
|
+
// A typo in --label would otherwise look exactly like "no history yet", and the team would
|
|
90
|
+
// record a second baseline under the wrong name without noticing.
|
|
91
|
+
if (known.length > 0 && !known.includes(label)) {
|
|
92
|
+
console.error(`[tewip] no recorded runs for label "${label}". This project has: ${known.join(', ')}.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const comparisons = timingsToCompare(run)
|
|
96
|
+
.map((metric) => compareMetric(metric, run.timings[metric].p95, baselineFor(history, metric)))
|
|
97
|
+
// A metric that is zero now and was zero every time before says nothing. http_req_blocked is
|
|
98
|
+
// zero on a local run, and printing "0ms is within 0-0ms" three times buries the real lines.
|
|
99
|
+
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
100
|
+
const recorded = cmd.record === true;
|
|
101
|
+
if (recorded)
|
|
102
|
+
recordRun(root, label, run, requests, cmd.gitSha);
|
|
103
|
+
const breached = (run.thresholds ?? []).some((t) => t.breached);
|
|
104
|
+
const regressed = comparisons.some((c) => c.direction === 'slower');
|
|
105
|
+
const report = renderPerf(run, comparisons, requests, label, history.length, recorded);
|
|
106
|
+
console.log(report);
|
|
107
|
+
if (cmd.gate && (breached || regressed)) {
|
|
108
|
+
const why = [breached ? 'a threshold you wrote was breached' : '', regressed ? 'something is slower than the baselines explain' : '']
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(', and ');
|
|
111
|
+
console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
}
|
|
114
|
+
if (cmd.out) {
|
|
115
|
+
const path = resolve(root, cmd.out);
|
|
116
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
117
|
+
writeFileSync(path, report, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
return { run, comparisons, requests, regressed, breached, report };
|
|
120
|
+
}
|
package/dist/mcp/tools-prove.js
CHANGED
|
@@ -95,6 +95,33 @@ export function registerProofTools(server, { answer, readOnly, project }) {
|
|
|
95
95
|
return 'No migration or schema file in that change.';
|
|
96
96
|
return renderSchema(migrations, stillUsed(root, migrations.flatMap((m) => m.findings)));
|
|
97
97
|
}));
|
|
98
|
+
server.registerTool('is_this_slower', {
|
|
99
|
+
title: 'Is this change slower, and does it matter?',
|
|
100
|
+
description: "Judges a k6 load-test run, rather than restating its numbers. Two answers kept apart: whether the thresholds the team wrote in their own k6 script held, and whether this run is slower than the recorded baseline. The second is answered only from three or more baseline runs - below that it returns 'cannot say', because one run against one run cannot distinguish a regression from a busy machine, and a percentage printed without that distinction is how performance tooling gets ignored. It never predicts a duration, a capacity or a user impact, and it says what the run does not cover. Use it when asked whether a change made something slower.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
project,
|
|
103
|
+
summary: z.string().describe('Path to the JSON from `k6 run --summary-export=summary.json`'),
|
|
104
|
+
label: z.string().optional().describe('Which load test this is, so two different suites do not become each other baseline'),
|
|
105
|
+
},
|
|
106
|
+
annotations: readOnly,
|
|
107
|
+
}, ({ project: root, summary, label }) => answer(async () => {
|
|
108
|
+
if (!isAbsolute(root) || !existsSync(root))
|
|
109
|
+
throw new Error(`project must be an existing absolute path, got "${root}"`);
|
|
110
|
+
// Never records: an assistant adding runs to a team's baseline unasked is how a regression
|
|
111
|
+
// silently becomes the new normal. Recording stays a deliberate act on the command line.
|
|
112
|
+
const { readK6Summary, requestCount } = await import('../engine/perf/k6.js');
|
|
113
|
+
const { compareMetric, timingsToCompare } = await import('../engine/perf/compare.js');
|
|
114
|
+
const { baselineFor, storedRuns } = await import('../engine/perf/history.js');
|
|
115
|
+
const { renderPerf } = await import('../engine/perf/perf-command.js');
|
|
116
|
+
const { isAbsolute: abs, resolve } = await import('node:path');
|
|
117
|
+
const run = readK6Summary(abs(summary) ? summary : resolve(root, summary));
|
|
118
|
+
const name = label ?? 'default';
|
|
119
|
+
const history = storedRuns(root, name);
|
|
120
|
+
const comparisons = timingsToCompare(run)
|
|
121
|
+
.map((m) => compareMetric(m, run.timings[m].p95, baselineFor(history, m)))
|
|
122
|
+
.filter((c) => c.now > 0 || c.baselineHigh > 0);
|
|
123
|
+
return renderPerf(run, comparisons, requestCount(run), name, history.length, false);
|
|
124
|
+
}));
|
|
98
125
|
server.registerTool('accessibility_barriers', {
|
|
99
126
|
title: 'Can everybody use this interface?',
|
|
100
127
|
description: "Accessibility barriers in a project's markup, each named against the WCAG 2.2 criterion a regulator would cite, with the evidence to check it. Two kinds of answer, kept apart on purpose: barriers that reading the markup proves (no alt attribute at all, a field with no label anywhere, a div that only a mouse can activate), and the ones only a person can settle, where something is written as an expression or a component and the markup cannot say. It also returns every criterion it did NOT check - contrast, focus order, whether alt text is any good - because those need a rendered page. Use it before shipping an interface, or when asked whether something is accessible; a clean result means these checks found nothing, never that the product is accessible. No browser, no network, no model.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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": [
|