tewip-ai 0.21.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,12 @@ 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.
|
|
103
109
|
tewip recall "<an error, or a question>" [--test "<id>"] [--limit 5]
|
|
104
110
|
What this project already concluded: earlier failures that look
|
|
105
111
|
like this one, what was decided, by which rule or which person,
|
package/dist/cli/tewip.js
CHANGED
|
@@ -177,6 +177,11 @@ 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
|
+
}
|
|
180
185
|
case 'recall': {
|
|
181
186
|
const question = args.filter((a, i) => !a.startsWith('--') && !['--test', '--limit', '--name', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
182
187
|
if (!question)
|
|
@@ -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.
|
|
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",
|