tewip-ai 0.70.0 → 0.70.2
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.
|
@@ -28,6 +28,14 @@ export const MAX_CHANGED_LINES = 20;
|
|
|
28
28
|
/** How many times the model may try before Tewip gives up and explains instead. */
|
|
29
29
|
export const MAX_ATTEMPTS = 3;
|
|
30
30
|
const CR = String.fromCharCode(13);
|
|
31
|
+
/**
|
|
32
|
+
* Does this text come back as the same text after a trip through UTF-8?
|
|
33
|
+
*
|
|
34
|
+
* The caller hands over a string already decoded from disk, so a byte that was not valid UTF-8 is
|
|
35
|
+
* already a U+FFFD by the time it gets here - and that replacement character is the tell. Nothing
|
|
36
|
+
* legitimate in source code contains it.
|
|
37
|
+
*/
|
|
38
|
+
const roundTrips = (text) => !text.includes(String.fromCharCode(0xfffd));
|
|
31
39
|
const NL = String.fromCharCode(10);
|
|
32
40
|
/**
|
|
33
41
|
* Which other tests a change broke, by name. A tail of runner output is mostly noise; the names of
|
|
@@ -132,6 +140,14 @@ async function tryFile(target, input, deps, { keep, result, alreadyFailing }) {
|
|
|
132
140
|
// threw away four correct fixes in a real measurement, so both sides are read in LF here and the
|
|
133
141
|
// file is written back in whatever it used.
|
|
134
142
|
const raw = deps.readFile(target.file);
|
|
143
|
+
// A file whose text does not survive a round trip through UTF-8 cannot be written back as it was,
|
|
144
|
+
// and every failure path here writes `raw` back. A legacy latin-1 source file - `// caf\xe9`, which
|
|
145
|
+
// older codebases are full of - would be returned re-encoded, three bytes where one had been, the
|
|
146
|
+
// character destroyed and nothing reported. Refusing is the only honest option: a fix that cannot
|
|
147
|
+
// be undone is not a fix Tewip is willing to try.
|
|
148
|
+
if (!roundTrips(raw)) {
|
|
149
|
+
return stop('cannot-restore', `${rel} is not valid UTF-8, so Tewip cannot change it and put it back exactly as it was. Nothing was written. Convert the file to UTF-8 if you want it fixed here.`);
|
|
150
|
+
}
|
|
135
151
|
const crlf = raw.includes(CR + NL);
|
|
136
152
|
const asFile = (text) => (crlf ? text.split(NL).join(CR + NL) : text);
|
|
137
153
|
const original = crlf ? raw.split(CR + NL).join(NL) : raw;
|
|
@@ -7,6 +7,14 @@ import { mayChange } from '../autofix/code-target.js';
|
|
|
7
7
|
import { changedLines } from '../autofix/code-fix.js';
|
|
8
8
|
export class Workspace {
|
|
9
9
|
root;
|
|
10
|
+
/**
|
|
11
|
+
* What each touched file held, as **bytes**.
|
|
12
|
+
*
|
|
13
|
+
* It was a utf8 string, and "put back byte for byte" was then only true of files that are valid
|
|
14
|
+
* UTF-8. A legacy latin-1 source file - `// caf\xe9`, which older codebases are full of - was
|
|
15
|
+
* decoded to U+FFFD and written back as three different bytes: 16 bytes in, 18 bytes out, the
|
|
16
|
+
* character destroyed, and nothing reported. Measured, not reasoned about.
|
|
17
|
+
*/
|
|
10
18
|
touched = new Map();
|
|
11
19
|
constructor(root) {
|
|
12
20
|
this.root = resolve(root);
|
|
@@ -15,7 +23,7 @@ export class Workspace {
|
|
|
15
23
|
remember(path) {
|
|
16
24
|
if (this.touched.has(path))
|
|
17
25
|
return;
|
|
18
|
-
this.touched.set(path, existsSync(path) ? readFileSync(path
|
|
26
|
+
this.touched.set(path, existsSync(path) ? readFileSync(path) : null);
|
|
19
27
|
}
|
|
20
28
|
write(path, content) {
|
|
21
29
|
this.remember(path);
|
|
@@ -53,7 +53,7 @@ function stillParses(full) {
|
|
|
53
53
|
* same control the write gate uses: a check that cannot fail proves nothing, and neither does a test
|
|
54
54
|
* that cannot see the code.
|
|
55
55
|
*/
|
|
56
|
-
function testsLoadIt(root, full, original, test, framework, runMs) {
|
|
56
|
+
function testsLoadIt(root, full, original, originalBytes, test, framework, runMs) {
|
|
57
57
|
const control = /\.py$/i.test(full)
|
|
58
58
|
? `raise RuntimeError("tewip control")${String.fromCharCode(10)}${original}`
|
|
59
59
|
: /\.(js|cjs|mjs|ts|tsx|mts|cts|jsx)$/i.test(full)
|
|
@@ -64,7 +64,7 @@ function testsLoadIt(root, full, original, test, framework, runMs) {
|
|
|
64
64
|
return !runTest(framework, resolve(root, test), runMs).passed;
|
|
65
65
|
}
|
|
66
66
|
finally {
|
|
67
|
-
restore(full,
|
|
67
|
+
restore(full, originalBytes);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
export function proveFile(root, file, covering, framework, opts = {}) {
|
|
@@ -79,8 +79,21 @@ export function proveFile(root, file, covering, framework, opts = {}) {
|
|
|
79
79
|
proof.blocked = 'no test imports this file, so no test could notice any change to it. That is the finding: this code is not covered by anything Tewip can name.';
|
|
80
80
|
return finish(proof, started);
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
// Bytes for putting it back, text for making the mutants - and a refusal when the two cannot be
|
|
83
|
+
// reconciled. Holding only the utf8 string meant a file that is not valid UTF-8 was written back
|
|
84
|
+
// re-encoded: a latin-1 `// caf\xe9` came back 18 bytes instead of 16, with the character
|
|
85
|
+
// destroyed. Worse, `restore` verified its own work by comparing the **decoded strings**, which
|
|
86
|
+
// were equally lossy on both sides, so it reported success every time. A check that cannot fail
|
|
87
|
+
// proves nothing - this project's own rule, inside the function written to enforce it.
|
|
88
|
+
const originalBytes = readFileSync(full);
|
|
89
|
+
const original = originalBytes.toString('utf8');
|
|
90
|
+
if (!Buffer.from(original, 'utf8').equals(originalBytes)) {
|
|
91
|
+
proof.blocked =
|
|
92
|
+
'this file is not valid UTF-8, so Tewip cannot change it and put it back exactly as it was. Nothing was written. ' +
|
|
93
|
+
'Convert the file to UTF-8 if you want it proved; a mutation Tewip cannot undo is not worth the answer.';
|
|
94
|
+
return finish(proof, started);
|
|
95
|
+
}
|
|
96
|
+
const stopGuarding = guardAgainstBeingKilled(full, originalBytes);
|
|
84
97
|
// The whole set first, then the sample: `found` is what the file allows and `mutants` is what
|
|
85
98
|
// there was time to ask about, and the report has to be able to tell the reader the difference.
|
|
86
99
|
const candidates = candidateMutants(original);
|
|
@@ -114,7 +127,7 @@ export function proveFile(root, file, covering, framework, opts = {}) {
|
|
|
114
127
|
// The control: break the file outright and check that a covering test notices. Without this, a file
|
|
115
128
|
// the tests never load would produce a full report of things they "failed to catch".
|
|
116
129
|
say('checking the tests really load this file');
|
|
117
|
-
if (!testsLoadIt(root, full, original, usable[0], framework, runMs)) {
|
|
130
|
+
if (!testsLoadIt(root, full, original, originalBytes, usable[0], framework, runMs)) {
|
|
118
131
|
proof.blocked = `${usable[0]} passes even when this file is made unusable, so the tests are not loading the file that was edited - they are reading an installed copy or a build output. Nothing here can be proved until they run against this source (for a Python src layout, install it in editable mode: pip install -e .).`;
|
|
119
132
|
return finish(proof, started, stopGuarding);
|
|
120
133
|
}
|
|
@@ -157,7 +170,7 @@ export function proveFile(root, file, covering, framework, opts = {}) {
|
|
|
157
170
|
noticedBy = error instanceof Error ? error.message.slice(0, 120) : '';
|
|
158
171
|
}
|
|
159
172
|
finally {
|
|
160
|
-
restore(full,
|
|
173
|
+
restore(full, originalBytes);
|
|
161
174
|
}
|
|
162
175
|
proof.tried.push({ mutant, fate, noticedBy, seconds: Math.round((Date.now() - at) / 100) / 10 });
|
|
163
176
|
}
|
|
@@ -168,8 +181,8 @@ export function proveFile(root, file, covering, framework, opts = {}) {
|
|
|
168
181
|
* leave somebody's source broken, which would make it unusable however good its answers were.
|
|
169
182
|
*/
|
|
170
183
|
function restore(full, original) {
|
|
171
|
-
writeFileSync(full, original
|
|
172
|
-
if (readFileSync(full
|
|
184
|
+
writeFileSync(full, original);
|
|
185
|
+
if (!readFileSync(full).equals(original)) {
|
|
173
186
|
throw new Error(`${full} could not be put back as it was. Check it before doing anything else: git checkout -- "${full}"`);
|
|
174
187
|
}
|
|
175
188
|
}
|
|
@@ -169,12 +169,54 @@ export async function scanWeb(url, { timeoutMs = 15_000, probeExposed = true, pa
|
|
|
169
169
|
return { findings, requests, note: null };
|
|
170
170
|
}
|
|
171
171
|
/** Localhost, a private address, or a name only this machine resolves. */
|
|
172
|
+
/** The four octets of a dotted IPv4 address, or null for anything else - including a name. */
|
|
173
|
+
function ipv4(host) {
|
|
174
|
+
const parts = host.split('.');
|
|
175
|
+
if (parts.length !== 4)
|
|
176
|
+
return null;
|
|
177
|
+
const numbers = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : -1));
|
|
178
|
+
return numbers.every((n) => n >= 0 && n <= 255) ? numbers : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Is this address the person's own machine or network?
|
|
182
|
+
*
|
|
183
|
+
* **This is a consent control, not a convenience.** `secure --url` refuses a non-local address
|
|
184
|
+
* without `--i-am-authorised`, because scanning a system you do not run is not Tewip's decision to
|
|
185
|
+
* make - so anything this returns `true` for is scanned with no questions asked.
|
|
186
|
+
*
|
|
187
|
+
* It was a prefix match, and five hostnames walked straight past it: `localhost.evil.example`,
|
|
188
|
+
* `localhostile.example`, `10.evil.example`, `127.0.0.1.evil.example` and `172.16.evil.example` were
|
|
189
|
+
* all "this machine", because each *starts with* something local-looking. `localhost.<anything>` is
|
|
190
|
+
* one of the oldest allowlist bypasses there is, and a careless paste reaches it as easily as a
|
|
191
|
+
* malicious one.
|
|
192
|
+
*
|
|
193
|
+
* So a name is local only if it **is** `localhost` or ends in a reserved local suffix, and an address
|
|
194
|
+
* is local only if its octets really fall in a private range. Link-local 169.254/16 is deliberately
|
|
195
|
+
* **not** local: `169.254.169.254` is the cloud metadata endpoint, and asking before touching it is
|
|
196
|
+
* exactly the point of this function.
|
|
197
|
+
*/
|
|
172
198
|
export function isLocal(url) {
|
|
199
|
+
let host;
|
|
173
200
|
try {
|
|
174
|
-
|
|
175
|
-
return /^(?:localhost|127\.|0\.0\.0\.0|\[::1\]|::1|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(host) || host.endsWith('.local') || host.endsWith('.localhost');
|
|
201
|
+
host = new URL(url).hostname.toLowerCase();
|
|
176
202
|
}
|
|
177
203
|
catch {
|
|
178
204
|
return false;
|
|
179
205
|
}
|
|
206
|
+
// `new URL('http://[::1]').hostname` is "[::1]" in Node, so both spellings are accepted.
|
|
207
|
+
if (host === 'localhost' || host === '::1' || host === '[::1]')
|
|
208
|
+
return true;
|
|
209
|
+
if (host.endsWith('.localhost') || host.endsWith('.local'))
|
|
210
|
+
return true;
|
|
211
|
+
const octets = ipv4(host);
|
|
212
|
+
if (!octets)
|
|
213
|
+
return false;
|
|
214
|
+
const [a, b] = octets;
|
|
215
|
+
if (a === 0 || a === 127)
|
|
216
|
+
return true; // 0.0.0.0 and the whole loopback range
|
|
217
|
+
if (a === 10)
|
|
218
|
+
return true;
|
|
219
|
+
if (a === 192 && b === 168)
|
|
220
|
+
return true;
|
|
221
|
+
return a === 172 && b >= 16 && b <= 31;
|
|
180
222
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.70.
|
|
3
|
+
"version": "0.70.2",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|