tewip-ai 0.22.0 → 0.23.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,13 @@ 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 pursue "<a goal>" [--act] [--out next.md]
104
+ Give Tewip a goal instead of a verb - "keep the checkout flow
105
+ green" - and it says what it would do next and why, from what
106
+ it can actually see. The order is fixed and written down: a
107
+ real bug first, then the environment, then flakiness, then a
108
+ coverage gap. It decides; it does not act unless you ask, and
109
+ even then only through the gates that prove a change.
103
110
  tewip trace [--only "<ticket or title>"] [--out trace.md]
104
111
  Is this requirement covered, and is it passing right now? Joins
105
112
  the plans Tewip wrote to the tests that cover them and the runs
package/dist/cli/tewip.js CHANGED
@@ -177,6 +177,14 @@ 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 'pursue': {
181
+ const goal = args.filter((a, i) => !a.startsWith('--') && !['--name', '--out'].includes(args[i - 1] ?? '')).join(' ').trim();
182
+ if (!goal)
183
+ throw new Error('usage: tewip pursue "<a goal, e.g. keep the checkout flow green>" [--act]');
184
+ const { runPursue } = await import('../engine/pursue/pursue-command.js');
185
+ runPursue({ root: process.cwd(), goal, act: args.includes('--act'), name: flag(args, 'name'), out: flag(args, 'out') });
186
+ return;
187
+ }
180
188
  case 'trace': {
181
189
  const { runTrace } = await import('../engine/trace/trace-command.js');
182
190
  runTrace({ root: process.cwd(), only: flag(args, 'only'), name: flag(args, 'name'), out: flag(args, 'out') });
@@ -94,7 +94,10 @@ export class HistoryIndex {
94
94
  }
95
95
  runContext(runId) {
96
96
  const e = this.runs[this.indexOf(runId)];
97
- return { runId, branch: e.branch, testsAttempted: Object.keys(e.attempts).length, failures: e.failures };
97
+ // "Some run knows its commit" is the weakest true statement available: a single kept run with a
98
+ // sha means the history can distinguish code changes from flakiness at all.
99
+ const commitsKnown = this.runs.some((r) => Boolean(r.sha));
100
+ return { runId, branch: e.branch, testsAttempted: Object.keys(e.attempts).length, failures: e.failures, commitsKnown };
98
101
  }
99
102
  indexOf(runId) {
100
103
  const i = this.position.get(runId);
@@ -0,0 +1,91 @@
1
+ const list = (items, max = 3) => items.slice(0, max).join(', ') + (items.length > max ? `, and ${items.length - max} more` : '');
2
+ /**
3
+ * What to do next about a goal, in one pass, most urgent first.
4
+ *
5
+ * The order is the argument. A real bug beats everything, because the goal is already broken. The
6
+ * environment comes next, because nothing about the code has been shown and touching tests would
7
+ * be guessing. Flakiness comes third: the code may be fine and the test is lying. Only when
8
+ * nothing is failing is a coverage gap worth spending on, and only when nothing is failing and
9
+ * nothing is missing is the goal worth attesting to.
10
+ */
11
+ export function decide(goal, seen) {
12
+ if (seen.noHistory) {
13
+ return {
14
+ act: 'look-first',
15
+ next: 'run `tewip explain <report>` on a test report, so there is something to reason about',
16
+ why: 'no run has been kept for this project, so nothing is known about the goal yet - and guessing from the name of a goal would be inventing',
17
+ evidence: [],
18
+ changesFiles: false,
19
+ };
20
+ }
21
+ const realBugs = seen.failing.filter((f) => f.category === 'REAL_BUG');
22
+ if (realBugs.length > 0) {
23
+ return {
24
+ act: 'triage-real-bug',
25
+ next: `put ${realBugs.length} failing test(s) in front of a person: ${list(realBugs.map((f) => f.testId))}`,
26
+ why: `the goal is already broken: Tewip calls ${realBugs.length === 1 ? 'this failure' : 'these failures'} a real bug, which is the one kind it will not work around`,
27
+ evidence: realBugs.map((f) => `${f.testId}: REAL_BUG at ${f.confidence.toFixed(2)}`),
28
+ changesFiles: false,
29
+ };
30
+ }
31
+ const env = seen.failing.filter((f) => f.category === 'ENV');
32
+ if (env.length > 0 && env.length === seen.failing.length) {
33
+ return {
34
+ act: 'rerun-environment',
35
+ next: `re-run, and do not touch the tests: ${list(env.map((f) => f.testId))}`,
36
+ why: 'every failure the goal depends on is the environment, so nothing has been shown about the code and changing a test would be guessing',
37
+ evidence: env.map((f) => `${f.testId}: ENV at ${f.confidence.toFixed(2)}`),
38
+ changesFiles: false,
39
+ };
40
+ }
41
+ const timing = seen.failing.filter((f) => f.category === 'TIMING');
42
+ if (timing.length > 0 || seen.flaky.length > 0) {
43
+ const worst = seen.flaky.slice().sort((a, b) => b.times - a.times);
44
+ return {
45
+ act: 'settle-flaky',
46
+ next: worst.length > 0 ? `settle ${worst[0].testId}, which has flaked ${worst[0].times} time(s)` : `settle ${list(timing.map((f) => f.testId))}`,
47
+ why: 'the goal is not broken, but the tests watching it come and go, so nobody can tell whether it is broken',
48
+ evidence: [...timing.map((f) => `${f.testId}: TIMING at ${f.confidence.toFixed(2)}`), ...worst.map((f) => `${f.testId}: flaked ${f.times} time(s)`)],
49
+ changesFiles: true,
50
+ };
51
+ }
52
+ // Anything still failing has to be dealt with before anything else is spent on. Without this the
53
+ // policy read "3 failing" in its own state line and then said "nothing the goal depends on is
54
+ // failing", because undecided failures matched none of the branches above - the exact shape of
55
+ // error this project exists to refuse.
56
+ if (seen.failing.length > 0) {
57
+ return {
58
+ act: 'triage-undecided',
59
+ next: `put ${seen.failing.length} failing test(s) in front of a person: ${list(seen.failing.map((f) => f.testId))}`,
60
+ why: `the goal is failing and Tewip cannot say why: ${list([...new Set(seen.failing.map((f) => f.category))], 4)}. It will not move on to anything else while something it is watching is red`,
61
+ evidence: seen.failing.map((f) => `${f.testId}: ${f.category}${f.confidence ? ` at ${f.confidence.toFixed(2)}` : ' (undiagnosed)'}`),
62
+ changesFiles: false,
63
+ };
64
+ }
65
+ if (seen.uncovered.length > 0) {
66
+ const first = seen.uncovered[0];
67
+ return {
68
+ act: 'cover-gap',
69
+ next: `write a test for ${first.id}: ${first.title}`,
70
+ why: `nothing the goal depends on is failing, so the next thing worth spending on is what nothing is checking - ${seen.uncovered.length} scenario(s), worst first`,
71
+ evidence: seen.uncovered.slice(0, 5).map((u) => `${u.id} (${u.priority || 'no priority'}): ${u.title}`),
72
+ changesFiles: true,
73
+ };
74
+ }
75
+ if (seen.passing > 0) {
76
+ return {
77
+ act: 'attest',
78
+ next: 'produce the evidence bundle with `tewip attest`, and stop',
79
+ why: `everything the goal covers ran and passed (${seen.passing} test(s)), and nothing it depends on is unchecked. There is no next action worth taking`,
80
+ evidence: [`${seen.passing} test(s) passed in the last run`],
81
+ changesFiles: false,
82
+ };
83
+ }
84
+ return {
85
+ act: 'stand-down',
86
+ next: 'nothing: name the goal in words this project uses, or check the project is the right one',
87
+ why: `nothing kept here matches "${goal.slice(0, 60)}" - no test, no scenario, no failure - so acting would mean acting on something Tewip cannot see`,
88
+ evidence: [],
89
+ changesFiles: false,
90
+ };
91
+ }
@@ -0,0 +1,125 @@
1
+ // `tewip pursue "<a goal>"`: what Tewip would do next about it, and why.
2
+ //
3
+ // By default it decides and explains, and does nothing. `--act` lets it carry out the one action
4
+ // it chose, and only through the gates that already prove a change before keeping it. There is no
5
+ // mode in which it loops unattended: an agent that keeps going until it decides to stop is the
6
+ // thing teams roll back, and nothing here needs it.
7
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
10
+ import { decide } from './policy.js';
11
+ const words = (text) => (text.toLowerCase().match(/[a-z][a-z0-9]{2,}/g) ?? []).filter((w) => !['the', 'and', 'for', 'this', 'that', 'with', 'keep', 'green', 'sprint', 'flow', 'test', 'tests'].includes(w));
12
+ /** Does this test or scenario belong to the goal? Deterministic, so a person can predict it. */
13
+ function about(goal, text) {
14
+ if (goal.length === 0)
15
+ return true; // an unworded goal means the whole project
16
+ const haystack = text.toLowerCase();
17
+ return goal.some((w) => haystack.includes(w));
18
+ }
19
+ /** What is true about this project right now, as far as anything kept here can show. */
20
+ export function observe(root, goal, only) {
21
+ const dir = join(root, '.tewip', 'history');
22
+ const runs = [];
23
+ const records = [];
24
+ if (existsSync(dir)) {
25
+ for (const project of only ? [only] : readdirSync(dir)) {
26
+ const where = join(dir, project);
27
+ if (!existsSync(where))
28
+ continue;
29
+ runs.push(...readJsonl(datasetFiles(where).runs));
30
+ records.push(...readTriageRecords(datasetFiles(where).triage));
31
+ }
32
+ }
33
+ runs.sort((a, b) => (a.finishedAt > b.finishedAt ? 1 : -1));
34
+ const newest = runs.at(-1);
35
+ const wanted = words(goal);
36
+ const failing = records
37
+ .filter((r) => r.runId === newest?.runId && about(wanted, r.testId))
38
+ .map((r) => {
39
+ const d = r.diagnosis;
40
+ return { testId: r.testId, category: String(d?.category ?? 'UNKNOWN'), confidence: Number(d?.confidence ?? 0) };
41
+ });
42
+ // A test that failed in more than one earlier run and is not failing now has come and gone.
43
+ const failedBefore = new Map();
44
+ for (const r of records)
45
+ if (about(wanted, r.testId))
46
+ failedBefore.set(r.testId, (failedBefore.get(r.testId) ?? 0) + 1);
47
+ const failingNow = new Set(failing.map((f) => f.testId));
48
+ const flaky = [...failedBefore.entries()].filter(([testId, times]) => times >= 2 && !failingNow.has(testId)).map(([testId, times]) => ({ testId, times }));
49
+ const attempted = Object.keys(newest?.attempts ?? {}).filter((t) => about(wanted, t));
50
+ return {
51
+ failing,
52
+ flaky,
53
+ uncovered: uncoveredScenarios(root, wanted, attempted),
54
+ passing: attempted.length - failing.length,
55
+ noHistory: runs.length === 0,
56
+ };
57
+ }
58
+ /** Scenarios of a kept plan that no attempted test can be matched to, worst priority first. */
59
+ function uncoveredScenarios(root, goal, attempted) {
60
+ const dir = join(root, '.tewip', 'plans');
61
+ if (!existsSync(dir))
62
+ return [];
63
+ const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
64
+ const out = [];
65
+ for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
66
+ try {
67
+ const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'));
68
+ const plan = saved.plan ?? saved;
69
+ if (!Array.isArray(plan.scenarios))
70
+ continue;
71
+ if (!about(goal, `${plan.title ?? ''} ${saved.feature ?? ''}`))
72
+ continue;
73
+ for (const scenario of plan.scenarios) {
74
+ const covered = (scenario.covered_by ?? '').trim() || attempted.find((t) => clean(t).includes(clean(scenario.id)) && clean(scenario.id).length >= 4);
75
+ if (!covered)
76
+ out.push({ id: scenario.id, title: scenario.title, priority: scenario.priority ?? '' });
77
+ }
78
+ }
79
+ catch {
80
+ // Not a readable plan.
81
+ }
82
+ }
83
+ return out.sort((a, b) => (a.priority || 'P9').localeCompare(b.priority || 'P9'));
84
+ }
85
+ export function renderPursuit(goal, seen, decision, acting) {
86
+ const lines = [
87
+ `# Goal: ${goal}`,
88
+ '',
89
+ `What is true now: ${seen.passing} passing, ${seen.failing.length} failing, ${seen.flaky.length} that come and go, ${seen.uncovered.length} unchecked.`,
90
+ '',
91
+ '## What Tewip would do next',
92
+ '',
93
+ `**${decision.next}**`,
94
+ '',
95
+ decision.why + '.',
96
+ '',
97
+ ];
98
+ if (decision.evidence.length > 0) {
99
+ lines.push('What it is reacting to:', '');
100
+ for (const e of decision.evidence)
101
+ lines.push(`- ${e}`);
102
+ lines.push('');
103
+ }
104
+ lines.push(decision.changesFiles
105
+ ? acting
106
+ ? 'This changes files, so it runs through the gate that proves a change before keeping it: anything unproved is thrown away.'
107
+ : 'This would change files, so Tewip has not done it. Run the command it names, or pass `--act`.'
108
+ : 'Tewip has not done anything: this is a decision, and the action is yours.', '', 'The order of these decisions is fixed and written down, not chosen by a model: a real bug first,', 'then the environment, then flakiness, then a coverage gap, and only then is a goal worth attesting.');
109
+ return lines.join('\n');
110
+ }
111
+ export function runPursue(cmd) {
112
+ const observed = observe(cmd.root, cmd.goal, cmd.name);
113
+ const decision = decide(cmd.goal, observed);
114
+ const report = renderPursuit(cmd.goal, observed, decision, Boolean(cmd.act));
115
+ console.log(report);
116
+ if (cmd.act && decision.changesFiles) {
117
+ console.error('\n`--act` is accepted, but Tewip carries out a change only through the command that proves it.');
118
+ console.error(`Run: ${decision.next}`);
119
+ }
120
+ if (cmd.out) {
121
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
122
+ writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
123
+ }
124
+ return { decision, observed };
125
+ }
@@ -163,10 +163,18 @@ export const INTERMITTENT_MIN_ALTERNATIONS = 3;
163
163
  */
164
164
  export const timingIntermittent = {
165
165
  id: 'timing-intermittent',
166
- evaluate({ history, loadFailedRequests }) {
166
+ evaluate({ history, loadFailedRequests, run }) {
167
167
  const { alternations, runs, failures } = history;
168
168
  if (alternations < INTERMITTENT_MIN_ALTERNATIONS)
169
169
  return null;
170
+ // Flipping only means flakiness if the code stayed the same while it flipped. Where no run
171
+ // carries a commit, nothing can tell "the test comes and goes" from "somebody kept changing
172
+ // the code" - which is exactly what happened when a regression was broken and restored several
173
+ // times in a row, and this rule called the regression flaky with the same confidence it gives
174
+ // a genuine flake. Without commits, that claim is not available.
175
+ if (!run.commitsKnown) {
176
+ return declined(this.id, `this test flipped ${alternations} times in ${runs} runs, but no run recorded a commit, so there is no way to tell a flaky test from code that kept changing`);
177
+ }
170
178
  // A dependency that fails now and then also makes a test come and go, and that is ENV, not a
171
179
  // race in the test. Seen on held-out data, where an external media API broke this test.
172
180
  const failed = dependencyFailure(loadFailedRequests);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",