@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/index.js CHANGED
@@ -475,9 +475,740 @@ function resetLSPFixerCache() {
475
475
  }
476
476
 
477
477
  // src/index.ts
478
+ import * as fs7 from "node:fs";
479
+ import * as path7 from "node:path";
480
+
481
+ // src/typeContext.ts
478
482
  import * as fs3 from "node:fs";
479
483
  import * as path3 from "node:path";
480
- var noopLogger = {
484
+ import * as ts3 from "typescript";
485
+ var programCache2 = /* @__PURE__ */ new Map();
486
+ function resetTypeContextCache() {
487
+ programCache2.clear();
488
+ }
489
+ function buildProgram(workspaceRoot) {
490
+ const tsconfigPath = path3.join(workspaceRoot, "tsconfig.json");
491
+ if (!fs3.existsSync(tsconfigPath)) return null;
492
+ let configMtime = 0;
493
+ try {
494
+ configMtime = fs3.statSync(tsconfigPath).mtimeMs;
495
+ } catch {
496
+ return null;
497
+ }
498
+ const cached = programCache2.get(workspaceRoot);
499
+ if (cached && cached.configMtime === configMtime) return cached.program;
500
+ const configFile = ts3.readConfigFile(tsconfigPath, ts3.sys.readFile);
501
+ if (configFile.error) return null;
502
+ const parsed = ts3.parseJsonConfigFileContent(
503
+ configFile.config,
504
+ ts3.sys,
505
+ path3.dirname(tsconfigPath)
506
+ );
507
+ const host = ts3.createCompilerHost(parsed.options);
508
+ const workspaceLibDir = path3.join(workspaceRoot, "node_modules", "typescript", "lib");
509
+ if (fs3.existsSync(workspaceLibDir)) {
510
+ const origGetDefaultLibFileName = host.getDefaultLibFileName.bind(host);
511
+ host.getDefaultLibLocation = () => workspaceLibDir;
512
+ host.getDefaultLibFileName = (options) => {
513
+ const fileName = path3.basename(origGetDefaultLibFileName(options));
514
+ return path3.join(workspaceLibDir, fileName);
515
+ };
516
+ }
517
+ let program;
518
+ try {
519
+ program = ts3.createProgram({
520
+ rootNames: parsed.fileNames,
521
+ options: parsed.options,
522
+ host
523
+ });
524
+ } catch {
525
+ return null;
526
+ }
527
+ programCache2.set(workspaceRoot, { program, configMtime });
528
+ return program;
529
+ }
530
+ function sliceLines(content, oneIndexedLine, padding) {
531
+ const allLines = content.split("\n");
532
+ const start = Math.max(0, oneIndexedLine - 1 - padding);
533
+ const end = Math.min(allLines.length, oneIndexedLine + padding);
534
+ const width = String(end).length;
535
+ return allLines.slice(start, end).map((l, i) => `${String(start + i + 1).padStart(width, " ")} | ${l}`).join("\n");
536
+ }
537
+ function getNodeAtPosition(sourceFile, position) {
538
+ let result = sourceFile;
539
+ function walk(node) {
540
+ ts3.forEachChild(node, (child) => {
541
+ if (position >= child.getStart(sourceFile) && position < child.getEnd()) {
542
+ result = child;
543
+ walk(child);
544
+ return true;
545
+ }
546
+ return false;
547
+ });
548
+ }
549
+ walk(sourceFile);
550
+ return result;
551
+ }
552
+ function isLibFile(fileName) {
553
+ return /lib\.[a-z0-9.]+\.d\.ts$/.test(fileName);
554
+ }
555
+ function findTypeDeclaration(checker, startNode, maxWalkUp = 4) {
556
+ const tryResolve = (n) => {
557
+ const type = checker.getTypeAtLocation(n);
558
+ const symbol = type.getSymbol() ?? type.aliasSymbol;
559
+ const declarations = symbol?.getDeclarations();
560
+ if (!declarations || declarations.length === 0) return void 0;
561
+ const nonLib = declarations.find((d) => !isLibFile(d.getSourceFile().fileName));
562
+ if (!nonLib) return void 0;
563
+ return { decl: nonLib, symbolName: symbol?.getName() ?? "(unnamed)" };
564
+ };
565
+ let node = startNode;
566
+ for (let i = 0; i < maxWalkUp && node; i++) {
567
+ const direct = tryResolve(node);
568
+ if (direct) return direct;
569
+ if (ts3.isPropertyAccessExpression(node) || ts3.isElementAccessExpression(node)) {
570
+ const sibling = tryResolve(node.expression);
571
+ if (sibling) return sibling;
572
+ }
573
+ node = node.parent;
574
+ }
575
+ return void 0;
576
+ }
577
+ function getTypeContext(opts) {
578
+ const { workspaceRoot, diagnostic } = opts;
579
+ const errorPadding = opts.errorPadding ?? 3;
580
+ const declarationPadding = opts.declarationPadding ?? 20;
581
+ const filePath = path3.isAbsolute(diagnostic.file) ? diagnostic.file : path3.join(workspaceRoot, diagnostic.file);
582
+ const fileContent = fs3.existsSync(filePath) ? fs3.readFileSync(filePath, "utf-8") : "";
583
+ const errorSite = {
584
+ file: diagnostic.file,
585
+ lines: sliceLines(fileContent, diagnostic.line, errorPadding)
586
+ };
587
+ const program = buildProgram(workspaceRoot);
588
+ if (!program) return { errorSite };
589
+ const sourceFile = program.getSourceFile(filePath);
590
+ if (!sourceFile) return { errorSite };
591
+ let position;
592
+ try {
593
+ position = ts3.getPositionOfLineAndCharacter(
594
+ sourceFile,
595
+ diagnostic.line - 1,
596
+ diagnostic.column - 1
597
+ );
598
+ } catch {
599
+ return { errorSite };
600
+ }
601
+ const errorNode = getNodeAtPosition(sourceFile, position);
602
+ const checker = program.getTypeChecker();
603
+ const found = findTypeDeclaration(checker, errorNode);
604
+ if (!found) return { errorSite };
605
+ const declSourceFile = found.decl.getSourceFile();
606
+ const declStart = found.decl.getStart(declSourceFile);
607
+ const { line: declLine0 } = ts3.getLineAndCharacterOfPosition(declSourceFile, declStart);
608
+ return {
609
+ errorSite,
610
+ typeDeclaration: {
611
+ file: path3.relative(workspaceRoot, declSourceFile.fileName) || declSourceFile.fileName,
612
+ lines: sliceLines(declSourceFile.text, declLine0 + 1, declarationPadding),
613
+ symbol: found.symbolName
614
+ }
615
+ };
616
+ }
617
+
618
+ // src/applyEditBlock.ts
619
+ import * as fs4 from "node:fs";
620
+ import * as path4 from "node:path";
621
+ var SEARCH_MARKER = "<<<<<<< SEARCH";
622
+ var SEPARATOR = "=======";
623
+ var REPLACE_MARKER = ">>>>>>> REPLACE";
624
+ function extractFilePath(line) {
625
+ const trimmed = line.trim();
626
+ const attrMatch = trimmed.match(/<\s*file\s+path\s*=\s*["']([^"']+)["']\s*\/?>/i);
627
+ if (attrMatch) return attrMatch[1];
628
+ return trimmed.replace(/^[`'"]+|[`'"]+$/g, "");
629
+ }
630
+ function parseEditBlocks(llmOutput) {
631
+ const blocks = [];
632
+ const lines = llmOutput.split("\n");
633
+ let i = 0;
634
+ while (i < lines.length) {
635
+ while (i < lines.length && lines[i].trim() !== SEARCH_MARKER) {
636
+ i++;
637
+ }
638
+ if (i >= lines.length) {
639
+ break;
640
+ }
641
+ let fileIdx = i - 1;
642
+ while (fileIdx >= 0) {
643
+ const trimmed = lines[fileIdx].trim();
644
+ if (trimmed === "" || trimmed.startsWith("```") || trimmed.startsWith("</")) {
645
+ fileIdx--;
646
+ continue;
647
+ }
648
+ break;
649
+ }
650
+ const filePath = fileIdx >= 0 ? extractFilePath(lines[fileIdx]) : "";
651
+ i++;
652
+ const searchLines = [];
653
+ while (i < lines.length && lines[i].trim() !== SEPARATOR) {
654
+ searchLines.push(lines[i]);
655
+ i++;
656
+ }
657
+ if (i >= lines.length) {
658
+ break;
659
+ }
660
+ i++;
661
+ const replaceLines = [];
662
+ while (i < lines.length && lines[i].trim() !== REPLACE_MARKER) {
663
+ replaceLines.push(lines[i]);
664
+ i++;
665
+ }
666
+ if (i >= lines.length) {
667
+ break;
668
+ }
669
+ blocks.push({
670
+ file: filePath,
671
+ search: searchLines.join("\n"),
672
+ replace: replaceLines.join("\n")
673
+ });
674
+ i++;
675
+ }
676
+ return blocks;
677
+ }
678
+ function countOccurrences(haystack, needle) {
679
+ if (needle.length === 0) return 0;
680
+ let count = 0;
681
+ let pos = 0;
682
+ while (true) {
683
+ const idx = haystack.indexOf(needle, pos);
684
+ if (idx < 0) return count;
685
+ count++;
686
+ pos = idx + needle.length;
687
+ }
688
+ }
689
+ function rstripPerLine(text) {
690
+ return text.split("\n").map((l) => l.replace(/\s+$/, "")).join("\n");
691
+ }
692
+ function stripPerLine(text) {
693
+ return text.split("\n").map((l) => l.trim()).join("\n");
694
+ }
695
+ function spliceLines(originalContent, normalizedContent, normalizedSearch, replace) {
696
+ const idx = normalizedContent.indexOf(normalizedSearch);
697
+ if (idx < 0) return void 0;
698
+ const linesBefore = normalizedContent.slice(0, idx).split("\n").length - 1;
699
+ const matchLineCount = normalizedSearch.split("\n").length;
700
+ const origLines = originalContent.split("\n");
701
+ const before = origLines.slice(0, linesBefore);
702
+ const after = origLines.slice(linesBefore + matchLineCount);
703
+ return [...before, replace, ...after].join("\n");
704
+ }
705
+ function applySingleBlock(fileContent, search, replace) {
706
+ if (search === "") {
707
+ return { error: "empty search block" };
708
+ }
709
+ const exactCount = countOccurrences(fileContent, search);
710
+ if (exactCount === 1) {
711
+ return { newContent: fileContent.replace(search, replace), matchedTier: "exact" };
712
+ }
713
+ if (exactCount > 1) {
714
+ return { error: `ambiguous: ${exactCount} exact matches` };
715
+ }
716
+ const rstripContent = rstripPerLine(fileContent);
717
+ const rstripSearch = rstripPerLine(search);
718
+ const rstripCount = countOccurrences(rstripContent, rstripSearch);
719
+ if (rstripCount === 1) {
720
+ const out = spliceLines(fileContent, rstripContent, rstripSearch, replace);
721
+ if (out !== void 0) {
722
+ return { newContent: out, matchedTier: "rstrip" };
723
+ }
724
+ }
725
+ if (rstripCount > 1) {
726
+ return { error: `ambiguous: ${rstripCount} rstrip matches` };
727
+ }
728
+ const stripContent = stripPerLine(fileContent);
729
+ const stripSearch = stripPerLine(search);
730
+ const stripCount = countOccurrences(stripContent, stripSearch);
731
+ if (stripCount === 1) {
732
+ const out = spliceLines(fileContent, stripContent, stripSearch, replace);
733
+ if (out !== void 0) {
734
+ return { newContent: out, matchedTier: "strip" };
735
+ }
736
+ }
737
+ if (stripCount > 1) {
738
+ return { error: `ambiguous: ${stripCount} strip matches` };
739
+ }
740
+ return { error: "no match" };
741
+ }
742
+ function applyEditBlocks(opts) {
743
+ const { workspaceRoot, blocks, dryRun = false } = opts;
744
+ const fileSnapshots = /* @__PURE__ */ new Map();
745
+ const failures = [];
746
+ const filesEdited = /* @__PURE__ */ new Set();
747
+ let applied = 0;
748
+ for (const block of blocks) {
749
+ const filePath = path4.isAbsolute(block.file) ? block.file : path4.join(workspaceRoot, block.file);
750
+ let content = fileSnapshots.get(filePath);
751
+ if (content === void 0) {
752
+ try {
753
+ content = fs4.readFileSync(filePath, "utf-8");
754
+ } catch (err) {
755
+ failures.push({
756
+ block,
757
+ reason: `cannot read file: ${err instanceof Error ? err.message : String(err)}`
758
+ });
759
+ continue;
760
+ }
761
+ }
762
+ const result = applySingleBlock(content, block.search, block.replace);
763
+ if ("error" in result) {
764
+ failures.push({ block, reason: result.error });
765
+ continue;
766
+ }
767
+ fileSnapshots.set(filePath, result.newContent);
768
+ filesEdited.add(filePath);
769
+ applied++;
770
+ }
771
+ if (!dryRun) {
772
+ for (const [filePath, content] of fileSnapshots) {
773
+ try {
774
+ fs4.writeFileSync(filePath, content);
775
+ } catch (err) {
776
+ failures.push({
777
+ block: { file: filePath, search: "", replace: "" },
778
+ reason: `write failed: ${err instanceof Error ? err.message : String(err)}`
779
+ });
780
+ }
781
+ }
782
+ }
783
+ return {
784
+ blocks,
785
+ applied,
786
+ filesEdited: Array.from(filesEdited),
787
+ failures
788
+ };
789
+ }
790
+
791
+ // src/mendAgent.ts
792
+ import * as fs5 from "node:fs";
793
+ import * as path5 from "node:path";
794
+ import { generateText } from "ai";
795
+ import { createAnthropic } from "@ai-sdk/anthropic";
796
+ var SYSTEM_INSTRUCTIONS = `You are a TypeScript code-repair tool. You receive a TypeScript file with one or more compiler errors and resolve them.
797
+
798
+ Output ONLY SEARCH/REPLACE blocks. No prose, no explanations, no XML wrappers.
799
+
800
+ 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:
801
+
802
+ src/api.ts
803
+ <<<<<<< SEARCH
804
+ const x = 1;
805
+ =======
806
+ const x: number = 1;
807
+ >>>>>>> REPLACE
808
+
809
+ Rules:
810
+ - The file path is a plain line. Do not wrap it in tags, fences, or quotes.
811
+ - SEARCH text must match the file VERBATIM. Whitespace, indentation, line endings: copy exactly.
812
+ - Make SEARCH unique. If a one-line search would match multiple places in the file, include 1-2 lines of surrounding context.
813
+ - REPLACE must be valid TypeScript that resolves the diagnostic.
814
+ - Do not invent imports, types, properties, or values. Use only what the type-context section shows.
815
+ - One SEARCH/REPLACE block per logical change.
816
+ - If you cannot resolve a diagnostic with the information given, omit a block for it.`;
817
+ function workspaceRelative(workspaceRoot, p) {
818
+ return path5.isAbsolute(p) ? path5.relative(workspaceRoot, p) : p;
819
+ }
820
+ function buildSystemBlock(context, erroredFile) {
821
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
822
+ const absPath = path5.isAbsolute(erroredFile) ? erroredFile : path5.join(context.workspaceRoot, erroredFile);
823
+ let fileContent;
824
+ try {
825
+ fileContent = fs5.readFileSync(absPath, "utf-8");
826
+ } catch {
827
+ fileContent = "(file unreadable)";
828
+ }
829
+ const fileDiags = context.diagnostics.filter(
830
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
831
+ );
832
+ const typeContexts = [];
833
+ const seen = /* @__PURE__ */ new Set();
834
+ for (const diag of fileDiags) {
835
+ const ctx = getTypeContext({
836
+ workspaceRoot: context.workspaceRoot,
837
+ diagnostic: diag
838
+ });
839
+ if (!ctx.typeDeclaration) continue;
840
+ const key = `${ctx.typeDeclaration.file}:${ctx.typeDeclaration.symbol}`;
841
+ if (seen.has(key)) continue;
842
+ seen.add(key);
843
+ typeContexts.push(
844
+ `// type: ${ctx.typeDeclaration.symbol}
845
+ // file: ${ctx.typeDeclaration.file}
846
+ ` + ctx.typeDeclaration.lines
847
+ );
848
+ }
849
+ const parts = [
850
+ SYSTEM_INSTRUCTIONS,
851
+ "",
852
+ `### file: ${wsRel}`,
853
+ "```ts",
854
+ fileContent.replace(/\n$/, ""),
855
+ "```"
856
+ ];
857
+ if (typeContexts.length > 0) {
858
+ parts.push("", "### type-context");
859
+ for (const tc of typeContexts) {
860
+ parts.push("```ts", tc, "```");
861
+ }
862
+ }
863
+ if (context.taskDescription) {
864
+ parts.push("", `### task`, context.taskDescription);
865
+ }
866
+ return parts.join("\n");
867
+ }
868
+ function buildUserBlock(context, erroredFile) {
869
+ const wsRel = workspaceRelative(context.workspaceRoot, erroredFile);
870
+ const fileDiags = context.diagnostics.filter(
871
+ (d) => d.category === "error" && workspaceRelative(context.workspaceRoot, d.file) === wsRel
872
+ );
873
+ const lines = fileDiags.map(
874
+ (d) => `${d.file}(${d.line},${d.column}): ${d.code}: ${d.message}`
875
+ );
876
+ return `tsc reports:
877
+ ${lines.join("\n")}
878
+
879
+ Emit SEARCH/REPLACE blocks to resolve.`;
880
+ }
881
+ var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
882
+ const anthropic = createAnthropic({ apiKey });
883
+ const result = await generateText({
884
+ model: anthropic(model),
885
+ system: systemBlock,
886
+ messages: [{ role: "user", content: userBlock }]
887
+ });
888
+ return {
889
+ text: result.text,
890
+ inputTokens: result.usage?.inputTokens ?? 0,
891
+ outputTokens: result.usage?.outputTokens ?? 0
892
+ };
893
+ };
894
+ async function mendSingleFile(opts) {
895
+ const { context, llm, dryRun = false, _callLLM = defaultLLMCall } = opts;
896
+ const erroredFile = context.erroredFiles[0];
897
+ if (!erroredFile) {
898
+ throw new Error("mendSingleFile: no errored files in context");
899
+ }
900
+ const systemBlock = buildSystemBlock(context, erroredFile);
901
+ const userBlock = buildUserBlock(context, erroredFile);
902
+ const startMs = Date.now();
903
+ const llmResult = await _callLLM({
904
+ systemBlock,
905
+ userBlock,
906
+ model: llm.model,
907
+ apiKey: llm.apiKey
908
+ });
909
+ const latencyMs = Date.now() - startMs;
910
+ const rawResponse = llmResult.text;
911
+ const blocks = parseEditBlocks(rawResponse);
912
+ const apply = applyEditBlocks({
913
+ workspaceRoot: context.workspaceRoot,
914
+ blocks,
915
+ dryRun
916
+ });
917
+ return {
918
+ rawResponse,
919
+ blocks,
920
+ apply,
921
+ inputTokens: llmResult.inputTokens,
922
+ outputTokens: llmResult.outputTokens,
923
+ latencyMs
924
+ };
925
+ }
926
+
927
+ // src/stubAndContinue.ts
928
+ import * as fs6 from "node:fs";
929
+ import * as path6 from "node:path";
930
+ var noopLogger = { info: () => {
931
+ }, warn: () => {
932
+ }, error: () => {
933
+ } };
934
+ function groupByLine(diagnostics) {
935
+ const groups = /* @__PURE__ */ new Map();
936
+ for (const d of diagnostics) {
937
+ if (d.category !== "error") continue;
938
+ const key = `${d.file}::${d.line}`;
939
+ const list = groups.get(key);
940
+ if (list) list.push(d);
941
+ else groups.set(key, [d]);
942
+ }
943
+ return groups;
944
+ }
945
+ function resolveFile(diagnosticFile, workspaceRoot) {
946
+ return path6.isAbsolute(diagnosticFile) ? diagnosticFile : path6.resolve(workspaceRoot, diagnosticFile);
947
+ }
948
+ function shouldSkipFile(file, workspaceRoot) {
949
+ const rel = path6.relative(workspaceRoot, file);
950
+ if (rel.startsWith("node_modules") || rel.includes(`${path6.sep}node_modules${path6.sep}`)) {
951
+ return "node_modules";
952
+ }
953
+ if (file.endsWith(".d.ts")) {
954
+ return "declaration_file";
955
+ }
956
+ if (!fs6.existsSync(file)) {
957
+ return "file_not_found";
958
+ }
959
+ return null;
960
+ }
961
+ function lineIsTsSuppression(line) {
962
+ return /^\s*\/\/\s*@ts-(?:expect-error|ignore)\b/.test(line);
963
+ }
964
+ function leadingWhitespace(line) {
965
+ const match = line.match(/^(\s*)/);
966
+ return match ? match[1] : "";
967
+ }
968
+ function truncate(s, max) {
969
+ if (s.length <= max) return s;
970
+ return s.slice(0, max - 1) + "\u2026";
971
+ }
972
+ function buildStubComment(group, marker, maxMessageLength) {
973
+ const codes = Array.from(new Set(group.map((d) => d.code))).sort();
974
+ const messages = Array.from(new Set(group.map((d) => d.message.replace(/\s+/g, " ").trim()))).join(" | ");
975
+ const truncated = truncate(messages, maxMessageLength);
976
+ return `// @ts-expect-error - ${marker}: ${codes.join(", ")} \u2014 ${truncated}`;
977
+ }
978
+ function stubAndContinue(opts) {
979
+ const {
980
+ workspaceRoot,
981
+ diagnostics,
982
+ dryRun = false,
983
+ logger = noopLogger,
984
+ stubMarker = "tsfix",
985
+ maxMessageLength = 120
986
+ } = opts;
987
+ const errorOnly = diagnostics.filter((d) => d.category === "error");
988
+ const grouped = groupByLine(errorOnly);
989
+ const stubsApplied = [];
990
+ const skipped = [];
991
+ const filesEditedSet = /* @__PURE__ */ new Set();
992
+ const byFile = /* @__PURE__ */ new Map();
993
+ for (const [key, group] of grouped) {
994
+ const sepIdx = key.lastIndexOf("::");
995
+ const rawFile = key.slice(0, sepIdx);
996
+ const file = resolveFile(rawFile, workspaceRoot);
997
+ const line = parseInt(key.slice(sepIdx + 2), 10);
998
+ const list = byFile.get(file) ?? [];
999
+ list.push({ line, group });
1000
+ byFile.set(file, list);
1001
+ }
1002
+ for (const [file, entries] of byFile) {
1003
+ const skipReason = shouldSkipFile(file, workspaceRoot);
1004
+ if (skipReason !== null) {
1005
+ for (const entry of entries) {
1006
+ skipped.push({
1007
+ file,
1008
+ line: entry.line,
1009
+ codes: Array.from(new Set(entry.group.map((d) => d.code))).sort(),
1010
+ reason: skipReason
1011
+ });
1012
+ }
1013
+ continue;
1014
+ }
1015
+ const source = fs6.readFileSync(file, "utf-8");
1016
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
1017
+ const lines = source.split(/\r?\n/);
1018
+ entries.sort((a, b) => b.line - a.line);
1019
+ let edited = false;
1020
+ for (const { line: errorLine, group } of entries) {
1021
+ const errorIdx = errorLine - 1;
1022
+ if (errorIdx < 0 || errorIdx >= lines.length) {
1023
+ skipped.push({
1024
+ file,
1025
+ line: errorLine,
1026
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1027
+ reason: "file_too_short"
1028
+ });
1029
+ continue;
1030
+ }
1031
+ const lineAbove = errorIdx > 0 ? lines[errorIdx - 1] : "";
1032
+ if (lineIsTsSuppression(lineAbove)) {
1033
+ skipped.push({
1034
+ file,
1035
+ line: errorLine,
1036
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1037
+ reason: "already_stubbed"
1038
+ });
1039
+ continue;
1040
+ }
1041
+ const indent = leadingWhitespace(lines[errorIdx]);
1042
+ const commentText = buildStubComment(group, stubMarker, maxMessageLength);
1043
+ const commentLineWithIndent = `${indent}${commentText}`;
1044
+ lines.splice(errorIdx, 0, commentLineWithIndent);
1045
+ edited = true;
1046
+ stubsApplied.push({
1047
+ file,
1048
+ errorLine,
1049
+ // original line as reported by tsc; in the file post-stub, the comment is here and the code is at errorLine+1
1050
+ codes: Array.from(new Set(group.map((d) => d.code))).sort(),
1051
+ commentText
1052
+ });
1053
+ }
1054
+ if (edited) {
1055
+ filesEditedSet.add(file);
1056
+ if (!dryRun) {
1057
+ fs6.writeFileSync(file, lines.join(eol), "utf-8");
1058
+ logger.info(`[stub-and-continue] stubbed ${entries.length} site(s) in ${path6.relative(workspaceRoot, file)}`);
1059
+ } else {
1060
+ logger.info(`[stub-and-continue] (dry-run) would stub ${entries.length} site(s) in ${path6.relative(workspaceRoot, file)}`);
1061
+ }
1062
+ }
1063
+ }
1064
+ return {
1065
+ stubsApplied,
1066
+ skipped,
1067
+ filesEdited: Array.from(filesEditedSet),
1068
+ diagnosticsBefore: errorOnly.length,
1069
+ // Each applied stub suppresses every diagnostic on its line. Compare
1070
+ // after resolving raw diagnostic paths to absolute, since stubsApplied
1071
+ // stores absolute paths but the input diagnostics may be relative.
1072
+ diagnosticsAfter: errorOnly.length - stubsApplied.reduce((acc, s) => {
1073
+ const onLine = errorOnly.filter(
1074
+ (d) => resolveFile(d.file, workspaceRoot) === s.file && d.line === s.errorLine
1075
+ ).length;
1076
+ return acc + onLine;
1077
+ }, 0)
1078
+ };
1079
+ }
1080
+
1081
+ // src/runMendLoop.ts
1082
+ var noopLogger2 = { info: () => {
1083
+ }, warn: () => {
1084
+ }, error: () => {
1085
+ } };
1086
+ function errorSignature(d) {
1087
+ return `${d.file}:${d.line}:${d.column}:${d.code}`;
1088
+ }
1089
+ function signatureSet(diags) {
1090
+ const out = /* @__PURE__ */ new Set();
1091
+ for (const d of diags) {
1092
+ if (d.category === "error") out.add(errorSignature(d));
1093
+ }
1094
+ return out;
1095
+ }
1096
+ function setsEqual(a, b) {
1097
+ if (a.size !== b.size) return false;
1098
+ for (const x of a) if (!b.has(x)) return false;
1099
+ return true;
1100
+ }
1101
+ function refreshDiagnostics(workspaceRoot, files) {
1102
+ resetInProcessTscCache();
1103
+ const result = runInProcessTsc({
1104
+ workspaceRoot,
1105
+ generatedFiles: files,
1106
+ logger: noopLogger2
1107
+ });
1108
+ return result.diagnostics.filter((d) => d.category === "error");
1109
+ }
1110
+ async function runMendLoop(opts) {
1111
+ const { context, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1112
+ const startMs = Date.now();
1113
+ const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
1114
+ if (diagnosticsBefore.length === 0) {
1115
+ return {
1116
+ iterations: [],
1117
+ diagnosticsBefore,
1118
+ diagnosticsAfter: [],
1119
+ passed: true,
1120
+ stopReason: "noErrors",
1121
+ totalInputTokens: 0,
1122
+ totalOutputTokens: 0,
1123
+ totalLatencyMs: Date.now() - startMs
1124
+ };
1125
+ }
1126
+ const filesInScope = Array.from(new Set(context.diagnostics.map((d) => d.file)));
1127
+ const iterations = [];
1128
+ let currentDiags = diagnosticsBefore;
1129
+ let prevSig = signatureSet(currentDiags);
1130
+ let stopReason = "maxIterations";
1131
+ let totalInputTokens = 0;
1132
+ let totalOutputTokens = 0;
1133
+ for (let i = 0; i < maxIterations; i++) {
1134
+ const erroredFiles = Array.from(new Set(currentDiags.map((d) => d.file)));
1135
+ const iterContext = {
1136
+ ...context,
1137
+ diagnostics: currentDiags,
1138
+ erroredFiles
1139
+ };
1140
+ const mend = await mendSingleFile({
1141
+ context: iterContext,
1142
+ llm,
1143
+ dryRun,
1144
+ _callLLM
1145
+ });
1146
+ totalInputTokens += mend.inputTokens;
1147
+ totalOutputTokens += mend.outputTokens;
1148
+ const newDiags = dryRun ? currentDiags : refreshDiagnostics(context.workspaceRoot, filesInScope);
1149
+ const newSig = signatureSet(newDiags);
1150
+ iterations.push({
1151
+ index: i,
1152
+ diagnosticsBefore: currentDiags.length,
1153
+ diagnosticsAfter: newDiags.length,
1154
+ patchesApplied: mend.apply.applied,
1155
+ patchesFailed: mend.apply.failures.length,
1156
+ inputTokens: mend.inputTokens,
1157
+ outputTokens: mend.outputTokens,
1158
+ latencyMs: mend.latencyMs,
1159
+ rawResponse: mend.rawResponse
1160
+ });
1161
+ if (dryRun) {
1162
+ currentDiags = newDiags;
1163
+ stopReason = "maxIterations";
1164
+ break;
1165
+ }
1166
+ if (newDiags.length === 0) {
1167
+ stopReason = "fixed";
1168
+ currentDiags = newDiags;
1169
+ break;
1170
+ }
1171
+ if (newSig.size > prevSig.size) {
1172
+ stopReason = "regressed";
1173
+ currentDiags = newDiags;
1174
+ break;
1175
+ }
1176
+ if (setsEqual(newSig, prevSig)) {
1177
+ stopReason = "noProgress";
1178
+ currentDiags = newDiags;
1179
+ break;
1180
+ }
1181
+ currentDiags = newDiags;
1182
+ prevSig = newSig;
1183
+ }
1184
+ let stubs;
1185
+ if (stubOnFailure && !dryRun && currentDiags.length > 0) {
1186
+ const stubResult = stubAndContinue({
1187
+ workspaceRoot: context.workspaceRoot,
1188
+ diagnostics: currentDiags
1189
+ });
1190
+ stubs = stubResult.stubsApplied;
1191
+ const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1192
+ if (postStubDiags.length === 0) {
1193
+ stopReason = "stubbed";
1194
+ }
1195
+ currentDiags = postStubDiags;
1196
+ }
1197
+ return {
1198
+ iterations,
1199
+ diagnosticsBefore,
1200
+ diagnosticsAfter: currentDiags,
1201
+ passed: currentDiags.length === 0,
1202
+ stopReason,
1203
+ totalInputTokens,
1204
+ totalOutputTokens,
1205
+ totalLatencyMs: Date.now() - startMs,
1206
+ ...stubs !== void 0 ? { stubs } : {}
1207
+ };
1208
+ }
1209
+
1210
+ // src/index.ts
1211
+ var noopLogger3 = {
481
1212
  info: () => {
482
1213
  },
483
1214
  warn: () => {
@@ -491,7 +1222,7 @@ function discoverTsFiles(workspaceRoot) {
491
1222
  const walk = (dir) => {
492
1223
  let entries;
493
1224
  try {
494
- entries = fs3.readdirSync(dir, { withFileTypes: true });
1225
+ entries = fs7.readdirSync(dir, { withFileTypes: true });
495
1226
  } catch {
496
1227
  return;
497
1228
  }
@@ -500,10 +1231,10 @@ function discoverTsFiles(workspaceRoot) {
500
1231
  if (skip.has(e.name)) {
501
1232
  continue;
502
1233
  }
503
- walk(path3.join(dir, e.name));
1234
+ walk(path7.join(dir, e.name));
504
1235
  } else if (e.isFile() && !e.name.endsWith(".d.ts")) {
505
1236
  if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
506
- out.push(path3.relative(workspaceRoot, path3.join(dir, e.name)));
1237
+ out.push(path7.relative(workspaceRoot, path7.join(dir, e.name)));
507
1238
  }
508
1239
  }
509
1240
  }
@@ -513,11 +1244,11 @@ function discoverTsFiles(workspaceRoot) {
513
1244
  }
514
1245
  function runValidationLoop(opts) {
515
1246
  const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
516
- const logger = opts.logger ?? noopLogger;
517
- if (!fs3.existsSync(workspaceRoot)) {
1247
+ const logger = opts.logger ?? noopLogger3;
1248
+ if (!fs7.existsSync(workspaceRoot)) {
518
1249
  throw new Error(`workspace not found: ${workspaceRoot}`);
519
1250
  }
520
- if (!fs3.existsSync(path3.join(workspaceRoot, "tsconfig.json"))) {
1251
+ if (!fs7.existsSync(path7.join(workspaceRoot, "tsconfig.json"))) {
521
1252
  throw new Error(`no tsconfig.json in ${workspaceRoot}`);
522
1253
  }
523
1254
  const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
@@ -565,12 +1296,20 @@ function runValidationLoop(opts) {
565
1296
  };
566
1297
  }
567
1298
  export {
1299
+ applyEditBlocks,
1300
+ applySingleBlock,
568
1301
  discoverTsFiles,
1302
+ getTypeContext,
569
1303
  isInProcessTscEnabled,
570
1304
  isLSPFixerEnabled,
1305
+ mendSingleFile,
1306
+ parseEditBlocks,
571
1307
  resetInProcessTscCache,
572
1308
  resetLSPFixerCache,
1309
+ resetTypeContextCache,
573
1310
  runInProcessTsc,
574
1311
  runLSPFixerPass,
575
- runValidationLoop
1312
+ runMendLoop,
1313
+ runValidationLoop,
1314
+ stubAndContinue
576
1315
  };