@modelstatus/cli 0.1.86 → 0.1.88

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/src/fix.js CHANGED
@@ -9,15 +9,23 @@
9
9
  * - Replace ONLY on the recorded line. If the string is no longer there (file
10
10
  * changed since the scan), the ref is reported "stale" and skipped — never
11
11
  * guess. A rescan refreshes the locations.
12
- * - Boundary-aware matching: "gpt-4" must never rewrite inside "gpt-4o" /
13
- * "gpt-4-turbo". A model-id character ([A-Za-z0-9._-]) on either side of the
14
- * match blocks it.
12
+ * - Boundary-aware matching in LOCKSTEP with detect/core.js (same predicates,
13
+ * same case-insensitivity): "gpt-4" must never rewrite inside "gpt-4o" /
14
+ * "gpt-4-turbo", but provider prefixes ("anthropic.claude-…", "openai/…")
15
+ * are fine on the left — exactly what the scan matched, fix can rewrite.
16
+ * - Version-pinned occurrences ("claude-3-sonnet@20240229", Bedrock "-v1:0",
17
+ * ft ":acme", template "-${ver}") are NEVER base-swapped — the old model's
18
+ * pin glued to the new id is a nonexistent id. They skip with an explicit
19
+ * "pinned variant — review manually" note.
20
+ * - Never corrupt bytes: non-UTF-8 files are skipped loudly, and every line
21
+ * keeps its own \n / \r\n ending (one stray CRLF never converts the file).
15
22
  * - Style-preserving replacement: if the code says "openai/gpt-4" the new id
16
23
  * keeps the provider prefix; if it says "gpt-4" it stays bare.
17
24
  */
18
25
  import fs from "node:fs";
19
26
  import os from "node:os";
20
27
  import path from "node:path";
28
+ import { isTokenChar, isPrefixSep, MODEL_SUFFIX } from "./detect/core.js";
21
29
 
22
30
  /**
23
31
  * Follow a replacement chain to the first CURRENT model. Replacements can
@@ -25,6 +33,12 @@ import path from "node:path";
25
33
  * rewriting to a dying model just means fixing twice — land on the live one.
26
34
  * `getModel(slug)` → model|null; `isCurrent(model)` → bool. Cycle-guarded;
27
35
  * unknown slugs end the walk (best known answer wins).
36
+ *
37
+ * Returns the WRITE-FORM of the target — "provider/canonical_id" when the
38
+ * model is known (e.g. "openai/gpt-5.5"), else the registry slug. Registry
39
+ * slugs dash-encode dots ("openai/gpt-5-5"), and providers reject the slug
40
+ * form ("gpt-5-5" 404s where the API id is "gpt-5.5") — canonical_id is the
41
+ * provider's real API id. styleReplacement() derives both styles from it.
28
42
  */
29
43
  export function terminalReplacement(startSlug, getModel, isCurrent) {
30
44
  let cur = startSlug;
@@ -35,7 +49,14 @@ export function terminalReplacement(startSlug, getModel, isCurrent) {
35
49
  if (!m || isCurrent(m) || !m.replacement_slug) break;
36
50
  cur = m.replacement_slug;
37
51
  }
38
- return cur || startSlug;
52
+ const slug = cur || startSlug;
53
+ const m = getModel(slug);
54
+ if (m?.canonical_id) {
55
+ const provider = m.provider_slug
56
+ || (typeof m.slug === "string" && m.slug.includes("/") ? m.slug.slice(0, m.slug.indexOf("/")) : "");
57
+ return provider ? `${provider}/${m.canonical_id}` : m.canonical_id;
58
+ }
59
+ return slug; // no canonical known — the slug is the best answer we have
39
60
  }
40
61
 
41
62
  /* ------------------------------------------------------------ fix history */
@@ -73,43 +94,94 @@ export function readFixes() {
73
94
  }
74
95
  }
75
96
 
