tewip-ai 0.70.1 → 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.70.1",
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": [