tewip-ai 0.5.0 → 0.6.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
@@ -164,6 +164,38 @@ from them): 38 checks, **0 differences reported on pages that match their design
164
164
  injected drifts caught** - a renamed button, a dropped sentence, a region that stopped being marked
165
165
  up. No designer has judged the output yet, so the roadmap's own metric is still open.
166
166
 
167
+ ## Epics, stories and acceptance criteria (Phase 14, new)
168
+
169
+ ```bash
170
+ tewip spec "Readers can search the library and open a result"
171
+ tewip spec "..." --project PROJ # shows what it would file in Jira
172
+ tewip spec "..." --project PROJ --create # files the epic and its stories
173
+ ```
174
+
175
+ An epic, user stories and Given / When / Then acceptance criteria, written from your code, your
176
+ tests and your failure history. Anything it says the system **already** does carries a quote from a
177
+ file it read, and Tewip checks that quote word by word; what it cannot quote becomes an open
178
+ question instead of a confident sentence. It writes no estimates, dates or business cases, because
179
+ it cannot check them.
180
+
181
+ Measured on three real projects: 15 stories, 49 acceptance criteria, and of 46 claims about existing
182
+ behaviour, **22 were kept with a checkable quote and 24 were turned into open questions**.
183
+
184
+ ## Any CI, your chat, and your own machine (Phase 13, new)
185
+
186
+ ```bash
187
+ tewip report report.xml --gate # GitLab CI, Jenkins, CircleCI, Azure, Bitbucket, Travis, Drone
188
+ tewip watch --cmd "npm test" # beside your editor: explains each failure as it happens
189
+ tewip note --days 7 # the weekly quality note for a lead - free, no model calls
190
+ ```
191
+
192
+ `tewip report` reads which CI it is in from that platform's own variables, prints the triage board,
193
+ writes it to a file, appends it to the job summary, and with `--gate` fails the build **only** on
194
+ failures that are not known flaky or environment noise. On GitLab it posts the diagnosis as one
195
+ merge-request note and edits that same note next run instead of piling up. With
196
+ `TEWIP_SLACK_WEBHOOK` or `TEWIP_TEAMS_WEBHOOK` it tells the team's chat - and says nothing at all
197
+ when no failure needs a person, so nobody mutes it.
198
+
167
199
  ## Fix the code, but only when the tests prove it (Phase 12, new, opt-in)
168
200
 
169
201
  ```bash
@@ -179,11 +211,17 @@ configuration, CI files, lockfiles, migrations or anything with secrets in it, b
179
211
  green after changing those proves nothing. If it cannot prove a change, it explains the bug instead
180
212
  and puts your file back byte for byte. It never commits and never merges.
181
213
 
182
- Measured by replaying **8 real bug fixes** from a public repository (unjs/ufo): the commit before
183
- each fix, with only that fix's tests, so the real bug reproduces. **4 of 8 got a change proved by
184
- the tests, 4 of 4 in the same file the developers changed, each a one-liner doing what the
185
- developers' own fix does. 0 wrong fixes were offered** - the other four were refused (three times
186
- the model wanted a 50-line rewrite, which the size rule threw away).
214
+ Measured by replaying **16 real bug fixes** from two public repositories, in two languages: the
215
+ commit before each fix, with only that fix's tests, so the real bug really reproduces.
216
+
217
+ | Project | Language | Bugs | Proved by the tests | Right file |
218
+ |---|---|---|---|---|
219
+ | unjs/ufo | TypeScript, Vitest | 8 | **8** | 8 of 8 |
220
+ | python-humanize/humanize | Python, pytest | 8 | **8** | 8 of 8 |
221
+
222
+ Every change is small (1 to 16 lines, median 4) and most are what the developers themselves wrote.
223
+ **No wrong fix was offered**: anything that could not be proved was thrown away and the file put
224
+ back byte for byte.
187
225
 
188
226
  ## Where it has been tried
189
227
 
package/TEAM-GUIDE.md CHANGED
@@ -121,6 +121,10 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
121
121
  | `tewip write` | Turns the plan's scenarios into real tests in your own framework, and keeps only the ones that pass repeatedly **and** fail when their check is broken on purpose | When you know what to test but not how to write it |
122
122
  | `tewip plan --issue PROJ-123` | Plans from the Jira ticket itself: its description and acceptance criteria | "Here's the story, what should we test?" |
123
123
  | `tewip jira plan.json --project PROJ` | Files the plan's scenarios as test-case tickets, linked to the story | Handing work to the team |
124
+ | `tewip spec "<feature>"` | An epic, its user stories and acceptance criteria, grounded in your code; files them in Jira with `--project PROJ --create` | Before the work is broken down |
125
+ | `tewip report report.xml --gate` | The CI command for GitLab, Jenkins, CircleCI, Azure, Bitbucket and the rest: diagnoses the run, comments on the GitLab merge request, and fails the build only on failures that need a person | Your CI is not GitHub Actions |
126
+ | `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
127
+ | `tewip note --days 7` | The weekly quality note for a lead, from runs already diagnosed (no model calls, free) | Monday morning |
124
128
  | `tewip fix --code report.xml` | Opt-in: proposes the smallest change to application code for a real bug, and keeps it only if the failing test passes and the whole suite still passes | "It is a real bug and I want a starting patch" |
