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 |
|---|---|
true or "block" |
Blocks the write: Claude must fix |
"warn" |
Allows write, logs violation to metrics |
"ignore" or false |
Rule disabled |
Built-in Rules
PHP Rules
| Rule ID | Pattern | Default | Description |
|---|---|---|---|
PHP001 |
^(abstract|readonly|)?\s*class\s |
block | Class must be final |
PHP002 |
public\s+function\s+__construct |
block | Constructor must be private |
PHP003 |
public\s+function\s+set[A-Z] |
block | No setters (use behavioral methods) |
PHP004 |
^<\?php without declare(strict_types=1) |
block | strict_types=1 required |
PHP005 |
new\s+\\DateTime\(\) |
block | Use Clock abstraction |
PHP006 |
catch\s*\([^)]+\)\s*\{\s*\} |
block | No empty catch blocks |
TypeScript Rules
| Rule ID | Pattern | Default | Description |
|---|---|---|---|
TS001 |
:\s*any\b |
block | No any type |
TS002 |
(?<!readonly\s)(?:public|private|protected)\s+\w+: |
warn | Properties should be readonly |
TS003 |
export default |
block | No default exports |
Architecture Rules
| Rule ID | Check | Default | Description |
|---|---|---|---|
LAYER001 |
Domain imports Infrastructure | block | Domain must not import Infrastructure |
LAYER002 |
Domain imports Application | block | Domain must not import Application |
LAYER003 |
Application imports Infrastructure | warn | Application should not import Infrastructure directly |
Short-Form Rules (.craft-config.yml)
Short-form rules reference built-in rule IDs:
rules:
php:
final_classes: true # PHP001 → block
private_constructors: true # PHP002 → block
no_setters: warn # PHP003 → warn
strict_types: true # PHP004 → block
no_datetime_direct: true # PHP005 → block
no_empty_catch: ignore # PHP006 → disabled
Long-Form Custom Rules (.craft-config.yml)
Define custom regex-based rules for your project:
custom_rules:
- id: "CUSTOM001"
name: "no-doctrine-find-by-id"
description: "Use Repository method instead of find()"
severity: "warn"
pattern: '->find\(\$id\)'
languages: ["php"]
message: "Use ->findById() repository method for better encapsulation"
- id: "CUSTOM002"
name: "no-hardcoded-urls"
description: "No hardcoded production URLs in code"
severity: "block"
pattern: 'https://api\.production\.com'
languages: ["php", "typescript"]
message: "Use environment variables for URLs"
Directory-Level Overrides (.craft-rules.yml)
Place a .craft-rules.yml in any directory:
# src/Legacy/.craft-rules.yml
# Relax rules for legacy code
rules:
php:
final_classes: ignore # Legacy classes are not final
private_constructors: ignore
strict_types: warn # Warn but don't block legacy files
# src/Tests/.craft-rules.yml
# Different rules for test files
rules:
php:
final_classes: ignore # Test classes don't need final
no_setters: ignore # Test builders use setters
custom_rules:
no-doctrine-find-by-id:
severity: ignore # Tests can use find() directly
Directory walking
The rules engine walks up the directory tree from the current file, collecting .craft-rules.yml files. The closest file to the edited file wins. Results are cached per directory for performance.
Rules Engine Public API
The rules engine exposes these shell functions for use in hooks:
# Initialize the engine (load all configs)
rules_init
# Get severity for a named rule (returns: block|warn|ignore)
rules_severity "php.final_classes"
# → "block"
# Get severity for a rule, scoped to a specific file
rules_severity_for_file "php.final_classes" "/path/to/file.php"
# → "ignore" (if file is in a directory with .craft-rules.yml that disables it)
# List all custom rules
rules_custom_list
# → CUSTOM001 CUSTOM002
# Get the regex pattern for a custom rule
rules_pattern "CUSTOM001"
# → '->find\(\$id\)'
# Get the message for a custom rule
rules_message "CUSTOM001"
# → "Use ->findById() repository method..."
Validation Flow
On every Write or Edit tool call:
File write triggered
↓
Level 1: Regex validation (<50ms)
→ rules_severity_for_file() for each applicable rule
→ Regex match against file content
→ BLOCK if severity=block and match found
↓
Level 2: Static analysis (<2s)
→ PHPStan (PHP files)
→ ESLint (TypeScript files)
→ BLOCK on errors
↓
Level 3: Architecture check (<2s)
→ deptrac (layer violations)
→ dependency-cruiser (TypeScript)
→ BLOCK on violations
↓
Metrics recorded (SQLite)
→ violations count
→ rule IDs triggered
→ file path (anonymized)
Performance
Level 1 (regex) runs on every write in <50ms. Levels 2 and 3 are skipped if the file hasn’t changed significantly (content hash comparison). Total overhead: <3 seconds for a typical write.
Disable Rules Inline
For specific lines or blocks, use craftsman-ignore comments:
// craftsman-ignore-next-line PHP001
class LegacyAdapter implements ThirdPartyInterface
{
// ...
}
// craftsman-ignore-next-line TS003
export default function handler() {} // Next.js requires default export