tewip-ai 0.46.0 → 0.48.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
@@ -38,6 +38,36 @@ never goes to the model again.
38
38
  Tewip prints what every command costs, caps its own spend, and works with no key at all - the rules
39
39
  run on their own and everything they cannot settle is left as UNKNOWN rather than guessed.
40
40
 
41
+ ## Start here
42
+
43
+ ```bash
44
+ npm i -g tewip-ai
45
+ cd your-project
46
+ tewip
47
+ ```
48
+
49
+ `tewip` on its own looks at **your** project and names the two or three commands that will tell you
50
+ something, with your own paths in them — and says plainly what it cannot answer here. It reads the
51
+ directory and stops: nothing is run, nothing is written, about a second.
52
+
53
+ ```
54
+ Here is what Tewip can tell you about **this** project, in the order worth doing.
55
+
56
+ **1. `tewip vouch`**
57
+ 282 test files here (282 JavaScript or TypeScript). This reads them and names the ones
58
+ that cannot fail - no model, a second or two.
59
+
60
+ **2. `tewip schema --diff main...HEAD`**
61
+ 165 migrations here (165 SQL). This says what a change would do to a live database
62
+ before it runs.
63
+
64
+ ## What it will not do here
65
+ - No k6, JMeter or Lighthouse report was found. Tewip judges one you produce; it does
66
+ not run load tests itself.
67
+ ```
68
+
69
+ Everything below is the full list, for when you want it.
70
+
41
71
  ## Use it in your project
42
72
 
43
73
  **Giving it to your team (or another company): [TEAM-GUIDE.md](TEAM-GUIDE.md)** — what to install, what each role uses it for, and what it never does.
