@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
226 lines • 8.66 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';
/**
* KarpathyRule — opinionated CLAUDE.md quality advisories.
*
* Heuristics inspired by Andrej Karpathy's public commentary on writing for
* LLMs and "context engineering": you program the model in English, so the
* context window should be minimal, high signal-to-noise, literal, and
* example-driven. This is a curated, opinionated ruleset — not an official
* standard — and every finding is INFO severity (a recommendation, not a
* failure).
*
* Checks:
* 1. Hedging language ("try to", "where appropriate") that makes an
* instruction non-literal — the model follows instructions literally, so
* ambiguity invites drift.
* 2. Filler / politeness ("please", "thank you", "you are a helpful
* assistant") that spends context tokens without adding signal.
* 3. Show, don't tell — guideline sections that list many rules but include
* no concrete example (few-shot beats zero-shot).
* 4. Signal-to-noise — overly long prose paragraphs; prefer tight,
* skimmable lines or bullets.
*
* Scope: CLAUDE.md files only. Code fences are excluded from prose checks.
*/
export class KarpathyRule {
id = 'karpathy';
description = 'Opinionated CLAUDE.md recommendations inspired by Karpathy: minimal, ' +
'high-signal, literal, example-driven context';
static HEDGING_PHRASES = [
'try to',
'where appropriate',
'as appropriate',
'where possible',
'when possible',
'if possible',
'as needed',
'as necessary',
'generally',
'usually',
'typically',
'ideally',
'more or less',
'and so on',
];
static FILLER_PHRASES = [
'please',
'kindly',
'thank you',
'feel free to',
'it is important to note',
'it should be noted',
'as a reminder',
'needless to say',
'you are a helpful assistant',
'as an ai language model',
];
static GUIDELINE_WORDS = [
'convention',
'guideline',
'rule',
'standard',
'style',
'instruction',
'workflow',
'practice',
'principle',
];
static MAX_PARAGRAPH_WORDS = 80;
static MAX_PARAGRAPH_CHARS = 600;
appliesTo(file) {
return file.isClaudeMarkdown();
}
lint(file) {
if (!KarpathyRule.isClaudeMd(file.path)) {
return [];
}
const lines = file.lines;
const inCode = KarpathyRule.markCodeFences(lines);
return [
...this.checkPhrases(lines, inCode, KarpathyRule.HEDGING_PHRASES, phrase => `Hedging phrase "${phrase}" weakens a literal instruction. ` +
`State the rule directly so the model follows it deterministically.`),
...this.checkPhrases(lines, inCode, KarpathyRule.FILLER_PHRASES, phrase => `Filler/politeness "${phrase}" spends context without signal. ` +
`Drop it — CLAUDE.md is instructions for a model, not prose for a person.`),
...this.checkExamples(lines, inCode),
...this.checkParagraphs(lines, inCode),
];
}
static isClaudeMd(path) {
return /(^|[/\\])CLAUDE\.md$/i.test(path);
}
/** Returns a boolean per line: true when the line sits inside a ``` fence. */
static markCodeFences(lines) {
const inCode = lines.map(() => false);
let open = false;
for (let i = 0; i < lines.length; i++) {
const isFence = /^\s*```/.test(lines[i] ?? '');
if (isFence) {
// The fence line itself is treated as code so it is never prose.
inCode[i] = true;
open = !open;
continue;
}
inCode[i] = open;
}
return inCode;
}
checkPhrases(lines, inCode, phrases, message) {
const violations = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (inCode[i])
continue;
const lower = (lines[i] ?? '').toLowerCase();
for (const phrase of phrases) {
if (seen.has(phrase))
continue;
if (KarpathyRule.containsPhrase(lower, phrase)) {
seen.add(phrase);
violations.push(new Violation(this.id, message(phrase), Severity.INFO, new Location(i + 1, 1)));
}
}
}
return violations;
}
static containsPhrase(haystackLower, phrase) {
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`\\b${escaped}\\b`, 'i').test(haystackLower);
}
/**
* Flag guideline-style sections (Conventions, Guidelines, Rules, …) that
* enumerate several rules but show no concrete example — show, don't tell.
*/
checkExamples(lines, inCode) {
const violations = [];
const headings = KarpathyRule.collectSections(lines);
for (const section of headings) {
const titleLower = section.title.toLowerCase();
const isGuideline = KarpathyRule.GUIDELINE_WORDS.some(w => new RegExp(`\\b${w}`, 'i').test(titleLower));
if (!isGuideline)
continue;
let bullets = 0;
let hasCode = false;
for (let i = section.bodyStart; i < section.bodyEnd; i++) {
if (inCode[i]) {
hasCode = true;
continue;
}
if (/^\s*([-*]|\d+\.)\s+/.test(lines[i] ?? ''))
bullets++;
}
if (bullets >= 4 && !hasCode) {
violations.push(new Violation(this.id, `Section "${section.title}" lists ${bullets} rules but shows no ` +
`example. Add a concrete example (show, don't tell) — few-shot ` +
`context steers the model better than abstract rules.`, Severity.INFO, new Location(section.headingLine, 1)));
}
}
return violations;
}
static collectSections(lines) {
const sections = [];
const headingIdx = [];
const inCode = KarpathyRule.markCodeFences(lines);
for (let i = 0; i < lines.length; i++) {
if (inCode[i])
continue;
const m = /^#{1,6}\s+(.*)$/.exec(lines[i] ?? '');
if (m)
headingIdx.push({ idx: i, title: (m[1] ?? '').trim() });
}
for (let h = 0; h < headingIdx.length; h++) {
const cur = headingIdx[h];
if (!cur)
continue;
const next = headingIdx[h + 1];
const end = next ? next.idx : lines.length;
sections.push({
title: cur.title,
headingLine: cur.idx + 1,
bodyStart: cur.idx + 1,
bodyEnd: end,
});
}
return sections;
}
/** Flag prose paragraphs that are too long to skim. */
checkParagraphs(lines, inCode) {
const violations = [];
let buffer = [];
let startLine = 0;
const flush = () => {
if (buffer.length === 0)
return;
const text = buffer.join(' ').trim();
const words = text.split(/\s+/).filter(Boolean).length;
if (words > KarpathyRule.MAX_PARAGRAPH_WORDS ||
text.length > KarpathyRule.MAX_PARAGRAPH_CHARS) {
violations.push(new Violation(this.id, `Paragraph is ${words} words. Tighten to high-signal lines or ` +
`bullets — dense prose buries the instruction.`, Severity.INFO, new Location(startLine, 1)));
}
buffer = [];
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i] ?? '';
const isProse = !inCode[i] &&
line.trim() !== '' &&
!/^#{1,6}\s+/.test(line) &&
!/^\s*([-*]|\d+\.)\s+/.test(line) &&
!/^\s*>/.test(line) &&
!/^\s*\|/.test(line);
if (isProse) {
if (buffer.length === 0)
startLine = i + 1;
buffer.push(line.trim());
}
else {
flush();
}
}
flush();
return violations;
}
}
//# sourceMappingURL=KarpathyRule.js.map