tewip-ai 0.10.0 → 0.12.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 +51 -0
- package/TEAM-GUIDE.md +3 -1
- package/dist/cli/tewip.js +44 -1
- package/dist/engine/autofix/fix-selector.js +93 -0
- package/dist/engine/autofix/selector-source.js +162 -0
- package/dist/engine/build/apply.js +99 -0
- package/dist/engine/build/build-command.js +190 -0
- package/dist/engine/build/build.js +89 -0
- package/dist/engine/import/junit.js +40 -4
- package/dist/engine/import/other-formats.js +127 -0
- package/dist/engine/review/coverage.js +188 -0
- package/dist/engine/review/review-command.js +44 -0
- package/dist/engine/review/review.js +127 -0
- package/dist/engine/work/azure-linear.js +116 -0
- package/dist/engine/work/tracker.js +14 -3
- package/dist/engine/work/trackers.js +9 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,6 +166,57 @@ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra s
|
|
|
166
166
|
implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
|
|
167
167
|
own metric is still open.
|
|
168
168
|
|
|
169
|
+
## More runners: Robot Framework, JMeter, k6 (new)
|
|
170
|
+
|
|
171
|
+
Tewip now reads **Robot Framework's output.xml**, **JMeter's .jtl** and a **k6 summary** as well as
|
|
172
|
+
JUnit XML, TRX, NUnit and Playwright's own reports. A load test does not fail test by test, so a
|
|
173
|
+
JMeter report becomes one line per request ("4 of 5 requests failed, an assertion failed") and a k6
|
|
174
|
+
run becomes one record per crossed threshold, with the number that crossed it.
|
|
175
|
+
|
|
176
|
+
Appium and Selenium need no importer: those suites report through Mocha, pytest or JUnit, which
|
|
177
|
+
Tewip already reads, and their own error messages ("Could not find a connected device", "stale
|
|
178
|
+
element reference") are already understood.
|
|
179
|
+
|
|
180
|
+
Each reader was checked against real reports published in public repositories, which is how three
|
|
181
|
+
defects were found before release - a Robot timestamp that is not a date, k6's numbers living under
|
|
182
|
+
`values`, and a JMeter line that read "mostly 200" when an assertion had failed.
|
|
183
|
+
|
|
184
|
+
## Build a feature, test first (new)
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
tewip build "naturalsize should take a sep argument that goes between the number and the unit"
|
|
188
|
+
tewip build "..." --dry-run # prove it, show the patch, leave nothing on disk
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Tewip writes the **test first** and runs it. **If that test passes, it stops and keeps nothing** —
|
|
192
|
+
a test that passes before the feature exists is testing nothing. Then it writes the smallest code
|
|
193
|
+
that makes the test pass, and keeps the work only if the new test passes *and* nothing that was
|
|
194
|
+
passing has started to fail. Otherwise every file goes back byte for byte, including the test it
|
|
195
|
+
wrote. Nothing is ever committed.
|
|
196
|
+
|
|
197
|
+
Tried on two real public projects: it added a trailing-slash rule to **unjs/ufo** (4 lines,
|
|
198
|
+
TypeScript, $0.18) and a `sep` argument to **python-humanize** (2 lines, Python, $0.10), each with
|
|
199
|
+
its own failing-then-passing test and the project's whole suite still green. Asked for something
|
|
200
|
+
that already worked, it stopped after one model call and kept nothing.
|
|
201
|
+
|
|
202
|
+
The old promise was *"it can't touch your code"*. The new one is *"it can't change your code without
|
|
203
|
+
showing you it works"*.
|
|
204
|
+
|
|
205
|
+
## Before anyone reads the diff (new)
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
tewip review --diff main...HEAD
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Which tests cover this change and **how** (they import the file, they import the package that
|
|
212
|
+
exports it, or their words merely match); which of them have flaked before, so a red run is not
|
|
213
|
+
blamed on your change; what has gone wrong in these areas before; what nothing tests at all; and the
|
|
214
|
+
command to run the telling tests first. From your project's own history, so it costs nothing.
|
|
215
|
+
|
|
216
|
+
Measured on **16 real commits** from two public projects, where the developers changed source *and*
|
|
217
|
+
its test: given only the source files, Tewip **named the right test in 16 of 16, ranked it first in
|
|
218
|
+
12**.
|
|
219
|
+
|
|
169
220
|
## Which flaky tests to quarantine, and what they cost (new)
|
|
170
221
|
|
|
171
222
|
```bash
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -127,6 +127,8 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
|
|
|
127
127
|
| `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 |
|
|
128
128
|
| `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 |
|
|
129
129
|
| `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
|
|
130
|
+
| `tewip build "<what it should do>"` | Writes a feature test first, then the code, and keeps it only if the test failed without it and passes with it | You want a small feature built and proved |
|
|
131
|
+
| `tewip review --diff main...HEAD` | Which tests cover your change, which have flaked before, what nothing tests, and what to run first | Before you open a pull request |
|
|
130
132
|
| `tewip flaky --days 30` | Which tests to quarantine, which to fix, what they cost in CI minutes - and when a quarantined one can come back | Monday morning |
|
|
131
133
|
| `tewip note --days 7` | The weekly quality note for a lead, from runs already diagnosed (no model calls, free) | Monday morning |
|
|
132
134
|
| `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" |
|
|
@@ -140,7 +142,7 @@ Notes that save you time:
|
|
|
140
142
|
- `write` never touches your application code. It only adds new test files, and you review and
|
|
141
143
|
commit them yourself.
|
|
142
144
|
- Everything it writes locally goes to the `.tewip` folder in your project.
|
|
143
|
-
- Work items go to **Jira, Rally
|
|
145
|
+
- Work items go to **Jira, Rally, GitHub Issues, Azure Boards or Linear** - set `TEWIP_TRACKER`, or just the credentials
|
|
144
146
|
of the one you use. GitHub Issues uses your existing `gh` login, so it needs no new secret.
|
|
145
147
|
- Jira works with **Cloud, Server and Data Center**. To read tickets from a tracker anyone can read
|
|
146
148
|
(many open-source ones), `TEWIP_JIRA_URL` alone is enough - no account. Writing needs a token.
|
package/dist/cli/tewip.js
CHANGED
|
@@ -67,6 +67,17 @@ Usage:
|
|
|
67
67
|
the GitLab merge request, tells Slack or Teams only when a
|
|
68
68
|
failure needs a person, and with --gate fails the build only on
|
|
69
69
|
those failures.
|
|
70
|
+
tewip build "<what it should do>" [--dry-run] [--repairs 2] [--out feature.patch]
|
|
71
|
+
Writes a feature, test first: the test must fail without the
|
|
72
|
+
code and pass with it, and every test that was passing must
|
|
73
|
+
still pass - otherwise everything goes back byte for byte.
|
|
74
|
+
Nothing is committed.
|
|
75
|
+
tewip review [--diff main...HEAD] [--out review.md]
|
|
76
|
+
Before anyone reads the diff: which tests cover this change,
|
|
77
|
+
which of them have flaked before, what has gone wrong in these
|
|
78
|
+
areas, what nothing tests at all, and the command that runs the
|
|
79
|
+
telling tests first. From this project's own history, so it
|
|
80
|
+
costs nothing.
|
|
70
81
|
tewip watch [--cmd "npm test"] [--report <path>] [--paths src,tests]
|
|
71
82
|
Beside your editor: runs your tests when a file changes (or
|
|
72
83
|
watches for new reports) and explains each failure as it
|
|
@@ -92,7 +103,9 @@ Usage:
|
|
|
92
103
|
tewip --version Print the version.
|
|
93
104
|
|
|
94
105
|
Reports it reads: Playwright, pytest/unittest, Jest, Vitest, Mocha, node:test, JUnit/TestNG
|
|
95
|
-
(Maven, Gradle), Go (gotestsum), .NET (TRX or JUnit), RSpec, PHPUnit, Rust (nextest)
|
|
106
|
+
(Maven, Gradle), Go (gotestsum), .NET (TRX or JUnit), NUnit 3, RSpec, PHPUnit, Rust (nextest),
|
|
107
|
+
Robot Framework (output.xml), JMeter (.jtl) and k6 (--summary-export). Appium, Selenium and
|
|
108
|
+
WebdriverIO suites report through one of the above, and their own error messages are understood.
|
|
96
109
|
Set OPENAI_API_KEY to let the AI layer decide what the rules leave open.`;
|
|
97
110
|
const flag = (args, name) => {
|
|
98
111
|
const i = args.indexOf(`--${name}`);
|
|
@@ -248,6 +261,36 @@ async function main() {
|
|
|
248
261
|
process.exitCode = 1;
|
|
249
262
|
return;
|
|
250
263
|
}
|
|
264
|
+
case 'build': {
|
|
265
|
+
const feature = args.filter((a, i) => !a.startsWith('--') && !['--repairs', '--out', '--max-lines'].includes(args[i - 1] ?? '')).join(' ').trim();
|
|
266
|
+
if (!feature)
|
|
267
|
+
throw new Error('usage: tewip build "<what the feature should do>" [--dry-run] [--repairs 2] [--out feature.patch]');
|
|
268
|
+
const { runBuild, renderBuild } = await import('../engine/build/build-command.js');
|
|
269
|
+
const dryRun = args.includes('--dry-run');
|
|
270
|
+
const result = await runBuild({
|
|
271
|
+
feature,
|
|
272
|
+
root: process.cwd(),
|
|
273
|
+
dryRun,
|
|
274
|
+
repairs: flag(args, 'repairs') === undefined ? 2 : Math.max(0, Number(flag(args, 'repairs')) || 0),
|
|
275
|
+
maxLines: flag(args, 'max-lines') ? Number(flag(args, 'max-lines')) : undefined,
|
|
276
|
+
out: flag(args, 'out'),
|
|
277
|
+
});
|
|
278
|
+
console.log(`
|
|
279
|
+
${renderBuild(result, feature, dryRun)}`);
|
|
280
|
+
if (result.status !== 'built')
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
case 'review': {
|
|
285
|
+
const { runReview } = await import('../engine/review/review-command.js');
|
|
286
|
+
await runReview({
|
|
287
|
+
root: process.cwd(),
|
|
288
|
+
diff: flag(args, 'diff') ?? 'main...HEAD',
|
|
289
|
+
out: flag(args, 'out'),
|
|
290
|
+
name: flag(args, 'name'),
|
|
291
|
+
});
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
251
294
|
case 'watch': {
|
|
252
295
|
const { runWatch } = await import('../engine/watch/watch-command.js');
|
|
253
296
|
await runWatch({
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ACT + VERIFY for a drifted selector in a Cypress, Selenium or WebdriverIO test.
|
|
2
|
+
//
|
|
3
|
+
// The same loop as every other fix Tewip makes: change one thing in the test file, run that test,
|
|
4
|
+
// keep the change only if it passes, and otherwise put the file back exactly as it was. What is new
|
|
5
|
+
// is where the replacement comes from - the repository, not a page snapshot - which is why these
|
|
6
|
+
// tools can be fixed at all without a trace.
|
|
7
|
+
//
|
|
8
|
+
// It will not touch application code, will not change what the test asserts, and will not offer a
|
|
9
|
+
// replacement it cannot point at in the source or in a commit.
|
|
10
|
+
import { relative, resolve, sep } from 'node:path';
|
|
11
|
+
import { driverLocator } from '../driver-errors.js';
|
|
12
|
+
import { likeItInSource, renamedInGit, tokenOf, writeSelector } from './selector-source.js';
|
|
13
|
+
/**
|
|
14
|
+
* Where a selector is written in a test: the whole line, so the replacement reads in context.
|
|
15
|
+
* Cypress and Selenium put the selector in a plain string, so this is simpler than Playwright's
|
|
16
|
+
* chained calls - and looking for the quoted form keeps it from matching a comment about it.
|
|
17
|
+
*/
|
|
18
|
+
export function findSelectorSites(source, selector) {
|
|
19
|
+
const quoted = [`'${selector}'`, `"${selector}"`, `\`${selector}\``];
|
|
20
|
+
const sites = [];
|
|
21
|
+
source.split('\n').forEach((text, index) => {
|
|
22
|
+
if (quoted.some((q) => text.includes(q)))
|
|
23
|
+
sites.push({ line: index + 1, text });
|
|
24
|
+
});
|
|
25
|
+
return sites;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* One drifted selector, fixed from what the repository knows. `keep` false checks a suggestion
|
|
29
|
+
* without leaving it in the file, exactly as the locator fix does.
|
|
30
|
+
*/
|
|
31
|
+
export async function attemptSelectorFix({ record, diagnosis, action, specFile }, deps, { keep = true } = {}) {
|
|
32
|
+
const title = record.testId.split(' > ').at(-1) ?? record.testId;
|
|
33
|
+
const result = { testId: record.testId, specFile, status: 'not-eligible', applied: false, log: [] };
|
|
34
|
+
const stop = (status, why) => ({ ...result, status, log: [...result.log, why] });
|
|
35
|
+
if (diagnosis.category !== 'LOCATOR_DRIFT' || action.kind !== 'fix-locator') {
|
|
36
|
+
return stop('not-eligible', `only a renamed element is fixed this way; this one is ${diagnosis.category} (${action.kind})`);
|
|
37
|
+
}
|
|
38
|
+
const rel = relative(resolve(deps.allowedRoot), resolve(specFile));
|
|
39
|
+
if (rel.startsWith('..') || rel.startsWith(sep) || /^[a-zA-Z]:/.test(rel))
|
|
40
|
+
return stop('outside-sandbox', `refused: ${specFile} is outside ${deps.allowedRoot}`);
|
|
41
|
+
// What the tool said it was looking for.
|
|
42
|
+
const selector = driverLocator(record.errorMessage) ?? '';
|
|
43
|
+
if (!selector)
|
|
44
|
+
return stop('no-selector', 'the error does not say which selector was not found');
|
|
45
|
+
result.log.push(`the test looked for ${selector}`);
|
|
46
|
+
// What the line in the test really holds: Selenium prints "css selector=#x" but writes "#x".
|
|
47
|
+
const written = /^[a-z ]+=\s*/i.test(selector) ? selector.replace(/^[a-z ]+=\s*/i, '') : selector;
|
|
48
|
+
const token = tokenOf(selector);
|
|
49
|
+
if (!token) {
|
|
50
|
+
return stop('not-identifiable', `${selector} is not an id, class or data attribute, so there is nothing in the source to compare it against. A person should look at the page.`);
|
|
51
|
+
}
|
|
52
|
+
// Git first: somebody renamed it, and the commit says to what. The source is the weaker answer.
|
|
53
|
+
const found = renamedInGit(deps.projectRoot, token) ?? likeItInSource(deps.projectRoot, token, deps.sources);
|
|
54
|
+
if (!found) {
|
|
55
|
+
return stop('no-replacement', `nothing in the source or in the history looks like "${token.value}" renamed. Tewip will not invent a selector.`);
|
|
56
|
+
}
|
|
57
|
+
const evidence = found.how === 'renamed in git'
|
|
58
|
+
? `commit ${found.commit} changed "${token.value}" to "${found.value}" in ${found.file}`
|
|
59
|
+
: `"${token.value}" is nowhere in the source any more, and ${found.file} now has "${found.value}"`;
|
|
60
|
+
result.evidence = evidence;
|
|
61
|
+
result.log.push(evidence);
|
|
62
|
+
const replacement = writeSelector(token, found.value);
|
|
63
|
+
const source = deps.readFile(specFile);
|
|
64
|
+
const sites = findSelectorSites(source, written);
|
|
65
|
+
const site = sites[0];
|
|
66
|
+
if (!site)
|
|
67
|
+
return stop('selector-not-in-test', `${selector} is not written in ${rel}: it comes from a helper or a page object, which a person should change`);
|
|
68
|
+
result.line = site.line;
|
|
69
|
+
result.original = site.text;
|
|
70
|
+
result.replacement = site.text.replace(written, replacement);
|
|
71
|
+
deps.writeFile(specFile, source.replace(site.text, result.replacement));
|
|
72
|
+
try {
|
|
73
|
+
const run = await deps.verify(specFile, title);
|
|
74
|
+
if (!run.passed) {
|
|
75
|
+
deps.writeFile(specFile, source);
|
|
76
|
+
return stop('still-failing', `with ${replacement} the test still failed, so the file was put back: ${run.detail.slice(-200)}`);
|
|
77
|
+
}
|
|
78
|
+
result.log.push(`with ${replacement} the test passes`);
|
|
79
|
+
if (!keep)
|
|
80
|
+
deps.writeFile(specFile, source);
|
|
81
|
+
return { ...result, status: 'fixed', applied: keep };
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
deps.writeFile(specFile, source);
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** The one-line diff a reviewer reads. */
|
|
89
|
+
export function selectorFixDiff(result) {
|
|
90
|
+
if (!result.original || !result.replacement)
|
|
91
|
+
return '';
|
|
92
|
+
return [`--- a/${result.specFile}`, `+++ b/${result.specFile}`, `@@ line ${result.line} @@`, `-${result.original.trim()}`, `+${result.replacement.trim()}`].join('\n');
|
|
93
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Phase 5, for the tools that do not have a page snapshot: Cypress, Selenium and WebdriverIO.
|
|
2
|
+
//
|
|
3
|
+
// Playwright's traces tell Tewip what was on the page, so a drifted locator can be matched against
|
|
4
|
+
// the real elements. A Cypress or Selenium failure in a JUnit report carries none of that - only
|
|
5
|
+
// "Expected to find element: '#submit-btn', but never found it". Inventing a replacement from that
|
|
6
|
+
// would be exactly the kind of guess this project exists not to make.
|
|
7
|
+
//
|
|
8
|
+
// So the answer comes from the repository instead. If a test looks for `#submit-btn` and the app
|
|
9
|
+
// now renders `id="submit-button"`, the new name is in the application source, and git knows which
|
|
10
|
+
// commit renamed it. That is evidence, not a guess - and the fix is still only kept if the test
|
|
11
|
+
// passes afterwards.
|
|
12
|
+
import { execFileSync } from 'node:child_process';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
/** What a CSS or XPath selector is really identifying, when it is something identifiable. */
|
|
15
|
+
export function tokenOf(selector) {
|
|
16
|
+
// Selenium and WebdriverIO name the strategy first: "css selector=#submit-btn", "id=submit-btn".
|
|
17
|
+
const strategy = /^(css selector|css|xpath|id|name|class name|link text|partial link text|tag name)\s*=\s*(.+)$/i.exec(selector.trim());
|
|
18
|
+
const s = (strategy ? strategy[2] : selector).trim();
|
|
19
|
+
if (strategy) {
|
|
20
|
+
const method = strategy[1].toLowerCase();
|
|
21
|
+
if (method === 'id')
|
|
22
|
+
return { kind: 'id', value: s };
|
|
23
|
+
if (method === 'class name')
|
|
24
|
+
return { kind: 'class', value: s.split(/\s+/)[0] };
|
|
25
|
+
if (method === 'link text' || method === 'partial link text')
|
|
26
|
+
return { kind: 'text', value: s };
|
|
27
|
+
}
|
|
28
|
+
const data = /\[\s*([\w-]*data[\w-]*)\s*[~|^$*]?=\s*["']?([^"'\]]+)["']?\s*\]/i.exec(s);
|
|
29
|
+
if (data)
|
|
30
|
+
return { kind: 'data', attribute: data[1], value: data[2] };
|
|
31
|
+
const id = /^#([\w-]+)$|\[\s*id\s*=\s*["']([^"']+)["']\s*\]|@id\s*=\s*["']([^"']+)["']/i.exec(s);
|
|
32
|
+
if (id)
|
|
33
|
+
return { kind: 'id', value: id[1] ?? id[2] ?? id[3] };
|
|
34
|
+
const cls = /^\.([\w-]+)$|\[\s*class\s*=\s*["']([^"']+)["']\s*\]/i.exec(s);
|
|
35
|
+
if (cls)
|
|
36
|
+
return { kind: 'class', value: (cls[1] ?? cls[2]).split(/\s+/)[0] };
|
|
37
|
+
const text = /text\(\)\s*=\s*["']([^"']+)["']|contains\(\s*(?:text\(\)|\.)\s*,\s*["']([^"']+)["']/i.exec(s);
|
|
38
|
+
if (text)
|
|
39
|
+
return { kind: 'text', value: text[1] ?? text[2] };
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** How close two names are, 0 to 1, by the words in them - "submit-btn" and "submit-button". */
|
|
43
|
+
export function closeness(a, b) {
|
|
44
|
+
const words = (s) => new Set(s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
45
|
+
const left = words(a);
|
|
46
|
+
const right = words(b);
|
|
47
|
+
if (left.size === 0 || right.size === 0)
|
|
48
|
+
return 0;
|
|
49
|
+
let shared = 0;
|
|
50
|
+
for (const w of left)
|
|
51
|
+
if (right.has(w))
|
|
52
|
+
shared++;
|
|
53
|
+
// A shared prefix counts too: "submitBtn" and "submitButton" have no word in common.
|
|
54
|
+
const prefix = commonPrefix(a.toLowerCase(), b.toLowerCase()) / Math.max(a.length, b.length);
|
|
55
|
+
return Math.max(shared / Math.max(left.size, right.size), prefix);
|
|
56
|
+
}
|
|
57
|
+
function commonPrefix(a, b) {
|
|
58
|
+
let i = 0;
|
|
59
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
60
|
+
i++;
|
|
61
|
+
return i;
|
|
62
|
+
}
|
|
63
|
+
const git = (root, args) => {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, timeout: 60_000 });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The commit that removed this value, and what took its place on the same line. This is the
|
|
73
|
+
* strongest evidence there is: somebody renamed it, and the diff says to what.
|
|
74
|
+
*/
|
|
75
|
+
export function renamedInGit(root, token, maxCommits = 5) {
|
|
76
|
+
const shas = git(root, ['log', '-S', token.value, '--format=%H', `-n${maxCommits}`, '--', '.'])
|
|
77
|
+
.split('\n')
|
|
78
|
+
.map((s) => s.trim())
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
for (const sha of shas) {
|
|
81
|
+
const diff = git(root, ['show', '--unified=0', '--format=', sha]);
|
|
82
|
+
if (!diff)
|
|
83
|
+
continue;
|
|
84
|
+
let file = '';
|
|
85
|
+
const removed = [];
|
|
86
|
+
const added = [];
|
|
87
|
+
for (const line of diff.split('\n')) {
|
|
88
|
+
if (line.startsWith('+++ b/'))
|
|
89
|
+
file = line.slice(6);
|
|
90
|
+
else if (line.startsWith('-') && line.includes(token.value))
|
|
91
|
+
removed.push(line);
|
|
92
|
+
else if (line.startsWith('+'))
|
|
93
|
+
added.push(line);
|
|
94
|
+
}
|
|
95
|
+
if (removed.length === 0 || added.length === 0)
|
|
96
|
+
continue;
|
|
97
|
+
// The best replacement is the attribute value on an added line that most resembles the old one.
|
|
98
|
+
let best = null;
|
|
99
|
+
for (const line of added) {
|
|
100
|
+
for (const value of attributeValues(line, token)) {
|
|
101
|
+
const score = closeness(token.value, value);
|
|
102
|
+
if (value !== token.value && score >= 0.5 && (!best || score > best.score)) {
|
|
103
|
+
best = { value, file, how: 'renamed in git', commit: sha.slice(0, 8), score };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (best)
|
|
108
|
+
return best;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/** Values of the same kind of attribute written on a line of source. */
|
|
113
|
+
function attributeValues(line, token) {
|
|
114
|
+
const out = [];
|
|
115
|
+
const attribute = token.kind === 'data' ? token.attribute ?? 'data-[\\w-]+' : token.kind === 'id' ? 'id' : token.kind === 'class' ? 'class(?:Name)?' : '';
|
|
116
|
+
if (!attribute)
|
|
117
|
+
return out;
|
|
118
|
+
const pattern = new RegExp(`${attribute}\\s*=\\s*["'\`{]?\\s*["']?([\\w\\s-]+)["']?`, 'gi');
|
|
119
|
+
for (const m of line.matchAll(pattern)) {
|
|
120
|
+
for (const value of m[1].trim().split(/\s+/))
|
|
121
|
+
if (value)
|
|
122
|
+
out.push(value);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The closest value of the same kind anywhere in the application source now. Weaker than git,
|
|
128
|
+
* because nobody wrote down that it is a rename - so it is offered only when it is clearly close.
|
|
129
|
+
*/
|
|
130
|
+
export function likeItInSource(root, token, sources, min = 0.6) {
|
|
131
|
+
let best = null;
|
|
132
|
+
for (const file of sources) {
|
|
133
|
+
let text;
|
|
134
|
+
try {
|
|
135
|
+
text = readFileSync(file, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (text.includes(token.value))
|
|
141
|
+
return null; // it is still there; this is not a rename
|
|
142
|
+
for (const line of text.split('\n')) {
|
|
143
|
+
for (const value of attributeValues(line, token)) {
|
|
144
|
+
const score = closeness(token.value, value);
|
|
145
|
+
if (value !== token.value && score >= min && (!best || score > best.score)) {
|
|
146
|
+
best = { value, file, how: 'found in the source', score };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return best;
|
|
152
|
+
}
|
|
153
|
+
/** The selector written the way each tool writes it, so the fix reads like the rest of the test. */
|
|
154
|
+
export function writeSelector(token, value) {
|
|
155
|
+
if (token.kind === 'id')
|
|
156
|
+
return `#${value}`;
|
|
157
|
+
if (token.kind === 'class')
|
|
158
|
+
return `.${value}`;
|
|
159
|
+
if (token.kind === 'data')
|
|
160
|
+
return `[${token.attribute}="${value}"]`;
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Applying a set of edits to a working tree, and being able to put everything back exactly as it
|
|
2
|
+
// was. Every step of building a feature is provisional until the tests say otherwise, so nothing
|
|
3
|
+
// here writes anything it cannot undo.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { mayChange } from '../autofix/code-target.js';
|
|
7
|
+
import { changedLines } from '../autofix/code-fix.js';
|
|
8
|
+
export class Workspace {
|
|
9
|
+
root;
|
|
10
|
+
touched = new Map();
|
|
11
|
+
constructor(root) {
|
|
12
|
+
this.root = resolve(root);
|
|
13
|
+
}
|
|
14
|
+
/** Remembers a file's contents the first time it is touched, so it can always be restored. */
|
|
15
|
+
remember(path) {
|
|
16
|
+
if (this.touched.has(path))
|
|
17
|
+
return;
|
|
18
|
+
this.touched.set(path, existsSync(path) ? readFileSync(path, 'utf8') : null);
|
|
19
|
+
}
|
|
20
|
+
write(path, content) {
|
|
21
|
+
this.remember(path);
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileSync(path, content, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
/** Everything back as it was: files restored, files Tewip created removed. */
|
|
26
|
+
restore() {
|
|
27
|
+
for (const [path, was] of this.touched) {
|
|
28
|
+
if (was === null)
|
|
29
|
+
rmSync(path, { force: true });
|
|
30
|
+
else
|
|
31
|
+
writeFileSync(path, was, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
this.touched.clear();
|
|
34
|
+
}
|
|
35
|
+
/** Keeps the changes, and forgets how to undo them. */
|
|
36
|
+
keep() {
|
|
37
|
+
this.touched.clear();
|
|
38
|
+
}
|
|
39
|
+
/** What has been touched so far, for the report. */
|
|
40
|
+
files() {
|
|
41
|
+
return [...this.touched.keys()].map((p) => relative(this.root, p).split(sep).join('/'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const CR = String.fromCharCode(13);
|
|
45
|
+
const NL = String.fromCharCode(10);
|
|
46
|
+
/**
|
|
47
|
+
* Applies a model's edits, refusing anything that is not allowed or does not match. Line endings
|
|
48
|
+
* are compared in one form, because a Windows checkout and a model's answer never agree on them.
|
|
49
|
+
*/
|
|
50
|
+
export function applyEdits(workspace, root, edits, maxLines) {
|
|
51
|
+
const applied = [];
|
|
52
|
+
const refused = [];
|
|
53
|
+
for (const edit of edits) {
|
|
54
|
+
const path = resolve(root, edit.file);
|
|
55
|
+
const rel = relative(resolve(root), path).split(sep).join('/');
|
|
56
|
+
if (rel.startsWith('..') || resolve(path).startsWith('..')) {
|
|
57
|
+
refused.push(`${edit.file} is outside the project`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const allowed = mayChange(path);
|
|
61
|
+
if (!allowed.allowed) {
|
|
62
|
+
refused.push(`${rel} may not be changed: ${allowed.why}`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const exists = existsSync(path);
|
|
66
|
+
if (!exists) {
|
|
67
|
+
if (edit.before.trim()) {
|
|
68
|
+
refused.push(`${rel} does not exist, but the change expects text to already be in it`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
workspace.write(path, edit.after);
|
|
72
|
+
applied.push({ ...edit, path, was: null, changed: edit.after.split(NL).length });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const raw = readFileSync(path, 'utf8');
|
|
76
|
+
const crlf = raw.includes(CR + NL);
|
|
77
|
+
const original = crlf ? raw.split(CR + NL).join(NL) : raw;
|
|
78
|
+
const before = edit.before.split(CR + NL).join(NL);
|
|
79
|
+
const after = edit.after.split(CR + NL).join(NL);
|
|
80
|
+
if (!before.trim()) {
|
|
81
|
+
refused.push(`${rel} already exists, so a change to it must say which text to replace`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const occurrences = original.split(before).length - 1;
|
|
85
|
+
if (occurrences !== 1) {
|
|
86
|
+
refused.push(`the text to replace in ${rel} ${occurrences === 0 ? 'is not in the file' : `appears ${occurrences} times`}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const changed = original.replace(before, after);
|
|
90
|
+
const touched = changedLines(original, changed);
|
|
91
|
+
if (touched > maxLines) {
|
|
92
|
+
refused.push(`the change to ${rel} touches ${touched} lines, which is a rewrite rather than a feature`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
workspace.write(path, crlf ? changed.split(NL).join(CR + NL) : changed);
|
|
96
|
+
applied.push({ ...edit, path, was: raw, changed: touched });
|
|
97
|
+
}
|
|
98
|
+
return { applied, refused };
|
|
99
|
+
}
|