Skip to main content
Fensu’s defaults describe a coherent architecture, but every serious repository eventually develops contracts specific to its own domain. Custom rules turn those contracts into executable checks that Fensu enforces consistently.
Agent-assisted authoring is recommended. Describe the architectural contract, provide passing and failing examples, and ask a coding agent to implement and test the rule. The result is ordinary Python that your team reviews and Fensu executes like any other rule.

Agent-assisted authoring

Repository-specific static analysis traditionally required enough specialized work that architectural knowledge stayed trapped in documentation and code review. A coding agent can inspect the repository, implement a rule against Fensu’s public analysis API, add boundary tests, and iterate. The agent implements the policy; it does not invent it. Your team still defines the contract, reviews the rule and its edge cases, and decides which false positives are acceptable.

Give the agent the contract

Start with examples rather than an implementation strategy. Give the agent enough context to inspect existing patterns and enough boundaries to distinguish the contract from nearby valid code:
That is enough to begin. Run the rule, inspect its faults, and refine the passing and failing examples until it captures the intended boundary. Then review the contract and tests more closely than the traversal mechanics: confirm that faults identify the right syntax, legitimate near-misses pass, project access goes through ctx.project, and diagnostics are deterministic.

The shape of a rule

A custom rule is a function decorated with @rule. The decorator carries a mandatory metadata envelope; the function body is unconstrained.
The idea is to constrain the envelope and free the body. You cannot create a rule without its identity and message, but inside the check you can write any Python you like.

The public API

Import rule-authoring names from the top-level package:
Anything not exported is internal and may change. The top-level package also exports public analysis protocols and stable location, handle, dependency, and fact models. Import a named public type from fensu when a custom rule needs it; do not import from fensu.analysis or another internal module. The top-level package also exports RuleCase, RuleFile, RuleResult, and evaluate_rule for testing custom rules through the ordinary discovery and evaluation pipeline.

The @rule decorator

Codes and namespaces

A custom code is X, plus zero or more uppercase letters, plus one or more digits: X001, XAC001, XDB042. The namespace is validated when the decorator runs; a custom code that starts with FF is rejected at load, so a project can never silently redefine what a core rule means. The letters between X and the digits are yours to carve into namespaces. A team or plugin claims a prefix (XDB* for the database team, XDJ* for the Django conventions package), and because selectors are prefixes, namespaces nest: X selects every custom rule, XD everything under XDB and XDJ, XDB one namespace, XDB001 one rule.

The check signature

The signature is frozen. New capabilities arrive as new context methods, never as signature changes.
  • module is the parsed ast.Module of the file being checked.
  • ctx is a RuleContext.
  • The return value is a list of Fault objects.

The Fault

A Fault is a frozen dataclass. You usually construct one through the context so line, column, and code are wired automatically, but the type is public so return types are nameable.

Family, Severity, and Threshold

Threshold names the configurable numeric limits, one member per key in the [thresholds] table (Threshold.MAX_STATEMENTS is max_statements, and so on). It exists so a rule asks for a limit by name rather than by string: ctx.threshold(name=Threshold.MAX_STATEMENTS) returns the value applicable to the current file, with any global, per-role, or per-path override already applied. There is no string-keyed form, for the same reason FFH007 exists.

The rule context

RuleContext is a convenience toolbox, not a cage. Its supported analysis surface is split into five public, backend-neutral zones. You may still ignore them and walk module yourself when a rule needs unrestricted Python AST access.
Every ctx.project query records its input for cache invalidation. Pass the file whose result depends on the query as requester, normally ctx.path. Construct diagnostics through the context as well:
See the RuleContext reference for every zone, helper, fault constructor, public model, and configuration method. Prefer ctx.nodes() over ast.walk(module) for module-wide scans so cooperating rules share one traversal.

Where rules live

Keep project rules in a rules/ role module inside your tooling tree. A rules/ module may contain imports, import-only TYPE_CHECKING blocks, and any number of @rule functions; supporting logic belongs in the other roles.
Name modules after the policy they implement. FFR706 rejects filenames that are exactly a core or custom rule code, such as fft104.py or xac001.py, because a descriptive name remains useful when policies grow or codes change.

Loading rules

Point configuration at your rules, and select them:
  • rule_paths are directories (or files) of .py modules. Each file is loaded under an isolated synthetic module name, so it cannot pollute or collide with your import namespace, and only the decorated functions that file defines are picked up.
  • rule_modules are importable dotted names, loaded with a normal import. Their source must resolve inside the repository.
An import error in a rule file is reported as a clear configuration error naming the file, not a raw traceback. Every rule code across core and custom must be unique, so a duplicate code is rejected rather than silently overriding another rule. You enable custom rules like any other. The X family selector turns on all default-enabled custom rules; an exact code turns on a single rule (and is the only way to enable an enabled_by_default=False custom rule).

Testing rules

Test a rule with the public harness rather than constructing internal objects. Each case runs one decorated rule through the same discovery, configuration, evaluation, and dependency tracking as fensu check. Import RuleCase, RuleResult, evaluate_rule, and RuleFile from fensu.
expected_fault_count documents the case; evaluate_rule does not assert it for you. RuleCase accepts path, scope (root, test, or tooling), scope_root, supporting files, and a config fragment limited to contracts, roles, rule_exceptions, threshold_overrides, and thresholds. Supporting RuleFile entries are discovered as project context but are not direct evaluation targets. RuleResult exposes faults, dependencies, and fault_count. FFR707 requires each configured custom rule to have at least min_custom_rule_test_cases statically visible RuleCase instances (default 1, 0 disables it). Keep cases in a visible pytest.mark.parametrize list calling evaluate_rule(rule=<decorated function>, test_case=...); dynamic factories and opaque collections cannot prove a count.

Keeping custom rules cacheable

fensu check caches results by default, but a custom rule body is arbitrary Python, so Fensu cannot assume that it reads only inputs the cache can track. By default, selecting any custom rule disables caching for the whole run. Setting require_cacheable = true in the [cache] table changes this: every selected custom rule is verified at startup, and the run stays cached. This tracked-input isolation is sometimes called “hermeticity.” Verification requires the rule’s source file to:
  1. Import only from a pure standard-library allowlist (ast, re, dataclasses, typing, collections, enum, functools, itertools, pathlib for lexical path work, and similar pure modules), from fensu itself, and from the configured rule packages.
  2. Never call open, eval, exec, input, or __import__.
  3. Never touch the filesystem directly. Read project state through the public ctx.project zone (exists, is_file, is_dir, directory_entries, glob, python_anchor, analysis, and focused semantic queries), which records inputs so cache invalidation remains correct.
A violation is a hard configuration error at startup naming the file, line, and reason. Unselected rules are never scanned. Repository-owned rule sources and helpers are content-hashed, so editing a rule or its helpers invalidates the cache correctly.

Selecting and inspecting

Once configured, custom rules are indistinguishable from core rules to the rest of Fensu. They run under fensu check, they appear in fensu rule (with their source file shown), and they are included in the guidance produced by fensu skills:
Because the message and remediation envelope is the same one core rules use, your custom rules automatically become part of your repository’s generated agent guidance, generated from the rules your CI enforces.