Writing Plugin Rules
Zenzic supports external lint rules written in Python. A plugin rule is a
subclass of BaseRule distributed as a normal Python package and discovered at
runtime via the zenzic.rules entry-point group.
The Rule Contract
Every plugin rule must satisfy three non-negotiable requirements. These are
enforced at engine construction time — a rule that violates any of them is
rejected with a PluginContractError before the first file is scanned.
1. Defined at module level
The class must be importable by name from a module. Classes defined inside functions or closures cannot be pickled and will be rejected.
# ✓ correct — importable as my_rules.NoDraftRule
class NoDraftRule(BaseRule): ...
# ✗ wrong — not pickleable; will raise PluginContractError at load time
def make_rule():
class NoDraftRule(BaseRule): ...
return NoDraftRule()
2. Pickle-serialisable
The AdaptiveRuleEngine serialises rules via pickle before dispatching them
to worker processes. Every attribute stored on self must be pickleable.
Safe attributes: strings, numbers, re.compile() patterns, frozen dataclasses,
Path objects, tuples of safe types.
Unsafe attributes: open file handles, database connections, lambda functions,
threading.Lock, generator objects, or any object that defines __reduce__
incorrectly.
# ✓ compiled regex is pickleable
class NoDraftRule(BaseRule):
_pattern = re.compile(r"(?i)\bDRAFT\b") # class-level attribute
# ✓ also fine as an instance attribute set in __init__
class NoDraftRule(BaseRule):
def __init__(self) -> None:
self._pattern = re.compile(r"(?i)\bDRAFT\b")
3. Pure and deterministic
check() and check_vsm() must:
- Never open files, make network requests, or call subprocesses.
- Always return the same output for the same input — no randomness, no dependency on mutable global state.
- Not mutate their arguments (
file_path,text,vsm,anchors_cache).
A rule that writes to a global counter will appear to work in sequential
mode but will produce non-deterministic, silently wrong results in
parallel mode. Worker processes each receive an independent pickle copy
of the engine — mutations are local to the worker and discarded on
completion. All state must be returned as RuleFinding objects.
Minimal example
# my_org_rules/rules.py
import re
from pathlib import Path
from zenzic.rules import BaseRule, RuleFinding
class NoInternalHostnameRule(BaseRule):
"""Flag occurrences of the internal hostname in public documentation."""
_pattern = re.compile(r"internal\.corp\.example\.com", re.IGNORECASE)
@property
def rule_id(self) -> str:
return "MYORG-001"
def check(self, file_path: Path, text: str) -> list[RuleFinding]:
findings = []
for lineno, line in enumerate(text.splitlines(), start=1):
if self._pattern.search(line):
findings.append(
RuleFinding(
file_path=file_path,
line_no=lineno,
rule_id=self.rule_id,
message="Internal hostname must not appear in public docs.",
severity="error",
matched_line=line,
)
)
return findings
Packaging and registration
Expose the rule through the zenzic.rules entry-point group in your package's
pyproject.toml:
[project.entry-points."zenzic.rules"]
no-internal-hostname = "my_org_rules.rules:NoInternalHostnameRule"
The entry-point name (no-internal-hostname) is the plugin ID that users
reference in zenzic.toml (see Enabling plugins below).
Install your package alongside Zenzic:
uv add my-org-rules # or: pip install my-org-rules
After installing, run zenzic plugins list to confirm the rule is discovered:
zenzic plugins list
# Installed plugin rules (2 found)
# broken-links Z001 (core) zenzic.core.rules.VSMBrokenLinkRule
# no-internal-hostname MYORG-001 (my-org-rules) my_org_rules.rules.NoInternalHostnameRule
Fast-Track: from zero to plugin in 30 seconds
Use the scaffold command to generate a ready-to-edit plugin package:
zenzic init --plugin plugin-scaffold-demo
Generated structure:
plugin-scaffold-demo/
pyproject.toml
README.md
zenzic.toml
docs/
index.md
src/
plugin_scaffold_demo/
__init__.py
rules.py
The scaffold includes:
- a pre-configured
zenzic.rulesentry-point inpyproject.toml - a module-level
BaseRuleclass template inrules.py - a minimal docs fixture so
zenzic check allpasses immediately
Quick verification:
cd plugin-scaffold-demo
uv pip install -e .
zenzic plugins list
zenzic check all
Enabling plugins
Core rules (registered under zenzic.rules by Zenzic itself) are always
active. External plugin rules must be explicitly enabled in zenzic.toml
under the plugins key:
# zenzic.toml
[build_context]
engine = "mkdocs"
plugins = ["no-internal-hostname"]
Only plugins listed here will be loaded. Installing a package that registers
rules under zenzic.rules without listing it in plugins has no effect —
this is intentional Safe Harbor behaviour: you always know exactly which
rules are active in your project.
VSM-aware rules
Rules that need to validate links against the routing table should override
check_vsm instead of (or in addition to) check. The engine calls
check_vsm when a VSM and anchors_cache are available:
from collections.abc import Mapping
from zenzic.core.rules import BaseRule, RuleFinding
from zenzic.models.vsm import Route
class NoOrphanLinkRule(BaseRule):
@property
def rule_id(self) -> str:
return "MYORG-002"
def check(self, file_path, text):
return [] # no standalone check; requires VSM context
def check_vsm(self, file_path, text, vsm: Mapping[str, Route], anchors_cache):
# vsm maps canonical URL → Route; consult vsm[url].status
...
return [] # return list[Violation]
See BaseRule in the API reference for the complete interface.
Testing your rules
Use the run_rule test helper to validate a rule in a single call — no engine
setup required:
from zenzic.rules import run_rule
from my_org_rules.rules import NoInternalHostnameRule
def test_internal_hostname_detected():
findings = run_rule(
NoInternalHostnameRule(),
"Visit internal.corp.example.com for details.",
)
assert len(findings) == 1
assert findings[0].rule_id == "MYORG-001"
assert findings[0].severity == "error"
def test_clean_content_passes():
findings = run_rule(NoInternalHostnameRule(), "All public content here.")
assert findings == []
run_rule creates an AdaptiveRuleEngine internally, runs the rule, and
returns the findings list. It accepts an optional file_path keyword argument
for labelling (defaults to test.md).
Error isolation
If a plugin rule raises an unexpected exception inside check() or
check_vsm(), the engine catches it, emits a single "error" finding with
rule_id="RULE-ENGINE-ERROR", and continues scanning. One faulty plugin
cannot abort the scan of the entire docs tree.
If a plugin rule fails the eager pickle validation at load time (i.e. it
is not serialisable), Zenzic raises PluginContractError immediately and
refuses to start. Fix the rule before running Zenzic.
Checklist before publishing
- Class defined at module level (not inside a function or lambda).
- All
self.*attributes are pickleable. -
check()is pure: no I/O, no side effects, same output for same input. -
rule_idis a stable, unique string (include an org prefix, e.g."MYORG-001"). - Entry-point registered under
zenzic.rulesinpyproject.toml. - Plugin ID listed in the project's
zenzic.tomlunderplugins.
Bridge your rule from implementation to production Sentinel flow:
- Register and enable the plugin ID in
zenzic.tomlunderplugins(see Enabling plugins). - Validate the rule under strict pipeline semantics:
zenzic check all --strict. For run-time policy controls, see CLI Commands: Global flags. - If your rule is nav-aware, map expected Ghost Route behavior against the VSM model: Checks Reference — VSM.