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 +43 -5
- package/TEAM-GUIDE.md +11 -0
- package/dist/ci/gate.js +37 -0
- package/dist/cli/tewip.js +76 -0
- package/dist/engine/autofix/code-command.js +3 -1
- package/dist/engine/autofix/code-fix.js +73 -53
- package/dist/engine/autofix/code-propose.js +1 -1
- package/dist/engine/autofix/code-target.js +64 -9
- package/dist/engine/autofix/line-diff.js +110 -0
- package/dist/engine/ci/gitlab.js +63 -0
- package/dist/engine/ci/platform.js +140 -0
- package/dist/engine/ci/report-command.js +96 -0
- package/dist/engine/notify/chat.js +85 -0
- package/dist/engine/notify/note-command.js +76 -0
- package/dist/engine/spec/spec-command.js +85 -0
- package/dist/engine/spec/spec-render.js +65 -0
- package/dist/engine/spec/spec.js +114 -0
- package/dist/engine/watch/watch-command.js +160 -0
- package/package.json +1 -1
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 **
|
|
183
|
-
each fix, with only that fix's tests, so the real bug reproduces.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
package/dist/ci/gate.js
ADDED
|
@@ -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
|
-
|
|
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 =
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
58
|
+
/** How many lines a change really adds or removes, counted from that same diff. */
|
|
56
59
|
export function changedLines(original, changed) {
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
176
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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.
|
|
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 = (
|
|
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: {
|
|
107
|
+
target: { ...ordered[0], why: say(ordered[0]) },
|
|
103
108
|
why: '',
|
|
104
|
-
more: ordered.slice(1, 3).map((
|
|
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
|
-
|
|
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
|
|
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((
|
|
192
|
+
return scored.sort((a, b) => b.score - a.score).map(({ file, line }) => ({ file, line }));
|
|
138
193
|
}
|