tewip-ai 0.18.6 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pyte
7
7
  | Where | How |
8
8
  |---|---|
9
9
  | **Terminal** | `npm install -g tewip-ai`, then `tewip explain report.xml`, `tewip ci owner/repo --pr 12`, `tewip mcp` |
10
- | **VS Code, Cursor, Windsurf, VSCodium** | the Tewip extension: registers Tewip with the editor's AI assistant and adds *Explain a test report* and *Why did CI fail on this branch?* |
10
+ | **VS Code, Cursor, Windsurf, VSCodium** | the Tewip extension ([download](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)): registers Tewip with the editor's AI assistant and adds six commands - explain a report, why CI failed on this branch, the risk review of your change, which flaky tests to quarantine, the security check, and what your corrections add up to |
11
11
  | **JetBrains, Claude, other AI assistants** | add the MCP server `tewip mcp` (see [editor setup](#use-it-from-your-editor-any-ide-that-speaks-mcp)) |
12
12
  | **Any model** | OpenAI, Claude, Gemini, Azure, or a model on your own machine with Ollama (no account, nothing leaves the computer): `TEWIP_PROVIDER` |
13
13
  | **GitHub CI** | the Tewip Action: a comment on every pull request, an optional merge gate, verified fix PRs ([QUICKSTART](QUICKSTART.md)) |
@@ -96,6 +96,20 @@ The report reads the history cache of the branch it runs on, usually the default
96
96
 
97
97
  The repository needs "Allow GitHub Actions to create and approve pull requests" enabled for fix PRs. Pull requests opened by the workflow token don't trigger CI themselves; the agent verified each change by re-running the test before opening the PR. See [triage-agent-sandbox](https://github.com/hmamut39/triage-agent-sandbox) for a working example. If this repository is private, other repositories can only use the action when Settings → Actions → Access allows it.
98
98
 
99
+ ### The editor extension (VS Code, Cursor, Windsurf, VSCodium)
100
+
101
+ Download **[tewip-0.5.0.vsix](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)**, then in the
102
+ editor press `Ctrl+Shift+P` and run **Extensions: Install from VSIX…**. It needs the command line:
103
+ `npm install -g tewip-ai`.
104
+
105
+ Press `Ctrl+Shift+P` and type "Tewip" for: *Explain a test report…*, *Why did CI fail on this
106
+ branch?*, *Risk review of this change*, *Which flaky tests to quarantine*, *Check secrets and
107
+ dependencies*, and *What our corrections add up to*. The editor's AI assistant also picks Tewip up
108
+ as an MCP server with nothing to configure.
109
+
110
+ It is not in the Visual Studio Marketplace: publishing there now requires an Azure subscription,
111
+ which means a credit card for a free extension. The file above installs exactly the same thing.
112
+
99
113
  ### 2. From any AI assistant, through MCP (the whole IT team)
100
114
 
101
115
  `mcp/server.ts` is a read-only [Model Context Protocol](https://modelcontextprotocol.io) server. Anyone on the team can ask their own assistant about test failures, in the editor or chat they already use:
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 incident <sentry.json | otel.json | stack.txt> [--framework <id>] [--out incident.md]
104
+ Turn a production incident into a test that proves it: reads
105
+ Sentry, OpenTelemetry or a pasted stack trace, writes a test
106
+ against the code the trace names, runs it, and keeps it ONLY if
107
+ it fails for the incident's own reason. A test that passes, or
108
+ fails its own way, is reported and thrown away.
109
+ tewip attest [--run <id>] [--out attestation.md] [--json bundle.json]
110
+ What Tewip checked for a build, as one artifact: the claims with
111
+ their evidence and which layer decided each, what it refused to
112
+ decide and why, and what it did not check at all. Nothing enters
113
+ it that Tewip did not verify itself. No model, no cost.
103
114
  tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
104
115
  tewip --version Print the version.
105
116
 
package/dist/cli/tewip.js CHANGED
@@ -177,6 +177,22 @@ 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 'incident': {
181
+ const file = args.find((a, i) => !a.startsWith('--') && !['--framework', '--out'].includes(args[i - 1] ?? ''));
182
+ if (!file)
183
+ throw new Error('usage: tewip incident <sentry.json | otel.json | stack.txt> [--framework pytest] [--keep] [--out incident.md]');
184
+ const { runIncident } = await import('../engine/incident/incident-command.js');
185
+ const result = await runIncident({ root: process.cwd(), file, framework: flag(args, 'framework'), keep: args.includes('--keep'), out: flag(args, 'out') });
186
+ // A reproduction that was not reproduced is not a success, and a script should be able to tell.
187
+ if (!result.kept)
188
+ process.exitCode = 1;
189
+ return;
190
+ }
191
+ case 'attest': {
192
+ const { runAttest } = await import('../engine/attest/attest-command.js');
193
+ runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
194
+ return;
195
+ }
180
196
  case 'mcp':
181
197
  // An editor starts this from any folder, and an installed tool's own folder may be read-only:
182
198
  // the AI answer cache goes to the user's home folder, which is always writable.
@@ -0,0 +1,63 @@
1
+ // `tewip attest`: the evidence for one run, as an artifact a build can carry.
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
5
+ import { ENGINE_VERSION } from '../engine.js';
6
+ import { attestRun, renderAttestation } from './attest.js';
7
+ /** Everything Tewip kept for one project, newest run last. */
8
+ function history(root, only) {
9
+ const dir = join(root, '.tewip', 'history');
10
+ const projects = existsSync(dir) ? (only ? [only] : readdirSync(dir)) : [];
11
+ for (const project of projects) {
12
+ const files = datasetFiles(join(dir, project));
13
+ const runs = readJsonl(files.runs);
14
+ if (runs.length > 0)
15
+ return { project, runs, records: readTriageRecords(files.triage) };
16
+ }
17
+ return { project: only ?? 'this project', runs: [], records: [] };
18
+ }
19
+ /**
20
+ * What ran, in the words of the thing that ran. Only what is recorded counts: an attestation that
21
+ * listed checks Tewip merely supports would be the fabrication this whole phase exists to prevent.
22
+ */
23
+ function whatWasChecked(records) {
24
+ const checked = [];
25
+ const notChecked = [];
26
+ checked.push('every failing test in the run was read, and each was given a cause or left undecided');
27
+ const byRules = records.filter((r) => r.diagnosis?.source === 'rules').length;
28
+ const byModel = records.filter((r) => r.diagnosis?.source === 'llm').length;
29
+ if (byRules > 0)
30
+ checked.push(`${byRules} decided by deterministic rules (${ENGINE_VERSION}), which call no model and give the same answer every time`);
31
+ if (byModel > 0)
32
+ checked.push(`${byModel} sent to the model, because no rule could settle them`);
33
+ // What Tewip is silent about unless it was asked, and therefore must say it did not do.
34
+ notChecked.push('the application itself: no security scan, dependency audit or web check is part of this bundle unless `tewip secure` ran and is quoted here');
35
+ notChecked.push('tests that passed: a passing test is counted, not examined');
36
+ notChecked.push('anything no test covers, which no test report can show');
37
+ return { checked, notChecked };
38
+ }
39
+ export function runAttest(cmd) {
40
+ const { project, runs, records } = history(cmd.root, cmd.name);
41
+ if (runs.length === 0) {
42
+ throw new Error('nothing to attest: no run has been kept here yet. Run `tewip explain <report>` first, so there is evidence to attest to.');
43
+ }
44
+ const run = cmd.runId ? runs.find((r) => r.runId === cmd.runId) : runs[runs.length - 1];
45
+ if (!run)
46
+ throw new Error(`no run called ${cmd.runId} was kept here`);
47
+ const mine = records.filter((r) => r.runId === run.runId);
48
+ const { checked, notChecked } = whatWasChecked(mine);
49
+ const attestation = attestRun(project, run, mine, { checked, notChecked, tool: 'tewip', engine: ENGINE_VERSION });
50
+ const markdown = renderAttestation(attestation);
51
+ console.log(markdown);
52
+ if (cmd.out) {
53
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
54
+ writeFileSync(resolve(cmd.out), `${markdown}\n`, 'utf8');
55
+ console.error(`\nWritten to ${cmd.out}`);
56
+ }
57
+ if (cmd.json) {
58
+ mkdirSync(dirname(resolve(cmd.json)), { recursive: true });
59
+ writeFileSync(resolve(cmd.json), `${JSON.stringify(attestation, null, 2)}\n`, 'utf8');
60
+ console.error(`Machine-readable bundle written to ${cmd.json}`);
61
+ }
62
+ return attestation;
63
+ }
@@ -0,0 +1,89 @@
1
+ const sourceOf = (record) => String(record.diagnosis?.source ?? '');
2
+ /** The evidence line a person reads: the first thing the deciding layer actually said. */
3
+ function evidenceOf(record) {
4
+ const evidence = record.diagnosis?.evidence ?? [];
5
+ const first = evidence.find((e) => e.message)?.message ?? '';
6
+ return first.slice(0, 300);
7
+ }
8
+ /**
9
+ * The bundle for one run. `records` and `run` are what Tewip itself wrote when it read the report,
10
+ * so everything here is traceable; nothing is inferred and nothing is re-judged.
11
+ */
12
+ export function attestRun(project, run, records, { checked, notChecked, tool, engine }) {
13
+ const claims = [];
14
+ const refusals = [];
15
+ for (const record of records) {
16
+ const diagnosis = record.diagnosis;
17
+ const category = diagnosis?.category ?? 'UNKNOWN';
18
+ const decidedBy = sourceOf(record);
19
+ // Undecided is not a claim. It is the refusal that makes the rest of the bundle believable.
20
+ if (category === 'UNKNOWN' || !decidedBy) {
21
+ refusals.push({
22
+ what: `${record.testId} failed, and Tewip did not classify it`,
23
+ why: evidenceOf(record) || 'no rule matched and the model was not asked or did not answer',
24
+ runId: record.runId,
25
+ });
26
+ continue;
27
+ }
28
+ claims.push({
29
+ claim: `${record.testId} failed because of ${category}`,
30
+ evidence: evidenceOf(record) || 'no evidence recorded',
31
+ runId: record.runId,
32
+ decidedBy,
33
+ confidence: Number(diagnosis?.confidence ?? 0),
34
+ });
35
+ }
36
+ return {
37
+ project,
38
+ commit: run.gitSha ?? '',
39
+ runId: run.runId,
40
+ startedAt: run.startedAt,
41
+ finishedAt: run.finishedAt,
42
+ testsAttempted: Object.keys(run.attempts ?? {}).length,
43
+ failures: records.length,
44
+ checked,
45
+ notChecked,
46
+ claims,
47
+ refusals,
48
+ producedBy: { tool, engine, at: new Date().toISOString() },
49
+ };
50
+ }
51
+ /** The bundle as a person reads it. The refusals are not hidden at the bottom; they are the point. */
52
+ export function renderAttestation(a) {
53
+ const when = a.finishedAt ? a.finishedAt.slice(0, 19).replace('T', ' ') : 'unknown';
54
+ const lines = [
55
+ `# What Tewip checked: ${a.project}`,
56
+ '',
57
+ `Run \`${a.runId}\`${a.commit ? ` at commit \`${a.commit.slice(0, 8)}\`` : ''}, finished ${when} UTC.`,
58
+ `${a.testsAttempted} test(s) attempted, ${a.failures} failed.`,
59
+ '',
60
+ '## What was checked',
61
+ '',
62
+ ...(a.checked.length > 0 ? a.checked.map((c) => `- ${c}`) : ['- nothing: this bundle makes no claims']),
63
+ '',
64
+ '## What was NOT checked',
65
+ '',
66
+ ...(a.notChecked.length > 0
67
+ ? a.notChecked.map((c) => `- ${c}`)
68
+ : ['- nothing is recorded as unchecked, which is itself worth doubting: a check that was never run cannot report itself']),
69
+ '',
70
+ ];
71
+ lines.push(`## Claims (${a.claims.length})`, '');
72
+ if (a.claims.length === 0)
73
+ lines.push('None: nothing failed, so there is nothing to explain.', '');
74
+ for (const claim of a.claims) {
75
+ lines.push(`- **${claim.claim}** — ${claim.decidedBy}, confidence ${claim.confidence.toFixed(2)}`);
76
+ lines.push(` - Evidence: ${claim.evidence}`);
77
+ lines.push(` - From run \`${claim.runId}\``);
78
+ }
79
+ lines.push('', `## Refused (${a.refusals.length})`, '');
80
+ lines.push(a.refusals.length > 0
81
+ ? 'Tewip could not decide these. They are listed because a tool that never says so cannot be trusted about the rest.'
82
+ : 'Nothing was left undecided in this run.', '');
83
+ for (const refusal of a.refusals) {
84
+ lines.push(`- **${refusal.what}**`);
85
+ lines.push(` - Why: ${refusal.why}`);
86
+ }
87
+ lines.push('', '---', '', `Produced by ${a.producedBy.tool} (${a.producedBy.engine}) at ${a.producedBy.at.slice(0, 19).replace('T', ' ')} UTC.`, 'Every claim above names the run it came from and the layer that decided it. This is a statement', 'about the checks listed under "what was checked" and nothing else.');
88
+ return lines.join('\n');
89
+ }
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.18.6",
3
+ "version": "0.20.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",