tewip-ai 0.66.0 → 0.68.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/dist/cli/tewip.js CHANGED
@@ -199,7 +199,7 @@ async function main() {
199
199
  if (!question)
200
200
  throw new Error('usage: tewip recall "<an error, or a question>" [--test "<test id>"] [--limit 5]');
201
201
  const { runRecall } = await import('../engine/recall/recall-command.js');
202
- runRecall({ root: process.cwd(), question, testId: flag(args, 'test'), limit: Number(flag(args, 'limit')) || undefined, name: flag(args, 'name'), out: flag(args, 'out') });
202
+ await runRecall({ root: process.cwd(), question, testId: flag(args, 'test'), limit: Number(flag(args, 'limit')) || undefined, name: flag(args, 'name'), out: flag(args, 'out') });
203
203
  return;
204
204
  }
205
205
  case 'incident': {
@@ -24,7 +24,13 @@ function gather(root, only) {
24
24
  }
25
25
  export function runLearn(cmd) {
26
26
  const { corrections, records } = gather(cmd.root, cmd.name);
27
- const learned = whatWeLearned(corrections, loadVerdicts(cmd.root), records);
27
+ // The denominator is diagnoses, not kept failures. A store can hold thousands of failures that
28
+ // nothing has diagnosed yet - the harvest writes exactly that - and counting those as decisions
29
+ // divides the corrections by a number far larger than the decisions Tewip actually made, making
30
+ // it look more accurate than it is. Found next to the same mistake in `recall`, which called 1,305
31
+ // undiagnosed failures "searched".
32
+ const diagnosed = records.filter((r) => r.diagnosis?.category).length;
33
+ const learned = whatWeLearned(corrections, loadVerdicts(cmd.root), records, diagnosed);
28
34
  const report = renderLearning(learned);
29
35
  console.log(report);
30
36
  if (cmd.labels) {
@@ -96,6 +96,18 @@ function inPattern(line, at) {
96
96
  * changes to one function say much less about a suite than five changes in five places.
97
97
  */
98
98
  export function mutantsFor(text, limit = 6) {
99
+ return spread(candidateMutants(text), limit);
100
+ }
101
+ /**
102
+ * Every change this file allows, before any sampling.
103
+ *
104
+ * The caller needs this number, not only the sample. `prove engine/verdict/verdict.ts --limit 6`
105
+ * used to report "6 change(s) were made... 0 went unnoticed", and the file allows 20: eight of the
106
+ * twelve it never tried were in code the tests had never been asked about. A clean report that
107
+ * silently covers a third of the file reads exactly like a clean report that covers all of it,
108
+ * which is the one thing this project will not print.
109
+ */
110
+ export function candidateMutants(text) {
99
111
  const ranges = textRanges(text);
100
112
  const lines = text.split(String.fromCharCode(10));
101
113
  const offsets = [];
@@ -128,14 +140,14 @@ export function mutantsFor(text, limit = 6) {
128
140
  break; // one change per line keeps each answer about one thing
129
141
  }
130
142
  }
131
- return spread(found, limit);
143
+ return found;
132
144
  }
133
145
  /**
134
146
  * Takes at most `limit`, as far apart in the file as they can be. A suite that catches everything in
135
147
  * one function and nothing in the rest of the file should not look like a suite that catches
136
148
  * everything, and taking the first six mutants in order is how that happens.
137
149
  */
138
- function spread(all, limit) {
150
+ export function spread(all, limit) {
139
151
  if (all.length <= limit)
140
152
  return all;
141
153
  const step = all.length / limit;
@@ -24,6 +24,36 @@ export function filesToProve(cmd) {
24
24
  return [];
25
25
  return change.files.map((f) => f.split('\\').join('/')).filter((f) => SOURCE.test(f) && !IS_TEST.test(f) && existsSync(resolve(cmd.root, f)));
26
26
  }
27
+ /**
28
+ * " of the 20" - the part of the headline that keeps a clean answer honest.
29
+ *
30
+ * `--limit` samples the file, so "6 change(s) were made... 0 went unnoticed" was true of six lines
31
+ * and read as true of the file. Found on Tewip's own `engine/verdict/verdict.ts`: a report of six
32
+ * caught out of six, in a file that allows twenty. Silent when everything the file allows was tried,
33
+ * because then there is nothing to warn about.
34
+ */
35
+ function ofHowMany(proofs, tried) {
36
+ const found = proofs.reduce((total, p) => total + p.found, 0);
37
+ return found > tried ? ` of the ${found}` : '';
38
+ }
39
+ /**
40
+ * The two versions of a line, cut so that the difference between them is visible.
41
+ *
42
+ * Both used to be truncated at 120 characters from the start, which on a long line put the change
43
+ * past the cut and printed an identical pair. Found on Tewip's own `verdict.ts:112`, where a real
44
+ * hole - nothing tests the "and 4 more" count - was reported as `was: X` / `became: X`. A reader
45
+ * seeing that concludes the tool is broken, and stops reading the findings that are right.
46
+ */
47
+ export function aroundTheChange(was, now, width = 120) {
48
+ if (was.length <= width && now.length <= width)
49
+ return [was, now];
50
+ let at = 0;
51
+ while (at < was.length && at < now.length && was[at] === now[at])
52
+ at += 1;
53
+ const from = Math.max(0, at - Math.floor(width / 3));
54
+ const cut = (text) => (from > 0 ? '...' : '') + text.slice(from, from + width) + (from + width < text.length ? '...' : '');
55
+ return [cut(was), cut(now)];
56
+ }
27
57
  export function renderProof(proofs) {
28
58
  const tried = proofs.flatMap((p) => p.tried);
29
59
  const unnoticed = tried.filter((t) => t.fate === 'unnoticed');
@@ -36,7 +66,7 @@ export function renderProof(proofs) {
36
66
  '',
37
67
  tried.length === 0
38
68
  ? 'Nothing was tried. See below for why.'
39
- : `${tried.length} change(s) were made to your code and the covering tests were run against each. **${unnoticed.length} went unnoticed.** ${noticed.length} were caught. Took ${seconds}s.`,
69
+ : `${tried.length}${ofHowMany(proofs, tried.length)} change(s) were made to your code and the covering tests were run against each. **${unnoticed.length} went unnoticed.** ${noticed.length} were caught. Took ${seconds}s.`,
40
70
  '',
41
71
  ];
42
72
  if (unnoticed.length > 0) {
@@ -47,9 +77,10 @@ export function renderProof(proofs) {
47
77
  continue;
48
78
  lines.push(`### ${proof.file}`, '');
49
79
  for (const gap of gaps) {
80
+ const [was, now] = aroundTheChange(gap.mutant.was, gap.mutant.now);
50
81
  lines.push(`- **line ${gap.mutant.line}: ${gap.mutant.kind}**`);
51
- lines.push(` - was: \`${gap.mutant.was.slice(0, 120)}\``);
52
- lines.push(` - became: \`${gap.mutant.now.slice(0, 120)}\``);
82
+ lines.push(` - was: \`${was}\``);
83
+ lines.push(` - became: \`${now}\``);
53
84
  }
54
85
  lines.push('', `Run by: ${proof.covering.join(', ')}`, '');
55
86
  }
@@ -67,7 +98,8 @@ export function renderProof(proofs) {
67
98
  lines.push(`- **${p.file}**: ${p.blocked}`);
68
99
  lines.push('');
69
100
  }
70
- if (invalid.length > 0 || broke.length > 0 || proofs.some((p) => p.notTried > 0 || p.setAside.length > 0)) {
101
+ const neverSampled = proofs.filter((p) => p.found > p.tried.length + p.notTried);
102
+ if (invalid.length > 0 || broke.length > 0 || neverSampled.length > 0 || proofs.some((p) => p.notTried > 0 || p.setAside.length > 0)) {
71
103
  lines.push('## Not counted', '');
72
104
  if (invalid.length > 0)
73
105
  lines.push(`- ${invalid.length} change(s) did not compile, so they say nothing about the tests. That is Tewip's fault, not yours.`);
@@ -79,6 +111,9 @@ export function renderProof(proofs) {
79
111
  const left = proofs.reduce((total, p) => total + p.notTried, 0);
80
112
  if (left > 0)
81
113
  lines.push(`- ${left} change(s) were never tried: the time budget ran out. Raise it with \`--budget <seconds>\`.`);
114
+ for (const p of neverSampled) {
115
+ lines.push(`- **${p.file}** allows ${p.found} change(s) and ${p.tried.length + p.notTried} were taken, spread across the file. Nothing above says anything about the other ${p.found - p.tried.length - p.notTried}. To ask about all of them: \`--limit ${p.found}\`.`);
116
+ }
82
117
  lines.push('');
83
118
  }
84
119
  lines.push('---', '', 'Every line above was proved by running your own tests against your own code, one small change at', 'a time, and every file was put back exactly as it was. No model was called.', '', 'What this does not say: that the unnoticed changes are bugs, or that the caught ones mean the', 'suite is good. It says only which of these particular mistakes your tests would have stopped -', 'which is more than a coverage percentage can tell you, and less than a proof that the code works.');
@@ -121,7 +156,7 @@ export function runProve(cmd) {
121
156
  const proofs = [];
122
157
  for (const file of files) {
123
158
  if (!existsSync(resolve(cmd.root, file))) {
124
- proofs.push({ file, covering: [], tried: [], blocked: 'no such file here', setAside: [], notTried: 0, seconds: 0 });
159
+ proofs.push({ file, covering: [], tried: [], blocked: 'no such file here', setAside: [], notTried: 0, found: 0, seconds: 0 });
125
160
  continue;
126
161
  }
127
162
  // Nothing importing it by name is the normal case in a layered codebase, not the absence of
@@ -13,7 +13,7 @@ import { spawnSync } from 'node:child_process';
13
13
  import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import { join, relative, resolve } from 'node:path';
15
15
  import { runTest } from '../quality/write-verify.js';
16
- import { applyMutant, mutantsFor } from './mutants.js';
16
+ import { applyMutant, candidateMutants, spread } from './mutants.js';
17
17
  /**
18
18
  * Does this mutated file still parse?
19
19
  *
@@ -74,14 +74,18 @@ export function proveFile(root, file, covering, framework, opts = {}) {
74
74
  const runMs = (opts.runSeconds ?? 180) * 1000;
75
75
  const say = opts.onStep ?? (() => { });
76
76
  const full = resolve(root, file);
77
- const proof = { file, covering, tried: [], blocked: '', setAside: [], notTried: 0, seconds: 0 };
77
+ const proof = { file, covering, tried: [], blocked: '', setAside: [], notTried: 0, found: 0, seconds: 0 };
78
78
  if (covering.length === 0) {
79
79
  proof.blocked = 'no test imports this file, so no test could notice any change to it. That is the finding: this code is not covered by anything Tewip can name.';
80
80
  return finish(proof, started);
81
81
  }
82
82
  const original = readFileSync(full, 'utf8');
83
83
  const stopGuarding = guardAgainstBeingKilled(full, original);
84
- const mutants = mutantsFor(original, limit);
84
+ // The whole set first, then the sample: `found` is what the file allows and `mutants` is what
85
+ // there was time to ask about, and the report has to be able to tell the reader the difference.
86
+ const candidates = candidateMutants(original);
87
+ const mutants = spread(candidates, limit);
88
+ proof.found = candidates.length;
85
89
  if (mutants.length === 0) {
86
90
  proof.blocked = 'nothing in this file could be changed in a way a test should be expected to catch - no comparison, no condition, no returned value outside of strings and comments';
87
91
  return finish(proof, started, stopGuarding);
@@ -1,43 +1,82 @@
1
1
  // `tewip recall "<an error, or a question>"`: what this project already concluded.
2
2
  import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, join, resolve } from 'node:path';
4
- import { datasetFiles, readTriageRecords } from '../../reporters/store.js';
4
+ import { buildItems } from '../../outlets/items.js';
5
+ import { datasetFiles, readRunSummaries, readTriageRecords } from '../../reporters/store.js';
5
6
  import { loadCorrections } from '../corrections.js';
7
+ import { HistoryIndex } from '../history.js';
6
8
  import { memories, recall, renderRecall } from './recall.js';
7
9
  /** Everything kept for this project: its failures, and the times a person overruled Tewip. */
8
10
  function kept(root, only) {
9
11
  const dir = join(root, '.tewip', 'history');
10
12
  if (!existsSync(dir))
11
- return { records: [], corrections: [] };
13
+ return { records: [], runs: [], corrections: [] };
12
14
  const records = [];
15
+ const runs = [];
13
16
  const corrections = [];
14
17
  for (const project of only ? [only] : readdirSync(dir)) {
15
18
  const where = join(dir, project);
16
19
  if (!existsSync(where))
17
20
  continue;
18
- records.push(...readTriageRecords(datasetFiles(where).triage));
21
+ const files = datasetFiles(where);
22
+ records.push(...readTriageRecords(files.triage));
23
+ runs.push(...readRunSummaries(files.runs));
19
24
  corrections.push(...loadCorrections(where));
20
25
  }
21
- return { records, corrections };
26
+ return { records, runs, corrections };
27
+ }
28
+ /**
29
+ * The kept failures with a category on each, diagnosing the ones that do not have one stored.
30
+ *
31
+ * This is the whole bug Phase 59 was looking for. `memories()` keeps only records that carry a
32
+ * `diagnosis`, and **nothing on the collection path writes one**: the reporter and the harvest both
33
+ * store the raw failure, and the category is worked out when a report is rendered. So on a real
34
+ * 1,305-failure history, `recall` had nothing to search - and said "Nothing in this project's
35
+ * history resembles it... this is new here", footed by "Searched 1305 kept failure(s)". Both halves
36
+ * false: nothing was searched, and 660 of those 1,305 failures were the timeout being asked about.
37
+ *
38
+ * `note`, `flaky` and the risk digest had all been diagnosing on read for months. Recall was the
39
+ * one retrieval layer that demanded a field nobody writes, which made it permanently empty on every
40
+ * project that had not been through `tewip explain` - and empty in a way that read like an answer.
41
+ *
42
+ * Rules only, never a model: `recall`'s promise is no model, no network, nothing to set up, and a
43
+ * question asked of local history must not start costing money because of how it is indexed.
44
+ */
45
+ async function diagnosed(records, runs, corrections) {
46
+ if (records.length === 0)
47
+ return [];
48
+ const items = await buildItems(records, new HistoryIndex(records, runs), { dataset: 'recall', llm: null, reuseStoredDiagnoses: true, corrections });
49
+ const byCase = new Map(items.map((item) => [`${item.runId}::${item.testId}`, item.diagnosis]));
50
+ return records.map((record) => (record.diagnosis ? record : { ...record, diagnosis: byCase.get(`${record.runId}::${record.testId}`) }));
22
51
  }
23
52
  /**
24
53
  * The report, without printing it. An MCP server speaks JSON-RPC over stdout, so anything one of
25
54
  * its tools calls has to return its answer: a stray console.log corrupts every response the
26
55
  * assistant receives, and the corruption looks like the tool being broken rather than noisy.
27
56
  */
28
- export function recallReport(cmd) {
29
- const { records, corrections } = kept(cmd.root, cmd.name);
57
+ export async function recallReport(cmd) {
58
+ const { records, runs, corrections } = kept(cmd.root, cmd.name);
30
59
  if (records.length === 0) {
31
60
  return { report: 'Nothing has been kept here yet, so there is no history to search. Run `tewip explain <report>` first.', searched: 0, corrections: 0 };
32
61
  }
33
- const found = recall(cmd.question, memories(records, corrections), { testId: cmd.testId, limit: cmd.limit ?? 5 });
34
- return { report: renderRecall(cmd.question, found), searched: records.length, corrections: corrections.length };
62
+ // The count reported is the count searched. It used to be `records.length` whatever `memories`
63
+ // actually indexed, which is how an empty index came to be described as 1,305 failures searched.
64
+ const mem = memories(await diagnosed(records, runs, corrections), corrections);
65
+ if (mem.length === 0) {
66
+ return {
67
+ report: `${records.length} failure(s) are kept here, but none of them could be given a category, so there is nothing to compare this against. Recall searches what Tewip concluded about a failure, and a failure it cannot read is not a conclusion.`,
68
+ searched: 0,
69
+ corrections: corrections.length,
70
+ };
71
+ }
72
+ const found = recall(cmd.question, mem, { testId: cmd.testId, limit: cmd.limit ?? 5 });
73
+ return { report: renderRecall(cmd.question, found, mem.length), searched: mem.length, corrections: corrections.length };
35
74
  }
36
- export function runRecall(cmd) {
37
- const { report, searched, corrections } = recallReport(cmd);
75
+ export async function runRecall(cmd) {
76
+ const { report, searched, corrections } = await recallReport(cmd);
38
77
  console.log(report);
39
78
  if (searched > 0)
40
- console.error(`\nSearched ${searched} kept failure(s) and ${corrections} correction(s). No model was called.`);
79
+ console.error(`\nSearched ${searched} diagnosed failure(s) and ${corrections} correction(s). No model was called.`);
41
80
  if (cmd.out) {
42
81
  mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
43
82
  writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
@@ -88,10 +88,20 @@ export function recall(question, all, { testId, limit = 5 } = {}) {
88
88
  .slice(0, limit);
89
89
  }
90
90
  /** What a person reads. Every line names the run it came from, so nothing has to be believed. */
91
- export function renderRecall(question, found) {
91
+ /**
92
+ * What a person reads.
93
+ *
94
+ * `searched` is not decoration. "Nothing in this project's history resembles it" is only an answer
95
+ * if something was actually compared against, and on a real 1,305-failure history this said exactly
96
+ * that while the searchable set was **empty** - see `recallReport`. The number belongs in the report
97
+ * body rather than only on stderr, because an MCP client and a `> file` both keep the body and throw
98
+ * stderr away, which is precisely where a reader would have caught it.
99
+ */
100
+ export function renderRecall(question, found, searched) {
92
101
  const lines = [`# What this project already knows`, '', `Asked: ${question.replace(/\s+/g, ' ').slice(0, 160)}`, ''];
93
102
  if (found.length === 0) {
94
- lines.push('Nothing in this project\'s history resembles it. That is an answer, not a failure: it means', 'this is new here, and there is no earlier decision to follow.');
103
+ const against = searched === undefined ? 'this project\'s history' : `the ${searched} diagnosed failure(s) kept here`;
104
+ lines.push(`Nothing in ${against} resembles it. That is an answer, not a failure: it means`, 'this is new here, and there is no earlier decision to follow.');
95
105
  return lines.join('\n');
96
106
  }
97
107
  const corrected = found.filter((f) => f.correctedBy);
@@ -111,7 +111,7 @@ server.registerTool('recall', {
111
111
  if (!isAbsolute(root) || !existsSync(root))
112
112
  throw new Error(`project must be an existing absolute path, got "${root}"`);
113
113
  const { recallReport } = await import('../engine/recall/recall-command.js');
114
- return recallReport({ root, question, testId: test }).report;
114
+ return (await recallReport({ root, question, testId: test })).report;
115
115
  }));
116
116
  server.registerTool('trace_requirements', {
117
117
  title: 'Is this requirement covered, and is it passing?',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.66.0",
3
+ "version": "0.68.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": [