Custom Rules Guide
Note: For contributing built-in rules, see the Contributing Guide.
claudelint allows you to define custom validation rules to extend the built-in rule set with your own team-specific or project-specific requirements.
Quick Start
- Create a
.claudelint/rules/directory in your project root - Add a custom rule file (
.tsor.js) - Export a
ruleobject that implements the Rule interface - Run
claudelint check-allto load and execute your custom rules
Example custom rule that validates SKILL.md files have cross-references:
// .claudelint/rules/require-skill-see-also.ts
import type { Rule } from 'claude-code-lint';
import { hasHeading } from 'claudelint/utils';
export const rule: Rule = {
meta: {
id: 'require-skill-see-also',
name: 'Require Skill See Also',
description: 'SKILL.md must have a ## See Also section for cross-referencing related skills',
category: 'Skills',
severity: 'warn',
fixable: false,
// since is optional for custom rules
},
validate: async (context) => {
if (!context.filePath.endsWith('SKILL.md')) {
return;
}
if (!hasHeading(context.fileContent, 'See Also', 2)) {
context.report({
message: 'Missing ## See Also section',
line: 1,
fix: 'Add a ## See Also section linking to related skills',
});
}
},
};This is a real rule from claudelint's own
.claudelint/rules/directory. All examples in this guide come from working, tested rules that run in CI.
Directory Structure
Custom rules are automatically discovered in the .claudelint/rules/ directory:
your-project/
├── .claudelint/
│ └── rules/
│ ├── team-rule.ts
│ ├── project-rule.ts
│ └── conventions/
│ └── naming-rule.ts
├── CLAUDE.md
└── .claudelintrc.jsonKey features:
- Rules can be organized in subdirectories
- Both
.tsand.jsfiles are supported .d.ts,.test.ts, and.spec.tsfiles are automatically excluded- Rules are loaded recursively from all subdirectories
Rule Interface
Every custom rule must implement the Rule interface:
interface Rule {
meta: RuleMetadata;
validate: (context: RuleContext) => Promise<void> | void;
}Rule Metadata
The meta object describes your rule:
interface RuleMetadata {
id: string; // Unique identifier (e.g., 'no-profanity')
name: string; // Human-readable name
description: string; // What the rule checks
category: RuleCategory; // Must be a valid category (see Valid Categories below)
severity: 'off' | 'warn' | 'error'; // Default severity level
fixable: boolean; // Whether rule can auto-fix violations
deprecated?: boolean; // Mark rule as deprecated
since: string; // Version when rule was introduced
}Important: Rule IDs must be unique across all custom rules and built-in rules. If a custom rule ID conflicts with an existing rule, the loader will reject it.
Valid Categories
Custom rules must use one of the built-in categories. The category determines which validator executes your rule. The loader rejects rules with invalid categories and lists the valid options in the error message.
| Category | Description |
|---|---|
CLAUDE.md | Rules targeting CLAUDE.md configuration files |
Skills | Rules for skill definitions (SKILL.md) |
Settings | Rules for settings files |
Hooks | Rules for hook configurations |
MCP | Rules for MCP server configurations |
Plugin | Rules for plugin manifests |
Commands | Rules for command definitions |
Agents | Rules for agent definitions |
OutputStyles | Rules for output style configurations |
LSP | Rules for LSP server configurations |
Validation Function
The validate function receives a RuleContext and reports issues:
interface RuleContext {
filePath: string; // Absolute path to file being validated
fileContent: string; // Full content of the file
options: Record<string, unknown>; // Rule-specific options from config
report: (issue: RuleIssue) => void; // Report a validation issue
}
interface RuleIssue {
message: string; // Description of the issue
line?: number; // Line number (optional)
fix?: string; // Quick fix suggestion (optional)
autoFix?: AutoFix; // Automatic fix (optional)
}Examples
Each example below is a working rule from claudelint's .claudelint/rules/ directory. These rules validate the project's own files and run on every CI build.
Pattern matching with line reporting
Source: .claudelint/rules/no-user-paths.ts
Detects hardcoded user-specific paths (/Users/name/, /home/name/, C:\Users\) with precise line numbers:
import type { Rule } from 'claude-code-lint';
import { findLinesMatching } from 'claudelint/utils';
const USER_PATH_PATTERN = /(?:\/Users\/|\/home\/|C:\\Users\\)[^\s/\\]+/;
export const rule: Rule = {
meta: {
id: 'no-user-paths',
name: 'No User Paths',
description: 'CLAUDE.md must not contain hardcoded user-specific paths',
category: 'CLAUDE.md',
severity: 'warn',
fixable: false,
// since is optional for custom rules
},
validate: async (context) => {
if (!context.filePath.endsWith('CLAUDE.md')) {
return;
}
// Use contentWithoutCode to avoid false positives in code examples
const content = context.contentWithoutCode ?? context.fileContent;
const matches = findLinesMatching(content, USER_PATH_PATTERN);
for (const match of matches) {
context.report({
message: `Hardcoded user path: ${match.match}`,
line: match.line,
fix: 'Use a relative path or environment variable instead',
});
}
},
};Key techniques:
contentWithoutCodestrips fenced code blocks to avoid false positivesfindLinesMatching()returns{ line, match }pairs for precise line reporting- Early return on wrong file type
Auto-fix
Custom rules can provide automatic fixes that users apply with the --fix flag. Include an autoFix object in your context.report() call:
interface AutoFix {
ruleId: string; // Must match your rule's meta.id
description: string; // Human-readable description of the fix
filePath: string; // Path to file being fixed (use context.filePath)
range: [number, number]; // Character offsets [start, end) to replace
text: string; // Replacement text
}Fixes use character-range edits (inspired by ESLint's fix format):
range: [start, end]— 0-based character offsets, start inclusive, end exclusivetext— replacement text to insert at the range- Insertion: use
range: [pos, pos](zero-length range) with non-emptytext - Deletion: use a range spanning the text to remove with
text: '' - Replacement: use a range spanning the old text with
textset to the new text
Source: .claudelint/rules/normalize-code-fences.ts
This rule detects bare code fences (``` without a language) and auto-fixes them by replacing each bare ``` with ```text:
import type { Rule } from 'claude-code-lint';
export const rule: Rule = {
meta: {
id: 'normalize-code-fences',
// ...
fixable: true, // Required for auto-fix
// since is optional for custom rules
},
validate: async (context) => {
const { fileContent, filePath } = context;
const lines = fileContent.split('\n');
let inCodeBlock = false;
let offset = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (inCodeBlock) {
if (/^```\s*$/.test(line)) {
inCodeBlock = false;
}
offset += line.length + 1;
continue;
}
if (/^```\s*$/.test(line)) {
inCodeBlock = true;
const fenceStart = offset;
const fenceEnd = offset + line.trimEnd().length;
context.report({
message: 'Code fence missing language identifier',
line: i + 1,
fix: 'Add a language (e.g. ```bash, ```typescript, ```text)',
autoFix: {
ruleId: 'normalize-code-fences',
description: 'Add "text" language to bare code fence',
filePath,
range: [fenceStart, fenceEnd],
text: '```text',
},
});
} else if (/^```\w/.test(line)) {
inCodeBlock = true;
}
offset += line.length + 1;
}
},
};Key techniques:
- Set
fixable: truein meta when providingautoFix - Track a character
offsetwhile iterating lines to compute ranges - Each violation gets its own fix with a precise range — multiple fixes on the same file are applied together, sorted by position
- Overlapping fixes are automatically skipped (second fix dropped)
Using auto-fix
Run claudelint with the --fix flag to apply automatic fixes:
# Preview fixes (dry-run)
claudelint check-all --fix --dry-run
# Apply fixes
claudelint check-all --fixAuto-fix best practices
- Always mark fixable rules: Set
fixable: truein meta when providing autoFix - Make fixes idempotent: Running the fix multiple times should produce the same result
- Use precise ranges: Compute exact character offsets for the text being replaced
- One fix per violation: Each
context.report()should target one specific edit - Use simple transformations: Complex fixes are better done manually
Configurable options with Zod
Rules can accept user-configurable options via meta.schema (a Zod schema) and meta.defaultOptions.
Source: .claudelint/rules/max-section-depth.ts
This rule limits heading depth to keep documents flat and scannable:
import { z } from 'zod';
import type { Rule } from 'claude-code-lint';
import { extractHeadings } from 'claudelint/utils';
const optionsSchema = z.object({
maxDepth: z.number().int().min(1).max(6).optional(),
});
export const rule: Rule = {
meta: {
id: 'max-section-depth',
name: 'Max Section Depth',
description: 'CLAUDE.md headings must not exceed a configurable depth',
category: 'CLAUDE.md',
severity: 'warn',
fixable: false,
// since is optional for custom rules
schema: optionsSchema,
defaultOptions: {
maxDepth: 4,
},
},
validate: async (context) => {
if (!context.filePath.endsWith('CLAUDE.md')) {
return;
}
const maxDepth = (context.options.maxDepth as number) ?? 4;
const headings = extractHeadings(context.fileContent);
for (const heading of headings) {
if (heading.level > maxDepth) {
context.report({
message: `Heading "${'#'.repeat(heading.level)} ${heading.text}" exceeds max depth ${maxDepth}`,
line: heading.line,
fix: `Restructure to use heading level ${maxDepth} or shallower`,
});
}
}
},
};Users configure options in .claudelintrc.json using the array syntax:
{
"rules": {
"max-section-depth": ["warn", { "maxDepth": 3 }]
}
}Key techniques:
- Define
schemawith Zod for type-safe validation of user options defaultOptionsprovides fallbacks when user doesn't configure- Access options via
context.optionsin the validate function
Configuration
Custom rules can be configured in .claudelintrc.json:
{
"rules": {
"require-skill-see-also": "warn",
"no-user-paths": "error",
"normalize-code-fences": "off"
}
}Severity levels:
"error"- Treat violations as errors (exit code 2)"warn"- Treat violations as warnings"off"- Disable the rule
Loading Behavior
Custom rules are loaded automatically when you run claudelint check-all:
- claudelint searches for
.claudelint/rules/in the project root - All
.tsand.jsfiles are discovered recursively - Each file is loaded and validated
- Rules are registered with the rule registry
- Configured rules are executed during validation
Load Results
If a custom rule fails to load, you'll see an error message:
Failed to load custom rule: .claudelint/rules/broken-rule.ts
Error: Rule does not implement Rule interface (must have meta and validate)Common load failures:
- Missing
ruleexport - Invalid rule interface (missing
metaorvalidate) - Rule ID conflicts with existing rule
- Syntax errors in rule file
Best Practices
Descriptive IDs and names
// Good
meta: {
id: 'no-todo-comments',
name: 'No TODO Comments',
}
// Bad
meta: {
id: 'rule1',
name: 'Rule',
}Helpful error messages
// Good
context.report({
message: 'Found TODO comment on line 42. Please create a GitHub issue instead.',
line: 42,
});
// Bad
context.report({
message: 'Invalid',
});Focused rules
Each rule should check one thing. Don't combine multiple validations into a single rule.
Handle edge cases
validate: async (context) => {
// Check if file is relevant
if (!context.filePath.endsWith('.md')) {
return;
}
// Handle empty files
if (!context.fileContent.trim()) {
return;
}
// Your validation logic...
}Appropriate severity
error- For violations that must be fixed (security, breaking conventions)warn- For suggestions or style preferences
Test your rules
Create test cases for your custom rules. See the dogfood rule tests for a tested pattern using a collectIssues helper:
import { rule } from './.claudelint/rules/my-rule';
async function collectIssues(rule, filePath, fileContent) {
const issues = [];
await rule.validate({
filePath,
fileContent,
options: {},
report: (issue) => issues.push(issue),
});
return issues;
}
// Test violation detection
const issues = await collectIssues(rule, '/test/SKILL.md', 'content missing required section');
expect(issues).toHaveLength(1);
// Test clean input passes
const clean = await collectIssues(rule, '/test/SKILL.md', 'content with required section');
expect(clean).toHaveLength(0);
// Test file type filtering
const skipped = await collectIssues(rule, '/test/README.md', 'wrong file type');
expect(skipped).toHaveLength(0);Helper Library
claudelint provides utility functions for common validation tasks like heading detection, pattern matching, frontmatter parsing, and file system operations.
See the Helper Library Reference for the complete API with examples.
Advanced Topics
File Type Filtering
Only validate specific file types:
validate: async (context) => {
// Only check markdown files
if (!context.filePath.endsWith('.md')) {
return;
}
// Your validation logic...
}Multi-line matching
validate: async (context) => {
// Find code blocks
const codeBlockPattern = /```[\s\S]*?```/g;
const matches = context.fileContent.matchAll(codeBlockPattern);
for (const match of matches) {
// Validate code block content...
}
}Line Number Calculation
validate: async (context) => {
const lines = context.fileContent.split('\n');
lines.forEach((line, index) => {
if (someCondition(line)) {
context.report({
message: 'Violation found',
line: index + 1, // Lines are 1-indexed
});
}
});
}Further Reading
- Helper Library Reference - Utility functions for custom rules
- Architecture Documentation - How custom rules fit into claudelint
- Built-in Rules - Examples of rule implementations
- Contributing Guide - How to contribute rules to claudelint
Support
If you encounter issues with custom rules:
- Check the Troubleshooting guide
- Review example rules in
.claudelint/rules/ - Open an issue on GitHub