diff options
Diffstat (limited to 'validate.js')
| -rw-r--r-- | validate.js | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/validate.js b/validate.js new file mode 100644 index 0000000..404a6f0 --- /dev/null +++ b/validate.js @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +/** + * CLI validator for .gitinfo files + * Usage: node validate.js [file] + * node validate.js # validates .gitinfo in current directory + * node validate.js path/to/.gitinfo + */ + +const fs = require("fs"); +const path = require("path"); + +// Load the schema +const SCHEMA_PATH = path.join(__dirname, "gitinfo.schema.json"); + +/** + * Strip JSONC comments (single-line // and multi-line /* *\/) + */ +function stripJsonComments(jsonc) { + let result = ""; + let i = 0; + let inString = false; + let stringChar = null; + + while (i < jsonc.length) { + const char = jsonc[i]; + const next = jsonc[i + 1]; + + // Track string state + if (!inString && (char === '"' || char === "'")) { + inString = true; + stringChar = char; + result += char; + i++; + continue; + } + + if (inString) { + if (char === "\\" && i + 1 < jsonc.length) { + // Escape sequence + result += char + jsonc[i + 1]; + i += 2; + continue; + } + if (char === stringChar) { + inString = false; + stringChar = null; + } + result += char; + i++; + continue; + } + + // Single-line comment + if (char === "/" && next === "/") { + while (i < jsonc.length && jsonc[i] !== "\n") { + i++; + } + continue; + } + + // Multi-line comment + if (char === "/" && next === "*") { + i += 2; + while ( + i < jsonc.length - 1 && + !(jsonc[i] === "*" && jsonc[i + 1] === "/") + ) { + i++; + } + i += 2; + continue; + } + + result += char; + i++; + } + + return result; +} + +/** + * Simple JSON Schema validator (subset of draft 2020-12) + */ +function validateSchema(data, schema, path = "") { + const errors = []; + + if (schema.type === "object") { + if (typeof data !== "object" || data === null || Array.isArray(data)) { + errors.push(`${path || "root"}: expected object`); + return errors; + } + + // Check additionalProperties + if (schema.additionalProperties === false && schema.properties) { + const allowed = new Set(Object.keys(schema.properties)); + for (const key of Object.keys(data)) { + if (!allowed.has(key)) { + errors.push(`${path || "root"}: unknown property "${key}"`); + } + } + } + + // Validate each property + if (schema.properties) { + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (data[key] !== undefined) { + errors.push( + ...validateSchema(data[key], propSchema, `${path}.${key}`), + ); + } + } + } + } else if (schema.type === "array") { + if (!Array.isArray(data)) { + errors.push(`${path}: expected array`); + return errors; + } + + if (schema.items) { + if (Array.isArray(schema.items)) { + // Tuple validation + for (let i = 0; i < data.length; i++) { + const itemSchema = schema.items[i] || {}; + errors.push(...validateSchema(data[i], itemSchema, `${path}[${i}]`)); + } + if (schema.minItems && data.length < schema.minItems) { + errors.push(`${path}: expected at least ${schema.minItems} items`); + } + if (schema.maxItems && data.length > schema.maxItems) { + errors.push(`${path}: expected at most ${schema.maxItems} items`); + } + } else { + // Array of same type + for (let i = 0; i < data.length; i++) { + errors.push( + ...validateSchema(data[i], schema.items, `${path}[${i}]`), + ); + } + } + } + } else if (schema.type === "string") { + if (typeof data !== "string") { + errors.push(`${path}: expected string`); + return errors; + } + + if (schema.minLength && data.length < schema.minLength) { + errors.push(`${path}: string too short (min ${schema.minLength})`); + } + + if (schema.format === "uri") { + try { + new URL(data); + } catch { + errors.push(`${path}: invalid URI "${data}"`); + } + } + + if (schema.format === "email") { + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data)) { + errors.push(`${path}: invalid email "${data}"`); + } + } + + if (schema.pattern) { + if (!new RegExp(schema.pattern).test(data)) { + errors.push(`${path}: does not match pattern ${schema.pattern}`); + } + } + } + + return errors; +} + +function main() { + const args = process.argv.slice(2); + const filePath = args[0] || ".gitinfo"; + + // Check if file exists + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found: ${filePath}`); + process.exit(1); + } + + // Check if schema exists + if (!fs.existsSync(SCHEMA_PATH)) { + console.error(`Error: Schema not found: ${SCHEMA_PATH}`); + process.exit(1); + } + + // Read and parse schema + let schema; + try { + schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf-8")); + } catch (err) { + console.error(`Error parsing schema: ${err.message}`); + process.exit(1); + } + + // Read and parse .gitinfo file + let content; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch (err) { + console.error(`Error reading file: ${err.message}`); + process.exit(1); + } + + let data; + try { + const stripped = stripJsonComments(content); + data = JSON.parse(stripped); + } catch (err) { + console.error(`Error parsing JSONC: ${err.message}`); + process.exit(1); + } + + // Validate against schema + const errors = validateSchema(data, schema); + + if (errors.length > 0) { + console.error(`Validation failed for ${filePath}:`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); + } + + console.log(`✓ ${filePath} is valid`); + process.exit(0); +} + +main(); |
