diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8a375774..b3ae480a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -44,10 +44,5 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile=false --ignore-scripts - - name: Install pipx (for JSON schema validation) - run: | - python3 -m pip install --user pipx - python3 -m pipx ensurepath - - name: Run pre-commit hooks against the full tree run: pnpm exec lefthook run pre-commit --all-files --force diff --git a/lefthook.yml b/lefthook.yml index 1277fc94..7821aa66 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -4,50 +4,21 @@ pre-commit: json-validity: glob: "**/*.json" run: | - has_schema_validator=0 - if command -v pipx >/dev/null 2>&1; then - has_schema_validator=1 + if ! command -v node >/dev/null 2>&1; then + echo "❌ node not available; cannot validate JSON" + exit 1 fi - for f in {files}; do - [ -f "$f" ] || continue - if ! jq empty "$f" >/dev/null 2>&1; then - echo "❌ invalid JSON: $f" - exit 1 - fi - schema="" - case "$f" in - *.claude-plugin/plugin.json|*/.claude-plugin/plugin.json) - schema="https://www.schemastore.org/claude-code-plugin-manifest.json" ;; - *.claude-plugin/marketplace.json|*/.claude-plugin/marketplace.json) - schema="https://www.schemastore.org/claude-code-marketplace.json" ;; - *.claude/settings.json|*.claude/settings.local.json|*/.claude/settings.json|*/.claude/settings.local.json) - schema="https://www.schemastore.org/claude-code-settings.json" ;; - esac - if [ -n "$schema" ] && [ "$has_schema_validator" = "1" ]; then - if ! pipx run check-jsonschema --schemafile "$schema" "$f" >/dev/null 2>&1; then - pipx run check-jsonschema --schemafile "$schema" "$f" - echo "❌ schema validation failed: $f against $schema" - exit 1 - fi - fi - done + node scripts/validate-json.mjs {files} yaml-validity: glob: "**/*.{yml,yaml}" exclude: - "plugins/*/skills/*/assets/**" run: | - if ! command -v python3 >/dev/null 2>&1; then - echo "ℹ️ python3 not available; skipping yaml-validity" - exit 0 - fi - if ! python3 -c "import yaml" >/dev/null 2>&1; then - echo "ℹ️ python3 yaml module not available; skipping yaml-validity" - exit 0 + if ! command -v node >/dev/null 2>&1; then + echo "❌ node not available; cannot validate YAML" + exit 1 fi - for f in {files}; do - [ -f "$f" ] || continue - python3 -c "import yaml; yaml.safe_load(open('$f'))" 2>/dev/null || { echo "❌ invalid YAML: $f"; exit 1; } - done + node scripts/validate-yaml.mjs {files} skill-frontmatter: glob: "plugins/*/skills/*/SKILL.md" run: | diff --git a/package.json b/package.json index e8606bee..8aa3446b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,9 @@ "devDependencies": { "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "js-yaml": "4.2.0", "lefthook": "^2.1.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9a5efc9..2aaaa136 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@commitlint/config-conventional': specifier: ^21.0.2 version: 21.0.2 + ajv: + specifier: 8.20.0 + version: 8.20.0 + ajv-formats: + specifier: 3.0.1 + version: 3.0.1(ajv@8.20.0) + js-yaml: + specifier: 4.2.0 + version: 4.2.0 lefthook: specifier: ^2.1.9 version: 2.1.9 @@ -120,6 +129,14 @@ packages: '@types/node@25.3.5': resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -519,6 +536,10 @@ snapshots: dependencies: undici-types: 7.18.2 + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 diff --git a/scripts/validate-json.mjs b/scripts/validate-json.mjs new file mode 100755 index 00000000..b43af257 --- /dev/null +++ b/scripts/validate-json.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +// JSON validator for framework files. It always checks JSON syntax, validates +// Claude metadata against SchemaStore when available, and falls back to local +// structural checks if remote schemas cannot be loaded. + +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; + +const ROOT = process.cwd(); +const INPUTS = process.argv.slice(2).filter((file) => file !== "--"); +const SCHEMA_TIMEOUT_MS = 10_000; +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const errors = []; +const warnings = []; +const schemaCache = new Map(); + +const SCHEMAS = { + pluginManifest: "https://www.schemastore.org/claude-code-plugin-manifest.json", + marketplace: "https://www.schemastore.org/claude-code-marketplace.json", + claudeSettings: "https://www.schemastore.org/claude-code-settings.json", +}; + +function fail(file, message) { + errors.push(`${file}: ${message}`); +} + +function warn(file, message) { + warnings.push(`${file}: ${message}`); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireString(file, object, key) { + if (typeof object[key] !== "string" || object[key].trim() === "") { + fail(file, `missing or invalid string field '${key}'`); + } +} + +function requireStringArray(file, object, key) { + if (!Array.isArray(object[key]) || object[key].some((value) => typeof value !== "string" || value.trim() === "")) { + fail(file, `missing or invalid string array '${key}'`); + } +} + +async function pathExists(file, relativePath, label, baseDir = path.dirname(path.join(ROOT, file))) { + try { + await access(path.resolve(baseDir, relativePath)); + } catch { + fail(file, `${label} does not exist: ${relativePath}`); + } +} + +function schemaFor(file) { + if (file.endsWith(".claude-plugin/plugin.json") || file.includes("/.claude-plugin/plugin.json")) { + return { type: "pluginManifest", url: SCHEMAS.pluginManifest }; + } + if (file.endsWith(".claude-plugin/marketplace.json") || file.includes("/.claude-plugin/marketplace.json")) { + return { type: "marketplace", url: SCHEMAS.marketplace }; + } + if (file.endsWith(".claude/settings.json") || file.endsWith(".claude/settings.local.json") || file.includes("/.claude/settings.")) { + return { type: "claudeSettings", url: SCHEMAS.claudeSettings }; + } + return null; +} + +async function loadSchemaValidator(url) { + if (schemaCache.has(url)) return schemaCache.get(url); + + const validatorPromise = (async () => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), SCHEMA_TIMEOUT_MS); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const schema = await response.json(); + return ajv.compile(schema); + } finally { + clearTimeout(timeout); + } + })(); + + schemaCache.set(url, validatorPromise); + return validatorPromise; +} + +function formatSchemaError(error) { + const location = error.instancePath || "/"; + const message = error.message ?? "schema validation failed"; + return `${location} ${message}`; +} + +async function validateAgainstRemoteSchema(file, data, schema) { + let validator; + try { + validator = await loadSchemaValidator(schema.url); + } catch (error) { + warn(file, `could not load ${schema.url}; using local fallback (${error.message})`); + return false; + } + + if (!validator(data)) { + for (const error of validator.errors ?? []) { + fail(file, formatSchemaError(error)); + } + } + return true; +} + +async function validatePluginManifestFallback(file, data) { + for (const key of ["name", "version", "description", "repository", "homepage", "license"]) { + requireString(file, data, key); + } + if (!isObject(data.author)) { + fail(file, "missing or invalid object field 'author'"); + } else { + requireString(file, data.author, "name"); + } + requireStringArray(file, data, "skills"); + const pluginRoot = path.dirname(path.dirname(path.join(ROOT, file))); + for (const skillPath of data.skills ?? []) { + await pathExists(file, skillPath, "skill path", pluginRoot); + } + if (data.agents !== undefined) { + requireStringArray(file, data, "agents"); + for (const agentPath of data.agents ?? []) { + await pathExists(file, agentPath, "agent path", pluginRoot); + } + } + if (data.keywords !== undefined) { + requireStringArray(file, data, "keywords"); + } +} + +async function validateMarketplaceFallback(file, data) { + for (const key of ["name", "version", "description"]) { + requireString(file, data, key); + } + if (!isObject(data.owner)) { + fail(file, "missing or invalid object field 'owner'"); + } else { + requireString(file, data.owner, "name"); + } + if (!Array.isArray(data.plugins) || data.plugins.length === 0) { + fail(file, "missing or invalid non-empty array 'plugins'"); + return; + } + const names = new Set(); + for (const [index, plugin] of data.plugins.entries()) { + const label = `plugins[${index}]`; + if (!isObject(plugin)) { + fail(file, `${label} must be an object`); + continue; + } + for (const key of ["name", "version", "source", "description"]) { + if (typeof plugin[key] !== "string" || plugin[key].trim() === "") { + fail(file, `${label}.${key} must be a non-empty string`); + } + } + if (names.has(plugin.name)) fail(file, `duplicate plugin name: ${plugin.name}`); + names.add(plugin.name); + if (typeof plugin.strict !== "boolean") fail(file, `${label}.strict must be boolean`); + if (typeof plugin.recommended !== "boolean") fail(file, `${label}.recommended must be boolean`); + if (typeof plugin.source === "string") await pathExists(file, plugin.source, `${label}.source`, ROOT); + } +} + +function validateClaudeSettingsFallback(file, data) { + if (data.extraKnownMarketplaces !== undefined && !isObject(data.extraKnownMarketplaces)) { + fail(file, "extraKnownMarketplaces must be an object when present"); + } + if (data.enabledPlugins !== undefined) { + if (!isObject(data.enabledPlugins)) { + fail(file, "enabledPlugins must be an object when present"); + } else { + for (const [name, enabled] of Object.entries(data.enabledPlugins)) { + if (typeof enabled !== "boolean") fail(file, `enabledPlugins.${name} must be boolean`); + } + } + } +} + +async function validateWithLocalFallback(file, data, type) { + if (type === "pluginManifest") { + await validatePluginManifestFallback(file, data); + } else if (type === "marketplace") { + await validateMarketplaceFallback(file, data); + } else if (type === "claudeSettings") { + validateClaudeSettingsFallback(file, data); + } +} + +async function validate(file) { + let data; + try { + data = JSON.parse(await readFile(path.join(ROOT, file), "utf8")); + } catch (error) { + fail(file, `invalid JSON (${error.message})`); + return; + } + + const schema = schemaFor(file); + if (!schema) return; + + const usedRemoteSchema = await validateAgainstRemoteSchema(file, data, schema); + if (!usedRemoteSchema) await validateWithLocalFallback(file, data, schema.type); +} + +for (const file of INPUTS) { + await validate(file); +} + +if (errors.length > 0) { + console.error(errors.map((error) => `❌ ${error}`).join("\n")); + process.exit(1); +} + +if (warnings.length > 0) { + console.warn(warnings.map((warning) => `⚠️ ${warning}`).join("\n")); +} + +console.log(`JSON validation passed for ${INPUTS.length} file(s).`); diff --git a/scripts/validate-yaml.mjs b/scripts/validate-yaml.mjs new file mode 100755 index 00000000..e667e2f3 --- /dev/null +++ b/scripts/validate-yaml.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Validates YAML syntax using the repository's Node dependency, avoiding Python in hooks. + +import { readFile } from "node:fs/promises"; +import yaml from "js-yaml"; + +const files = process.argv.slice(2).filter((file) => file !== "--"); +const errors = []; + +for (const file of files) { + try { + yaml.load(await readFile(file, "utf8"), { filename: file }); + } catch (error) { + errors.push(`${file}: ${error.message}`); + } +} + +if (errors.length > 0) { + console.error(errors.map((error) => `❌ ${error}`).join("\n")); + process.exit(1); +} + +console.log(`YAML validation passed for ${files.length} file(s).`);