tewip-ai 0.37.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
@@ -624,6 +624,37 @@ compare everywhere else.
624
624
  **It never predicts a duration, a capacity, or what a user will feel.** It cannot see your production
625
625
  hardware, your real data volume or your real traffic, and every report says so.
626
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
+
627
658
  One more thing it refuses: if your summary came from `--new-machine-readable-summary`, that export
628
659
  records **no threshold results at all** — the word appears in it only inside a copy of your script.
629
660
  Tewip says "this export cannot tell me" rather than "nothing was breached".
package/dist/cli/help.js CHANGED
@@ -118,16 +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> [--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
+ 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.
131
133
  tewip access [<file or directory>] [--report axe.json] [--strict] [--out access.md]
132
134
  Can everybody use this? Reads your markup and names the barriers
133
135
  that reading alone can prove - a picture with no alt text, a field
@@ -25,7 +25,8 @@ export function storedRuns(root, label) {
25
25
  const parsed = JSON.parse(trimmed);
26
26
  // A line Tewip cannot read is skipped rather than throwing: one bad line written by a crashed
27
27
  // run must not make the whole baseline unreadable.
28
- if (parsed && typeof parsed.at === 'string' && parsed.label === label && parsed.timings)
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))
29
30
  out.push(parsed);
30
31
  }
31
32
  catch {
@@ -34,22 +35,36 @@ export function storedRuns(root, label) {
34
35
  }
35
36
  return out;
36
37
  }
37
- export function recordRun(root, label, run, requests, gitSha) {
38
+ export function recordRun(root, label, run, gitSha) {
38
39
  const stored = {
39
40
  at: run.at,
40
41
  label,
41
42
  ...(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,
43
+ source: run.source,
44
+ metrics: Object.fromEntries(Object.entries(run.metrics).map(([m, v]) => [m, v.value])),
45
+ requests: run.volume.n,
44
46
  };
45
47
  const path = file(root);
46
48
  mkdirSync(dirname(path), { recursive: true });
47
49
  appendFileSync(path, `${JSON.stringify(stored)}\n`, 'utf8');
48
50
  return stored;
49
51
  }
50
- /** Every stored p95 for one metric, oldest first, for the comparison to read the spread from. */
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
+ */
51
59
  export function baselineFor(runs, metric) {
52
- return runs.map((r) => r.timings[metric]?.p95).filter((v) => typeof v === 'number' && Number.isFinite(v));
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);
53
68
  }
54
69
  /** The labels this project has recorded, so a typo in --label does not look like an empty history. */
55
70
  export function knownLabels(root) {
@@ -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
+ }
@@ -1,111 +1,156 @@
1
- // Phase 30: `tewip perf` - is this change slower, and does it matter?
1
+ // Phase 30, extended in Phase 31: `tewip perf` - is this change slower, and does it matter?
2
2
  //
3
- // Two questions, answered separately because they fail separately:
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:
4
6
  //
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.
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.
11
13
  //
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.
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.
15
17
  import { mkdirSync, writeFileSync } from 'node:fs';
16
18
  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) {
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 = []) {
22
35
  const lines = ['# Tewip performance check', ''];
23
36
  const breaches = (run.thresholds ?? []).filter((t) => t.breached);
37
+ const poor = Object.entries(run.metrics).filter(([, m]) => m.rating === 'poor');
24
38
  const slower = comparisons.filter((c) => c.direction === 'slower');
25
39
  const faster = comparisons.filter((c) => c.direction === 'faster');
26
40
  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) {
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)
30
45
  lines.push(`**${breaches.length} of your own thresholds ${breaches.length === 1 ? 'was' : 'were'} breached.**`, '');
31
- }
46
+ if (poor.length > 0)
47
+ lines.push(`**${poor.length} ${poor.length === 1 ? 'metric is' : 'metrics are'} in ${tool}'s worst band.**`, '');
32
48
  if (slower.length > 0)
