Chapter 11: Custom Quality Rules

⚠️ Superseded, as of pmat 3.32.0 (2026-08-25). pmat has no rules subcommand, and never has had one.

This chapter used to document a rule-authoring system: rules init, rules create, rules test, rules validate, rules install, rules publish, rules package, rules benchmark, rules profile, rules scope, rules debug, rules docs, rules optimize, rules archive, rules generate, rules test-suite, rules update — 29 examples in all, plus a YAML rule-definition language stored under .pmat/rules/, a custom_rules_directory key in pmat.toml, and rule categories for security, performance and team standards.

None of it ever shipped. Not “removed in a later version” — never written. There is no RulesCommands enum, no RulesArgs struct and no rules clap subcommand in any commit of pmat’s history, and the YAML files the chapter told you to create are read by nothing. The chapter’s own test script, tests/ch11/test_custom_rules.sh, wrote seven of those YAML files and then asserted only that the files it had just written existed. It never invoked pmat once, and it passed for a year.

The old status badge on this page claimed “✅ 100% Working (8/8 examples)”. It has been removed. A validation badge backed by a test that cannot fail is worse than no badge at all. tests/ch11/test_custom_rules.sh has been rewritten to run the real binary and assert on its real output — it now makes 16 assertions, every one of which has been checked to go red when the claim behind it is false.

What pmat 3.32.0 actually gives you is a configurable threshold system rather than a rule-authoring one: pmat quality-gates reading .pmat-gates.toml, and pmat quality-gate with threshold flags. Both are documented below, and every example was executed against pmat 3.32.0.

Project-specific quality enforcement in pmat: what you can configure, what you cannot author, and which command actually fails a build.

The Honest Summary

pmat does not let you write a new check. The set of things it knows how to measure is fixed and compiled in. What it does let you do is choose which of those checks run, and at what threshold, per project, from a file you commit.

For most teams that covers the real need — “complexity over 15 fails the build here, but 25 is fine in the legacy service” — without any rule language. If you genuinely need a bespoke pattern check, use a dedicated tool (ast-grep, semgrep, a clippy lint) and gate it separately; pmat will not do it, and no version of pmat ever could.

Two commands are involved, and the difference between them is not obvious from the names.

CommandConfig fileWhat it runsFails the build?
pmat quality-gates (plural).pmat-gates.tomlclippy, tests, coverage, complexityYes, exit 1
pmat quality-gate (singular)flags, .pmat-metrics.tomlcomplexity, dead code, SATD, entropy, security, duplicates, coverage, sections, provabilityYes, exit 1 on blocking violations

pmat quality-gates: a Config File You Commit

This is the closest thing pmat has to “custom rules”. Start by generating the file:

pmat quality-gates init
✅ Created .pmat-gates.toml

The generated file is a commented list of every knob there is:

# PMAT Quality Gate Configuration
# Generated by: pmat quality-gates init

[gates]
# Run clippy linter
run_clippy = true

# Enforce strict clippy (-D warnings)
clippy_strict = true

# Run test suite
run_tests = true

# Test timeout in seconds
test_timeout = 300

# Check code coverage
check_coverage = true

# Minimum coverage percentage (0-100)
min_coverage = 80.0

# Check cyclomatic complexity
check_complexity = true

# Maximum cyclomatic complexity per function
max_complexity = 10

That is the complete schema. Eight keys. There is no [rules] table, no per-language section, no severity levels and no pattern matching — if a key is not in the list above, pmat does not read it.

Reading the Effective Configuration

pmat quality-gates show
[gates]
run_clippy = false
clippy_strict = true
run_tests = false
test_timeout = 300
check_coverage = false
min_coverage = 80
check_complexity = true
max_complexity = 10

show prints the merged configuration — your file over the defaults — which is the value to check when a gate behaves unexpectedly rather than re-reading your TOML.

Validating It

pmat quality-gates validate
✅ Configuration is valid

Validation is a type check, not a semantic one. A misspelled value is caught:

