src / validate.ts
src / validate.ts
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { extname } from "path";
import { runCommand } from "./shell";
import type { Workspace } from "./workspace";
/**
* Cheap syntax validation run automatically after a write. A small model will
* happily emit code with an unclosed brace and only discover it much later, so
* every write reports its own damage immediately.
*
* Conservative by design: it only speaks up when it is confident something is
* wrong. An unknown file type, a missing interpreter, or a checker that cannot
* run all return "no complaint" rather than a false alarm.
*/
const CHECK_TIMEOUT_MS = 8000;
const MAX_CHECKED_BYTES = 512 * 1024;
/** Returns a warning line to append to the tool result, or "" if it looks fine. */
export async function validateSyntax(ws: Workspace, absPath: string): Promise<string> {
try {
if (!existsSync(absPath)) return "";
const ext = extname(absPath).toLowerCase();
const display = ws.rel(absPath);
if (ext === ".json") return await checkJson(absPath, display);
if ([".js", ".mjs", ".cjs"].includes(ext)) return await checkWithCommand(ws, absPath, display, "node --check");
if (ext === ".py") return await checkWithCommand(ws, absPath, display, "python -m py_compile");
return "";
} catch {
// Validation is a courtesy; never let it interfere with the write itself.
return "";
}
}
async function checkJson(absPath: string, display: string): Promise<string> {
const body = await readFile(absPath, "utf-8");
if (body.trim() === "") return "";
try {
JSON.parse(body);
return "";
} catch (error) {
const message = (error as Error).message;
const line = locateJsonError(body, message);
return warn(display, line === undefined ? message : `${message} (near line ${line})`);
}
}
/** JSON.parse reports a character offset; turn it into something actionable. */
function locateJsonError(body: string, message: string): number | undefined {
const match = /position (\d+)/.exec(message);
if (match === null) return undefined;
const offset = Number(match[1]);
if (!Number.isFinite(offset)) return undefined;
return body.slice(0, offset).split(/\r?\n/).length;
}
async function checkWithCommand(
ws: Workspace,
absPath: string,
display: string,
command: string,
): Promise<string> {
const { statSync } = await import("fs");
if (statSync(absPath).size > MAX_CHECKED_BYTES) return "";
const result = await runCommand(`${command} "${absPath}"`, {
cwd: ws.root,
timeoutMs: CHECK_TIMEOUT_MS,
maxBuffer: 256 * 1024,
});
if (result.ok) return "";
const output = `${result.stderr}\n${result.stdout}`.trim();
// The checker itself being absent is not a syntax problem.
if (output === "" || /not recognized|command not found|ENOENT|No such file/i.test(output)) return "";
return warn(display, firstUsefulLine(output, absPath));
}
/** Build output repeats the absolute path on every line; the model needs the reason. */
function firstUsefulLine(output: string, absPath: string): string {
const lines = output
.split(/\r?\n/)
.map((line) => line.replace(absPath, "").trim())
.filter((line) => line !== "" && !line.startsWith("^"));
const pointed = lines.find((line) => /error|Error|SyntaxError|line \d+/.test(line));
return (pointed ?? lines[0] ?? "syntax error").slice(0, 300);
}
function warn(display: string, detail: string): string {
return (
`\n\nWARNING: ${display} does not parse -- ${detail}\n` +
`The file was still written. Read it back and fix the syntax before moving on.`
);
}
import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { extname } from "path";
import { runCommand } from "./shell";
import type { Workspace } from "./workspace";
/**
* Cheap syntax validation run automatically after a write. A small model will
* happily emit code with an unclosed brace and only discover it much later, so
* every write reports its own damage immediately.
*
* Conservative by design: it only speaks up when it is confident something is
* wrong. An unknown file type, a missing interpreter, or a checker that cannot
* run all return "no complaint" rather than a false alarm.
*/
const CHECK_TIMEOUT_MS = 8000;
const MAX_CHECKED_BYTES = 512 * 1024;
/** Returns a warning line to append to the tool result, or "" if it looks fine. */
export async function validateSyntax(ws: Workspace, absPath: string): Promise<string> {
try {
if (!existsSync(absPath)) return "";
const ext = extname(absPath).toLowerCase();
const display = ws.rel(absPath);
if (ext === ".json") return await checkJson(absPath, display);
if ([".js", ".mjs", ".cjs"].includes(ext)) return await checkWithCommand(ws, absPath, display, "node --check");
if (ext === ".py") return await checkWithCommand(ws, absPath, display, "python -m py_compile");
return "";
} catch {
// Validation is a courtesy; never let it interfere with the write itself.
return "";
}
}
async function checkJson(absPath: string, display: string): Promise<string> {
const body = await readFile(absPath, "utf-8");
if (body.trim() === "") return "";
try {
JSON.parse(body);
return "";
} catch (error) {
const message = (error as Error).message;
const line = locateJsonError(body, message);
return warn(display, line === undefined ? message : `${message} (near line ${line})`);
}
}
/** JSON.parse reports a character offset; turn it into something actionable. */
function locateJsonError(body: string, message: string): number | undefined {
const match = /position (\d+)/.exec(message);
if (match === null) return undefined;
const offset = Number(match[1]);
if (!Number.isFinite(offset)) return undefined;
return body.slice(0, offset).split(/\r?\n/).length;
}
async function checkWithCommand(
ws: Workspace,
absPath: string,
display: string,
command: string,
): Promise<string> {
const { statSync } = await import("fs");
if (statSync(absPath).size > MAX_CHECKED_BYTES) return "";
const result = await runCommand(`${command} "${absPath}"`, {
cwd: ws.root,
timeoutMs: CHECK_TIMEOUT_MS,
maxBuffer: 256 * 1024,
});
if (result.ok) return "";
const output = `${result.stderr}\n${result.stdout}`.trim();
// The checker itself being absent is not a syntax problem.
if (output === "" || /not recognized|command not found|ENOENT|No such file/i.test(output)) return "";
return warn(display, firstUsefulLine(output, absPath));
}
/** Build output repeats the absolute path on every line; the model needs the reason. */
function firstUsefulLine(output: string, absPath: string): string {
const lines = output
.split(/\r?\n/)
.map((line) => line.replace(absPath, "").trim())
.filter((line) => line !== "" && !line.startsWith("^"));
const pointed = lines.find((line) => /error|Error|SyntaxError|line \d+/.test(line));
return (pointed ?? lines[0] ?? "syntax error").slice(0, 300);
}
function warn(display: string, detail: string): string {
return (
`\n\nWARNING: ${display} does not parse -- ${detail}\n` +
`The file was still written. Read it back and fix the syntax before moving on.`
);
}