Rules Engine
3-level rule inheritance system: Global → Project → Directory. Built-in rules, custom rules, and severity levels.
Overview
The Rules Engine (hooks/lib/rules-engine.sh) is the core of Craftsman’s validation system. It implements a 3-level configuration hierarchy with file-based key-value storage (Bash 3.2 compatible, no associative arrays).
Inheritance order (highest wins):
Directory (.craft-rules.yml)
↑ overrides
Project (.craft-config.yml)
↑ overrides
Global (~/.claude/.craft-config.yml)
Severity Levels
| Value | Behavior |
|---|---|
block |
Refuses the write: the agent must fix it |
warn |
Allows the write, reports the finding, records it in metrics |
ignore |
Rule disabled |
These three words are the only values the engine reads. Where a rule lands also depends on strictness:
strictness |
Effect |
|---|---|
strict |
Every rule at its declared default |
moderate |
LAYER* and SEC* keep their default; every other rule warns |
relaxed |
Every rule warns, nothing blocks |
With no strictness set, the plugin derives one: strict for a repository under 20 commits or one with a committed baseline (.craftsman-baseline.json), moderate otherwise. A finding already present at the baseline is reported as a warning instead of blocking. Some rules are demoted to a warning in test paths (relaxed_in_tests in their manifest): a credential in a fixture is a fixture.
Built-in Rules
The engine holds no rule of its own in code. Each rule is declared once, with its wording and its default severity, in the manifest that owns it: rules/core.yml for the rules every language shares, each pack’s pack.yml (under rules.owned) for the rest.
Core rules (rules/core.yml)
| Rule ID | Default | What it refuses |
|---|---|---|
LAYER001 |
block | Domain importing Infrastructure |
LAYER002 |
block | Domain importing Presentation |
LAYER003 |
block | Application importing Presentation |
LAYER004 |
block | Raw SQL or DQL inside the Domain |
DB001 |
warn | SELECT * |
DB002 |
warn | A migration up() without a tested down() |
DB003 |
warn | A query inside a loop |
SEC001 |
block, no marker | A hardcoded secret |
SEC002 |
block, no marker | eval or a shell call fed by a value you did not author |
SEC003 |
block, no marker | SQL built by concatenation or template literal |
SEC004 |
block | An HTML sink fed by an unescaped value |
GOD001 |
warn | A class spanning too many lines |
LOC001 |
warn | A function body past the limit |
NEST001 |
warn | Control flow nested three levels deep |
PARAM001 |
warn | More than three parameters |
RATCHET001 |
warn | A structural regression on a touched file |
ANALYSER001 |
warn | A Level 2 or 3 analyser that crashed or ran out of budget, so the file was not fully analysed |
From 4.14.0, SEC001 also covers JavaScript and Python files and knows recent issuer prefixes (Anthropic sk-ant-, OpenAI sk-proj-, GitLab glpat-, npm npm_ and others), from one list every pack reads, hooks/lib/secret-patterns.sh. Upgrading can refuse a file that 4.13.1 let through.
Symfony pack (PHP)
| Rule ID | Default | What it asks for |
|---|---|---|
PHP001 |
block | declare(strict_types=1) in every PHP file |
PHP002 |
block | Every class is final |
PHP003 |
warn | No public setter: use behavioral methods |
PHP004 |
block | No new DateTime(): inject a Clock |
PHP005 |
warn | No empty catch block |
CTRL001 |
warn | A Controller performs no persistence: move it into an Application UseCase |
WARN-PHP001 |
warn | Four or more parameters: consider an object |
React pack (TypeScript and JavaScript)
| Rule ID | Default | What it asks for |
|---|---|---|
TS001 |
block | No any: use a precise type or unknown |
TS002 |
warn | No default export: use named exports |
TS003 |
warn | No non-null assertion (!) |
WARN-TS001 |
warn | Four or more parameters: consider an object |
Other packs
The Python (PY001 to PY007), Bash (SH001 to SH005), Go (GO001 to GO006, ERRCHECK001) and Rust (RUST001 to RUST005, CLIPPY001) packs declare their rules the same way, each with a WARN- advisory rule. Their pack.yml is the reference.
Short-Form Overrides (.craft-config.yml)
Set a rule’s severity by its ID:
rules:
PHP002: warn # final classes reported, not refused
TS002: ignore # default exports allowed in this project
Long-Form Custom Rules (.craft-config.yml)
Define a regex rule of your own under the same rules: key:
rules:
CUSTOM001:
pattern: "dd\\("
message: "No dd() in production code"
severity: block
languages: [php]
Directory-Level Overrides (.craft-rules.yml)
Place a .craft-rules.yml in any directory. It holds a rules: section only:
# src/Legacy/.craft-rules.yml
rules:
PHP001: warn # inherited files without strict_types report, not block
PHP002: ignore # legacy classes are not final
Directory walking
The engine walks up from the edited file’s directory to the project root and uses the nearest .craft-rules.yml that sets the rule. A directory override wins over the project and global files, and over the test-path relaxation.
Rules Engine Public API
The rules engine exposes these shell functions for use in hooks:
source "hooks/lib/rules-engine.sh"
rules_init "$project_dir" # load global, project and directory config
rules_severity "PHP001" # block|warn|ignore
rules_severity_for_file "src/Infra/Repo.php" "PHP001"
rules_custom_list "php" # CUSTOM001, one per line
rules_pattern "CUSTOM001" # its regex
rules_message "CUSTOM001" # its message
Validation Flow
On every write the host performs through its write tool (Claude Code Write/Edit, Codex apply_patch, Grok write/search_replace, Hermes write_file/patch when write_gate: on):
Pre-write hook, before the file exists
Level 1: regex, always on
-> severity resolved per file (global, project, directory)
-> BLOCK before disk when a rule resolves to block
↓
Post-write hook, on the file that landed
Level 1.5: LSP semantics, when the language server is installed
Level 2: static analysis, opt-in per machine (trust_project_tools)
-> PHPStan, ESLint, clippy ...
Level 3: architecture, opt-in per machine
-> deptrac, dependency-cruiser; a verdict supersedes the matching Level 1 rule
-> findings handed back to the agent as a correction to answer for
↓
Metrics recorded (SQLite, in this host and session's own store)
Performance
The cost of Level 1 is measured on every release by the plugin’s latency benchmark (tests/perf/test-hook-latency.sh), not promised. Levels 2 and 3 run under a budget of 15 seconds per file and 30 seconds per project. A timeout, a crash or an analyser configured to ignore a rule is not a clean verdict: the Level 1 finding it would have replaced is reported instead.
Disable Rules Inline
Name the rule in a craftsman-ignore comment, with the reason:
// craftsman-ignore: PHP002 (debt: a third-party library extends this adapter)
class LegacyAdapter implements ThirdPartyInterface
{
// ...
}
// craftsman-ignore: TS002 (wrong: Next.js requires a default export here)
export default function handler() {}
Several rules go in one marker, comma-separated: craftsman-ignore: PHP001, PHP002. The reason, (wrong: ...) when the rule is wrong here or (debt: ...) when the code stays as it is, is what the refusal asks for, and the metrics record it as a verdict on the rule. SEC001, SEC002 and SEC003 honour no marker, on any front-end: fix the code.