@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
83 lines • 3.75 kB
JavaScript
import { ContextFile } from '../domain/ContextFile.js';
import { Violation } from '../domain/Violation.js';
import { Location } from '../domain/Location.js';
import { Severity } from '../domain/Severity.js';
import { FrontmatterParser, Frontmatter } from './support/FrontmatterParser.js';
export class SkillStructureRule {
id = 'skill-structure';
description = 'Validates Claude Code skill structure and frontmatter';
requireDescription;
maxDescriptionLength;
minDescriptionLength = 10;
constructor(options) {
this.requireDescription = options?.requireDescription ?? true;
this.maxDescriptionLength = options?.maxDescriptionLength ?? 200;
}
lint(file) {
const violations = [];
if (!this.isSkillFile(file.path)) {
return violations;
}
const frontmatter = FrontmatterParser.parse(file.lines);
violations.push(...this.validateFrontmatter(frontmatter));
violations.push(...this.validateStructure(file.lines));
return violations;
}
isSkillFile(path) {
return path.includes('.claude/skills/') && path.endsWith('.md');
}
validateFrontmatter(frontmatter) {
const violations = [];
const name = frontmatter.getString('name');
const description = frontmatter.getString('description');
if (!frontmatter.hasFence) {
violations.push(new Violation(this.id, 'Skill file is missing frontmatter. Add --- at the start with name and description.', Severity.ERROR, new Location(1, 1)));
return violations;
}
if (!name) {
violations.push(new Violation(this.id, 'Skill frontmatter is missing required "name" field.', Severity.ERROR, new Location(1, 1)));
}
else if (!this.isValidSkillName(name)) {
violations.push(new Violation(this.id, `Skill name "${name}" should use kebab-case (lowercase with hyphens).`, Severity.ERROR, new Location(1, 1)));
}
if (!description) {
if (this.requireDescription) {
violations.push(new Violation(this.id, 'Skill frontmatter is missing required "description" field.', Severity.ERROR, new Location(1, 1)));
}
}
else {
const descLength = description.length;
if (descLength < this.minDescriptionLength) {
violations.push(new Violation(this.id, `Skill description is too short (${descLength} chars). Minimum is ${this.minDescriptionLength} characters.`, Severity.WARNING, new Location(1, 1)));
}
if (descLength > this.maxDescriptionLength) {
violations.push(new Violation(this.id, `Skill description is too long (${descLength} chars). Maximum is ${this.maxDescriptionLength} characters.`, Severity.WARNING, new Location(1, 1)));
}
}
return violations;
}
isValidSkillName(name) {
return /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(name);
}
validateStructure(lines) {
const violations = [];
let lineNumber = 0;
let foundContent = false;
for (const line of lines) {
lineNumber++;
const trimmed = line.trim();
if (trimmed === '---') {
continue;
}
if (trimmed.length > 0 && !trimmed.startsWith('#')) {
foundContent = true;
break;
}
}
if (!foundContent) {
violations.push(new Violation(this.id, 'Skill file appears empty after frontmatter. Add content describing the skill.', Severity.WARNING, new Location(lineNumber, 1)));
}
return violations;
}
}
//# sourceMappingURL=SkillStructureRule.js.map