Writing Custom Rules (Custom Rule SDK v3)¶
v0.28.0 Breaking Change: The legacy Custom Rules API v2 (
BaseASTRule) was hard deprecated and removed in Zenzic v0.28.0. All custom rules must use the Custom Rule SDK v3 (ZenzicRuleV3+RuleMetadata).
Migration from API v2 to SDK v3¶
The table below illustrates the structural shift from the un-typed v2 API to the governed, typed SDK v3.
Before (API v2 — Removed in v0.28.0)¶
from collections.abc import Generator
from pathlib import Path
from zenzic.core.ast import BlockNode
from zenzic.core.rules import RuleFinding
from zenzic.rules.base import BaseASTRule
class LegacyRule(BaseASTRule):
def __init__(self) -> None:
super().__init__(rule_id="LOCAL-001", severity="error")
def visit_block_node(self, node: BlockNode, file_path: Path) -> Generator[RuleFinding, None, None]:
...
def visit_html_node(self, node: object, file_path: Path) -> Generator[RuleFinding, None, None]:
...
After (Custom Rule SDK v3 — Current)¶
from pathlib import Path
from zenzic.sdk import ZenzicRuleV3, RuleMetadata
from zenzic.core.rules import RuleFinding
class ModernRule(ZenzicRuleV3):
metadata = RuleMetadata(
code="ZZ-NO-BAD-URL",
title="Forbidden Internal URL",
description="Internal URLs must not appear in published documentation.",
severity="warning",
category="content",
penalty=1.0,
)
def visit_line(self, file_path: Path, line_no: int, line_text: str) -> list[RuleFinding]:
if "bad.example.com" in line_text:
return [
self.create_finding(
file_path=file_path,
line_no=line_no,
message="Forbidden internal URL found.",
matched_line=line_text,
)
]
return []
Overview¶
Custom Rule SDK v3 allows developers to author deterministic Python linting rules. Rules placed inside .zenzic/rules/*.py are auto-discovered at scan startup. Alternatively, rules can be configured via [[custom_rules]] in .zenzic.toml:
SDK v3 rules inherit from ZenzicRuleV3 and require a typed RuleMetadata declaration.
The RuleMetadata Schema¶
RuleMetadata dictates finding code, severity, category (for DQS weighting), and penalty impact:
| Attribute | Type | Description | Default |
|---|---|---|---|
code |
str |
Unique rule identifier (e.g. "ZZ-NO-BAD-URL" or "MY_RULE_001"). |
Required |
title |
str |
Short title of the rule. | Required |
description |
str |
Full description of the rule check. | Required |
severity |
"error" \| "warning" \| "info" |
Severity level of produced findings. | "warning" |
category |
"structural" \| "navigation" \| "content" \| "brand" \| "governance" |
Taxonomy category for scoring. | "content" |
penalty |
float |
DQS penalty cost per finding. | 1.0 |
docs_url |
Optional[str] |
Optional URL to rule documentation. | None |
supports_autofix |
bool |
Whether automated quick-fixes are supported. | False |
Visitor Interface¶
SDK v3 rules can override any of the following visitor hooks:
visit_document(self, file_path: Path, text: str) -> list[RuleFinding]: Inspect full raw source.visit_line(self, file_path: Path, line_no: int, line_text: str) -> list[RuleFinding]: Inspect individual lines.visit_link(self, file_path: Path, line_no: int, link_text: str, target_url: str) -> list[RuleFinding]: Inspect links.visit_heading(self, file_path: Path, line_no: int, level: int, title: str) -> list[RuleFinding]: Inspect headings.visit_code_block(self, file_path: Path, start_line: int, lang: str, code: str) -> list[RuleFinding]: Inspect code blocks.
Deterministic Constraints (ADR-007 Sovereign Sandbox)¶
To preserve mathematical determinism (\(O(N)\) runtime complexity) and maintain engine security, all custom rules MUST adhere to these strict constraints:
- Zero Network I/O: Rules must never make HTTP, HTTPS, DNS, or socket requests.
- Zero Subprocesses: Invoking subprocesses (
subprocess.run,os.system) is strictly forbidden (ADR-002). - No Probabilistic NLP: Rules must be deterministic mathematical functions. Probabilistic models and external AI/LLM APIs are prohibited.
- RE2 Regular Expressions (ADR-013): Regular expressions must use linear-time
google-re2viaimport zenzic.core.regex as reto prevent Catastrophic Backtracking (ReDoS). - Pure Functions: Rules must not mutate the filesystem, shared global state, or cross-file caches.
Testing SDK v3 Rules¶
from pathlib import Path
from zenzic.sdk.examples import NoTodoRule
def test_no_todo_rule(tmp_path: Path) -> None:
rule = NoTodoRule()
doc_path = tmp_path / "docs" / "index.md"
findings = rule.check(doc_path, "Line 1\nTODO fix this\nLine 3")
assert len(findings) == 1
assert findings[0].rule_id == "ZZ-NO-TODO"
assert findings[0].line_no == 2