tewip-ai 0.20.0 → 0.22.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/help.js CHANGED
@@ -100,6 +100,17 @@ Usage:
100
100
  same way more than once, which rule decided it, and the failures
101
101
  it came from. Says "not yet" rather than inventing a pattern
102
102
  from two cases. No model, no cost.
103
+ tewip trace [--only "<ticket or title>"] [--out trace.md]
104
+ Is this requirement covered, and is it passing right now? Joins
105
+ the plans Tewip wrote to the tests that cover them and the runs
106
+ that attempted them. A scenario counts as covered only when a
107
+ real test can be named, and passing only when a real run
108
+ attempted it. No model, no cost.
109
+ tewip recall "<an error, or a question>" [--test "<id>"] [--limit 5]
110
+ What this project already concluded: earlier failures that look
111
+ like this one, what was decided, by which rule or which person,
112
+ and the run each came from. Searches the history Tewip keeps -
113
+ no model, no network, nothing to set up.
103
114
  tewip incident <sentry.json | otel.json | stack.txt> [--framework <id>] [--out incident.md]
104
115
  Turn a production incident into a test that proves it: reads
105
116
  Sentry, OpenTelemetry or a pasted stack trace, writes a test
package/dist/cli/tewip.js CHANGED
@@ -177,6 +177,19 @@ async function main() {
177
177
  runLearn({ root: process.cwd(), name: flag(args, 'name'), labels: flag(args, 'labels'), out: flag(args, 'out') });
178
178
  return;
179
179
  }
