tewip-ai 0.70.13 → 0.70.15

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.
@@ -62,7 +62,8 @@ function commonPrefix(a, b) {
62
62
  }
63
63
  const git = (root, args) => {
64
64
  try {
65
- return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60_000 });
65
+ // Captured, not inherited: see the note in grounding.ts.
66
+ return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] });
66
67
  }
67
68
  catch {
68
69
  return '';
@@ -8,6 +8,9 @@ import { flakyTests, readyToRelease, weeklyCost } from './flaky.js';
8
8
  import { applyQuarantine, frameworkOf, quarantineFor } from './quarantine.js';
9
9
  import { loadVerdicts, saidByAPerson, verdictFor } from '../learn/verdicts.js';
10
10
  const DAY_MS = 24 * 60 * 60 * 1000;
11
+ /** How many tests each group lists, and how many quarantine lines are written out in full. */
12
+ const SHOW = 15;
13
+ const SHOW_LINES = 5;
11
14
  const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
12
15
  function projects(root, only) {
13
16
  const dir = join(root, '.tewip', 'history');
@@ -50,7 +53,7 @@ export async function runFlaky(cmd) {
50
53
  if (chosen.length === 0)
51
54
  return;
52
55
  lines.push(`### ${title} (${chosen.length})`, '', explain, '');
53
- for (const test of chosen.slice(0, 15)) {
56
+ for (const test of chosen.slice(0, SHOW)) {
54
57
  lines.push(`- **${test.title}**`);
55
58
  lines.push(` - ${test.failedRuns} of ${test.totalRuns} run(s), ${test.failedAttempts} failed attempt(s), ${minutes(test.wastedMs)} lost${test.recoveredOnRetry > 0 ? `, passed on a retry ${test.recoveredOnRetry} time(s)` : ''}`);
56
59
  lines.push(` - Tewip read them as: ${test.categories.map((c) => `${c.category} ×${c.count}`).join(', ')}`);
@@ -58,6 +61,10 @@ export async function runFlaky(cmd) {
58
61
  if (test.file)
59
62
  lines.push(` - Where: ${test.file}`);
60
63
  }
64
+ // A heading that says 48 above a list of 15 is the defect this project keeps finding in
65
+ // itself: a true number beside a shorter answer, with nothing saying they differ.
66
+ if (chosen.length > SHOW)
67
+ lines.push(`_Showing the ${SHOW} costliest. The other ${chosen.length - SHOW} are not listed here._`);
61
68
  lines.push('');
62
69
  };
63
70
  group('quarantine', 'Quarantine these', 'Unreliable rather than wrong. Stop them blocking everyone, then fix them on purpose.');
@@ -82,6 +89,7 @@ export async function runFlaky(cmd) {
82
89
  lines.push(...applyAll(cmd.root, toQuarantine));
83
90
  else if (toQuarantine.length > 0)
84
91
  lines.push(...suggest(cmd.root, toQuarantine));
92
+ lines.push(...notChecked(all, cmd.days));
85
93
  const report = lines.join('\n');
86
94
  console.log(report);
87
95
  if (cmd.out) {
@@ -97,7 +105,7 @@ function readQuarantined(root) {
97
105
  }
98
106
  function suggest(root, tests) {
99
107
  const lines = ['### The line to add', ''];
100
- for (const test of tests.slice(0, 5)) {
108
+ for (const test of tests.slice(0, SHOW_LINES)) {
101
109
  const path = testFile(root, test);
102
110
  const framework = path ? frameworkOf(path, readFileSync(path, 'utf8')) : null;
103
111
  if (!path || !framework) {
@@ -106,7 +114,10 @@ function suggest(root, tests) {
106
114
  }
107
115
  lines.push(`- ${test.title} (${test.file}):`, '```', quarantineFor(test, framework).line, '```');
108
116
  }
109
- lines.push('', 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
117
+ const shown = Math.min(tests.length, SHOW_LINES);
118
+ lines.push('', shown < tests.length
119
+ ? `Showing ${shown} of ${tests.length}. \`tewip flaky --apply\` quarantines **all ${tests.length}**, not only the ${shown} printed here - each with its own evidence written above it. No test outside those ${tests.length} is touched.`
120
+ : 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
110
121
  return lines;
111
122
  }
112
123
  /** Writes the quarantine into the test files, and remembers which tests were quarantined. */
@@ -148,3 +159,28 @@ function applyAll(root, tests) {
148
159
  lines.push('', 'Nothing else in those files was changed. Review the diff before committing: a quarantined test is a decision, not a fix.', '');
149
160
  return lines;
150
161
  }
162
+ /**
163
+ * What this report did not check.
164
+ *
165
+ * The quarantine rules in `decide` carry a rail: a test whose failures Tewip read as the
166
+ * application being wrong is never quarantined, because hiding it would hide the bug. That rail
167
+ * reads the diagnosed category of each failure - so on history where nothing has been diagnosed it
168
+ * has nothing to read and cannot fire, and the verdict rests on retry behaviour alone. On real
169
+ * history that is the normal case rather than the exception: of the 15 quarantine candidates this
170
+ * printed for WordPress/gutenberg, all 15 were UNKNOWN - every one. The recommendation is still the
171
+ * best reading of the evidence there is. What was missing was the sentence telling the person which
172
+ * of the two they were being shown.
173
+ */
174
+ export function notChecked(tests, days) {
175
+ const quarantine = tests.filter((t) => t.verdict === 'quarantine');
176
+ const undiagnosed = quarantine.filter((t) => t.categories.every((c) => c.category === 'UNKNOWN'));
177
+ const lines = ['## What this did not check', ''];
178
+ lines.push(`- Any test that did not fail, and any run nobody put through \`tewip explain\` or \`tewip report\`. These numbers are read from kept history of the last ${days} day(s), and a suite nobody recorded looks exactly like a suite with no flaky tests.`);
179
+ if (undiagnosed.length > 0) {
180
+ lines.push(`- **${undiagnosed.length} of the ${quarantine.length} quarantine recommendation(s) rest on retry behaviour alone.** None of those failures had been diagnosed, so the rule that refuses to quarantine a test whose failures looked like the application being wrong had nothing to read and did not fire. A test that fails and then passes on a retry can also be a test correctly catching a race in the product - quarantine that one and the race is what you hide. Running \`tewip explain\` over those runs is what tells the two apart.`);
181
+ }
182
+ lines.push('- Whether a test is the only thing covering what it covers. Tewip does not know what stops being tested when you quarantine it.');
183
+ lines.push('- Whether a test is the same test. These are matched by name, so a renamed test looks like a new one and its history stays behind with the old name.');
184
+ lines.push('');
185
+ return lines;
186
+ }
@@ -161,5 +161,5 @@ function importShards(shards, meta, filesDir) {
161
161
  status: runStatus,
162
162
  attempts,
163
163
  };
164
- return { records, summary, skippedShards };
164
+ return { records, summary, skippedShards, incomplete: skippedShards.map((shard) => `shard ${shard} could not be read, so its tests are missing from this run`) };
165
165
  }
@@ -10,9 +10,10 @@
10
10
  // selector drift is not diagnosed from them. Every format below was checked against a report its
11
11
  // real runner wrote (fixtures/polyglot, run by .github/workflows/polyglot-fixtures.yml).
12
12
  import { readdirSync, readFileSync, statSync } from 'node:fs';
13
- import { join } from 'node:path';
13
+ import { basename, join } from 'node:path';
14
14
  import { SCHEMA_VERSION } from '../../reporters/types.js';
15
15
  import { descendants, parseXml } from './xml.js';
16
+ import { shortfall } from './shortfall.js';
16
17
  import { jmeterCases, jmeterCsvCases, k6Cases, looksLikeCsvJtl, looksLikeK6, robotCases, robotTime } from './other-formats.js';
17
18
  import { nunitCases, trxCases } from './dotnet.js';
18
19
  import { PASSED, failure } from './case.js';
@@ -161,6 +162,7 @@ export function importJunit(path, meta) {
161
162
  let startedAt = '';
162
163
  let anyFailed = false;
163
164
  const unread = [];
165
+ const incomplete = [];
164
166
  let parsedFiles = 0;
165
167
  for (const file of junitFiles(path)) {
166
168
  const raw = readFileSync(file, 'utf8');
@@ -199,6 +201,9 @@ export function importJunit(path, meta) {
199
201
  }
200
202
  else if (root.name === 'testsuites' || root.name === 'testsuite' || root.name === 'testrun') {
201
203
  cases = junitCases(root, meta.workspace);
204
+ const missing = shortfall(root);
205
+ if (missing)
206
+ incomplete.push(`${basename(file)}: ${missing}`);
202
207
  const stamp = root.attrs.timestamp ?? root.children.find((c) => c.name === 'testsuite')?.attrs.timestamp;
203
208
  if (stamp && (!startedAt || stamp < startedAt))
204
209
  startedAt = stamp;
@@ -271,5 +276,5 @@ export function importJunit(path, meta) {
271
276
  }
272
277
  console.error(`[tewip] ignored ${unread.length} file(s) that are not a test report: ${list}`);
273
278
  }
274
- return { records, summary, skippedShards: [] };
279
+ return { records, summary, skippedShards: [], incomplete };
275
280
  }
@@ -0,0 +1,62 @@
1
+ // Does a test report hold the run it says it holds?
2
+ //
3
+ // Kept apart from the importers because it asks nothing about any one format's shape: it compares a
4
+ // report's own declared totals with what is actually inside it, which is the one check that catches
5
+ // a file that parsed cleanly and is missing most of its contents.
6
+ import { descendants } from './xml.js';
7
+ /** A failing outcome, under either of the two names JUnit uses for one. */
8
+ const FAILURE = new Set(['failure', 'error']);
9
+ /**
10
+ * What the report says about itself, against what it actually contains.
11
+ *
12
+ * Every JUnit suite declares its own totals - `tests`, `failures`, `errors` - in the attributes of
13
+ * the element that holds the results. Nothing here ever read them, and a truncated upload is the
14
+ * commonest corruption there is: the runner dies, or the artifact is cut off mid-write, and what
15
+ * arrives is well-formed enough to parse and missing most of the run.
16
+ *
17
+ * Found by handing `tewip explain` a report whose header said `tests="500" failures="37"` and whose
18
+ * body held one passing testcase. The answer was **"All 1 test(s) passed. Nothing to explain."** The
19
+ * evidence that it was reading a fragment was in the file, two lines above the fragment.
20
+ *
21
+ * Only a shortfall is reported. A surplus is ordinary: rerun plugins write an entry per attempt
22
+ * while `tests` counts the test once, so finding more testcases than declared means a re-run, not a
23
+ * corrupt file.
24
+ */
25
+ export function shortfall(root) {
26
+ // The one signal that does not depend on the report declaring anything. A streaming writer like
27
+ // node:test puts each suite's totals in its closing tag, so a report cut off in the middle loses
28
+ // the tests and the count of them together and has nothing left to disagree with itself about.
29
+ // What it cannot hide is the tag it never closed.
30
+ if (root.truncated) {
31
+ return 'the file ends in the middle of itself - an element is left open, so it was cut off while being written or uploaded. Whatever ran after that point is not in here, and Tewip cannot say how much that was.';
32
+ }
33
+ const num = (e, key) => {
34
+ const n = Number(e.attrs[key]);
35
+ return Number.isFinite(n) ? n : 0;
36
+ };
37
+ // Only the innermost suites are counted. PHPUnit wraps every suite in another suite carrying the
38
+ // same totals, and real PHPUnit output was read as half missing when both levels were added up -
39
+ // a false alarm on a perfectly good report, which is worse than the silence this replaces. A
40
+ // parent's totals already include its children's, so the leaves are the whole run exactly once.
41
+ const all = [...(root.name === 'testsuite' ? [root] : []), ...descendants(root, 'testsuite')];
42
+ const suites = all.filter((e) => descendants(e, 'testsuite').length === 0);
43
+ const totals = (key, fallback) => root.attrs[key] !== undefined
44
+ ? num(root, key)
45
+ : suites.reduce((sum, e) => sum + fallback.reduce((s, k) => s + num(e, k), 0), 0);
46
+ const declaredTests = totals('tests', ['tests']);
47
+ const declaredBad = root.attrs.failures !== undefined || root.attrs.errors !== undefined
48
+ ? num(root, 'failures') + num(root, 'errors')
49
+ : suites.reduce((sum, e) => sum + num(e, 'failures') + num(e, 'errors'), 0);
50
+ const cases = descendants(root, 'testcase');
51
+ const foundBad = cases.filter((c) => c.children.some((ch) => FAILURE.has(ch.name))).length;
52
+ const missingTests = declaredTests - cases.length;
53
+ const missingBad = declaredBad - foundBad;
54
+ if (missingTests <= 0 && missingBad <= 0)
55
+ return null;
56
+ const said = [];
57
+ if (missingTests > 0)
58
+ said.push(`${missingTests} of the ${declaredTests} test(s) it says it holds are not in it`);
59
+ if (missingBad > 0)
60
+ said.push(`${missingBad} of the ${declaredBad} failure(s) it reports are not in it`);
61
+ return `${said.join(', and ')}. This report is a fragment of its run - most likely cut off while being written or uploaded. Nothing below covers the missing part.`;
62
+ }
@@ -65,6 +65,10 @@ export function parseXml(source) {
65
65
  const first = root.children[0];
66
66
  if (!first)
67
67
  throw new Error('not an XML document: no root element');
68
+ // Anything still on the stack is an element nobody closed. Well-formed XML leaves only the
69
+ // document itself behind.
70
+ if (stack.length > 1)
71
+ first.truncated = true;
68
72
  return first;
69
73
  }
70
74
  function end(source, token, from) {
@@ -115,7 +115,11 @@ export function relatedTestSources(root, feature, maxFiles = 4, maxChars = 8_000
115
115
  /** What a git range changed (e.g. "main...HEAD"); null outside a git checkout. */
116
116
  export function changeSummary(root, range, maxPatch = 12_000) {
117
117
  try {
118
- const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
118
+ // `stdio` so git's own stderr is captured rather than inherited. `execFileSync` sends a child's
119
+ // stderr straight to the parent unless told not to, so a range that does not exist printed three
120
+ // lines of `fatal: ambiguous argument 'main...HEAD'` and a git usage hint **before** Tewip's own
121
+ // explanation of the same thing. The failure was always handled; it just looked like a crash.
122
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
119
123
  const files = git(['diff', '--name-only', range]).split('\n').filter(Boolean);
120
124
  const patch = git(['diff', '--unified=2', range]);
121
125
  return { range, files, patch: patch.length > maxPatch ? `${patch.slice(0, maxPatch)}\n... (diff cut at ${maxPatch} characters)` : patch };
@@ -0,0 +1,67 @@
1
+ // Is every place Tewip is published actually carrying this release?
2
+ //
3
+ // There is a standing rule on this project: check every distribution channel after every
4
+ // improvement, because a stale channel looks exactly like a current one. It was being done by hand,
5
+ // and by hand it failed twice in one day - not because the check was skipped, but because the
6
+ // question was wrong. The MCP registry was asked `?search=<name>&limit=100`, which returns a capped
7
+ // version history and came back without the two newest releases in it. It answered with a version
8
+ // number, the version number looked plausible, and it would have looked just as plausible if
9
+ // nothing were wrong.
10
+ //
11
+ // So the verdict here has three values, not two. `unverified` is the whole point of the file: a
12
+ // channel nobody could read is not a channel that is up to date, and must never be rendered as one.
13
+ /** Compares two dotted versions. Missing parts count as zero, so 0.70 is older than 0.70.1. */
14
+ export function compareVersions(a, b) {
15
+ const parts = (v) => v.split('.').map((n) => Number.parseInt(n, 10) || 0);
16
+ const [x, y] = [parts(a), parts(b)];
17
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
18
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
19
+ if (d !== 0)
20
+ return d < 0 ? -1 : 1;
21
+ }
22
+ return 0;
23
+ }
24
+ export function judge(reading) {
25
+ if (reading.actual === null) {
26
+ return {
27
+ ...reading,
28
+ verdict: 'unverified',
29
+ says: `could not be read${reading.problem ? `: ${reading.problem}` : ''}. This is not the same as being up to date - nothing here says whether ${reading.name} has ${reading.expected} or not.`,
30
+ };
31
+ }
32
+ const order = compareVersions(reading.actual, reading.expected);
33
+ if (order === 0)
34
+ return { ...reading, verdict: 'current', says: `has ${reading.actual}.` };
35
+ if (order < 0) {
36
+ return { ...reading, verdict: 'behind', says: `has ${reading.actual}, but this repository is at ${reading.expected}. The release did not reach it.` };
37
+ }
38
+ // Ahead is not an error on its own: the editor extension keeps its own version line, and a
39
+ // channel can legitimately carry something this checkout has not caught up with.
40
+ return { ...reading, verdict: 'ahead', says: `has ${reading.actual}, which is newer than this checkout's ${reading.expected}.` };
41
+ }
42
+ const MARK = { current: 'ok', behind: 'BEHIND', ahead: 'ahead', unverified: 'UNVERIFIED' };
43
+ /** The whole answer, written so the two states that need a person are impossible to skim past. */
44
+ export function renderChannels(readings) {
45
+ const judged = readings.map(judge);
46
+ const width = Math.max(...judged.map((c) => c.name.length), 12);
47
+ const lines = ['Where Tewip is published', ''];
48
+ for (const c of judged)
49
+ lines.push(` ${MARK[c.verdict].padEnd(11)} ${c.name.padEnd(width)} ${c.says}`);
50
+ const behind = judged.filter((c) => c.verdict === 'behind');
51
+ const unverified = judged.filter((c) => c.verdict === 'unverified');
52
+ lines.push('');
53
+ if (behind.length > 0) {
54
+ lines.push(`${behind.length} channel(s) did not get this release: ${behind.map((c) => c.name).join(', ')}.`, '');
55
+ }
56
+ if (unverified.length > 0) {
57
+ // Named separately from `behind` on purpose. Folding the two together is how an unanswered
58
+ // query becomes a clean report.
59
+ lines.push(`${unverified.length} channel(s) could not be checked at all: ${unverified.map((c) => c.name).join(', ')}. Nothing above says whether those are current.`, '');
60
+ }
61
+ if (behind.length === 0 && unverified.length === 0)
62
+ lines.push('Every channel answered, and every one has this release.', '');
63
+ lines.push('How each was asked:');
64
+ for (const c of judged)
65
+ lines.push(` ${c.name}: ${c.asked}`);
66
+ return { text: lines.join(String.fromCharCode(10)), behind: behind.length, unverified: unverified.length };
67
+ }
@@ -92,7 +92,9 @@ function classify(file, before, after) {
92
92
  }
93
93
  function sourceDiff(root, range) {
94
94
  try {
95
- return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
95
+ // Captured, not inherited: see the note in grounding.ts - an unknown range printed git's own
96
+ // error before Tewip's.
97
+ return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
96
98
  }
97
99
  catch {
98
100
  return '';
@@ -24,7 +24,8 @@ const title = (line) => /['"`]([^'"`]{4,120})['"`]/.exec(line)?.[1] ?? /\b(?:def
24
24
  /** The diff of test files only, which is all this looks at. */
25
25
  function testDiff(root, range) {
26
26
  try {
27
- return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
27
+ // Captured, not inherited: see the note in grounding.ts.
28
+ return execFileSync('git', ['diff', '--unified=0', range, '--'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
28
29
  }
29
30
  catch {
30
31
  return '';
@@ -6,6 +6,48 @@ import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  const NEWLINE = String.fromCharCode(10);
9
+ /**
10
+ * A scanner's JSON, or `undefined` when it did not produce any.
11
+ *
12
+ * Empty output counts as no report rather than as an empty one. A scanner that printed nothing
13
+ * cannot be told apart from a scanner that crashed before it printed, and the two possible
14
+ * mistakes are not equally bad: calling a clean scan unreadable sends somebody to look, while
15
+ * calling an unreadable scan clean is the false all-clear this whole report exists to avoid.
16
+ */
17
+ function readJson(out) {
18
+ if (!out.trim())
19
+ return undefined;
20
+ try {
21
+ return JSON.parse(out);
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ /**
28
+ * Did this scanner produce a report, or only exit?
29
+ *
30
+ * `key` is the field its report always carries, so JSON that parses but is not a scan result - a
31
+ * usage message, a progress object, an error body - is caught too. The exit code is deliberately
32
+ * not consulted: pip-audit and npm audit both exit non-zero precisely when they have found
33
+ * something, so treating that as failure would discard the findings that matter most.
34
+ */
35
+ export function scanOutcome(tool, out, err, shape, read) {
36
+ const report = readJson(out);
37
+ if (report === undefined)
38
+ return { findings: [], problem: `${tool} did not return a report${firstLine(out, err)}` };
39
+ // Older pip-audit prints the dependency list unwrapped, so a bare array is a real report there
40
+ // and nowhere else. Accepting one everywhere would let a stray array pass for an empty scan.
41
+ const known = report?.[shape.key] !== undefined || (shape.orArray === true && Array.isArray(report));
42
+ if (!known)
43
+ return { findings: [], problem: `${tool} returned JSON that is not a scan report` };
44
+ return { findings: read(report), problem: null };
45
+ }
46
+ /** The first line a failing tool said, for the report - whichever stream it used. */
47
+ function firstLine(out, err) {
48
+ const said = [err, out].map((x) => x.trim()).find(Boolean);
49
+ return said ? `: ${said.split(NEWLINE)[0].slice(0, 120)}` : '';
50
+ }
9
51
  const run = (command, cwd, timeoutMs = 5 * 60_000) => {
10
52
  const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 40 * 1024 * 1024 });
11
53
  return { code: r.status ?? 1, out: `${r.stdout ?? ''}`, err: `${r.stderr ?? ''}` };
@@ -55,90 +97,94 @@ export function npmAudit(root) {
55
97
  }
56
98
  return { findings, problem: null };
57
99
  }
100
+ /**
101
+ * What pip-audit found, given what it printed. Separated from running it so the shapes real
102
+ * pip-audit produces can be tested without the tool installed.
103
+ */
104
+ export function pipFindings(report) {
105
+ const list = report?.dependencies ?? report;
106
+ return (Array.isArray(list) ? list : []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
107
+ package: String(dep.name ?? ''),
108
+ version: String(dep.version ?? ''),
109
+ severity: String(v.severity ?? 'unknown').toLowerCase(),
110
+ id: String(v.id ?? ''),
111
+ title: String(v.description ?? '').split('\n')[0].slice(0, 200),
112
+ fixedIn: (v.fix_versions ?? []).join(', '),
113
+ source: 'pip-audit',
114
+ dev: false,
115
+ })));
116
+ }
58
117
  /** `pip-audit`, when the project is Python and the tool is installed. */
59
118
  export function pipAudit(root) {
60
119
  const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
61
120
  if (!python || !has('pip-audit', root))
62
- return [];
63
- const { out } = run('pip-audit -f json --progress-spinner off', root);
64
- try {
65
- const report = JSON.parse(out);
66
- const list = report.dependencies ?? report;
67
- return (Array.isArray(list) ? list : []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
68
- package: String(dep.name ?? ''),
69
- version: String(dep.version ?? ''),
70
- severity: String(v.severity ?? 'unknown').toLowerCase(),
71
- id: String(v.id ?? ''),
72
- title: String(v.description ?? '').split('\n')[0].slice(0, 200),
73
- fixedIn: (v.fix_versions ?? []).join(', '),
74
- source: 'pip-audit',
75
- dev: false,
76
- })));
77
- }
78
- catch {
79
- return [];
121
+ return { findings: [], problem: null };
122
+ const { out, err } = run('pip-audit -f json --progress-spinner off', root);
123
+ return scanOutcome('pip-audit', out, err, { key: 'dependencies', orArray: true }, pipFindings);
124
+ }
125
+ /**
126
+ * What osv-scanner found, given what it printed. Separated from running it for the same reason as
127
+ * `pipFindings`: the parsing is what needs testing, and the tool is rarely installed.
128
+ */
129
+ export function osvFindings(report) {
130
+ const findings = [];
131
+ for (const result of report?.results ?? []) {
132
+ for (const pkg of result.packages ?? []) {
133
+ for (const v of pkg.vulnerabilities ?? []) {
134
+ findings.push({
135
+ package: String(pkg.package?.name ?? ''),
136
+ version: String(pkg.package?.version ?? ''),
137
+ severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
138
+ id: String(v.id ?? ''),
139
+ title: String(v.summary ?? '').slice(0, 200),
140
+ fixedIn: '',
141
+ // osv-scanner's output says neither which version fixes it nor whether the project
142
+ // declares the package, so neither is claimed.
143
+ fixExists: false,
144
+ direct: false,
145
+ source: 'osv-scanner',
146
+ dev: false,
147
+ });
148
+ }
149
+ }
80
150
  }
151
+ return findings;
81
152
  }
82
153
  /** OSV-Scanner, which covers Go, Rust, Java, Ruby, PHP and more, when it is installed. */
83
154
  export function osvScan(root) {
84
155
  if (!has('osv-scanner', root))
85
- return [];
86
- const { out } = run(`osv-scanner --format json --recursive "${root}"`, root);
87
- try {
88
- const report = JSON.parse(out);
89
- const findings = [];
90
- for (const result of report.results ?? []) {
91
- for (const pkg of result.packages ?? []) {
92
- for (const v of pkg.vulnerabilities ?? []) {
93
- findings.push({
94
- package: String(pkg.package?.name ?? ''),
95
- version: String(pkg.package?.version ?? ''),
96
- severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
97
- id: String(v.id ?? ''),
98
- title: String(v.summary ?? '').slice(0, 200),
99
- fixedIn: '',
100
- // osv-scanner's output says neither which version fixes it nor whether the project
101
- // declares the package, so neither is claimed.
102
- fixExists: false,
103
- direct: false,
104
- source: 'osv-scanner',
105
- dev: false,
106
- });
107
- }
108
- }
109
- }
110
- return findings;
111
- }
112
- catch {
113
- return [];
114
- }
156
+ return { findings: [], problem: null };
157
+ const { out, err } = run(`osv-scanner --format json --recursive "${root}"`, root);
158
+ return scanOutcome('osv-scanner', out, err, { key: 'results' }, osvFindings);
115
159
  }
116
160
  export function auditDependencies(root) {
117
161
  const ran = [];
118
162
  const missing = [];
119
163
  const findings = [];
120
- if (existsSync(join(root, 'package.json'))) {
121
- const npm = npmAudit(root);
122
- if (npm.problem)
123
- missing.push(`npm audit could not run: ${npm.problem}`);
164
+ // `ran` used to be pushed before the scanner was called, so a pip-audit that was installed and
165
+ // failed left "Checked: pip-audit" standing above zero findings. Measured on a project pinning
166
+ // requests==2.19.1 and flask==0.12.2 - both long known vulnerable - with a pip-audit that exited
167
+ // without JSON: the report read "Checked: secrets in the working tree, pip-audit. 0 need someone
168
+ // now." A scanner is named as having run only once it has returned something readable.
169
+ const record = (tool, outcome) => {
170
+ if (outcome.problem)
171
+ missing.push(`${tool} could not run: ${outcome.problem}`);
124
172
  else
125
- ran.push('npm audit');
126
- findings.push(...npm.findings);
173
+ ran.push(tool);
174
+ findings.push(...outcome.findings);
175
+ };
176
+ if (existsSync(join(root, 'package.json'))) {
177
+ record('npm audit', npmAudit(root));
127
178
  }
128
179
  const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
129
180
  if (python) {
130
- if (has('pip-audit', root)) {
131
- ran.push('pip-audit');
132
- findings.push(...pipAudit(root));
133
- }
134
- else {
181
+ if (has('pip-audit', root))
182
+ record('pip-audit', pipAudit(root));
183
+ else
135
184
  missing.push('pip-audit (pip install pip-audit) for the Python dependencies');
136
- }
137
- }
138
- if (has('osv-scanner', root)) {
139
- ran.push('osv-scanner');
140
- findings.push(...osvScan(root));
141
185
  }
186
+ if (has('osv-scanner', root))
187
+ record('osv-scanner', osvScan(root));
142
188
  else if (!existsSync(join(root, 'package.json')) && !python) {
143
189
  missing.push('osv-scanner (https://osv.dev) for this project\'s ecosystem');
144
190
  }
@@ -71,10 +71,74 @@ const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\
71
71
  * there is, and a key pasted into an example file by accident is one of the commonest ways
72
72
  * credentials leak. That case is now shown as a template finding instead of being dismissed.
73
73
  */
74
- export function looksPlanted(value, line, file, before = '') {
74
+ /**
75
+ * A URL-shaped credential whose destination cannot be a real system, or whose user and password
76
+ * are the same word.
77
+ *
78
+ * A credential matters because it is a way in to something. `postgres://saleor:saleor@localhost`
79
+ * is a way in to the machine it is already running on, and `user:password@example.com` points at
80
+ * a domain reserved by RFC 2606 precisely so that it cannot be anybody's system.
81
+ *
82
+ * Measured on Saleor at its pinned commit: six findings were reported as needing someone now, at
83
+ * high severity, each telling the team to "revoke it at the issuer first". Three were the Django
84
+ * local-development default in settings.py, one of them on a commented-out line, and the evidence
85
+ * offered was that the value "has the randomness of a real credential" - about the word `saleor`.
86
+ *
87
+ * Nothing is dropped. The finding becomes a placeholder finding, shown as noise with its reason
88
+ * attached. Suppressing a real secret is far worse than reporting a false one, so the two tests
89
+ * are narrow: a destination that provably reaches nobody, and a password identical to its own
90
+ * username, which nobody chooses for a credential that guards anything.
91
+ */
92
+ /** Domains that exist so they cannot be anybody's system (RFC 2606, RFC 6761). */
93
+ const RESERVED = /^(?:[\w-]+\.)*example\.(?:com|org|net)$|\.(?:invalid|test|localhost|local)$/i;
94
+ /** Addresses that only ever mean the machine the code is running on. */
95
+ const THIS_MACHINE = /^(?:localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/i;
96
+ const CREDENTIALLED_URL = /[a-z][a-z0-9+.-]*:\/\/([^:/\s'\"]+):([^@\s'\"]+)@([^/\s'\"?#]+)/gi;
97
+ /**
98
+ * A credential whose destination is the machine it runs on.
99
+ *
100
+ * This is deliberately not treated as a placeholder. A hardcoded password in settings.py is a real
101
+ * string that a person should see - an earlier phase decided exactly that, about this exact case -
102
+ * and calling it noise would hide it. What was wrong was only the urgency: Saleor's Django default
103
+ * was reported as needing someone now, at high severity, told to "revoke it at the issuer first".
104
+ * There is no issuer. It belongs in "real, but not reachable", which is the bucket for something
105
+ * true of the repository and not of what it runs.
106
+ */
107
+ export function credentialGoesNowhere(value, line) {
108
+ for (const [, , password, hostport] of [...value.matchAll(CREDENTIALLED_URL), ...[...line.matchAll(CREDENTIALLED_URL)].filter((m) => m[2] === value)]) {
109
+ if (password !== value && !value.includes(password))
110
+ continue;
111
+ const host = hostport.replace(/:\d+$/, '');
112
+ if (THIS_MACHINE.test(host))
113
+ return `the URL points at ${host}, so it reaches only the machine it runs on`;
114
+ }
115
+ return null;
116
+ }
117
+ export function urlCredentialIsHarmless(value, line) {
118
+ // The URL rules capture the whole URL as the value, not the password inside it - which is also
119
+ // why these pass the entropy gate at all, and why the report offered "the randomness of a real
120
+ // credential (3.82 bits per character)" as evidence about a string that is mostly the word
121
+ // `postgres` and a hostname. So the value is read as a URL first, and only then looked for inside
122
+ // the line, which is how the patterns that do capture a bare password arrive here.
123
+ const inValue = [...value.matchAll(CREDENTIALLED_URL)];
124
+ const inLine = [...line.matchAll(CREDENTIALLED_URL)].filter((m) => m[2] === value);
125
+ for (const [, user, password, hostport] of [...inValue, ...inLine]) {
126
+ const host = hostport.replace(/:\d+$/, '');
127
+ if (RESERVED.test(host))
128
+ return `the URL points at ${host}, a domain reserved so that it cannot be anybody's system`;
129
+ if (user.toLowerCase() === password.toLowerCase())
130
+ return 'the user and the password are the same word, which is a stand-in rather than a credential';
131
+ }
132
+ return null;
133
+ }
134
+ export function looksPlanted(value, line, file, before = '', whole = value) {
75
135
  if (PLACEHOLDER_WORDS.test(value))
76
136
  return 'the value itself says it is an example';
77
- if (PLACEHOLDER_WORDS.test(line))
137
+ // Only the words *around* the credential are commentary about it. A hostname or a database name
138
+ // inside the URL is part of the credential: a real password at db.acme-prod.example-corp.io was
139
+ // dismissed because "example" was in the line, and it was in the line because it was in the host.
140
+ const around = line.split(whole).join(' ');
141
+ if (PLACEHOLDER_WORDS.test(around))
78
142
  return 'the line around it says it is an example';
79
143
  // JSON and YAML put the key on its own line when the value is long, and an OpenAPI schema is all
80
144
  // long values. nocodb declares `"example":` and then the token underneath it, so reading only the
@@ -83,6 +147,9 @@ export function looksPlanted(value, line, file, before = '') {
83
147
  return 'the key above it says it is an example';
84
148
  if (/(.)\1{5,}/.test(value))
85
149
  return 'the value repeats one character, so it is a stand-in';
150
+ const harmless = urlCredentialIsHarmless(value, line);
151
+ if (harmless)
152
+ return harmless;
86
153
  return null;
87
154
  }
88
155
  /** True when only the file's name suggests a template, whatever the value itself looks like. */
@@ -150,7 +217,16 @@ export function scanSecrets(root, files = sourceFiles(root)) {
150
217
  for (const { kind, what, re, minEntropy } of PATTERNS) {
151
218
  for (const match of text.matchAll(re)) {
152
219
  const value = match[1];
153
- const entropy = entropyOf(value);
220
+ // Where a pattern captures the credential separately, that is what gets judged. The two
221
+ // URL rules capture the whole URL first and the password second, and judging the URL meant
222
+ // a hostname or a database name decided whether a password was real: a genuine credential
223
+ // at db.acme-prod.example-corp.io was dismissed because its host contains "example", and
224
+ // any connection string to a database called sample or demo would go the same way.
225
+ // Suppressing a real secret is the worst thing this scanner can do, and it was one word in
226
+ // an unrelated part of the string away. The whole match is still what gets redacted, so
227
+ // nothing of the URL is printed either way.
228
+ const secret = match[2] ?? value;
229
+ const entropy = entropyOf(secret);
154
230
  if (entropy < (minEntropy ?? 3))
155
231
  continue;
156
232
  const index = match.index ?? 0;
@@ -170,7 +246,8 @@ export function scanSecrets(root, files = sourceFiles(root)) {
170
246
  line,
171
247
  evidence: redact(lines[line - 1] ?? '', value),
172
248
  entropy: Number(entropy.toFixed(2)),
173
- placeholder: looksPlanted(value, lines[line - 1] ?? '', rel, lines[line - 2] ?? ''),
249
+ placeholder: looksPlanted(secret, lines[line - 1] ?? '', rel, lines[line - 2] ?? '', value),
250
+ localOnly: credentialGoesNowhere(value, lines[line - 1] ?? ''),
174
251
  template: inATemplate(rel),
175
252
  });
176
253
  }
@@ -92,8 +92,13 @@ export async function runSecure(cmd) {
92
92
  }
93
93
  if (cmd.zapReport) {
94
94
  const fromZap = readZapReport(resolve(cmd.zapReport));
95
- checked.push(`an OWASP ZAP report (${fromZap.length} finding(s))`);
96
- for (const finding of fromZap)
95
+ // Named as checked only once it has been read. "(0 finding(s))" above a file Tewip could not
96
+ // open is a measurement of nothing, and a mistyped path answered exactly like a clean scan.
97
+ if (fromZap.problem)
98
+ notChecked.push(`the ZAP report you asked for: ${fromZap.problem}`);
99
+ else
100
+ checked.push(`an OWASP ZAP report (${fromZap.findings.length} finding(s))`);
101
+ for (const finding of fromZap.findings)
97
102
  items.push(triageWeb(finding, accepted));
98
103
  }
99
104
  else if (cmd.url) {
@@ -63,6 +63,15 @@ export function triageSecret(finding, root, accepted) {
63
63
  if (finding.placeholder) {
64
64
  return { ...base, verdict: 'noise', severity: 'low', why: `${finding.placeholder}: ${finding.evidence}`, next: 'nothing: this is not a credential' };
65
65
  }
66
+ if (finding.localOnly) {
67
+ return {
68
+ ...base,
69
+ verdict: 'not-reachable',
70
+ severity: 'low',
71
+ why: `${finding.evidence} - ${finding.localOnly}`,
72
+ next: 'nothing to revoke, because nothing outside this machine accepts it. Worth replacing with a value read from the environment, on a normal schedule.',
73
+ };
74
+ }
66
75
  if (finding.template) {
67
76
  // Only the file's name says this is a template; the value itself looks like a real credential.
68
77
  // Dismissing it would hide the commonest accident there is - a working key pasted into an
@@ -10,17 +10,22 @@ const RISK = { High: 'high', Medium: 'medium', Low: 'low', Informational: 'low'
10
10
  /** Findings from a ZAP JSON report, in Tewip's own shape. */
11
11
  export function readZapReport(path) {
12
12
  if (!existsSync(path))
13
- return [];
13
+ return { findings: [], problem: `there is no file at ${path}` };
14
14
  let report;
15
15
  try {
16
16
  report = JSON.parse(readFileSync(path, 'utf8'));
17
17
  }
18
18
  catch {
19
- return [];
19
+ return { findings: [], problem: `${path} is not JSON, so it is not a ZAP report Tewip can read` };
20
+ }
21
+ // ZAP always writes `site`, one entry per host it scanned, even when it found nothing. Anything
22
+ // without it is some other tool's JSON, or ZAP's own error body.
23
+ if (!Array.isArray(report?.site)) {
24
+ return { findings: [], problem: `${path} is JSON but not a ZAP report: it has no "site" list` };
20
25
  }
21
26
  const alerts = (report.site ?? []).flatMap((site) => site.alerts ?? []);
22
27
  const strip = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
23
- return alerts.map((alert) => {
28
+ const findings = alerts.map((alert) => {
24
29
  const risk = (alert.riskdesc ?? '').split(' ')[0];
25
30
  const first = (alert.instances ?? [])[0] ?? {};
26
31
  return {
@@ -32,6 +37,7 @@ export function readZapReport(path) {
32
37
  next: strip(alert.solution ?? '').slice(0, 300) || 'see the ZAP report for the recommended fix',
33
38
  };
34
39
  });
40
+ return { findings, problem: null };
35
41
  }
36
42
  /** Whether a ZAP baseline scan can be run from here at all. */
37
43
  export function zapAvailable(cwd) {
@@ -73,6 +73,30 @@ export function vouchAll(root, paths) {
73
73
  }
74
74
  const count = (all, verdict) => all.filter((j) => j.verdict === verdict).length;
75
75
  export function renderVouch(files) {
76
+ // No test file was read, so there is nothing to say about tests.
77
+ //
78
+ // This printed the ordinary summary - "0 test(s) read in 0 file(s). **0 cannot fail at all.**" -
79
+ // followed by the heading **"Nothing here is unable to fail"** and the line "Every test read can
80
+ // be broken by a change to the code". All true of nothing, and all of it reads as a pass, in the
81
+ // command whose entire job is to find tests that cannot fail.
82
+ //
83
+ // The comment in `testFilesUnder` above already diagnosed this in Phase 36, when three languages
84
+ // were missing from the extension list and a file named outright came back as "0 test(s) read in
85
+ // 0 file(s) - which reads like a clean answer and is not one". The cause was fixed; the report
86
+ // was not, so every other way of reading nothing still produced a pass: the wrong folder, a
87
+ // framework Tewip does not recognise, or a project that genuinely has no tests yet.
88
+ if (files.length === 0) {
89
+ return [
90
+ '# Which of these tests could fail?',
91
+ '',
92
+ '**No test file was read, so nothing here has been checked.** This is not a report that your tests are sound - it is a report that Tewip found none to look at.',
93
+ '',
94
+ 'Three things usually explain it: the path given holds no tests, the tests are somewhere Tewip was not pointed at (`tewip vouch <path>`), or they are written in a language it cannot read yet.',
95
+ '',
96
+ 'It reads JavaScript, TypeScript, Python, Java, Kotlin, Scala, Groovy, Go, C#, F#, Ruby, PHP and Rust.',
97
+ '',
98
+ ].join(String.fromCharCode(10));
99
+ }
76
100
  const all = files.flatMap((f) => f.judgements);
77
101
  const cannotFail = all.filter((j) => CANNOT_FAIL.includes(j.verdict));
78
102
  const crashOnly = count(all, 'crash-only');
@@ -50,8 +50,19 @@ async function main() {
50
50
  const index = new HistoryIndex([...pastRecords, ...imported.records], [...pastRuns, imported.summary]);
51
51
  const items = await buildItems(imported.records, index, { dataset: `local: ${name}`, llm: llmFromEnv(), corrections: loadCorrections(historyDir) });
52
52
  const tests = Object.keys(imported.summary.attempts).length;
53
- if (items.length === 0)
54
- console.log(`All ${tests} test(s) passed. Nothing to explain.`);
53
+ // A report missing part of itself is not a report that nothing went wrong. This goes first,
54
+ // above any board and in place of any all-clear, because it changes what everything below means.
55
+ if (imported.incomplete.length > 0) {
56
+ console.log('**This run is incomplete.**');
57
+ for (const why of imported.incomplete)
58
+ console.log(`- ${why}`);
59
+ console.log('');
60
+ }
61
+ if (items.length === 0) {
62
+ console.log(imported.incomplete.length > 0
63
+ ? `The ${tests} test(s) that did arrive all passed. That is not the same as the run passing, and Tewip cannot say whether the missing part did.`
64
+ : `All ${tests} test(s) passed. Nothing to explain.`);
65
+ }
55
66
  else
56
67
  console.log(sdetBoardView(items, runId, `local: ${name}`).markdown);
57
68
  // Keep this run as history for the next one, with its diagnoses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.70.13",
3
+ "version": "0.70.15",
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": [
@@ -65,6 +65,7 @@
65
65
  "prepack": "npm run build",
66
66
  "tewip": "node cli/tewip.ts",
67
67
  "version": "node scripts/sync-version.ts && git add server.json",
68
+ "channels": "node scripts/channels.ts",
68
69
  "corpus": "node scripts/corpus.ts"
69
70
  },
70
71
  "license": "Apache-2.0",