UNPKG

@felixgeelhaar/cclint

Version:

Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.

198 lines 8.26 kB
import { ContextFile } from '../domain/ContextFile.js'; import { Violation } from '../domain/Violation.js'; import { Location } from '../domain/Location.js'; import { Severity } from '../domain/Severity.js'; /** * Strict SemVer 2.0.0 pattern (major.minor.patch with optional pre-release and * build metadata). Plugin and marketplace manifests declare versions that * tooling compares, so a non-semver value is a real defect. */ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; /** * Fields in a plugin manifest whose value points at bundled resources. Each may * be a single relative path string or an array of them; `hooks` additionally * accepts an inline object, which we skip rather than treat as a path. */ const PATH_FIELDS = ['commands', 'agents', 'skills', 'hooks']; const AT_START = new Location(1, 1); /** * Validates Claude Code plugin manifests: the plugin descriptor * (`.claude-plugin/plugin.json`) and the marketplace listing * (`marketplace.json`). * * @remarks * A malformed manifest silently breaks plugin discovery/installation, so this * rule checks the invariants Claude Code relies on: parseable JSON, a required * `name`, a SemVer `version`, well-formed resource path references, and the * shape of a marketplace's `plugins` array. It is a pure rule — it reasons only * over the already-read {@link ContextFile} content and performs no IO. */ export class PluginManifestRule { id = 'plugin-manifest'; description = 'Validates Claude Code plugin.json and marketplace.json manifests'; appliesTo(file) { return file.isPluginManifest(); } lint(file) { if (!file.isPluginManifest()) { return []; } const parsed = parseJson(file.content); if (!parsed.ok) { return [ new Violation(this.id, `Invalid JSON: ${parsed.error}`, Severity.ERROR, AT_START), ]; } const root = parsed.value; if (!isRecord(root)) { return [ new Violation(this.id, 'Plugin manifest must be a JSON object.', Severity.ERROR, AT_START), ]; } return this.isMarketplace(file.path) ? this.validateMarketplace(root) : this.validatePlugin(root); } isMarketplace(path) { return /(^|[\\/])marketplace\.json$/i.test(path); } validatePlugin(root) { const violations = []; violations.push(...this.requireName(root)); violations.push(...this.validateVersion(root['version'])); violations.push(...this.validateOptionalString(root, 'description')); for (const field of PATH_FIELDS) { violations.push(...this.validatePathField(field, root[field])); } return violations; } validateMarketplace(root) { const violations = []; violations.push(...this.requireName(root)); const plugins = root['plugins']; if (plugins === undefined) { violations.push(new Violation(this.id, 'Marketplace manifest is missing required "plugins" array.', Severity.ERROR, AT_START)); return violations; } if (!Array.isArray(plugins)) { violations.push(new Violation(this.id, '"plugins" must be an array of plugin entries.', Severity.ERROR, AT_START)); return violations; } plugins.forEach((entry, index) => violations.push(...this.validateMarketplaceEntry(entry, index))); return violations; } validateMarketplaceEntry(entry, index) { const where = `plugins[${index}]`; if (!isRecord(entry)) { return [ new Violation(this.id, `Marketplace entry "${where}" must be an object.`, Severity.ERROR, AT_START), ]; } const violations = []; const name = entry['name']; if (typeof name !== 'string' || name.trim() === '') { violations.push(new Violation(this.id, `Marketplace entry "${where}" is missing a non-empty "name" string.`, Severity.ERROR, AT_START)); } if (entry['source'] === undefined) { violations.push(new Violation(this.id, `Marketplace entry "${where}" is missing a "source" (path, object, or git reference).`, Severity.WARNING, AT_START)); } violations.push(...this.validateVersion(entry['version'])); return violations; } requireName(root) { const name = root['name']; if (name === undefined) { return [ new Violation(this.id, 'Manifest is missing required "name" field.', Severity.ERROR, AT_START), ]; } if (typeof name !== 'string' || name.trim() === '') { return [ new Violation(this.id, '"name" must be a non-empty string.', Severity.ERROR, AT_START), ]; } return []; } validateVersion(version) { if (version === undefined) { // Only the plugin descriptor conventionally carries a top-level version; // its absence is a recommendation, not a hard error. return []; } if (typeof version !== 'string') { return [ new Violation(this.id, '"version" must be a SemVer string (e.g. "1.2.3").', Severity.ERROR, AT_START), ]; } if (!SEMVER.test(version.trim())) { return [ new Violation(this.id, `"version" ("${version}") is not valid SemVer (expected major.minor.patch, e.g. "1.0.0").`, Severity.ERROR, AT_START), ]; } return []; } validateOptionalString(root, key) { const value = root[key]; if (value !== undefined && typeof value !== 'string') { return [ new Violation(this.id, `"${key}" must be a string.`, Severity.ERROR, AT_START), ]; } return []; } validatePathField(field, value) { if (value === undefined) { return []; } // `hooks` may reference a hooks file (path) or inline the config object. if (field === 'hooks' && isRecord(value)) { return []; } if (typeof value === 'string') { return this.validatePathString(field, value); } if (Array.isArray(value)) { const violations = []; value.forEach((item, index) => { if (typeof item !== 'string') { violations.push(new Violation(this.id, `"${field}[${index}]" must be a path string.`, Severity.ERROR, AT_START)); return; } violations.push(...this.validatePathString(`${field}[${index}]`, item)); }); return violations; } return [ new Violation(this.id, `"${field}" must be a path string or an array of path strings.`, Severity.ERROR, AT_START), ]; } validatePathString(where, path) { const violations = []; if (path.trim() === '') { violations.push(new Violation(this.id, `"${where}" is an empty path.`, Severity.ERROR, AT_START)); return violations; } if (path.startsWith('/')) { violations.push(new Violation(this.id, `"${where}" ("${path}") is an absolute path; plugin resources should be referenced relative to the plugin root.`, Severity.WARNING, AT_START)); } if (path.includes('\\')) { violations.push(new Violation(this.id, `"${where}" ("${path}") uses backslashes; use forward slashes for portable paths.`, Severity.WARNING, AT_START)); } return violations; } } function parseJson(content) { try { return { ok: true, value: JSON.parse(content) }; } catch (error) { return { ok: false, error: error instanceof Error ? error.message : 'Unknown JSON error', }; } } function isRecord(value) { return typeof value === 'object' && value !== null && !Array.isArray(value); } //# sourceMappingURL=PluginManifestRule.js.map