@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
287 lines • 12.2 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';
/**
* Rule that validates CLAUDE.md content organization and quality
*
* @remarks
* Enforces Anthropic's best practices for content organization:
* - Be specific: "Use 2-space indentation" vs "Format code properly"
* - Use structure: Bullet points and markdown headings
* - Clear instructions: Avoid vague language
* - Emphasis for critical items: IMPORTANT, YOU MUST, etc.
*
* This rule focuses on content quality rather than technology-specific patterns.
*
* @see {@link https://docs.claude.com/en/docs/claude-code/memory#memory-best-practices | Memory best practices}
*
* @category Rules
*/
export class ContentOrganizationRule {
id = 'content-organization';
description = 'Validates content organization and specificity following Anthropic best practices';
vaguePhrases = new Set([
'properly',
'correctly',
'appropriately',
'well',
'good',
'bad',
'better',
'best',
'nice',
'clean',
'neat',
]);
emphasisMarkers = new Set([
'IMPORTANT',
'YOU MUST',
'REQUIRED',
'CRITICAL',
'ALWAYS',
'NEVER',
]);
appliesTo(file) {
return file.isMarkdown();
}
lint(file) {
const violations = [];
this.checkHeadingHierarchy(file, violations);
this.checkBulletPointUsage(file, violations);
this.checkVagueLanguage(file, violations);
this.checkEmphasisUsage(file, violations);
this.checkEmphasisOveruse(file, violations);
this.checkSpecificity(file, violations);
return violations;
}
/**
* Check heading hierarchy (h1 → h2 → h3, no skipping levels)
*/
checkHeadingHierarchy(file, violations) {
let previousLevel = 0;
let lineNumber = 0;
for (const line of file.lines) {
lineNumber++;
const match = line.match(/^(#{1,6})\s/);
if (match) {
const hashes = match[1];
if (!hashes)
continue;
const currentLevel = hashes.length;
// Check if we're skipping heading levels (e.g., h1 → h3)
if (previousLevel > 0 && currentLevel > previousLevel + 1) {
violations.push(new Violation(this.id, `Heading hierarchy skips from h${previousLevel} to h${currentLevel}. Use h${previousLevel + 1} instead.`, Severity.WARNING, new Location(lineNumber, 1)));
}
previousLevel = currentLevel;
}
}
}
/**
* Check that sections use bullet points for organization
*/
checkBulletPointUsage(file, violations) {
let currentSection = '';
let sectionStart = 0;
let hasBullets = false;
let contentLines = 0;
for (let i = 0; i < file.lines.length; i++) {
const line = file.lines[i] ?? '';
// Track section headings
const headerMatch = line.match(/^(#{1,6})\s+(.+)/);
if (headerMatch) {
// Check previous section
if (currentSection &&
contentLines > 3 &&
!hasBullets &&
!this.isCodeBlockSection(file.lines, sectionStart, i)) {
violations.push(new Violation(this.id, `Section "${currentSection}" has ${contentLines} lines but no bullet points. Use bullet points for better organization.`, Severity.INFO, new Location(sectionStart, 1)));
}
// Start new section
currentSection = headerMatch[2] ?? '';
sectionStart = i + 1;
hasBullets = false;
contentLines = 0;
continue;
}
// Check for bullet points
if (/^\s*[-*+]\s/.test(line)) {
hasBullets = true;
}
// Count non-empty lines
if (line.trim() !== '') {
contentLines++;
}
}
}
/**
* Detect vague language that should be more specific.
*
* Tracks fenced code-block state so vague terms inside a block
* are ignored. Previously only the fence delimiter line itself
* was skipped, letting vague terms in code examples generate
* false positives.
*/
checkVagueLanguage(file, violations) {
let inCodeBlock = false;
for (let i = 0; i < file.lines.length; i++) {
const line = file.lines[i] ?? '';
// Track fenced code-block state and skip the fence line itself.
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
continue;
}
if (inCodeBlock) {
continue;
}
for (const vague of this.vaguePhrases) {
const regex = new RegExp(`\\b${vague}\\b`, 'i');
if (regex.test(line)) {
const suggestion = this.getSuggestionForVagueTerm(vague, line);
violations.push(new Violation(this.id, `Vague term "${vague}" detected. ${suggestion}`, Severity.INFO, new Location(i + 1, line.toLowerCase().indexOf(vague.toLowerCase()) + 1)));
}
}
}
}
/**
* Check for proper use of emphasis markers
*/
checkEmphasisUsage(file, violations) {
let hasEmphasis = false;
const criticalKeywords = [
'must',
'always',
'never',
'required',
'critical',
];
for (let i = 0; i < file.lines.length; i++) {
const line = file.lines[i] ?? '';
// Check if line has emphasis markers
for (const marker of this.emphasisMarkers) {
if (line.includes(marker)) {
hasEmphasis = true;
break;
}
}
// Check if line contains critical keywords without emphasis
const lowerLine = line.toLowerCase();
for (const keyword of criticalKeywords) {
if (lowerLine.includes(keyword)) {
// Check if it's already emphasized (uppercase or bold)
const isEmphasized = line.includes(keyword.toUpperCase()) ||
line.includes(`**${keyword}**`) ||
line.includes(`*${keyword}*`);
if (!isEmphasized && !line.trim().startsWith('#')) {
violations.push(new Violation(this.id, `Critical instruction contains "${keyword}" but lacks emphasis. Consider using "${keyword.toUpperCase()}" or **${keyword}** for important requirements.`, Severity.INFO, new Location(i + 1, 1)));
}
}
}
}
// Suggest adding emphasis if file has no emphasis markers
if (!hasEmphasis && file.lines.length > 20) {
violations.push(new Violation(this.id, 'File contains no emphasis markers (IMPORTANT, YOU MUST, etc.). Consider adding emphasis to critical instructions.', Severity.INFO, new Location(1, 1)));
}
}
/**
* Check for overuse of emphasis markers (reduces effectiveness)
*/
checkEmphasisOveruse(file, violations) {
const emphasisLines = [];
const totalLines = file.lines.length;
for (let i = 0; i < file.lines.length; i++) {
const line = file.lines[i] ?? '';
// Check if line has emphasis markers
for (const marker of this.emphasisMarkers) {
if (line.includes(marker)) {
emphasisLines.push(i + 1);
break;
}
}
}
// Calculate emphasis ratio
const emphasisRatio = emphasisLines.length / totalLines;
// Warn if >20% of lines have emphasis
if (emphasisRatio > 0.2) {
violations.push(new Violation(this.id, `${Math.round(emphasisRatio * 100)}% of lines use emphasis markers (${emphasisLines.length}/${totalLines}). Overuse reduces effectiveness - reserve emphasis for truly critical instructions`, Severity.WARNING, new Location(1, 1)));
}
// Check for consecutive emphasis (reduces impact)
for (let i = 1; i < emphasisLines.length; i++) {
const currentLine = emphasisLines[i] ?? 0;
const previousLine = emphasisLines[i - 1] ?? 0;
if (currentLine - previousLine <= 2) {
violations.push(new Violation(this.id, `Consecutive lines with emphasis detected (lines ${previousLine}-${currentLine}). Space out emphasis to maintain impact`, Severity.INFO, new Location(currentLine, 1)));
}
}
}
/**
* Check for specific, measurable guidelines.
*
* The tool-name allowlist intentionally excludes "format" itself so
* sentences like "Use the appropriate format for code" still fire —
* previously the keyword "format" matched the allowlist regex and
* silently suppressed every format-related warning.
*/
checkSpecificity(file, violations) {
const formatInstructions = [
'format',
'indent',
'spacing',
'style',
'convention',
];
for (let i = 0; i < file.lines.length; i++) {
const line = file.lines[i] ?? '';
const lowerLine = line.toLowerCase();
for (const instruction of formatInstructions) {
if (lowerLine.includes(instruction)) {
// Check if it has specific measurements
const hasSpecifics = /\d+/.test(line) || // Has numbers
/spaces|tabs|characters|lines/.test(lowerLine) || // Has units
/\b(eslint|prettier|black|gofmt|rustfmt|biome|clang-format|ruff)\b/.test(lowerLine); // Has tool names — excludes the keyword itself
if (!hasSpecifics && !line.trim().startsWith('#')) {
violations.push(new Violation(this.id, `Instruction about "${instruction}" lacks specifics. Add measurements (e.g., "2-space", "80 characters") or tool names (e.g., "Prettier", "ESLint").`, Severity.INFO, new Location(i + 1, 1)));
}
}
}
}
}
/**
* Check if a section is primarily code blocks
*/
isCodeBlockSection(lines, start, end) {
let codeBlockLines = 0;
let inCodeBlock = false;
for (let i = start; i < end && i < lines.length; i++) {
const line = lines[i] ?? '';
if (line.trim().startsWith('```')) {
inCodeBlock = !inCodeBlock;
}
if (inCodeBlock) {
codeBlockLines++;
}
}
const totalLines = end - start;
return codeBlockLines / totalLines > 0.5; // More than 50% code blocks
}
/**
* Get suggestion for vague term based on context
*/
getSuggestionForVagueTerm(term, line) {
const lowerLine = line.toLowerCase();
if (term === 'properly' || term === 'correctly') {
return 'Be specific about the expected format or standard (e.g., "Use 2-space indentation" instead of "Format properly").';
}
if (term === 'well' || term === 'good') {
return 'Define what "good" means with measurable criteria.';
}
if (term === 'bad' || term === 'wrong') {
return "Explain why it's problematic and what the correct approach is.";
}
if (lowerLine.includes('format') || lowerLine.includes('style')) {
return 'Specify the exact format or style guide (e.g., "Follow Prettier defaults" or "Use 80-character line limit").';
}
return 'Be more specific about expectations and requirements.';
}
}
//# sourceMappingURL=ContentOrganizationRule.js.map