Custom Rules¶
Create custom validation rules by extending the Rule base class.
Custom rules live as .py files inside one repository — to share the
same rules across many repositories (or publish them on PyPI), package
them as a rule plugin instead; the rule-writing API is
identical.
Custom rules use the lint tree — the same typed data structure that
built-in rules operate on — to discover files instead of walking the
filesystem directly. Run skillsaw tree to see what nodes your repo
contains (see Lint Tree for details).
Let an LLM write your rule
Point your AI coding assistant at the skillsaw repo and these docs, then describe what you want to check — it can produce a working custom rule in a single prompt.
Example: flag TODO comments in instruction files¶
This rule finds every instruction file node in the tree (CLAUDE.md,
AGENTS.md, .cursorrules, etc.), reads its content, and reports a
violation for each TODO or FIXME it finds — with line numbers.
It also supports deterministic autofix to remove those lines.
import re
from typing import List
from skillsaw import Rule, RuleViolation, Severity, RepositoryContext
from skillsaw import AutofixResult, AutofixConfidence
from skillsaw.blocks import InstructionBlock
class NoTodoInInstructionsRule(Rule):
"""Instruction files should not contain TODO/FIXME comments."""
autofix_confidence = AutofixConfidence.SAFE
@property
def rule_id(self) -> str:
return "no-todo-instructions"
@property
def description(self) -> str:
return "Instruction files should not contain TODO/FIXME comments"
def default_severity(self) -> Severity:
return Severity.WARNING
def check(self, context: RepositoryContext) -> List[RuleViolation]:
violations = []
pattern = re.compile(r"\bTODO\b|\bFIXME\b")
for block in context.lint_tree.find(InstructionBlock):
content = block.read_body(strip_code_blocks=False)
if content is None:
continue
for i, line in enumerate(content.splitlines(), start=1):
if pattern.search(line):
violations.append(
self.violation(
f"Found TODO/FIXME: {line.strip()}",
file_path=block.path,
line=i,
)
)
return violations
def fix(
self,
context: RepositoryContext,
violations: List[RuleViolation],
) -> List[AutofixResult]:
by_file = {}
for v in violations:
by_file.setdefault(v.file_path, []).append(v)
results = []
for path, file_violations in by_file.items():
original = path.read_text(encoding="utf-8")
lines = original.splitlines(keepends=True)
remove = {v.line for v in file_violations if v.line}
fixed = "".join(
ln for i, ln in enumerate(lines, start=1) if i not in remove
)
if fixed != original:
results.append(
AutofixResult(
rule_id=self.rule_id,
file_path=path,
confidence=AutofixConfidence.SAFE,
original_content=original,
fixed_content=fixed,
description="Removed TODO/FIXME lines",
violations_fixed=file_violations,
)
)
return results
Then reference it in .skillsaw.yaml:
custom-rules:
- ./no_todo_instructions.py
rules:
no-todo-instructions:
enabled: true
severity: warning
Rule IDs¶
A custom rule's ID must not collide with a builtin, a legacy alias of a
renamed builtin (for example plugin-readme, now claude-plugin-readme),
or one of skillsaw's own advisory IDs (deprecated-rule). Aliases resolve
to the builtin everywhere a rule is named, so a custom rule using one could
never be configured or suppressed under its own ID; advisory IDs never
affect the exit code, so findings reported under one would not fail CI. A
rule claiming any of these is skipped with a plugin-load-error warning.
Prefix your IDs with something distinctive when in doubt
(acme-no-todo).
Key concepts¶
| Concept | What the example shows |
|---|---|
| Tree discovery | context.lint_tree.find(InstructionBlock) returns only instruction-file nodes — no manual glob needed. |
| Node types | Import the block type you need from skillsaw.blocks. Common types: InstructionBlock, ClaudeMdBlock, CommandBlock, SkillBlock, AgentBlock. |
| Reading content | block.read_body() returns the file body. Use strip_code_blocks=False when you need the raw text. |
| Line numbers | Report line= on every violation so users can jump to the exact location. |
| Autofix | Override fix() and return AutofixResult objects. Set autofix_confidence on the class and match it in each result. |
| Side effects in fixes | fix() also runs for previews (fix --dry-run), so it must not mutate repository state itself. Put any state change (writing manifests, registries, etc.) in AutofixResult.on_apply — a callback invoked only when the fix is actually applied. |
For the full list of node types, see skillsaw.lint_target (structural nodes like PluginNode, SkillNode) and skillsaw.blocks (content blocks). The block types are also still re-exported from skillsaw.rules.builtin.content_analysis for backward compatibility.
Configuration¶
Custom rules can accept user-configurable parameters via config_schema:
Rule.setting() requires skillsaw 0.19.0 or newer. Each schema entry must be
a mapping with type, default, and description; declare every option the
rule reads, then read it with self.setting() so schema defaults and explicit
null values resolve consistently. Call setting() once per check() rather
than inside a per-block loop. Supported type names are list/array,
int/integer, float/number, bool/boolean, dict/object, and
str/string.
The universal keys enabled, severity, and exclude are reserved: read
enabled and severity through self.enabled / self.severity, and leave
exclude to the linter's per-rule filter. setting() raises KeyError for
them unless the rule declares the key in its own config_schema — then
setting() reads it normally, and the linter's list-of-strings shape check
for exclude still runs first.
class NoTodoInInstructionsRule(Rule):
config_schema = {
"patterns": {
"type": "list",
"default": ["TODO", "FIXME"],
"description": "Patterns to flag in instruction files",
},
}
def check(self, context: RepositoryContext) -> List[RuleViolation]:
patterns = self.setting("patterns")
pattern = re.compile("|".join(rf"\b{re.escape(p)}\b" for p in patterns))
# ... rest of check logic
Declaring a schema enables closed-world option validation: an undeclared key
is reported to the rule's users as invalid-config. During a partial schema
migration, set strict_options = False on the rule class; declared options
remain type-checked while additional keys are temporarily accepted. Remove
that escape hatch after every option is declared.
More examples¶
For a more complete example — including a config schema, promptfoo eval
validation, and test fixtures — see the
examples/custom-rules/
directory.