@shipispec/tsfix 0.1.0 → 0.3.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 +104 -0
- package/README.md +144 -86
- package/dist/cli.js +724 -0
- package/dist/index.d.ts +176 -0
- package/dist/index.js +576 -0
- package/dist/types/index.d.ts +176 -0
- package/dist/types/tsLanguageServiceFixer.d.ts +124 -0
- package/dist/types/validatorInProcess.d.ts +64 -0
- package/package.json +19 -16
- package/bin/tsfix.mjs +0 -49
- package/cli/run-stack.ts +0 -195
- package/src/index.ts +0 -202
- package/src/tsLanguageServiceFixer.ts +0 -486
- package/src/validatorInProcess.ts +0 -276
package/dist/index.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// src/validatorInProcess.ts
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as ts from "typescript";
|
|
5
|
+
var programCache = /* @__PURE__ */ new Map();
|
|
6
|
+
function runInProcessTsc(opts) {
|
|
7
|
+
const { workspaceRoot, generatedFiles, logger } = opts;
|
|
8
|
+
const tsconfigPath = path.join(workspaceRoot, "tsconfig.json");
|
|
9
|
+
if (!fs.existsSync(tsconfigPath)) {
|
|
10
|
+
return {
|
|
11
|
+
passed: true,
|
|
12
|
+
output: "(no tsconfig.json \u2014 skipped)",
|
|
13
|
+
diagnostics: [],
|
|
14
|
+
lineCount: 1
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const program = getOrCreateProgram(workspaceRoot, tsconfigPath, logger);
|
|
18
|
+
if (!program) {
|
|
19
|
+
return {
|
|
20
|
+
passed: true,
|
|
21
|
+
output: "(failed to create program \u2014 skipping)",
|
|
22
|
+
diagnostics: [],
|
|
23
|
+
lineCount: 1
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
const allDiagnostics = ts.getPreEmitDiagnostics(program);
|
|
27
|
+
const structured = formatDiagnostics(allDiagnostics, workspaceRoot);
|
|
28
|
+
const filtered = generatedFiles?.length ? structured.filter(
|
|
29
|
+
(d) => generatedFiles.some((g) => g.replace(/^\.\//, "") === d.file.replace(/^\.\//, ""))
|
|
30
|
+
) : structured;
|
|
31
|
+
const errors = filtered.filter((d) => d.category === "error");
|
|
32
|
+
const output = errors.map((d) => `${d.file}(${d.line},${d.column}): error ${d.code}: ${d.message}`).join("\n");
|
|
33
|
+
return {
|
|
34
|
+
passed: errors.length === 0,
|
|
35
|
+
output: output || "(no errors)",
|
|
36
|
+
diagnostics: filtered,
|
|
37
|
+
lineCount: output.split("\n").length
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function getOrCreateProgram(workspaceRoot, tsconfigPath, logger) {
|
|
41
|
+
let configMtime = 0;
|
|
42
|
+
try {
|
|
43
|
+
configMtime = fs.statSync(tsconfigPath).mtimeMs;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const cached = programCache.get(workspaceRoot);
|
|
48
|
+
if (cached && cached.configMtime === configMtime) {
|
|
49
|
+
try {
|
|
50
|
+
const refreshed = ts.createProgram({
|
|
51
|
+
rootNames: cached.rootFiles,
|
|
52
|
+
options: cached.options,
|
|
53
|
+
host: cached.host,
|
|
54
|
+
oldProgram: cached.program
|
|
55
|
+
});
|
|
56
|
+
cached.program = refreshed;
|
|
57
|
+
return refreshed;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.warn(
|
|
60
|
+
`[in-process-tsc] refresh failed; rebuilding: ${err instanceof Error ? err.message : String(err)}`
|
|
61
|
+
);
|
|
62
|
+
programCache.delete(workspaceRoot);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
66
|
+
if (configFile.error) {
|
|
67
|
+
logger.error(
|
|
68
|
+
`[in-process-tsc] tsconfig parse error: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`
|
|
69
|
+
);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
73
|
+
configFile.config,
|
|
74
|
+
ts.sys,
|
|
75
|
+
path.dirname(tsconfigPath)
|
|
76
|
+
);
|
|
77
|
+
if (parsed.errors.length > 0) {
|
|
78
|
+
const msgs = parsed.errors.map((e) => ts.flattenDiagnosticMessageText(e.messageText, "\n")).join("; ");
|
|
79
|
+
logger.warn(`[in-process-tsc] tsconfig parse warnings: ${msgs}`);
|
|
80
|
+
}
|
|
81
|
+
const host = ts.createCompilerHost(parsed.options);
|
|
82
|
+
const workspaceLibDir = path.join(workspaceRoot, "node_modules", "typescript", "lib");
|
|
83
|
+
if (fs.existsSync(workspaceLibDir)) {
|
|
84
|
+
const originalGetDefaultLibFileName = host.getDefaultLibFileName.bind(host);
|
|
85
|
+
host.getDefaultLibLocation = () => workspaceLibDir;
|
|
86
|
+
host.getDefaultLibFileName = (options) => {
|
|
87
|
+
const fileName = path.basename(originalGetDefaultLibFileName(options));
|
|
88
|
+
return path.join(workspaceLibDir, fileName);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
let program;
|
|
92
|
+
try {
|
|
93
|
+
program = ts.createProgram({
|
|
94
|
+
rootNames: parsed.fileNames,
|
|
95
|
+
options: parsed.options,
|
|
96
|
+
host
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
logger.error(
|
|
100
|
+
`[in-process-tsc] createProgram failed: ${err instanceof Error ? err.message : String(err)}`
|
|
101
|
+
);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
programCache.set(workspaceRoot, {
|
|
105
|
+
rootFiles: parsed.fileNames,
|
|
106
|
+
options: parsed.options,
|
|
107
|
+
host,
|
|
108
|
+
program,
|
|
109
|
+
configMtime
|
|
110
|
+
});
|
|
111
|
+
return program;
|
|
112
|
+
}
|
|
113
|
+
function formatDiagnostics(diagnostics, workspaceRoot) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const d of diagnostics) {
|
|
116
|
+
if (!d.file || d.start === void 0) {
|
|
117
|
+
out.push({
|
|
118
|
+
file: "(global)",
|
|
119
|
+
line: 0,
|
|
120
|
+
column: 0,
|
|
121
|
+
code: `TS${d.code}`,
|
|
122
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
|
|
123
|
+
category: categoryName(d.category)
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const fileName = d.file.fileName;
|
|
128
|
+
if (fileName.includes("node_modules")) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
|
|
135
|
+
out.push({
|
|
136
|
+
file: path.relative(workspaceRoot, fileName) || fileName,
|
|
137
|
+
line: line + 1,
|
|
138
|
+
column: character + 1,
|
|
139
|
+
code: `TS${d.code}`,
|
|
140
|
+
message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
|
|
141
|
+
category: categoryName(d.category)
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
function categoryName(c) {
|
|
147
|
+
switch (c) {
|
|
148
|
+
case ts.DiagnosticCategory.Error:
|
|
149
|
+
return "error";
|
|
150
|
+
case ts.DiagnosticCategory.Warning:
|
|
151
|
+
return "warning";
|
|
152
|
+
case ts.DiagnosticCategory.Message:
|
|
153
|
+
return "message";
|
|
154
|
+
case ts.DiagnosticCategory.Suggestion:
|
|
155
|
+
return "suggestion";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function resetInProcessTscCache() {
|
|
159
|
+
programCache.clear();
|
|
160
|
+
}
|
|
161
|
+
function isInProcessTscEnabled() {
|
|
162
|
+
return process.env.SPECTOSHIP_TSC_INPROCESS !== "false";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/tsLanguageServiceFixer.ts
|
|
166
|
+
import * as fs2 from "node:fs";
|
|
167
|
+
import * as path2 from "node:path";
|
|
168
|
+
import * as ts2 from "typescript";
|
|
169
|
+
var SAFE_FIXABLE_CODES = /* @__PURE__ */ new Set([
|
|
170
|
+
2304,
|
|
171
|
+
// Cannot find name 'X'
|
|
172
|
+
2305,
|
|
173
|
+
// Module '...' has no exported member 'X'
|
|
174
|
+
2551,
|
|
175
|
+
// Property 'X' does not exist on type 'Y'. Did you mean 'Z'?
|
|
176
|
+
2552,
|
|
177
|
+
// Cannot find name 'X'. Did you mean 'Y'?
|
|
178
|
+
2724
|
|
179
|
+
// '...' has no exported member named 'X'. Did you mean 'Y'?
|
|
180
|
+
]);
|
|
181
|
+
var SAFE_FIX_NAMES = /* @__PURE__ */ new Set([
|
|
182
|
+
"import",
|
|
183
|
+
// auto-add import statement (TS2304, TS2305)
|
|
184
|
+
"fixImport",
|
|
185
|
+
// alternative auto-import in some scenarios
|
|
186
|
+
"spelling",
|
|
187
|
+
// did-you-mean rename for TS2552 (the actual fixName the LSP returns)
|
|
188
|
+
"fixSpelling"
|
|
189
|
+
// alternate spelling-fix name some TS versions emit
|
|
190
|
+
]);
|
|
191
|
+
function runLSPFixerPass(opts) {
|
|
192
|
+
const { workspaceRoot, targetFiles, logger } = opts;
|
|
193
|
+
const maxIterations = opts.maxIterations ?? 5;
|
|
194
|
+
const dryRun = opts.dryRun ?? false;
|
|
195
|
+
const tsconfigPath = path2.join(workspaceRoot, "tsconfig.json");
|
|
196
|
+
if (!fs2.existsSync(tsconfigPath)) {
|
|
197
|
+
return {
|
|
198
|
+
fixesApplied: 0,
|
|
199
|
+
filesEdited: [],
|
|
200
|
+
iterations: 0,
|
|
201
|
+
allResolved: true,
|
|
202
|
+
remainingErrors: []
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const compilerOptions = readCompilerOptions(tsconfigPath, logger);
|
|
206
|
+
if (!compilerOptions) {
|
|
207
|
+
return {
|
|
208
|
+
fixesApplied: 0,
|
|
209
|
+
filesEdited: [],
|
|
210
|
+
iterations: 0,
|
|
211
|
+
allResolved: true,
|
|
212
|
+
remainingErrors: []
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const snapshots = /* @__PURE__ */ new Map();
|
|
216
|
+
const filesEdited = /* @__PURE__ */ new Set();
|
|
217
|
+
let totalFixes = 0;
|
|
218
|
+
const workspaceLibDir = path2.join(workspaceRoot, "node_modules", "typescript", "lib");
|
|
219
|
+
const hasWorkspaceLib = fs2.existsSync(workspaceLibDir);
|
|
220
|
+
const host = {
|
|
221
|
+
getScriptFileNames: () => Array.from(snapshots.keys()),
|
|
222
|
+
getScriptVersion: (fileName) => String(snapshots.get(fileName)?.version ?? 0),
|
|
223
|
+
getScriptSnapshot: (fileName) => {
|
|
224
|
+
const cached = snapshots.get(fileName);
|
|
225
|
+
if (cached) {
|
|
226
|
+
return ts2.ScriptSnapshot.fromString(cached.content);
|
|
227
|
+
}
|
|
228
|
+
if (!fs2.existsSync(fileName)) {
|
|
229
|
+
return void 0;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
const content = fs2.readFileSync(fileName, "utf-8");
|
|
233
|
+
snapshots.set(fileName, { content, version: 1 });
|
|
234
|
+
return ts2.ScriptSnapshot.fromString(content);
|
|
235
|
+
} catch {
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
getCurrentDirectory: () => workspaceRoot,
|
|
240
|
+
getCompilationSettings: () => compilerOptions,
|
|
241
|
+
getDefaultLibFileName: (options) => {
|
|
242
|
+
if (hasWorkspaceLib) {
|
|
243
|
+
return path2.join(workspaceLibDir, path2.basename(ts2.getDefaultLibFilePath(options)));
|
|
244
|
+
}
|
|
245
|
+
return ts2.getDefaultLibFilePath(options);
|
|
246
|
+
},
|
|
247
|
+
fileExists: (fileName) => snapshots.has(fileName) || fs2.existsSync(fileName),
|
|
248
|
+
readFile: (fileName) => snapshots.get(fileName)?.content ?? (fs2.existsSync(fileName) ? fs2.readFileSync(fileName, "utf-8") : void 0),
|
|
249
|
+
readDirectory: ts2.sys.readDirectory,
|
|
250
|
+
directoryExists: ts2.sys.directoryExists,
|
|
251
|
+
getDirectories: ts2.sys.getDirectories
|
|
252
|
+
};
|
|
253
|
+
for (const f of targetFiles) {
|
|
254
|
+
const abs = path2.isAbsolute(f) ? f : path2.join(workspaceRoot, f);
|
|
255
|
+
if (!fs2.existsSync(abs)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const content = fs2.readFileSync(abs, "utf-8");
|
|
259
|
+
snapshots.set(abs, { content, version: 1 });
|
|
260
|
+
}
|
|
261
|
+
const service = ts2.createLanguageService(host, ts2.createDocumentRegistry());
|
|
262
|
+
let iter = 0;
|
|
263
|
+
let lastErrorSignatures = /* @__PURE__ */ new Set();
|
|
264
|
+
for (iter = 1; iter <= maxIterations; iter++) {
|
|
265
|
+
const fixableErrors = collectFixableErrors(service, snapshots, workspaceRoot);
|
|
266
|
+
if (fixableErrors.length === 0) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
const signatures = computeErrorSignatures(fixableErrors);
|
|
270
|
+
if (signatureSetsEqual(signatures, lastErrorSignatures)) {
|
|
271
|
+
logger.info(
|
|
272
|
+
`[ts-lsp-fixer] iteration ${iter}: no progress (${fixableErrors.length} fixable error(s), same set as last iter) \u2014 stopping`
|
|
273
|
+
);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
lastErrorSignatures = signatures;
|
|
277
|
+
let appliedThisIter = 0;
|
|
278
|
+
for (const err of fixableErrors) {
|
|
279
|
+
const fixes = safeGetCodeFixes(service, err);
|
|
280
|
+
if (!fixes || fixes.length === 0) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const safeFixes = fixes.filter((f) => SAFE_FIX_NAMES.has(f.fixName));
|
|
284
|
+
if (safeFixes.length === 0) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (safeFixes.length > 1 && !fixesAreEquivalent(safeFixes)) {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const fix = safeFixes[0];
|
|
291
|
+
const applied = applyFixToSnapshots(fix, snapshots);
|
|
292
|
+
if (applied > 0) {
|
|
293
|
+
appliedThisIter++;
|
|
294
|
+
totalFixes++;
|
|
295
|
+
for (const change of fix.changes) {
|
|
296
|
+
filesEdited.add(change.fileName);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
logger.info(
|
|
301
|
+
`[ts-lsp-fixer] iteration ${iter}: applied ${appliedThisIter}/${fixableErrors.length} fixes`
|
|
302
|
+
);
|
|
303
|
+
if (appliedThisIter === 0) {
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (dryRun) {
|
|
308
|
+
if (filesEdited.size > 0) {
|
|
309
|
+
logger.info(
|
|
310
|
+
`[ts-lsp-fixer] dry-run: skipped writing ${filesEdited.size} file(s): ${[...filesEdited].map((f) => path2.relative(workspaceRoot, f) || f).join(", ")}`
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
for (const fileName of filesEdited) {
|
|
315
|
+
const snap = snapshots.get(fileName);
|
|
316
|
+
if (snap) {
|
|
317
|
+
try {
|
|
318
|
+
fs2.writeFileSync(fileName, snap.content, "utf-8");
|
|
319
|
+
} catch (err) {
|
|
320
|
+
logger.warn(
|
|
321
|
+
`[ts-lsp-fixer] failed to write ${fileName}: ${err instanceof Error ? err.message : String(err)}`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const remaining = collectAllErrors(service, snapshots, workspaceRoot);
|
|
328
|
+
service.dispose();
|
|
329
|
+
return {
|
|
330
|
+
fixesApplied: totalFixes,
|
|
331
|
+
filesEdited: Array.from(filesEdited).map((f) => path2.relative(workspaceRoot, f) || f),
|
|
332
|
+
iterations: iter,
|
|
333
|
+
allResolved: remaining.length === 0,
|
|
334
|
+
remainingErrors: remaining
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function readCompilerOptions(tsconfigPath, logger) {
|
|
338
|
+
const configFile = ts2.readConfigFile(tsconfigPath, ts2.sys.readFile);
|
|
339
|
+
if (configFile.error) {
|
|
340
|
+
logger.error(
|
|
341
|
+
`[ts-lsp-fixer] tsconfig parse error: ${ts2.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`
|
|
342
|
+
);
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const parsed = ts2.parseJsonConfigFileContent(
|
|
346
|
+
configFile.config,
|
|
347
|
+
ts2.sys,
|
|
348
|
+
path2.dirname(tsconfigPath)
|
|
349
|
+
);
|
|
350
|
+
return parsed.options;
|
|
351
|
+
}
|
|
352
|
+
function collectFixableErrors(service, snapshots, workspaceRoot) {
|
|
353
|
+
const out = [];
|
|
354
|
+
for (const [fileName] of snapshots) {
|
|
355
|
+
if (fileName.includes("node_modules")) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const semantic = service.getSemanticDiagnostics(fileName);
|
|
362
|
+
const syntactic = service.getSyntacticDiagnostics(fileName);
|
|
363
|
+
for (const d of [...semantic, ...syntactic]) {
|
|
364
|
+
if (!SAFE_FIXABLE_CODES.has(d.code)) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (d.start === void 0 || d.length === void 0) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
out.push({ file: fileName, start: d.start, length: d.length, code: d.code });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
void workspaceRoot;
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
function collectAllErrors(service, snapshots, workspaceRoot) {
|
|
377
|
+
const out = [];
|
|
378
|
+
for (const [fileName] of snapshots) {
|
|
379
|
+
if (fileName.includes("node_modules")) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
const semantic = service.getSemanticDiagnostics(fileName);
|
|
386
|
+
const syntactic = service.getSyntacticDiagnostics(fileName);
|
|
387
|
+
for (const d of [...semantic, ...syntactic]) {
|
|
388
|
+
if (d.category !== ts2.DiagnosticCategory.Error) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
let line = 0;
|
|
392
|
+
let column = 0;
|
|
393
|
+
if (d.file && d.start !== void 0) {
|
|
394
|
+
const pos = d.file.getLineAndCharacterOfPosition(d.start);
|
|
395
|
+
line = pos.line + 1;
|
|
396
|
+
column = pos.character + 1;
|
|
397
|
+
}
|
|
398
|
+
out.push({
|
|
399
|
+
file: path2.relative(workspaceRoot, fileName) || fileName,
|
|
400
|
+
line,
|
|
401
|
+
column,
|
|
402
|
+
code: `TS${d.code}`,
|
|
403
|
+
message: ts2.flattenDiagnosticMessageText(d.messageText, "\n")
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
function safeGetCodeFixes(service, err) {
|
|
410
|
+
try {
|
|
411
|
+
return service.getCodeFixesAtPosition(
|
|
412
|
+
err.file,
|
|
413
|
+
err.start,
|
|
414
|
+
err.start + err.length,
|
|
415
|
+
[err.code],
|
|
416
|
+
{},
|
|
417
|
+
{}
|
|
418
|
+
);
|
|
419
|
+
} catch {
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
function computeErrorSignatures(errors) {
|
|
424
|
+
return new Set(errors.map((e) => `${e.file}:${e.start}:${e.code}`));
|
|
425
|
+
}
|
|
426
|
+
function signatureSetsEqual(a, b) {
|
|
427
|
+
if (a.size !== b.size) {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
for (const s of a) {
|
|
431
|
+
if (!b.has(s)) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
function fixesAreEquivalent(fixes) {
|
|
438
|
+
if (fixes.length === 0) {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
const first = serializeFix(fixes[0]);
|
|
442
|
+
for (let i = 1; i < fixes.length; i++) {
|
|
443
|
+
if (serializeFix(fixes[i]) !== first) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
function serializeFix(fix) {
|
|
450
|
+
return fix.changes.map(
|
|
451
|
+
(c) => `${c.fileName}|${c.textChanges.map((t) => `${t.span.start}:${t.span.length}:${t.newText}`).join(";")}`
|
|
452
|
+
).join("||");
|
|
453
|
+
}
|
|
454
|
+
function applyFixToSnapshots(fix, snapshots) {
|
|
455
|
+
let applied = 0;
|
|
456
|
+
for (const change of fix.changes) {
|
|
457
|
+
const snap = snapshots.get(change.fileName);
|
|
458
|
+
if (!snap) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const sorted = [...change.textChanges].sort((a, b) => b.span.start - a.span.start);
|
|
462
|
+
let next = snap.content;
|
|
463
|
+
for (const tc of sorted) {
|
|
464
|
+
next = next.slice(0, tc.span.start) + tc.newText + next.slice(tc.span.start + tc.span.length);
|
|
465
|
+
}
|
|
466
|
+
snapshots.set(change.fileName, { content: next, version: snap.version + 1 });
|
|
467
|
+
applied++;
|
|
468
|
+
}
|
|
469
|
+
return applied;
|
|
470
|
+
}
|
|
471
|
+
function isLSPFixerEnabled() {
|
|
472
|
+
return process.env.SPECTOSHIP_TS_LSP_FIXER !== "false";
|
|
473
|
+
}
|
|
474
|
+
function resetLSPFixerCache() {
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/index.ts
|
|
478
|
+
import * as fs3 from "node:fs";
|
|
479
|
+
import * as path3 from "node:path";
|
|
480
|
+
var noopLogger = {
|
|
481
|
+
info: () => {
|
|
482
|
+
},
|
|
483
|
+
warn: () => {
|
|
484
|
+
},
|
|
485
|
+
error: () => {
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
function discoverTsFiles(workspaceRoot) {
|
|
489
|
+
const out = [];
|
|
490
|
+
const skip = /* @__PURE__ */ new Set(["node_modules", ".next", "dist", "build", ".git", "out", "coverage"]);
|
|
491
|
+
const walk = (dir) => {
|
|
492
|
+
let entries;
|
|
493
|
+
try {
|
|
494
|
+
entries = fs3.readdirSync(dir, { withFileTypes: true });
|
|
495
|
+
} catch {
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
for (const e of entries) {
|
|
499
|
+
if (e.isDirectory()) {
|
|
500
|
+
if (skip.has(e.name)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
walk(path3.join(dir, e.name));
|
|
504
|
+
} else if (e.isFile() && !e.name.endsWith(".d.ts")) {
|
|
505
|
+
if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
|
|
506
|
+
out.push(path3.relative(workspaceRoot, path3.join(dir, e.name)));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
walk(workspaceRoot);
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
function runValidationLoop(opts) {
|
|
515
|
+
const { workspaceRoot, skipLSPFixer = false, dryRun = false } = opts;
|
|
516
|
+
const logger = opts.logger ?? noopLogger;
|
|
517
|
+
if (!fs3.existsSync(workspaceRoot)) {
|
|
518
|
+
throw new Error(`workspace not found: ${workspaceRoot}`);
|
|
519
|
+
}
|
|
520
|
+
if (!fs3.existsSync(path3.join(workspaceRoot, "tsconfig.json"))) {
|
|
521
|
+
throw new Error(`no tsconfig.json in ${workspaceRoot}`);
|
|
522
|
+
}
|
|
523
|
+
const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
|
|
524
|
+
const startMs = Date.now();
|
|
525
|
+
resetInProcessTscCache();
|
|
526
|
+
const before = runInProcessTsc({ workspaceRoot, generatedFiles: targetFiles, logger });
|
|
527
|
+
const errorsBefore = before.diagnostics.filter((d) => d.category === "error").length;
|
|
528
|
+
let after = before;
|
|
529
|
+
let lspFixer = {
|
|
530
|
+
ran: false,
|
|
531
|
+
fixesApplied: 0,
|
|
532
|
+
filesEdited: [],
|
|
533
|
+
iterations: 0
|
|
534
|
+
};
|
|
535
|
+
if (errorsBefore > 0 && !skipLSPFixer) {
|
|
536
|
+
const lsp = runLSPFixerPass({ workspaceRoot, targetFiles, logger, dryRun });
|
|
537
|
+
lspFixer = {
|
|
538
|
+
ran: true,
|
|
539
|
+
fixesApplied: lsp.fixesApplied,
|
|
540
|
+
filesEdited: lsp.filesEdited,
|
|
541
|
+
iterations: lsp.iterations
|
|
542
|
+
};
|
|
543
|
+
if (lsp.fixesApplied > 0 && !dryRun) {
|
|
544
|
+
resetInProcessTscCache();
|
|
545
|
+
after = runInProcessTsc({ workspaceRoot, generatedFiles: targetFiles, logger });
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const errorDiags = after.diagnostics.filter((d) => d.category === "error");
|
|
549
|
+
const errorsAfter = errorDiags.length;
|
|
550
|
+
const remainingByCode = {};
|
|
551
|
+
const remainingByFile = {};
|
|
552
|
+
for (const d of errorDiags) {
|
|
553
|
+
remainingByCode[d.code] = (remainingByCode[d.code] ?? 0) + 1;
|
|
554
|
+
remainingByFile[d.file] = (remainingByFile[d.file] ?? 0) + 1;
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
passed: errorsAfter === 0,
|
|
558
|
+
errorsBefore,
|
|
559
|
+
errorsAfter,
|
|
560
|
+
lspFixer,
|
|
561
|
+
remainingByCode,
|
|
562
|
+
remainingByFile,
|
|
563
|
+
diagnostics: after.diagnostics,
|
|
564
|
+
elapsedMs: Date.now() - startMs
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
export {
|
|
568
|
+
discoverTsFiles,
|
|
569
|
+
isInProcessTscEnabled,
|
|
570
|
+
isLSPFixerEnabled,
|
|
571
|
+
resetInProcessTscCache,
|
|
572
|
+
resetLSPFixerCache,
|
|
573
|
+
runInProcessTsc,
|
|
574
|
+
runLSPFixerPass,
|
|
575
|
+
runValidationLoop
|
|
576
|
+
};
|