mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2026-01-15 20:04:29 +08:00
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 <175728472+Copilot@users.noreply.github.com>
312 lines
9.2 KiB
TypeScript
312 lines
9.2 KiB
TypeScript
import { Plugin, ViteDevServer, normalizePath } from "vite";
|
|
import { spawn, execSync, ChildProcess } from "child_process";
|
|
import { fileURLToPath } from "url";
|
|
|
|
export type OxlintCheckerOptions = {
|
|
/** Debounce delay in milliseconds before running lint after file changes. @default 125 */
|
|
debounceDelay?: number;
|
|
/** Run type-aware checks (slower but more thorough). @default true */
|
|
typeAware?: boolean;
|
|
/** Show browser overlay with lint status. @default true */
|
|
overlay?: boolean;
|
|
/** File extensions to watch for changes. @default ['.ts', '.tsx', '.js', '.jsx'] */
|
|
extensions?: string[];
|
|
};
|
|
|
|
type LintResult = {
|
|
errorCount: number;
|
|
warningCount: number;
|
|
running: boolean;
|
|
hadIssues: boolean;
|
|
typeAware?: boolean;
|
|
};
|
|
|
|
const OXLINT_SUMMARY_REGEX = /Found (\d+) warnings? and (\d+) errors?/;
|
|
|
|
export function oxlintChecker(options: OxlintCheckerOptions = {}): Plugin {
|
|
const {
|
|
debounceDelay = 125,
|
|
typeAware = true,
|
|
overlay = true,
|
|
extensions = [".ts", ".tsx", ".js", ".jsx"],
|
|
} = options;
|
|
|
|
let currentProcess: ChildProcess | null = null;
|
|
let debounceTimer: NodeJS.Timeout | null = null;
|
|
let server: ViteDevServer | null = null;
|
|
let isProduction = false;
|
|
let currentRunId = 0;
|
|
let lastLintResult: LintResult = {
|
|
errorCount: 0,
|
|
warningCount: 0,
|
|
running: false,
|
|
hadIssues: false,
|
|
};
|
|
|
|
const killCurrentProcess = (): boolean => {
|
|
if ((currentProcess && !currentProcess.killed) || currentProcess !== null) {
|
|
currentProcess.kill();
|
|
currentProcess = null;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const clearDebounceTimer = (): void => {
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
};
|
|
|
|
const parseLintOutput = (
|
|
output: string,
|
|
): Pick<LintResult, "errorCount" | "warningCount"> => {
|
|
const summaryMatch = output.match(OXLINT_SUMMARY_REGEX);
|
|
if (summaryMatch?.[1] !== undefined && summaryMatch?.[2] !== undefined) {
|
|
return {
|
|
warningCount: parseInt(summaryMatch[1], 10),
|
|
errorCount: parseInt(summaryMatch[2], 10),
|
|
};
|
|
}
|
|
return { errorCount: 0, warningCount: 0 };
|
|
};
|
|
|
|
const sendLintResult = (result: Partial<LintResult>): void => {
|
|
const previousHadIssues = lastLintResult.hadIssues;
|
|
|
|
const payload: LintResult = {
|
|
errorCount: result.errorCount ?? lastLintResult.errorCount,
|
|
warningCount: result.warningCount ?? lastLintResult.warningCount,
|
|
running: result.running ?? false,
|
|
hadIssues: previousHadIssues,
|
|
typeAware: result.typeAware,
|
|
};
|
|
|
|
// Only update hadIssues when we have actual lint results (not just running status)
|
|
if (result.running === false) {
|
|
const currentHasIssues =
|
|
(result.errorCount ?? 0) > 0 || (result.warningCount ?? 0) > 0;
|
|
lastLintResult = { ...payload, hadIssues: currentHasIssues };
|
|
} else {
|
|
// Keep hadIssues unchanged when just updating running status
|
|
lastLintResult = { ...payload, hadIssues: previousHadIssues };
|
|
}
|
|
|
|
if (server) {
|
|
server.ws.send("vite-plugin-oxlint", payload);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Runs an oxlint process with the given arguments and captures its combined output.
|
|
*
|
|
* This function is responsible for managing the lifecycle of the current lint process:
|
|
* - It spawns a new child process via `npx oxlint . ...args`.
|
|
* - It assigns the spawned process to the shared {@link currentProcess} variable so that
|
|
* other parts of the plugin can cancel or track the active lint run.
|
|
* - On process termination (either "error" or "close"), it clears {@link currentProcess}
|
|
* if it still refers to this child, avoiding interference with any newer process that
|
|
* may have started in the meantime.
|
|
*
|
|
* @param args Additional command-line arguments to pass to `oxlint`.
|
|
* @returns A promise that resolves with the process exit code (or `null` if
|
|
* the process exited due to a signal) and the full stdout/stderr output
|
|
* produced by the lint run.
|
|
*/
|
|
const runLintProcess = async (
|
|
args: string[],
|
|
): Promise<{ code: number | null; output: string }> => {
|
|
return new Promise((resolve) => {
|
|
const childProcess = spawn("npx", ["oxlint", ".", ...args], {
|
|
cwd: process.cwd(),
|
|
shell: true,
|
|
env: { ...process.env, FORCE_COLOR: "3" },
|
|
});
|
|
|
|
currentProcess = childProcess;
|
|
let output = "";
|
|
|
|
childProcess.stdout?.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
childProcess.stderr?.on("data", (data: Buffer) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
childProcess.on("error", (error: Error) => {
|
|
output += `\nError: ${error.message}`;
|
|
if (currentProcess === childProcess) {
|
|
currentProcess = null;
|
|
}
|
|
resolve({ code: 1, output });
|
|
});
|
|
|
|
childProcess.on("close", (code: number | null) => {
|
|
if (currentProcess === childProcess) {
|
|
currentProcess = null;
|
|
}
|
|
resolve({ code, output });
|
|
});
|
|
});
|
|
};
|
|
|
|
const runOxlint = async (): Promise<void> => {
|
|
const wasKilled = killCurrentProcess();
|
|
const runId = ++currentRunId;
|
|
|
|
console.log(
|
|
wasKilled
|
|
? "\x1b[36mRunning oxlint...\x1b[0m \x1b[90m(killed previous process)\x1b[0m"
|
|
: "\x1b[36mRunning oxlint...\x1b[0m",
|
|
);
|
|
|
|
sendLintResult({ running: true });
|
|
|
|
// First pass: fast oxlint without type checking
|
|
const { code, output } = await runLintProcess([]);
|
|
|
|
// Check if we were superseded by a newer run
|
|
if (runId !== currentRunId) {
|
|
return;
|
|
}
|
|
|
|
if (output) {
|
|
console.log(output);
|
|
}
|
|
|
|
// If first pass had errors, send them immediately (fast-fail)
|
|
if (code !== 0) {
|
|
const counts = parseLintOutput(output);
|
|
if (counts.errorCount > 0 || counts.warningCount > 0) {
|
|
sendLintResult({ ...counts, running: false });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// First pass clean - run type-aware check if enabled
|
|
if (!typeAware) {
|
|
sendLintResult({ errorCount: 0, warningCount: 0, running: false });
|
|
return;
|
|
}
|
|
|
|
console.log("\x1b[36mRunning type-aware checks...\x1b[0m");
|
|
sendLintResult({ running: true, typeAware: true });
|
|
const typeResult = await runLintProcess(["--type-check", "--type-aware"]);
|
|
|
|
// Check if we were superseded by a newer run
|
|
if (runId !== currentRunId) {
|
|
return;
|
|
}
|
|
|
|
if (typeResult.output) {
|
|
console.log(typeResult.output);
|
|
}
|
|
|
|
const counts =
|
|
typeResult.code !== 0
|
|
? parseLintOutput(typeResult.output)
|
|
: { errorCount: 0, warningCount: 0 };
|
|
sendLintResult({ ...counts, running: false });
|
|
};
|
|
|
|
const debouncedLint = (): void => {
|
|
clearDebounceTimer();
|
|
sendLintResult({ running: true });
|
|
debounceTimer = setTimeout(() => void runOxlint(), debounceDelay);
|
|
};
|
|
|
|
return {
|
|
name: "vite-plugin-oxlint-checker",
|
|
|
|
config(_, { command }) {
|
|
isProduction = command === "build";
|
|
},
|
|
|
|
configureServer(devServer: ViteDevServer) {
|
|
server = devServer;
|
|
|
|
// Send current lint status to new clients on connection
|
|
devServer.ws.on("connection", () => {
|
|
devServer.ws.send("vite-plugin-oxlint", lastLintResult);
|
|
});
|
|
|
|
// Run initial lint
|
|
void runOxlint();
|
|
|
|
// Listen for file changes
|
|
devServer.watcher.on("change", (file: string) => {
|
|
// Only lint on relevant file changes
|
|
if (extensions.some((ext) => file.endsWith(ext))) {
|
|
debouncedLint();
|
|
}
|
|
});
|
|
},
|
|
|
|
transformIndexHtml() {
|
|
if (!overlay) {
|
|
return [];
|
|
}
|
|
|
|
// Inject import to the overlay module (actual .ts file processed by Vite)
|
|
const overlayPath = normalizePath(
|
|
fileURLToPath(new URL("./oxlint-overlay.ts", import.meta.url)),
|
|
);
|
|
return [
|
|
{
|
|
tag: "script",
|
|
attrs: {
|
|
type: "module",
|
|
src: `/@fs${overlayPath}`,
|
|
},
|
|
injectTo: "body-prepend",
|
|
},
|
|
];
|
|
},
|
|
|
|
buildStart() {
|
|
// Only run during production builds, not dev server startup
|
|
if (!isProduction) {
|
|
return;
|
|
}
|
|
|
|
// Run oxlint synchronously during build
|
|
console.log("\n\x1b[1mRunning oxlint...\x1b[0m");
|
|
|
|
try {
|
|
const output = execSync(
|
|
"npx oxlint . && npx oxlint . --type-aware --type-check",
|
|
{
|
|
cwd: process.cwd(),
|
|
encoding: "utf-8",
|
|
env: { ...process.env, FORCE_COLOR: "3" },
|
|
},
|
|
);
|
|
|
|
if (output) {
|
|
console.log(output);
|
|
}
|
|
console.log(` \x1b[32m✓ No linting issues found\x1b[0m\n`);
|
|
} catch (error) {
|
|
// execSync throws on non-zero exit code (linting errors found)
|
|
if (error instanceof Error && "stdout" in error) {
|
|
const execError = error as Error & {
|
|
stdout?: string;
|
|
stderr?: string;
|
|
};
|
|
if (execError.stdout !== undefined) console.log(execError.stdout);
|
|
if (execError.stderr !== undefined) console.error(execError.stderr);
|
|
}
|
|
console.error("\n\x1b[31mBuild aborted due to linting errors\x1b[0m\n");
|
|
process.exit(1);
|
|
}
|
|
},
|
|
|
|
closeBundle() {
|
|
// Cleanup on server close
|
|
killCurrentProcess();
|
|
clearDebounceTimer();
|
|
},
|
|
};
|
|
}
|