tewip-ai 0.36.0 → 0.38.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
@@ -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
- Download **[tewip-0.5.0.vsix](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)**, then in the
142
- editor press `Ctrl+Shift+P` and run **Extensions: Install from VSIX…**. It needs the command line:
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
- Press `Ctrl+Shift+P` and type "Tewip" for: *Explain a test report…*, *Why did CI fail on this
146
- branch?*, *Risk review of this change*, *Which flaky tests to quarantine*, *Check secrets and
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
- It is not in the Visual Studio Marketplace: publishing there now requires an Azure subscription,
151
- which means a credit card for a free extension. The file above installs exactly the same thing.
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,89 @@ 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
+ ### Lighthouse too (Phase 31, new)
628
+
629
+ ```bash
630
+ tewip perf lighthouse-report.json --record
631
+ ```
632
+
633
+ k6 measures what your server does under load. Lighthouse measures what **one page load costs in a real
634
+ browser** — Largest Contentful Paint, layout shift, blocking time — which is nearer to what a person
635
+ actually waits for. `tewip perf` takes either and works out which it was given.
636
+
637
+ Lighthouse needs the same discipline *more*, not less. **Four real Lighthouse runs of an unchanged
638
+ page gave LCP of 922, 912, 936 and 916ms.** Same rule: three baselines, outside the range by more than
639
+ its own width.
640
+
641
+ **And Tewip does not invent a verdict.** "Is 2112ms bad?" has a published answer and it is Google's:
642
+ Lighthouse scores every metric on its own curve. Tewip reads that score rather than hard-coding
643
+ thresholds it remembered — which matters, because on the real slowed run FCP and LCP had the *identical*
644
+ value of 2112ms and Lighthouse rates them differently:
645
+
646
+ ```
647
+ - good — `largest-contentful-paint`: 2112ms
648
+ - needs improvement — `first-contentful-paint`: 2112ms
649
+ - **poor** — `server-response-time`: 611ms
650
+ ```
651
+
652
+ One remembered threshold applied to both would have been wrong.
653
+
654
+ It also surfaces the **ten accessibility audits Lighthouse refuses to score** — logical tab order, focus
655
+ traps, whether visual order follows the DOM. Those are neither failures nor passes, and a page with no
656
+ failures and ten unanswered manual checks is not a page that passed.
657
+
658
+ One more thing it refuses: if your summary came from `--new-machine-readable-summary`, that export
659
+ records **no threshold results at all** — the word appears in it only inside a copy of your script.
660
+ Tewip says "this export cannot tell me" rather than "nothing was breached".
661
+
577
662
  ## Can everybody use it? (Phase 28, new)
578
663
 
