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