125
129
  | `tewip design <figma link> --url <page>` | Compares a Figma frame against the running page: the words the design specifies and the controls its layer names imply | "Did we build what was designed?" |
126
130
  | `tewip mcp` | Serves all of the above to your editor's AI assistant | Set up once |
@@ -134,6 +138,8 @@ Notes that save you time:
134
138
  - Everything it writes locally goes to the `.tewip` folder in your project.
135
139
  - `tewip jira` shows what it *would* file and creates nothing until you add `--create`. It never
136
140
  files the same scenario twice, and never moves, assigns or closes a ticket.
141
+ - `tewip report` posts to Slack or Teams only when a failure needs a person (`TEWIP_SLACK_WEBHOOK`,
142
+ `TEWIP_TEAMS_WEBHOOK`). A run of known flaky tests pings nobody, on purpose.
137
143
  - `tewip design` needs a Figma read token (`TEWIP_FIGMA_TOKEN`), or a frame saved as JSON if you
138
144
  would rather not create one. It compares words, controls and page regions - never pixels, colours
139
145
  or spacing, because a design tool and a browser do not measure those the same way.
@@ -170,6 +176,11 @@ Notes that save you time:
170
176
  dependencies.
171
177
  - The quality view: flakiness hotspots and test-design debt, ranked by the CI time they waste.
172
178
 
179
+ **Business analyst / product owner**
180
+ - `tewip spec "<feature>"` writes the epic, the stories and the acceptance criteria from what the
181
+ code and tests actually do. Every "the system already does X" carries a quote you can check, and
182
+ anything it could not quote is listed as an open question for you to answer.
183
+
173
184
  **Designer, and the developer building from a design**
174
185
  - `tewip design <figma link> --url http://localhost:3000` after building a screen: it lists what the
175
186
  design says and what the page says, side by side, so a renamed button or a dropped sentence is
@@ -0,0 +1,37 @@
1
+ // The smart merge gate: fail the build on failures that need someone, let known noise through.
2
+ // Flaky-test products quarantine tests they have flagged so they stop blocking merges; this does
3
+ // the same per failure, from the diagnosis. It is conservative on purpose: only a deterministic
4
+ // rule's or a person's TIMING/ENV verdict counts as noise. An AI-only verdict, a low confidence,
5
+ // or anything undecided still blocks, because letting a real bug through costs far more than
6
+ // asking a person to look at a flaky test.
7
+ import { appendFileSync } from 'node:fs';
8
+ /** Below this, even a rule's TIMING/ENV verdict is not trusted to let a failure through. */
9
+ export const GATE_MIN_CONFIDENCE = 0.7;
10
+ /** True when this failure is noise the gate may let through. */
11
+ export function isNoise(item) {
12
+ const d = item.diagnosis;
13
+ const trusted = d.source === 'human' || (d.source === 'rules' && d.confidence >= GATE_MIN_CONFIDENCE);
14
+ return trusted && (d.category === 'TIMING' || d.category === 'ENV');
15
+ }
16
+ export function gate(items) {
17
+ return {
18
+ blocking: items.filter((i) => !isNoise(i)),
19
+ flaky: items.filter((i) => isNoise(i) && i.diagnosis.category === 'TIMING'),
20
+ environment: items.filter((i) => isNoise(i) && i.diagnosis.category === 'ENV'),
21
+ };
22
+ }
23
+ /** Writes the counts as step outputs, so later steps and workflows can act on them. */
24
+ export function writeGateOutputs(file, result) {
25
+ if (!file)
26
+ return;
27
+ appendFileSync(file, `blocking=${result.blocking.length}\nflaky=${result.flaky.length}\nenvironment=${result.environment.length}\n`, 'utf8');
28
+ }
29
+ /** One line for the log saying what the gate decided and why. */
30
+ export function gateSummary(result) {
31
+ const noise = result.flaky.length + result.environment.length;
32
+ if (result.blocking.length === 0) {
33
+ return noise === 0 ? 'gate: no failures' : `gate: passing - all ${noise} failure(s) are flaky (${result.flaky.length}) or environment (${result.environment.length}), classified by a rule or a person`;
34
+ }
35
+ const names = result.blocking.slice(0, 5).map((i) => `${i.title} (${i.diagnosis.category}${i.diagnosis.source === 'llm' ? ', AI only' : ''})`);
36
+ return `gate: failing - ${result.blocking.length} failure(s) need someone: ${names.join('; ')}${result.blocking.length > 5 ? '; ...' : ''}${noise ? `. ${noise} other failure(s) are flaky or environment and do not block` : ''}`;
37
+ }
package/dist/cli/tewip.js CHANGED
@@ -38,6 +38,25 @@ Usage:
38
38
  File the plan's scenarios as test-case issues, linked to the
