@shipispec/tsfix 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/bin/tsfix.mjs +49 -0
- package/cli/run-stack.ts +195 -0
- package/package.json +68 -0
- package/src/index.ts +202 -0
- package/src/tsLanguageServiceFixer.ts +486 -0
- package/src/validatorInProcess.ts +276 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TS Language Service Fixer — Sprint G / Sprint J (2026-05-03).
|
|
3
|
+
*
|
|
4
|
+
* Layer 0 of the mend stack. Uses TypeScript's built-in `LanguageService.getCodeFixesAtPosition`
|
|
5
|
+
* (the same engine VS Code's Quick Fix uses) to resolve common errors *deterministically*,
|
|
6
|
+
* before we spend a single LLM call on them.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: ~80% of generated-code TS errors fall into a small set of
|
|
9
|
+
* boring categories that the compiler already knows how to fix:
|
|
10
|
+
*
|
|
11
|
+
* - TS2304 "Cannot find name X" → auto-import
|
|
12
|
+
* - TS2305 "no exported member named X" → did-you-mean rename
|
|
13
|
+
* - TS2551 "Property X does not exist on Y. Did you mean Z?" → spelling fix
|
|
14
|
+
* - TS2552 "Cannot find name X. Did you mean Y?" → spelling fix
|
|
15
|
+
* - TS2724 "no exported member, did you mean Y?" → import rename
|
|
16
|
+
*
|
|
17
|
+
* For these, the fixer is free (no LLM), fast (~ms), and deterministic.
|
|
18
|
+
* The LLM mend stack only gets called for *interesting* errors that require
|
|
19
|
+
* semantic reasoning (signature drift, missing logic, package gotchas).
|
|
20
|
+
*
|
|
21
|
+
* Conservative coverage: we only apply fixes for codes whose auto-fixes are
|
|
22
|
+
* unambiguous. Codes like TS7006 (implicit any) and TS2741 (missing property)
|
|
23
|
+
* are skipped — those need human intent to choose the right type or default
|
|
24
|
+
* value, and a wrong auto-fix introduces silent bugs.
|
|
25
|
+
*
|
|
26
|
+
* Iteration cap: 5 passes. After each pass we re-validate; cascades like
|
|
27
|
+
* "rename import → rename type annotation → rename method call" can need 3-4
|
|
28
|
+
* hops to converge. The signature-set progress check stops sooner if no new
|
|
29
|
+
* errors appear. If errors remain after pass 5, escalate to LLM mend.
|
|
30
|
+
*
|
|
31
|
+
* Feature flag: `SPECTOSHIP_TS_LSP_FIXER=false` opts out (default: ON).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as path from "node:path";
|
|
36
|
+
import * as ts from "typescript";
|
|
37
|
+
|
|
38
|
+
/** TS error codes whose built-in code-fix is safe to apply without human review. */
|
|
39
|
+
const SAFE_FIXABLE_CODES = new Set<number>([
|
|
40
|
+
2304, // Cannot find name 'X'
|
|
41
|
+
2305, // Module '...' has no exported member 'X'
|
|
42
|
+
2551, // Property 'X' does not exist on type 'Y'. Did you mean 'Z'?
|
|
43
|
+
2552, // Cannot find name 'X'. Did you mean 'Y'?
|
|
44
|
+
2724, // '...' has no exported member named 'X'. Did you mean 'Y'?
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Allowlist of TypeScript fix names we will apply. Many TS error codes return
|
|
49
|
+
* multiple alternative fixes (e.g. for TS2304: "import" adds an import,
|
|
50
|
+
* `fixMissingFunctionDeclaration` declares a stub) and the wrong one rewrites
|
|
51
|
+
* intent. Only the names below are deterministic and safe.
|
|
52
|
+
*
|
|
53
|
+
* Discovered via probe (2026-05-03): for TS2304 'Cannot find name', the LSP
|
|
54
|
+
* returns ["import", "fixMissingFunctionDeclaration"]. Without this allowlist,
|
|
55
|
+
* the equivalence check rejected both and the auto-import never fired.
|
|
56
|
+
*/
|
|
57
|
+
const SAFE_FIX_NAMES = new Set<string>([
|
|
58
|
+
"import", // auto-add import statement (TS2304, TS2305)
|
|
59
|
+
"fixImport", // alternative auto-import in some scenarios
|
|
60
|
+
"spelling", // did-you-mean rename for TS2552 (the actual fixName the LSP returns)
|
|
61
|
+
"fixSpelling", // alternate spelling-fix name some TS versions emit
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export interface LSPFixerLogger {
|
|
65
|
+
info(msg: string): void;
|
|
66
|
+
warn(msg: string): void;
|
|
67
|
+
error(msg: string): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LSPFixerOptions {
|
|
71
|
+
workspaceRoot: string;
|
|
72
|
+
/** Files where errors were detected. Limits the fix scope. */
|
|
73
|
+
targetFiles: string[];
|
|
74
|
+
logger: LSPFixerLogger;
|
|
75
|
+
/** Max iterations (default 5). Signature-set progress check stops sooner. */
|
|
76
|
+
maxIterations?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface LSPFixerResult {
|
|
80
|
+
/** Number of fixes successfully applied across all iterations. */
|
|
81
|
+
fixesApplied: number;
|
|
82
|
+
/** Files whose contents were modified on disk. */
|
|
83
|
+
filesEdited: string[];
|
|
84
|
+
/** Iteration count when fixer stopped (1 if it converged on first pass). */
|
|
85
|
+
iterations: number;
|
|
86
|
+
/** When true, every diagnostic was auto-fixable and resolved. Caller can skip LLM mend. */
|
|
87
|
+
allResolved: boolean;
|
|
88
|
+
/** Errors remaining after the last iteration (caller passes these to LLM mend). */
|
|
89
|
+
remainingErrors: Array<{
|
|
90
|
+
file: string;
|
|
91
|
+
line: number;
|
|
92
|
+
column: number;
|
|
93
|
+
code: string;
|
|
94
|
+
message: string;
|
|
95
|
+
}>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply LSP code-fixes to all diagnostics in the workspace whose error code
|
|
100
|
+
* is in SAFE_FIXABLE_CODES. Writes edits back to disk. Re-runs ts diagnostics
|
|
101
|
+
* after each pass; stops when no further fixable errors remain or
|
|
102
|
+
* maxIterations is reached.
|
|
103
|
+
*
|
|
104
|
+
* Throws on host setup failure (missing tsconfig, etc.) — callers should
|
|
105
|
+
* catch and fall through to LLM mend.
|
|
106
|
+
*/
|
|
107
|
+
export function runLSPFixerPass(opts: LSPFixerOptions): LSPFixerResult {
|
|
108
|
+
const { workspaceRoot, targetFiles, logger } = opts;
|
|
109
|
+
const maxIterations = opts.maxIterations ?? 5;
|
|
110
|
+
const tsconfigPath = path.join(workspaceRoot, "tsconfig.json");
|
|
111
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
112
|
+
return {
|
|
113
|
+
fixesApplied: 0,
|
|
114
|
+
filesEdited: [],
|
|
115
|
+
iterations: 0,
|
|
116
|
+
allResolved: true,
|
|
117
|
+
remainingErrors: [],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const compilerOptions = readCompilerOptions(tsconfigPath, logger);
|
|
122
|
+
if (!compilerOptions) {
|
|
123
|
+
return {
|
|
124
|
+
fixesApplied: 0,
|
|
125
|
+
filesEdited: [],
|
|
126
|
+
iterations: 0,
|
|
127
|
+
allResolved: true,
|
|
128
|
+
remainingErrors: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build a versioned in-memory snapshot table. The host reads from this
|
|
133
|
+
// table for files we've edited, falling back to disk for everything else.
|
|
134
|
+
// Without versioning, the LanguageService caches stale ASTs and misfires.
|
|
135
|
+
const snapshots = new Map<string, { content: string; version: number }>();
|
|
136
|
+
const filesEdited = new Set<string>();
|
|
137
|
+
let totalFixes = 0;
|
|
138
|
+
|
|
139
|
+
// Resolve workspace's typescript lib dir for `getDefaultLibFileName` — the
|
|
140
|
+
// extension-bundled typescript can't find its lib files (esbuild strips
|
|
141
|
+
// `__dirname` resolution). See validatorInProcess.ts for the same fix.
|
|
142
|
+
const workspaceLibDir = path.join(workspaceRoot, "node_modules", "typescript", "lib");
|
|
143
|
+
const hasWorkspaceLib = fs.existsSync(workspaceLibDir);
|
|
144
|
+
|
|
145
|
+
const host: ts.LanguageServiceHost = {
|
|
146
|
+
getScriptFileNames: () => Array.from(snapshots.keys()),
|
|
147
|
+
getScriptVersion: (fileName) => String(snapshots.get(fileName)?.version ?? 0),
|
|
148
|
+
getScriptSnapshot: (fileName) => {
|
|
149
|
+
const cached = snapshots.get(fileName);
|
|
150
|
+
if (cached) {
|
|
151
|
+
return ts.ScriptSnapshot.fromString(cached.content);
|
|
152
|
+
}
|
|
153
|
+
if (!fs.existsSync(fileName)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const content = fs.readFileSync(fileName, "utf-8");
|
|
158
|
+
snapshots.set(fileName, { content, version: 1 });
|
|
159
|
+
return ts.ScriptSnapshot.fromString(content);
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
getCurrentDirectory: () => workspaceRoot,
|
|
165
|
+
getCompilationSettings: () => compilerOptions,
|
|
166
|
+
getDefaultLibFileName: (options) => {
|
|
167
|
+
if (hasWorkspaceLib) {
|
|
168
|
+
// Return absolute path inside the workspace's typescript install.
|
|
169
|
+
// LanguageService uses the directory of this file as the lib dir,
|
|
170
|
+
// which means lib.dom.d.ts / lib.es2015.d.ts etc. resolve there too.
|
|
171
|
+
return path.join(workspaceLibDir, path.basename(ts.getDefaultLibFilePath(options)));
|
|
172
|
+
}
|
|
173
|
+
return ts.getDefaultLibFilePath(options);
|
|
174
|
+
},
|
|
175
|
+
fileExists: (fileName) => snapshots.has(fileName) || fs.existsSync(fileName),
|
|
176
|
+
readFile: (fileName) =>
|
|
177
|
+
snapshots.get(fileName)?.content ??
|
|
178
|
+
(fs.existsSync(fileName) ? fs.readFileSync(fileName, "utf-8") : undefined),
|
|
179
|
+
readDirectory: ts.sys.readDirectory,
|
|
180
|
+
directoryExists: ts.sys.directoryExists,
|
|
181
|
+
getDirectories: ts.sys.getDirectories,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Seed the snapshot map with all target files so the LanguageService
|
|
185
|
+
// scans them on first call.
|
|
186
|
+
for (const f of targetFiles) {
|
|
187
|
+
const abs = path.isAbsolute(f) ? f : path.join(workspaceRoot, f);
|
|
188
|
+
if (!fs.existsSync(abs)) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const content = fs.readFileSync(abs, "utf-8");
|
|
192
|
+
snapshots.set(abs, { content, version: 1 });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const service = ts.createLanguageService(host, ts.createDocumentRegistry());
|
|
196
|
+
|
|
197
|
+
let iter = 0;
|
|
198
|
+
let lastErrorSignatures = new Set<string>();
|
|
199
|
+
for (iter = 1; iter <= maxIterations; iter++) {
|
|
200
|
+
const fixableErrors = collectFixableErrors(service, snapshots, workspaceRoot);
|
|
201
|
+
if (fixableErrors.length === 0) {
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
// Detect "stuck loop": same identical set of fixable errors across two
|
|
205
|
+
// iterations. Compare by (file, start, code) signature, not just count —
|
|
206
|
+
// a fix can convert a TS2724 at position A into a TS2552 at position B,
|
|
207
|
+
// which keeps the count at 1 but is genuine progress.
|
|
208
|
+
const signatures = computeErrorSignatures(fixableErrors);
|
|
209
|
+
if (signatureSetsEqual(signatures, lastErrorSignatures)) {
|
|
210
|
+
logger.info(
|
|
211
|
+
`[ts-lsp-fixer] iteration ${iter}: no progress (${fixableErrors.length} fixable error(s), same set as last iter) — stopping`,
|
|
212
|
+
);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
lastErrorSignatures = signatures;
|
|
216
|
+
|
|
217
|
+
let appliedThisIter = 0;
|
|
218
|
+
for (const err of fixableErrors) {
|
|
219
|
+
const fixes = safeGetCodeFixes(service, err);
|
|
220
|
+
if (!fixes || fixes.length === 0) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Pick the safest applicable fix:
|
|
224
|
+
// 1. Filter to only fixes whose `fixName` is in SAFE_FIX_NAMES.
|
|
225
|
+
// This rules out destructive alternatives like
|
|
226
|
+
// `fixMissingFunctionDeclaration` (declares a stub) which TS
|
|
227
|
+
// suggests alongside `import` for TS2304.
|
|
228
|
+
// 2. If multiple safe fixes remain and they're not textually
|
|
229
|
+
// equivalent, skip — genuine ambiguity (e.g., import from
|
|
230
|
+
// package A vs package B).
|
|
231
|
+
const safeFixes = fixes.filter((f) => SAFE_FIX_NAMES.has(f.fixName));
|
|
232
|
+
if (safeFixes.length === 0) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (safeFixes.length > 1 && !fixesAreEquivalent(safeFixes)) {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const fix = safeFixes[0];
|
|
239
|
+
const applied = applyFixToSnapshots(fix, snapshots);
|
|
240
|
+
if (applied > 0) {
|
|
241
|
+
appliedThisIter++;
|
|
242
|
+
totalFixes++;
|
|
243
|
+
for (const change of fix.changes) {
|
|
244
|
+
filesEdited.add(change.fileName);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
logger.info(
|
|
249
|
+
`[ts-lsp-fixer] iteration ${iter}: applied ${appliedThisIter}/${fixableErrors.length} fixes`,
|
|
250
|
+
);
|
|
251
|
+
if (appliedThisIter === 0) {
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Persist the final snapshots back to disk for files we modified.
|
|
257
|
+
for (const fileName of filesEdited) {
|
|
258
|
+
const snap = snapshots.get(fileName);
|
|
259
|
+
if (snap) {
|
|
260
|
+
try {
|
|
261
|
+
fs.writeFileSync(fileName, snap.content, "utf-8");
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logger.warn(
|
|
264
|
+
`[ts-lsp-fixer] failed to write ${fileName}: ${err instanceof Error ? err.message : String(err)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Final diagnostic snapshot for the caller (now reading from edited files).
|
|
271
|
+
const remaining = collectAllErrors(service, snapshots, workspaceRoot);
|
|
272
|
+
service.dispose();
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
fixesApplied: totalFixes,
|
|
276
|
+
filesEdited: Array.from(filesEdited).map((f) => path.relative(workspaceRoot, f) || f),
|
|
277
|
+
iterations: iter,
|
|
278
|
+
allResolved: remaining.length === 0,
|
|
279
|
+
remainingErrors: remaining,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function readCompilerOptions(
|
|
284
|
+
tsconfigPath: string,
|
|
285
|
+
logger: LSPFixerLogger,
|
|
286
|
+
): ts.CompilerOptions | null {
|
|
287
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
288
|
+
if (configFile.error) {
|
|
289
|
+
logger.error(
|
|
290
|
+
`[ts-lsp-fixer] tsconfig parse error: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`,
|
|
291
|
+
);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
295
|
+
configFile.config,
|
|
296
|
+
ts.sys,
|
|
297
|
+
path.dirname(tsconfigPath),
|
|
298
|
+
);
|
|
299
|
+
return parsed.options;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function collectFixableErrors(
|
|
303
|
+
service: ts.LanguageService,
|
|
304
|
+
snapshots: Map<string, { content: string; version: number }>,
|
|
305
|
+
workspaceRoot: string,
|
|
306
|
+
): Array<{ file: string; start: number; length: number; code: number }> {
|
|
307
|
+
const out: Array<{ file: string; start: number; length: number; code: number }> = [];
|
|
308
|
+
for (const [fileName] of snapshots) {
|
|
309
|
+
if (fileName.includes("node_modules")) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const semantic = service.getSemanticDiagnostics(fileName);
|
|
316
|
+
const syntactic = service.getSyntacticDiagnostics(fileName);
|
|
317
|
+
for (const d of [...semantic, ...syntactic]) {
|
|
318
|
+
if (!SAFE_FIXABLE_CODES.has(d.code)) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (d.start === undefined || d.length === undefined) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
out.push({ file: fileName, start: d.start, length: d.length, code: d.code });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
void workspaceRoot;
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function collectAllErrors(
|
|
332
|
+
service: ts.LanguageService,
|
|
333
|
+
snapshots: Map<string, { content: string; version: number }>,
|
|
334
|
+
workspaceRoot: string,
|
|
335
|
+
): LSPFixerResult["remainingErrors"] {
|
|
336
|
+
const out: LSPFixerResult["remainingErrors"] = [];
|
|
337
|
+
for (const [fileName] of snapshots) {
|
|
338
|
+
if (fileName.includes("node_modules")) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const semantic = service.getSemanticDiagnostics(fileName);
|
|
345
|
+
const syntactic = service.getSyntacticDiagnostics(fileName);
|
|
346
|
+
for (const d of [...semantic, ...syntactic]) {
|
|
347
|
+
if (d.category !== ts.DiagnosticCategory.Error) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
let line = 0;
|
|
351
|
+
let column = 0;
|
|
352
|
+
if (d.file && d.start !== undefined) {
|
|
353
|
+
const pos = d.file.getLineAndCharacterOfPosition(d.start);
|
|
354
|
+
line = pos.line + 1;
|
|
355
|
+
column = pos.character + 1;
|
|
356
|
+
}
|
|
357
|
+
out.push({
|
|
358
|
+
file: path.relative(workspaceRoot, fileName) || fileName,
|
|
359
|
+
line,
|
|
360
|
+
column,
|
|
361
|
+
code: `TS${d.code}`,
|
|
362
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function safeGetCodeFixes(
|
|
370
|
+
service: ts.LanguageService,
|
|
371
|
+
err: { file: string; start: number; length: number; code: number },
|
|
372
|
+
): readonly ts.CodeFixAction[] | null {
|
|
373
|
+
try {
|
|
374
|
+
return service.getCodeFixesAtPosition(
|
|
375
|
+
err.file,
|
|
376
|
+
err.start,
|
|
377
|
+
err.start + err.length,
|
|
378
|
+
[err.code],
|
|
379
|
+
{},
|
|
380
|
+
{},
|
|
381
|
+
);
|
|
382
|
+
} catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* When the LanguageService returns multiple code-fix candidates, only apply
|
|
389
|
+
* if they're textually equivalent (same edits on the same files). This
|
|
390
|
+
* conservatively skips ambiguous cases (e.g., import from `lib/foo` vs
|
|
391
|
+
* `lib/bar` where both export `Foo`) where guessing wrong is worse than
|
|
392
|
+
* deferring to the LLM.
|
|
393
|
+
*/
|
|
394
|
+
/**
|
|
395
|
+
* @internal Compute a stable `(file, start, code)` signature for each fixable
|
|
396
|
+
* error. Used by the iteration loop's stuck-loop detector.
|
|
397
|
+
*/
|
|
398
|
+
export function computeErrorSignatures(
|
|
399
|
+
errors: readonly { file: string; start: number; code: number }[],
|
|
400
|
+
): Set<string> {
|
|
401
|
+
return new Set(errors.map((e) => `${e.file}:${e.start}:${e.code}`));
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @internal True if `a` and `b` contain the same members. Used to decide
|
|
406
|
+
* whether the iteration loop is stuck (same error set across passes) vs.
|
|
407
|
+
* making genuine progress (set membership changed even if size didn't).
|
|
408
|
+
*/
|
|
409
|
+
export function signatureSetsEqual(a: Set<string>, b: Set<string>): boolean {
|
|
410
|
+
if (a.size !== b.size) {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
for (const s of a) {
|
|
414
|
+
if (!b.has(s)) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* @internal True if every fix in `fixes` produces identical text edits.
|
|
423
|
+
* Used to decide whether multiple candidate fixes for one error are safe to
|
|
424
|
+
* pick automatically (identical = unambiguous; different = abstain).
|
|
425
|
+
*/
|
|
426
|
+
export function fixesAreEquivalent(fixes: readonly ts.CodeFixAction[]): boolean {
|
|
427
|
+
if (fixes.length === 0) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
const first = serializeFix(fixes[0]);
|
|
431
|
+
for (let i = 1; i < fixes.length; i++) {
|
|
432
|
+
if (serializeFix(fixes[i]) !== first) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function serializeFix(fix: ts.CodeFixAction): string {
|
|
440
|
+
return fix.changes
|
|
441
|
+
.map(
|
|
442
|
+
(c) =>
|
|
443
|
+
`${c.fileName}|${c.textChanges.map((t) => `${t.span.start}:${t.span.length}:${t.newText}`).join(";")}`,
|
|
444
|
+
)
|
|
445
|
+
.join("||");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* @internal Apply a CodeFixAction's text changes to in-memory snapshots.
|
|
450
|
+
* Returns the number of changes successfully applied. Bumps script versions
|
|
451
|
+
* so the LanguageService re-parses on next call. Skips edits to files not
|
|
452
|
+
* already in `snapshots` (defensive — won't create new files unbeknownst).
|
|
453
|
+
*/
|
|
454
|
+
export function applyFixToSnapshots(
|
|
455
|
+
fix: ts.CodeFixAction,
|
|
456
|
+
snapshots: Map<string, { content: string; version: number }>,
|
|
457
|
+
): number {
|
|
458
|
+
let applied = 0;
|
|
459
|
+
for (const change of fix.changes) {
|
|
460
|
+
const snap = snapshots.get(change.fileName);
|
|
461
|
+
if (!snap) {
|
|
462
|
+
// New file (e.g., auto-import sometimes creates a new file). Skip
|
|
463
|
+
// for safety — we don't want the fixer creating files unbeknownst.
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
// Apply edits in reverse-order so earlier offsets stay valid.
|
|
467
|
+
const sorted = [...change.textChanges].sort((a, b) => b.span.start - a.span.start);
|
|
468
|
+
let next = snap.content;
|
|
469
|
+
for (const tc of sorted) {
|
|
470
|
+
next = next.slice(0, tc.span.start) + tc.newText + next.slice(tc.span.start + tc.span.length);
|
|
471
|
+
}
|
|
472
|
+
snapshots.set(change.fileName, { content: next, version: snap.version + 1 });
|
|
473
|
+
applied++;
|
|
474
|
+
}
|
|
475
|
+
return applied;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Whether the LSP fixer is enabled (env-flag opt-out). Default ON. */
|
|
479
|
+
export function isLSPFixerEnabled(): boolean {
|
|
480
|
+
return process.env.SPECTOSHIP_TS_LSP_FIXER !== "false";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Reset internal caches (for tests). No-op currently — service is created per-call. */
|
|
484
|
+
export function resetLSPFixerCache(): void {
|
|
485
|
+
// Intentional no-op: we create a fresh LanguageService per call.
|
|
486
|
+
}
|