@shipispec/tsfix 0.1.0 → 0.2.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/src/index.ts DELETED
@@ -1,202 +0,0 @@
1
- /**
2
- * @shipispec/tsfix — public API.
3
- *
4
- * A reusable TypeScript error-recovery agent. Validates LLM-generated (or any)
5
- * TypeScript code via in-process tsc, auto-fixes deterministic error classes
6
- * (TS2304/2305/2552/2724) via TypeScript's built-in code-fix engine, and
7
- * exposes hooks for LLM-driven repair (planned, not yet shipped).
8
- *
9
- * ## Quick start (library)
10
- *
11
- * ```ts
12
- * import { runValidationLoop } from "@shipispec/tsfix";
13
- *
14
- * const result = await runValidationLoop({
15
- * workspaceRoot: "/path/to/your/project",
16
- * targetFiles: ["src/index.ts", "src/utils.ts"],
17
- * });
18
- *
19
- * console.log(result.passed, result.errorsAfter, result.lspFixer.fixesApplied);
20
- * ```
21
- *
22
- * ## Quick start (CLI)
23
- *
24
- * ```
25
- * npx @shipispec/tsfix --workspace ./my-project
26
- * ```
27
- *
28
- * ## Layered API
29
- *
30
- * - `runValidationLoop` — full deterministic loop (recommended entry point)
31
- * - `runInProcessTsc` — just type-check, returns structured diagnostics
32
- * - `runLSPFixerPass` — just the auto-fix pass, edits files in place
33
- *
34
- * ## What it doesn't do (yet)
35
- *
36
- * LLM-driven repair (the mend-agent layers from the spectoship pipeline) is
37
- * not exported here yet. They depend on internal types (ParsedTask) that need
38
- * to be redesigned as opaque interfaces before they can be moved into this
39
- * package. v0.2 target.
40
- */
41
-
42
- export { runInProcessTsc, isInProcessTscEnabled, resetInProcessTscCache } from "./validatorInProcess.js";
43
- export type { InProcessTscOptions, InProcessTscResult } from "./validatorInProcess.js";
44
-
45
- export { runLSPFixerPass, isLSPFixerEnabled, resetLSPFixerCache } from "./tsLanguageServiceFixer.js";
46
- export type { LSPFixerOptions, LSPFixerResult, LSPFixerLogger } from "./tsLanguageServiceFixer.js";
47
-
48
- import * as fs from "node:fs";
49
- import * as path from "node:path";
50
- import {
51
- runInProcessTsc,
52
- resetInProcessTscCache,
53
- type InProcessTscResult,
54
- } from "./validatorInProcess.js";
55
- import { runLSPFixerPass } from "./tsLanguageServiceFixer.js";
56
-
57
- /** Logger shape required by the validation/fix loop. Plain object with three methods. */
58
- export interface Logger {
59
- info(msg: string): void;
60
- warn(msg: string): void;
61
- error(msg: string): void;
62
- }
63
-
64
- export interface ValidationLoopOptions {
65
- /** Absolute path to the workspace (must contain `tsconfig.json`). */
66
- workspaceRoot: string;
67
- /**
68
- * Files to scope the type-check + fix to. If omitted, all .ts/.tsx files
69
- * under `workspaceRoot` (excluding node_modules, .next, dist, build, .git)
70
- * are discovered.
71
- */
72
- targetFiles?: string[];
73
- /** Skip Layer 0 LSP auto-fixer. Default false. */
74
- skipLSPFixer?: boolean;
75
- /** Default: a no-op logger. Pass your own to capture layer events. */
76
- logger?: Logger;
77
- }
78
-
79
- export interface ValidationLoopResult {
80
- passed: boolean;
81
- errorsBefore: number;
82
- errorsAfter: number;
83
- lspFixer: {
84
- ran: boolean;
85
- fixesApplied: number;
86
- filesEdited: string[];
87
- iterations: number;
88
- };
89
- remainingByCode: Record<string, number>;
90
- remainingByFile: Record<string, number>;
91
- diagnostics: InProcessTscResult["diagnostics"];
92
- elapsedMs: number;
93
- }
94
-
95
- const noopLogger: Logger = {
96
- info: () => {},
97
- warn: () => {},
98
- error: () => {},
99
- };
100
-
101
- /**
102
- * Discover all `.ts` / `.tsx` files under a workspace, excluding common
103
- * non-source dirs. Skips `.d.ts` declaration files.
104
- */
105
- export function discoverTsFiles(workspaceRoot: string): string[] {
106
- const out: string[] = [];
107
- const skip = new Set(["node_modules", ".next", "dist", "build", ".git", "out", "coverage"]);
108
- const walk = (dir: string): void => {
109
- let entries: fs.Dirent[];
110
- try {
111
- entries = fs.readdirSync(dir, { withFileTypes: true });
112
- } catch {
113
- return;
114
- }
115
- for (const e of entries) {
116
- if (e.isDirectory()) {
117
- if (skip.has(e.name)) {
118
- continue;
119
- }
120
- walk(path.join(dir, e.name));
121
- } else if (e.isFile() && !e.name.endsWith(".d.ts")) {
122
- if (e.name.endsWith(".ts") || e.name.endsWith(".tsx")) {
123
- out.push(path.relative(workspaceRoot, path.join(dir, e.name)));
124
- }
125
- }
126
- }
127
- };
128
- walk(workspaceRoot);
129
- return out;
130
- }
131
-
132
- /**
133
- * Run the full deterministic validation + fix loop:
134
- *
135
- * 1. In-process tsc → capture baseline diagnostics
136
- * 2. If errors AND not `skipLSPFixer`, run Layer 0 LSP auto-fix
137
- * 3. If fixes were applied, re-run in-process tsc to capture post-fix state
138
- * 4. Return aggregated result
139
- *
140
- * Throws on missing `tsconfig.json` or workspace path.
141
- */
142
- export function runValidationLoop(opts: ValidationLoopOptions): ValidationLoopResult {
143
- const { workspaceRoot, skipLSPFixer = false } = opts;
144
- const logger = opts.logger ?? noopLogger;
145
-
146
- if (!fs.existsSync(workspaceRoot)) {
147
- throw new Error(`workspace not found: ${workspaceRoot}`);
148
- }
149
- if (!fs.existsSync(path.join(workspaceRoot, "tsconfig.json"))) {
150
- throw new Error(`no tsconfig.json in ${workspaceRoot}`);
151
- }
152
-
153
- const targetFiles = opts.targetFiles ?? discoverTsFiles(workspaceRoot);
154
- const startMs = Date.now();
155
-
156
- resetInProcessTscCache();
157
- const before = runInProcessTsc({ workspaceRoot, generatedFiles: targetFiles, logger });
158
- const errorsBefore = before.diagnostics.filter((d) => d.category === "error").length;
159
-
160
- let after = before;
161
- let lspFixer = {
162
- ran: false,
163
- fixesApplied: 0,
164
- filesEdited: [] as string[],
165
- iterations: 0,
166
- };
167
-
168
- if (errorsBefore > 0 && !skipLSPFixer) {
169
- const lsp = runLSPFixerPass({ workspaceRoot, targetFiles, logger });
170
- lspFixer = {
171
- ran: true,
172
- fixesApplied: lsp.fixesApplied,
173
- filesEdited: lsp.filesEdited,
174
- iterations: lsp.iterations,
175
- };
176
- if (lsp.fixesApplied > 0) {
177
- resetInProcessTscCache();
178
- after = runInProcessTsc({ workspaceRoot, generatedFiles: targetFiles, logger });
179
- }
180
- }
181
-
182
- const errorDiags = after.diagnostics.filter((d) => d.category === "error");
183
- const errorsAfter = errorDiags.length;
184
-
185
- const remainingByCode: Record<string, number> = {};
186
- const remainingByFile: Record<string, number> = {};
187
- for (const d of errorDiags) {
188
- remainingByCode[d.code] = (remainingByCode[d.code] ?? 0) + 1;
189
- remainingByFile[d.file] = (remainingByFile[d.file] ?? 0) + 1;
190
- }
191
-
192
- return {
193
- passed: errorsAfter === 0,
194
- errorsBefore,
195
- errorsAfter,
196
- lspFixer,
197
- remainingByCode,
198
- remainingByFile,
199
- diagnostics: after.diagnostics,
200
- elapsedMs: Date.now() - startMs,
201
- };
202
- }
@@ -1,486 +0,0 @@
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
- }