579
664
  ```bash
package/dist/cli/help.js CHANGED
@@ -118,6 +118,18 @@ 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 | lighthouse.json> [--record] [--label checkout] [--strict] [--out perf.md]
122
+ Is this change slower, and does it matter? Reads a k6 run (what
123
+ the server does under load) or a Lighthouse report (what one
124
+ page load costs in a browser), and answers two questions apart:
125
+ is anything in a bad state now - from the thresholds you wrote,
126
+ or from Lighthouse's own scoring, never a budget Tewip invented
127
+ - and is this slower than it was. The second only from repeated
128
+ baselines: with fewer than three it says cannot say, because one
129
+ run against one cannot tell a regression from a busy machine.
130
+ --record adds a run to the baseline, and is deliberately not
131
+ automatic: a regression recorded by accident becomes next week's
132
+ normal. It never predicts a duration or a capacity. No cost.
121
133
  tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
122
134
  Can everybody use this? Reads your markup and names the barriers
123
135
  that reading alone can prove - a picture with no alt text, a field
@@ -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,89 @@
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
+ // Either shape counts: `metrics` is what is written now, `timings` what older versions wrote.
29
+ if (parsed && typeof parsed.at === 'string' && parsed.label === label && (parsed.metrics ?? parsed.timings))
30
+ out.push(parsed);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+ export function recordRun(root, label, run, gitSha) {
39
+ const stored = {
40
+ at: run.at,
41
+ label,
42
+ ...(gitSha ? { gitSha } : {}),
43
+ source: run.source,
44
+ metrics: Object.fromEntries(Object.entries(run.metrics).map(([m, v]) => [m, v.value])),
45
+ requests: run.volume.n,
46
+ };
47
+ const path = file(root);
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ appendFileSync(path, `${JSON.stringify(stored)}\n`, 'utf8');
50
+ return stored;
51
+ }
52
+ /**
53
+ * Every stored value for one metric, oldest first, for the comparison to read the spread from.
54
+ *
55
+ * Reads the older shape as well, so upgrading Tewip does not quietly empty a project's baseline -
56
+ * which would drop it below three runs and make Tewip refuse to judge a project it judged fine the
57
+ * day before.
58
+ */
59
+ export function baselineFor(runs, metric) {
60
+ return runs
61
+ .map((r) => r.metrics?.[metric] ?? r.timings?.[metric]?.p95)
62
+ .filter((v) => typeof v === 'number' && Number.isFinite(v));
63
+ }
64
+ /** Runs from the same tool. A k6 p95 and a Lighthouse LCP are not each other's baseline. */
65
+ export function fromSameTool(runs, source) {
66
+ // Runs recorded before the field existed were all k6, which is what the absence means.
67
+ return runs.filter((r) => (r.source ?? 'k6') === source);
68
+ }
69
+ /** The labels this project has recorded, so a typo in --label does not look like an empty history. */
70
+ export function knownLabels(root) {
71
+ const path = file(root);
72
+ if (!existsSync(path))
73
+ return [];
74
+ const labels = new Set();
75
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
76
+ const trimmed = line.trim();
77
+ if (trimmed === '')
78
+ continue;
79
+ try {
80
+ const parsed = JSON.parse(trimmed);
81
+ if (typeof parsed?.label === 'string')
82
+ labels.add(parsed.label);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ }
88
+ return [...labels].sort();
89
+ }
@@ -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,149 @@
1
+ // Phase 31: reading a Lighthouse report.
2
+ //
3
+ // k6 measures what a server does under load. Lighthouse measures what one page load costs in a real
4
+ // browser - Largest Contentful Paint, layout shift, blocking time - which is nearer to what a person
5
+ // actually waits for, and is what most teams already run. Tewip could read neither until now.
6
+ //
7
+ // The same discipline as everywhere else applies, and Lighthouse needs it more than k6 does. Four
8
+ // real Lighthouse runs of an unchanged page, measured here, gave LCP of 922, 912, 936 and 916ms: a
9
+ // 2.6% spread from nothing happening. Lighthouse's own score moved between runs too. So the
10
+ // comparison is the same one k6 gets - outside the baseline range by more than its own width, with
11
+ // three baselines required before anything is called at all.
12
+ //
13
+ // One thing is deliberately not invented. "Is 2112ms bad?" has a published answer, and it is
14
+ // Google's, not Tewip's: Lighthouse scores every metric 0 to 1 on its own curve and renders the
15
+ // bands at 0.9 and 0.5. Tewip reads that score rather than hard-coding thresholds it remembered,
16
+ // so the word "poor" in a report is Lighthouse's judgement and is traceable to it.
17
+ import { readFileSync } from 'node:fs';
18
+ /** The audits worth comparing between runs: what the page load cost, not advice about it. */
19
+ const MEASURED = new Set([
20
+ 'largest-contentful-paint',
21
+ 'first-contentful-paint',
22
+ 'total-blocking-time',
23
+ 'cumulative-layout-shift',
24
+ 'speed-index',
25
+ 'interaction-to-next-paint',
26
+ 'server-response-time',
27
+ 'time-to-first-byte',
28
+ ]);
29
+ /** Lighthouse renders green at 0.9 and orange at 0.5. The bands are its own, and so is the word. */
30
+ function rating(score) {
31
+ if (typeof score !== 'number' || !Number.isFinite(score))
32
+ return null;
33
+ if (score >= 0.9)
34
+ return 'good';
35
+ if (score >= 0.5)
36
+ return 'needs-improvement';
37
+ return 'poor';
38
+ }
39
+ const text = (v) => (typeof v === 'string' ? v : '');
40
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
41
+ function looksLikeLighthouse(v) {
42
+ if (typeof v !== 'object' || v === null)
43
+ return false;
44
+ const o = v;
45
+ return typeof o.lighthouseVersion === 'string' && typeof o.audits === 'object' && o.audits !== null;
46
+ }
47
+ /** The nodes a failing accessibility audit points at. */
48
+ function failingNodes(audit) {
49
+ const details = (audit.details ?? {});
50
+ const items = Array.isArray(details.items) ? details.items : [];
51
+ const out = [];
52
+ for (const item of items) {
53
+ const node = (item?.node ?? {});
54
+ out.push({
55
+ selector: text(node.selector),
56
+ snippet: text(node.snippet).replace(/\s+/g, ' ').slice(0, 160),
57
+ explanation: text(node.explanation)
58
+ .split(/\r?\n/)
59
+ .map((l) => l.trim())
60
+ .filter((l) => l !== '' && !/^Fix (any|all) of the following:$/.test(l))
61
+ .join('; '),
62
+ });
63
+ }
64
+ return out;
65
+ }
66
+ export function readLighthouse(path) {
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
70
+ }
71
+ catch {
72
+ throw new Error(`${path} is not JSON. Tewip reads the JSON Lighthouse writes with --output=json.`);
73
+ }
74
+ if (!looksLikeLighthouse(parsed)) {
75
+ throw new Error(`${path} is JSON, but not a Lighthouse report: Tewip looked for "lighthouseVersion" and an "audits" object. ` +
76
+ 'Write one with: lighthouse <url> --output=json --output-path=report.json');
77
+ }
78
+ const o = parsed;
79
+ // A report can carry a runtime error and still be valid JSON with empty audits. Reporting "no
80
+ // regression" from a run that never loaded the page would be the worst kind of wrong.
81
+ const runtimeError = (o.runtimeError ?? null);
82
+ if (runtimeError && text(runtimeError.code) !== 'NO_ERROR' && text(runtimeError.message) !== '') {
83
+ throw new Error(`that Lighthouse run did not complete: ${text(runtimeError.message)}`);
84
+ }
85
+ const audits = (o.audits ?? {});
86
+ const categories = (o.categories ?? {});
87
+ const metrics = {};
88
+ for (const [id, audit] of Object.entries(audits)) {
89
+ if (!MEASURED.has(id) || typeof audit.numericValue !== 'number')
90
+ continue;
91
+ metrics[id] = {
92
+ value: num(audit.numericValue),
93
+ unit: audit.numericUnit === 'millisecond' ? 'ms' : 'unitless',
94
+ rating: rating(audit.score),
95
+ };
96
+ }
97
+ // The category score itself is worth tracking: it is the number teams quote at each other.
98
+ for (const [name, category] of Object.entries(categories)) {
99
+ if (typeof category?.score === 'number') {
100
+ metrics[`${name}-score`] = { value: category.score, unit: 'score', rating: rating(category.score) };
101
+ }
102
+ }
103
+ const accessibilityFailures = [];
104
+ const manual = [];
105
+ const a11yRefs = (categories.accessibility?.auditRefs ?? []);
106
+ for (const ref of a11yRefs) {
107
+ const audit = audits[text(ref.id)];
108
+ if (!audit)
109
+ continue;
110
+ const id = text(audit.id);
111
+ if (audit.scoreDisplayMode === 'manual') {
112
+ manual.push({ id, title: text(audit.title) });
113
+ continue;
114
+ }
115
+ if (typeof audit.score === 'number' && audit.score < 1) {
116
+ const nodes = failingNodes(audit);
117
+ if (nodes.length === 0)
118
+ accessibilityFailures.push({ id, title: text(audit.title), selector: '', snippet: '', explanation: '' });
119
+ for (const node of nodes)
120
+ accessibilityFailures.push({ id, title: text(audit.title), ...node });
121
+ }
122
+ }
123
+ return {
124
+ run: {
125
+ source: 'lighthouse',
126
+ at: text(o.fetchTime) || new Date().toISOString(),
127
+ metrics,
128
+ // Lighthouse has no user-written thresholds the way a k6 script does. That is an absence of
129
+ // the feature, not an export that forgot to record them, so an empty list is the honest value.
130
+ thresholds: [],
131
+ volume: { kind: 'page load', n: 1 },
132
+ },
133
+ categories: Object.fromEntries(Object.entries(categories).map(([k, v]) => [k, num(v?.score)])),
134
+ accessibilityFailures,
135
+ manual,
136
+ url: text(o.finalDisplayedUrl) || text(o.finalUrl) || text(o.requestedUrl),
137
+ lighthouseVersion: text(o.lighthouseVersion),
138
+ };
139
+ }
140
+ /** True of a Lighthouse report, for a command that accepts either tool's output. */
141
+ export function isLighthouseFile(path) {
142
+ try {
143
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
144
+ return looksLikeLighthouse(parsed);
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ }
@@ -0,0 +1,165 @@
1
+ // Phase 30, extended in Phase 31: `tewip perf` - is this change slower, and does it matter?
2
+ //
3
+ // Reads a k6 run (what a server does under load) or a Lighthouse report (what one page load costs in
4
+ // a browser), whichever it is handed, and answers two questions separately because they fail
5
+ // separately:
6
+ //
7
+ // 1. **Is anything in a bad state right now?** From the team's own k6 thresholds, or from
8
+ // Lighthouse's published scoring bands. Never from a budget Tewip invented.
9
+ // 2. **Is it slower than it was?** Only from repeated baselines, and refused below three, because
10
+ // one run against one run is not evidence. Four real k6 runs of unchanged code gave p95 of
11
+ // 60.8-62.8ms; four real Lighthouse runs of an unchanged page gave LCP of 912-936ms. Both move
12
+ // on their own, and a tool that calls that a regression is muted within a week.
13
+ //
14
+ // It never predicts a duration, a capacity or a user impact: it cannot see the production hardware,
15
+ // the real data volume or the real traffic, and an invented number for any of those is worse than
16
+ // silence.
17
+ import { mkdirSync, writeFileSync } from 'node:fs';
18
+ import { dirname, resolve } from 'node:path';
19
+ import { readK6Summary, requestCount } from './k6.js';
20
+ import { isLighthouseFile, readLighthouse } from './lighthouse.js';
21
+ import { fromK6, metricsInOrder, show } from './run.js';
22
+ import { compareMetric, ENOUGH_BASELINES } from './compare.js';
23
+ import { baselineFor, fromSameTool, knownLabels, recordRun, storedRuns } from './history.js';
24
+ /** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
25
+ export const ENOUGH_REQUESTS = 100;
26
+ export function readPerfFile(path) {
27
+ if (isLighthouseFile(path)) {
28
+ const report = readLighthouse(path);
29
+ return { run: report.run, manual: report.manual, url: report.url };
30
+ }
31
+ const k6 = readK6Summary(path);
32
+ return { run: fromK6(k6, requestCount(k6)), manual: [], url: '' };
33
+ }
34
+ export function renderPerf(run, comparisons, label, baselineCount, recorded, manual = []) {
35
+ const lines = ['# Tewip performance check', ''];
36
+ const breaches = (run.thresholds ?? []).filter((t) => t.breached);
37
+ const poor = Object.entries(run.metrics).filter(([, m]) => m.rating === 'poor');
38
+ const slower = comparisons.filter((c) => c.direction === 'slower');
39
+ const faster = comparisons.filter((c) => c.direction === 'faster');
40
+ const unsure = comparisons.filter((c) => c.direction === 'cannot-say');
41
+ const tool = run.source === 'k6' ? 'k6' : 'Lighthouse';
42
+ lines.push(`Read a ${tool} run of **${run.volume.n} ${run.volume.kind}${run.volume.n === 1 ? '' : 's'}**, compared against **${baselineCount}** recorded ` +
43
+ `${tool} baseline ${baselineCount === 1 ? 'run' : 'runs'} for \`${label}\`.`, '');
44
+ if (breaches.length > 0)
45
+ lines.push(`**${breaches.length} of your own thresholds ${breaches.length === 1 ? 'was' : 'were'} breached.**`, '');
46
+ if (poor.length > 0)
47
+ lines.push(`**${poor.length} ${poor.length === 1 ? 'metric is' : 'metrics are'} in ${tool}'s worst band.**`, '');
48
+ if (slower.length > 0)
49
+ lines.push(`**${slower.length} ${slower.length === 1 ? 'metric is' : 'metrics are'} slower than the baseline runs can explain.**`, '');
50
+ if (breaches.length === 0 && poor.length === 0 && slower.length === 0) {
51
+ lines.push(run.thresholds === null
52
+ ? '**No regression this baseline can detect.** Your thresholds are not in this export, so nothing is said about them.'
53
+ : '**Nothing in a bad state, and no regression this baseline can detect.**', '');
54
+ }
55
+ lines.push('## Is anything in a bad state right now?', '');
56
+ if (run.source === 'k6') {
57
+ if (run.thresholds === null) {
58
+ lines.push('**Cannot say.** This summary came from `--new-machine-readable-summary`, and that export records no threshold ' +
59
+ 'results at all - the word appears in it only inside the copy of your script. Tewip will not report "nothing was ' +
60
+ 'breached" from a file that does not say. Re-run with plain `--summary-export` to get the answer.', '');
61
+ }
62
+ else if (run.thresholds.length === 0) {
63
+ lines.push('This run has no thresholds. Add them to your k6 script and the team decides what "fast enough" means, rather than Tewip.', '');
64
+ }
65
+ else {
66
+ for (const t of run.thresholds)
67
+ lines.push(`- ${t.breached ? '**breached**' : 'held'} — \`${t.metric}\`: \`${t.expression}\``);
68
+ lines.push('');
69
+ }
70
+ }
71
+ else {
72
+ // The bands are Lighthouse's own, computed by Lighthouse. Tewip is relaying a judgement with a
73
+ // published basis, not applying numbers it chose.
74
+ lines.push("Rated by Lighthouse's own scoring, not by any threshold Tewip invented:", '');
75
+ for (const name of metricsInOrder(run)) {
76
+ const m = run.metrics[name];
77
+ if (m.rating === null)
78
+ continue;
79
+ const mark = m.rating === 'poor' ? '**poor**' : m.rating === 'needs-improvement' ? 'needs improvement' : 'good';
80
+ lines.push(`- ${mark} — \`${name}\`: ${show(m)}`);
81
+ }
82
+ lines.push('');
83
+ }
84
+ lines.push('## Is it slower than it was?', '');
85
+ if (baselineCount < ENOUGH_BASELINES) {
86
+ lines.push(`**Cannot say.** There ${baselineCount === 1 ? 'is' : 'are'} ${baselineCount} recorded ${tool} ${baselineCount === 1 ? 'run' : 'runs'} ` +
87
+ `for \`${label}\`, and Tewip needs ${ENOUGH_BASELINES} before it will call anything a regression. Performance numbers move ` +
88
+ 'between identical runs, and without several of them there is no way to tell a change from the machine having a busy moment. ' +
89
+ 'Record baselines on your main branch with `tewip perf <file> --record`.', '');
90
+ }
91
+ for (const c of [...slower, ...faster, ...unsure]) {
92
+ const verdict = c.direction === 'slower' ? '**slower**' : c.direction === 'faster' ? 'faster' : 'cannot say';
93
+ const unit = run.metrics[c.metric]?.unit ?? 'ms';
94
+ const u = (n) => (unit === 'ms' ? `${n}ms` : `${n}`);
95
+ lines.push(`- \`${c.metric}\` ${u(c.now)} — ${verdict}`);
96
+ lines.push(` - baseline ${u(c.baselineLow)}–${u(c.baselineHigh)} over ${c.baselineRuns} runs, middle ${u(c.before)}`);
97
+ lines.push(` - ${c.why}`);
98
+ }
99
+ lines.push('');
100
+ if (run.source === 'k6' && run.volume.n < ENOUGH_REQUESTS) {
101
+ lines.push(`**${run.volume.n} requests is thin.** A p95 from this few is a handful of data points and will move on its own. ` +
102
+ 'Treat everything above as indicative and lengthen the run before deciding anything.', '');
103
+ }
104
+ lines.push('## What this does not tell you', '');
105
+ lines.push('- **Nothing about production.** This is the hardware the run happened on, with that data volume and that ' +
106
+ '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 run exercised.** A page or endpoint it never touched is not covered, however green this looks.', '- **Not capacity.** This says whether it changed, not how many users it would hold.');
107
+ if (run.source === 'lighthouse') {
108
+ lines.push(`- **One page load.** k6 reports a percentile over many requests; this is a single load of one page, and a single ` +
109
+ 'load is the noisiest measurement either tool offers.');
110
+ if (manual.length > 0) {
111
+ lines.push(`- **${manual.length} accessibility checks Lighthouse refuses to score** and hands to a person - ` +
112
+ `${manual.slice(0, 3).map((m) => `\`${m.id}\``).join(', ')} and others. They are not failures and not passes. ` +
113
+ 'Run `tewip access` for the accessibility side.');
114
+ }
115
+ }
116
+ lines.push('');
117
+ if (recorded)
118
+ lines.push(`This run was added to the baseline for \`${label}\`.`, '');
119
+ else
120
+ 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.', '');
121
+ return lines.join('\n');
122
+ }
123
+ export function runPerf(cmd) {
124
+ const root = resolve(cmd.root);
125
+ const label = cmd.label ?? 'default';
126
+ const { run, manual } = readPerfFile(resolve(root, cmd.summary));
127
+ const all = storedRuns(root, label);
128
+ const history = fromSameTool(all, run.source);
129
+ if (all.length === 0) {
130
+ const known = knownLabels(root);
131
+ if (known.length > 0 && !known.includes(label)) {
132
+ console.error(`[tewip] no recorded runs for label "${label}". This project has: ${known.join(', ')}.`);
133
+ }
134
+ }
135
+ const comparisons = metricsInOrder(run)
136
+ .map((metric) => compareMetric(metric, run.metrics[metric].value, baselineFor(history, metric)))
137
+ // A metric that is zero now and was zero every time before says nothing, and printing
138
+ // "0ms is within 0-0ms" three times buries the real lines.
139
+ .filter((c) => c.now > 0 || c.baselineHigh > 0);
140
+ const recorded = cmd.record === true;
141
+ if (recorded)
142
+ recordRun(root, label, run, cmd.gitSha);
143
+ const breached = (run.thresholds ?? []).some((t) => t.breached);
144
+ const poor = Object.values(run.metrics).some((m) => m.rating === 'poor');
145
+ const regressed = comparisons.some((c) => c.direction === 'slower');
146
+ const report = renderPerf(run, comparisons, label, history.length, recorded, manual);
147
+ console.log(report);
148
+ if (cmd.gate && (breached || regressed || poor)) {
149
+ const why = [
150
+ breached ? 'a threshold you wrote was breached' : '',
151
+ poor ? "something is in Lighthouse's worst band" : '',
152
+ regressed ? 'something is slower than the baselines explain' : '',
153
+ ]
154
+ .filter(Boolean)
155
+ .join(', and ');
156
+ console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
157
+ process.exitCode = 1;
158
+ }
159
+ if (cmd.out) {
160
+ const path = resolve(root, cmd.out);
161
+ mkdirSync(dirname(path), { recursive: true });
162
+ writeFileSync(path, report, 'utf8');
163
+ }
164
+ return { run, comparisons, regressed, breached: breached || poor, report };
165
+ }
@@ -0,0 +1,39 @@
1
+ /** k6's percentiles, reduced to the one number a comparison needs: p95. */
2
+ export function fromK6(run, requests) {
3
+ const metrics = {};
4
+ for (const [name, m] of Object.entries(run.timings)) {
5
+ // Tagged duplicates (`http_req_duration{expected_response:true}`) are the same measurement
6
+ // sliced, and comparing both says the same thing twice.
7
+ if (name.includes('{'))
8
+ continue;
9
+ // k6 publishes no bands. It reports numbers and leaves "fast enough" to the team's thresholds,
10
+ // which is the right division and the reason rating is null here.
11
+ metrics[name] = { value: m.p95, unit: 'ms', rating: null };
12
+ }
13
+ return { source: 'k6', at: run.at, metrics, thresholds: run.thresholds, volume: { kind: 'request', n: requests } };
14
+ }
15
+ /** Metrics worth putting in front of a person, most important first. */
16
+ const ORDER = [
17
+ 'largest-contentful-paint',
18
+ 'cumulative-layout-shift',
19
+ 'total-blocking-time',
20
+ 'interaction-to-next-paint',
21
+ 'first-contentful-paint',
22
+ 'speed-index',
23
+ 'server-response-time',
24
+ 'http_req_duration',
25
+ ];
26
+ export function metricsInOrder(run) {
27
+ const names = Object.keys(run.metrics);
28
+ const known = ORDER.filter((m) => names.includes(m));
29
+ const rest = names.filter((m) => !ORDER.includes(m)).sort();
30
+ return [...known, ...rest];
31
+ }
32
+ /** How a metric is written in a report: 2112ms, 0.08, 97. */
33
+ export function show(metric) {
34
+ if (metric.unit === 'ms')
35
+ return `${Math.round(metric.value)}ms`;
36
+ if (metric.unit === 'score')
37
+ return `${Math.round(metric.value * 100)}`;
38
+ return `${Math.round(metric.value * 1000) / 1000}`;
39
+ }
@@ -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 { compareMetric } = await import('../engine/perf/compare.js');
113
+ const { baselineFor, fromSameTool, storedRuns } = await import('../engine/perf/history.js');
114
+ const { readPerfFile, renderPerf } = await import('../engine/perf/perf-command.js');
115
+ const { metricsInOrder } = await import('../engine/perf/run.js');
116
+ const { isAbsolute: abs, resolve } = await import('node:path');
117
+ const { run, manual } = readPerfFile(abs(summary) ? summary : resolve(root, summary));
118
+ const name = label ?? 'default';
119
+ const history = fromSameTool(storedRuns(root, name), run.source);
120
+ const comparisons = metricsInOrder(run)
121
+ .map((m) => compareMetric(m, run.metrics[m].value, baselineFor(history, m)))
122
+ .filter((c) => c.now > 0 || c.baselineHigh > 0);
123
+ return renderPerf(run, comparisons, name, history.length, false, manual);
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.36.0",
3
+ "version": "0.38.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": [