76
- /** The string to write into the file: the replacement slug, styled to match how
77
- * the old id was written (provider-prefixed vs bare). */
78
- export function styleReplacement(oldStr, replacementSlug) {
79
- if (!replacementSlug) return null;
80
- const i = replacementSlug.indexOf("/");
81
- const bare = i >= 0 ? replacementSlug.slice(i + 1) : replacementSlug;
82
- return oldStr.includes("/") ? replacementSlug : bare;
97
+ /** The string to write into the file, styled to match how the old id was
98
+ * written (provider-prefixed vs bare). `replacement` is terminalReplacement's
99
+ * write-form ("provider/canonical_id", e.g. "openai/gpt-5.5") — bare originals
100
+ * get the canonical id after the first "/", prefixed originals the whole thing.
101
+ * (A plain registry slug still styles correctly, just without canonical ids.) */
102
+ export function styleReplacement(oldStr, replacement) {
103
+ if (!replacement) return null;
104
+ const i = replacement.indexOf("/");
105
+ const bare = i >= 0 ? replacement.slice(i + 1) : replacement;
106
+ return oldStr.includes("/") ? replacement : bare;
83
107
  }
84
108
 
85
- const BOUND = "[A-Za-z0-9._-]";
86
- const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
87
-
88
- /** Boundary-safe replace of every occurrence of `from` in one line of text.
89
- * Returns { out, n } the rewritten line and how many occurrences changed. */
109
+ /** Boundary-safe replace of every occurrence of `from` in one line of text,
110
+ * with the SAME boundary semantics (and case-insensitivity) as detection —
111
+ * whatever the scan matched, this can find. Occurrences whose right context is
112
+ * a version pin of the OLD model ("@20240229", "-v1:0", ":acme", "-${ver}")
113
+ * are never base-swapped (the old pin glued to the new id is a nonexistent
114
+ * id); they're returned in `pinned` so callers can report them honestly.
115
+ * Returns { out, n, pinned } — the rewritten line, how many occurrences
116
+ * changed, and the pinned occurrences (with a bit of right context). */
90
117
  export function replaceOnLine(lineText, from, to) {
91
- const re = new RegExp(`(?<!${BOUND})${escRe(from)}(?!${BOUND})`, "g");
118
+ const lower = lineText.toLowerCase();
119
+ const f = String(from ?? "").toLowerCase();
120
+ let out = "";
121
+ let last = 0;
92
122
  let n = 0;
93
- const out = lineText.replace(re, () => {
94
- n += 1;
95
- return to;
96
- });
97
- return { out, n };
123
+ const pinned = [];
124
+ let at = 0;
125
+ while (f && (at = lower.indexOf(f, at)) >= 0) {
126
+ const before = at > 0 ? lower[at - 1] : "";
127
+ if (before && isTokenChar(before) && !isPrefixSep(before)) {
128
+ at += 1; // inside a longer identifier — a later occurrence may be clean
129
+ continue;
130
+ }
131
+ const rest = lower.slice(at + f.length);
132
+ const after = rest[0] ?? "";
133
+ // Trailing separators the generic detector trims ("claude-3-5-sonnet-${ver}",
134
+ // "gpt-4."): a [-._]+ run NOT followed by more token chars.
135
+ const trimmedTail = rest.replace(/^[-._]+/, "");
136
+ const isPinned =
137
+ (after === "@" && /^@[a-z0-9]/.test(rest)) || // Vertex-style @-pin
138
+ (isTokenChar(after) && MODEL_SUFFIX.test(rest)) || // ':0' / '-v1' / '-20240229'
139
+ (/^[-._]/.test(rest) && !(trimmedTail && isTokenChar(trimmedTail[0])));
140
+ if (isPinned) {
141
+ const ctx = /^[^\s"'`]{0,16}/.exec(lineText.slice(at + f.length))?.[0] ?? "";
142
+ pinned.push(lineText.slice(at, at + f.length) + ctx);
143
+ at += f.length;
144
+ continue;
145
+ }
146
+ if (after === "" || !isTokenChar(after)) {
147
+ out += lineText.slice(last, at) + to;
148
+ last = at + f.length;
149
+ n += 1;
150
+ at += f.length;
151
+ continue;
152
+ }
153
+ at += 1; // embedded in a longer id (gpt-4 in gpt-4o) — keep looking
154
+ }
155
+ return { out: out + lineText.slice(last), n, pinned };
156
+ }
157
+
158
+ /** The skip reason for a line where nothing was rewritten. */
159
+ function skipError(pinned) {
160
+ return pinned.length
161
+ ? `pinned variant "${pinned[0]}" — review manually`
162
+ : "string not on that line anymore — rescan";
163
+ }
164
+
165
+ /** Decode a buffer as UTF-8 only if it round-trips byte-for-byte — otherwise
166
+ * null (writing a lossy decode back would corrupt the file: 0xE9 → U+FFFD). */
167
+ function decodeUtf8Strict(buf) {
168
+ const text = buf.toString("utf8");
169
+ return Buffer.from(text, "utf8").equals(buf) ? text : null;
98
170
  }
99
171
 
100
172
  /**
101
173
  * Build fix plans from scan refs. `refs` = [{ model_string, source_path,
102
- * source_line }] (the shape the scanner emits); `replacementSlug` is the
103
- * registry replacement for the model those refs belong to. Refs without a real
104
- * file path (integration scheme labels like vercel://) are skipped only
105
- * filesystem refs are rewritable.
174
+ * source_line }] (the shape the scanner emits); `replacement` is
175
+ * terminalReplacement's write-form ("provider/canonical_id") for the model
176
+ * those refs belong to. Refs without a real file path (integration scheme
177
+ * labels like vercel://) are skipped — only filesystem refs are rewritable.
106
178
  */
107
- export function planFixes(refs, replacementSlug) {
179
+ export function planFixes(refs, replacement) {
108
180
  const plans = [];
109
181
  const seen = new Set();
110
182
  for (const r of refs || []) {
111
183
  if (!r?.source_path || !r.source_line || !r.model_string) continue;
112
- const to = styleReplacement(r.model_string, replacementSlug);
184
+ const to = styleReplacement(r.model_string, replacement);
113
185
  if (!to || to === r.model_string) continue;
114
186
  const key = `${r.source_path}:${r.source_line}:${r.model_string}`;
115
187
  if (seen.has(key)) continue;
@@ -133,24 +205,29 @@ export function planFixes(refs, replacementSlug) {
133
205
  export function previewFixes(dir, plans) {
134
206
  const previews = [];
135
207
  const stale = [];
136
- const cache = new Map(); // file -> lines[] | null
208
+ const cache = new Map(); // file -> { lines } | { error } | {}
137
209
  for (const p of plans) {
138
210
  if (!cache.has(p.file)) {
211
+ let entry = {}; // unreadable → "file or line missing" below
139
212
  try {
140
- cache.set(p.file, fs.readFileSync(path.resolve(dir, p.file), "utf8").split(/\r?\n/));
141
- } catch {
142
- cache.set(p.file, null);
143
- }
213
+ const text = decodeUtf8Strict(fs.readFileSync(path.resolve(dir, p.file)));
214
+ entry = text == null ? { error: "not valid UTF-8 — skipped" } : { lines: text.split(/\r?\n/) };
215
+ } catch { /* keep {} */ }
216
+ cache.set(p.file, entry);
144
217
  }
145
- const lines = cache.get(p.file);
146
- const before = lines?.[p.line - 1];
218
+ const ent = cache.get(p.file);
219
+ if (ent.error) {
220
+ stale.push({ ...p, error: ent.error });
221
+ continue;
222
+ }
223
+ const before = ent.lines?.[p.line - 1];
147
224
  if (before == null) {
148
225
  stale.push({ ...p, error: "file or line missing — rescan" });
149
226
  continue;
150
227
  }
151
- const { out, n } = replaceOnLine(before, p.from, p.to);
228
+ const { out, n, pinned } = replaceOnLine(before, p.from, p.to);
152
229
  if (n === 0) {
153
- stale.push({ ...p, error: "string not on that line anymore — rescan" });
230
+ stale.push({ ...p, error: skipError(pinned) });
154
231
  continue;
155
232
  }
156
233
  previews.push({ ...p, before, after: out });
@@ -177,13 +254,20 @@ export function applyFixes(dir, plans) {
177
254
  const abs = path.resolve(dir, file);
178
255
  let text;
179
256
  try {
180
- text = fs.readFileSync(abs, "utf8");
257
+ text = decodeUtf8Strict(fs.readFileSync(abs));
258
+ if (text == null) {
259
+ // A lossy decode written back would corrupt every non-UTF-8 byte in
260
+ // the file (0xE9 → U+FFFD) — refuse loudly instead.
261
+ for (const p of filePlans) failed.push({ ...p, error: "not valid UTF-8 — skipped" });
262
+ continue;
263
+ }
181
264
  } catch (e) {
182
265
  for (const p of filePlans) failed.push({ ...p, error: e.code === "ENOENT" ? "file not found" : e.message });
183
266
  continue;
184
267
  }
185
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
186
- const lines = text.split(/\r?\n/);
268
+ // Split KEEPING each line's own terminator so untouched lines round-trip
269
+ // byte-identical one stray CRLF line must never convert the whole file.
270
+ const lines = text.split(/(?<=\n)/);
187
271
  let dirty = false;
188
272
  for (const p of filePlans) {
189
273
  const idx = p.line - 1;
@@ -191,19 +275,21 @@ export function applyFixes(dir, plans) {
191
275
  stale.push({ ...p, error: "line out of range — rescan" });
192
276
  continue;
193
277
  }
194
- const before = lines[idx];
195
- const { out, n } = replaceOnLine(before, p.from, p.to);
278
+ const raw = lines[idx];
279
+ const nl = /\r?\n$/.exec(raw)?.[0] ?? "";
280
+ const body = nl ? raw.slice(0, -nl.length) : raw;
281
+ const { out, n, pinned } = replaceOnLine(body, p.from, p.to);
196
282
  if (n === 0) {
197
- stale.push({ ...p, error: "string not on that line anymore — rescan" });
283
+ stale.push({ ...p, error: skipError(pinned) });
198
284
  continue;
199
285
  }
200
- lines[idx] = out;
286
+ lines[idx] = out + nl;
201
287
  dirty = true;
202
- applied.push({ ...p, count: n, before, after: out });
288
+ applied.push({ ...p, count: n, before: body, after: out });
203
289
  }
204
290
  if (dirty) {
205
291
  try {
206
- fs.writeFileSync(abs, lines.join(eol));
292
+ fs.writeFileSync(abs, lines.join(""));
207
293
  } catch (e) {
208
294
  // Roll the bookkeeping back: everything in this file actually failed.
209
295
  for (let i = applied.length - 1; i >= 0; i--) {