UNPKG

@felixgeelhaar/cclint

Version:

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

249 lines 9.82 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'; export class FormatRule { id = 'format'; description = 'Markdown format validation for proper syntax and style'; validLanguages = new Set([ 'bash', 'sh', 'shell', 'javascript', 'js', 'typescript', 'ts', 'python', 'py', 'java', 'c', 'cpp', 'csharp', 'c#', 'go', 'rust', 'php', 'ruby', 'swift', 'kotlin', 'lua', 'html', 'css', 'scss', 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'sql', 'dockerfile', 'makefile', 'markdown', 'md', 'text', 'txt', ]); appliesTo(file) { return file.isMarkdown(); } lint(file) { const violations = []; if (file.content.trim() === '') { return violations; } // Lines inside fenced code blocks are literal content — markdown // formatting rules (and their autofixes) must never touch them, or a // '#!/bin/bash' shebang, a YAML '- item', or intentional whitespace gets // rewritten and the code corrupted. const fenced = this.fencedLineIndices(file); this.checkHeaders(file, violations, fenced); this.checkEmptyLines(file, violations, fenced); this.checkTrailingWhitespace(file, violations, fenced); this.checkCodeBlocks(file, violations, fenced); this.checkListFormatting(file); this.checkEndOfFile(file, violations); return violations; } /** * Indices (0-based) of lines that sit INSIDE a fenced code block. The ``` * delimiter lines themselves are excluded — they are markdown structure. */ fencedLineIndices(file) { const fenced = new Set(); let inFence = false; for (let i = 0; i < file.lines.length; i++) { if ((file.lines[i] ?? '').trim().startsWith('```')) { inFence = !inFence; continue; } if (inFence) { fenced.add(i); } } return fenced; } checkHeaders(file, violations, fenced) { for (let i = 0; i < file.lines.length; i++) { if (fenced.has(i)) continue; const line = file.lines[i] ?? ''; const headerMatch = line.match(/^(#{1,6})(.*)$/); if (headerMatch) { const [, hashes, content] = headerMatch; if (hashes && content && !content.startsWith(' ')) { const at = new Location(i + 1, hashes.length + 1); violations.push(new Violation(this.id, `Header missing space after ${hashes}`, Severity.ERROR, at, { range: { start: at, end: at }, text: ' ', description: 'Add space after header #', })); } } } } checkEmptyLines(file, violations, fenced) { let consecutiveEmpty = 0; let emptyLineStart = -1; for (let i = 0; i < file.lines.length; i++) { if (fenced.has(i)) { consecutiveEmpty = 0; // blank lines inside code are intentional continue; } const line = file.lines[i] ?? ''; if (line.trim() === '') { if (consecutiveEmpty === 0) { emptyLineStart = i; } consecutiveEmpty++; } else { if (consecutiveEmpty > 2) { violations.push(new Violation(this.id, `Too many consecutive empty lines (${consecutiveEmpty}), maximum 2 allowed`, Severity.WARNING, new Location(emptyLineStart + 1, 1), { range: { start: new Location(emptyLineStart + 1, 1), end: new Location(emptyLineStart + 2, 1), }, text: '', description: 'Remove extra empty line', })); } consecutiveEmpty = 0; } } } checkTrailingWhitespace(file, violations, fenced) { for (let i = 0; i < file.lines.length; i++) { if (fenced.has(i)) continue; // trailing space inside code may be significant const line = file.lines[i] ?? ''; if (line.length > 0 && line !== line.trimEnd()) { const startCol = line.trimEnd().length + 1; violations.push(new Violation(this.id, 'Line has trailing whitespace', Severity.WARNING, new Location(i + 1, startCol), { range: { start: new Location(i + 1, startCol), end: new Location(i + 1, line.length + 1), }, text: '', description: 'Remove trailing whitespace', })); } } } checkCodeBlocks(file, violations, fenced) { let inCodeBlock = false; let codeBlockStart = -1; for (let i = 0; i < file.lines.length; i++) { const line = file.lines[i] ?? ''; // Check for code block start/end if (line.trim().startsWith('```')) { if (!inCodeBlock) { inCodeBlock = true; codeBlockStart = i; // Check language specification const language = line .slice(line.indexOf('```') + 3) .trim() .toLowerCase(); if (language && !this.validLanguages.has(language)) { const langStart = line.indexOf(language); const fix = langStart !== -1 ? { range: { start: new Location(i + 1, langStart + 1), end: new Location(i + 1, langStart + language.length + 1), }, text: 'text', description: 'Replace unknown language with "text"', } : undefined; violations.push(new Violation(this.id, `Unknown code block language: "${language}"`, Severity.INFO, new Location(i + 1, line.indexOf('```') + 4), fix)); } } else { inCodeBlock = false; } } } // Check for unclosed code blocks if (inCodeBlock) { const lastLine = file.lines[file.lines.length - 1] ?? ''; const endOfFile = new Location(file.lines.length, lastLine.length + 1); violations.push(new Violation(this.id, 'Unclosed code block', Severity.ERROR, new Location(codeBlockStart + 1, 1), { range: { start: endOfFile, end: endOfFile }, text: '\n```', description: 'Close unclosed code block', })); } // Check list markers separately this.checkListConsistency(file, violations, fenced); } checkListConsistency(file, violations, fenced) { const listMarkers = new Set(); for (let i = 0; i < file.lines.length; i++) { if (fenced.has(i)) continue; // a YAML/Markdown '-' in a code sample is not a doc list const line = file.lines[i] ?? ''; // Check for list markers const listMatch = line.match(/^\s*([-*+]|\d+[.)])\s+/); if (listMatch) { const marker = listMatch[1]; if (marker) { const normalizedMarker = marker.match(/\d/) ? 'numbered' : marker.charAt(0); listMarkers.add(normalizedMarker); } } } // Check for consistent list markers const bulletMarkers = Array.from(listMarkers).filter(m => m !== 'numbered'); if (bulletMarkers.length > 1) { violations.push(new Violation(this.id, `Inconsistent list markers found: ${bulletMarkers.join(', ')}. Use consistent markers throughout.`, Severity.WARNING, new Location(1, 1))); } } checkListFormatting(file) { for (let i = 0; i < file.lines.length; i++) { const line = file.lines[i] ?? ''; // Check numbered list formatting - placeholder for future enhancements const numberedMatch = line.match(/^\s*(\d+)([.)])\s*/); if (numberedMatch) { // This could be extended to check for consistent separator usage } } } checkEndOfFile(file, violations) { if (file.content.length > 0 && !file.content.endsWith('\n')) { const lastLineIndex = file.getLineCount() - 1; const lastLine = file.lines[lastLineIndex] ?? ''; const at = new Location(file.getLineCount(), lastLine.length + 1); violations.push(new Violation(this.id, 'File should end with a newline', Severity.WARNING, at, { range: { start: at, end: at }, text: '\n', description: 'Add final newline', })); } } } //# sourceMappingURL=FormatRule.js.map