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, 'utf8') : null);
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, original);
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
- const original = readFileSync(full, 'utf8');
83
- const stopGuarding = guardAgainstBeingKilled(full, original);
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, original);
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, 'utf8');
172
- if (readFileSync(full, 'utf8') !== original) {
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
- const host = new URL(url).hostname;
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.0",
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": [