@shipispec/tsfix 0.4.0 → 0.6.0

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 path4 from "node:path";
5
- import * as fs4 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";
@@ -473,14 +473,847 @@ function applyFixToSnapshots(fix, snapshots) {
473
473
  }
474
474
 
475
475
  // src/index.ts
476
- import * as fs3 from "node:fs";
477
- import * as path3 from "node:path";
476
+ import * as fs8 from "node:fs";
477
+ import * as path8 from "node:path";
478
478
 
479
479
  // src/typeContext.ts
480
+ import * as fs3 from "node:fs";
481
+ import * as path3 from "node:path";
480
482
  import * as ts3 from "typescript";
483
+ var programCache2 = /* @__PURE__ */ new Map();
484
+ function buildProgram(workspaceRoot) {
485
+ const tsconfigPath = path3.join(workspaceRoot, "tsconfig.json");
486
+ if (!fs3.existsSync(tsconfigPath)) return null;
487
+ let configMtime = 0;
488
+ try {
489
+ configMtime = fs3.statSync(tsconfigPath).mtimeMs;
490
+ } catch {
491
+ return null;
492
+ }
493
+ const cached = programCache2.get(workspaceRoot);
494
+ if (cached && cached.configMtime === configMtime) return cached.program;
495
+ const configFile = ts3.readConfigFile(tsconfigPath, ts3.sys.readFile);
496
+ if (configFile.error) return null;
497
+ const parsed = ts3.parseJsonConfigFileContent(
498
+ configFile.config,
499
+ ts3.sys,
500
+ path3.dirname(tsconfigPath)
501
+ );
502
+ const host = ts3.createCompilerHost(parsed.options);
503
+ const workspaceLibDir = path3.join(workspaceRoot, "node_modules", "typescript", "lib");
504
+ if (fs3.existsSync(workspaceLibDir)) {
505
+ const origGetDefaultLibFileName = host.getDefaultLibFileName.bind(host);
506
+ host.getDefaultLibLocation = () => workspaceLibDir;
507
+ host.getDefaultLibFileName = (options) => {
508
+ const fileName = path3.basename(origGetDefaultLibFileName(options));
509
+ return path3.join(workspaceLibDir, fileName);
510
+ };
511
+ }
512
+ let program;
513
+ try {
514
+ program = ts3.createProgram({
515
+ rootNames: parsed.fileNames,
516
+ options: parsed.options,
517
+ host
518
+ });
519
+ } catch {
520
+ return null;
521
+ }
522
+ programCache2.set(workspaceRoot, { program, configMtime });
523
+ return program;
524
+ }
525
+ function sliceLines(content, oneIndexedLine, padding) {
526
+ const allLines = content.split("\n");
527
+ const start = Math.max(0, oneIndexedLine - 1 - padding);
528
+ const end = Math.min(allLines.length, oneIndexedLine + padding);
529
+ const width = String(end).length;
530
+ return allLines.slice(start, end).map((l, i) => `${String(start + i + 1).padStart(width, " ")} | ${l}`).join("\n");
531
+ }
532
+ function getNodeAtPosition(sourceFile, position) {
533
+ let result = sourceFile;
534
+ function walk(node) {
535
+ ts3.forEachChild(node, (child) => {
536
+ if (position >= child.getStart(sourceFile) && position < child.getEnd()) {
537
+ result = child;
538
+ walk(child);
539
+ return true;
540
+ }
541
+ return false;
542
+ });
543
+ }
544
+ walk(sourceFile);
545
+ return result;
546
+ }
547
+ function isLibFile(fileName) {
548
+ return /lib\.[a-z0-9.]+\.d\.ts$/.test(fileName);
549
+ }
550
+ function findTypeDeclaration(checker, startNode, maxWalkUp = 4) {
551
+ const tryResolve = (n) => {
552
+ let type;
553
+ try {
554
+ type = checker.getTypeAtLocation(n);
555
+ } catch {
556
+ return void 0;
557
+ }
558
+ let symbol;
559
+ let declarations;
560
+ try {
561
+ symbol = type.getSymbol() ?? type.aliasSymbol;
562
+ declarations = symbol?.getDeclarations();
563
+ } catch {
564
+ return void 0;
565
+ }
566
+ if (!declarations || declarations.length === 0) return void 0;
567
+ const nonLib = declarations.find((d) => !isLibFile(d.getSourceFile().fileName));
568
+ if (!nonLib) return void 0;
569
+ return { decl: nonLib, symbolName: symbol?.getName() ?? "(unnamed)" };
570
+ };
571
+ let node = startNode;
572
+ for (let i = 0; i < maxWalkUp && node; i++) {
573
+ const direct = tryResolve(node);
574
+ if (direct) return direct;
575
+ if (ts3.isPropertyAccessExpression(node) || ts3.isElementAccessExpression(node)) {
576
+ const sibling = tryResolve(node.expression);
577
+ if (sibling) return sibling;
578
+ }
579
+ node = node.parent;
580
+ }
581
+ return void 0;
582
+ }
583
+ function getTypeContext(opts) {
584
+ const { workspaceRoot, diagnostic } = opts;
585
+ const errorPadding = opts.errorPadding ?? 3;
586
+ const declarationPadding = opts.declarationPadding ?? 20;
587
+ const filePath = path3.isAbsolute(diagnostic.file) ? diagnostic.file : path3.join(workspaceRoot, diagnostic.file);
588
+ const fileContent = fs3.existsSync(filePath) ? fs3.readFileSync(filePath, "utf-8") : "";
589
+ const errorSite = {
590
+ file: diagnostic.file,
591
+ lines: sliceLines(fileContent, diagnostic.line, errorPadding)
592
+ };
593
+ const program = buildProgram(workspaceRoot);
594
+ if (!program) return { errorSite };
595
+ const sourceFile = program.getSourceFile(filePath);
596
+ if (!sourceFile) return { errorSite };
597
+ let position;
598
+ try {
599
+ position = ts3.getPositionOfLineAndCharacter(
600
+ sourceFile,
601
+ diagnostic.line - 1,
602
+ diagnostic.column - 1
603
+ );
604
+ } catch {
605
+ return { errorSite };
606
+ }
607
+ const errorNode = getNodeAtPosition(sourceFile, position);
608
+ const checker = program.getTypeChecker();
609
+ const found = findTypeDeclaration(checker, errorNode);
610
+ if (!found) return { errorSite };
611
+ const declSourceFile = found.decl.getSourceFile();
612
+ const declStart = found.decl.getStart(declSourceFile);
613
+ const { line: declLine0 } = ts3.getLineAndCharacterOfPosition(declSourceFile, declStart);
614
+ return {
615
+ errorSite,
616
+ typeDeclaration: {
617
+ file: path3.relative(workspaceRoot, declSourceFile.fileName) || declSourceFile.fileName,
618
+ lines: sliceLines(declSourceFile.text, declLine0 + 1, declarationPadding),
619
+ symbol: found.symbolName
620
+ }
621
+ };
622
+ }
623
+
624
+ // src/applyEditBlock.ts
625
+ import * as fs4 from "node:fs";
626
+ import * as path4 from "node:path";
627
+ var SEARCH_MARKER = "<<<<<<< SEARCH";
628
+ var SEPARATOR = "=======";
629
+ var REPLACE_MARKER = ">>>>>>> REPLACE";
630
+ function extractFilePath(line) {
631
+ const trimmed = line.trim();
632
+ const attrMatch = trimmed.match(/<\s*file\s+path\s*=\s*["']([^"']+)["']\s*\/?>/i);
633
+ if (attrMatch) return attrMatch[1];
634
+ return trimmed.replace(/^[`'"]+|[`'"]+$/g, "");
635
+ }
636
+ function parseEditBlocks(llmOutput) {
637
+ const blocks = [];
638
+ const lines = llmOutput.split("\n");
639
+ let i = 0;
640
+ while (i < lines.length) {
641
+ while (i < lines.length && lines[i].trim() !== SEARCH_MARKER) {
642
+ i++;
643
+ }
644
+ if (i >= lines.length) {
645
+ break;
646
+ }
647
+ let fileIdx = i - 1;
648
+ while (fileIdx >= 0) {
649
+ const trimmed = lines[fileIdx].trim();
650
+ if (trimmed === "" || trimmed.startsWith("```") || trimmed.startsWith("</")) {
651
+ fileIdx--;
652
+ continue;
653
+ }
654
+ break;
655
+ }
656
+ const filePath = fileIdx >= 0 ? extractFilePath(lines[fileIdx]) : "";
657
+ i++;
658
+ const searchLines = [];
659
+ while (i < lines.length && lines[i].trim() !== SEPARATOR) {
660
+ searchLines.push(lines[i]);
661
+ i++;
662
+ }
663
+ if (i >= lines.length) {
664
+ break;
665
+ }
666
+ i++;
667
+ const replaceLines = [];
668
+ while (i < lines.length && lines[i].trim() !== REPLACE_MARKER) {
669
+ replaceLines.push(lines[i]);
670
+ i++;
671
+ }
672
+ if (i >= lines.length) {
673
+ break;
674
+ }
675
+ blocks.push({
676
+ file: filePath,
677
+ search: searchLines.join("\n"),
678
+ replace: replaceLines.join("\n")
679
+ });
680
+ i++;
681
+ }
682
+ return blocks;
683
+ }
684
+ function countOccurrences(haystack, needle) {
685
+ if (needle.length === 0) return 0;
686
+ let count = 0;
687
+ let pos = 0;
688
+ while (true) {
689
+ const idx = haystack.indexOf(needle, pos);
690
+ if (idx < 0) return count;
691
+ count++;
692
+ pos = idx + needle.length;
693
+ }
694
+ }
695
+ function rstripPerLine(text) {
696
+ return text.split("\n").map((l) => l.replace(/\s+$/, "")).join("\n");
697
+ }
698
+ function stripPerLine(text) {
699
+ return text.split("\n").map((l) => l.trim()).join("\n");
700
+ }
701
+ function spliceLines(originalContent, normalizedContent, normalizedSearch, replace) {
702
+ const idx = normalizedContent.indexOf(normalizedSearch);
703
+ if (idx < 0) return void 0;
704
+ const linesBefore = normalizedContent.slice(0, idx).split("\n").length - 1;
705
+ const matchLineCount = normalizedSearch.split("\n").length;
706
+ const origLines = originalContent.split("\n");
707
+ const before = origLines.slice(0, linesBefore);
708
+ const after = origLines.slice(linesBefore + matchLineCount);
709
+ return [...before, replace, ...after].join("\n");
710
+ }
711
+ function applySingleBlock(fileContent, search, replace) {
712
+ if (search === "") {
713
+ return { error: "empty search block" };
714
+ }
715
+ const exactCount = countOccurrences(fileContent, search);
716
+ if (exactCount === 1) {
717
+ return { newContent: fileContent.replace(search, replace), matchedTier: "exact" };
718
+ }
719
+ if (exactCount > 1) {
720
+ return { error: `ambiguous: ${exactCount} exact matches` };
721
+ }
722
+ const rstripContent = rstripPerLine(fileContent);
723
+ const rstripSearch = rstripPerLine(search);
724
+ const rstripCount = countOccurrences(rstripContent, rstripSearch);
725
+ if (rstripCount === 1) {
726
+ const out = spliceLines(fileContent, rstripContent, rstripSearch, replace);
727
+ if (out !== void 0) {
728
+ return { newContent: out, matchedTier: "rstrip" };
729
+ }
730
+ }
731
+ if (rstripCount > 1) {
732
+ return { error: `ambiguous: ${rstripCount} rstrip matches` };
733
+ }
734
+ const stripContent = stripPerLine(fileContent);
735
+ const stripSearch = stripPerLine(search);
736
+ const stripCount = countOccurrences(stripContent, stripSearch);
737
+ if (stripCount === 1) {
738
+ const out = spliceLines(fileContent, stripContent, stripSearch, replace);
739
+ if (out !== void 0) {
740
+ return { newContent: out, matchedTier: "strip" };
741
+ }
742
+ }
743
+ if (stripCount > 1) {
744
+ return { error: `ambiguous: ${stripCount} strip matches` };
745
+ }
746
+ return { error: "no match" };
747
+ }
748
+ function applyEditBlocks(opts) {
749
+ const { workspaceRoot, blocks, dryRun = false } = opts;
750
+ const fileSnapshots = /* @__PURE__ */ new Map();
751
+ const failures = [];
752
+ const filesEdited = /* @__PURE__ */ new Set();
753
+ let applied = 0;
754
+ for (const block of blocks) {
755
+ const filePath = path4.isAbsolute(block.file) ? block.file : path4.join(workspaceRoot, block.file);
756
+ let content = fileSnapshots.get(filePath);
757
+ if (content === void 0) {
758
+ try {
759
+ content = fs4.readFileSync(filePath, "utf-8");
760
+ } catch (err) {
761
+ failures.push({
762
+ block,
763
+ reason: `cannot read file: ${err instanceof Error ? err.message : String(err)}`
764
+ });
765
+ continue;
766
+ }
767
+ }
768
+ const result = applySingleBlock(content, block.search, block.replace);
769
+ if ("error" in result) {
770
+ failures.push({ block, reason: result.error });
771
+ continue;
772
+ }
773
+ fileSnapshots.set(filePath, result.newContent);
774
+ filesEdited.add(filePath);
775
+ applied++;
776
+ }
777
+ if (!dryRun) {
778
+ for (const [filePath, content] of fileSnapshots) {
779
+ try {
780
+ fs4.writeFileSync(filePath, content);
781
+ } catch (err) {
782
+ failures.push({
783
+ block: { file: filePath, search: "", replace: "" },
784
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`
785
+ });
786
+ }
787
+ }
788
+ }
789
+ return {
790
+ blocks,
791
+ applied,
792
+ filesEdited: Array.from(filesEdited),
793
+ failures
794
+ };
795
+ }
796
+
797
+ // src/mendAgent.ts
798
+ import * as fs6 from "node:fs";
799
+ import * as path6 from "node:path";
800
+ import { generateText } from "ai";
801
+ import { createAnthropic } from "@ai-sdk/anthropic";
802
+
803
+ // src/libraryMigrations.ts
804
+ import * as fs5 from "node:fs";
805
+ import * as path5 from "node:path";
806
+ var BUILT_IN_LIBRARY_MIGRATIONS = [
807
+ {
808
+ match: { name: "vite-plugin-svgr", minMajor: 4 },
809
+ 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."
810
+ },
811
+ {
812
+ match: { name: "next", minMajor: 15 },
813
+ 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."
814
+ },
815
+ {
816
+ match: { name: "ai", minMajor: 3, maxMajor: 4 },
817
+ 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."
818
+ },
819
+ {
820
+ match: { name: "drizzle-orm" },
821
+ 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."
822
+ }
823
+ ];
824
+ function parseMajor(spec) {
825
+ const m = spec.match(/(\d+)(?:\.\d+)*/);
826
+ return m ? parseInt(m[1], 10) : null;
827
+ }
828
+ function detectLibraryMigrations(workspaceRoot, registry = BUILT_IN_LIBRARY_MIGRATIONS) {
829
+ let pkg;
830
+ try {
831
+ const pkgPath = path5.join(workspaceRoot, "package.json");
832
+ if (!fs5.existsSync(pkgPath)) return [];
833
+ pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
834
+ } catch {
835
+ return [];
836
+ }
837
+ const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
838
+ const hints = [];
839
+ for (const entry of registry) {
840
+ const { match, hint } = entry;
841
+ const versionSpec = allDeps[match.name];
842
+ if (!versionSpec) continue;
843
+ const major = parseMajor(versionSpec);
844
+ if (match.minMajor != null && (major == null || major < match.minMajor)) continue;
845
+ if (match.maxMajor != null && (major == null || major > match.maxMajor)) continue;
846
+ hints.push({ name: `${match.name}@${versionSpec}`, hint });
847
+ }
848
+ return hints;
849
+ }
850
+ function formatLibraryMigrationsBlock(hints) {
851
+ if (hints.length === 0) return "";
852
+ const lines = ["### library-migrations", ""];
853
+ lines.push(
854
+ "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."
855
+ );
856
+ lines.push("");
857
+ for (const h of hints) {
858
+ lines.push(`- [${h.name}] ${h.hint}`);
859
+ }
860
+ return lines.join("\n");
861
+ }
862
+ function formatLibraryMigrationsTaskDescription(hints) {
863
+ if (hints.length === 0) return void 0;
864
+ const names = hints.map((h) => h.name).join(", ");
865
+ return `Library migration: ${names}`;
866
+ }
867
+
868
+ // src/mendAgent.ts
869
+ var SYSTEM_INSTRUCTIONS = `You are a TypeScript code-repair tool. You receive a TypeScript file with one or more compiler errors and resolve them.
870
+
871
+ Output ONLY SEARCH/REPLACE blocks. No prose, no explanations, no XML wrappers.
872
+
873
+ The first line of each block is the workspace-relative file path on its own line. Then the SEARCH/REPLACE markers around the change. Concrete example:
874
+
875
+ src/api.ts
876
+ <<<<<<< SEARCH
877
+ const x = 1;
878
+ =======
879
+ const x: number = 1;
880
+ >>>>>>> REPLACE
881
+
882
+ Rules:
883
+ - The file path is a plain line. Do not wrap it in tags, fences, or quotes.
884
+ - SEARCH text must match the file VERBATIM. Whitespace, indentation, line endings: copy exactly.
885
+ - Make SEARCH unique. If a one-line search would match multiple places in the file, include 1-2 lines of surrounding context.
886
+ - REPLACE must be valid TypeScript that resolves the diagnostic.
887
+ - Do not invent imports, types, properties, or values. Use only what the type-context section shows.
888
+ - One SEARCH/REPLACE block per logical change.
889
+ - If you cannot resolve a diagnostic with the information given, omit a block for it.
890
+
891
+ 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:
892
+
893
+ 1. Type-assertion escape-hatches that hide the error rather than fix it:
894
+ - \`x as any\` / \`x as unknown as T\` to dodge a real mismatch.
895
+ - \`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.
896
+ - \`!\` 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.
897
+
898
+ 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.
899
+
900
+ 3. SQL / NoSQL / shell injection patterns:
901
+ - 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).
902
+ - Never use template literals to interpolate user input into a raw SQL string unless the literal is itself a parameterizing tagged template.
903
+
904
+ 4. React XSS escape-hatches:
905
+ - \`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.
906
+ - Setting \`innerHTML\` directly on a DOM element from user input.
907
+
908
+ 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.
909
+
910
+ 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:
911
+
912
+ - 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.
913
+ - 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.
914
+ - 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.
915
+
916
+ 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.`;
917
+ function workspaceRelative(workspaceRoot, p) {
918
+ return path6.isAbsolute(p) ? path6.relative(workspaceRoot, p) : p;
919
+ }
920
+ function buildSystemBlock(context, erroredFile) {
921
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
922
+ const absPath = path6.isAbsolute(erroredFile) ? erroredFile : path6.join(context.workspaceRoot, erroredFile);
923
+ let fileContent;
924
+ try {
925
+ fileContent = fs6.readFileSync(absPath, "utf-8");
926
+ } catch {
927
+ fileContent = "(file unreadable)";
928
+ }
929
+ const fileDiags = context.diagnostics.filter(
930
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
931
+ );
932
+ const typeContexts = [];
933
+ const seen = /* @__PURE__ */ new Set();
934
+ for (const diag of fileDiags) {
935
+ let ctx;
936
+ try {
937
+ ctx = getTypeContext({
938
+ workspaceRoot: context.workspaceRoot,
939
+ diagnostic: diag
940
+ });
941
+ } catch {
942
+ continue;
943
+ }
944
+ if (!ctx.typeDeclaration) continue;
945
+ const key = `${ctx.typeDeclaration.file}:${ctx.typeDeclaration.symbol}`;
946
+ if (seen.has(key)) continue;
947
+ seen.add(key);
948
+ typeContexts.push(
949
+ `// type: ${ctx.typeDeclaration.symbol}
950
+ // file: ${ctx.typeDeclaration.file}
951
+ ` + ctx.typeDeclaration.lines
952
+ );
953
+ }
954
+ const parts = [SYSTEM_INSTRUCTIONS, ""];
955
+ const libMigrations = context.libraryMigrations ?? [];
956
+ if (libMigrations.length > 0) {
957
+ parts.push(formatLibraryMigrationsBlock(libMigrations), "");
958
+ }
959
+ parts.push(`### file: ${wsRel}`, "```ts", fileContent.replace(/\n$/, ""), "```");
960
+ if (typeContexts.length > 0) {
961
+ parts.push("", "### type-context");
962
+ for (const tc of typeContexts) {
963
+ parts.push("```ts", tc, "```");
964
+ }
965
+ }
966
+ const taskHeadline = formatLibraryMigrationsTaskDescription(libMigrations) ?? context.taskDescription;
967
+ if (taskHeadline) {
968
+ parts.push("", `### task`, taskHeadline);
969
+ }
970
+ return parts.join("\n");
971
+ }
972
+ function buildUserBlock(context, erroredFile) {
973
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
974
+ const fileDiags = context.diagnostics.filter(
975
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
976
+ );
977
+ const lines = fileDiags.map(
978
+ (d) => `${d.file}(${d.line},${d.column}): ${d.code}: ${d.message}`
979
+ );
980
+ return `tsc reports:
981
+ ${lines.join("\n")}
982
+
983
+ Emit SEARCH/REPLACE blocks to resolve.`;
984
+ }
985
+ var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
986
+ const anthropic = createAnthropic({ apiKey });
987
+ const result = await generateText({
988
+ model: anthropic(model),
989
+ system: systemBlock,
990
+ messages: [{ role: "user", content: userBlock }]
991
+ });
992
+ return {
993
+ text: result.text,
994
+ inputTokens: result.usage?.inputTokens ?? 0,
995
+ outputTokens: result.usage?.outputTokens ?? 0
996
+ };
997
+ };
998
+ async function mendSingleFile(opts) {
999
+ const { context, llm, dryRun = false, _callLLM = defaultLLMCall } = opts;
1000
+ const erroredFile = context.erroredFiles[0];
1001
+ if (!erroredFile) {
1002
+ throw new Error("mendSingleFile: no errored files in context");
1003
+ }
1004
+ const systemBlock = buildSystemBlock(context, erroredFile);
1005
+ const userBlock = buildUserBlock(context, erroredFile);
1006
+ const startMs = Date.now();
1007
+ const llmResult = await _callLLM({
1008
+ systemBlock,
1009
+ userBlock,
1010
+ model: llm.model,
1011
+ apiKey: llm.apiKey
1012
+ });
1013
+ const latencyMs = Date.now() - startMs;
1014
+ const rawResponse = llmResult.text;
1015
+ const blocks = parseEditBlocks(rawResponse);
1016
+ const apply = applyEditBlocks({
1017
+ workspaceRoot: context.workspaceRoot,
1018
+ blocks,
1019
+ dryRun
1020
+ });
1021
+ return {
1022
+ rawResponse,
1023
+ blocks,
1024
+ apply,
1025
+ inputTokens: llmResult.inputTokens,
1026
+ outputTokens: llmResult.outputTokens,
1027
+ latencyMs
1028
+ };
1029
+ }
1030
+
1031
+ // src/stubAndContinue.ts
1032
+ import * as fs7 from "node:fs";
1033
+ import * as path7 from "node:path";
1034
+ var noopLogger = { info: () => {
1035
+ }, warn: () => {
1036
+ }, error: () => {
1037
+ } };
1038
+ function groupByLine(diagnostics) {
1039
+ const groups = /* @__PURE__ */ new Map();
1040
+ for (const d of diagnostics) {
1041
+ if (d.category !== "error") continue;
1042
+ const key = `${d.file}::${d.line}`;
1043
+ const list = groups.get(key);
1044
+ if (list) list.push(d);
1045
+ else groups.set(key, [d]);
1046
+ }
1047
+ return groups;
1048
+ }
1049
+ function resolveFile(diagnosticFile, workspaceRoot) {
1050
+ return path7.isAbsolute(diagnosticFile) ? diagnosticFile : path7.resolve(workspaceRoot, diagnosticFile);
1051
+ }
1052
+ function shouldSkipFile(file, workspaceRoot) {
1053
+ const rel = path7.relative(workspaceRoot, file);
1054
+ if (rel.startsWith("node_modules") || rel.includes(`${path7.sep}node_modules${path7.sep}`)) {
1055
+ return "node_modules";
1056
+ }
1057
+ if (file.endsWith(".d.ts")) {
1058
+ return "declaration_file";
1059
+ }
1060
+ if (!fs7.existsSync(file)) {
1061
+ return "file_not_found";
1062
+ }
1063
+ return null;
1064
+ }
1065
+ function lineIsTsSuppression(line) {
1066
+ return /^\s*\/\/\s*@ts-(?:expect-error|ignore)\b/.test(line);
1067
+ }
1068
+ function leadingWhitespace(line) {
1069
+ const match = line.match(/^(\s*)/);
1070
+ return match ? match[1] : "";
1071
+ }
1072
+ function truncate(s, max) {
1073
+ if (s.length <= max) return s;
1074
+ return s.slice(0, max - 1) + "\u2026";
1075
+ }
1076
+ function buildStubComment(group, marker, maxMessageLength) {
1077
+ const codes = Array.from(new Set(group.map((d) => d.code))).sort();
1078
+ const messages = Array.from(new Set(group.map((d) => d.message.replace(/\s+/g, " ").trim()))).join(" | ");
1079
+ const truncated = truncate(messages, maxMessageLength);
1080
+ return `// @ts-expect-error - ${marker}: ${codes.join(", ")} \u2014 ${truncated}`;
1081
+ }
1082
+ function stubAndContinue(opts) {
1083
+ const {
1084
+ workspaceRoot,
1085
+ diagnostics,
1086
+ dryRun = false,
1087
+ logger = noopLogger,
1088
+ stubMarker = "tsfix",
1089
+ maxMessageLength = 120
1090
+ } = opts;
1091
+ const errorOnly = diagnostics.filter((d) => d.category === "error");
1092
+ const grouped = groupByLine(errorOnly);
1093
+ const stubsApplied = [];
1094
+ const skipped = [];
1095
+ const filesEditedSet = /* @__PURE__ */ new Set();
1096
+ const byFile = /* @__PURE__ */ new Map();
1097
+ for (const [key, group] of grouped) {
1098
+ const sepIdx = key.lastIndexOf("::");
1099
+ const rawFile = key.slice(0, sepIdx);
1100
+ const file = resolveFile(rawFile, workspaceRoot);
1101
+ const line = parseInt(key.slice(sepIdx + 2), 10);
1102
+ const list = byFile.get(file) ?? [];
1103
+ list.push({ line, group });
1104
+ byFile.set(file, list);
1105
+ }
1106
+ for (const [file, entries] of byFile) {
1107
+ const skipReason = shouldSkipFile(file, workspaceRoot);
1108
+ if (skipReason !== null) {
1109
+ for (const entry of entries) {
1110
+ skipped.push({
1111
+ file,
1112
+ line: entry.line,
1113
+ codes: Array.from(new Set(entry.group.map((d) => d.code))).sort(),
1114
+ reason: skipReason
1115
+ });
1116
+ }
1117
+ continue;
1118
+ }
1119
+ const source = fs7.readFileSync(file, "utf-8");
1120
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
1121
+ const lines = source.split(/\r?\n/);
1122
+ entries.sort((a, b) => b.line - a.line);
1123
+ let edited = false;
1124
+ for (const { line: errorLine, group } of entries) {
1125
+ const errorIdx = errorLine - 1;
1126
+ if (errorIdx < 0 || errorIdx >= lines.length) {
1127
+ skipped.push({
1128
+ file,
1129
+ line: errorLine,
1130
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1131
+ reason: "file_too_short"
1132
+ });
1133
+ continue;
1134
+ }
1135
+ const lineAbove = errorIdx > 0 ? lines[errorIdx - 1] : "";
1136
+ if (lineIsTsSuppression(lineAbove)) {
1137
+ skipped.push({
1138
+ file,
1139
+ line: errorLine,
1140
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1141
+ reason: "already_stubbed"
1142
+ });
1143
+ continue;
1144
+ }
1145
+ const indent = leadingWhitespace(lines[errorIdx]);
1146
+ const commentText = buildStubComment(group, stubMarker, maxMessageLength);
1147
+ const commentLineWithIndent = `${indent}${commentText}`;
1148
+ lines.splice(errorIdx, 0, commentLineWithIndent);
1149
+ edited = true;
1150
+ stubsApplied.push({
1151
+ file,
1152
+ errorLine,
1153
+ // original line as reported by tsc; in the file post-stub, the comment is here and the code is at errorLine+1
1154
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1155
+ commentText
1156
+ });
1157
+ }
1158
+ if (edited) {
1159
+ filesEditedSet.add(file);
1160
+ if (!dryRun) {
1161
+ fs7.writeFileSync(file, lines.join(eol), "utf-8");
1162
+ logger.info(`[stub-and-continue] stubbed ${entries.length} site(s) in ${path7.relative(workspaceRoot, file)}`);
1163
+ } else {
1164
+ logger.info(`[stub-and-continue] (dry-run) would stub ${entries.length} site(s) in ${path7.relative(workspaceRoot, file)}`);
1165
+ }
1166
+ }
1167
+ }
1168
+ return {
1169
+ stubsApplied,
1170
+ skipped,
1171
+ filesEdited: Array.from(filesEditedSet),
1172
+ diagnosticsBefore: errorOnly.length,
1173
+ // Each applied stub suppresses every diagnostic on its line. Compare
1174
+ // after resolving raw diagnostic paths to absolute, since stubsApplied
1175
+ // stores absolute paths but the input diagnostics may be relative.
1176
+ diagnosticsAfter: errorOnly.length - stubsApplied.reduce((acc, s) => {
1177
+ const onLine = errorOnly.filter(
1178
+ (d) => resolveFile(d.file, workspaceRoot) === s.file && d.line === s.errorLine
1179
+ ).length;
1180
+ return acc + onLine;
1181
+ }, 0)
1182
+ };
1183
+ }
1184
+
1185
+ // src/runMendLoop.ts
1186
+ var noopLogger2 = { info: () => {
1187
+ }, warn: () => {
1188
+ }, error: () => {
1189
+ } };
1190
+ function errorSignature(d) {
1191
+ return `${d.file}:${d.line}:${d.column}:${d.code}`;
1192
+ }
1193
+ function signatureSet(diags) {
1194
+ const out = /* @__PURE__ */ new Set();
1195
+ for (const d of diags) {
1196
+ if (d.category === "error") out.add(errorSignature(d));
1197
+ }
1198
+ return out;
1199
+ }
1200
+ function setsEqual(a, b) {
1201
+ if (a.size !== b.size) return false;
1202
+ for (const x of a) if (!b.has(x)) return false;
1203
+ return true;
1204
+ }
1205
+ function refreshDiagnostics(workspaceRoot, files) {
1206
+ resetInProcessTscCache();
1207
+ const result = runInProcessTsc({
1208
+ workspaceRoot,
1209
+ generatedFiles: files,
1210
+ logger: noopLogger2
1211
+ });
1212
+ return result.diagnostics.filter((d) => d.category === "error");
1213
+ }
1214
+ async function runMendLoop(opts) {
1215
+ const { context: rawContext, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1216
+ const startMs = Date.now();
1217
+ const context = rawContext.libraryMigrations === void 0 ? { ...rawContext, libraryMigrations: detectLibraryMigrations(rawContext.workspaceRoot) } : rawContext;
1218
+ const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
1219
+ if (diagnosticsBefore.length === 0) {
1220
+ return {
1221
+ iterations: [],
1222
+ diagnosticsBefore,
1223
+ diagnosticsAfter: [],
1224
+ passed: true,
1225
+ stopReason: "noErrors",
1226
+ totalInputTokens: 0,
1227
+ totalOutputTokens: 0,
1228
+ totalLatencyMs: Date.now() - startMs
1229
+ };
1230
+ }
1231
+ const filesInScope = Array.from(new Set(context.diagnostics.map((d) => d.file)));
1232
+ const iterations = [];
1233
+ let currentDiags = diagnosticsBefore;
1234
+ let prevSig = signatureSet(currentDiags);
1235
+ let stopReason = "maxIterations";
1236
+ let totalInputTokens = 0;
1237
+ let totalOutputTokens = 0;
1238
+ for (let i = 0; i < maxIterations; i++) {
1239
+ const erroredFiles = Array.from(new Set(currentDiags.map((d) => d.file)));
1240
+ const iterContext = {
1241
+ ...context,
1242
+ diagnostics: currentDiags,
1243
+ erroredFiles
1244
+ };
1245
+ const mend = await mendSingleFile({
1246
+ context: iterContext,
1247
+ llm,
1248
+ dryRun,
1249
+ _callLLM
1250
+ });
1251
+ totalInputTokens += mend.inputTokens;
1252
+ totalOutputTokens += mend.outputTokens;
1253
+ const newDiags = dryRun ? currentDiags : refreshDiagnostics(context.workspaceRoot, filesInScope);
1254
+ const newSig = signatureSet(newDiags);
1255
+ iterations.push({
1256
+ index: i,
1257
+ diagnosticsBefore: currentDiags.length,
1258
+ diagnosticsAfter: newDiags.length,
1259
+ patchesApplied: mend.apply.applied,
1260
+ patchesFailed: mend.apply.failures.length,
1261
+ inputTokens: mend.inputTokens,
1262
+ outputTokens: mend.outputTokens,
1263
+ latencyMs: mend.latencyMs,
1264
+ rawResponse: mend.rawResponse
1265
+ });
1266
+ if (dryRun) {
1267
+ currentDiags = newDiags;
1268
+ stopReason = "maxIterations";
1269
+ break;
1270
+ }
1271
+ if (newDiags.length === 0) {
1272
+ stopReason = "fixed";
1273
+ currentDiags = newDiags;
1274
+ break;
1275
+ }
1276
+ if (newSig.size > prevSig.size) {
1277
+ stopReason = "regressed";
1278
+ currentDiags = newDiags;
1279
+ break;
1280
+ }
1281
+ if (setsEqual(newSig, prevSig)) {
1282
+ stopReason = "noProgress";
1283
+ currentDiags = newDiags;
1284
+ break;
1285
+ }
1286
+ currentDiags = newDiags;
1287
+ prevSig = newSig;
1288
+ }
1289
+ let stubs;
1290
+ if (stubOnFailure && !dryRun && currentDiags.length > 0) {
1291
+ const stubResult = stubAndContinue({
1292
+ workspaceRoot: context.workspaceRoot,
1293
+ diagnostics: currentDiags
1294
+ });
1295
+ stubs = stubResult.stubsApplied;
1296
+ const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1297
+ if (postStubDiags.length === 0) {
1298
+ stopReason = "stubbed";
1299
+ }
1300
+ currentDiags = postStubDiags;
1301
+ }
1302
+ return {
1303
+ iterations,
1304
+ diagnosticsBefore,
1305
+ diagnosticsAfter: currentDiags,
1306
+ passed: currentDiags.length === 0,
1307
+ stopReason,
1308
+ totalInputTokens,
1309
+ totalOutputTokens,
1310
+ totalLatencyMs: Date.now() - startMs,
1311
+ ...stubs !== void 0 ? { stubs } : {}
1312
+ };
1313
+ }
481
1314
 
482
1315
  // src/index.ts
483
- var noopLogger = {
1316
+ var noopLogger3 = {
484
1317
  info: () => {
485
1318
  },
486
1319
  warn: () => {
@@ -494,7 +1327,7 @@ function discoverTsFiles(workspaceRoot) {
494
1327
  const walk = (dir) => {
495
1328
  let entries;
496
1329
  try {
497
- entries = fs3.readdirSync(dir, { withFileTypes: true });
1330
+ entries = fs8.readdirSync(dir, { withFileTypes: true });
498
1331
  } catch {
499
1332
  return;
500
1333
  }
@@ -503,10 +1336,10 @@ function discoverTsFiles(workspaceRoot) {
503
1336
  if (skip.has(e.name)) {
504
1337
  continue;
505
1338
  }
506
- walk(path3.join(dir, e.name));
1339
+ walk(path8.join(dir, e.name));
507
1340
  } else if (e.isFile() && !e.name.endsWith(".d.ts")) {
508
1341
  if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
509
- out.push(path3.relative(workspaceRoot, path3.join(dir, e.name)));
1342
+ out.push(path8.relative(workspaceRoot, path8.join(dir, e.name)));
510
1343
  }
511
1344
  }
512
1345
  }
@@ -516,11 +1349,11 @@ function discoverTsFiles(workspaceRoot) {
516
1349
  }
517
1350
  function runValidationLoop(opts) {
518
1351
  const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
519
- const logger = opts.logger ?? noopLogger;
520
- if (!fs3.existsSync(workspaceRoot)) {
1352
+ const logger = opts.logger ?? noopLogger3;
1353
+ if (!fs8.existsSync(workspaceRoot)) {
521
1354
  throw new Error(`workspace not found: ${workspaceRoot}`);
522
1355
  }
523
- if (!fs3.existsSync(path3.join(workspaceRoot, "tsconfig.json"))) {
1356
+ if (!fs8.existsSync(path8.join(workspaceRoot, "tsconfig.json"))) {
524
1357
  throw new Error(`no tsconfig.json in ${workspaceRoot}`);
525
1358
  }
526
1359
  const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
@@ -569,6 +1402,16 @@ function runValidationLoop(opts) {
569
1402
  }
570
1403
 
571
1404
  // cli/run-stack.ts
1405
+ var ANTHROPIC_PRICING = {
1406
+ "claude-haiku-4-5": { input: 0.8, output: 4 },
1407
+ "claude-sonnet-4-5": { input: 3, output: 15 },
1408
+ "claude-opus-4-7": { input: 15, output: 75 }
1409
+ };
1410
+ function estimateCostUsd(model, inputTokens, outputTokens) {
1411
+ const p = ANTHROPIC_PRICING[model];
1412
+ if (!p) return 0;
1413
+ return (inputTokens * p.input + outputTokens * p.output) / 1e6;
1414
+ }
572
1415
  function parseArgs(argv) {
573
1416
  const args = {
574
1417
  workspace: "",
@@ -576,7 +1419,12 @@ function parseArgs(argv) {
576
1419
  noLsp: false,
577
1420
  dryRun: false,
578
1421
  files: void 0,
579
- verbose: false
1422
+ verbose: false,
1423
+ llm: false,
1424
+ llmModel: "claude-haiku-4-5",
1425
+ llmMaxIterations: 3,
1426
+ llmBudgetUsd: void 0,
1427
+ noLibraryHints: false
580
1428
  };
581
1429
  for (let i = 0; i < argv.length; i++) {
582
1430
  const a = argv[i];
@@ -592,6 +1440,26 @@ function parseArgs(argv) {
592
1440
  args.files = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
593
1441
  } else if (a === "--verbose" || a === "-v") {
594
1442
  args.verbose = true;
1443
+ } else if (a === "--llm") {
1444
+ args.llm = true;
1445
+ } else if (a === "--llm-model") {
1446
+ args.llmModel = argv[++i] ?? args.llmModel;
1447
+ } else if (a === "--llm-max-iterations") {
1448
+ const n = parseInt(argv[++i] ?? "", 10);
1449
+ if (Number.isNaN(n) || n < 1) {
1450
+ console.error(`error: --llm-max-iterations expects a positive integer, got '${argv[i]}'`);
1451
+ process.exit(2);
1452
+ }
1453
+ args.llmMaxIterations = n;
1454
+ } else if (a === "--llm-budget-usd") {
1455
+ const v = parseFloat(argv[++i] ?? "");
1456
+ if (Number.isNaN(v) || v < 0) {
1457
+ console.error(`error: --llm-budget-usd expects a positive number, got '${argv[i]}'`);
1458
+ process.exit(2);
1459
+ }
1460
+ args.llmBudgetUsd = v;
1461
+ } else if (a === "--no-library-hints") {
1462
+ args.noLibraryHints = true;
595
1463
  } else if (a === "--help" || a === "-h") {
596
1464
  printHelp();
597
1465
  process.exit(0);
@@ -606,22 +1474,39 @@ function parseArgs(argv) {
606
1474
  }
607
1475
  function printHelp() {
608
1476
  console.error(`
609
- Usage: run-stack --workspace <path> [options]
610
-
611
- Options:
612
- --workspace, -w <path> Workspace root (required)
613
- --files <list> Comma-separated file paths to scope tsc/lsp to (default: all .ts/.tsx)
614
- --no-lsp Skip Layer 0 LSP auto-fixer
615
- --dry-run Run the LSP fixer in memory; do NOT write changes
616
- to disk. Lists files that would be edited.
617
- --json Emit JSON report on stdout
618
- --verbose, -v Stream layer logs to stderr
619
- --help, -h Show this help
1477
+ Usage: tsfix --workspace <path> [options]
1478
+
1479
+ Layer 0/1 (default \u2014 deterministic, no network):
1480
+ --workspace, -w <path> Workspace root (required)
1481
+ --files <a.ts,b.ts> Scope tsc/lsp to this comma-separated list
1482
+ --no-lsp Skip Layer 0 LSP auto-fixer (validate only)
1483
+ --dry-run Run fixer in memory; list edits but don't write
1484
+ --json Emit JSON report on stdout
1485
+ --verbose, -v Stream layer logs to stderr
1486
+ --help, -h Show this help
1487
+
1488
+ Layer 2 (opt-in \u2014 single-file LLM mend via Anthropic):
1489
+ --llm Enable Layer 2 on errors that survive Layer 0/1
1490
+ --llm-model <name> Anthropic model (default: claude-haiku-4-5)
1491
+ Known-priced models: claude-haiku-4-5,
1492
+ claude-sonnet-4-5, claude-opus-4-7.
1493
+ Cost estimate is 0 for unknown models.
1494
+ --llm-max-iterations <N> Cap on LLM retries (default: 3)
1495
+ --llm-budget-usd <amount> Soft cost cap. Exits with code 3 if exceeded.
1496
+ --no-library-hints Disable auto-detection of library breaking-change
1497
+ hints (vite-plugin-svgr v4 ?react migration,
1498
+ Next.js 15 async params, etc.). When workspace
1499
+ package.json contains a known migration target,
1500
+ tsfix injects hints into Layer 2's prompt + skips
1501
+ Layer 0/1 (whose quick-fix would conflict).
1502
+
1503
+ Layer 2 requires ANTHROPIC_API_KEY in the environment.
620
1504
 
621
1505
  Exit codes:
622
1506
  0 no errors after stack
623
1507
  1 errors remain after stack
624
1508
  2 bad arguments / harness error
1509
+ 3 Layer 2 budget exceeded (errors may still remain; partial work persisted)
625
1510
  `.trim());
626
1511
  }
627
1512
  function makeLogger(captureLines, verbose) {
@@ -658,6 +1543,15 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
658
1543
  }
659
1544
  } else {
660
1545
  w.write(` LSP fixer: skipped
1546
+ `);
1547
+ }
1548
+ if (r.layer2) {
1549
+ const l2 = r.layer2;
1550
+ w.write(
1551
+ ` 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" : ""}
1552
+ `
1553
+ );
1554
+ w.write(` model=${l2.model} \xB7 stopReason=${l2.stopReason}
661
1555
  `);
662
1556
  }
663
1557
  w.write(` errors after: ${r.errorsAfter}
@@ -679,12 +1573,12 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
679
1573
  }
680
1574
  async function main() {
681
1575
  const args = parseArgs(process.argv.slice(2));
682
- const workspaceRoot = path4.resolve(args.workspace);
683
- if (!fs4.existsSync(workspaceRoot)) {
1576
+ const workspaceRoot = path9.resolve(args.workspace);
1577
+ if (!fs9.existsSync(workspaceRoot)) {
684
1578
  console.error(`error: workspace not found: ${workspaceRoot}`);
685
1579
  return 2;
686
1580
  }
687
- if (!fs4.existsSync(path4.join(workspaceRoot, "tsconfig.json"))) {
1581
+ if (!fs9.existsSync(path9.join(workspaceRoot, "tsconfig.json"))) {
688
1582
  console.error(`error: no tsconfig.json in ${workspaceRoot}`);
689
1583
  return 2;
690
1584
  }
@@ -695,17 +1589,26 @@ async function main() {
695
1589
  console.error("error: no .ts/.tsx files found in workspace");
696
1590
  return 2;
697
1591
  }
1592
+ const libraryMigrations = args.noLibraryHints ? [] : detectLibraryMigrations(workspaceRoot);
1593
+ const migrationApplies = args.llm && libraryMigrations.length > 0;
1594
+ if (migrationApplies && !args.noLsp) {
1595
+ logger.info(
1596
+ `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.`
1597
+ );
1598
+ }
1599
+ const effectiveNoLsp = args.noLsp || migrationApplies;
698
1600
  const loop = runValidationLoop({
699
1601
  workspaceRoot,
700
1602
  targetFiles,
701
- skipLSPFixer: args.noLsp,
1603
+ skipLSPFixer: effectiveNoLsp,
702
1604
  dryRun: args.dryRun,
703
1605
  logger
704
1606
  });
705
1607
  const report = {
706
- workspace: path4.relative(process.cwd(), workspaceRoot) || workspaceRoot,
1608
+ workspace: path9.relative(process.cwd(), workspaceRoot) || workspaceRoot,
707
1609
  errorsBefore: loop.errorsBefore,
708
1610
  lspFixer: args.noLsp ? { ran: false, fixesApplied: 0, filesEdited: [], iterations: 0 } : loop.lspFixer,
1611
+ layer2: null,
709
1612
  errorsAfter: loop.errorsAfter,
710
1613
  remainingByCode: loop.remainingByCode,
711
1614
  remainingByFile: loop.remainingByFile,
@@ -713,11 +1616,79 @@ async function main() {
713
1616
  elapsedMs: loop.elapsedMs,
714
1617
  dryRun: args.dryRun
715
1618
  };
1619
+ let budgetExceeded = false;
1620
+ if (args.llm && loop.errorsAfter > 0) {
1621
+ if (args.dryRun) {
1622
+ console.error("error: --llm and --dry-run are mutually exclusive (Layer 2 writes patches to disk)");
1623
+ return 2;
1624
+ }
1625
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1626
+ if (!apiKey) {
1627
+ console.error("error: --llm requires ANTHROPIC_API_KEY in the environment");
1628
+ return 2;
1629
+ }
1630
+ if (!ANTHROPIC_PRICING[args.llmModel]) {
1631
+ logger.warn(
1632
+ `unknown model '${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1633
+ );
1634
+ }
1635
+ const errorDiags = loop.diagnostics.filter((d) => d.category === "error");
1636
+ const context = {
1637
+ workspaceRoot,
1638
+ diagnostics: errorDiags,
1639
+ erroredFiles: Array.from(new Set(errorDiags.map((d) => d.file))),
1640
+ // Explicitly pass migrations so `runMendLoop` doesn't re-detect.
1641
+ // `[]` is meaningful — it means "we know there are none" — vs
1642
+ // `undefined` which would trigger auto-detect.
1643
+ libraryMigrations
1644
+ };
1645
+ const layer2Start = Date.now();
1646
+ const mend = await runMendLoop({
1647
+ context,
1648
+ llm: { provider: "anthropic", model: args.llmModel, apiKey },
1649
+ maxIterations: args.llmMaxIterations
1650
+ });
1651
+ void layer2Start;
1652
+ const totalCostUsd = estimateCostUsd(
1653
+ args.llmModel,
1654
+ mend.totalInputTokens,
1655
+ mend.totalOutputTokens
1656
+ );
1657
+ budgetExceeded = args.llmBudgetUsd !== void 0 && totalCostUsd > args.llmBudgetUsd;
1658
+ report.layer2 = {
1659
+ ran: true,
1660
+ stopReason: mend.stopReason,
1661
+ errorsBefore: errorDiags.length,
1662
+ errorsAfter: mend.diagnosticsAfter.length,
1663
+ iterations: mend.iterations.length,
1664
+ totalInputTokens: mend.totalInputTokens,
1665
+ totalOutputTokens: mend.totalOutputTokens,
1666
+ totalCostUsd,
1667
+ budgetExceeded,
1668
+ model: args.llmModel
1669
+ };
1670
+ const post = runInProcessTsc({
1671
+ workspaceRoot,
1672
+ generatedFiles: targetFiles,
1673
+ logger
1674
+ });
1675
+ const postErrorDiags = post.diagnostics.filter((d) => d.category === "error");
1676
+ report.errorsAfter = postErrorDiags.length;
1677
+ report.remainingByCode = {};
1678
+ report.remainingByFile = {};
1679
+ for (const d of postErrorDiags) {
1680
+ report.remainingByCode[d.code] = (report.remainingByCode[d.code] ?? 0) + 1;
1681
+ report.remainingByFile[d.file] = (report.remainingByFile[d.file] ?? 0) + 1;
1682
+ }
1683
+ report.passed = report.errorsAfter === 0;
1684
+ report.elapsedMs = loop.elapsedMs + (Date.now() - layer2Start);
1685
+ }
716
1686
  if (args.json) {
717
1687
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
718
1688
  } else {
719
1689
  printHumanReport(report);
720
1690
  }
1691
+ if (budgetExceeded) return 3;
721
1692
  return report.passed ? 0 : 1;
722
1693
  }
723
1694
  main().then(