@modelstatus/cli 0.1.85 → 0.1.87

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelstatus/cli",
3
- "version": "0.1.85",
3
+ "version": "0.1.87",
4
4
  "description": "Track which AI models you use, where, and never get surprised by a retirement. Free offline model-health for any repo (mm status), browser sign-in for cloud inventory + alerts.",
5
5
  "keywords": [
6
6
  "llm",
@@ -22,7 +22,8 @@
22
22
  ],
23
23
  "homepage": "https://llmstatus.ai",
24
24
  "bugs": {
25
- "email": "it@llmstatus.ai"
25
+ "url": "https://github.com/randomartifact/llm-status/issues",
26
+ "email": "hi@llmstatus.ai"
26
27
  },
27
28
  "author": "LLM Status <it@llmstatus.ai>",
28
29
  "license": "MIT",
@@ -51,5 +52,9 @@
51
52
  },
52
53
  "devDependencies": {
53
54
  "ink-testing-library": "^4.0.0"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/randomartifact/llm-status.git"
54
59
  }
55
60
  }
package/src/api.js CHANGED
@@ -1,14 +1,35 @@
1
1
  /** Thin client for the LLM Status / Model Manager public API (/api/v1). */
2
+
3
+ // Per-request cap. Without one, a connection that stalls after the TCP accept
4
+ // (captive portal, blackholing proxy) holds a command for undici's ~5-minute
5
+ // default — or forever if the server dribbles bytes. Uploads get a longer
6
+ // leash (big inventories on slow links). Overridable for tests.
7
+ const timeoutMs = () => Number(process.env.MM_API_TIMEOUT_MS) || 15_000;
8
+ const uploadTimeoutMs = () => Number(process.env.MM_API_UPLOAD_TIMEOUT_MS) || Number(process.env.MM_API_TIMEOUT_MS) || 60_000;
9
+
10
+ const isTimeout = (e) => e?.name === "TimeoutError" || e?.name === "AbortError" || e?.code === "ABORT_ERR" || e?.cause?.name === "TimeoutError";
11
+
2
12
  export function createClient({ apiBase, apiKey }) {
3
- async function req(method, pathname, body) {
4
- const res = await fetch(`${apiBase}/api/v1${pathname}`, {
5
- method,
6
- headers: {
7
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
8
- ...(body ? { "content-type": "application/json" } : {}),
9
- },
10
- body: body ? JSON.stringify(body) : undefined,
11
- });
13
+ async function req(method, pathname, body, { timeout = timeoutMs() } = {}) {
14
+ let res;
15
+ try {
16
+ res = await fetch(`${apiBase}/api/v1${pathname}`, {
17
+ method,
18
+ headers: {
19
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
20
+ ...(body ? { "content-type": "application/json" } : {}),
21
+ },
22
+ body: body ? JSON.stringify(body) : undefined,
23
+ signal: AbortSignal.timeout(timeout),
24
+ });
25
+ } catch (e) {
26
+ if (isTimeout(e)) {
27
+ const err = new Error(`request timed out — check your network (${method} ${pathname} @ ${apiBase})`);
28
+ err.code = "ETIMEDOUT";
29
+ throw err;
30
+ }
31
+ throw e;
32
+ }
12
33
  const text = await res.text();
13
34
  let data;
14
35
  try {
@@ -18,7 +39,16 @@ export function createClient({ apiBase, apiKey }) {
18
39
  }
19
40
  if (!res.ok) {
20
41
  const msg = data?.error?.message || data?.message || `HTTP ${res.status}`;
21
- const err = new Error(`${method} ${pathname} ${msg}`);
42
+ // Auth failures get a re-login hint instead of a raw endpoint error —
43
+ // a revoked/rotated key is the most common cloud failure. 403 (valid key,
44
+ // insufficient scope/permissions) keeps the server's explanation.
45
+ const err = new Error(
46
+ res.status === 401
47
+ ? "API key invalid or expired — run `mm login` (or pass --key)"
48
+ : res.status === 403
49
+ ? `${msg} — run \`mm login\` to sign in with a different key`
50
+ : `${method} ${pathname} → ${msg}`,
51
+ );
22
52
  err.status = res.status;
23
53
  err.code = data?.error?.code;
24
54
  err.body = data;
@@ -62,7 +92,7 @@ export function createClient({ apiBase, apiKey }) {
62
92
  patchUsage: (id, body) => req("PATCH", `/usages/${id}`, body),
63
93
  deleteUsage: (id) => req("DELETE", `/usages/${id}`),
64
94
  linkUsage: (id, modelId) => req("POST", `/usages/${id}/link`, { model_id: modelId }),
65
- bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }),
95
+ bulkUpload: (projectId, usages) => req("POST", "/usages/bulk", { project_id: projectId, usages }, { timeout: uploadTimeoutMs() }),
66
96
  // Clear inventory: all usages, or `{ all: true }` to also wipe projects + rules + feed.
67
97
  clearUsages: ({ all = false, project } = {}) => req("DELETE", `/usages${qs({ all: all ? "true" : undefined, project_id: project })}`),
68
98
 
@@ -1,6 +1,29 @@
1
1
  /* GENERATED by scripts/gen-changelog.mjs from apps/web/lib/changelog.json — do not edit.
2
2
  * Release notes baked into the binary (in-TUI + the on-load what's-new card). */
3
3
  export const CHANGELOG = [
4
+ {
5
+ "version": "0.1.87",
6
+ "date": "2026-07-07",
7
+ "title": "Hardening pass: 40 fixes from an adversarial audit",
8
+ "items": [
9
+ "`mm fix` now writes the provider’s real model id (`gpt-5.5`), not the registry’s dash-form slug (`gpt-5-5`) — 59 of 85 replacement targets were affected. It also fixes everything the scanner detects (Bedrock ARNs, dated variants, mixed case), preserves your file’s line endings, and refuses to touch files that aren’t valid UTF-8.",
10
+ "The CI gate can’t pass by accident anymore: `--fail-on=retiring` (equals-style flags) now parses, a typo’d `--sources` or a path that doesn’t exist is a hard error instead of a silent green build, and withdrawn models fail the gate like retired ones. Unknown flags error out instead of being ignored.",
11
+ "`mm scan --dry-run` is fully local — no account needed, nothing sent anywhere.",
12
+ "Every network call has a timeout: a stalled CDN or API can’t hang a command for minutes. Failed self-updates back off instead of re-downloading each run, and self-update works on Windows.",
13
+ "Vendor CLI failures (aws, vercel, supabase…) warn instead of silently reporting a clean scan. `.github/workflows` files are scanned now, and `--vercel-project` verifies the linked project instead of relabeling output.",
14
+ "Telemetry no longer includes your working directory path — anonymous event names only, as documented.",
15
+ "TUI: long alerts lists scroll properly (keys can’t act on an off-screen rule, delete asks first), the arcade game can’t strand your terminal or undo a sign-in, and checkout polling stops when you quit."
16
+ ]
17
+ },
18
+ {
19
+ "version": "0.1.86",
20
+ "date": "2026-06-16",
21
+ "title": "Search the What's New feed",
22
+ "items": [
23
+ "The What's New tab now has `/` search, like Scan and Inventory. Filter the registry feed, alerts, fixes, and releases by typing — e.g. `deprecated` to see just the deprecations, or a model or provider name to jump to a specific change.",
24
+ "Press `/` to start filtering, `esc` to clear; the count of matches shows as you type."
25
+ ]
26
+ },
4
27
  {
5
28
  "version": "0.1.85",
6
29
  "date": "2026-06-14",
package/src/ci.js CHANGED
@@ -9,10 +9,14 @@ import { getRegistry } from "./registry/fetch.js";
9
9
  import { resolveLocal, computeHealth } from "./registry/local.js";
10
10
  import { collectFrom } from "./sources/index.js";
11
11
 
12
- export const HEALTH_RANK = { ok: 0, deprecating: 1, retiring: 2, retired: 3 };
12
+ // "withdrawn" (the provider already pulled the model) is as dead as retired
13
+ // same rank, mirroring tui/scan-stream.js — so it fails every threshold except
14
+ // "none". Omitting it here once meant `undefined >= threshold` and withdrawn
15
+ // models could NEVER fail the build.
16
+ export const HEALTH_RANK = { ok: 0, deprecating: 1, retiring: 2, retired: 3, withdrawn: 3 };
13
17
  // `--fail-on` threshold → the minimum health rank that fails the build.
14
18
  export const FAIL_THRESHOLD = { none: 99, deprecating: 1, retiring: 2, retired: 3 };
15
- const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴" };
19
+ const BADGE = { ok: "🟢", deprecating: "🟡", retiring: "🟠", retired: "🔴", withdrawn: "⛔" };
16
20
 
17
21
  /** Evaluate `dir` and return { findings, failing, threshold, failOn, counts, snapshot }.
18
22
  * `findings` are per-(model, location) entries with health worse than ok. */
@@ -54,7 +58,7 @@ export async function evaluateCi({ dir, sources = ["filesystem"], explicit = new
54
58
  }
55
59
  findings.sort((a, b) => HEALTH_RANK[b.health] - HEALTH_RANK[a.health] || String(a.retires || "9999").localeCompare(String(b.retires || "9999")));
56
60
 
57
- const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0 };
61
+ const counts = { ok: 0, deprecating: 0, retiring: 0, retired: 0, withdrawn: 0 };
58
62
  for (const f of findings) counts[f.health]++;
59
63
  const failing = findings.filter((f) => HEALTH_RANK[f.health] >= threshold);
60
64
  return { snapshot, candidates, findings, failing, threshold, failOn, counts };
@@ -126,7 +130,7 @@ export function annotationLines(findings, threshold) {
126
130
  export function summaryMarkdown(findings, { failing, failOn } = {}) {
127
131
  const lines = ["## LLM Status — model lifecycle check", ""];
128
132
  if (!findings.length) {
129
- lines.push("✅ No deprecated, retiring, or retired AI models found.");
133
+ lines.push("✅ No deprecated, retiring, retired, or withdrawn AI models found.");
130
134
  return lines.join("\n") + "\n";
131
135
  }
132
136
  const fc = failing ? failing.length : 0;
@@ -16,21 +16,22 @@ function cleanGeneric(s) {
16
16
 
17
17
  /** A char is part of a model token if it's alnum or one of - _ . / : (slug-ish).
18
18
  * Mirrors the server PR scanner (apps/web/lib/github/scan-pr.ts) so the CLI and
19
- * the GitHub check agree on what counts as a boundary. */
20
- function isTokenChar(ch) {
19
+ * the GitHub check agree on what counts as a boundary. Exported so fix.js keeps
20
+ * its rewrite boundaries in lockstep with detection. */
21
+ export function isTokenChar(ch) {
21
22
  return /[A-Za-z0-9._/:-]/.test(ch);
22
23
  }
23
24
 
24
25
  // Provider prefixes legitimately precede an id ("anthropic.claude-…", "ft:gpt-…",
25
26
  // "us.anthropic.…", "openrouter/…"), so '.' ':' '/' on the LEFT is still a boundary.
26
- function isPrefixSep(ch) {
27
+ export function isPrefixSep(ch) {
27
28
  return ch === "." || ch === ":" || ch === "/";
28
29
  }
29
30
  // Known model-id SUFFIXES seen in real configs: Bedrock ':0'/'-v1', dated
30
31
  // '-20250514' snapshots, '@version'. The remainder starting with one is still a
31
32
  // boundary — so "claude-opus-4-20250514" resolves inside a Bedrock ARN, while
32
33
  // "gpt-4" still does NOT match inside "gpt-4o". Kept identical to scan-pr.ts.
33
- const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
34
+ export const MODEL_SUFFIX = /^(:|-v[0-9]|-[0-9]{6,}|@)/;
34
35
 
35
36
  /** True when `term` occurs in `haystack` at a model-id boundary — tolerating
36
37
  * provider prefixes + known version/region/snapshot suffixes, but NOT a plain
@@ -135,8 +136,18 @@ export function detectInLine(line, compiled) {
135
136
  re.lastIndex = 0;
136
137
  let m;
137
138
  while ((m = re.exec(line))) {
138
- const cand = cleanGeneric(m[0].toLowerCase());
139
- if (looksLikeModel(cand)) found.add(cand);
139
+ // Left boundary — same rule as matchesAtBoundary. A token char right
140
+ // before the match means we're inside a longer identifier ("ChatGPT-4",
141
+ // "ngrok-2.3.40", "my-gpt-4-deployment"), not a model id; provider
142
+ // prefixes ('.' ':' '/') stay legal ("openai/gpt-4", "ft:gpt-4",
143
+ // "anthropic.claude-…"). Hyphen/underscore-joined prefixes are treated
144
+ // as compound identifiers (resource/deployment names), matching the
145
+ // exact-string matcher. (scan-pr.ts should mirror this — see web repo.)
146
+ const before = m.index > 0 ? line[m.index - 1] : "";
147
+ if (!before || !isTokenChar(before) || isPrefixSep(before)) {
148
+ const cand = cleanGeneric(m[0].toLowerCase());
149
+ if (looksLikeModel(cand)) found.add(cand);
150
+ }
140
151
  if (re.lastIndex === m.index) re.lastIndex++;
141
152
  }
142
153
  }
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--) {