@shipispec/tsfix 0.4.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/CHANGELOG.md +59 -0
- package/dist/cli.js +866 -26
- package/dist/index.d.ts +8 -0
- package/dist/index.js +281 -34717
- package/dist/types/index.d.ts +8 -0
- package/dist/types/runMendLoop.d.ts +16 -1
- package/dist/types/stubAndContinue.d.ts +68 -0
- package/package.json +2 -3
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
|
|
5
|
-
import * as
|
|
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,14 +473,737 @@ function applyFixToSnapshots(fix, snapshots) {
|
|
|
473
473
|
}
|
|
474
474
|
|
|
475
475
|
// src/index.ts
|
|
476
|
-
import * as
|
|
477
|
-
import * as
|
|
476
|
+
import * as fs7 from "node:fs";
|
|
477
|
+
import * as path7 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
|
+
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
|
+
}
|
|
481
1204
|
|
|
482
1205
|
// src/index.ts
|
|
483
|
-
var
|
|
1206
|
+
var noopLogger3 = {
|
|
484
1207
|
info: () => {
|
|
485
1208
|
},
|
|
486
1209
|
warn: () => {
|
|
@@ -494,7 +1217,7 @@ function discoverTsFiles(workspaceRoot) {
|
|
|
494
1217
|
const walk = (dir) => {
|
|
495
1218
|
let entries;
|
|
496
1219
|
try {
|
|
497
|
-
entries =
|
|
1220
|
+
entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
498
1221
|
} catch {
|
|
499
1222
|
return;
|
|
500
1223
|
}
|
|
@@ -503,10 +1226,10 @@ function discoverTsFiles(workspaceRoot) {
|
|
|
503
1226
|
if (skip.has(e.name)) {
|
|
504
1227
|
continue;
|
|
505
1228
|
}
|
|
506
|
-
walk(
|
|
1229
|
+
walk(path7.join(dir, e.name));
|
|
507
1230
|
} else if (e.isFile() && !e.name.endsWith(".d.ts")) {
|
|
508
1231
|
if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
|
|
509
|
-
out.push(
|
|
1232
|
+
out.push(path7.relative(workspaceRoot, path7.join(dir, e.name)));
|
|
510
1233
|
}
|
|
511
1234
|
}
|
|
512
1235
|
}
|
|
@@ -516,11 +1239,11 @@ function discoverTsFiles(workspaceRoot) {
|
|
|
516
1239
|
}
|
|
517
1240
|
function runValidationLoop(opts) {
|
|
518
1241
|
const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
|
|
519
|
-
const logger = opts.logger ??
|
|
520
|
-
if (!
|
|
1242
|
+
const logger = opts.logger ?? noopLogger3;
|
|
1243
|
+
if (!fs7.existsSync(workspaceRoot)) {
|
|
521
1244
|
throw new Error(`workspace not found: ${workspaceRoot}`);
|
|
522
1245
|
}
|
|
523
|
-
if (!
|
|
1246
|
+
if (!fs7.existsSync(path7.join(workspaceRoot, "tsconfig.json"))) {
|
|
524
1247
|
throw new Error(`no tsconfig.json in ${workspaceRoot}`);
|
|
525
1248
|
}
|
|
526
1249
|
const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
|
|
@@ -569,6 +1292,16 @@ function runValidationLoop(opts) {
|
|
|
569
1292
|
}
|
|
570
1293
|
|
|
571
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
|
+
}
|
|
572
1305
|
function parseArgs(argv) {
|
|
573
1306
|
const args = {
|
|
574
1307
|
workspace: "",
|
|
@@ -576,7 +1309,11 @@ function parseArgs(argv) {
|
|
|
576
1309
|
noLsp: false,
|
|
577
1310
|
dryRun: false,
|
|
578
1311
|
files: void 0,
|
|
579
|
-
verbose: false
|
|
1312
|
+
verbose: false,
|
|
1313
|
+
llm: false,
|
|
1314
|
+
llmModel: "claude-haiku-4-5",
|
|
1315
|
+
llmMaxIterations: 3,
|
|
1316
|
+
llmBudgetUsd: void 0
|
|
580
1317
|
};
|
|
581
1318
|
for (let i = 0; i < argv.length; i++) {
|
|
582
1319
|
const a = argv[i];
|
|
@@ -592,6 +1329,24 @@ function parseArgs(argv) {
|
|
|
592
1329
|
args.files = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
593
1330
|
} else if (a === "--verbose" || a === "-v") {
|
|
594
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;
|
|
595
1350
|
} else if (a === "--help" || a === "-h") {
|
|
596
1351
|
printHelp();
|
|
597
1352
|
process.exit(0);
|
|
@@ -606,22 +1361,33 @@ function parseArgs(argv) {
|
|
|
606
1361
|
}
|
|
607
1362
|
function printHelp() {
|
|
608
1363
|
console.error(`
|
|
609
|
-
Usage:
|
|
1364
|
+
Usage: tsfix --workspace <path> [options]
|
|
610
1365
|
|
|
611
|
-
|
|
612
|
-
--workspace, -w <path>
|
|
613
|
-
--files <
|
|
614
|
-
--no-lsp
|
|
615
|
-
--dry-run
|
|
616
|
-
|
|
617
|
-
--
|
|
618
|
-
--
|
|
619
|
-
|
|
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.
|
|
620
1385
|
|
|
621
1386
|
Exit codes:
|
|
622
1387
|
0 no errors after stack
|
|
623
1388
|
1 errors remain after stack
|
|
624
1389
|
2 bad arguments / harness error
|
|
1390
|
+
3 Layer 2 budget exceeded (errors may still remain; partial work persisted)
|
|
625
1391
|
`.trim());
|
|
626
1392
|
}
|
|
627
1393
|
function makeLogger(captureLines, verbose) {
|
|
@@ -658,6 +1424,15 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
|
|
|
658
1424
|
}
|
|
659
1425
|
} else {
|
|
660
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}
|
|
661
1436
|
`);
|
|
662
1437
|
}
|
|
663
1438
|
w.write(` errors after: ${r.errorsAfter}
|
|
@@ -679,12 +1454,12 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
|
|
|
679
1454
|
}
|
|
680
1455
|
async function main() {
|
|
681
1456
|
const args = parseArgs(process.argv.slice(2));
|
|
682
|
-
const workspaceRoot =
|
|
683
|
-
if (!
|
|
1457
|
+
const workspaceRoot = path8.resolve(args.workspace);
|
|
1458
|
+
if (!fs8.existsSync(workspaceRoot)) {
|
|
684
1459
|
console.error(`error: workspace not found: ${workspaceRoot}`);
|
|
685
1460
|
return 2;
|
|
686
1461
|
}
|
|
687
|
-
if (!
|
|
1462
|
+
if (!fs8.existsSync(path8.join(workspaceRoot, "tsconfig.json"))) {
|
|
688
1463
|
console.error(`error: no tsconfig.json in ${workspaceRoot}`);
|
|
689
1464
|
return 2;
|
|
690
1465
|
}
|
|
@@ -703,9 +1478,10 @@ async function main() {
|
|
|
703
1478
|
logger
|
|
704
1479
|
});
|
|
705
1480
|
const report = {
|
|
706
|
-
workspace:
|
|
1481
|
+
workspace: path8.relative(process.cwd(), workspaceRoot) || workspaceRoot,
|
|
707
1482
|
errorsBefore: loop.errorsBefore,
|
|
708
1483
|
lspFixer: args.noLsp ? { ran: false, fixesApplied: 0, filesEdited: [], iterations: 0 } : loop.lspFixer,
|
|
1484
|
+
layer2: null,
|
|
709
1485
|
errorsAfter: loop.errorsAfter,
|
|
710
1486
|
remainingByCode: loop.remainingByCode,
|
|
711
1487
|
remainingByFile: loop.remainingByFile,
|
|
@@ -713,11 +1489,75 @@ async function main() {
|
|
|
713
1489
|
elapsedMs: loop.elapsedMs,
|
|
714
1490
|
dryRun: args.dryRun
|
|
715
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
|
+
}
|
|
716
1555
|
if (args.json) {
|
|
717
1556
|
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
718
1557
|
} else {
|
|
719
1558
|
printHumanReport(report);
|
|
720
1559
|
}
|
|
1560
|
+
if (budgetExceeded) return 3;
|
|
721
1561
|
return report.passed ? 0 : 1;
|
|
722
1562
|
}
|
|
723
1563
|
main().then(
|