Skip to content

Commit eb92e1a

Browse files
MiodecCopilot
andauthored
impr: replace vite-plugin-checker with ~~vibe~~ (@Miodec) (monkeytypegame#7271)
vite plugin checker seems to happily spawn a new linting process per file save, causing issues. This vibe coded solution kills the previously running process. It also splits linting into two steps to get some fast fail behavior. I (AI) tried to merge it into one file but the overlay refused to show that way. !nuf --------- Co-authored-by: Copilot <[email protected]>
1 parent 4771285 commit eb92e1a

File tree

5 files changed

+500
-94
lines changed

5 files changed

+500
-94
lines changed

frontend/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@
9191
"unplugin-inject-preload": "3.0.0",
9292
"vite": "7.1.12",
9393
"vite-bundle-visualizer": "1.2.1",
94-
"vite-plugin-checker": "0.11.0",
9594
"vite-plugin-filter-replace": "0.1.14",
9695
"vite-plugin-html-inject": "1.1.2",
9796
"vite-plugin-inspect": "11.3.3",
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { Plugin, ViteDevServer, normalizePath } from "vite";
2+
import { spawn, execSync, ChildProcess } from "child_process";
3+
import { fileURLToPath } from "url";
4+
5+
export type OxlintCheckerOptions = {
6+
/** Debounce delay in milliseconds before running lint after file changes. @default 125 */
7+
debounceDelay?: number;
8+
/** Run type-aware checks (slower but more thorough). @default true */
9+
typeAware?: boolean;
10+
/** Show browser overlay with lint status. @default true */
11+
overlay?: boolean;
12+
/** File extensions to watch for changes. @default ['.ts', '.tsx', '.js', '.jsx'] */
13+
extensions?: string[];
14+
};
15+
16+
type LintResult = {
17+
errorCount: number;
18+
warningCount: number;
19+
running: boolean;
20+
hadIssues: boolean;
21+
typeAware?: boolean;
22+
};
23+
24+
const OXLINT_SUMMARY_REGEX = /Found (\d+) warnings? and (\d+) errors?/;
25+
26+
export function oxlintChecker(options: OxlintCheckerOptions = {}): Plugin {
27+
const {
28+
debounceDelay = 125,
29+
typeAware = true,
30+
overlay = true,
31+
extensions = [".ts", ".tsx", ".js", ".jsx"],
32+
} = options;
33+
34+
let currentProcess: ChildProcess | null = null;
35+
let debounceTimer: NodeJS.Timeout | null = null;
36+
let server: ViteDevServer | null = null;
37+
let isProduction = false;
38+
let currentRunId = 0;
39+
let lastLintResult: LintResult = {
40+
errorCount: 0,
41+
warningCount: 0,
42+
running: false,
43+
hadIssues: false,
44+
};
45+
46+
const killCurrentProcess = (): boolean => {
47+
if ((currentProcess && !currentProcess.killed) || currentProcess !== null) {
48+
currentProcess.kill();
49+
currentProcess = null;
50+
return true;
51+
}
52+
return false;
53+
};
54+
55+
const clearDebounceTimer = (): void => {
56+
if (debounceTimer) {
57+
clearTimeout(debounceTimer);
58+
debounceTimer = null;
59+
}
60+
};
61+
62+
const parseLintOutput = (
63+
output: string,
64+
): Pick<LintResult, "errorCount" | "warningCount"> => {
65+
const summaryMatch = output.match(OXLINT_SUMMARY_REGEX);
66+
if (summaryMatch?.[1] !== undefined && summaryMatch?.[2] !== undefined) {
67+
return {
68+
warningCount: parseInt(summaryMatch[1], 10),
69+
errorCount: parseInt(summaryMatch[2], 10),
70+
};
71+
}
72+
return { errorCount: 0, warningCount: 0 };
73+
};
74+
75+
const sendLintResult = (result: Partial<LintResult>): void => {
76+
const previousHadIssues = lastLintResult.hadIssues;
77+
78+
const payload: LintResult = {
79+
errorCount: result.errorCount ?? lastLintResult.errorCount,
80+
warningCount: result.warningCount ?? lastLintResult.warningCount,
81+
running: result.running ?? false,
82+
hadIssues: previousHadIssues,
83+
typeAware: result.typeAware,
84+
};
85+
86+
// Only update hadIssues when we have actual lint results (not just running status)
87+
if (result.running === false) {
88+
const currentHasIssues =
89+
(result.errorCount ?? 0) > 0 || (result.warningCount ?? 0) > 0;
90+
lastLintResult = { ...payload, hadIssues: currentHasIssues };
91+
} else {
92+
// Keep hadIssues unchanged when just updating running status
93+
lastLintResult = { ...payload, hadIssues: previousHadIssues };
94+
}
95+
96+
if (server) {
97+
server.ws.send("vite-plugin-oxlint", payload);
98+
}
99+
};
100+
101+
/**
102+
* Runs an oxlint process with the given arguments and captures its combined output.
103+
*
104+
* This function is responsible for managing the lifecycle of the current lint process:
105+
* - It spawns a new child process via `npx oxlint . ...args`.
106+
* - It assigns the spawned process to the shared {@link currentProcess} variable so that
107+
* other parts of the plugin can cancel or track the active lint run.
108+
* - On process termination (either "error" or "close"), it clears {@link currentProcess}
109+
* if it still refers to this child, avoiding interference with any newer process that
110+
* may have started in the meantime.
111+
*
112+
* @param args Additional command-line arguments to pass to `oxlint`.
113+
* @returns A promise that resolves with the process exit code (or `null` if
114+
* the process exited due to a signal) and the full stdout/stderr output
115+
* produced by the lint run.
116+
*/
117+
const runLintProcess = async (
118+
args: string[],
119+
): Promise<{ code: number | null; output: string }> => {
120+
return new Promise((resolve) => {
121+
const childProcess = spawn("npx", ["oxlint", ".", ...args], {
122+
cwd: process.cwd(),
123+
shell: true,
124+
env: { ...process.env, FORCE_COLOR: "3" },
125+
});
126+
127+
currentProcess = childProcess;
128+
let output = "";
129+
130+
childProcess.stdout?.on("data", (data: Buffer) => {
131+
output += data.toString();
132+
});
133+
134+
childProcess.stderr?.on("data", (data: Buffer) => {
135+
output += data.toString();
136+
});
137+
138+
childProcess.on("error", (error: Error) => {
139+
output += `\nError: ${error.message}`;
140+
if (currentProcess === childProcess) {
141+
currentProcess = null;
142+
}
143+
resolve({ code: 1, output });
144+
});
145+
146+
childProcess.on("close", (code: number | null) => {
147+
if (currentProcess === childProcess) {
148+
currentProcess = null;
149+
}
150+
resolve({ code, output });
151+
});
152+
});
153+
};
154+
155+
const runOxlint = async (): Promise<void> => {
156+
const wasKilled = killCurrentProcess();
157+
const runId = ++currentRunId;
158+
159+
console.log(
160+
wasKilled
161+
? "\x1b[36mRunning oxlint...\x1b[0m \x1b[90m(killed previous process)\x1b[0m"
162+
: "\x1b[36mRunning oxlint...\x1b[0m",
163+
);
164+
165+
sendLintResult({ running: true });
166+
167+
// First pass: fast oxlint without type checking
168+
const { code, output } = await runLintProcess([]);
169+
170+
// Check if we were superseded by a newer run
171+
if (runId !== currentRunId) {
172+
return;
173+
}
174+
175+
if (output) {
176+
console.log(output);
177+
}
178+
179+
// If first pass had errors, send them immediately (fast-fail)
180+
if (code !== 0) {
181+
const counts = parseLintOutput(output);
182+
if (counts.errorCount > 0 || counts.warningCount > 0) {
183+
sendLintResult({ ...counts, running: false });
184+
return;
185+
}
186+
}
187+
188+
// First pass clean - run type-aware check if enabled
189+
if (!typeAware) {
190+
sendLintResult({ errorCount: 0, warningCount: 0, running: false });
191+
return;
192+
}
193+
194+
console.log("\x1b[36mRunning type-aware checks...\x1b[0m");
195+
sendLintResult({ running: true, typeAware: true });
196+
const typeResult = await runLintProcess(["--type-check", "--type-aware"]);
197+
198+
// Check if we were superseded by a newer run
199+
if (runId !== currentRunId) {
200+
return;
201+
}
202+
203+
if (typeResult.output) {
204+
console.log(typeResult.output);
205+
}
206+
207+
const counts =
208+
typeResult.code !== 0
209+
? parseLintOutput(typeResult.output)
210+
: { errorCount: 0, warningCount: 0 };
211+
sendLintResult({ ...counts, running: false });
212+
};
213+
214+
const debouncedLint = (): void => {
215+
clearDebounceTimer();
216+
sendLintResult({ running: true });
217+
debounceTimer = setTimeout(() => void runOxlint(), debounceDelay);
218+
};
219+
220+
return {
221+
name: "vite-plugin-oxlint-checker",
222+
223+
config(_, { command }) {
224+
isProduction = command === "build";
225+
},
226+
227+
configureServer(devServer: ViteDevServer) {
228+
server = devServer;
229+
230+
// Send current lint status to new clients on connection
231+
devServer.ws.on("connection", () => {
232+
devServer.ws.send("vite-plugin-oxlint", lastLintResult);
233+
});
234+
235+
// Run initial lint
236+
void runOxlint();
237+
238+
// Listen for file changes
239+
devServer.watcher.on("change", (file: string) => {
240+
// Only lint on relevant file changes
241+
if (extensions.some((ext) => file.endsWith(ext))) {
242+
debouncedLint();
243+
}
244+
});
245+
},
246+
247+
transformIndexHtml() {
248+
if (!overlay) {
249+
return [];
250+
}
251+
252+
// Inject import to the overlay module (actual .ts file processed by Vite)
253+
const overlayPath = normalizePath(
254+
fileURLToPath(new URL("./oxlint-overlay.ts", import.meta.url)),
255+
);
256+
return [
257+
{
258+
tag: "script",
259+
attrs: {
260+
type: "module",
261+
src: `/@fs${overlayPath}`,
262+
},
263+
injectTo: "body-prepend",
264+
},
265+
];
266+
},
267+
268+
buildStart() {
269+
// Only run during production builds, not dev server startup
270+
if (!isProduction) {
271+
return;
272+
}
273+
274+
// Run oxlint synchronously during build
275+
console.log("\n\x1b[1mRunning oxlint...\x1b[0m");
276+
277+
try {
278+
const output = execSync(
279+
"npx oxlint . && npx oxlint . --type-aware --type-check",
280+
{
281+
cwd: process.cwd(),
282+
encoding: "utf-8",
283+
env: { ...process.env, FORCE_COLOR: "3" },
284+
},
285+
);
286+
287+
if (output) {
288+
console.log(output);
289+
}
290+
console.log(` \x1b[32m✓ No linting issues found\x1b[0m\n`);
291+
} catch (error) {
292+
// execSync throws on non-zero exit code (linting errors found)
293+
if (error instanceof Error && "stdout" in error) {
294+
const execError = error as Error & {
295+
stdout?: string;
296+
stderr?: string;
297+
};
298+
if (execError.stdout !== undefined) console.log(execError.stdout);
299+
if (execError.stderr !== undefined) console.error(execError.stderr);
300+
}
301+
console.error("\n\x1b[31mBuild aborted due to linting errors\x1b[0m\n");
302+
process.exit(1);
303+
}
304+
},
305+
306+
closeBundle() {
307+
// Cleanup on server close
308+
killCurrentProcess();
309+
clearDebounceTimer();
310+
},
311+
};
312+
}

0 commit comments

Comments
 (0)