@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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * In-process TypeScript validator (Phase 5 — TSC iron-clad).
3
+ *
4
+ * Replaces the `tsc --noEmit` shell exec with `ts.createProgram` +
5
+ * `getPreEmitDiagnostics()`. Three wins over shelling:
6
+ *
7
+ * 1. **Structured diagnostics** — `{file, start, length, code, messageText,
8
+ * category, relatedInformation}` natively, no regex parsing of tsc text
9
+ * output. Feeds Layer 2's symbol tracer directly.
10
+ *
11
+ * 2. **Speed** — long-lived Program per workspace caches lib files,
12
+ * transitive deps, and tsconfig parse. Cold start is comparable; warm
13
+ * validation is ~5-10× faster than spawning a fresh tsc process.
14
+ *
15
+ * 3. **Diagnostic enrichment** — we have the AST in hand, so each error
16
+ * can carry the offending node's source span and aliased symbol info,
17
+ * which the shell tsc doesn't provide.
18
+ *
19
+ * Backwards compatibility: feature-flagged via `SPECTOSHIP_TSC_INPROCESS`
20
+ * env var. Shell tsc remains the default until we measure parity on real
21
+ * projects.
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import * as ts from "typescript";
27
+
28
+ export interface InProcessTscResult {
29
+ passed: boolean;
30
+ /** Diagnostic messages formatted in the same shape as `tsc` output. */
31
+ output: string;
32
+ /** Structured per-error data — drives Layer 2 cross-file tracer. */
33
+ diagnostics: Array<{
34
+ file: string;
35
+ line: number;
36
+ column: number;
37
+ code: string;
38
+ message: string;
39
+ category: "error" | "warning" | "message" | "suggestion";
40
+ }>;
41
+ /** Number of lines of output for log truncation. */
42
+ lineCount: number;
43
+ }
44
+
45
+ export interface InProcessTscOptions {
46
+ workspaceRoot: string;
47
+ /** Optional list of files to filter diagnostics to (matches shell tsc filter). */
48
+ generatedFiles?: string[];
49
+ logger: { info(msg: string): void; warn(msg: string): void; error(msg: string): void };
50
+ }
51
+
52
+ /** Long-lived per-workspace Program cache. Speeds warm validation. */
53
+ interface CachedProgram {
54
+ rootFiles: string[];
55
+ options: ts.CompilerOptions;
56
+ host: ts.CompilerHost;
57
+ program: ts.Program;
58
+ configMtime: number;
59
+ }
60
+ const programCache = new Map<string, CachedProgram>();
61
+
62
+ /**
63
+ * Run tsc in-process. Compatible with the shell-based ToolResult shape
64
+ * so the caller can swap implementations transparently.
65
+ */
66
+ export function runInProcessTsc(opts: InProcessTscOptions): InProcessTscResult {
67
+ const { workspaceRoot, generatedFiles, logger } = opts;
68
+ const tsconfigPath = path.join(workspaceRoot, "tsconfig.json");
69
+ if (!fs.existsSync(tsconfigPath)) {
70
+ return {
71
+ passed: true,
72
+ output: "(no tsconfig.json — skipped)",
73
+ diagnostics: [],
74
+ lineCount: 1,
75
+ };
76
+ }
77
+
78
+ const program = getOrCreateProgram(workspaceRoot, tsconfigPath, logger);
79
+ if (!program) {
80
+ return {
81
+ passed: true,
82
+ output: "(failed to create program — skipping)",
83
+ diagnostics: [],
84
+ lineCount: 1,
85
+ };
86
+ }
87
+
88
+ const allDiagnostics = ts.getPreEmitDiagnostics(program);
89
+ const structured = formatDiagnostics(allDiagnostics, workspaceRoot);
90
+
91
+ // Filter to generated files when provided (mirrors shell-tsc filter).
92
+ const filtered = generatedFiles?.length
93
+ ? structured.filter((d) =>
94
+ generatedFiles.some((g) => g.replace(/^\.\//, "") === d.file.replace(/^\.\//, "")),
95
+ )
96
+ : structured;
97
+
98
+ const errors = filtered.filter((d) => d.category === "error");
99
+
100
+ const output = errors
101
+ .map((d) => `${d.file}(${d.line},${d.column}): error ${d.code}: ${d.message}`)
102
+ .join("\n");
103
+
104
+ return {
105
+ passed: errors.length === 0,
106
+ output: output || "(no errors)",
107
+ diagnostics: filtered,
108
+ lineCount: output.split("\n").length,
109
+ };
110
+ }
111
+
112
+ function getOrCreateProgram(
113
+ workspaceRoot: string,
114
+ tsconfigPath: string,
115
+ logger: { warn(msg: string): void; error(msg: string): void },
116
+ ): ts.Program | null {
117
+ let configMtime = 0;
118
+ try {
119
+ configMtime = fs.statSync(tsconfigPath).mtimeMs;
120
+ } catch {
121
+ return null;
122
+ }
123
+
124
+ const cached = programCache.get(workspaceRoot);
125
+ if (cached && cached.configMtime === configMtime) {
126
+ // Refresh source files in case generated code changed on disk —
127
+ // the program reuses the same options/host but reloads source-file content.
128
+ try {
129
+ const refreshed = ts.createProgram({
130
+ rootNames: cached.rootFiles,
131
+ options: cached.options,
132
+ host: cached.host,
133
+ oldProgram: cached.program,
134
+ });
135
+ cached.program = refreshed;
136
+ return refreshed;
137
+ } catch (err) {
138
+ logger.warn(
139
+ `[in-process-tsc] refresh failed; rebuilding: ${err instanceof Error ? err.message : String(err)}`,
140
+ );
141
+ programCache.delete(workspaceRoot);
142
+ }
143
+ }
144
+
145
+ // Cold path — parse tsconfig from scratch.
146
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
147
+ if (configFile.error) {
148
+ logger.error(
149
+ `[in-process-tsc] tsconfig parse error: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n")}`,
150
+ );
151
+ return null;
152
+ }
153
+ const parsed = ts.parseJsonConfigFileContent(
154
+ configFile.config,
155
+ ts.sys,
156
+ path.dirname(tsconfigPath),
157
+ );
158
+ if (parsed.errors.length > 0) {
159
+ const msgs = parsed.errors
160
+ .map((e) => ts.flattenDiagnosticMessageText(e.messageText, "\n"))
161
+ .join("; ");
162
+ logger.warn(`[in-process-tsc] tsconfig parse warnings: ${msgs}`);
163
+ }
164
+
165
+ const host = ts.createCompilerHost(parsed.options);
166
+ // Override the default-lib location to point at the WORKSPACE's typescript
167
+ // install instead of the extension-bundled one. esbuild bundles `typescript`
168
+ // into dist/extension.js, which breaks the bundled module's __dirname-based
169
+ // lib file lookup (`lib.dom.d.ts`, `lib.es2015.d.ts`, etc. aren't shipped
170
+ // inside the bundle). Without this override, every workspace task fails
171
+ // with "Cannot find name 'Promise'" / "'window'" / etc. (test28R, 2026-05-03).
172
+ const workspaceLibDir = path.join(workspaceRoot, "node_modules", "typescript", "lib");
173
+ if (fs.existsSync(workspaceLibDir)) {
174
+ const originalGetDefaultLibFileName = host.getDefaultLibFileName.bind(host);
175
+ host.getDefaultLibLocation = () => workspaceLibDir;
176
+ host.getDefaultLibFileName = (options) => {
177
+ const fileName = path.basename(originalGetDefaultLibFileName(options));
178
+ return path.join(workspaceLibDir, fileName);
179
+ };
180
+ }
181
+ let program: ts.Program;
182
+ try {
183
+ program = ts.createProgram({
184
+ rootNames: parsed.fileNames,
185
+ options: parsed.options,
186
+ host,
187
+ });
188
+ } catch (err) {
189
+ logger.error(
190
+ `[in-process-tsc] createProgram failed: ${err instanceof Error ? err.message : String(err)}`,
191
+ );
192
+ return null;
193
+ }
194
+
195
+ programCache.set(workspaceRoot, {
196
+ rootFiles: parsed.fileNames,
197
+ options: parsed.options,
198
+ host,
199
+ program,
200
+ configMtime,
201
+ });
202
+ return program;
203
+ }
204
+
205
+ function formatDiagnostics(
206
+ diagnostics: readonly ts.Diagnostic[],
207
+ workspaceRoot: string,
208
+ ): InProcessTscResult["diagnostics"] {
209
+ const out: InProcessTscResult["diagnostics"] = [];
210
+ for (const d of diagnostics) {
211
+ if (!d.file || d.start === undefined) {
212
+ // Generic non-file errors (rare) — emit with synthetic location.
213
+ out.push({
214
+ file: "(global)",
215
+ line: 0,
216
+ column: 0,
217
+ code: `TS${d.code}`,
218
+ message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
219
+ category: categoryName(d.category),
220
+ });
221
+ continue;
222
+ }
223
+ const fileName = d.file.fileName;
224
+ // Skip lib files and node_modules from diagnostics — they're never
225
+ // the user's bug to fix.
226
+ if (fileName.includes("node_modules")) {
227
+ continue;
228
+ }
229
+ if (/lib\.[a-z0-9.]+\.d\.ts$/.test(fileName)) {
230
+ continue;
231
+ }
232
+
233
+ const { line, character } = d.file.getLineAndCharacterOfPosition(d.start);
234
+ out.push({
235
+ file: path.relative(workspaceRoot, fileName) || fileName,
236
+ line: line + 1,
237
+ column: character + 1,
238
+ code: `TS${d.code}`,
239
+ message: ts.flattenDiagnosticMessageText(d.messageText, "\n"),
240
+ category: categoryName(d.category),
241
+ });
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function categoryName(
247
+ c: ts.DiagnosticCategory,
248
+ ): InProcessTscResult["diagnostics"][number]["category"] {
249
+ switch (c) {
250
+ case ts.DiagnosticCategory.Error:
251
+ return "error";
252
+ case ts.DiagnosticCategory.Warning:
253
+ return "warning";
254
+ case ts.DiagnosticCategory.Message:
255
+ return "message";
256
+ case ts.DiagnosticCategory.Suggestion:
257
+ return "suggestion";
258
+ }
259
+ }
260
+
261
+ /** Reset cache (for tests + when workspace switches). */
262
+ export function resetInProcessTscCache(): void {
263
+ programCache.clear();
264
+ }
265
+
266
+ /**
267
+ * Whether the in-process path is enabled. Defaults to ON as of Sprint J
268
+ * (2026-05-03) — shell tsc consistently times out at 60s on Node 23 due to
269
+ * the documented tsc startup-pause bug, blocking every code-gen task.
270
+ * In-process is also 5-10× faster on warm runs because the Program is reused.
271
+ *
272
+ * Set `SPECTOSHIP_TSC_INPROCESS=false` to opt out.
273
+ */
274
+ export function isInProcessTscEnabled(): boolean {
275
+ return process.env.SPECTOSHIP_TSC_INPROCESS !== "false";
276
+ }