@shipispec/tsfix 0.5.0 → 0.6.1

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/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // cli/run-stack.ts
4
- import * as path8 from "node:path";
5
- import * as fs8 from "node:fs";
4
+ import * as path9 from "node:path";
5
+ import * as fs9 from "node:fs";
6
6
 
7
7
  // src/validatorInProcess.ts
8
8
  import * as fs from "node:fs";
@@ -192,7 +192,7 @@ var SAFE_FIX_NAMES = /* @__PURE__ */ new Set([
192
192
  // alternate spelling-fix name some TS versions emit
193
193
  ]);
194
194
  function runLSPFixerPass(opts) {
195
- const { workspaceRoot, targetFiles, logger } = opts;
195
+ const { workspaceRoot, targetFiles, logger, onLayerEvent } = opts;
196
196
  const maxIterations = opts.maxIterations ?? 5;
197
197
  const dryRun = opts.dryRun ?? false;
198
198
  const tsconfigPath = path2.join(workspaceRoot, "tsconfig.json");
@@ -279,15 +279,37 @@ function runLSPFixerPass(opts) {
279
279
  lastErrorSignatures = signatures;
280
280
  let appliedThisIter = 0;
281
281
  for (const err of fixableErrors) {
282
+ const errStartMs = Date.now();
282
283
  const fixes = safeGetCodeFixes(service, err);
283
284
  if (!fixes || fixes.length === 0) {
285
+ onLayerEvent?.({
286
+ layer: 1,
287
+ errorCode: err.code,
288
+ fixed: false,
289
+ latencyMs: Date.now() - errStartMs,
290
+ ts: Date.now()
291
+ });
284
292
  continue;
285
293
  }
286
294
  const safeFixes = fixes.filter((f) => SAFE_FIX_NAMES.has(f.fixName));
287
295
  if (safeFixes.length === 0) {
296
+ onLayerEvent?.({
297
+ layer: 1,
298
+ errorCode: err.code,
299
+ fixed: false,
300
+ latencyMs: Date.now() - errStartMs,
301
+ ts: Date.now()
302
+ });
288
303
  continue;
289
304
  }
290
305
  if (safeFixes.length > 1 && !fixesAreEquivalent(safeFixes)) {
306
+ onLayerEvent?.({
307
+ layer: 1,
308
+ errorCode: err.code,
309
+ fixed: false,
310
+ latencyMs: Date.now() - errStartMs,
311
+ ts: Date.now()
312
+ });
291
313
  continue;
292
314
  }
293
315
  const fix = safeFixes[0];
@@ -298,6 +320,21 @@ function runLSPFixerPass(opts) {
298
320
  for (const change of fix.changes) {
299
321
  filesEdited.add(change.fileName);
300
322
  }
323
+ onLayerEvent?.({
324
+ layer: 1,
325
+ errorCode: err.code,
326
+ fixed: true,
327
+ latencyMs: Date.now() - errStartMs,
328
+ ts: Date.now()
329
+ });
330
+ } else {
331
+ onLayerEvent?.({
332
+ layer: 1,
333
+ errorCode: err.code,
334
+ fixed: false,
335
+ latencyMs: Date.now() - errStartMs,
336
+ ts: Date.now()
337
+ });
301
338
  }
302
339
  }
303
340
  logger.info(
@@ -473,8 +510,8 @@ function applyFixToSnapshots(fix, snapshots) {
473
510
  }
474
511
 
475
512
  // src/index.ts
476
- import * as fs7 from "node:fs";
477
- import * as path7 from "node:path";
513
+ import * as fs8 from "node:fs";
514
+ import * as path8 from "node:path";
478
515
 
479
516
  // src/typeContext.ts
480
517
  import * as fs3 from "node:fs";
@@ -549,9 +586,20 @@ function isLibFile(fileName) {
549
586
  }
550
587
  function findTypeDeclaration(checker, startNode, maxWalkUp = 4) {
551
588
  const tryResolve = (n) => {
552
- const type = checker.getTypeAtLocation(n);
553
- const symbol = type.getSymbol() ?? type.aliasSymbol;
554
- const declarations = symbol?.getDeclarations();
589
+ let type;
590
+ try {
591
+ type = checker.getTypeAtLocation(n);
592
+ } catch {
593
+ return void 0;
594
+ }
595
+ let symbol;
596
+ let declarations;
597
+ try {
598
+ symbol = type.getSymbol() ?? type.aliasSymbol;
599
+ declarations = symbol?.getDeclarations();
600
+ } catch {
601
+ return void 0;
602
+ }
555
603
  if (!declarations || declarations.length === 0) return void 0;
556
604
  const nonLib = declarations.find((d) => !isLibFile(d.getSourceFile().fileName));
557
605
  if (!nonLib) return void 0;
@@ -784,10 +832,79 @@ function applyEditBlocks(opts) {
784
832
  }
785
833
 
786
834
  // src/mendAgent.ts
787
- import * as fs5 from "node:fs";
788
- import * as path5 from "node:path";
835
+ import * as fs6 from "node:fs";
836
+ import * as path6 from "node:path";
789
837
  import { generateText } from "ai";
790
838
  import { createAnthropic } from "@ai-sdk/anthropic";
839
+ import { createOpenAI } from "@ai-sdk/openai";
840
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
841
+
842
+ // src/libraryMigrations.ts
843
+ import * as fs5 from "node:fs";
844
+ import * as path5 from "node:path";
845
+ var BUILT_IN_LIBRARY_MIGRATIONS = [
846
+ {
847
+ match: { name: "vite-plugin-svgr", minMajor: 4 },
848
+ hint: "vite-plugin-svgr v4+ (released 2023-09-20) changed how SVG imports work. The PREVIOUS form `import { ReactComponent as X } from './x.svg'` no longer works \u2014 the ambient module declaration now only matches `*.svg?react`. Correct fix: `import X from './x.svg?react'` (default import + ?react query suffix). DO NOT use tsc's quick-fix `import X from './x.svg'` (no query) \u2014 that type-checks but resolves to the asset URL string at runtime, not a component."
849
+ },
850
+ {
851
+ match: { name: "next", minMajor: 15 },
852
+ hint: "Next.js 15 changed dynamic-route page props: `params` and `searchParams` are now `Promise<...>` instead of plain objects. The fix shape is: change the page's `params` type to `Promise<{...}>`, mark the page component `async`, and `await params` inside. See https://nextjs.org/docs/app/api-reference/file-conventions/page."
853
+ },
854
+ {
855
+ match: { name: "ai", minMajor: 3, maxMajor: 4 },
856
+ hint: "Vercel AI SDK v3.x has overload-narrowing issues with `generateObject`. If passing a schema through an object widened with `satisfies Record<K, z.ZodTypeAny>`, the typed overload silently falls back to `output: 'no-schema'` (which forbids the `schema` property). Fix: drop the `satisfies Record<...>` widener, or cast the schema at the call site."
857
+ },
858
+ {
859
+ match: { name: "drizzle-orm" },
860
+ hint: "Drizzle ORM table access has two distinct surfaces. `db.<table>` is for `select/insert/update/delete` builders. `db.query.<table>` is the Relational Queries API for `findFirst`/`findMany` with relation loading. If you see `Property '<table>' does not exist on type 'PostgresJsDatabase<...>'` when trying to call `.findFirst`/`.findMany`, use `db.query.<table>` instead."
861
+ }
862
+ ];
863
+ function parseMajor(spec) {
864
+ const m = spec.match(/(\d+)(?:\.\d+)*/);
865
+ return m ? parseInt(m[1], 10) : null;
866
+ }
867
+ function detectLibraryMigrations(workspaceRoot, registry = BUILT_IN_LIBRARY_MIGRATIONS) {
868
+ let pkg;
869
+ try {
870
+ const pkgPath = path5.join(workspaceRoot, "package.json");
871
+ if (!fs5.existsSync(pkgPath)) return [];
872
+ pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
873
+ } catch {
874
+ return [];
875
+ }
876
+ const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
877
+ const hints = [];
878
+ for (const entry of registry) {
879
+ const { match, hint } = entry;
880
+ const versionSpec = allDeps[match.name];
881
+ if (!versionSpec) continue;
882
+ const major = parseMajor(versionSpec);
883
+ if (match.minMajor != null && (major == null || major < match.minMajor)) continue;
884
+ if (match.maxMajor != null && (major == null || major > match.maxMajor)) continue;
885
+ hints.push({ name: `${match.name}@${versionSpec}`, hint });
886
+ }
887
+ return hints;
888
+ }
889
+ function formatLibraryMigrationsBlock(hints) {
890
+ if (hints.length === 0) return "";
891
+ const lines = ["### library-migrations", ""];
892
+ lines.push(
893
+ "These migrations apply to your workspace's installed deps. When tsc's quick-fix conflicts with the migration target below, PREFER the migration target. tsc only checks types, not runtime semantics \u2014 these hints encode runtime constraints tsc cannot see."
894
+ );
895
+ lines.push("");
896
+ for (const h of hints) {
897
+ lines.push(`- [${h.name}] ${h.hint}`);
898
+ }
899
+ return lines.join("\n");
900
+ }
901
+ function formatLibraryMigrationsTaskDescription(hints) {
902
+ if (hints.length === 0) return void 0;
903
+ const names = hints.map((h) => h.name).join(", ");
904
+ return `Library migration: ${names}`;
905
+ }
906
+
907
+ // src/mendAgent.ts
791
908
  var SYSTEM_INSTRUCTIONS = `You are a TypeScript code-repair tool. You receive a TypeScript file with one or more compiler errors and resolve them.
792
909
 
793
910
  Output ONLY SEARCH/REPLACE blocks. No prose, no explanations, no XML wrappers.
@@ -808,16 +925,43 @@ Rules:
808
925
  - REPLACE must be valid TypeScript that resolves the diagnostic.
809
926
  - Do not invent imports, types, properties, or values. Use only what the type-context section shows.
810
927
  - One SEARCH/REPLACE block per logical change.
811
- - If you cannot resolve a diagnostic with the information given, omit a block for it.`;
928
+ - If you cannot resolve a diagnostic with the information given, omit a block for it.
929
+
930
+ Anti-patterns \u2014 these silence the type error but break runtime semantics, lose type safety, or introduce security regressions. Do NOT emit a patch that does any of the following:
931
+
932
+ 1. Type-assertion escape-hatches that hide the error rather than fix it:
933
+ - \`x as any\` / \`x as unknown as T\` to dodge a real mismatch.
934
+ - \`key as keyof T\` to silence a TS7053 index-signature error when \`key\` is a runtime \`string\` (not a statically-known literal). Narrow the parameter type to \`keyof T\` at the function signature instead, OR widen the object type to include an index signature, OR perform a runtime \`if (key in obj)\` guard. \`as keyof T\` keeps the call site type-passing while losing all the runtime safety the index signature gave.
935
+ - \`!\` non-null assertions to dodge TS18047/TS2532 \u2014 narrow with a truthiness check or optional-chaining + nullish-coalesce that actually preserves the narrow on the true branch.
936
+
937
+ 2. Removing or substituting a declared dependency to dodge a missing-import error. If \`package.json\` lists the package and the source uses it, RESTORE the import. Do not substitute a different library (e.g. \`bcrypt\` \u2192 \`crypto.subtle.digest\`) \u2014 that is a security regression even when tsc accepts it.
938
+
939
+ 3. SQL / NoSQL / shell injection patterns:
940
+ - String concatenation of user-controlled values into raw query strings (\`db.execute("WHERE id = " + userId)\`). Use the library's tagged-template / parameterized form (\`db.execute(sql\\\`WHERE id = \\\${userId}\\\`)\` for Drizzle; placeholders for Prisma / mysql2; etc).
941
+ - Never use template literals to interpolate user input into a raw SQL string unless the literal is itself a parameterizing tagged template.
942
+
943
+ 4. React XSS escape-hatches:
944
+ - \`dangerouslySetInnerHTML\` to dodge a children-type error. If a component expects \`children: string\` and you have arbitrary HTML, render it as text (JSX \`{value}\` auto-escapes) or sanitize via a library (DOMPurify) and document the assumption.
945
+ - Setting \`innerHTML\` directly on a DOM element from user input.
946
+
947
+ These anti-patterns apply only to the listed shapes. For other diagnostics, follow the regular Rules above and pick the smallest valid fix \u2014 including legitimate uses of \`as unknown as T\`, \`keyof typeof T\` (as a type annotation, not a cast), or restructuring a type union. Do not omit a block just because the fix involves an \`as\` cast or a structural change \u2014 only omit when the fix would match one of the four anti-patterns above.
948
+
949
+ When a type, union variant, or interface property has been removed or renamed, consumer code that referenced the old shape needs FULL cleanup, not partial cleanup:
950
+
951
+ - TS2322 / TS2353 (excess property in object literal): REMOVE the excess property from the literal. Do not retain it. Example: if a \`{ type: 'archived', userId, reason, at }\` object now needs \`type: 'created'\` and the \`created\` variant has no \`reason\` field, the fix is to drop \`reason\` from the object \u2014 keeping it produces a fresh TS2353. This is field deletion, not "silencing an error" \u2014 there is no error to silence; the property genuinely no longer belongs.
952
+ - Function parameters and return types that exist solely to support the removed variant (e.g. a \`reason: string\` parameter on a function that no longer needs reasons) should be dropped along with their use sites in the same SEARCH/REPLACE block.
953
+ - TS2367 (comparison with no overlap): if comparing against a removed literal, EITHER pick a still-valid literal that preserves the function's spirit, OR delete the comparison and its branch if neither makes sense. Don't leave a comparison against a now-invalid literal.
954
+
955
+ The goal is internal consistency: if you change one reference to a removed variant/property, sweep ALL references in this file in the same patch. A half-cleanup leaves new tsc errors and is worse than the original state.`;
812
956
  function workspaceRelative(workspaceRoot, p) {
813
- return path5.isAbsolute(p) ? path5.relative(workspaceRoot, p) : p;
957
+ return path6.isAbsolute(p) ? path6.relative(workspaceRoot, p) : p;
814
958
  }
815
959
  function buildSystemBlock(context, erroredFile) {
816
960
  const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
817
- const absPath = path5.isAbsolute(erroredFile) ? erroredFile : path5.join(context.workspaceRoot, erroredFile);
961
+ const absPath = path6.isAbsolute(erroredFile) ? erroredFile : path6.join(context.workspaceRoot, erroredFile);
818
962
  let fileContent;
819
963
  try {
820
- fileContent = fs5.readFileSync(absPath, "utf-8");
964
+ fileContent = fs6.readFileSync(absPath, "utf-8");
821
965
  } catch {
822
966
  fileContent = "(file unreadable)";
823
967
  }
@@ -827,10 +971,15 @@ function buildSystemBlock(context, erroredFile) {
827
971
  const typeContexts = [];
828
972
  const seen = /* @__PURE__ */ new Set();
829
973
  for (const diag of fileDiags) {
830
- const ctx = getTypeContext({
831
- workspaceRoot: context.workspaceRoot,
832
- diagnostic: diag
833
- });
974
+ let ctx;
975
+ try {
976
+ ctx = getTypeContext({
977
+ workspaceRoot: context.workspaceRoot,
978
+ diagnostic: diag
979
+ });
980
+ } catch {
981
+ continue;
982
+ }
834
983
  if (!ctx.typeDeclaration) continue;
835
984
  const key = `${ctx.typeDeclaration.file}:${ctx.typeDeclaration.symbol}`;
836
985
  if (seen.has(key)) continue;
@@ -841,22 +990,21 @@ function buildSystemBlock(context, erroredFile) {
841
990
  ` + ctx.typeDeclaration.lines
842
991
  );
843
992
  }
844
- const parts = [
845
- SYSTEM_INSTRUCTIONS,
846
- "",
847
- `### file: ${wsRel}`,
848
- "```ts",
849
- fileContent.replace(/\n$/, ""),
850
- "```"
851
- ];
993
+ const parts = [SYSTEM_INSTRUCTIONS, ""];
994
+ const libMigrations = context.libraryMigrations ?? [];
995
+ if (libMigrations.length > 0) {
996
+ parts.push(formatLibraryMigrationsBlock(libMigrations), "");
997
+ }
998
+ parts.push(`### file: ${wsRel}`, "```ts", fileContent.replace(/\n$/, ""), "```");
852
999
  if (typeContexts.length > 0) {
853
1000
  parts.push("", "### type-context");
854
1001
  for (const tc of typeContexts) {
855
1002
  parts.push("```ts", tc, "```");
856
1003
  }
857
1004
  }
858
- if (context.taskDescription) {
859
- parts.push("", `### task`, context.taskDescription);
1005
+ const taskHeadline = formatLibraryMigrationsTaskDescription(libMigrations) ?? context.taskDescription;
1006
+ if (taskHeadline) {
1007
+ parts.push("", `### task`, taskHeadline);
860
1008
  }
861
1009
  return parts.join("\n");
862
1010
  }
@@ -873,10 +1021,24 @@ ${lines.join("\n")}
873
1021
 
874
1022
  Emit SEARCH/REPLACE blocks to resolve.`;
875
1023
  }
876
- var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
877
- const anthropic = createAnthropic({ apiKey });
1024
+ function buildLanguageModel(provider, model, apiKey) {
1025
+ switch (provider) {
1026
+ case "anthropic":
1027
+ return createAnthropic({ apiKey })(model);
1028
+ case "openai":
1029
+ return createOpenAI({ apiKey })(model);
1030
+ case "google":
1031
+ return createGoogleGenerativeAI({ apiKey })(model);
1032
+ default: {
1033
+ const _exhaustive = provider;
1034
+ throw new Error(`unknown provider: ${_exhaustive}`);
1035
+ }
1036
+ }
1037
+ }
1038
+ var defaultLLMCall = async ({ systemBlock, userBlock, provider = "anthropic", model, apiKey }) => {
1039
+ const llmModel = buildLanguageModel(provider, model, apiKey);
878
1040
  const result = await generateText({
879
- model: anthropic(model),
1041
+ model: llmModel,
880
1042
  system: systemBlock,
881
1043
  messages: [{ role: "user", content: userBlock }]
882
1044
  });
@@ -898,6 +1060,7 @@ async function mendSingleFile(opts) {
898
1060
  const llmResult = await _callLLM({
899
1061
  systemBlock,
900
1062
  userBlock,
1063
+ provider: llm.provider,
901
1064
  model: llm.model,
902
1065
  apiKey: llm.apiKey
903
1066
  });
@@ -920,8 +1083,8 @@ async function mendSingleFile(opts) {
920
1083
  }
921
1084
 
922
1085
  // src/stubAndContinue.ts
923
- import * as fs6 from "node:fs";
924
- import * as path6 from "node:path";
1086
+ import * as fs7 from "node:fs";
1087
+ import * as path7 from "node:path";
925
1088
  var noopLogger = { info: () => {
926
1089
  }, warn: () => {
927
1090
  }, error: () => {
@@ -938,17 +1101,17 @@ function groupByLine(diagnostics) {
938
1101
  return groups;
939
1102
  }
940
1103
  function resolveFile(diagnosticFile, workspaceRoot) {
941
- return path6.isAbsolute(diagnosticFile) ? diagnosticFile : path6.resolve(workspaceRoot, diagnosticFile);
1104
+ return path7.isAbsolute(diagnosticFile) ? diagnosticFile : path7.resolve(workspaceRoot, diagnosticFile);
942
1105
  }
943
1106
  function shouldSkipFile(file, workspaceRoot) {
944
- const rel = path6.relative(workspaceRoot, file);
945
- if (rel.startsWith("node_modules") || rel.includes(`${path6.sep}node_modules${path6.sep}`)) {
1107
+ const rel = path7.relative(workspaceRoot, file);
1108
+ if (rel.startsWith("node_modules") || rel.includes(`${path7.sep}node_modules${path7.sep}`)) {
946
1109
  return "node_modules";
947
1110
  }
948
1111
  if (file.endsWith(".d.ts")) {
949
1112
  return "declaration_file";
950
1113
  }
951
- if (!fs6.existsSync(file)) {
1114
+ if (!fs7.existsSync(file)) {
952
1115
  return "file_not_found";
953
1116
  }
954
1117
  return null;
@@ -1007,7 +1170,7 @@ function stubAndContinue(opts) {
1007
1170
  }
1008
1171
  continue;
1009
1172
  }
1010
- const source = fs6.readFileSync(file, "utf-8");
1173
+ const source = fs7.readFileSync(file, "utf-8");
1011
1174
  const eol = source.includes("\r\n") ? "\r\n" : "\n";
1012
1175
  const lines = source.split(/\r?\n/);
1013
1176
  entries.sort((a, b) => b.line - a.line);
@@ -1049,10 +1212,10 @@ function stubAndContinue(opts) {
1049
1212
  if (edited) {
1050
1213
  filesEditedSet.add(file);
1051
1214
  if (!dryRun) {
1052
- fs6.writeFileSync(file, lines.join(eol), "utf-8");
1053
- logger.info(`[stub-and-continue] stubbed ${entries.length} site(s) in ${path6.relative(workspaceRoot, file)}`);
1215
+ fs7.writeFileSync(file, lines.join(eol), "utf-8");
1216
+ logger.info(`[stub-and-continue] stubbed ${entries.length} site(s) in ${path7.relative(workspaceRoot, file)}`);
1054
1217
  } else {
1055
- logger.info(`[stub-and-continue] (dry-run) would stub ${entries.length} site(s) in ${path6.relative(workspaceRoot, file)}`);
1218
+ logger.info(`[stub-and-continue] (dry-run) would stub ${entries.length} site(s) in ${path7.relative(workspaceRoot, file)}`);
1056
1219
  }
1057
1220
  }
1058
1221
  }
@@ -1102,9 +1265,37 @@ function refreshDiagnostics(workspaceRoot, files) {
1102
1265
  });
1103
1266
  return result.diagnostics.filter((d) => d.category === "error");
1104
1267
  }
1268
+ function parseTsCode(code) {
1269
+ const m = /^TS(\d+)$/.exec(code);
1270
+ return m ? parseInt(m[1], 10) : 0;
1271
+ }
1272
+ function dominantErrorCode(diags) {
1273
+ const counts = /* @__PURE__ */ new Map();
1274
+ for (const d of diags) {
1275
+ counts.set(d.code, (counts.get(d.code) ?? 0) + 1);
1276
+ }
1277
+ let bestCode = "";
1278
+ let bestCount = 0;
1279
+ for (const [code, count] of counts) {
1280
+ if (count > bestCount) {
1281
+ bestCount = count;
1282
+ bestCode = code;
1283
+ }
1284
+ }
1285
+ return parseTsCode(bestCode);
1286
+ }
1105
1287
  async function runMendLoop(opts) {
1106
- const { context, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1288
+ const {
1289
+ context: rawContext,
1290
+ llm,
1291
+ maxIterations = 3,
1292
+ dryRun = false,
1293
+ stubOnFailure = false,
1294
+ onLayerEvent,
1295
+ _callLLM
1296
+ } = opts;
1107
1297
  const startMs = Date.now();
1298
+ const context = rawContext.libraryMigrations === void 0 ? { ...rawContext, libraryMigrations: detectLibraryMigrations(rawContext.workspaceRoot) } : rawContext;
1108
1299
  const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
1109
1300
  if (diagnosticsBefore.length === 0) {
1110
1301
  return {
@@ -1153,6 +1344,13 @@ async function runMendLoop(opts) {
1153
1344
  latencyMs: mend.latencyMs,
1154
1345
  rawResponse: mend.rawResponse
1155
1346
  });
1347
+ onLayerEvent?.({
1348
+ layer: 2,
1349
+ errorCode: dominantErrorCode(currentDiags),
1350
+ fixed: newDiags.length === 0,
1351
+ latencyMs: mend.latencyMs,
1352
+ ts: Date.now()
1353
+ });
1156
1354
  if (dryRun) {
1157
1355
  currentDiags = newDiags;
1158
1356
  stopReason = "maxIterations";
@@ -1183,6 +1381,20 @@ async function runMendLoop(opts) {
1183
1381
  diagnostics: currentDiags
1184
1382
  });
1185
1383
  stubs = stubResult.stubsApplied;
1384
+ if (onLayerEvent) {
1385
+ const stubTs = Date.now();
1386
+ for (const stub of stubResult.stubsApplied) {
1387
+ for (const code of stub.codes) {
1388
+ onLayerEvent({
1389
+ layer: 4,
1390
+ errorCode: parseTsCode(code),
1391
+ fixed: true,
1392
+ latencyMs: 0,
1393
+ ts: stubTs
1394
+ });
1395
+ }
1396
+ }
1397
+ }
1186
1398
  const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1187
1399
  if (postStubDiags.length === 0) {
1188
1400
  stopReason = "stubbed";
@@ -1217,7 +1429,7 @@ function discoverTsFiles(workspaceRoot) {
1217
1429
  const walk = (dir) => {
1218
1430
  let entries;
1219
1431
  try {
1220
- entries = fs7.readdirSync(dir, { withFileTypes: true });
1432
+ entries = fs8.readdirSync(dir, { withFileTypes: true });
1221
1433
  } catch {
1222
1434
  return;
1223
1435
  }
@@ -1226,10 +1438,10 @@ function discoverTsFiles(workspaceRoot) {
1226
1438
  if (skip.has(e.name)) {
1227
1439
  continue;
1228
1440
  }
1229
- walk(path7.join(dir, e.name));
1441
+ walk(path8.join(dir, e.name));
1230
1442
  } else if (e.isFile() && !e.name.endsWith(".d.ts")) {
1231
1443
  if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
1232
- out.push(path7.relative(workspaceRoot, path7.join(dir, e.name)));
1444
+ out.push(path8.relative(workspaceRoot, path8.join(dir, e.name)));
1233
1445
  }
1234
1446
  }
1235
1447
  }
@@ -1240,10 +1452,10 @@ function discoverTsFiles(workspaceRoot) {
1240
1452
  function runValidationLoop(opts) {
1241
1453
  const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
1242
1454
  const logger = opts.logger ?? noopLogger3;
1243
- if (!fs7.existsSync(workspaceRoot)) {
1455
+ if (!fs8.existsSync(workspaceRoot)) {
1244
1456
  throw new Error(`workspace not found: ${workspaceRoot}`);
1245
1457
  }
1246
- if (!fs7.existsSync(path7.join(workspaceRoot, "tsconfig.json"))) {
1458
+ if (!fs8.existsSync(path8.join(workspaceRoot, "tsconfig.json"))) {
1247
1459
  throw new Error(`no tsconfig.json in ${workspaceRoot}`);
1248
1460
  }
1249
1461
  const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
@@ -1259,7 +1471,13 @@ function runValidationLoop(opts) {
1259
1471
  iterations: 0
1260
1472
  };
1261
1473
  if (errorsBefore > 0 && !skipLSPFixer) {
1262
- const lsp = runLSPFixerPass({ workspaceRoot, targetFiles, logger, dryRun });
1474
+ const lsp = runLSPFixerPass({
1475
+ workspaceRoot,
1476
+ targetFiles,
1477
+ logger,
1478
+ dryRun,
1479
+ onLayerEvent: opts.onLayerEvent
1480
+ });
1263
1481
  lspFixer = {
1264
1482
  ran: true,
1265
1483
  fixesApplied: lsp.fixesApplied,
@@ -1292,16 +1510,59 @@ function runValidationLoop(opts) {
1292
1510
  }
1293
1511
 
1294
1512
  // cli/run-stack.ts
1295
- var ANTHROPIC_PRICING = {
1296
- "claude-haiku-4-5": { input: 0.8, output: 4 },
1297
- "claude-sonnet-4-5": { input: 3, output: 15 },
1298
- "claude-opus-4-7": { input: 15, output: 75 }
1513
+ var PRICING = {
1514
+ anthropic: {
1515
+ // All 4.5+ models share the same tier (the 4.5 release brought a
1516
+ // significant price drop on Opus). 4.1 retains the older Opus tier.
1517
+ "claude-haiku-4-5": { input: 1, output: 5 },
1518
+ "claude-sonnet-4-5": { input: 3, output: 15 },
1519
+ "claude-sonnet-4-6": { input: 3, output: 15 },
1520
+ "claude-opus-4-5": { input: 5, output: 25 },
1521
+ "claude-opus-4-6": { input: 5, output: 25 },
1522
+ "claude-opus-4-7": { input: 5, output: 25 },
1523
+ "claude-opus-4-1": { input: 15, output: 75 }
1524
+ },
1525
+ openai: {
1526
+ // Mini / nano tiers — well-matched to TypeScript repair (small
1527
+ // context, structured output). Default model uses one of these.
1528
+ "gpt-5-nano": { input: 0.05, output: 0.4 },
1529
+ "gpt-5-mini": { input: 0.25, output: 2 },
1530
+ // gpt-5 flagship + recent point releases (all $1.25 / $10).
1531
+ "gpt-5": { input: 1.25, output: 10 },
1532
+ "gpt-5.1": { input: 1.25, output: 10 },
1533
+ "gpt-5.2": { input: 1.75, output: 14 },
1534
+ // Reasoning models — sometimes better at semantic repair, more expensive.
1535
+ "o3-mini": { input: 1.1, output: 4.4 },
1536
+ "o4-mini": { input: 1.1, output: 4.4 },
1537
+ "o3": { input: 2, output: 8 }
1538
+ },
1539
+ google: {
1540
+ // Lite < flash < pro, matching the haiku/sonnet/opus mental model.
1541
+ "gemini-2.5-flash-lite": { input: 0.1, output: 0.4 },
1542
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
1543
+ // Standard tier (≤200k tokens). 2.5-pro doubles to $2.50/$15.00 above
1544
+ // 200k — not modeled here since our prompts are well below that.
1545
+ "gemini-2.5-pro": { input: 1.25, output: 10 }
1546
+ }
1299
1547
  };
1300
- function estimateCostUsd(model, inputTokens, outputTokens) {
1301
- const p = ANTHROPIC_PRICING[model];
1548
+ function estimateCostUsd(provider, model, inputTokens, outputTokens) {
1549
+ const p = PRICING[provider]?.[model];
1302
1550
  if (!p) return 0;
1303
1551
  return (inputTokens * p.input + outputTokens * p.output) / 1e6;
1304
1552
  }
1553
+ var ENV_KEY_BY_PROVIDER = {
1554
+ anthropic: "ANTHROPIC_API_KEY",
1555
+ openai: "OPENAI_API_KEY",
1556
+ google: "GOOGLE_GENERATIVE_AI_API_KEY"
1557
+ };
1558
+ var DEFAULT_MODEL_BY_PROVIDER = {
1559
+ anthropic: "claude-haiku-4-5",
1560
+ openai: "gpt-5-mini",
1561
+ google: "gemini-2.5-flash"
1562
+ };
1563
+ function isLLMProvider(s) {
1564
+ return s === "anthropic" || s === "openai" || s === "google";
1565
+ }
1305
1566
  function parseArgs(argv) {
1306
1567
  const args = {
1307
1568
  workspace: "",
@@ -1311,9 +1572,14 @@ function parseArgs(argv) {
1311
1572
  files: void 0,
1312
1573
  verbose: false,
1313
1574
  llm: false,
1314
- llmModel: "claude-haiku-4-5",
1575
+ llmProvider: "anthropic",
1576
+ // llmModel default depends on provider — we set it AFTER parsing so
1577
+ // `--llm-provider openai` without `--llm-model` picks gpt-4o-mini, etc.
1578
+ // An empty string here means "use the provider's default".
1579
+ llmModel: "",
1315
1580
  llmMaxIterations: 3,
1316
- llmBudgetUsd: void 0
1581
+ llmBudgetUsd: void 0,
1582
+ noLibraryHints: false
1317
1583
  };
1318
1584
  for (let i = 0; i < argv.length; i++) {
1319
1585
  const a = argv[i];
@@ -1331,6 +1597,13 @@ function parseArgs(argv) {
1331
1597
  args.verbose = true;
1332
1598
  } else if (a === "--llm") {
1333
1599
  args.llm = true;
1600
+ } else if (a === "--llm-provider") {
1601
+ const p = argv[++i] ?? "";
1602
+ if (!isLLMProvider(p)) {
1603
+ console.error(`error: --llm-provider expects one of: anthropic, openai, google. Got '${p}'`);
1604
+ process.exit(2);
1605
+ }
1606
+ args.llmProvider = p;
1334
1607
  } else if (a === "--llm-model") {
1335
1608
  args.llmModel = argv[++i] ?? args.llmModel;
1336
1609
  } else if (a === "--llm-max-iterations") {
@@ -1347,6 +1620,8 @@ function parseArgs(argv) {
1347
1620
  process.exit(2);
1348
1621
  }
1349
1622
  args.llmBudgetUsd = v;
1623
+ } else if (a === "--no-library-hints") {
1624
+ args.noLibraryHints = true;
1350
1625
  } else if (a === "--help" || a === "-h") {
1351
1626
  printHelp();
1352
1627
  process.exit(0);
@@ -1357,6 +1632,9 @@ function parseArgs(argv) {
1357
1632
  printHelp();
1358
1633
  process.exit(2);
1359
1634
  }
1635
+ if (args.llmModel === "") {
1636
+ args.llmModel = DEFAULT_MODEL_BY_PROVIDER[args.llmProvider];
1637
+ }
1360
1638
  return args;
1361
1639
  }
1362
1640
  function printHelp() {
@@ -1372,16 +1650,36 @@ Layer 0/1 (default \u2014 deterministic, no network):
1372
1650
  --verbose, -v Stream layer logs to stderr
1373
1651
  --help, -h Show this help
1374
1652
 
1375
- Layer 2 (opt-in \u2014 single-file LLM mend via Anthropic):
1653
+ Layer 2 (opt-in \u2014 single-file LLM mend):
1376
1654
  --llm Enable Layer 2 on errors that survive Layer 0/1
1377
- --llm-model <name> Anthropic model (default: claude-haiku-4-5)
1378
- Known-priced models: claude-haiku-4-5,
1379
- claude-sonnet-4-5, claude-opus-4-7.
1380
- Cost estimate is 0 for unknown models.
1655
+ --llm-provider <name> anthropic | openai | google (default: anthropic)
1656
+ --llm-model <name> Model name. Defaults per provider:
1657
+ anthropic \u2192 claude-haiku-4-5
1658
+ openai \u2192 gpt-5-mini
1659
+ google \u2192 gemini-2.5-flash
1660
+ Known-priced models per provider:
1661
+ anthropic: claude-haiku-4-5, -sonnet-4-5,
1662
+ -sonnet-4-6, -opus-4-5, -opus-4-6,
1663
+ -opus-4-7, -opus-4-1
1664
+ openai: gpt-5-nano, gpt-5-mini, gpt-5,
1665
+ gpt-5.1, gpt-5.2, o3-mini, o4-mini, o3
1666
+ google: gemini-2.5-flash-lite, gemini-2.5-flash,
1667
+ gemini-2.5-pro
1668
+ Cost estimate is 0 for unlisted models (the
1669
+ warning suggests pinning a listed one).
1381
1670
  --llm-max-iterations <N> Cap on LLM retries (default: 3)
1382
1671
  --llm-budget-usd <amount> Soft cost cap. Exits with code 3 if exceeded.
1672
+ --no-library-hints Disable auto-detection of library breaking-change
1673
+ hints (vite-plugin-svgr v4 ?react migration,
1674
+ Next.js 15 async params, etc.). When workspace
1675
+ package.json contains a known migration target,
1676
+ tsfix injects hints into Layer 2's prompt + skips
1677
+ Layer 0/1 (whose quick-fix would conflict).
1383
1678
 
1384
- Layer 2 requires ANTHROPIC_API_KEY in the environment.
1679
+ Layer 2 requires the provider's API key in env:
1680
+ anthropic \u2192 ANTHROPIC_API_KEY
1681
+ openai \u2192 OPENAI_API_KEY
1682
+ google \u2192 GOOGLE_GENERATIVE_AI_API_KEY
1385
1683
 
1386
1684
  Exit codes:
1387
1685
  0 no errors after stack
@@ -1432,7 +1730,7 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
1432
1730
  ` Layer 2 (LLM): ${l2.errorsBefore} \u2192 ${l2.errorsAfter} errors ${l2.iterations}\xD7 iter ${l2.totalInputTokens}\u2192${l2.totalOutputTokens} tokens $${l2.totalCostUsd.toFixed(4)} ${l2.budgetExceeded ? "\u26A0\uFE0F budget exceeded" : ""}
1433
1731
  `
1434
1732
  );
1435
- w.write(` model=${l2.model} \xB7 stopReason=${l2.stopReason}
1733
+ w.write(` ${l2.provider}/${l2.model} \xB7 stopReason=${l2.stopReason}
1436
1734
  `);
1437
1735
  }
1438
1736
  w.write(` errors after: ${r.errorsAfter}
@@ -1454,12 +1752,12 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
1454
1752
  }
1455
1753
  async function main() {
1456
1754
  const args = parseArgs(process.argv.slice(2));
1457
- const workspaceRoot = path8.resolve(args.workspace);
1458
- if (!fs8.existsSync(workspaceRoot)) {
1755
+ const workspaceRoot = path9.resolve(args.workspace);
1756
+ if (!fs9.existsSync(workspaceRoot)) {
1459
1757
  console.error(`error: workspace not found: ${workspaceRoot}`);
1460
1758
  return 2;
1461
1759
  }
1462
- if (!fs8.existsSync(path8.join(workspaceRoot, "tsconfig.json"))) {
1760
+ if (!fs9.existsSync(path9.join(workspaceRoot, "tsconfig.json"))) {
1463
1761
  console.error(`error: no tsconfig.json in ${workspaceRoot}`);
1464
1762
  return 2;
1465
1763
  }
@@ -1470,15 +1768,23 @@ async function main() {
1470
1768
  console.error("error: no .ts/.tsx files found in workspace");
1471
1769
  return 2;
1472
1770
  }
1771
+ const libraryMigrations = args.noLibraryHints ? [] : detectLibraryMigrations(workspaceRoot);
1772
+ const migrationApplies = args.llm && libraryMigrations.length > 0;
1773
+ if (migrationApplies && !args.noLsp) {
1774
+ logger.info(
1775
+ `Library migrations detected (${libraryMigrations.map((h) => h.name).join(", ")}) \u2014 skipping Layer 0/1 to let Layer 2 apply the migration target. Use --no-library-hints to disable.`
1776
+ );
1777
+ }
1778
+ const effectiveNoLsp = args.noLsp || migrationApplies;
1473
1779
  const loop = runValidationLoop({
1474
1780
  workspaceRoot,
1475
1781
  targetFiles,
1476
- skipLSPFixer: args.noLsp,
1782
+ skipLSPFixer: effectiveNoLsp,
1477
1783
  dryRun: args.dryRun,
1478
1784
  logger
1479
1785
  });
1480
1786
  const report = {
1481
- workspace: path8.relative(process.cwd(), workspaceRoot) || workspaceRoot,
1787
+ workspace: path9.relative(process.cwd(), workspaceRoot) || workspaceRoot,
1482
1788
  errorsBefore: loop.errorsBefore,
1483
1789
  lspFixer: args.noLsp ? { ran: false, fixesApplied: 0, filesEdited: [], iterations: 0 } : loop.lspFixer,
1484
1790
  layer2: null,
@@ -1495,30 +1801,36 @@ async function main() {
1495
1801
  console.error("error: --llm and --dry-run are mutually exclusive (Layer 2 writes patches to disk)");
1496
1802
  return 2;
1497
1803
  }
1498
- const apiKey = process.env.ANTHROPIC_API_KEY;
1804
+ const envKeyName = ENV_KEY_BY_PROVIDER[args.llmProvider];
1805
+ const apiKey = process.env[envKeyName];
1499
1806
  if (!apiKey) {
1500
- console.error("error: --llm requires ANTHROPIC_API_KEY in the environment");
1807
+ console.error(`error: --llm with provider '${args.llmProvider}' requires ${envKeyName} in the environment`);
1501
1808
  return 2;
1502
1809
  }
1503
- if (!ANTHROPIC_PRICING[args.llmModel]) {
1810
+ if (!PRICING[args.llmProvider]?.[args.llmModel]) {
1504
1811
  logger.warn(
1505
- `unknown model '${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1812
+ `unknown model '${args.llmProvider}/${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1506
1813
  );
1507
1814
  }
1508
1815
  const errorDiags = loop.diagnostics.filter((d) => d.category === "error");
1509
1816
  const context = {
1510
1817
  workspaceRoot,
1511
1818
  diagnostics: errorDiags,
1512
- erroredFiles: Array.from(new Set(errorDiags.map((d) => d.file)))
1819
+ erroredFiles: Array.from(new Set(errorDiags.map((d) => d.file))),
1820
+ // Explicitly pass migrations so `runMendLoop` doesn't re-detect.
1821
+ // `[]` is meaningful — it means "we know there are none" — vs
1822
+ // `undefined` which would trigger auto-detect.
1823
+ libraryMigrations
1513
1824
  };
1514
1825
  const layer2Start = Date.now();
1515
1826
  const mend = await runMendLoop({
1516
1827
  context,
1517
- llm: { provider: "anthropic", model: args.llmModel, apiKey },
1828
+ llm: { provider: args.llmProvider, model: args.llmModel, apiKey },
1518
1829
  maxIterations: args.llmMaxIterations
1519
1830
  });
1520
1831
  void layer2Start;
1521
1832
  const totalCostUsd = estimateCostUsd(
1833
+ args.llmProvider,
1522
1834
  args.llmModel,
1523
1835
  mend.totalInputTokens,
1524
1836
  mend.totalOutputTokens
@@ -1534,6 +1846,7 @@ async function main() {
1534
1846
  totalOutputTokens: mend.totalOutputTokens,
1535
1847
  totalCostUsd,
1536
1848
  budgetExceeded,
1849
+ provider: args.llmProvider,
1537
1850
  model: args.llmModel
1538
1851
  };
1539
1852
  const post = runInProcessTsc({