Error: TOML parse error at line 2, column 18
  |
2 | max_complexity = "ten"
  |                  ^^^^^
invalid type: string "ten", expected u32

and the command exits 1. A misspelled key, however, is silently ignored — the same trap as any serde-backed config. show is how you catch that: if the key you just added does not appear in show’s output, nothing is reading it.

Running the Gates

A worked example. Create a crate with one moderately branchy function:

mkdir -p rules-demo/src && cd rules-demo
cat > Cargo.toml << 'EOF'
[package]
name = "rules-demo"
version = "0.1.0"
edition = "2021"
EOF
cat > src/lib.rs << 'EOF'
pub fn classify(n: i32) -> &'static str {
    if n < 0 {
        "negative"
    } else if n == 0 {
        "zero"
    } else if n < 10 {
        "small"
    } else if n < 100 {
        "medium"
    } else if n < 1000 {
        "large"
    } else {
        "huge"
    }
}
EOF
cat > .pmat-gates.toml << 'EOF'
[gates]
run_clippy = false
run_tests = false
check_coverage = false
check_complexity = true
max_complexity = 10
EOF

classify has cyclomatic complexity 6, so at a threshold of 10 the gate passes:

pmat quality-gates
Running complexity check...

✅ Quality Gate Results
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

✓ complexity (0.00s)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total time: 0.00s

Tighten max_complexity to 5 and it fails, naming the function and the measured value:

Running complexity check...

❌ Quality Gate Results
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

✗ complexity (0.00s)
  ✗ Complexity: classify in ./src/lib.rs has cyclomatic 6 (> 5); measured 1 function(s) in 1 of 1 file(s)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total time: 0.00s

The process exit code is 1. This is the property that matters for CI, and it is the property the old chapter’s fictional rule engine never had.

Note the denominator in that message — “measured 1 function(s) in 1 of 1 file(s)”. A gate that parsed none of your files would also report zero violations, so read the counts, not just the tick.

Machine-Readable Output

pmat quality-gates --json
{
  "gates": [
    {
      "name": "complexity",
      "passed": true,
      "duration": 3,
      "message": "✓ Complexity: max cyclomatic 6 (≤ 10); measured 1 function(s) in 1 of 1 file(s)"
    }
  ],
  "passed": true,
  "total_duration": 3,
  "timestamp": "2026-08-25T20:45:24.345444719+00:00"
}

and a markdown report for a PR comment:

pmat quality-gates --report
# Quality Gate Report

**Timestamp**: 2026-08-25T20:46:59.425961937+00:00

**Status**: ❌ FAIL

## Gate Results

- ✗ **complexity** (0.00s)
  ✗ Complexity: classify in ./src/lib.rs has cyclomatic 6 (> 5); measured 1 function(s) in 1 of 1 file(s)

**Total Time**: 0.00s

--json and --report apply only to a bare pmat quality-gates run; they are ignored when a subcommand (init, show, validate) is given.

Pointing at a Different File

pmat quality-gates --config ci/strict-gates.toml

This is how a repository keeps a lenient local profile and a strict CI one without branching logic in the workflow.

pmat quality-gate: the Broader Check Set

The singular command runs a different, wider set of analyses and is configured by flags rather than by .pmat-gates.toml.

pmat quality-gate -p .
🔍 Running quality gate checks...

📋 Checks to run:
  ✓ Complexity analysis
  ✓ Dead code detection
  ✓ Self-admitted technical debt (SATD)
  ✓ Security vulnerabilities
  ✓ Code entropy
  ✓ Duplicate code
  ✓ Test coverage

  🔍 Checking complexity...  ⚙️  Complexity thresholds: cyclomatic 30, cognitive 25 (from built-in defaults)
 0 violations found
  🔍 Checking dead code... 0 violations found
  🔍 Checking technical debt... 1 violations found
  🔍 Checking code entropy... 0 violations found
  🔍 Checking security... 0 violations found
  🔍 Checking duplicates... 0 violations found
  🔍 Checking test coverage... 1 violations found
  ...