39
39
  story. Shows what it would create; --create actually files
40
40
  them, and the same scenario is never filed twice.
41
+ tewip spec "<feature>" [--diff main...HEAD] [--url <page> ...] [--project PROJ] [--create]
42
+ An epic, its user stories and their acceptance criteria, from
43
+ this project's code, tests and failure history. Anything it
44
+ says the system already does is quoted from a file it read;
45
+ what it cannot quote becomes an open question. With --project it
46
+ shows what it would file in Jira; --create files it.
47
+ tewip report <report> [--gate] [--out report.md] [--name <project>] [--no-post]
48
+ The CI command for any platform: GitLab CI, Jenkins, CircleCI,
49
+ Azure, Bitbucket, Travis, Drone. Diagnoses the run, comments on
50
+ the GitLab merge request, tells Slack or Teams only when a
51
+ failure needs a person, and with --gate fails the build only on
52
+ those failures.
53
+ tewip watch [--cmd "npm test"] [--report <path>] [--paths src,tests]
54
+ Beside your editor: runs your tests when a file changes (or
55
+ watches for new reports) and explains each failure as it
56
+ happens, without CI.
57
+ tewip note [--days 7] [--name <project>] [--out note.md] [--post]
58
+ The weekly quality note for a lead, written from the runs Tewip
59
+ already diagnosed. No model calls, so it costs nothing.
41
60
  tewip fix --code <report> [--only "<test title>"] [--max 3] [--apply] [--suite "<command>"]
42
61
  Opt-in: for failures diagnosed as a real bug, propose the
43
62
  smallest change to application code, and keep it only if the
@@ -132,6 +151,63 @@ async function main() {
132
151
  });
133
152
  return;
134
153
  }
