tewip-ai 0.19.0 → 0.21.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 +11 -0
- package/dist/cli/tewip.js +19 -0
- package/dist/engine/incident/incident-command.js +111 -0
- package/dist/engine/incident/incident.js +116 -0
- package/dist/engine/incident/reproduced.js +87 -0
- package/dist/engine/recall/recall-command.js +39 -0
- package/dist/engine/recall/recall.js +109 -0
- package/package.json +1 -1
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 recall "<an error, or a question>" [--test "<id>"] [--limit 5]
|
|
104
|
+
What this project already concluded: earlier failures that look
|
|
105
|
+
like this one, what was decided, by which rule or which person,
|
|
106
|
+
and the run each came from. Searches the history Tewip keeps -
|
|
107
|
+
no model, no network, nothing to set up.
|
|
108
|
+
tewip incident <sentry.json | otel.json | stack.txt> [--framework <id>] [--out incident.md]
|
|
109
|
+
Turn a production incident into a test that proves it: reads
|
|
110
|
+
Sentry, OpenTelemetry or a pasted stack trace, writes a test
|
|
111
|
+
against the code the trace names, runs it, and keeps it ONLY if
|
|
112
|
+
it fails for the incident's own reason. A test that passes, or
|
|
113
|
+
fails its own way, is reported and thrown away.
|
|
103
114
|
tewip attest [--run <id>] [--out attestation.md] [--json bundle.json]
|
|
104
115
|
What Tewip checked for a build, as one artifact: the claims with
|
|
105
116
|
their evidence and which layer decided each, what it refused to
|
package/dist/cli/tewip.js
CHANGED
|
@@ -177,6 +177,25 @@ 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 'recall': {
|
|
181
|
+
const question = args.filter((a, i) => !a.startsWith('--') && !['--test', '--limit', '--name', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
182
|
+
if (!question)
|
|
183
|
+
throw new Error('usage: tewip recall "<an error, or a question>" [--test "<test id>"] [--limit 5]');
|
|
184
|
+
const { runRecall } = await import('../engine/recall/recall-command.js');
|
|
185
|
+
runRecall({ root: process.cwd(), question, testId: flag(args, 'test'), limit: Number(flag(args, 'limit')) || undefined, name: flag(args, 'name'), out: flag(args, 'out') });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
case 'incident': {
|
|
189
|
+
const file = args.find((a, i) => !a.startsWith('--') && !['--framework', '--out'].includes(args[i - 1] ?? ''));
|
|
190
|
+
if (!file)
|
|
191
|
+
throw new Error('usage: tewip incident <sentry.json | otel.json | stack.txt> [--framework pytest] [--keep] [--out incident.md]');
|
|
192
|
+
const { runIncident } = await import('../engine/incident/incident-command.js');
|
|
193
|
+
const result = await runIncident({ root: process.cwd(), file, framework: flag(args, 'framework'), keep: args.includes('--keep'), out: flag(args, 'out') });
|
|
194
|
+
// A reproduction that was not reproduced is not a success, and a script should be able to tell.
|
|
195
|
+
if (!result.kept)
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
180
199
|
case 'attest': {
|
|
181
200
|
const { runAttest } = await import('../engine/attest/attest-command.js');
|
|
182
201
|
runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// `tewip incident <file>`: turn what happened in production into a test that proves it.
|
|
2
|
+
//
|
|
3
|
+
// The loop, and the reason each step is there:
|
|
4
|
+
// read the incident - deterministic, from Sentry, OpenTelemetry or a pasted stack trace
|
|
5
|
+
// find the code it names - the project's own frames, so the test aims at real functions
|
|
6
|
+
// write a failing test - the model's only job, and the smallest one
|
|
7
|
+
// run it - because a test nobody ran proves nothing
|
|
8
|
+
// judge it - red is not proof: it has to be red for the incident's reason
|
|
9
|
+
//
|
|
10
|
+
// Nothing is kept unless the judgement is "reproduced". A test that passes, fails its own way, or
|
|
11
|
+
// never ran is reported as exactly that and the file is removed, because a test wrongly labelled
|
|
12
|
+
// "reproduces the outage" is worse than no test: the next person will trust it.
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join, resolve } from 'node:path';
|
|
15
|
+
import { noModelHelp } from '../llm/providers.js';
|
|
16
|
+
import { writerFromEnv } from '../quality/model.js';
|
|
17
|
+
import { usageLine } from '../llm/usage.js';
|
|
18
|
+
import { chooseFramework, detectFrameworks } from '../quality/frameworks.js';
|
|
19
|
+
import { draftTest } from '../quality/write.js';
|
|
20
|
+
import { runTest } from '../quality/write-verify.js';
|
|
21
|
+
import { ourFrames, readIncident } from './incident.js';
|
|
22
|
+
import { judgeReproduction, nextStep } from './reproduced.js';
|
|
23
|
+
/** The code the incident points at, so the test is written against real functions, not guesses. */
|
|
24
|
+
function codeFromFrames(root, incident) {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const frame of ourFrames(incident)) {
|
|
28
|
+
// A production path (/app/src/pay.ts) rarely matches a checkout, so the tail is tried too.
|
|
29
|
+
const candidates = [frame.file, ...frame.file.split('/').map((_, i, all) => all.slice(i).join('/'))];
|
|
30
|
+
for (const candidate of candidates) {
|
|
31
|
+
const path = resolve(root, candidate);
|
|
32
|
+
if (seen.has(path) || !existsSync(path) || !path.startsWith(resolve(root)))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(path);
|
|
35
|
+
out.push({ file: candidate, content: readFileSync(path, 'utf8').slice(0, 8000) });
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (out.length >= 3)
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** The incident as a scenario the writer already knows how to take. */
|
|
44
|
+
function asScenario(incident) {
|
|
45
|
+
const where = ourFrames(incident)[0];
|
|
46
|
+
return {
|
|
47
|
+
id: 'INCIDENT-1',
|
|
48
|
+
title: `reproduce: ${incident.title}`.slice(0, 120),
|
|
49
|
+
preconditions: [incident.operation ? `the system is doing: ${incident.operation}` : 'the code path below is exercised'],
|
|
50
|
+
steps: [
|
|
51
|
+
where ? `call the code in ${where.file}${where.fn ? ` (${where.fn})` : ''}${where.line ? ` around line ${where.line}` : ''} the way production did` : 'exercise the failing path',
|
|
52
|
+
'do not guard against the failure; the test must hit it',
|
|
53
|
+
],
|
|
54
|
+
expected: `it fails with ${[incident.errorType, incident.errorMessage].filter(Boolean).join(': ') || incident.title}. Write the test asserting the behaviour that SHOULD happen, so it is red now and green once the bug is fixed.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function runIncident(cmd) {
|
|
58
|
+
const incident = readIncident(readFileSync(resolve(cmd.file), 'utf8'));
|
|
59
|
+
const framework = chooseFramework(detectFrameworks(cmd.root), cmd.framework, false);
|
|
60
|
+
if (!framework)
|
|
61
|
+
throw new Error('no test framework was found here, so there is nothing to write a test in. Run this inside the project.');
|
|
62
|
+
const model = writerFromEnv(cmd.root);
|
|
63
|
+
if (!model)
|
|
64
|
+
throw new Error(`reproducing an incident needs a model to write the test.\n${noModelHelp()}`);
|
|
65
|
+
const code = codeFromFrames(cmd.root, incident);
|
|
66
|
+
console.error(`Read a ${incident.source} incident: ${incident.title.slice(0, 90)}`);
|
|
67
|
+
console.error(code.length > 0 ? `It points at ${code.map((c) => c.file).join(', ')}.` : 'It names no file inside this project, so the test is written from the error alone.');
|
|
68
|
+
// The framework names the file, so its runner picks it up and its imports resolve.
|
|
69
|
+
const fileName = framework.fileFor(`incident-${Date.now().toString(36)}`);
|
|
70
|
+
const testFile = join(framework.testDir, fileName);
|
|
71
|
+
mkdirSync(dirname(testFile), { recursive: true });
|
|
72
|
+
const draft = await draftTest(model, { framework, fileName, pages: [], feature: `A production incident: ${incident.title}`, code }, asScenario(incident));
|
|
73
|
+
writeFileSync(testFile, draft.code, 'utf8');
|
|
74
|
+
console.error('Wrote the test; running it now, because a test nobody ran proves nothing…');
|
|
75
|
+
const run = runTest(framework, testFile);
|
|
76
|
+
const judgement = judgeReproduction(incident, { passed: run.passed, output: run.output });
|
|
77
|
+
const kept = judgement.verdict === 'reproduced';
|
|
78
|
+
if (!kept)
|
|
79
|
+
rmSync(testFile, { force: true });
|
|
80
|
+
const report = renderIncident(incident, judgement, kept ? testFile : '', draft.code);
|
|
81
|
+
console.log(report);
|
|
82
|
+
console.error(`\n${usageLine()}`);
|
|
83
|
+
if (cmd.out) {
|
|
84
|
+
mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
|
|
85
|
+
writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
|
|
86
|
+
}
|
|
87
|
+
return { incident, judgement, testFile: kept ? testFile : '', kept };
|
|
88
|
+
}
|
|
89
|
+
export function renderIncident(incident, judgement, testFile, code) {
|
|
90
|
+
const where = ourFrames(incident)[0];
|
|
91
|
+
const lines = [
|
|
92
|
+
`# ${judgement.verdict === 'reproduced' ? 'Reproduced' : 'Not reproduced'}: ${incident.title.slice(0, 100)}`,
|
|
93
|
+
'',
|
|
94
|
+
`Read from ${incident.source}.${incident.operation ? ` During: ${incident.operation}.` : ''}`,
|
|
95
|
+
`Production reported: ${[incident.errorType, incident.errorMessage].filter(Boolean).join(': ') || '(no error text)'}`,
|
|
96
|
+
where ? `Your code in the trace: ${where.file}${where.line ? `:${where.line}` : ''}${where.fn ? ` (${where.fn})` : ''}` : 'No file of yours appears in the trace.',
|
|
97
|
+
'',
|
|
98
|
+
`## ${judgement.verdict === 'reproduced' ? 'The test is red for the right reason' : 'Nothing is kept'}`,
|
|
99
|
+
'',
|
|
100
|
+
judgement.why,
|
|
101
|
+
'',
|
|
102
|
+
`**Do:** ${nextStep[judgement.verdict]}`,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
if (testFile)
|
|
106
|
+
lines.push(`The test is at \`${testFile}\`. It should stay red until the bug is fixed, and turning it green is the proof that it was.`, '');
|
|
107
|
+
else
|
|
108
|
+
lines.push('The test Tewip wrote is below, unkept, so you can see what was tried:', '', '```', code.slice(0, 2000), '```', '');
|
|
109
|
+
lines.push('Tewip never keeps a test that has not been run, and never calls a red test a reproduction unless it is red for the reason production was.');
|
|
110
|
+
return lines.join('\n');
|
|
111
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const LIBRARY = /[\\/](?:node_modules|site-packages|dist-packages|vendor|\.venv|venv|target[\\/]dependency|gems)[\\/]/;
|
|
2
|
+
const RUNTIME = /^(?:node:|internal[\\/]|java\.|javax\.|sun\.|System\.|Microsoft\.)/;
|
|
3
|
+
const ours = (file) => Boolean(file) && !LIBRARY.test(file) && !RUNTIME.test(file);
|
|
4
|
+
const frame = (file, line, fn) => ({
|
|
5
|
+
file: String(file ?? '').replace(/\\/g, '/'),
|
|
6
|
+
line: Number(line) || 0,
|
|
7
|
+
fn: String(fn ?? ''),
|
|
8
|
+
ours: ours(String(file ?? '')),
|
|
9
|
+
});
|
|
10
|
+
/** A stack trace as every language prints it, which is the fallback when there is no tool. */
|
|
11
|
+
export function framesFromStack(stack) {
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const line of stack.split('\n')) {
|
|
14
|
+
// JavaScript: "at handler (/app/src/pay.ts:42:9)" or "at /app/src/pay.ts:42:9"
|
|
15
|
+
const js = /^\s*at\s+(?:(?<fn>[^(]+?)\s+\()?(?<file>[^():]+?):(?<line>\d+):\d+\)?\s*$/.exec(line);
|
|
16
|
+
if (js?.groups) {
|
|
17
|
+
out.push(frame(js.groups.file, js.groups.line, (js.groups.fn ?? '').trim()));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
// Python: ' File "/app/pay.py", line 42, in charge'
|
|
21
|
+
const py = /^\s*File\s+"(?<file>[^"]+)",\s+line\s+(?<line>\d+)(?:,\s+in\s+(?<fn>\S+))?/.exec(line);
|
|
22
|
+
if (py?.groups) {
|
|
23
|
+
out.push(frame(py.groups.file, py.groups.line, py.groups.fn ?? ''));
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Java and C#: "at com.shop.Pay.charge(Pay.java:42)"
|
|
27
|
+
const jvm = /^\s*at\s+(?<fn>[\w.$<>]+)\((?<file>[^):]+):(?<line>\d+)\)/.exec(line);
|
|
28
|
+
if (jvm?.groups)
|
|
29
|
+
out.push(frame(jvm.groups.file, jvm.groups.line, jvm.groups.fn));
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** Sentry's event JSON, as its API and its "download JSON" button produce it. */
|
|
34
|
+
function fromSentry(event) {
|
|
35
|
+
const thrown = event.exception?.values?.at?.(-1) ?? event.exception?.values?.[0];
|
|
36
|
+
if (!thrown && !event.title && !event.message)
|
|
37
|
+
return null;
|
|
38
|
+
const frames = (thrown?.stacktrace?.frames ?? [])
|
|
39
|
+
.map((f) => frame(f.abs_path ?? f.filename ?? '', f.lineno, f.function ?? ''))
|
|
40
|
+
// Sentry lists the oldest frame first; a person reads the newest first, and so does a fix.
|
|
41
|
+
.reverse();
|
|
42
|
+
const request = event.request ?? {};
|
|
43
|
+
return {
|
|
44
|
+
title: String(event.title ?? thrown?.value ?? event.message ?? 'an incident'),
|
|
45
|
+
errorType: String(thrown?.type ?? ''),
|
|
46
|
+
errorMessage: String(thrown?.value ?? event.message ?? ''),
|
|
47
|
+
frames,
|
|
48
|
+
operation: [request.method, request.url].filter(Boolean).join(' ') || String(event.culprit ?? ''),
|
|
49
|
+
when: String(event.datetime ?? event.timestamp ?? ''),
|
|
50
|
+
source: 'sentry',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** An OpenTelemetry span that failed, carrying the standard `exception` event. */
|
|
54
|
+
function fromOtel(span) {
|
|
55
|
+
const events = span.events ?? span.Events ?? [];
|
|
56
|
+
const thrown = events.find?.((e) => (e.name ?? '') === 'exception');
|
|
57
|
+
const status = span.status?.code ?? span.status?.Code ?? '';
|
|
58
|
+
if (!thrown && !/ERROR|STATUS_CODE_ERROR|2/.test(String(status)))
|
|
59
|
+
return null;
|
|
60
|
+
const attrs = {};
|
|
61
|
+
// OTLP writes attributes either as a map or as a list of {key,value}; both are seen in exports.
|
|
62
|
+
const raw = thrown?.attributes ?? {};
|
|
63
|
+
if (Array.isArray(raw))
|
|
64
|
+
for (const a of raw)
|
|
65
|
+
attrs[a.key] = a.value?.stringValue ?? a.value?.string_value ?? a.value;
|
|
66
|
+
else
|
|
67
|
+
Object.assign(attrs, raw);
|
|
68
|
+
const stack = String(attrs['exception.stacktrace'] ?? '');
|
|
69
|
+
return {
|
|
70
|
+
title: String(span.name ?? attrs['exception.type'] ?? 'a failed operation'),
|
|
71
|
+
errorType: String(attrs['exception.type'] ?? ''),
|
|
72
|
+
errorMessage: String(attrs['exception.message'] ?? span.status?.message ?? ''),
|
|
73
|
+
frames: framesFromStack(stack),
|
|
74
|
+
operation: String(span.name ?? ''),
|
|
75
|
+
when: String(span.startTime ?? span.startTimeUnixNano ?? ''),
|
|
76
|
+
source: 'opentelemetry',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** Whatever was pasted: the first line is the error, the rest is the stack. */
|
|
80
|
+
function fromText(text) {
|
|
81
|
+
const lines = text.split('\n');
|
|
82
|
+
const head = lines.find((l) => l.trim()) ?? '';
|
|
83
|
+
const typed = /^\s*(?<type>[\w.]*(?:Error|Exception|Fault))\b:?\s*(?<message>.*)$/.exec(head);
|
|
84
|
+
return {
|
|
85
|
+
title: head.trim().slice(0, 160) || 'an incident',
|
|
86
|
+
errorType: typed?.groups?.type ?? '',
|
|
87
|
+
errorMessage: (typed?.groups?.message ?? head).trim(),
|
|
88
|
+
frames: framesFromStack(text),
|
|
89
|
+
operation: '',
|
|
90
|
+
when: '',
|
|
91
|
+
source: 'stack trace',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Reads an incident from a file's contents, whichever of the three shapes it is in. */
|
|
95
|
+
export function readIncident(contents) {
|
|
96
|
+
const text = contents.trim();
|
|
97
|
+
if (text.startsWith('{') || text.startsWith('[')) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(text);
|
|
100
|
+
const one = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
101
|
+
// A Sentry event and an OTel span are both objects; each is recognised by what it carries.
|
|
102
|
+
const sentry = one?.exception || one?.culprit || one?.event_id ? fromSentry(one) : null;
|
|
103
|
+
if (sentry)
|
|
104
|
+
return sentry;
|
|
105
|
+
const otel = fromOtel(one?.resourceSpans?.[0]?.scopeSpans?.[0]?.spans?.[0] ?? one?.span ?? one);
|
|
106
|
+
if (otel)
|
|
107
|
+
return otel;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Not JSON after all: fall through and read it as a pasted stack trace.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return fromText(text);
|
|
114
|
+
}
|
|
115
|
+
/** The frames in the project's own code, newest first: where a reproduction has to aim. */
|
|
116
|
+
export const ourFrames = (incident) => incident.frames.filter((f) => f.ours);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Did that test actually reproduce the incident?
|
|
2
|
+
//
|
|
3
|
+
// This is the whole of Phase 16 in one question, and getting it wrong is how a tool like this
|
|
4
|
+
// becomes worthless. A test that fails is not a reproduction. A test with a typo fails. A test
|
|
5
|
+
// against a missing fixture fails. A test that asserts the opposite of the truth fails. Each of
|
|
6
|
+
// those would let Tewip announce "reproduced your incident" and hand over something that proves
|
|
7
|
+
// nothing, and then the fix that makes it pass would fix nothing.
|
|
8
|
+
//
|
|
9
|
+
// So a reproduction has to fail **for the incident's reason**, and that is checkable without a
|
|
10
|
+
// model: the error the test produced and the error production reported have to be the same error.
|
|
11
|
+
// Everything else - a pass, a failure of a different kind, a crash before the test ran - is
|
|
12
|
+
// refused and said out loud.
|
|
13
|
+
import { errorShape } from '../signature.js';
|
|
14
|
+
/** Words that mean the test never got as far as exercising anything. */
|
|
15
|
+
const NEVER_RAN = /\b(?:cannot find module|ModuleNotFoundError|No module named|SyntaxError|IndentationError|fixture '[^']+' not found|ERR_MODULE_NOT_FOUND|NoClassDefFoundError|ClassNotFoundException|command not found|no tests? (?:found|ran)|collection failure)\b/i;
|
|
16
|
+
const typeOf = (text) => /\b([\w.]*(?:Error|Exception|Fault))\b/.exec(text)?.[1]?.split('.').pop() ?? '';
|
|
17
|
+
/** The words of an error, without the parts that differ every run. */
|
|
18
|
+
const wordsOf = (text) => new Set(errorShape(text)
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.match(/[a-z][a-z-]{2,}/g)
|
|
21
|
+
?.filter((w) => !['error', 'exception', 'the', 'and', 'for', 'with', 'from', 'test', 'failed', 'assert'].includes(w)) ?? []);
|
|
22
|
+
/**
|
|
23
|
+
* Judges one test run against the incident it was written for.
|
|
24
|
+
*
|
|
25
|
+
* `passed` is not a near miss. It means the test does not exercise the broken path at all, and the
|
|
26
|
+
* honest thing is to say the reproduction failed, not to weaken the test until it goes red.
|
|
27
|
+
*/
|
|
28
|
+
export function judgeReproduction(incident, run) {
|
|
29
|
+
if (NEVER_RAN.test(run.output)) {
|
|
30
|
+
return {
|
|
31
|
+
verdict: 'never-ran',
|
|
32
|
+
why: 'the test never ran - something was missing or would not compile - so it shows nothing about the incident',
|
|
33
|
+
match: 'none',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (run.passed) {
|
|
37
|
+
return {
|
|
38
|
+
verdict: 'passed',
|
|
39
|
+
why: 'the test passes against the code that is broken in production, so it does not exercise what broke. It is not a reproduction, and weakening it until it fails would prove nothing',
|
|
40
|
+
match: 'none',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const wanted = `${incident.errorType} ${incident.errorMessage}`.trim();
|
|
44
|
+
const sameType = Boolean(incident.errorType) && typeOf(run.output) === typeOf(wanted);
|
|
45
|
+
const theirs = wordsOf(wanted);
|
|
46
|
+
const ours = wordsOf(run.output);
|
|
47
|
+
const shared = [...theirs].filter((w) => ours.has(w));
|
|
48
|
+
// Most of the incident's own words, not most of the test output's: a long stack trace should not
|
|
49
|
+
// earn a match by sheer length.
|
|
50
|
+
const overlap = theirs.size > 0 ? shared.length / theirs.size : 0;
|
|
51
|
+
if (sameType && overlap >= 0.5) {
|
|
52
|
+
return { verdict: 'reproduced', why: `the test fails with the same error production reported (${typeOf(wanted)}), and most of the same words`, match: 'same type and message' };
|
|
53
|
+
}
|
|
54
|
+
if (sameType) {
|
|
55
|
+
return { verdict: 'reproduced', why: `the test fails with ${typeOf(wanted)}, the same error type production reported, though the message differs`, match: 'same type' };
|
|
56
|
+
}
|
|
57
|
+
if (overlap >= 0.7 && theirs.size >= 3) {
|
|
58
|
+
return { verdict: 'reproduced', why: 'the test fails with the same message production reported, although neither names an error type', match: 'same message' };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
verdict: 'failed-differently',
|
|
62
|
+
why: `the test fails, but not with the incident's error: production reported ${wanted.slice(0, 80) || 'its own error'}, and the test reported ${errorLine(run.output)}`,
|
|
63
|
+
match: 'none',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The line of a runner's output that is actually the error. Quoting the first non-empty line put
|
|
68
|
+
* "RUN v5.0.2 C:/Users/..." into a real report as though it were what the test said - a banner, not
|
|
69
|
+
* a finding, and exactly the loose evidence this project refuses everywhere else.
|
|
70
|
+
*/
|
|
71
|
+
function errorLine(output) {
|
|
72
|
+
// Two passes, because a runner prints its own status lines too: " FAIL tests/a.test.js" says
|
|
73
|
+
// which file failed, not what went wrong. A named error always wins over a status line.
|
|
74
|
+
const named = /\b(?:\w*Error|\w*Exception|expected|Expected|assert)\b/;
|
|
75
|
+
const anyFailure = /\b(?:FAIL|failed|failure)\b/i;
|
|
76
|
+
const noise = /^\s*(?:RUN|DEV|WATCH)\b/;
|
|
77
|
+
const lines = output.split('\n').filter((l) => l.trim() && !noise.test(l));
|
|
78
|
+
const found = lines.find((l) => named.test(l)) ?? lines.find((l) => anyFailure.test(l)) ?? lines[0];
|
|
79
|
+
return (found ?? '').trim().slice(0, 100);
|
|
80
|
+
}
|
|
81
|
+
/** What to do about each verdict, for the report. */
|
|
82
|
+
export const nextStep = {
|
|
83
|
+
reproduced: 'keep this test: it is red for the reason production was, so a fix that turns it green has fixed the incident',
|
|
84
|
+
passed: 'do not keep this test. Either the incident depends on data or state the test does not create, or it is not in this code at all',
|
|
85
|
+
'failed-differently': 'do not keep this test: it is broken in its own way, which would be mistaken for the incident later',
|
|
86
|
+
'never-ran': 'fix the setup and try again; nothing has been shown either way',
|
|
87
|
+
};
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|