180
+ case 'trace': {
181
+ const { runTrace } = await import('../engine/trace/trace-command.js');
182
+ runTrace({ root: process.cwd(), only: flag(args, 'only'), name: flag(args, 'name'), out: flag(args, 'out') });
183
+ return;
184
+ }
185
+ case 'recall': {
186
+ const question = args.filter((a, i) => !a.startsWith('--') && !['--test', '--limit', '--name', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
187
+ if (!question)
188
+ throw new Error('usage: tewip recall "<an error, or a question>" [--test "<test id>"] [--limit 5]');
189
+ const { runRecall } = await import('../engine/recall/recall-command.js');
190
+ runRecall({ root: process.cwd(), question, testId: flag(args, 'test'), limit: Number(flag(args, 'limit')) || undefined, name: flag(args, 'name'), out: flag(args, 'out') });
191
+ return;
192
+ }
180
193
  case 'incident': {
181
194
  const file = args.find((a, i) => !a.startsWith('--') && !['--framework', '--out'].includes(args[i - 1] ?? ''));
182
195
  if (!file)
@@ -0,0 +1,39 @@
1
+ // `tewip recall "<an error, or a question>"`: what this project already concluded.
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { datasetFiles, readTriageRecords } from '../../reporters/store.js';
5
+ import { loadCorrections } from '../corrections.js';
6
+ import { memories, recall, renderRecall } from './recall.js';
7
+ /** Everything kept for this project: its failures, and the times a person overruled Tewip. */
8
+ function kept(root, only) {
9
+ const dir = join(root, '.tewip', 'history');
10
+ if (!existsSync(dir))
11
+ return { records: [], corrections: [] };
12
+ const records = [];
13
+ const corrections = [];
14
+ for (const project of only ? [only] : readdirSync(dir)) {
15
+ const where = join(dir, project);
16
+ if (!existsSync(where))
17
+ continue;
18
+ records.push(...readTriageRecords(datasetFiles(where).triage));
19
+ corrections.push(...loadCorrections(where));
20
+ }
21
+ return { records, corrections };
22
+ }
23
+ export function runRecall(cmd) {
24
+ const { records, corrections } = kept(cmd.root, cmd.name);
25
+ if (records.length === 0) {
26
+ const empty = 'Nothing has been kept here yet, so there is no history to search. Run `tewip explain <report>` first.';
27
+ console.log(empty);
28
+ return empty;
29
+ }
30
+ const found = recall(cmd.question, memories(records, corrections), { testId: cmd.testId, limit: cmd.limit ?? 5 });
31
+ const report = renderRecall(cmd.question, found);
32
+ console.log(report);
33
+ console.error(`\nSearched ${records.length} kept failure(s) and ${corrections.length} correction(s). No model was called.`);
34
+ if (cmd.out) {
35
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
36
+ writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
37
+ }
38
+ return report;
39
+ }
@@ -0,0 +1,109 @@
1
+ import { errorShape } from '../signature.js';
2
+ /** Words worth matching on: rare, specific, not the furniture of every stack trace. */
3
+ const COMMON = new Set([
4
+ 'error', 'failed', 'failure', 'test', 'expected', 'received', 'actual', 'value', 'object', 'null', 'undefined', 'true', 'false', 'none', 'with', 'from', 'this', 'that', 'have', 'been', 'were', 'when', 'then', 'there', 'which', 'while', 'after', 'before', 'timeout', 'waiting', 'call', 'log', 'line', 'file', 'at', 'in', 'on', 'of', 'to', 'for', 'and', 'the', 'a', 'an', 'is', 'it', 'was', 'not', 'no', 'yes',
5
+ ]);
6
+ const words = (text) => (errorShape(text).toLowerCase().match(/[a-z][a-z0-9_-]{2,}/g) ?? []).filter((w) => !COMMON.has(w));
7
+ /** How rare each word is across the history: a word in every failure tells you nothing. */
8
+ function rarity(all) {
9
+ const seen = new Map();
10
+ for (const doc of all)
11
+ for (const w of new Set(doc))
12
+ seen.set(w, (seen.get(w) ?? 0) + 1);
13
+ const total = Math.max(all.length, 1);
14
+ const out = new Map();
15
+ for (const [w, n] of seen)
16
+ out.set(w, Math.log(total / n) + 1);
17
+ return out;
18
+ }
19
+ /** Everything Tewip has kept about this project, as memories that can be cited. */
20
+ export function memories(records, corrections = []) {
21
+ const byShape = new Map();
22
+ for (const c of corrections)
23
+ byShape.set(`${c.testId}::${c.errorShape}`, c);
24
+ const out = [];
25
+ for (const record of records) {
26
+ const diagnosis = record.diagnosis;
27
+ if (!diagnosis?.category)
28
+ continue;
29
+ const correction = byShape.get(`${record.testId}::${errorShape(record.errorMessage)}`);
30
+ out.push({
31
+ testId: record.testId,
32
+ category: correction?.category ?? diagnosis.category,
33
+ decidedBy: correction ? `${correction.by} (a person)` : String(diagnosis.source ?? 'unknown'),
34
+ when: record.timestamp,
35
+ runId: record.runId,
36
+ error: (record.errorMessage ?? '').replace(/\s+/g, ' ').slice(0, 220),
37
+ full: (record.errorMessage ?? '').replace(/\s+/g, ' ').slice(0, 4000),
38
+ ...(correction ? { correctedBy: correction.by, correctionReason: correction.reason } : {}),
39
+ });
40
+ }
41
+ return out;
42
+ }
43
+ /**
44
+ * The memories that answer a question, most useful first.
45
+ *
46
+ * Three things decide the order, and each is stated in `why` so a person can disagree with it: the
47
+ * same error shape is the strongest signal there is, the same test is next, and shared rare words
48
+ * carry the rest. A memory a person corrected is lifted above one only Tewip decided, because what
49
+ * the team concluded outranks what the tool concluded.
50
+ */
51
+ export function recall(question, all, { testId, limit = 5 } = {}) {
52
+ if (all.length === 0)
53
+ return [];
54
+ const docs = all.map((m) => words(`${m.testId} ${m.full ?? m.error}`));
55
+ const weight = rarity(docs);
56
+ const asked = new Set(words(question));
57
+ const askedShape = errorShape(question);
58
+ const scored = all.map((memory, i) => {
59
+ const shared = docs[i].filter((w) => asked.has(w));
60
+ const mass = [...new Set(shared)].reduce((sum, w) => sum + (weight.get(w) ?? 1), 0);
61
+ const possible = [...asked].reduce((sum, w) => sum + (weight.get(w) ?? 1), 0) || 1;
62
+ let score = Math.min(mass / possible, 1);
63
+ const reasons = [];
64
+ if (askedShape && errorShape(memory.full ?? memory.error) === askedShape) {
65
+ score = Math.max(score, 0.95);
66
+ reasons.push('the same error, word for word once the changing parts are removed');
67
+ }
68
+ else if (shared.length > 0) {
69
+ reasons.push(`shares ${[...new Set(shared)].slice(0, 4).join(', ')}`);
70
+ }
71
+ if (testId && memory.testId === testId) {
72
+ score = Math.min(score + 0.25, 1);
73
+ reasons.push('the same test');
74
+ }
75
+ if (memory.correctedBy) {
76
+ score = Math.min(score + 0.1, 1);
77
+ reasons.push(`${memory.correctedBy} corrected this one`);
78
+ }
79
+ return { ...memory, score, sameTest: Boolean(testId && memory.testId === testId), why: reasons.join('; ') || 'nothing in common but the project' };
80
+ });
81
+ // Two memories can both reach the ceiling - an identical error recorded twice does - so the
82
+ // ordering needs a tie-break of its own. When the question is about one test, that test's own
83
+ // history comes first; otherwise the most recent, because the project changes underneath you.
84
+ return scored
85
+ .filter((m) => m.score > 0.15)
86
+ .sort((a, b) => b.score - a.score || Number(b.sameTest) - Number(a.sameTest) || (b.when > a.when ? 1 : -1))
87
+ .map(({ sameTest, ...rest }) => rest)
88
+ .slice(0, limit);
89
+ }
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) {
92
+ const lines = [`# What this project already knows`, '', `Asked: ${question.replace(/\s+/g, ' ').slice(0, 160)}`, ''];
93
+ 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.');
95
+ return lines.join('\n');
96
+ }
97
+ const corrected = found.filter((f) => f.correctedBy);
98
+ lines.push(`${found.length} earlier failure(s) look like this one.${corrected.length > 0 ? ` ${corrected.length} of them a person judged themselves.` : ''}`, '');
99
+ for (const memory of found) {
100
+ lines.push(`- **${memory.category}** — ${memory.testId}`);
101
+ lines.push(` - Decided by ${memory.decidedBy}${memory.when ? ` on ${memory.when.slice(0, 10)}` : ''}, run \`${memory.runId}\``);
102
+ if (memory.correctionReason)
103
+ lines.push(` - They said: ${memory.correctionReason}`);
104
+ lines.push(` - Why it matched: ${memory.why}`);
105
+ lines.push(` - The error then: ${memory.error.slice(0, 140)}`);
106
+ }
107
+ lines.push('', 'No model was called to answer this, and nothing above is a guess: each line is a record kept from a real run of this project.');
108
+ return lines.join('\n');
109
+ }
@@ -0,0 +1,63 @@
1
+ // `tewip trace`: is this requirement covered, and is it passing right now?
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
5
+ import { renderTrace, tracePlan } from './trace.js';
6
+ /** The plans this project has kept, as `tewip plan` saved them. */
7
+ function plans(root, only) {
8
+ const dir = join(root, '.tewip', 'plans');
9
+ if (!existsSync(dir))
10
+ return [];
11
+ const out = [];
12
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
13
+ try {
14
+ // `tewip plan` saves { plan, feature, urls, model }: the scenarios live under `plan`, and the
15
+ // feature - the words someone actually asked for - sits beside it. Reading the top level
16
+ // alone found no plans at all in a project that had one.
17
+ const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'));
18
+ const inner = saved.plan ?? saved;
19
+ if (!Array.isArray(inner?.scenarios))
20
+ continue;
21
+ const parsed = { ...inner, feature: saved.feature ?? inner.feature, ticket: saved.ticket ?? inner.ticket };
22
+ const haystack = `${parsed.title ?? ''} ${parsed.feature ?? ''} ${parsed.ticket ?? ''} ${file}`.toLowerCase();
23
+ if (only && !haystack.includes(only.toLowerCase()))
24
+ continue;
25
+ out.push(parsed);
26
+ }
27
+ catch {
28
+ // A plan that is not readable JSON is not a plan; the Markdown beside it is still there.
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ function history(root, only) {
34
+ const dir = join(root, '.tewip', 'history');
35
+ if (!existsSync(dir))
36
+ return { runs: [], records: [] };
37
+ const runs = [];
38
+ const records = [];
39
+ for (const project of only ? [only] : readdirSync(dir)) {
40
+ const where = join(dir, project);
41
+ if (!existsSync(where))
42
+ continue;
43
+ runs.push(...readJsonl(datasetFiles(where).runs));
44
+ records.push(...readTriageRecords(datasetFiles(where).triage));
45
+ }
46
+ runs.sort((a, b) => (a.finishedAt > b.finishedAt ? 1 : -1));
47
+ return { runs, records };
48
+ }
49
+ export function runTrace(cmd) {
50
+ const found = plans(cmd.root, cmd.only);
51
+ const { runs, records } = history(cmd.root, cmd.name);
52
+ const traced = found.map((plan) => tracePlan(plan, runs, records));
53
+ const report = renderTrace(traced);
54
+ console.log(report);
55
+ if (found.length > 0) {
56
+ console.error(`\nTraced ${found.length} plan(s) against ${runs.length} kept run(s). No model was called.`);
57
+ }
58
+ if (cmd.out) {
59
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
60
+ writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
61
+ }
62
+ return traced;
63
+ }
@@ -0,0 +1,93 @@
1
+ const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
2
+ /**
3
+ * The test that covers a scenario, named from evidence rather than hope.
4
+ *
5
+ * Two ways a link is real: the plan says which existing test covers it - and that claim was already
6
+ * checked against the project's real test names when the plan was written - or a test file carries
7
+ * the scenario's own id, which is how `tewip write` names what it creates.
8
+ */
9
+ export function testFor(scenario, attempted) {
10
+ const claim = (scenario.covered_by ?? '').trim();
11
+ if (claim) {
12
+ const exact = attempted.find((t) => t === claim);
13
+ if (exact)
14
+ return exact;
15
+ // A plan names a test as a person writes it; a report names it as the runner prints it.
16
+ const loose = attempted.find((t) => clean(t).includes(clean(claim.split(':')[0])) && clean(claim.split(':')[0]).length > 6);
17
+ if (loose)
18
+ return loose;
19
+ return claim; // named, but this project has not attempted it in any kept run
20
+ }
21
+ const id = clean(scenario.id);
22
+ return id.length >= 4 ? (attempted.find((t) => clean(t).includes(id)) ?? '') : '';
23
+ }
24
+ /** Joins one plan to what the runs actually say. Nothing here is inferred from anything else. */
25
+ export function tracePlan(plan, runs, records) {
26
+ const newest = runs.at(-1);
27
+ const attempted = newest ? Object.keys(newest.attempts ?? {}) : [];
28
+ const everAttempted = new Set(runs.flatMap((r) => Object.keys(r.attempts ?? {})));
29
+ const failures = new Map();
30
+ for (const record of records)
31
+ if (record.runId === newest?.runId)
32
+ failures.set(record.testId, record);
33
+ const scenarios = plan.scenarios.map((scenario) => {
34
+ const test = testFor(scenario, attempted);
35
+ const base = { id: scenario.id, title: scenario.title, priority: scenario.priority ?? '', test, runId: newest?.runId ?? '', when: newest?.finishedAt ?? '' };
36
+ if (!test) {
37
+ return { ...base, standing: 'not-covered', diagnosis: '', why: 'no test in this project can be named for it, so nothing is checking it' };
38
+ }
39
+ if (!everAttempted.has(test)) {
40
+ return { ...base, standing: 'never-run', runId: '', when: '', diagnosis: '', why: `${test} is named as the cover, but no run kept here has attempted it` };
41
+ }
42
+ const failed = failures.get(test);
43
+ if (failed) {
44
+ const d = failed.diagnosis;
45
+ const diagnosis = d?.category ? `${d.category} at ${Number(d.confidence ?? 0).toFixed(2)} (${d.source ?? 'unknown'})` : 'failed, undiagnosed';
46
+ return { ...base, standing: 'failing', diagnosis, why: `${test} failed in the last run, and Tewip calls it ${d?.category ?? 'undiagnosed'}` };
47
+ }
48
+ if (!attempted.includes(test)) {
49
+ return { ...base, standing: 'never-run', diagnosis: '', why: `${test} ran before but not in the last run, so its standing now is unknown` };
50
+ }
51
+ return { ...base, standing: 'passing', diagnosis: '', why: `${test} was attempted in the last run and recorded no failure` };
52
+ });
53
+ return { requirement: plan.title || plan.feature || 'an unnamed requirement', ticket: plan.ticket ?? '', scenarios };
54
+ }
55
+ const MARK = { passing: 'passing', failing: 'FAILING', 'never-run': 'never run', 'not-covered': 'NOT COVERED' };
56
+ /** The answer to "is this covered, and is it passing", written so it can be disagreed with. */
57
+ export function renderTrace(traced) {
58
+ const all = traced.flatMap((t) => t.scenarios);
59
+ const count = (s) => all.filter((x) => x.standing === s).length;
60
+ const lines = ['# From what was asked for, to what is passing', ''];
61
+ if (all.length === 0) {
62
+ return [
63
+ '# From what was asked for, to what is passing',
64
+ '',
65
+ 'No plan has been kept here, so there is nothing to trace. `tewip plan "<what it should do>"`',
66
+ 'writes one, and `tewip write` turns its scenarios into tests.',
67
+ ].join('\n');
68
+ }
69
+ lines.push(`${all.length} scenario(s) across ${traced.length} requirement(s): ${count('passing')} passing, ${count('failing')} failing, ${count('never-run')} never run, ${count('not-covered')} not covered.`, '', 'A scenario counts as covered only when a real test can be named, and as passing only when a real', 'run attempted it. Everything else says what is missing instead.', '');
70
+ for (const requirement of traced) {
71
+ lines.push(`## ${requirement.requirement}${requirement.ticket ? ` (${requirement.ticket})` : ''}`, '');
72
+ for (const s of requirement.scenarios) {
73
+ lines.push(`- **${MARK[s.standing]}** — ${s.id}${s.priority ? ` (${s.priority})` : ''}: ${s.title}`);
74
+ lines.push(` - ${s.why}`);
75
+ if (s.diagnosis)
76
+ lines.push(` - Diagnosis: ${s.diagnosis}`);
77
+ // A run is only worth quoting when it says something about this scenario. Printing the last
78
+ // run beside "no test can be named for it" invited the reader to think one had checked it.
79
+ if (s.runId && s.test)
80
+ lines.push(` - From run \`${s.runId}\`${s.when ? ` on ${s.when.slice(0, 10)}` : ''}`);
81
+ }
82
+ lines.push('');
83
+ }
84
+ const worst = all.filter((s) => s.standing === 'failing' || s.standing === 'not-covered');
85
+ if (worst.length > 0) {
86
+ lines.push('## What to do first', '');
87
+ for (const s of worst.slice(0, 5))
88
+ lines.push(`- ${s.standing === 'failing' ? `fix or triage ${s.test}` : `write a test for ${s.id}: ${s.title}`}`);
89
+ lines.push('');
90
+ }
91
+ lines.push('Every line above comes from a plan Tewip wrote and a run it recorded. Nothing is inferred.');
92
+ return lines.join('\n');
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "test-failures",