154
+ case 'spec': {
155
+ const feature = args.filter((a, i) => !a.startsWith('--') && !['--diff', '--url', '--out', '--project'].includes(args[i - 1] ?? '')).join(' ').trim();
156
+ if (!feature)
157
+ throw new Error('usage: tewip spec "<what the feature should do>" [--diff main...HEAD] [--url http://localhost:3000/page] [--project PROJ] [--create]');
158
+ const { runSpec } = await import('../engine/spec/spec-command.js');
159
+ await runSpec({
160
+ feature,
161
+ root: process.cwd(),
162
+ diffRange: flag(args, 'diff'),
163
+ urls: args.flatMap((a, i) => (args[i - 1] === '--url' ? [a] : [])),
164
+ out: flag(args, 'out'),
165
+ projectKey: flag(args, 'project'),
166
+ create: args.includes('--create'),
167
+ });
168
+ return;
169
+ }
170
+ case 'report': {
171
+ const report = args.find((a, i) => !a.startsWith('--') && !['--out', '--name'].includes(args[i - 1] ?? ''));
172
+ if (!report)
173
+ throw new Error('usage: tewip report <report.xml | reports-dir | blob-report-dir> [--gate] [--out report.md] [--name project] [--no-post]');
174
+ process.env.TEWIP_LLM_CACHE ??= join(process.cwd(), '.tewip', 'llm-cache.jsonl');
175
+ const { runReport } = await import('../engine/ci/report-command.js');
176
+ const result = await runReport({
177
+ root: process.cwd(),
178
+ report,
179
+ name: flag(args, 'name'),
180
+ out: flag(args, 'out'),
181
+ gate: args.includes('--gate'),
182
+ post: !args.includes('--no-post'),
183
+ });
184
+ // The gate fails the build only for failures that are not known flaky or environment noise.
185
+ if (args.includes('--gate') && result.blocking > 0)
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ case 'watch': {
190
+ const { runWatch } = await import('../engine/watch/watch-command.js');
191
+ await runWatch({
192
+ root: process.cwd(),
193
+ cmd: flag(args, 'cmd'),
194
+ report: flag(args, 'report'),
195
+ paths: (flag(args, 'paths') ?? '').split(',').map((s) => s.trim()).filter(Boolean),
196
+ once: args.includes('--once'),
197
+ });
198
+ return;
199
+ }
200
+ case 'note': {
201
+ const { runNote } = await import('../engine/notify/note-command.js');
202
+ await runNote({
203
+ root: process.cwd(),
204
+ name: flag(args, 'name'),
205
+ days: Math.max(1, Number(flag(args, 'days')) || 7),
206
+ out: flag(args, 'out'),
207
+ post: args.includes('--post'),
208
+ });
209
+ return;
210
+ }
135
211
  case 'fix': {
136
212
  // Changing application code is the one thing Tewip has always refused, so it happens only
137
213
  // when a person asks for it by name. Without --code, this says so rather than guessing.
@@ -13,6 +13,7 @@ import { HistoryIndex } from '../history.js';
13
13
  import { importJunit } from '../import/junit.js';
14
14
  import { importBlobReportDir } from '../import/blob.js';
15
15
  import { llmFromEnv } from '../llm/client.js';
16
+ import { writerFromEnv } from '../quality/model.js';
16
17
  import { usageLine } from '../llm/usage.js';
17
18
  import { buildItems } from '../../outlets/items.js';
18
19
  import { chooseFramework, detectFrameworks } from '../quality/frameworks.js';
@@ -30,7 +31,8 @@ function testFileOf(file, root) {
30
31
  return existsSync(direct) ? direct : null;
31
32
  }
32
33
  export async function runCodeFix(cmd) {
33
- const model = llmFromEnv();
34
+ // Fixing code is writing code, so it uses the writing model, not the small triage one.
35
+ const model = writerFromEnv(cmd.root);
34
36
  if (!model)
35
37
  throw new Error('fixing code needs a model. Set TEWIP_API_KEY (and TEWIP_PROVIDER), or OPENAI_API_KEY.');
36
38
  const path = resolve(cmd.report);
@@ -14,6 +14,7 @@
14
14
  import { relative, resolve, sep } from 'node:path';
15
15
  import { codeTarget, mayChange } from './code-target.js';
16
16
  import { excerpt, proposeCodeFix } from './code-propose.js';
17
+ import { countChanged, lineDiff, renderDiff } from './line-diff.js';
17
18
  /**
18
19
  * The runner never ran the test: a missing module, a reporter or config it could not load, a
19
20
  * browser that never started. The test did not fail - it never happened - so a change afterwards
@@ -21,51 +22,42 @@ import { excerpt, proposeCodeFix } from './code-propose.js';
21
22
  * running the fixer against real bug fixes with a mistyped runner flag: every case looked like a
22
23
  * failing test, and every fix looked like it did not work.)
23
24
  */
24
- const NEVER_RAN = /No test files found|Failed to load url|Cannot find module|ERR_MODULE_NOT_FOUND|has not captured in \d+ ?ms|No captured browser|EADDRINUSE|webServer.{0,60}(?:exited|timed out)|command not found|is not recognized as an internal/i;
25
+ const NEVER_RAN = /No test files found|Failed to load url|Cannot find module|ModuleNotFoundError|ImportError while importing test module|ERR_MODULE_NOT_FOUND|has not captured in \d+ ?ms|No captured browser|EADDRINUSE|webServer.{0,60}(?:exited|timed out)|command not found|is not recognized as an internal/i;
25
26
  /** More changed lines than this is a rewrite, not a bug fix, whatever the model calls it. */
26
27
  export const MAX_CHANGED_LINES = 20;
27
28
  /** How many times the model may try before Tewip gives up and explains instead. */
28
- export const MAX_ATTEMPTS = 2;
29
- /** A unified diff of one replacement, for a person to read. */
29
+ export const MAX_ATTEMPTS = 3;
30
+ const CR = String.fromCharCode(13);
31
+ const NL = String.fromCharCode(10);
32
+ /**
33
+ * Which other tests a change broke, by name. A tail of runner output is mostly noise; the names of
34
+ * the tests that used to pass say what behaviour the fix must keep, and that is what the next
35
+ * attempt needs to hear.
36
+ */
37
+ export function failingTests(output) {
38
+ const names = [
39
+ ...output.matchAll(/^(?:FAILED|ERROR)\s+(\S+)/gm), // pytest
40
+ ...output.matchAll(/^\s*(?:FAIL|×)\s+(.+?)(?:\s+\d+\s*ms)?$/gm), // vitest, jest
41
+ ...output.matchAll(/^\s*\d+\)\s+(.+)$/gm), // mocha
42
+ ...output.matchAll(/^\s*\[FAIL\]\s+(.+)$/gm), // .NET
43
+ ]
44
+ .map((m) => m[1].trim())
45
+ .filter((name) => name.length > 0 && !/^\d+$/.test(name));
46
+ return [...new Set(names)];
47
+ }
48
+ /** Those names, said in one line for the model to read. */
49
+ export function brokeWhat(names, fallback, max = 8) {
50
+ if (names.length === 0)
51
+ return fallback.slice(-400);
52
+ return `${names.slice(0, max).join(', ')}${names.length > max ? `, and ${names.length - max} more` : ''}`;
53
+ }
54
+ /** The patch a reviewer reads: real hunks, not the whole span between the first and last change. */
30
55
  export function changeDiff(file, original, changed) {
31
- const a = original.split('\n');
32
- const b = changed.split('\n');
33
- let start = 0;
34
- while (start < a.length && start < b.length && a[start] === b[start])
35
- start++;
36
- let endA = a.length - 1;
37
- let endB = b.length - 1;
38
- while (endA > start && endB > start && a[endA] === b[endB]) {
39
- endA--;
40
- endB--;
41
- }
42
- const context = 3;
43
- const from = Math.max(0, start - context);
44
- const lines = [`--- a/${file}`, `+++ b/${file}`, `@@ -${from + 1},${endA - from + 1 + context} +${from + 1},${endB - from + 1 + context} @@`];
45
- for (let i = from; i < start; i++)
46
- lines.push(` ${a[i]}`);
47
- for (let i = start; i <= endA; i++)
48
- lines.push(`-${a[i]}`);
49
- for (let i = start; i <= endB; i++)
50
- lines.push(`+${b[i]}`);
51
- for (let i = endA + 1; i <= Math.min(a.length - 1, endA + context); i++)
52
- lines.push(` ${a[i]}`);
53
- return lines.join('\n');
56
+ return renderDiff(file, lineDiff(original, changed));
54
57
  }
55
- /** How many lines the change touches, counted on both sides. */
58
+ /** How many lines a change really adds or removes, counted from that same diff. */
56
59
  export function changedLines(original, changed) {
57
- const a = original.split('\n');
58
- const b = changed.split('\n');
59
- let start = 0;
60
- while (start < a.length && start < b.length && a[start] === b[start])
61
- start++;
62
- let endA = a.length - 1;
63
- let endB = b.length - 1;
64
- while (endA >= start && endB >= start && a[endA] === b[endB]) {
65
- endA--;
66
- endB--;
67
- }
68
- return Math.max(0, endA - start + 1) + Math.max(0, endB - start + 1);
60
+ return countChanged(lineDiff(original, changed));
69
61
  }
70
62
  export async function attemptCodeFix(input, deps, { keep = false } = {}) {
71
63
  const { record, diagnosis, action } = input;
@@ -100,14 +92,30 @@ export async function attemptCodeFix(input, deps, { keep = false } = {}) {
100
92
  return stop('test-never-ran', `the test never ran, so nothing can be proved here: ${said?.trim().slice(0, 200)}`);
101
93
  }
102
94
  result.log.push('the test fails before any change, as expected');
95
+ // What is already broken here, before Tewip touches anything. Real projects have failing and
96
+ // skipped tests of their own, and a repository checked out at an older commit has more; blaming a
97
+ // fix for those would reject every correct change, which is exactly what happened in a measurement
98
+ // before this existed. The suite is read once, lazily, and only to know what NOT to blame.
99
+ let baseline = null;
100
+ const alreadyFailing = async () => {
101
+ if (!baseline) {
102
+ const run = await deps.runSuite();
103
+ baseline = new Set(run.passed ? [] : failingTests(run.output));
104
+ if (baseline.size > 0)
105
+ result.log.push(`${baseline.size} test(s) in this project were already failing before any change, and are not counted against the fix`);
106
+ }
107
+ return baseline;
108
+ };
103
109
  // The bug is not always in the first file that looked right. When the model says the cause is not
104
110
  // in the file it was given, the next candidate is tried rather than the whole attempt abandoned.
105
111
  let last = stop('no-target', why || 'nothing to change');
112
+ const trail = [];
106
113
  for (const candidate of [target, ...more]) {
107
- const outcome = await tryFile(candidate, input, deps, { keep, result: { ...result, file: relative(resolve(deps.root), candidate.file) } });
114
+ const outcome = await tryFile(candidate, input, deps, { keep, alreadyFailing, result: { ...result, file: relative(resolve(deps.root), candidate.file) } });
108
115
  if (outcome.status === 'fixed')
109
- return outcome;
110
- last = outcome;
116
+ return { ...outcome, log: [...trail, ...outcome.log] };
117
+ trail.push(`${relative(resolve(deps.root), candidate.file)}: ${outcome.status}`);
118
+ last = { ...outcome, log: [...trail, ...outcome.log] };
111
119
  if (outcome.status !== 'model-declined' && outcome.status !== 'change-not-found')
112
120
  break;
113
121
  if (more.length > 0)
@@ -116,11 +124,17 @@ export async function attemptCodeFix(input, deps, { keep = false } = {}) {
116
124
  return last;
117
125
  }
118
126
  /** One file: ask, check the change is small and exact, apply, run the test, run the suite. */
119
- async function tryFile(target, input, deps, { keep, result }) {
127
+ async function tryFile(target, input, deps, { keep, result, alreadyFailing }) {
120
128
  const { record } = input;
121
129
  const rel = relative(resolve(deps.root), target.file);
122
130
  const stop = (status, why) => ({ ...result, status, why, log: [...result.log, why] });
123
- const original = deps.readFile(target.file);
131
+ // A checkout on Windows has CRLF line endings; a model answers in LF. Comparing them directly
132
+ // threw away four correct fixes in a real measurement, so both sides are read in LF here and the
133
+ // file is written back in whatever it used.
134
+ const raw = deps.readFile(target.file);
135
+ const crlf = raw.includes(CR + NL);
136
+ const asFile = (text) => (crlf ? text.split(NL).join(CR + NL) : text);
137
+ const original = crlf ? raw.split(CR + NL).join(NL) : raw;
124
138
  const piece = excerpt(original, target.line);
125
139
  const earlier = [];
126
140
  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
@@ -142,7 +156,9 @@ async function tryFile(target, input, deps, { keep, result }) {
142
156
  if (change.fault === 'test' || !change.before.trim()) {
143
157
  return stop('model-declined', `no code change offered: ${change.why || 'the cause is not visible in this file'}`);
144
158
  }
145
- const occurrences = original.split(change.before).length - 1;
159
+ const before = change.before.split(CR + NL).join(NL);
160
+ const after = change.after.split(CR + NL).join(NL);
161
+ const occurrences = original.split(before).length - 1;
146
162
  if (occurrences !== 1) {
147
163
  earlier.push(`attempt ${attempt}: the text to replace ${occurrences === 0 ? 'was not in the file' : `appeared ${occurrences} times`}`);
148
164
  result.attempts.push({ file: rel, changedLines: 0, detail: earlier.at(-1) });
@@ -150,7 +166,7 @@ async function tryFile(target, input, deps, { keep, result }) {
150
166
  return stop('change-not-found', `the proposed change does not match the file exactly (${earlier.at(-1)})`);
151
167
  continue;
152
168
  }
153
- const changed = original.replace(change.before, change.after);
169
+ const changed = original.replace(before, after);
154
170
  const touched = changedLines(original, changed);
155
171
  if (touched > MAX_CHANGED_LINES) {
156
172
  earlier.push(`attempt ${attempt}: the change touched ${touched} lines, which is a rewrite rather than a fix`);
@@ -160,22 +176,26 @@ async function tryFile(target, input, deps, { keep, result }) {
160
176
  continue;
161
177
  }
162
178
  // 5 and 6. The test, then everything else. The file goes back unless both hold.
163
- deps.writeFile(target.file, changed);
179
+ deps.writeFile(target.file, asFile(changed));
164
180
  try {
165
181
  const after = await deps.runTest();
166
182
  if (!after.passed) {
167
183
  earlier.push(`attempt ${attempt}: the test still failed after the change: ${after.output.slice(-400)}`);
168
184
  result.attempts.push({ file: rel, changedLines: touched, detail: 'the test still failed' });
169
- deps.writeFile(target.file, original);
185
+ deps.writeFile(target.file, raw);
170
186
  if (attempt === MAX_ATTEMPTS)
171
187
  return stop('still-failing', 'the test still failed after every change tried, so the file was put back');
172
188
  continue;
173
189
  }
174
190
  const suite = await deps.runSuite();
175
- if (!suite.passed) {
176
- earlier.push(`attempt ${attempt}: the test passed but other tests broke: ${suite.output.slice(-400)}`);
191
+ // A change is rejected for what it broke, not for what was broken before it.
192
+ const failingNow = failingTests(suite.output);
193
+ const wasFailing = suite.passed ? new Set() : await alreadyFailing();
194
+ const brokeNow = failingNow.filter((name) => !wasFailing.has(name));
195
+ if (!suite.passed && (brokeNow.length > 0 || failingNow.length === 0)) {
196
+ earlier.push(`attempt ${attempt}: the failing test passed, but this change broke tests that were passing: ${brokeWhat(brokeNow, suite.output)}. Those describe behaviour the code must keep. Fix the reported bug without changing what they rely on.`);
177
197
  result.attempts.push({ file: rel, changedLines: touched, detail: 'other tests broke' });
178
- deps.writeFile(target.file, original);
198
+ deps.writeFile(target.file, raw);
179
199
  if (attempt === MAX_ATTEMPTS)
180
200
  return stop('broke-others', 'the change made this test pass by breaking others, so the file was put back');
181
201
  continue;
@@ -184,11 +204,11 @@ async function tryFile(target, input, deps, { keep, result }) {
184
204
  result.log.push(`the test passes with the change, and the rest of the suite still passes (${touched} line(s) changed in ${rel})`);
185
205
  const diff = changeDiff(rel, original, changed);
186
206
  if (!keep)
187
- deps.writeFile(target.file, original);
207
+ deps.writeFile(target.file, raw);
188
208
  return { ...result, status: 'fixed', applied: keep, before: change.before, after: change.after, why: change.why, diff };
189
209
  }
190
210
  catch (error) {
191
- deps.writeFile(target.file, original);
211
+ deps.writeFile(target.file, raw);
192
212
  throw error;
193
213
  }
194
214
  }
@@ -30,7 +30,7 @@ export async function proposeCodeFix(model, ctx) {
30
30
  * against real bug fixes the model copied text that still carried those numbers: the change matched
31
31
  * nothing in the file and was thrown away for a reason that was Tewip's fault, not the model's.
32
32
  */
33
- export function excerpt(source, around = 1, maxLines = 500) {
33
+ export function excerpt(source, around = 1, maxLines = 900) {
34
34
  const lines = source.split('\n');
35
35
  if (lines.length <= maxLines)
36
36
  return { text: source, from: 1 };
@@ -19,6 +19,7 @@ const FRAME_PATTERNS = [
19
19
  /([\w.\-/\\]+\.(?:rb|php)):(\d+)/g, // Ruby, PHP
20
20
  / in ([A-Za-z]:[^():\s]+|\/[^():\s]+):line (\d+)/g, // .NET
21
21
  /[❯>]\s+(?:[\w$.]+ )?([\w.\-/\\]+\.[cm]?[jt]sx?):(\d+):\d+/g, // Vitest and Jest: "❯ src/utils.ts:35:11"
22
+ /^\s*([\w.\-/\\]+\.(?:py|rb|php|java|kt|cs|go)):(\d+):/gm, // pytest and friends: "src/humanize/number.py:164: OverflowError"
22
23
  ];
23
24
  /** Files that are tests, not the code under test. */
24
25
  export const isTestFile = (path) => /(?:^|[\\/])(?:tests?|spec|specs|__tests__|e2e|cypress|testing)[\\/]/i.test(path) ||
@@ -90,18 +91,22 @@ export function codeTarget(stack, root, test, hint = '') {
90
91
  refused.push(`${rel} (${verdict.why})`);
91
92
  }
92
93
  if (test) {
93
- const imported = sourceUnderTest(test.file, test.source, root);
94
+ const imported = sourceUnderTest(test.file, test.source, root) ?? pythonModule(test.source, root);
94
95
  // A project with one module per feature exports them all through one file, so the file the test
95
96
  // imports is often not the file with the bug. The name of the failing test says which function
96
97
  // it is about, so the file that defines that function comes first.
97
98
  const byName = definers(imported ? dirname(imported) : dirname(test.file), hint, test.source.slice(0, 2000));
98
- const ordered = [...byName, ...(imported && !byName.includes(imported) ? [imported] : [])].filter((f) => mayChange(f).allowed);
99
+ const ordered = [...byName, ...(imported && !byName.some((d) => d.file === imported) ? [{ file: imported, line: 1 }] : [])]
100
+ .filter((d) => mayChange(d.file).allowed)
101
+ // A file that only re-exports other files holds no bug, and offering it wastes an attempt:
102
+ // in a real run the model rightly answered "this only imports and re-exports that function".
103
+ .filter((d) => !isReExportOnly(d.file));
99
104
  if (ordered.length > 0) {
100
- const say = (f) => `nothing threw inside the code, so the file that defines what the test is about was read: ${relative(resolve(root), f)}`;
105
+ const say = (d) => `nothing threw inside the code, so the file that defines what the test is about was read: ${relative(resolve(root), d.file)}${d.line > 1 ? `, around line ${d.line}` : ''}`;
101
106
  return {
102
- target: { file: ordered[0], line: 1, why: say(ordered[0]) },
107
+ target: { ...ordered[0], why: say(ordered[0]) },
103
108
  why: '',
104
- more: ordered.slice(1, 3).map((f) => ({ file: f, line: 1, why: say(f) })),
109
+ more: ordered.slice(1, 3).map((d) => ({ ...d, why: say(d) })),
105
110
  };
106
111
  }
107
112
  }
@@ -109,6 +114,49 @@ export function codeTarget(stack, root, test, hint = '') {
109
114
  return { target: null, why: 'the failure has no stack trace pointing inside this project, and the test imports no file from it', more: [] };
110
115
  return { target: null, why: `every file the failure names is one Tewip may not change: ${refused.slice(0, 3).join('; ')}`, more: [] };
111
116
  }
117
+ /**
118
+ * A file that defines no function and no class: a package's front door, which re-exports what other
119
+ * files implement. There is no bug to fix in one, and offering it wastes an attempt - in a real run
120
+ * the model answered, correctly, "this only imports and re-exports that function".
121
+ */
122
+ export function isReExportOnly(file) {
123
+ try {
124
+ const source = readFileSync(file, 'utf8');
125
+ const defines = /^\s*(?:async\s+)?def\s+\w|^\s*class\s+\w|(?:export\s+)?(?:function|class)\s+\w|=>\s*[{(]/m;
126
+ return !defines.test(source);
127
+ }
128
+ catch {
129
+ return false;
130
+ }
131
+ }
132
+ /**
133
+ * The project's own module a Python test imports. Python has no relative paths in an import, so the
134
+ * dotted name is turned back into a file under the layouts real projects use: `src/` beside the
135
+ * root, a package folder, or a single module.
136
+ */
137
+ function pythonModule(testSource, root) {
138
+ if (!/^\s*(?:from|import)\s+[\w.]+/m.test(testSource))
139
+ return null;
140
+ const modules = [...testSource.matchAll(/^\s*(?:from|import)\s+([\w.]+)/gm)]
141
+ .map((m) => m[1])
142
+ .filter((name) => !name.startsWith('.') && !STANDARD.has(name.split('.')[0]));
143
+ for (const dotted of modules) {
144
+ const parts = dotted.split('.');
145
+ // A submodule ("humanize.number") is a better answer than the package itself.
146
+ for (const depth of [parts.length, 1]) {
147
+ const head = parts.slice(0, depth);
148
+ for (const base of [join(root, 'src'), root]) {
149
+ for (const candidate of [join(base, ...head) + '.py', join(base, ...head, '__init__.py')]) {
150
+ if (existsSync(candidate) && statSync(candidate).isFile() && !isTestFile(candidate))
151
+ return candidate;
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+ /** Modules that are never the code under test, so an import of them says nothing. */
159
+ const STANDARD = new Set(['pytest', 'unittest', 'os', 'sys', 're', 'json', 'math', 'time', 'datetime', 'typing', 'pathlib', 'collections', 'decimal', 'itertools', 'functools', 'random', 'string', 'subprocess', 'freezegun', 'mock', 'hypothesis', '__future__']);
112
160
  /**
113
161
  * The source files in a folder that define the functions named in the failure, best match first.
114
162
  * This is what tells `parseQuery` in src/query.ts apart from everything else exported by src/.
@@ -123,16 +171,23 @@ function definers(dir, named, mentioned = '') {
123
171
  const weak = words(mentioned).filter((n) => !strong.includes(n));
124
172
  if (strong.length === 0 && weak.length === 0)
125
173
  return [];
126
- const defines = (source, name) => new RegExp(String.raw `(?:export\s+(?:async\s+)?(?:function|const|class)\s+|^def\s+)${name}\b`, 'm').test(source);
174
+ // Where a name is defined, not merely whether it is: a 600-line module keeps the function under
175
+ // test well past the point where a long file has to be cut, and the model must be sent that part.
176
+ const definition = (source, name) => {
177
+ const at = new RegExp(String.raw `(?:export\s+(?:async\s+)?(?:function|const|class)\s+|^\s*def\s+)${name}\b`, 'm').exec(source);
178
+ return at ? source.slice(0, at.index).split('\n').length : 0;
179
+ };
127
180
  const scored = [];
128
181
  for (const entry of readdirSync(dir)) {
129
182
  const file = join(dir, entry);
130
183
  if (!/\.[cm]?[jt]sx?$|\.py$/.test(entry) || isTestFile(file) || !statSync(file).isFile())
131
184
  continue;
132
185
  const source = readFileSync(file, 'utf8');
133
- const score = strong.filter((n) => defines(source, n)).length * 5 + weak.filter((n) => defines(source, n)).length;
186
+ const strongAt = strong.map((n) => definition(source, n)).filter((line) => line > 0);
187
+ const weakAt = weak.map((n) => definition(source, n)).filter((line) => line > 0);
188
+ const score = strongAt.length * 5 + weakAt.length;
134
189
  if (score > 0)
135
- scored.push({ file, score });
190
+ scored.push({ file, line: strongAt[0] ?? weakAt[0] ?? 1, score });
136
191
  }
137
- return scored.sort((a, b) => b.score - a.score).map((s) => s.file);
192
+ return scored.sort((a, b) => b.score - a.score).map(({ file, line }) => ({ file, line }));
138
193
  }