Files
tactical-shooter/package/dist/tools/validate-tools.js
T
shawn 91b878f347 Quaternius weapons, input fixes, sound & path fixes
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle)
- Created weapon resource files with balanced stats
- Wired Pistol + Rifle into player.tscn replacing Kenney blasters
- Fixed WASD input (matched input map action names)
- Fixed Escape key toggles mouse capture
- Added Audio autoload singleton
- Fixed broken sound asset paths across all scripts
- Fixed all scene ext_resource text paths (nested under assets/kenney-fps/)
- Fixed all .import source_file paths
- Deleted stale .import files (will be regenerated by editor on open)
2026-07-05 22:53:27 -04:00

375 lines
17 KiB
JavaScript

import { join } from 'path';
import { existsSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
import { randomUUID } from 'crypto';
import { normalizeParameters, validateSubPath, validateProjectArgs, createErrorResponse, extractGdError, getErrorMessage, } from '../utils/godot-runner.js';
export const validateToolDefinitions = [
{
name: 'validate',
description: "Validate GDScript syntax or scene file integrity using headless Godot. Use before attach_script or run_script to catch parse errors early. Single-target: provide exactly one of scriptPath, source, or scenePath. Batch: provide a targets array — runs all in one Godot process. Returns { valid, errors: [{ line?, message }] } for single, or { results: [{ target, valid, errors }] } for batch. Line numbers appear when Godot's stderr includes them (not always). Returns valid:false on any parse error; never throws.",
annotations: { readOnlyHint: true },
inputSchema: {
type: 'object',
properties: {
projectPath: {
type: 'string',
description: 'Path to the Godot project directory',
},
scriptPath: {
type: 'string',
description: '[single] Path to a .gd file relative to the project to validate (e.g. "scripts/player.gd")',
},
source: {
type: 'string',
description: '[single] Inline GDScript source code to validate. Written to a temporary file and validated against the project.',
},
scenePath: {
type: 'string',
description: '[single] Path to a .tscn scene file relative to the project to validate (e.g. "scenes/main.tscn")',
},
targets: {
type: 'array',
description: '[batch] Array of targets to validate in a single Godot process. Each item must have exactly one of: scriptPath, source, or scenePath.',
items: {
type: 'object',
properties: {
scriptPath: {
type: 'string',
description: 'Path to a .gd file relative to the project',
},
source: { type: 'string', description: 'Inline GDScript source code' },
scenePath: {
type: 'string',
description: 'Path to a .tscn file relative to the project',
},
},
},
},
},
required: ['projectPath'],
},
},
];
/**
* Core Godot stderr parser. Returns a flat list of error entries, each with an
* optional line number and optional res:// file path (from the "at:" line).
*/
function parseGodotErrorEntries(stderr) {
const entries = [];
if (!stderr)
return entries;
const lines = stderr.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Pattern: "SCRIPT ERROR: Parse Error: MESSAGE" or "ERROR: MESSAGE"
// followed by " at: res://...:LINE" or " at: ...:LINE"
const scriptErrorMatch = line.match(/SCRIPT ERROR:\s*(?:Parse Error:\s*)?(.+)/);
const errorMatch = !scriptErrorMatch ? line.match(/^ERROR:\s*(.+)/) : null;
const match = scriptErrorMatch || errorMatch;
if (match) {
const message = match[1].trim();
let lineNum;
let filePath;
if (i + 1 < lines.length) {
// Try res:// path first (captures file + line). Tolerates an optional
// "<method> (" prefix before res:// so we catch both
// " at: res://foo.gd:3"
// and
// " at: GDScript::reload (res://foo.gd:3)"
// Real Godot 4.5 stderr uses the parenthesized form.
const resAtMatch = lines[i + 1].match(/\s*at:\s*(?:[^()\n]*\()?(res:\/\/[^):"\s]+):(\d+)/);
if (resAtMatch) {
filePath = resAtMatch[1];
lineNum = parseInt(resAtMatch[2], 10);
i++;
}
else {
// Fall back to loose match (line only, e.g. native code "at:" lines)
const looseAtMatch = lines[i + 1].match(/\s*at:\s*.+:(\d+)/);
if (looseAtMatch) {
lineNum = parseInt(looseAtMatch[1], 10);
i++;
}
}
}
// Parse-error entries reference synthetic gdscript:// URIs in their `at:` line
// rather than a res:// path. Peek forward up to 10 lines for the secondary
// "Failed to load script/resource: \"res://...\"" message that names the file,
// and adopt that path so batch error attribution can find it. The window is
// intentionally wide enough to clear a full GDScript backtrace.
if (!filePath && /Parse Error/i.test(line)) {
const lookaheadLimit = Math.min(i + 11, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
const failMatch = lines[j].match(/Failed to load (?:script|resource):?\s*"?(res:\/\/[^":\s]+)/);
if (failMatch) {
filePath = failMatch[1];
break;
}
}
}
entries.push({ message, line: lineNum, filePath });
continue;
}
// Pattern: "Parse Error: MESSAGE at line LINE"
const parseErrorMatch = line.match(/Parse Error:\s*(.+?)\s+at line\s+(\d+)/);
if (parseErrorMatch) {
entries.push({
line: parseInt(parseErrorMatch[2], 10),
message: parseErrorMatch[1].trim(),
});
}
}
return entries;
}
function parseGodotErrors(stderr) {
return parseGodotErrorEntries(stderr).map(({ message, line }) => ({ message, line }));
}
/**
* Write inline GDScript source to a uniquely-named file under <projectPath>/.mcp/
* for validation. Returns the project-relative path (e.g. ".mcp/validate_temp_xxx.gd")
* that the runner consumes plus the absolute path the caller cleans up.
*/
function writeTempGdScript(projectPath, source, prefix) {
const mcpDir = join(projectPath, '.mcp');
mkdirSync(mcpDir, { recursive: true });
const name = `${prefix}_${randomUUID()}.gd`;
const absPath = join(mcpDir, name);
writeFileSync(absPath, source, 'utf8');
return { resPath: `.mcp/${name}`, absPath };
}
/**
* Group Godot stderr errors by their res:// file path.
* Used for batch validation where multiple files produce output in one stderr stream.
*/
function parseGodotErrorsByPath(stderr) {
const result = new Map();
for (const { message, line, filePath } of parseGodotErrorEntries(stderr)) {
if (filePath) {
if (!result.has(filePath))
result.set(filePath, []);
result.get(filePath).push({ line, message });
}
}
return result;
}
export async function handleValidate(runner, args) {
args = normalizeParameters(args);
const pv = validateProjectArgs(args);
if ('isError' in pv)
return pv;
// Batch mode: targets array
if (args.targets && Array.isArray(args.targets)) {
const targets = args.targets;
const tempFiles = [];
try {
const snakeTargets = [];
const preErrors = new Map();
for (let i = 0; i < targets.length; i++) {
const t = targets[i];
if (t.source) {
const { resPath, absPath } = writeTempGdScript(pv.projectPath, t.source, 'validate_batch');
tempFiles.push(absPath);
snakeTargets.push({ script_path: resPath });
}
else if (t.scriptPath) {
if (!validateSubPath(pv.projectPath, t.scriptPath)) {
preErrors.set(i, {
target: t.scriptPath,
errors: [
{
message: 'Invalid scriptPath: must be a relative path inside the project root, no ".."',
},
],
});
}
else {
snakeTargets.push({ script_path: t.scriptPath });
}
}
else if (t.scenePath) {
if (!validateSubPath(pv.projectPath, t.scenePath)) {
preErrors.set(i, {
target: t.scenePath,
errors: [
{
message: 'Invalid scenePath: must be a relative path inside the project root, no ".."',
},
],
});
}
else {
snakeTargets.push({ scene_path: t.scenePath });
}
}
else {
snakeTargets.push({});
}
}
// Short-circuit when every target failed pre-validation — no work for
// Godot, and spawning it would just cost ~3s for a no-op.
if (snakeTargets.length === 0 && preErrors.size === targets.length) {
const results = targets.map((_, i) => {
const pre = preErrors.get(i);
return { target: pre.target, valid: false, errors: pre.errors };
});
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
}
const { stdout, stderr } = await runner.executeOperation('validate_batch', { targets: snakeTargets }, pv.projectPath);
if (!stdout.trim()) {
return createErrorResponse(`Batch validate failed: ${extractGdError(stderr)}`, [
'Check that all target paths are valid',
'Ensure Godot is installed correctly',
]);
}
let parsed;
try {
parsed = JSON.parse(stdout.trim());
}
catch {
return createErrorResponse(`Invalid response from validate_batch: ${stdout}`, [
'Ensure Godot is installed correctly',
]);
}
const errorsByPath = parseGodotErrorsByPath(stderr || '');
const godotResults = parsed.results.map((r) => {
const key = r.target.startsWith('res://') ? r.target : `res://${r.target}`;
const stderrErrors = errorsByPath.get(key) || errorsByPath.get(r.target) || [];
const allErrors = stderrErrors.length > 0 ? stderrErrors : r.errors || [];
return {
target: r.target,
valid: r.valid && stderrErrors.length === 0,
errors: allErrors,
};
});
// Merge pre-validation failures back into their original positions so
// output order matches input order. Pre-validation errors are ours, not
// Godot's — they bypass the stderr overlay above.
const results = [];
let godotIdx = 0;
for (let i = 0; i < targets.length; i++) {
if (preErrors.has(i)) {
const pre = preErrors.get(i);
results.push({ target: pre.target, valid: false, errors: pre.errors });
}
else {
results.push(godotResults[godotIdx++]);
}
}
return { content: [{ type: 'text', text: JSON.stringify({ results }, null, 2) }] };
}
catch (error) {
return createErrorResponse(`Batch validation failed: ${getErrorMessage(error)}`, [
'Ensure Godot is installed correctly',
'Check if the GODOT_PATH environment variable is set correctly',
]);
}
finally {
for (const f of tempFiles) {
try {
unlinkSync(f);
}
catch {
/* ignore */
}
}
}
}
// Determine mode — exactly one must be provided
const modeCount = [args.scriptPath, args.source, args.scenePath].filter(Boolean).length;
if (modeCount === 0) {
return createErrorResponse('One of scriptPath, source, or scenePath is required', [
'Provide scriptPath to validate an existing .gd file, source to validate inline GDScript, or scenePath to validate a .tscn file',
]);
}
if (modeCount > 1) {
return createErrorResponse('Provide exactly one of scriptPath, source, or scenePath — not multiple', ['Only one target can be validated per call']);
}
let tempFile = false;
let resolvedScriptPath;
let resolvedScenePath;
try {
if (args.source) {
const { resPath } = writeTempGdScript(pv.projectPath, args.source, 'validate_temp');
resolvedScriptPath = resPath;
tempFile = true;
}
else if (args.scriptPath) {
if (!validateSubPath(pv.projectPath, args.scriptPath)) {
return createErrorResponse('Invalid scriptPath', [
'Provide a valid relative path without ".." that stays inside the project directory',
]);
}
const fullPath = join(pv.projectPath, args.scriptPath);
if (!existsSync(fullPath)) {
return createErrorResponse(`Script file does not exist: ${args.scriptPath}`, [
'Ensure the path is correct relative to the project directory',
]);
}
resolvedScriptPath = args.scriptPath;
}
else if (args.scenePath) {
if (!validateSubPath(pv.projectPath, args.scenePath)) {
return createErrorResponse('Invalid scenePath', [
'Provide a valid relative path without ".." that stays inside the project directory',
]);
}
const fullPath = join(pv.projectPath, args.scenePath);
if (!existsSync(fullPath)) {
return createErrorResponse(`Scene file does not exist: ${args.scenePath}`, [
'Ensure the path is correct relative to the project directory',
]);
}
resolvedScenePath = args.scenePath;
}
const params = {};
if (resolvedScriptPath)
params.scriptPath = resolvedScriptPath;
if (resolvedScenePath)
params.scenePath = resolvedScenePath;
const { stdout, stderr } = await runner.executeOperation('validate_resource', params, pv.projectPath);
// Parse stdout for the base valid/invalid signal from GDScript
let valid = false;
let gdErrors = [];
try {
const parsed = JSON.parse(stdout.trim());
valid = parsed.valid === true;
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
gdErrors = parsed.errors;
}
}
catch {
// stdout wasn't JSON — treat as invalid
valid = false;
}
// Parse stderr for detailed error messages from Godot's script compiler
const stderrErrors = parseGodotErrors(stderr || '');
// Merge errors: prefer detailed stderr errors when available, otherwise keep gdErrors
const allErrors = stderrErrors.length > 0 ? stderrErrors : gdErrors;
// The GDScript-side `valid` flag is unreliable for malformed scripts: load()
// returns a non-null placeholder Resource even when parsing fails, so
// resource != null is true. Fall back to the parsed stderr errors as the
// authoritative signal — matches the batch branch above.
const result = {
valid: valid && allErrors.length === 0,
errors: allErrors,
};
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
}
catch (error) {
return createErrorResponse(`Validation failed: ${getErrorMessage(error)}`, [
'Ensure Godot is installed correctly',
'Check if the GODOT_PATH environment variable is set correctly',
]);
}
finally {
if (tempFile && resolvedScriptPath) {
const tempFilePath = join(pv.projectPath, resolvedScriptPath);
try {
unlinkSync(tempFilePath);
}
catch {
// Ignore cleanup errors
}
}
}
}
//# sourceMappingURL=validate-tools.js.map