Skip to content
AI CraftsmanSUPERPOWERS

Rules Engine

3-level rule inheritance system: Global → Project → Directory. Built-in rules, custom rules, and severity levels.

Last updated: Edit on GitHub

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.