@shipispec/tsfix 0.3.0 → 0.5.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 path8 from "node:path";
5
+ import * as fs8 from "node:fs";
6
6
 
7
7
  // src/validatorInProcess.ts
8
8
  import * as fs from "node:fs";
@@ -473,9 +473,737 @@ function applyFixToSnapshots(fix, snapshots) {
473
473
  }
474
474
 
475
475
  // src/index.ts
476
+ import * as fs7 from "node:fs";
477
+ import * as path7 from "node:path";
478
+
479
+ // src/typeContext.ts
476
480
  import * as fs3 from "node:fs";
477
481
  import * as path3 from "node:path";
478
- var noopLogger = {
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
+ const type = checker.getTypeAtLocation(n);
553
+ const symbol = type.getSymbol() ?? type.aliasSymbol;
554
+ const declarations = symbol?.getDeclarations();
555
+ if (!declarations || declarations.length === 0) return void 0;
556
+ const nonLib = declarations.find((d) => !isLibFile(d.getSourceFile().fileName));
557
+ if (!nonLib) return void 0;
558
+ return { decl: nonLib, symbolName: symbol?.getName() ?? "(unnamed)" };
559
+ };
560
+ let node = startNode;
561
+ for (let i = 0; i < maxWalkUp && node; i++) {
562
+ const direct = tryResolve(node);
563
+ if (direct) return direct;
564
+ if (ts3.isPropertyAccessExpression(node) || ts3.isElementAccessExpression(node)) {
565
+ const sibling = tryResolve(node.expression);
566
+ if (sibling) return sibling;
567
+ }
568
+ node = node.parent;
569
+ }
570
+ return void 0;
571
+ }
572
+ function getTypeContext(opts) {
573
+ const { workspaceRoot, diagnostic } = opts;
574
+ const errorPadding = opts.errorPadding ?? 3;
575
+ const declarationPadding = opts.declarationPadding ?? 20;
576
+ const filePath = path3.isAbsolute(diagnostic.file) ? diagnostic.file : path3.join(workspaceRoot, diagnostic.file);
577
+ const fileContent = fs3.existsSync(filePath) ? fs3.readFileSync(filePath, "utf-8") : "";
578
+ const errorSite = {
579
+ file: diagnostic.file,
580
+ lines: sliceLines(fileContent, diagnostic.line, errorPadding)
581
+ };
582
+ const program = buildProgram(workspaceRoot);
583
+ if (!program) return { errorSite };
584
+ const sourceFile = program.getSourceFile(filePath);
585
+ if (!sourceFile) return { errorSite };
586
+ let position;
587
+ try {
588
+ position = ts3.getPositionOfLineAndCharacter(
589
+ sourceFile,
590
+ diagnostic.line - 1,
591
+ diagnostic.column - 1
592
+ );
593
+ } catch {
594
+ return { errorSite };
595
+ }
596
+ const errorNode = getNodeAtPosition(sourceFile, position);
597
+ const checker = program.getTypeChecker();
598
+ const found = findTypeDeclaration(checker, errorNode);
599
+ if (!found) return { errorSite };
600
+ const declSourceFile = found.decl.getSourceFile();
601
+ const declStart = found.decl.getStart(declSourceFile);
602
+ const { line: declLine0 } = ts3.getLineAndCharacterOfPosition(declSourceFile, declStart);
603
+ return {
604
+ errorSite,
605
+ typeDeclaration: {
606
+ file: path3.relative(workspaceRoot, declSourceFile.fileName) || declSourceFile.fileName,
607
+ lines: sliceLines(declSourceFile.text, declLine0 + 1, declarationPadding),
608
+ symbol: found.symbolName
609
+ }
610
+ };
611
+ }
612
+
613
+ // src/applyEditBlock.ts
614
+ import * as fs4 from "node:fs";
615
+ import * as path4 from "node:path";
616
+ var SEARCH_MARKER = "<<<<<<< SEARCH";
617
+ var SEPARATOR = "=======";
618
+ var REPLACE_MARKER = ">>>>>>> REPLACE";
619
+ function extractFilePath(line) {
620
+ const trimmed = line.trim();
621
+ const attrMatch = trimmed.match(/<\s*file\s+path\s*=\s*["']([^"']+)["']\s*\/?>/i);
622
+ if (attrMatch) return attrMatch[1];
623
+ return trimmed.replace(/^[`'"]+|[`'"]+$/g, "");
624
+ }
625
+ function parseEditBlocks(llmOutput) {
626
+ const blocks = [];
627
+ const lines = llmOutput.split("\n");
628
+ let i = 0;
629
+ while (i < lines.length) {
630
+ while (i < lines.length && lines[i].trim() !== SEARCH_MARKER) {
631
+ i++;
632
+ }
633
+ if (i >= lines.length) {
634
+ break;
635
+ }
636
+ let fileIdx = i - 1;
637
+ while (fileIdx >= 0) {
638
+ const trimmed = lines[fileIdx].trim();
639
+ if (trimmed === "" || trimmed.startsWith("```") || trimmed.startsWith("</")) {
640
+ fileIdx--;
641
+ continue;
642
+ }
643
+ break;
644
+ }
645
+ const filePath = fileIdx >= 0 ? extractFilePath(lines[fileIdx]) : "";
646
+ i++;
647
+ const searchLines = [];
648
+ while (i < lines.length && lines[i].trim() !== SEPARATOR) {
649
+ searchLines.push(lines[i]);
650
+ i++;
651
+ }
652
+ if (i >= lines.length) {
653
+ break;
654
+ }
655
+ i++;
656
+ const replaceLines = [];
657
+ while (i < lines.length && lines[i].trim() !== REPLACE_MARKER) {
658
+ replaceLines.push(lines[i]);
659
+ i++;
660
+ }
661
+ if (i >= lines.length) {
662
+ break;
663
+ }
664
+ blocks.push({
665
+ file: filePath,
666
+ search: searchLines.join("\n"),
667
+ replace: replaceLines.join("\n")
668
+ });
669
+ i++;
670
+ }
671
+ return blocks;
672
+ }
673
+ function countOccurrences(haystack, needle) {
674
+ if (needle.length === 0) return 0;
675
+ let count = 0;
676
+ let pos = 0;
677
+ while (true) {
678
+ const idx = haystack.indexOf(needle, pos);
679
+ if (idx < 0) return count;
680
+ count++;
681
+ pos = idx + needle.length;
682
+ }
683
+ }
684
+ function rstripPerLine(text) {
685
+ return text.split("\n").map((l) => l.replace(/\s+$/, "")).join("\n");
686
+ }
687
+ function stripPerLine(text) {
688
+ return text.split("\n").map((l) => l.trim()).join("\n");
689
+ }
690
+ function spliceLines(originalContent, normalizedContent, normalizedSearch, replace) {
691
+ const idx = normalizedContent.indexOf(normalizedSearch);
692
+ if (idx < 0) return void 0;
693
+ const linesBefore = normalizedContent.slice(0, idx).split("\n").length - 1;
694
+ const matchLineCount = normalizedSearch.split("\n").length;
695
+ const origLines = originalContent.split("\n");
696
+ const before = origLines.slice(0, linesBefore);
697
+ const after = origLines.slice(linesBefore + matchLineCount);
698
+ return [...before, replace, ...after].join("\n");
699
+ }
700
+ function applySingleBlock(fileContent, search, replace) {
701
+ if (search === "") {
702
+ return { error: "empty search block" };
703
+ }
704
+ const exactCount = countOccurrences(fileContent, search);
705
+ if (exactCount === 1) {
706
+ return { newContent: fileContent.replace(search, replace), matchedTier: "exact" };
707
+ }
708
+ if (exactCount > 1) {
709
+ return { error: `ambiguous: ${exactCount} exact matches` };
710
+ }
711
+ const rstripContent = rstripPerLine(fileContent);
712
+ const rstripSearch = rstripPerLine(search);
713
+ const rstripCount = countOccurrences(rstripContent, rstripSearch);
714
+ if (rstripCount === 1) {
715
+ const out = spliceLines(fileContent, rstripContent, rstripSearch, replace);
716
+ if (out !== void 0) {
717
+ return { newContent: out, matchedTier: "rstrip" };
718
+ }
719
+ }
720
+ if (rstripCount > 1) {
721
+ return { error: `ambiguous: ${rstripCount} rstrip matches` };
722
+ }
723
+ const stripContent = stripPerLine(fileContent);
724
+ const stripSearch = stripPerLine(search);
725
+ const stripCount = countOccurrences(stripContent, stripSearch);
726
+ if (stripCount === 1) {
727
+ const out = spliceLines(fileContent, stripContent, stripSearch, replace);
728
+ if (out !== void 0) {
729
+ return { newContent: out, matchedTier: "strip" };
730
+ }
731
+ }
732
+ if (stripCount > 1) {
733
+ return { error: `ambiguous: ${stripCount} strip matches` };
734
+ }
735
+ return { error: "no match" };
736
+ }
737
+ function applyEditBlocks(opts) {
738
+ const { workspaceRoot, blocks, dryRun = false } = opts;
739
+ const fileSnapshots = /* @__PURE__ */ new Map();
740
+ const failures = [];
741
+ const filesEdited = /* @__PURE__ */ new Set();
742
+ let applied = 0;
743
+ for (const block of blocks) {
744
+ const filePath = path4.isAbsolute(block.file) ? block.file : path4.join(workspaceRoot, block.file);
745
+ let content = fileSnapshots.get(filePath);
746
+ if (content === void 0) {
747
+ try {
748
+ content = fs4.readFileSync(filePath, "utf-8");
749
+ } catch (err) {
750
+ failures.push({
751
+ block,
752
+ reason: `cannot read file: ${err instanceof Error ? err.message : String(err)}`
753
+ });
754
+ continue;
755
+ }
756
+ }
757
+ const result = applySingleBlock(content, block.search, block.replace);
758
+ if ("error" in result) {
759
+ failures.push({ block, reason: result.error });
760
+ continue;
761
+ }
762
+ fileSnapshots.set(filePath, result.newContent);
763
+ filesEdited.add(filePath);
764
+ applied++;
765
+ }
766
+ if (!dryRun) {
767
+ for (const [filePath, content] of fileSnapshots) {
768
+ try {
769
+ fs4.writeFileSync(filePath, content);
770
+ } catch (err) {
771
+ failures.push({
772
+ block: { file: filePath, search: "", replace: "" },
773
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`
774
+ });
775
+ }
776
+ }
777
+ }
778
+ return {
779
+ blocks,
780
+ applied,
781
+ filesEdited: Array.from(filesEdited),
782
+ failures
783
+ };
784
+ }
785
+
786
+ // src/mendAgent.ts
787
+ import * as fs5 from "node:fs";
788
+ import * as path5 from "node:path";
789
+ import { generateText } from "ai";
790
+ import { createAnthropic } from "@ai-sdk/anthropic";
791
+ 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
+
793
+ Output ONLY SEARCH/REPLACE blocks. No prose, no explanations, no XML wrappers.
794
+
795
+ 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:
796
+
797
+ src/api.ts
798
+ <<<<<<< SEARCH
799
+ const x = 1;
800
+ =======
801
+ const x: number = 1;
802
+ >>>>>>> REPLACE
803
+
804
+ Rules:
805
+ - The file path is a plain line. Do not wrap it in tags, fences, or quotes.
806
+ - SEARCH text must match the file VERBATIM. Whitespace, indentation, line endings: copy exactly.
807
+ - Make SEARCH unique. If a one-line search would match multiple places in the file, include 1-2 lines of surrounding context.
808
+ - REPLACE must be valid TypeScript that resolves the diagnostic.
809
+ - Do not invent imports, types, properties, or values. Use only what the type-context section shows.
810
+ - One SEARCH/REPLACE block per logical change.
811
+ - If you cannot resolve a diagnostic with the information given, omit a block for it.`;
812
+ function workspaceRelative(workspaceRoot, p) {
813
+ return path5.isAbsolute(p) ? path5.relative(workspaceRoot, p) : p;
814
+ }
815
+ function buildSystemBlock(context, erroredFile) {
816
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
817
+ const absPath = path5.isAbsolute(erroredFile) ? erroredFile : path5.join(context.workspaceRoot, erroredFile);
818
+ let fileContent;
819
+ try {
820
+ fileContent = fs5.readFileSync(absPath, "utf-8");
821
+ } catch {
822
+ fileContent = "(file unreadable)";
823
+ }
824
+ const fileDiags = context.diagnostics.filter(
825
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
826
+ );
827
+ const typeContexts = [];
828
+ const seen = /* @__PURE__ */ new Set();
829
+ for (const diag of fileDiags) {
830
+ const ctx = getTypeContext({
831
+ workspaceRoot: context.workspaceRoot,
832
+ diagnostic: diag
833
+ });
834
+ if (!ctx.typeDeclaration) continue;
835
+ const key = `${ctx.typeDeclaration.file}:${ctx.typeDeclaration.symbol}`;
836
+ if (seen.has(key)) continue;
837
+ seen.add(key);
838
+ typeContexts.push(
839
+ `// type: ${ctx.typeDeclaration.symbol}
840
+ // file: ${ctx.typeDeclaration.file}
841
+ ` + ctx.typeDeclaration.lines
842
+ );
843
+ }
844
+ const parts = [
845
+ SYSTEM_INSTRUCTIONS,
846
+ "",
847
+ `### file: ${wsRel}`,
848
+ "```ts",
849
+ fileContent.replace(/\n$/, ""),
850
+ "```"
851
+ ];
852
+ if (typeContexts.length > 0) {
853
+ parts.push("", "### type-context");
854
+ for (const tc of typeContexts) {
855
+ parts.push("```ts", tc, "```");
856
+ }
857
+ }
858
+ if (context.taskDescription) {
859
+ parts.push("", `### task`, context.taskDescription);
860
+ }
861
+ return parts.join("\n");
862
+ }
863
+ function buildUserBlock(context, erroredFile) {
864
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
865
+ const fileDiags = context.diagnostics.filter(
866
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
867
+ );
868
+ const lines = fileDiags.map(
869
+ (d) => `${d.file}(${d.line},${d.column}): ${d.code}: ${d.message}`
870
+ );
871
+ return `tsc reports:
872
+ ${lines.join("\n")}
873
+
874
+ Emit SEARCH/REPLACE blocks to resolve.`;
875
+ }
876
+ var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
877
+ const anthropic = createAnthropic({ apiKey });
878
+ const result = await generateText({
879
+ model: anthropic(model),
880
+ system: systemBlock,
881
+ messages: [{ role: "user", content: userBlock }]
882
+ });
883
+ return {
884
+ text: result.text,
885
+ inputTokens: result.usage?.inputTokens ?? 0,
886
+ outputTokens: result.usage?.outputTokens ?? 0
887
+ };
888
+ };
889
+ async function mendSingleFile(opts) {
890
+ const { context, llm, dryRun = false, _callLLM = defaultLLMCall } = opts;
891
+ const erroredFile = context.erroredFiles[0];
892
+ if (!erroredFile) {
893
+ throw new Error("mendSingleFile: no errored files in context");
894
+ }
895
+ const systemBlock = buildSystemBlock(context, erroredFile);
896
+ const userBlock = buildUserBlock(context, erroredFile);
897
+ const startMs = Date.now();
898
+ const llmResult = await _callLLM({
899
+ systemBlock,
900
+ userBlock,
901
+ model: llm.model,
902
+ apiKey: llm.apiKey
903
+ });
904
+ const latencyMs = Date.now() - startMs;
905
+ const rawResponse = llmResult.text;
906
+ const blocks = parseEditBlocks(rawResponse);
907
+ const apply = applyEditBlocks({
908
+ workspaceRoot: context.workspaceRoot,
909
+ blocks,
910
+ dryRun
911
+ });
912
+ return {
913
+ rawResponse,
914
+ blocks,
915
+ apply,
916
+ inputTokens: llmResult.inputTokens,
917
+ outputTokens: llmResult.outputTokens,
918
+ latencyMs
919
+ };
920
+ }
921
+
922
+ // src/stubAndContinue.ts
923
+ import * as fs6 from "node:fs";
924
+ import * as path6 from "node:path";
925
+ var noopLogger = { info: () => {
926
+ }, warn: () => {
927
+ }, error: () => {
928
+ } };
929
+ function groupByLine(diagnostics) {
930
+ const groups = /* @__PURE__ */ new Map();
931
+ for (const d of diagnostics) {
932
+ if (d.category !== "error") continue;
933
+ const key = `${d.file}::${d.line}`;
934
+ const list = groups.get(key);
935
+ if (list) list.push(d);
936
+ else groups.set(key, [d]);
937
+ }
938
+ return groups;
939
+ }
940
+ function resolveFile(diagnosticFile, workspaceRoot) {
941
+ return path6.isAbsolute(diagnosticFile) ? diagnosticFile : path6.resolve(workspaceRoot, diagnosticFile);
942
+ }
943
+ 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}`)) {
946
+ return "node_modules";
947
+ }
948
+ if (file.endsWith(".d.ts")) {
949
+ return "declaration_file";
950
+ }
951
+ if (!fs6.existsSync(file)) {
952
+ return "file_not_found";
953
+ }
954
+ return null;
955
+ }
956
+ function lineIsTsSuppression(line) {
957
+ return /^\s*\/\/\s*@ts-(?:expect-error|ignore)\b/.test(line);
958
+ }
959
+ function leadingWhitespace(line) {
960
+ const match = line.match(/^(\s*)/);
961
+ return match ? match[1] : "";
962
+ }
963
+ function truncate(s, max) {
964
+ if (s.length <= max) return s;
965
+ return s.slice(0, max - 1) + "\u2026";
966
+ }
967
+ function buildStubComment(group, marker, maxMessageLength) {
968
+ const codes = Array.from(new Set(group.map((d) => d.code))).sort();
969
+ const messages = Array.from(new Set(group.map((d) => d.message.replace(/\s+/g, " ").trim()))).join(" | ");
970
+ const truncated = truncate(messages, maxMessageLength);
971
+ return `// @ts-expect-error - ${marker}: ${codes.join(", ")} \u2014 ${truncated}`;
972
+ }
973
+ function stubAndContinue(opts) {
974
+ const {
975
+ workspaceRoot,
976
+ diagnostics,
977
+ dryRun = false,
978
+ logger = noopLogger,
979
+ stubMarker = "tsfix",
980
+ maxMessageLength = 120
981
+ } = opts;
982
+ const errorOnly = diagnostics.filter((d) => d.category === "error");
983
+ const grouped = groupByLine(errorOnly);
984
+ const stubsApplied = [];
985
+ const skipped = [];
986
+ const filesEditedSet = /* @__PURE__ */ new Set();
987
+ const byFile = /* @__PURE__ */ new Map();
988
+ for (const [key, group] of grouped) {
989
+ const sepIdx = key.lastIndexOf("::");
990
+ const rawFile = key.slice(0, sepIdx);
991
+ const file = resolveFile(rawFile, workspaceRoot);
992
+ const line = parseInt(key.slice(sepIdx + 2), 10);
993
+ const list = byFile.get(file) ?? [];
994
+ list.push({ line, group });
995
+ byFile.set(file, list);
996
+ }
997
+ for (const [file, entries] of byFile) {
998
+ const skipReason = shouldSkipFile(file, workspaceRoot);
999
+ if (skipReason !== null) {
1000
+ for (const entry of entries) {
1001
+ skipped.push({
1002
+ file,
1003
+ line: entry.line,
1004
+ codes: Array.from(new Set(entry.group.map((d) => d.code))).sort(),
1005
+ reason: skipReason
1006
+ });
1007
+ }
1008
+ continue;
1009
+ }
1010
+ const source = fs6.readFileSync(file, "utf-8");
1011
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
1012
+ const lines = source.split(/\r?\n/);
1013
+ entries.sort((a, b) => b.line - a.line);
1014
+ let edited = false;
1015
+ for (const { line: errorLine, group } of entries) {
1016
+ const errorIdx = errorLine - 1;
1017
+ if (errorIdx < 0 || errorIdx >= lines.length) {
1018
+ skipped.push({
1019
+ file,
1020
+ line: errorLine,
1021
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1022
+ reason: "file_too_short"
1023
+ });
1024
+ continue;
1025
+ }
1026
+ const lineAbove = errorIdx > 0 ? lines[errorIdx - 1] : "";
1027
+ if (lineIsTsSuppression(lineAbove)) {
1028
+ skipped.push({
1029
+ file,
1030
+ line: errorLine,
1031
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1032
+ reason: "already_stubbed"
1033
+ });
1034
+ continue;
1035
+ }
1036
+ const indent = leadingWhitespace(lines[errorIdx]);
1037
+ const commentText = buildStubComment(group, stubMarker, maxMessageLength);
1038
+ const commentLineWithIndent = `${indent}${commentText}`;
1039
+ lines.splice(errorIdx, 0, commentLineWithIndent);
1040
+ edited = true;
1041
+ stubsApplied.push({
1042
+ file,
1043
+ errorLine,
1044
+ // original line as reported by tsc; in the file post-stub, the comment is here and the code is at errorLine+1
1045
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1046
+ commentText
1047
+ });
1048
+ }
1049
+ if (edited) {
1050
+ filesEditedSet.add(file);
1051
+ 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)}`);
1054
+ } else {
1055
+ logger.info(`[stub-and-continue] (dry-run) would stub ${entries.length} site(s) in ${path6.relative(workspaceRoot, file)}`);
1056
+ }
1057
+ }
1058
+ }
1059
+ return {
1060
+ stubsApplied,
1061
+ skipped,
1062
+ filesEdited: Array.from(filesEditedSet),
1063
+ diagnosticsBefore: errorOnly.length,
1064
+ // Each applied stub suppresses every diagnostic on its line. Compare
1065
+ // after resolving raw diagnostic paths to absolute, since stubsApplied
1066
+ // stores absolute paths but the input diagnostics may be relative.
1067
+ diagnosticsAfter: errorOnly.length - stubsApplied.reduce((acc, s) => {
1068
+ const onLine = errorOnly.filter(
1069
+ (d) => resolveFile(d.file, workspaceRoot) === s.file && d.line === s.errorLine
1070
+ ).length;
1071
+ return acc + onLine;
1072
+ }, 0)
1073
+ };
1074
+ }
1075
+
1076
+ // src/runMendLoop.ts
1077
+ var noopLogger2 = { info: () => {
1078
+ }, warn: () => {
1079
+ }, error: () => {
1080
+ } };
1081
+ function errorSignature(d) {
1082
+ return `${d.file}:${d.line}:${d.column}:${d.code}`;
1083
+ }
1084
+ function signatureSet(diags) {
1085
+ const out = /* @__PURE__ */ new Set();
1086
+ for (const d of diags) {
1087
+ if (d.category === "error") out.add(errorSignature(d));
1088
+ }
1089
+ return out;
1090
+ }
1091
+ function setsEqual(a, b) {
1092
+ if (a.size !== b.size) return false;
1093
+ for (const x of a) if (!b.has(x)) return false;
1094
+ return true;
1095
+ }
1096
+ function refreshDiagnostics(workspaceRoot, files) {
1097
+ resetInProcessTscCache();
1098
+ const result = runInProcessTsc({
1099
+ workspaceRoot,
1100
+ generatedFiles: files,
1101
+ logger: noopLogger2
1102
+ });
1103
+ return result.diagnostics.filter((d) => d.category === "error");
1104
+ }
1105
+ async function runMendLoop(opts) {
1106
+ const { context, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1107
+ const startMs = Date.now();
1108
+ const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
1109
+ if (diagnosticsBefore.length === 0) {
1110
+ return {
1111
+ iterations: [],
1112
+ diagnosticsBefore,
1113
+ diagnosticsAfter: [],
1114
+ passed: true,
1115
+ stopReason: "noErrors",
1116
+ totalInputTokens: 0,
1117
+ totalOutputTokens: 0,
1118
+ totalLatencyMs: Date.now() - startMs
1119
+ };
1120
+ }
1121
+ const filesInScope = Array.from(new Set(context.diagnostics.map((d) => d.file)));
1122
+ const iterations = [];
1123
+ let currentDiags = diagnosticsBefore;
1124
+ let prevSig = signatureSet(currentDiags);
1125
+ let stopReason = "maxIterations";
1126
+ let totalInputTokens = 0;
1127
+ let totalOutputTokens = 0;
1128
+ for (let i = 0; i < maxIterations; i++) {
1129
+ const erroredFiles = Array.from(new Set(currentDiags.map((d) => d.file)));
1130
+ const iterContext = {
1131
+ ...context,
1132
+ diagnostics: currentDiags,
1133
+ erroredFiles
1134
+ };
1135
+ const mend = await mendSingleFile({
1136
+ context: iterContext,
1137
+ llm,
1138
+ dryRun,
1139
+ _callLLM
1140
+ });
1141
+ totalInputTokens += mend.inputTokens;
1142
+ totalOutputTokens += mend.outputTokens;
1143
+ const newDiags = dryRun ? currentDiags : refreshDiagnostics(context.workspaceRoot, filesInScope);
1144
+ const newSig = signatureSet(newDiags);
1145
+ iterations.push({
1146
+ index: i,
1147
+ diagnosticsBefore: currentDiags.length,
1148
+ diagnosticsAfter: newDiags.length,
1149
+ patchesApplied: mend.apply.applied,
1150
+ patchesFailed: mend.apply.failures.length,
1151
+ inputTokens: mend.inputTokens,
1152
+ outputTokens: mend.outputTokens,
1153
+ latencyMs: mend.latencyMs,
1154
+ rawResponse: mend.rawResponse
1155
+ });
1156
+ if (dryRun) {
1157
+ currentDiags = newDiags;
1158
+ stopReason = "maxIterations";
1159
+ break;
1160
+ }
1161
+ if (newDiags.length === 0) {
1162
+ stopReason = "fixed";
1163
+ currentDiags = newDiags;
1164
+ break;
1165
+ }
1166
+ if (newSig.size > prevSig.size) {
1167
+ stopReason = "regressed";
1168
+ currentDiags = newDiags;
1169
+ break;
1170
+ }
1171
+ if (setsEqual(newSig, prevSig)) {
1172
+ stopReason = "noProgress";
1173
+ currentDiags = newDiags;
1174
+ break;
1175
+ }
1176
+ currentDiags = newDiags;
1177
+ prevSig = newSig;
1178
+ }
1179
+ let stubs;
1180
+ if (stubOnFailure && !dryRun && currentDiags.length > 0) {
1181
+ const stubResult = stubAndContinue({
1182
+ workspaceRoot: context.workspaceRoot,
1183
+ diagnostics: currentDiags
1184
+ });
1185
+ stubs = stubResult.stubsApplied;
1186
+ const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1187
+ if (postStubDiags.length === 0) {
1188
+ stopReason = "stubbed";
1189
+ }
1190
+ currentDiags = postStubDiags;
1191
+ }
1192
+ return {
1193
+ iterations,
1194
+ diagnosticsBefore,
1195
+ diagnosticsAfter: currentDiags,
1196
+ passed: currentDiags.length === 0,
1197
+ stopReason,
1198
+ totalInputTokens,
1199
+ totalOutputTokens,
1200
+ totalLatencyMs: Date.now() - startMs,
1201
+ ...stubs !== void 0 ? { stubs } : {}
1202
+ };
1203
+ }
1204
+
1205
+ // src/index.ts
1206
+ var noopLogger3 = {
479
1207
  info: () => {
480
1208
  },
481
1209
  warn: () => {
@@ -489,7 +1217,7 @@ function discoverTsFiles(workspaceRoot) {
489
1217
  const walk = (dir) => {
490
1218
  let entries;
491
1219
  try {
492
- entries = fs3.readdirSync(dir, { withFileTypes: true });
1220
+ entries = fs7.readdirSync(dir, { withFileTypes: true });
493
1221
  } catch {
494
1222
  return;
495
1223
  }
@@ -498,10 +1226,10 @@ function discoverTsFiles(workspaceRoot) {
498
1226
  if (skip.has(e.name)) {
499
1227
  continue;
500
1228
  }
501
- walk(path3.join(dir, e.name));
1229
+ walk(path7.join(dir, e.name));
502
1230
  } else if (e.isFile() && !e.name.endsWith(".d.ts")) {
503
1231
  if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
504
- out.push(path3.relative(workspaceRoot, path3.join(dir, e.name)));
1232
+ out.push(path7.relative(workspaceRoot, path7.join(dir, e.name)));
505
1233
  }
506
1234
  }
507
1235
  }
@@ -511,11 +1239,11 @@ function discoverTsFiles(workspaceRoot) {
511
1239
  }
512
1240
  function runValidationLoop(opts) {
513
1241
  const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
514
- const logger = opts.logger ?? noopLogger;
515
- if (!fs3.existsSync(workspaceRoot)) {
1242
+ const logger = opts.logger ?? noopLogger3;
1243
+ if (!fs7.existsSync(workspaceRoot)) {
516
1244
  throw new Error(`workspace not found: ${workspaceRoot}`);
517
1245
  }
518
- if (!fs3.existsSync(path3.join(workspaceRoot, "tsconfig.json"))) {
1246
+ if (!fs7.existsSync(path7.join(workspaceRoot, "tsconfig.json"))) {
519
1247
  throw new Error(`no tsconfig.json in ${workspaceRoot}`);
520
1248
  }
521
1249
  const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
@@ -564,6 +1292,16 @@ function runValidationLoop(opts) {
564
1292
  }
565
1293
 
566
1294
  // 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 }
1299
+ };
1300
+ function estimateCostUsd(model, inputTokens, outputTokens) {
1301
+ const p = ANTHROPIC_PRICING[model];
1302
+ if (!p) return 0;
1303
+ return (inputTokens * p.input + outputTokens * p.output) / 1e6;
1304
+ }
567
1305
  function parseArgs(argv) {
568
1306
  const args = {
569
1307
  workspace: "",
@@ -571,7 +1309,11 @@ function parseArgs(argv) {
571
1309
  noLsp: false,
572
1310
  dryRun: false,
573
1311
  files: void 0,
574
- verbose: false
1312
+ verbose: false,
1313
+ llm: false,
1314
+ llmModel: "claude-haiku-4-5",
1315
+ llmMaxIterations: 3,
1316
+ llmBudgetUsd: void 0
575
1317
  };
576
1318
  for (let i = 0; i < argv.length; i++) {
577
1319
  const a = argv[i];
@@ -587,6 +1329,24 @@ function parseArgs(argv) {
587
1329
  args.files = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
588
1330
  } else if (a === "--verbose" || a === "-v") {
589
1331
  args.verbose = true;
1332
+ } else if (a === "--llm") {
1333
+ args.llm = true;
1334
+ } else if (a === "--llm-model") {
1335
+ args.llmModel = argv[++i] ?? args.llmModel;
1336
+ } else if (a === "--llm-max-iterations") {
1337
+ const n = parseInt(argv[++i] ?? "", 10);
1338
+ if (Number.isNaN(n) || n < 1) {
1339
+ console.error(`error: --llm-max-iterations expects a positive integer, got '${argv[i]}'`);
1340
+ process.exit(2);
1341
+ }
1342
+ args.llmMaxIterations = n;
1343
+ } else if (a === "--llm-budget-usd") {
1344
+ const v = parseFloat(argv[++i] ?? "");
1345
+ if (Number.isNaN(v) || v < 0) {
1346
+ console.error(`error: --llm-budget-usd expects a positive number, got '${argv[i]}'`);
1347
+ process.exit(2);
1348
+ }
1349
+ args.llmBudgetUsd = v;
590
1350
  } else if (a === "--help" || a === "-h") {
591
1351
  printHelp();
592
1352
  process.exit(0);
@@ -601,22 +1361,33 @@ function parseArgs(argv) {
601
1361
  }
602
1362
  function printHelp() {
603
1363
  console.error(`
604
- Usage: run-stack --workspace <path> [options]
1364
+ Usage: tsfix --workspace <path> [options]
605
1365
 
606
- Options:
607
- --workspace, -w <path> Workspace root (required)
608
- --files <list> Comma-separated file paths to scope tsc/lsp to (default: all .ts/.tsx)
609
- --no-lsp Skip Layer 0 LSP auto-fixer
610
- --dry-run Run the LSP fixer in memory; do NOT write changes
611
- to disk. Lists files that would be edited.
612
- --json Emit JSON report on stdout
613
- --verbose, -v Stream layer logs to stderr
614
- --help, -h Show this help
1366
+ Layer 0/1 (default \u2014 deterministic, no network):
1367
+ --workspace, -w <path> Workspace root (required)
1368
+ --files <a.ts,b.ts> Scope tsc/lsp to this comma-separated list
1369
+ --no-lsp Skip Layer 0 LSP auto-fixer (validate only)
1370
+ --dry-run Run fixer in memory; list edits but don't write
1371
+ --json Emit JSON report on stdout
1372
+ --verbose, -v Stream layer logs to stderr
1373
+ --help, -h Show this help
1374
+
1375
+ Layer 2 (opt-in \u2014 single-file LLM mend via Anthropic):
1376
+ --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.
1381
+ --llm-max-iterations <N> Cap on LLM retries (default: 3)
1382
+ --llm-budget-usd <amount> Soft cost cap. Exits with code 3 if exceeded.
1383
+
1384
+ Layer 2 requires ANTHROPIC_API_KEY in the environment.
615
1385
 
616
1386
  Exit codes:
617
1387
  0 no errors after stack
618
1388
  1 errors remain after stack
619
1389
  2 bad arguments / harness error
1390
+ 3 Layer 2 budget exceeded (errors may still remain; partial work persisted)
620
1391
  `.trim());
621
1392
  }
622
1393
  function makeLogger(captureLines, verbose) {
@@ -653,6 +1424,15 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
653
1424
  }
654
1425
  } else {
655
1426
  w.write(` LSP fixer: skipped
1427
+ `);
1428
+ }
1429
+ if (r.layer2) {
1430
+ const l2 = r.layer2;
1431
+ w.write(
1432
+ ` 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
+ `
1434
+ );
1435
+ w.write(` model=${l2.model} \xB7 stopReason=${l2.stopReason}
656
1436
  `);
657
1437
  }
658
1438
  w.write(` errors after: ${r.errorsAfter}
@@ -674,12 +1454,12 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
674
1454
  }
675
1455
  async function main() {
676
1456
  const args = parseArgs(process.argv.slice(2));
677
- const workspaceRoot = path4.resolve(args.workspace);
678
- if (!fs4.existsSync(workspaceRoot)) {
1457
+ const workspaceRoot = path8.resolve(args.workspace);
1458
+ if (!fs8.existsSync(workspaceRoot)) {
679
1459
  console.error(`error: workspace not found: ${workspaceRoot}`);
680
1460
  return 2;
681
1461
  }
682
- if (!fs4.existsSync(path4.join(workspaceRoot, "tsconfig.json"))) {
1462
+ if (!fs8.existsSync(path8.join(workspaceRoot, "tsconfig.json"))) {
683
1463
  console.error(`error: no tsconfig.json in ${workspaceRoot}`);
684
1464
  return 2;
685
1465
  }
@@ -698,9 +1478,10 @@ async function main() {
698
1478
  logger
699
1479
  });
700
1480
  const report = {
701
- workspace: path4.relative(process.cwd(), workspaceRoot) || workspaceRoot,
1481
+ workspace: path8.relative(process.cwd(), workspaceRoot) || workspaceRoot,
702
1482
  errorsBefore: loop.errorsBefore,
703
1483
  lspFixer: args.noLsp ? { ran: false, fixesApplied: 0, filesEdited: [], iterations: 0 } : loop.lspFixer,
1484
+ layer2: null,
704
1485
  errorsAfter: loop.errorsAfter,
705
1486
  remainingByCode: loop.remainingByCode,
706
1487
  remainingByFile: loop.remainingByFile,
@@ -708,11 +1489,75 @@ async function main() {
708
1489
  elapsedMs: loop.elapsedMs,
709
1490
  dryRun: args.dryRun
710
1491
  };
1492
+ let budgetExceeded = false;
1493
+ if (args.llm && loop.errorsAfter > 0) {
1494
+ if (args.dryRun) {
1495
+ console.error("error: --llm and --dry-run are mutually exclusive (Layer 2 writes patches to disk)");
1496
+ return 2;
1497
+ }
1498
+ const apiKey = process.env.ANTHROPIC_API_KEY;
1499
+ if (!apiKey) {
1500
+ console.error("error: --llm requires ANTHROPIC_API_KEY in the environment");
1501
+ return 2;
1502
+ }
1503
+ if (!ANTHROPIC_PRICING[args.llmModel]) {
1504
+ logger.warn(
1505
+ `unknown model '${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1506
+ );
1507
+ }
1508
+ const errorDiags = loop.diagnostics.filter((d) => d.category === "error");
1509
+ const context = {
1510
+ workspaceRoot,
1511
+ diagnostics: errorDiags,
1512
+ erroredFiles: Array.from(new Set(errorDiags.map((d) => d.file)))
1513
+ };
1514
+ const layer2Start = Date.now();
1515
+ const mend = await runMendLoop({
1516
+ context,
1517
+ llm: { provider: "anthropic", model: args.llmModel, apiKey },
1518
+ maxIterations: args.llmMaxIterations
1519
+ });
1520
+ void layer2Start;
1521
+ const totalCostUsd = estimateCostUsd(
1522
+ args.llmModel,
1523
+ mend.totalInputTokens,
1524
+ mend.totalOutputTokens
1525
+ );
1526
+ budgetExceeded = args.llmBudgetUsd !== void 0 && totalCostUsd > args.llmBudgetUsd;
1527
+ report.layer2 = {
1528
+ ran: true,
1529
+ stopReason: mend.stopReason,
1530
+ errorsBefore: errorDiags.length,
1531
+ errorsAfter: mend.diagnosticsAfter.length,
1532
+ iterations: mend.iterations.length,
1533
+ totalInputTokens: mend.totalInputTokens,
1534
+ totalOutputTokens: mend.totalOutputTokens,
1535
+ totalCostUsd,
1536
+ budgetExceeded,
1537
+ model: args.llmModel
1538
+ };
1539
+ const post = runInProcessTsc({
1540
+ workspaceRoot,
1541
+ generatedFiles: targetFiles,
1542
+ logger
1543
+ });
1544
+ const postErrorDiags = post.diagnostics.filter((d) => d.category === "error");
1545
+ report.errorsAfter = postErrorDiags.length;
1546
+ report.remainingByCode = {};
1547
+ report.remainingByFile = {};
1548
+ for (const d of postErrorDiags) {
1549
+ report.remainingByCode[d.code] = (report.remainingByCode[d.code] ?? 0) + 1;
1550
+ report.remainingByFile[d.file] = (report.remainingByFile[d.file] ?? 0) + 1;
1551
+ }
1552
+ report.passed = report.errorsAfter === 0;
1553
+ report.elapsedMs = loop.elapsedMs + (Date.now() - layer2Start);
1554
+ }
711
1555
  if (args.json) {
712
1556
  process.stdout.write(JSON.stringify(report, null, 2) + "\n");
713
1557
  } else {
714
1558
  printHumanReport(report);
715
1559
  }
1560
+ if (budgetExceeded) return 3;
716
1561
  return report.passed ? 0 : 1;
717
1562
  }
718
1563
  main().then(