Quality Gate: FAILED
Total violations: 2
Blocking violations: 1

Selecting Checks

--checks accepts a repeated flag or a comma-separated list, from a fixed vocabulary: dead-code, complexity, coverage, sections, provability, satd, entropy, security, duplicates, all.

pmat quality-gate -p . --checks complexity,satd

A name outside that list is a clap error, not a silent skip — which is the behaviour you want from a gate.

Thresholds

pmat quality-gate -p . --checks complexity \
    --max-complexity-p99 5 \
    --max-dead-code 5 \
    --min-entropy 0.0
Quality Gate: PASSED
Total violations: 0
Blocking violations: 0

--min-entropy also reads [entropy] min_pattern_diversity from .pmat-gates.toml, .pmat-metrics.toml or pmat.toml when the flag is omitted, falling back to 0.3.

Violations vs Blocking Violations

The two counters in the summary are not the same number, and only the second one decides the exit code. In the run above, satd contributed a violation and the command still exited 0; the full run exited 1 because of a coverage violation whose message is worth quoting:

- project - Code coverage was NOT measured (no coverage report at
  .pmat/coverage-cache.json or .pmat-metrics/coverage.json), so the 80.0%
  minimum is unverified — this gate does not cover coverage

That is the right behaviour: an unmeasured metric fails rather than passing quietly. If you want the report without the verdict — for a dashboard, or while you are still driving the number down — use:

pmat quality-gate -p . --report-only

which always exits 0. (--fail-on-violation still parses but has done nothing since 3.32.0, when blocking violations began exiting non-zero by default.)

Output Formats

-f accepts summary, detailed, human, json, junit and markdown. junit is the one to reach for in CI:

pmat quality-gate -p . -f junit -o quality-gate.xml
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="Quality Gate">
  <testsuite name="Quality Checks" tests="2" failures="2">
    <testcase name="Code coverage was NOT measured ..." classname="coverage">
      <failure message="Code coverage was NOT measured ..." type="error"/>
    </testcase>
    ...
  </testsuite>
</testsuites>

One <testcase> is emitted per violation, not per check — so a clean run produces tests="0" failures="0", which several CI systems read as “no tests ran” rather than “everything passed”. Gate your job on the exit code and use the XML for annotations, not the other way round.

Rule-ID Compliance: pmat comply

For repositories that adopt pmat’s own conventions there is a third layer, pmat comply, whose checks carry stable rule identifiers (CB-1400, CB-2102, …) and whose baselines live in .pmat-ratchet.toml. Its subcommands are listed by:

pmat comply --help

Two caveats before you wire it into anything. First, pmat comply check is expensive — it can saturate every core on the machine, so do not run it in parallel across repositories or inside another build. Second, a large share of its checks only have anything to measure once pmat’s own local state exists under .pmat/, so failure counts are not comparable between machines. Treat it as a project-adoption tool, not as a general-purpose linter.

What You Still Cannot Do

Being explicit, because the old chapter promised all of it:

  • Author a new check. The check set is compiled into the binary.
  • Write a rule in YAML. .pmat/rules/*.yaml is read by nothing.
  • Set per-rule severity. A gate passes or fails; there are no levels.
  • Match a code pattern (regex or AST) of your own devising.
  • Scope a rule to a path glob from within pmat’s own config.
  • Publish or install a rule package. There is no rule registry.

Summary

The old chapter saidpmat 3.32.0
pmat rules init / create / test / publish …No rules subcommand has ever existed
YAML rule language under .pmat/rules/Read by nothing
custom_rules_directory in pmat.tomlRead by nothing
Per-rule severity and categoriesNo such concept
Rule packaging and publishingNo registry
“✅ 100% Working (8/8 examples)”The test never invoked pmat; badge removed

The real mechanism is smaller and it works: commit a .pmat-gates.toml, run pmat quality-gates in CI, and let a non-zero exit stop the line.