aboutsummaryrefslogtreecommitdiff
path: root/validators/nodejs/validate.js
diff options
context:
space:
mode:
Diffstat (limited to 'validators/nodejs/validate.js')
-rw-r--r--validators/nodejs/validate.js234
1 files changed, 234 insertions, 0 deletions
diff --git a/validators/nodejs/validate.js b/validators/nodejs/validate.js
new file mode 100644
index 0000000..8c230c4
--- /dev/null
+++ b/validators/nodejs/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 (two levels up from validators/nodejs/)
+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();