package/dist/cli/help.js CHANGED
@@ -4,6 +4,11 @@
4
4
  export const HELP = `Tewip - finds why each test failed, for any stack.
5
5
 
6
6
  Usage:
7
+ tewip Look at this project and name the two or three commands that
8
+ tewip start will tell you something about it, with your own paths in them,
9
+ and say plainly what Tewip cannot answer here. Reads the
10
+ directory and stops: nothing is run and nothing is written.
11
+ Start here. Everything below is the full list.
7
12
  tewip explain <report> Explain a test report: a JUnit XML or .trx file, a folder of
8
13
  them, or a Playwright blob-report folder. Keeps history per
9
14
  project, so later runs are judged against earlier ones.
@@ -118,10 +123,11 @@ Usage:
118
123
  is what was asked for covered. Answers ready, not ready, or cannot
119
124
  say - and cannot say is never rounded up to ready. --json writes a
120
125
  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]
126
+ tewip perf <k6.json | lighthouse.json | run.jtl> [--against before.jtl] [--record] [--strict]
122
127
  Is this change slower, and does it matter? Reads a k6 run (what
123
128
  the server does under load) or a Lighthouse report (what one
124
- page load costs in a browser), and answers two questions apart:
129
+ page load costs in a browser) or a JMeter .jtl, and answers two
130
+ questions apart:
125
131
  is anything in a bad state now - from the thresholds you wrote,
126
132
  or from Lighthouse's own scoring, never a budget Tewip invented
127
133
  - and is this slower than it was. The second only from repeated
@@ -129,7 +135,10 @@ Usage:
129
135
  run against one cannot tell a regression from a busy machine.
130
136
  --record adds a run to the baseline, and is deliberately not
131
137
  automatic: a regression recorded by accident becomes next week's
132
- normal. It never predicts a duration or a capacity. No cost.
138
+ normal. A .jtl is different and better: it carries every request,
139
+ so --against before.jtl compares the two distributions with a
140
+ real test rather than a range check - and refuses a difference
141
+ that is certain but too small to act on. No cost.
133
142
  tewip access [<file or directory>] [--report axe.json|lighthouse.json] [--strict]
134
143
  Can everybody use this? Reads your markup and names the barriers
135
144
  that reading alone can prove - a picture with no alt text, a field
package/dist/cli/tewip.js CHANGED
@@ -237,7 +237,16 @@ async function main() {
237
237
  case '-v':
238
238
  console.log(version());
239
239
  return;
240
+ // `tewip` on its own used to print 233 lines of help, which is a reference for somebody who
241
+ // already knows what they want and a wall for everybody else. It now looks at the project and
242
+ // names the two or three commands that will tell *this* team something. --help is unchanged
243
+ // and still has all of it.
240
244
  case undefined:
245
+ case 'start': {
246
+ const { runStart } = await import('../engine/start/start-command.js');
247
+ runStart(process.cwd());
248
+ return;
249
+ }
241
250
  case '--help':
242
251
  case '-h':
243
252
  case 'help':
@@ -35,9 +35,11 @@ export async function proveVerb(verb, args) {
35
35
  }
36
36
  case 'perf': {
37
37
  const { runPerf } = await import('../engine/perf/perf-command.js');
38
- const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label'].includes(args[i - 1] ?? ''));
38
+ const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label', '--against'].includes(args[i - 1] ?? ''));
39
39
  if (paths.length === 0) {
40
- console.error('tewip perf needs the JSON from a k6 run: tewip perf summary.json [--record] [--label checkout]');
40
+ console.error('tewip perf needs a k6 summary, a Lighthouse report or a JMeter .jtl.');
41
+ console.error(' tewip perf summary.json [--record] [--label checkout]');
42
+ console.error(' tewip perf now.jtl --against before.jtl');
41
43
  process.exitCode = 1;
42
44
  return true;
43
45
  }
@@ -45,6 +47,7 @@ export async function proveVerb(verb, args) {
45
47
  root: process.cwd(),
46
48
  summary: paths[0],
47
49
  label: flag(args, 'label'),
50
+ against: flag(args, 'against'),
48
51
  record: args.includes('--record'),
49
52
  out: flag(args, 'out'),
50
53
  gate: args.includes('--strict'),
@@ -13,7 +13,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { SCHEMA_VERSION } from '../../reporters/types.js';
15
15
  import { descendants, parseXml } from './xml.js';
16
- import { jmeterCases, k6Cases, looksLikeK6, robotCases, robotTime } from './other-formats.js';
16
+ import { jmeterCases, jmeterCsvCases, k6Cases, looksLikeCsvJtl, looksLikeK6, robotCases, robotTime } from './other-formats.js';
17
17
  import { nunitCases, trxCases } from './dotnet.js';
18
18
  import { PASSED, failure } from './case.js';
19
19
  const FAILURE = new Set(['failure', 'error']);
@@ -182,6 +182,13 @@ export function importJunit(path, meta) {
182
182
  addCases(k6Cases(parsed));
183
183
  continue;
184
184
  }
185
+ // JMeter's default output is CSV, not XML, so a real .jtl would otherwise be rejected as "not
186
+ // an XML document" - which it is not, and never was.
187
+ if (looksLikeCsvJtl(raw)) {
188
+ parsedFiles++;
189
+ addCases(jmeterCsvCases(raw));
190
+ continue;
191
+ }
185
192
  const root = parseXml(raw);
186
193
  let cases;
187
194
  if (root.name === 'TestRun') {
@@ -1,4 +1,6 @@
1
1
  import { descendants } from './xml.js';
2
+ import { looksLikeCsvJtl, readCsv } from '../perf/jtl.js';
3
+ export { looksLikeCsvJtl };
2
4
  const text = (element) => (element ? element.text.trim() : '');
3
5
  /**
4
6
  * Robot Framework writes output.xml by default, and only writes JUnit if asked. A suite holds
@@ -125,3 +127,56 @@ export function looksLikeK6(json) {
125
127
  return false;
126
128
  return ['http_req_duration', 'iterations', 'vus', 'checks', 'http_reqs'].some((m) => m in report.metrics);
127
129
  }
130
+ /**
131
+ * JMeter's CSV results file, which is the one it writes by default.
132
+ *
133
+ * The XML reader above has been the only one since this was written, and `.jtl` means CSV in every
134
+ * real repository you look in - so `importJunit` answered "not an XML document: no root element" on
135
+ * genuine JMeter output while the help text advertised JMeter support. A format nobody tested
136
+ * against a real file.
137
+ *
138
+ * The parsing is shared with `tewip perf`, which needs the same rows for its statistics, and it is a
139
+ * real CSV reader rather than a line split because JMeter quotes a failureMessage containing commas
140
+ * and newlines - one real record found here spans seven physical lines.
141
+ */
142
+ export function jmeterCsvCases(text) {
143
+ const rows = readCsv(text);
144
+ const header = (rows[0] ?? []).map((h) => h.trim());
145
+ const at = (name) => header.indexOf(name);
146
+ const elapsed = at('elapsed');
147
+ const label = at('label');
148
+ if (elapsed === -1 || label === -1)
149
+ return [];
150
+ const success = at('success');
151
+ const code = at('responseCode');
152
+ const message = at('responseMessage');
153
+ const failure = at('failureMessage');
154
+ const byLabel = new Map();
155
+ for (const row of rows.slice(1)) {
156
+ if (row.length <= Math.max(elapsed, label))
157
+ continue;
158
+ const name = (row[label] ?? '').trim() || 'sample';
159
+ const found = byLabel.get(name) ?? { failed: 0, total: 0, time: 0, codes: new Map(), messages: new Set() };
160
+ found.total += 1;
161
+ const ms = Number(row[elapsed]);
162
+ if (Number.isFinite(ms))
163
+ found.time += ms;
164
+ if (success !== -1 && (row[success] ?? '').trim().toLowerCase() === 'false') {
165
+ found.failed += 1;
166
+ const seen = `${code === -1 ? '' : (row[code] ?? '')} ${message === -1 ? '' : (row[message] ?? '')}`.trim();
167
+ if (seen)
168
+ found.codes.set(seen, (found.codes.get(seen) ?? 0) + 1);
169
+ const why = failure === -1 ? '' : (row[failure] ?? '').trim();
170
+ if (why)
171
+ found.messages.add(why.replace(/\s+/g, ' ').slice(0, 200));
172
+ }
173
+ byLabel.set(name, found);
174
+ }
175
+ return [...byLabel].map(([name, s]) => {
176
+ const worst = [...s.codes].sort((a, b) => b[1] - a[1])[0];
177
+ // A 2xx that still counts as failed is an assertion failing, not the server.
178
+ const why = worst ? (/^2\d\d/.test(worst[0]) ? `, an assertion failed (${worst[1]} times, response ${worst[0]})` : `, mostly ${worst[0]} (${worst[1]} times)`) : '';
179
+ const text = s.failed === 0 ? '' : `${s.failed} of ${s.total} requests failed${why}${s.messages.size > 0 ? `: ${[...s.messages][0]}` : ''}`;
180
+ return { testId: `JMeter > ${name}`, file: '', duration: s.time, attempts: [{ failed: s.failed > 0, message: text, stack: [...s.messages].join('\n').slice(0, 2000) }] };
181
+ });
182
+ }
@@ -0,0 +1,128 @@
1
+ // Phase 39: what Tewip says about a JMeter run.
2
+ //
3
+ // Kept apart from perf-command.ts because a .jtl is answered differently from a k6 summary or a
4
+ // Lighthouse report, and for a good reason rather than a tidy one: those two hand over aggregates,
5
+ // so the only question available is "is this number outside the baseline range". A .jtl hands over
6
+ // every request, so the question becomes "are these two distributions different", which is a
7
+ // better question with a real answer - per label, not just overall.
8
+ import { byLabel, overall, readJtlSamples } from './jtl.js';
9
+ import { compareSamples, ENOUGH_SAMPLES, median } from './samples.js';
10
+ const ms = (n) => `${Math.round(n * 10) / 10}ms`;
11
+ /** A run on its own: no baseline to compare against, so nothing is claimed about a change. */
12
+ export function describeRun(groups, total) {
13
+ const lines = ['# Tewip performance check', ''];
14
+ lines.push(`Read a JMeter run of **${total} requests** across ${groups.length} ${groups.length === 1 ? 'label' : 'labels'}.`, '');
15
+ lines.push('**No baseline given, so nothing is said about whether this is slower.** Pass the previous run ' +
16
+ 'with `--against baseline.jtl` and Tewip will compare the two request by request.', '');
17
+ lines.push('## What this run did', '');
18
+ lines.push('| Label | Requests | Failed | Median | p95 |', '| --- | --- | --- | --- | --- |');
19
+ for (const g of groups) {
20
+ lines.push(`| ${g.label || '(unnamed)'} | ${g.total} | ${g.failed} | ${ms(median(g.elapsed))} | ${ms(percentile(g.elapsed, 95))} |`);
21
+ }
22
+ lines.push('');
23
+ const failing = groups.filter((g) => g.failed > 0);
24
+ if (failing.length > 0) {
25
+ lines.push('## Requests that failed', '');
26
+ for (const g of failing) {
27
+ const codes = g.codes.map((c) => `${c.code} x${c.times}`).join(', ');
28
+ // A 2xx that still counts as failed is an assertion failing, not the server.
29
+ const assertion = g.codes.some((c) => /^2\d\d/.test(c.code)) ? ' - a 2xx counted as failed is an assertion, not the server' : '';
30
+ lines.push(`- **${g.label || '(unnamed)'}**: ${g.failed} of ${g.total}${codes ? ` (${codes})` : ''}${assertion}`);
31
+ }
32
+ lines.push('');
33
+ }
34
+ lines.push(...notKnown());
35
+ return lines.join('\n');
36
+ }
37
+ export function percentile(values, p) {
38
+ if (values.length === 0)
39
+ return 0;
40
+ const sorted = [...values].sort((a, b) => a - b);
41
+ const at = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
42
+ return sorted[Math.max(0, at)];
43
+ }
44
+ function notKnown() {
45
+ return [
46
+ '## What this does not tell you',
47
+ '',
48
+ '- **Nothing about production.** This is the hardware the run happened on, with that data volume and ' +
49
+ 'that concurrency. Tewip cannot see your real machines, your real data or your real traffic.',
50
+ '- **No duration is predicted**, and no capacity. It says whether the two runs differ, not how many users you could hold.',
51
+ '- **Only what the plan exercised.** An endpoint the test never called is not covered, however green this looks.',
52
+ '- **A statistical difference is not a cause.** Tewip says the distributions moved; why they moved is in your change.',
53
+ '',
54
+ ];
55
+ }
56
+ /** Two runs, compared request by request. */
57
+ export function compareRuns(nowPath, beforePath) {
58
+ const nowSamples = readJtlSamples(nowPath);
59
+ const beforeSamples = readJtlSamples(beforePath);
60
+ const nowGroups = byLabel(nowSamples);
61
+ const beforeByLabel = new Map(byLabel(beforeSamples).map((g) => [g.label, g]));
62
+ const comparisons = [];
63
+ // The whole run first: it is the headline, and a change that moves everything is the common case.
64
+ const everything = compareSamples(overall(nowSamples).elapsed, overall(beforeSamples).elapsed);
65
+ comparisons.push({
66
+ label: 'all requests',
67
+ verdict: everything,
68
+ failedNow: overall(nowSamples).failed,
69
+ totalNow: nowSamples.length,
70
+ codes: [],
71
+ });
72
+ for (const group of nowGroups) {
73
+ const before = beforeByLabel.get(group.label);
74
+ const verdict = before
75
+ ? compareSamples(group.elapsed, before.elapsed)
76
+ : {
77
+ direction: 'cannot-say',
78
+ now: median(group.elapsed),
79
+ before: 0,
80
+ slowerThanBefore: 0.5,
81
+ p: null,
82
+ sampleSize: { now: group.total, before: 0 },
83
+ why: 'this label is not in the baseline run, so there is nothing to compare it with',
84
+ };
85
+ comparisons.push({ label: group.label, verdict, failedNow: group.failed, totalNow: group.total, codes: group.codes });
86
+ }
87
+ const slower = comparisons.filter((c) => c.verdict.direction === 'slower');
88
+ const failing = comparisons.filter((c) => c.failedNow > 0);
89
+ return { comparisons, slower, failing, requests: nowSamples.length, report: render(comparisons, slower, failing, nowSamples.length, beforeSamples.length) };
90
+ }
91
+ function render(all, slower, failing, now, before) {
92
+ const lines = ['# Tewip performance check', ''];
93
+ lines.push(`Compared a JMeter run of **${now} requests** against a baseline of **${before}**, request by request.`, '');
94
+ if (slower.length > 0)
95
+ lines.push(`**${slower.length} ${slower.length === 1 ? 'label is' : 'labels are'} slower than the baseline, by more than the runs explain.**`, '');
96
+ const faster = all.filter((c) => c.verdict.direction === 'faster');
97
+ if (faster.length > 0)
98
+ lines.push(`${faster.length} ${faster.length === 1 ? 'is' : 'are'} faster.`, '');
99
+ if (slower.length === 0 && faster.length === 0)
100
+ lines.push('**No difference these two runs can establish.**', '');
101
+ if (failing.length > 0)
102
+ lines.push(`**${failing.reduce((n, c) => n + c.failedNow, 0)} requests failed**, which is a separate matter from speed and is below.`, '');
103
+ lines.push('## Is it slower?', '');
104
+ for (const c of all) {
105
+ const mark = c.verdict.direction === 'slower' ? '**slower**' : c.verdict.direction === 'faster' ? 'faster' : 'cannot say';
106
+ lines.push(`- \`${c.label || '(unnamed)'}\` — ${mark}`);
107
+ lines.push(` - median ${ms(c.verdict.before)} → ${ms(c.verdict.now)} over ${c.verdict.sampleSize.before} and ${c.verdict.sampleSize.now} requests`);
108
+ lines.push(` - ${c.verdict.why}`);
109
+ }
110
+ lines.push('');
111
+ if (failing.length > 0) {
112
+ lines.push('## Requests that failed', '');
113
+ for (const c of failing) {
114
+ const codes = c.codes.map((x) => `${x.code} x${x.times}`).join(', ');
115
+ const assertion = c.codes.some((x) => /^2\d\d/.test(x.code)) ? ' - a 2xx counted as failed is an assertion failing, not the server' : '';
116
+ lines.push(`- **${c.label || '(unnamed)'}**: ${c.failedNow} of ${c.totalNow}${codes ? ` (${codes})` : ''}${assertion}`);
117
+ }
118
+ lines.push('');
119
+ lines.push('A run with failures in it is a run whose timings describe something that did not work.', '');
120
+ }
121
+ const thin = all.filter((c) => c.verdict.p === null && c.verdict.sampleSize.before > 0);
122
+ if (thin.length > 0) {
123
+ lines.push(`${thin.length} ${thin.length === 1 ? 'label has' : 'labels have'} fewer than ${ENOUGH_SAMPLES} requests on one ` +
124
+ 'side, so no test was run for them. That is said above rather than guessed at.', '');
125
+ }
126
+ lines.push(...notKnown());
127
+ return lines.join('\n');
128
+ }
@@ -0,0 +1,159 @@
1
+ // Phase 39: reading a JMeter results file.
2
+ //
3
+ // Two things made this worth doing properly.
4
+ //
5
+ // **Tewip could not read one at all.** The importer handled `.jtl` only as XML, and CSV is JMeter's
6
+ // default and what every real repository has - so `importJunit` answered "not an XML document: no
7
+ // root element" on genuine JMeter output while the help text advertised JMeter support.
8
+ //
9
+ // **And a .jtl is the best performance data Tewip has ever been handed.** A k6 summary gives six
10
+ // aggregates per metric and no samples, which is why Phase 30's comparison had to be a crude range
11
+ // check. A .jtl has one row per request: real samples, per label, which support an actual
12
+ // statistical test. The better answer was always sitting in a format that would not open.
13
+ //
14
+ // The parser is a real CSV reader rather than a line split, and that is not fastidiousness. In the
15
+ // first real file read here - NikeshMan16/JmeterPerformance_testing_Project - the quoted
16
+ // failureMessage of a 400 response contains a comma *and a newline*, so one logical record spans
17
+ // seven physical lines. Splitting on newlines would have produced six corrupt rows out of one.
18
+ import { readFileSync } from 'node:fs';
19
+ /**
20
+ * RFC 4180 with JMeter's habits: quoted fields may contain commas, newlines and doubled quotes.
21
+ *
22
+ * Returned as rows of raw strings; nothing is interpreted here, because deciding what a column
23
+ * means is the caller's job and JMeter's column set is configurable.
24
+ */
25
+ export function readCsv(text) {
26
+ const rows = [];
27
+ let row = [];
28
+ let field = '';
29
+ let quoted = false;
30
+ let i = 0;
31
+ // A byte order mark would otherwise become part of the first column's name.
32
+ if (text.charCodeAt(0) === 0xfeff)
33
+ i = 1;
34
+ for (; i < text.length; i += 1) {
35
+ const ch = text[i];
36
+ if (quoted) {
37
+ if (ch === '"') {
38
+ // A doubled quote inside a quoted field is one literal quote.
39
+ if (text[i + 1] === '"') {
40
+ field += '"';
41
+ i += 1;
42
+ }
43
+ else
44
+ quoted = false;
45
+ }
46
+ else
47
+ field += ch;
48
+ continue;
49
+ }
50
+ if (ch === '"') {
51
+ quoted = true;
52
+ continue;
53
+ }
54
+ if (ch === ',') {
55
+ row.push(field);
56
+ field = '';
57
+ continue;
58
+ }
59
+ if (ch === '\r')
60
+ continue;
61
+ if (ch === '\n') {
62
+ row.push(field);
63
+ field = '';
64
+ // A blank line is not a record.
65
+ if (row.length > 1 || row[0] !== '')
66
+ rows.push(row);
67
+ row = [];
68
+ continue;
69
+ }
70
+ field += ch;
71
+ }
72
+ if (field !== '' || row.length > 0) {
73
+ row.push(field);
74
+ rows.push(row);
75
+ }
76
+ return rows;
77
+ }
78
+ /** JMeter's column names, which are configurable, so each is looked up rather than counted. */
79
+ function columns(header) {
80
+ const at = {};
81
+ header.forEach((name, i) => {
82
+ at[name.trim()] = i;
83
+ });
84
+ return at;
85
+ }
86
+ const CSV_HEADER = /(^|,)\s*timeStamp\s*(,|$)/;
87
+ /** True of a CSV results file, as opposed to the XML form or something else entirely. */
88
+ export function looksLikeCsvJtl(text) {
89
+ const firstLine = text.slice(0, 2000).split(/\r?\n/)[0] ?? '';
90
+ return CSV_HEADER.test(firstLine) && firstLine.includes('elapsed');
91
+ }
92
+ export function readJtlSamples(path) {
93
+ const text = readFileSync(path, 'utf8');
94
+ if (!looksLikeCsvJtl(text)) {
95
+ throw new Error(`${path} is not a CSV JMeter results file: Tewip looked for a header row naming timeStamp and elapsed. ` +
96
+ 'JMeter writes one by default; if yours is XML, it is read by `tewip explain` instead.');
97
+ }
98
+ const rows = readCsv(text);
99
+ const at = columns(rows[0] ?? []);
100
+ const elapsedAt = at.elapsed;
101
+ const labelAt = at.label;
102
+ if (elapsedAt === undefined || labelAt === undefined) {
103
+ throw new Error(`${path} has no elapsed or label column, so there is nothing to measure.`);
104
+ }
105
+ const out = [];
106
+ for (const row of rows.slice(1)) {
107
+ // A short row is a truncated write - a run killed mid-flush - and inventing values for it would
108
+ // put a zero into a latency distribution, which is the one place a zero does real damage.
109
+ if (row.length <= Math.max(elapsedAt, labelAt))
110
+ continue;
111
+ const elapsed = Number(row[elapsedAt]);
112
+ if (!Number.isFinite(elapsed) || elapsed < 0)
113
+ continue;
114
+ const success = at.success === undefined ? true : row[at.success]?.trim().toLowerCase() === 'true';
115
+ out.push({
116
+ label: (row[labelAt] ?? '').trim(),
117
+ elapsed,
118
+ success,
119
+ responseCode: (at.responseCode === undefined ? '' : (row[at.responseCode] ?? '')).trim(),
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+ /** The samples grouped by what was being requested, busiest first. */
125
+ export function byLabel(samples) {
126
+ const groups = new Map();
127
+ const codes = new Map();
128
+ for (const s of samples) {
129
+ let group = groups.get(s.label);
130
+ if (!group) {
131
+ group = { label: s.label, elapsed: [], failed: 0, total: 0, codes: [] };
132
+ groups.set(s.label, group);
133
+ codes.set(s.label, new Map());
134
+ }
135
+ group.elapsed.push(s.elapsed);
136
+ group.total += 1;
137
+ if (!s.success) {
138
+ group.failed += 1;
139
+ const seen = codes.get(s.label);
140
+ const code = s.responseCode === '' ? 'no response code' : s.responseCode;
141
+ seen.set(code, (seen.get(code) ?? 0) + 1);
142
+ }
143
+ }
144
+ for (const [label, seen] of codes) {
145
+ groups.get(label).codes = [...seen].map(([code, times]) => ({ code, times })).sort((a, b) => b.times - a.times);
146
+ }
147
+ return [...groups.values()].sort((a, b) => b.total - a.total);
148
+ }
149
+ /** The whole run as one label, for the headline number. */
150
+ export const ALL_REQUESTS = 'all requests';
151
+ export function overall(samples) {
152
+ return {
153
+ label: ALL_REQUESTS,
154
+ elapsed: samples.map((s) => s.elapsed),
155
+ failed: samples.filter((s) => !s.success).length,
156
+ total: samples.length,
157
+ codes: [],
158
+ };
159
+ }
@@ -21,6 +21,8 @@ import { isLighthouseFile, readLighthouse } from './lighthouse.js';
21
21
  import { fromK6, metricsInOrder, show } from './run.js';
22
22
  import { compareMetric, ENOUGH_BASELINES } from './compare.js';
23
23
  import { baselineFor, fromSameTool, knownLabels, recordRun, storedRuns } from './history.js';
24
+ import { byLabel, readJtlSamples } from './jtl.js';
25
+ import { compareRuns, describeRun } from './jtl-report.js';
24
26
  /** Enough requests for a percentile to mean anything. Below this, p95 is three data points. */
25
27
  export const ENOUGH_REQUESTS = 100;
26
28
  export function readPerfFile(path) {
@@ -120,8 +122,47 @@ export function renderPerf(run, comparisons, label, baselineCount, recorded, man
120
122
  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
123
  return lines.join('\n');
122
124
  }
125
+ /**
126
+ * A JMeter run. With `--against` the two are compared request by request; without it the run is
127
+ * described and nothing is claimed about a change, because one run is not a comparison.
128
+ */
129
+ function runJtl(root, cmd) {
130
+ const now = resolve(root, cmd.summary);
131
+ if (!cmd.against) {
132
+ const samples = readJtlSamples(now);
133
+ const report = describeRun(byLabel(samples), samples.length);
134
+ console.log(report);
135
+ if (cmd.out)
136
+ writeOut(root, cmd.out, report);
137
+ return undefined;
138
+ }
139
+ const result = compareRuns(now, resolve(root, cmd.against));
140
+ console.log(result.report);
141
+ if (cmd.out)
142
+ writeOut(root, cmd.out, result.report);
143
+ // Failures are a separate matter from speed and gate on their own: a run whose requests failed
144
+ // is a run whose timings describe something that did not work.
145
+ const failed = result.failing.reduce((n, c) => n + c.failedNow, 0);
146
+ if (cmd.gate && (result.slower.length > 0 || failed > 0)) {
147
+ const why = [result.slower.length > 0 ? `${result.slower.length} label(s) slower than the runs explain` : '', failed > 0 ? `${failed} request(s) failed` : '']
148
+ .filter(Boolean)
149
+ .join(', and ');
150
+ console.error(`${String.fromCharCode(10)}Performance gate failed: ${why}.`);
151
+ process.exitCode = 1;
152
+ }
153
+ return undefined;
154
+ }
155
+ function writeOut(root, out, report) {
156
+ const path = resolve(root, out);
157
+ mkdirSync(dirname(path), { recursive: true });
158
+ writeFileSync(path, report, 'utf8');
159
+ }
123
160
  export function runPerf(cmd) {
124
161
  const root = resolve(cmd.root);
162
+ // A JMeter results file is answered differently, and better: it carries every request rather
163
+ // than six aggregates, so two runs are compared with a real test instead of a range check.
164
+ if (/\.jtl$/i.test(cmd.summary))
165
+ return runJtl(root, cmd);
125
166
  const label = cmd.label ?? 'default';
126
167
  const { run, manual } = readPerfFile(resolve(root, cmd.summary));
127
168
  const all = storedRuns(root, label);
@@ -0,0 +1,145 @@
1
+ // Phase 39: comparing two sets of response times properly, because a .jtl gives real samples.
2
+ //
3
+ // Phase 30 had to be crude. A k6 summary carries six aggregates per metric and no samples, so the
4
+ // only honest test available was "is this value outside everything the baselines produced, by more
5
+ // than the width of their own spread". That refuses a lot of real regressions, which was the right
6
+ // trade when nothing better was possible.
7
+ //
8
+ // A JMeter .jtl carries one row per request, so something better *is* possible: a Mann-Whitney U
9
+ // test, which asks whether one distribution is shifted from another without assuming either is
10
+ // normal - and response times are never normal. They are a lump near the median with a long tail,
11
+ // which is exactly the shape a t-test handles badly.
12
+ //
13
+ // The decision that matters most here is **not** the p-value. With ten thousand samples a one
14
+ // millisecond shift is overwhelmingly significant and completely irrelevant, and a tool that
15
+ // reports it as a regression has taught its team that regressions are noise. So a change has to be
16
+ // both statistically real and large enough to care about, and the report says which of the two it
17
+ // failed when it fails.
18
+ /** Below this per side the normal approximation is unreliable, so no test is claimed. */
19
+ export const ENOUGH_SAMPLES = 20;
20
+ /** A shift smaller than this is not worth a person's attention, however certain it is. */
21
+ const SMALLEST_SHIFT = 0.05;
22
+ const SMALLEST_SHIFT_MS = 2;
23
+ export function median(values) {
24
+ if (values.length === 0)
25
+ return 0;
26
+ const sorted = [...values].sort((a, b) => a - b);
27
+ const mid = Math.floor(sorted.length / 2);
28
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
29
+ }
30
+ /**
31
+ * The Mann-Whitney U statistic, with the tie correction.
32
+ *
33
+ * Ties matter here rather than being a formality: response times are milliseconds rounded to
34
+ * integers, so a fast endpoint produces hundreds of identical values and the uncorrected variance
35
+ * would be wrong in the direction that invents significance.
36
+ */
37
+ export function mannWhitney(a, b) {
38
+ const n1 = a.length;
39
+ const n2 = b.length;
40
+ if (n1 === 0 || n2 === 0)
41
+ return null;
42
+ const all = [...a.map((v) => ({ v, from: 0 })), ...b.map((v) => ({ v, from: 1 }))].sort((x, y) => x.v - y.v);
43
+ const ranks = new Array(all.length);
44
+ const tieGroups = [];
45
+ for (let i = 0; i < all.length;) {
46
+ let j = i;
47
+ while (j + 1 < all.length && all[j + 1].v === all[i].v)
48
+ j += 1;
49
+ // Everything in a tie shares the average of the ranks they span.
50
+ const shared = (i + j + 2) / 2;
51
+ for (let k = i; k <= j; k += 1)
52
+ ranks[k] = shared;
53
+ if (j > i)
54
+ tieGroups.push(j - i + 1);
55
+ i = j + 1;
56
+ }
57
+ let rankSumA = 0;
58
+ for (const [i, entry] of all.entries())
59
+ if (entry.from === 0)
60
+ rankSumA += ranks[i];
61
+ const u1 = rankSumA - (n1 * (n1 + 1)) / 2;
62
+ const n = n1 + n2;
63
+ const mean = (n1 * n2) / 2;
64
+ const tieTerm = tieGroups.reduce((sum, t) => sum + (t * t * t - t), 0);
65
+ const variance = ((n1 * n2) / 12) * (n + 1 - tieTerm / (n * (n - 1)));
66
+ if (variance <= 0)
67
+ return { u: u1, z: 0, p: 1 };
68
+ // Continuity-corrected, two-sided.
69
+ const z = (u1 - mean - Math.sign(u1 - mean) * 0.5) / Math.sqrt(variance);
70
+ return { u: u1, z, p: 2 * (1 - normalCdf(Math.abs(z))) };
71
+ }
72
+ /** The standard normal CDF, by Abramowitz and Stegun 26.2.17 - accurate to about 1e-7. */
73
+ function normalCdf(x) {
74
+ const t = 1 / (1 + 0.2316419 * x);
75
+ const d = 0.3989422804014327 * Math.exp(-(x * x) / 2);
76
+ const poly = t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
77
+ return 1 - d * poly;
78
+ }
79
+ /**
80
+ * Whether this run is slower than the baseline, from the samples themselves.
81
+ *
82
+ * Both conditions have to hold. A difference that is not statistically real might be the machine;
83
+ * a difference that is real but tiny is not worth anybody's afternoon, and reporting it is how a
84
+ * team learns to ignore the report. The verdict says which one was missing.
85
+ */
86
+ export function compareSamples(now, before) {
87
+ const base = {
88
+ now: Math.round(median(now) * 10) / 10,
89
+ before: Math.round(median(before) * 10) / 10,
90
+ sampleSize: { now: now.length, before: before.length },
91
+ };
92
+ if (now.length < ENOUGH_SAMPLES || before.length < ENOUGH_SAMPLES) {
93
+ return {
94
+ ...base,
95
+ direction: 'cannot-say',
96
+ slowerThanBefore: 0.5,
97
+ p: null,
98
+ why: `${now.length} requests now and ${before.length} before. Below ${ENOUGH_SAMPLES} on either side the test ` +
99
+ 'is not reliable, so Tewip will not run it. Lengthen the run rather than reading the numbers.',
100
+ };
101
+ }
102
+ const test = mannWhitney(now, before);
103
+ if (test === null)
104
+ return { ...base, direction: 'cannot-say', slowerThanBefore: 0.5, p: null, why: 'there was nothing to compare' };
105
+ // U is the count of (now, before) pairs where now ranks higher, so U / (n1*n2) is directly the
106
+ // probability that a random request now is slower than a random one before. It is the most
107
+ // readable effect size there is, and it needs no units.
108
+ const slowerThanBefore = test.u / (now.length * before.length);
109
+ const shift = base.before > 0 ? (base.now - base.before) / base.before : 0;
110
+ const bigEnough = Math.abs(shift) >= SMALLEST_SHIFT && Math.abs(base.now - base.before) >= SMALLEST_SHIFT_MS;
111
+ const real = test.p < 0.01;
112
+ const percent = Math.round(shift * 100);
113
+ const chance = Math.round(slowerThanBefore * 100);
114
+ if (real && bigEnough) {
115
+ const faster = base.now < base.before;
116
+ return {
117
+ ...base,
118
+ direction: faster ? 'faster' : 'slower',
119
+ slowerThanBefore,
120
+ p: test.p,
121
+ why: `the median moved from ${base.before}ms to ${base.now}ms (${percent > 0 ? '+' : ''}${percent}%), and a random ` +
122
+ `request now is ${chance}% likely to be ${faster ? 'faster' : 'slower'} than a random one before. ` +
123
+ `Over ${now.length} and ${before.length} requests that is not the machine (p < 0.01).`,
124
+ };
125
+ }
126
+ if (real) {
127
+ return {
128
+ ...base,
129
+ direction: 'cannot-say',
130
+ slowerThanBefore,
131
+ p: test.p,
132
+ why: `the difference is real but too small to act on: ${base.before}ms to ${base.now}ms, ` +
133
+ `${percent > 0 ? '+' : ''}${percent}%. With this many samples even a millisecond is statistically certain, ` +
134
+ 'which is why certainty alone is not the test.',
135
+ };
136
+ }
137
+ return {
138
+ ...base,
139
+ direction: 'cannot-say',
140
+ slowerThanBefore,
141
+ p: test.p,
142
+ why: `${base.before}ms to ${base.now}ms is within what these two runs could produce by themselves ` +
143
+ `(p = ${test.p.toFixed(2)}). A difference the runs make on their own is not evidence about this change.`,
144
+ };
145
+ }
@@ -0,0 +1,216 @@
1
+ // `tewip` with nothing after it: look at this project and say what Tewip can actually do for it.
2
+ //
3
+ // Until now the answer to "I installed it, now what" was 233 lines of help listing eleven commands,
4
+ // which is a reference for somebody who already knows what they want and a wall for everybody else.
5
+ // Eleven commands is already more than most teams will run; the problem was never the number of
6
+ // things Tewip can do, it was that none of them were connected to *this* repository.
7
+ //
8
+ // So this looks. It counts what is actually here - test files and which framework wrote them,
9
+ // migrations and which tool, markup and which template language, a k6 or JMeter or Lighthouse
10
+ // report somebody has already produced - and then names the two or three commands that will tell
11
+ // this team something, with their own paths in them. And it says what Tewip cannot do here, because
12
+ // a front door that implies it can do everything is the same lie as a report with no limits on it.
13
+ //
14
+ // Nothing is run and nothing is written. It reads the directory and stops.
15
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
16
+ import { join, relative, resolve } from 'node:path';
17
+ import { languageOf, looksLikeTests } from '../vouch/tests-in.js';
18
+ import { MARKUP } from '../access/markup.js';
19
+ import { dialectOf } from '../schema/frameworks.js';
20
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.next', '.cache', '.tewip', '.triage-agent']);
21
+ /**
22
+ * A bounded look: enough to recognise a project, not enough to be slow on a large one.
23
+ *
24
+ * Breadth-first, and that is the whole correctness of it. Depth-first with a budget spends the
25
+ * budget wherever the alphabet happens to lead: pointed at Tewip itself it descended into `data/`,
26
+ * exhausted 4000 files on stored datasets, and reported **7 test files in a repository with 69**.
27
+ * A number that wrong is worse than no number, because the front door is the one place a person
28
+ * has no way to tell. Breadth-first sees every top-level directory before descending into any of
29
+ * them, so the budget is spent on breadth, which is what recognising a project needs.
30
+ */
31
+ export function lookAround(root, limit = 4000) {
32
+ const found = {
33
+ tests: new Map(),
34
+ migrations: new Map(),
35
+ markup: 0,
36
+ perfReports: [],
37
+ hasWorkflows: existsSync(join(root, '.github', 'workflows')),
38
+ hasStandards: existsSync(join(root, '.tewip', 'standards')),
39
+ filesSeen: false,
40
+ };
41
+ let seen = 0;
42
+ // A queue rather than recursion: every directory at one depth is read before any at the next.
43
+ const queue = [{ dir: root, depth: 0 }];
44
+ while (queue.length > 0 && seen < limit) {
45
+ const { dir, depth } = queue.shift();
46
+ if (depth > 8)
47
+ continue;
48
+ let entries;
49
+ try {
50
+ entries = readdirSync(dir);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ for (const entry of entries) {
56
+ if (seen >= limit)
57
+ break;
58
+ if (SKIP.has(entry) || (entry.startsWith('.') && entry !== '.github'))
59
+ continue;
60
+ const full = join(dir, entry);
61
+ let info;
62
+ try {
63
+ info = statSync(full);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ if (info.isDirectory()) {
69
+ queue.push({ dir: full, depth: depth + 1 });
70
+ continue;
71
+ }
72
+ seen += 1;
73
+ found.filesSeen = true;
74
+ const rel = relative(root, full).replaceAll('\\', '/');
75
+ if (MARKUP.test(entry))
76
+ found.markup += 1;
77
+ if (/\.jtl$/i.test(entry))
78
+ found.perfReports.push(rel);
79
+ else if (/(k6|lighthouse|summary|lhr)[\w.-]*\.json$/i.test(entry) && info.size < 8_000_000)
80
+ found.perfReports.push(rel);
81
+ const inMigrations = /(^|\/)(migrations?|db\/migrate|changelog)\//i.test(rel);
82
+ // Reading a file is only worth it for the two questions a name cannot answer: is this a
83
+ // test, and is this a migration. Both are cheap, and both are often wrong from the path.
84
+ const language = languageOf(entry);
85
+ const worthReading = (language !== null || /\.(sql|xml)$/i.test(entry)) && info.size < 1_000_000;
86
+ if (!worthReading)
87
+ continue;
88
+ let text = '';
89
+ try {
90
+ text = readFileSync(full, 'utf8');
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ if (language && looksLikeTests(rel, text))
96
+ found.tests.set(language, (found.tests.get(language) ?? 0) + 1);
97
+ if (inMigrations) {
98
+ const dialect = dialectOf(rel, text);
99
+ if (dialect !== 'unknown')
100
+ found.migrations.set(dialect, (found.migrations.get(dialect) ?? 0) + 1);
101
+ }
102
+ }
103
+ }
104
+ return found;
105
+ }
106
+ const total = (counts) => [...counts.values()].reduce((a, b) => a + b, 0);
107
+ /** What a person calls these, rather than what the code calls them. */
108
+ const PLAIN = {
109
+ js: 'JavaScript or TypeScript',
110
+ python: 'Python',
111
+ jvm: 'Java, Kotlin or Scala',
112
+ go: 'Go',
113
+ csharp: 'C#',
114
+ ruby: 'Ruby',
115
+ php: 'PHP',
116
+ rust: 'Rust',
117
+ sql: 'SQL',
118
+ django: 'Django',
119
+ rails: 'Rails',
120
+ alembic: 'Alembic',
121
+ typeorm: 'TypeORM',
122
+ sequelize: 'Sequelize',
123
+ knex: 'Knex',
124
+ efcore: 'EF Core',
125
+ liquibase: 'Liquibase',
126
+ };
127
+ const named = (counts) => [...counts.entries()]
128
+ .sort((a, b) => b[1] - a[1])
129
+ // Only the ones worth naming: a repository with one stray file in a ninth language does not
130
+ // need all nine listed at the front door.
131
+ .slice(0, 3)
132
+ .map(([name, n]) => `${n} ${PLAIN[name] ?? name}`)
133
+ .join(', ');
134
+ /** The two or three things worth running here, most useful first. */
135
+ export function suggest(found) {
136
+ const out = [];
137
+ const tests = total(found.tests);
138
+ const migrations = total(found.migrations);
139
+ if (tests > 0) {
140
+ out.push({
141
+ command: 'tewip vouch',
142
+ because: `${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
143
+ });
144
+ }
145
+ if (migrations > 0) {
146
+ out.push({
147
+ command: 'tewip schema --diff main...HEAD',
148
+ because: `${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
149
+ });
150
+ }
151
+ if (found.markup > 0) {
152
+ out.push({
153
+ command: 'tewip access',
154
+ because: `${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
155
+ });
156
+ }
157
+ for (const report of found.perfReports.slice(0, 1)) {
158
+ out.push({
159
+ command: `tewip perf ${report}`,
160
+ because: 'there is already a performance report here, and Tewip will judge it rather than restate it.',
161
+ });
162
+ }
163
+ if (tests > 0) {
164
+ out.push({
165
+ command: 'tewip done',
166
+ because: 'the one verdict over your current change, once you have made one. It answers ready, not ready, or cannot say.',
167
+ });
168
+ }
169
+ return out;
170
+ }
171
+ export function renderStart(found, root) {
172
+ const lines = ['# Tewip', ''];
173
+ if (!found.filesSeen) {
174
+ lines.push(`There is nothing to read in \`${root}\`. Run Tewip from the root of a project.`, '');
175
+ return lines.join('\n');
176
+ }
177
+ const suggestions = suggest(found);
178
+ if (suggestions.length === 0) {
179
+ lines.push(`Tewip looked around \`${root}\` and found nothing it can judge: no test files, no migrations, no markup, ` +
180
+ 'and no performance report. That is an honest answer rather than a failure - point it at a project with ' +
181
+ 'tests in it, or run `tewip --help` for the full list.', '');
182
+ return lines.join('\n');
183
+ }
184
+ lines.push('Here is what Tewip can tell you about **this** project, in the order worth doing.', '');
185
+ for (const [i, s] of suggestions.entries()) {
186
+ lines.push(`**${i + 1}. \`${s.command}\`**`, '', s.because, '');
187
+ }
188
+ lines.push('## What it will not do here', '');
189
+ const cannot = [];
190
+ if (total(found.tests) === 0)
191
+ cannot.push('No test files were found, so nothing can be said about whether your tests could fail or would catch a bug.');
192
+ if (total(found.migrations) === 0)
193
+ cannot.push('No migrations were found, so nothing is said about your database.');
194
+ if (found.markup === 0)
195
+ cannot.push('No markup was found, so nothing is said about accessibility.');
196
+ if (found.perfReports.length === 0)
197
+ cannot.push('No k6, JMeter or Lighthouse report was found. Tewip judges one you produce; it does not run load tests itself.');
198
+ if (!found.hasStandards)
199
+ cannot.push('This project has not taught Tewip any house rules yet. Put a markdown file in `.tewip/standards/` and `tewip standards` will enforce it.');
200
+ if (!found.hasWorkflows)
201
+ cannot.push('There is no GitHub Actions workflow here. `uses: hmamut39/tewip@main` with `mode: check` runs these same checks on every pull request.');
202
+ else
203
+ cannot.push('You have GitHub Actions. `mode: check` runs these same checks on every pull request.');
204
+ for (const line of cannot)
205
+ lines.push(`- ${line}`);
206
+ lines.push('');
207
+ lines.push('Everything Tewip answers carries its evidence and says what it did **not** check. It answers ' +
208
+ '"cannot say" rather than guessing. `tewip --help` has all eleven commands when you want them.', '');
209
+ return lines.join('\n');
210
+ }
211
+ export function runStart(root) {
212
+ const where = resolve(root);
213
+ const report = renderStart(lookAround(where), where);
214
+ console.log(report);
215
+ return report;
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.46.0",
3
+ "version": "0.48.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": [