33
49
  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) {
50
+ if (breaches.length === 0 && poor.length === 0 && slower.length === 0) {
35
51
  lines.push(run.thresholds === null
36
52
  ? '**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.**', '');
53
+ : '**Nothing in a bad state, and no regression this baseline can detect.**', '');
38
54
  }
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.', '');
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
+ }
47
70
  }
48
71
  else {
49
- for (const t of run.thresholds) {
50
- lines.push(`- ${t.breached ? '**breached**' : 'held'} — \`${t.metric}\`: \`${t.expression}\``);
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)}`);
51
81
  }
52
82
  lines.push('');
53
83
  }
54
84
  lines.push('## Is it slower than it was?', '');
55
85
  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\`.`, '');
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`.', '');
60
90
  }
61
91
  for (const c of [...slower, ...faster, ...unsure]) {
62
92
  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)}`);
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)}`);
65
97
  lines.push(` - ${c.why}`);
66
98
  }
67
99
  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. ` +
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. ` +
70
102
  'Treat everything above as indicative and lengthen the run before deciding anything.', '');
71
103
  }
72
104
  lines.push('## What this does not tell you', '');
73
105
  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.', '');
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('');
75
117
  if (recorded)
76
118
  lines.push(`This run was added to the baseline for \`${label}\`.`, '');
77
119
  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.`, '');
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.', '');
79
121
  return lines.join('\n');
80
122
  }
81
123
  export function runPerf(cmd) {
82
124
  const root = resolve(cmd.root);
83
125
  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) {
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) {
88
130
  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
131
  if (known.length > 0 && !known.includes(label)) {
92
132
  console.error(`[tewip] no recorded runs for label "${label}". This project has: ${known.join(', ')}.`);
93
133
  }
94
134
  }
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.
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.
99
139
  .filter((c) => c.now > 0 || c.baselineHigh > 0);
100
140
  const recorded = cmd.record === true;
101
141
  if (recorded)
102
- recordRun(root, label, run, requests, cmd.gitSha);
142
+ recordRun(root, label, run, cmd.gitSha);
103
143
  const breached = (run.thresholds ?? []).some((t) => t.breached);
144
+ const poor = Object.values(run.metrics).some((m) => m.rating === 'poor');
104
145
  const regressed = comparisons.some((c) => c.direction === 'slower');
105
- const report = renderPerf(run, comparisons, requests, label, history.length, recorded);
146
+ const report = renderPerf(run, comparisons, label, history.length, recorded, manual);
106
147
  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' : '']
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
+ ]
109
154
  .filter(Boolean)
110
155
  .join(', and ');
111
156
  console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
@@ -116,5 +161,5 @@ export function runPerf(cmd) {
116
161
  mkdirSync(dirname(path), { recursive: true });
117
162
  writeFileSync(path, report, 'utf8');
118
163
  }
119
- return { run, comparisons, requests, regressed, breached, report };
164
+ return { run, comparisons, regressed, breached: breached || poor, report };
120
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
+ }
@@ -109,18 +109,18 @@ export function registerProofTools(server, { answer, readOnly, project }) {
109
109
  throw new Error(`project must be an existing absolute path, got "${root}"`);
110
110
  // Never records: an assistant adding runs to a team's baseline unasked is how a regression
111
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');
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
116
  const { isAbsolute: abs, resolve } = await import('node:path');
117
- const run = readK6Summary(abs(summary) ? summary : resolve(root, summary));
117
+ const { run, manual } = readPerfFile(abs(summary) ? summary : resolve(root, summary));
118
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)))
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
122
  .filter((c) => c.now > 0 || c.baselineHigh > 0);
123
- return renderPerf(run, comparisons, requestCount(run), name, history.length, false);
123
+ return renderPerf(run, comparisons, name, history.length, false, manual);
124
124
  }));
125
125
  server.registerTool('accessibility_barriers', {
126
126
  title: 'Can everybody use this interface?',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.37.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": [