import { spawnSync } from "child_process"; import fs from "fs"; import path from "path"; import YAML from "yaml"; export interface StaticValidationCommandResult { status: number; error?: string; } export interface StaticValidationOptions { root: string; deploymentId: string; testCommand?: { command: string; args: string[] }; runCommand?: ( command: string, args: string[], options: { cwd: string }, ) => Promise | StaticValidationCommandResult; now?: () => string; } export interface StaticValidationResult { status: "passed" | "failed"; reportPath: string; } const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$/; const defaultRunCommand = ( command: string, args: string[], options: { cwd: string }, ): StaticValidationCommandResult => { const executable = process.platform === "win32" && (command === "npm" || command === "npx") ? `${command}.cmd` : command; const result = spawnSync(executable, args, { cwd: options.cwd, env: process.env, stdio: "inherit" }); return { status: result.status ?? 1, error: result.error?.message }; }; const taskDocuments = (taskDir: string): string[] => { const files: string[] = []; const visit = (directory: string): void => { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const filePath = path.join(directory, entry.name); if (entry.isSymbolicLink()) throw new Error(`Task validation does not follow symlinks: ${filePath}`); if (entry.isDirectory()) { visit(filePath); } else if (/\.(?:json|ya?ml)$/i.test(entry.name)) { files.push(filePath); } } }; visit(taskDir); return files.sort(); }; const parseTaskDocuments = (taskDir: string): StaticValidationCommandResult => { try { for (const filePath of taskDocuments(taskDir)) { const contents = fs.readFileSync(filePath, "utf8"); if (/\.json$/i.test(filePath)) JSON.parse(contents); else YAML.parse(contents); } return { status: 0 }; } catch (error) { return { status: 1, error: error instanceof Error ? error.message : String(error) }; } }; const writeTextAtomic = (filePath: string, contents: string): void => { fs.mkdirSync(path.dirname(filePath), { recursive: true }); const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`; try { fs.writeFileSync(temporary, contents); fs.renameSync(temporary, filePath); } catch (error) { if (fs.existsSync(temporary)) fs.unlinkSync(temporary); throw error; } }; export const runStaticValidation = async ( options: StaticValidationOptions, ): Promise => { if (!SAFE_ID.test(options.deploymentId)) throw new Error("deploymentId is invalid"); const taskDir = path.join(options.root, "scripts", "tasks", options.deploymentId); if (!fs.existsSync(taskDir) || !fs.statSync(taskDir).isDirectory()) { throw new Error(`Deployment task does not exist: ${options.deploymentId}`); } const runCommand = options.runCommand || defaultRunCommand; const commands = [ { label: "npm run compile", command: "npm", args: ["run", "compile"] }, { label: "npx tsc --noEmit", command: "npx", args: ["tsc", "--noEmit"] }, { label: (options.testCommand ? [options.testCommand.command, ...options.testCommand.args] : ["npm", "test"]).join(" "), command: options.testCommand?.command || "npm", args: options.testCommand?.args || ["test"], }, ]; const evidence: Array<{ label: string; result: StaticValidationCommandResult }> = []; for (const command of commands) { let result: StaticValidationCommandResult; try { result = await runCommand(command.command, command.args, { cwd: options.root }); } catch (error) { result = { status: 1, error: error instanceof Error ? error.message : String(error) }; } evidence.push({ label: command.label, result }); } evidence.push({ label: "YAML/JSON parse", result: parseTaskDocuments(taskDir) }); const status = evidence.every((entry) => entry.result.status === 0) ? "passed" : "failed"; const completedAt = (options.now || (() => new Date().toISOString()))(); const reportPath = `scripts/tasks/${options.deploymentId}/docs/static-validation.md`; const lines = [ "# Static Validation", "", `status: ${status}`, `completedAt: ${completedAt}`, "", ...evidence.flatMap((entry) => [ `## ${entry.label}`, `command: ${entry.label}`, `result: ${entry.result.status === 0 ? "passed" : "failed"}`, `exit: ${entry.result.status}`, ...(entry.result.error ? [`error: ${entry.result.error}`] : []), "", ]), ]; writeTextAtomic(path.join(options.root, ...reportPath.split("/")), lines.join("\n")); return { status, reportPath }; };