Option C Tools
Loading...
Searching...
No Matches
rule_registry.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/linter/rule_registry.py
4
5"""
6Purpose
7-------
8Provide a structured registry of all lint rules with human-readable
9documentation for each rule: rationale, correct pattern, common mistake,
10spec reference, and severity tier. Powers ``oct lint --explain <rule>``
11and the linter's three-tier failure classification.
12
13Responsibilities
14----------------
15- Define the ``RuleInfo`` dataclass that carries rule metadata.
16- Populate ``RULE_REGISTRY`` with one entry per rule in ``_DEFAULT_RULES``.
17- Expose ``get_rule_info()`` and ``list_rules()`` for lookup / enumeration.
18- Expose :func:`get_severity` so the linter and quality gate can decide
19 whether a finding blocks the build or is reported as advisory.
20
21Diagnostics
22-----------
23Domain: OCT-LINTER
24Levels:
25 L3 — rule registry queries
26
27Contracts
28---------
29- Every key in ``_DEFAULT_RULES`` must have a corresponding ``RuleInfo``.
30- ``RuleInfo`` is frozen — registry is immutable after module load.
31- ``severity`` is one of ``"blocking"``, ``"advisory"``, or ``"info"``.
32 ``blocking`` means a violation flips the run to non-zero exit;
33 ``advisory`` is reported but does not affect exit code; ``info`` is
34 reserved for future low-priority hints.
35
36Dependencies
37------------
38"""
39
40from __future__ import annotations
41
42from dataclasses import dataclass, field
43
44
45# FS-C4 / Drift item C4 — three-tier severity classification.
46SEVERITY_BLOCKING = "blocking"
47SEVERITY_ADVISORY = "advisory"
48SEVERITY_INFO = "info"
49_VALID_SEVERITIES = frozenset({SEVERITY_BLOCKING, SEVERITY_ADVISORY, SEVERITY_INFO})
50
51
52@dataclass(frozen=True)
54 """Immutable documentation record for a single lint rule.
55
56 The ``severity`` field (FS-C4) classifies each rule's failure mode:
57
58 - ``"blocking"`` (default) — a violation flips the linter exit code
59 to 1 and the quality gate to non-zero.
60 - ``"advisory"`` — a violation is reported in JSON output and logs
61 but does not affect exit codes. AI agents and CI can use this tier
62 to surface improvement opportunities without blocking merges.
63 - ``"info"`` — reserved for low-priority hints (future use).
64
65 The ``severity_per_profile`` map allows a rule to be ``advisory`` at
66 one profile and ``blocking`` at another (e.g. ``tests_exist`` is
67 advisory at compact, blocking at strict).
68 """
69
70 name: str
71 label: str
72 rationale: str
73 correct_pattern: str
74 common_mistake: str
75 spec_reference: str
76 fixable: bool
77 profiles: list[str]
78 severity: str = SEVERITY_BLOCKING
79 severity_per_profile: dict[str, str] = field(default_factory=dict)
80
81
82RULE_REGISTRY: dict[str, RuleInfo] = {
83 "header": RuleInfo(
84 name="header",
85 label="File header block",
86 rationale=(
87 "Every Python file must start with a 4-line header: shebang, "
88 "encoding declaration, file-identity comment, and a blank line. "
89 "This makes files self-locating and ensures consistent encoding."
90 ),
91 correct_pattern=(
92 "#!/usr/bin/env python3\n"
93 "# -*- coding: utf-8 -*-\n"
94 "# path/to/module.py\n"
95 ""
96 ),
97 common_mistake=(
98 "Missing shebang, wrong encoding line, or file-identity path "
99 "that does not match the file's actual location in the project."
100 ),
101 spec_reference="Spec S7 — File Identity and Self-Location",
102 fixable=True,
103 profiles=["proto", "compact", "strict"],
104 ),
105 "docstring": RuleInfo(
106 name="docstring",
107 label="Module docstring structure",
108 rationale=(
109 "Module docstrings must contain Purpose, Responsibilities, "
110 "Diagnostics, and Contracts sections so that both humans and "
111 "AI assistants can understand the module's role at a glance."
112 ),
113 correct_pattern=(
114 '"""\n'
115 "Purpose\n"
116 "-------\n"
117 "Brief description of what this module does.\n\n"
118 "Responsibilities\n"
119 "----------------\n"
120 "- Bullet list of responsibilities.\n\n"
121 "Diagnostics\n"
122 "-----------\n"
123 "Domain: MY-DOMAIN\n\n"
124 "Contracts\n"
125 "---------\n"
126 "- Invariants and guarantees.\n"
127 '"""'
128 ),
129 common_mistake=(
130 "Missing one or more required sections (Purpose, "
131 "Responsibilities, Diagnostics, Contracts) or using "
132 "non-standard section headers."
133 ),
134 spec_reference="Spec S8 — Module Docstring Specification",
135 fixable=False,
136 profiles=["proto", "compact", "strict"],
137 ),
138 "dependencies_section": RuleInfo(
139 name="dependencies_section",
140 label="Module Dependencies docstring section",
141 rationale=(
142 "At strict profile, every module docstring must include a "
143 "Dependencies section listing project-internal and notable "
144 "third-party imports as ``- module_name (purpose)``. The "
145 "machine-parsable form is what ``oct deps`` consumes to build "
146 "the project-wide dependency graph and Sphinx documentation."
147 ),
148 correct_pattern=(
149 "Dependencies\n"
150 "------------\n"
151 "- oct.core.exclusions (directory exclusion lists)\n"
152 "- oc_diagnostics (structured debug logger)"
153 ),
154 common_mistake=(
155 "Omitting the Dependencies section, missing the underline, or "
156 "writing free-form bullets without the ``(purpose)`` parenthetical."
157 ),
158 spec_reference="Spec Appendix C — Dependencies Docstring Format",
159 fixable=False,
160 profiles=["strict"],
161 # FS-C4: advisory at strict — surface missing sections without
162 # blocking the build until the project's modules are fully
163 # annotated. Promote to "blocking" once dog-fooding is complete.
164 severity=SEVERITY_ADVISORY,
165 ),
166 "diagnostics_profile": RuleInfo(
167 name="diagnostics_profile",
168 label="Diagnostics profile block",
169 rationale=(
170 "The Diagnostics section in the module docstring must declare "
171 "a Domain and at least L2/L3/L4 level descriptions so that "
172 "debug output is traceable to a specific subsystem and severity."
173 ),
174 correct_pattern=(
175 "Diagnostics\n"
176 "-----------\n"
177 "Domain: MY-DOMAIN\n"
178 "Levels:\n"
179 " L2 — lifecycle events\n"
180 " L3 — semantic details\n"
181 " L4 — deep tracing"
182 ),
183 common_mistake=(
184 "Omitting the Domain line, missing level descriptions, or "
185 "placing the Diagnostics block outside the module docstring."
186 ),
187 spec_reference="Spec S11 — Diagnostics System",
188 fixable=False,
189 profiles=["compact", "strict"],
190 ),
191 "dbg_usage": RuleInfo(
192 name="dbg_usage",
193 label="_dbg() usage in non-trivial modules",
194 rationale=(
195 "Non-trivial modules (>= 20 lines) must call _dbg() at least "
196 "once to ensure runtime diagnostics coverage. Silent modules "
197 "are invisible to the diagnostics system."
198 ),
199 correct_pattern=(
200 'from oc_diagnostics import _dbg\n\n'
201 'func = "my_function"\n'
202 '_dbg("MY-DOMAIN", func, "starting processing", 2)'
203 ),
204 common_mistake=(
205 "Using print() instead of _dbg(), or having a non-trivial "
206 "module with no diagnostics calls at all."
207 ),
208 spec_reference="Spec S11 — Diagnostics System, S14 — Linter Specification",
209 fixable=False,
210 profiles=["compact", "strict"],
211 ),
212 "dbg_import": RuleInfo(
213 name="dbg_import",
214 label="_dbg import source",
215 rationale=(
216 "The _dbg function must be imported from oc_diagnostics, not "
217 "defined locally or imported from another location. This "
218 "ensures a single canonical diagnostics pipeline."
219 ),
220 correct_pattern='from oc_diagnostics import _dbg',
221 common_mistake=(
222 "Importing _dbg from a local module, aliasing it "
223 "(import _dbg as dbg), or defining a local _dbg function."
224 ),
225 spec_reference="Spec S11 — Diagnostics System",
226 fixable=False,
227 profiles=["compact", "strict"],
228 ),
229 "func_pattern": RuleInfo(
230 name="func_pattern",
231 label='func = "..." variable pattern',
232 rationale=(
233 "Functions that call _dbg() must define a local "
234 'func = "function_name" variable so that diagnostics output '
235 "includes the originating function name without relying on "
236 "stack introspection."
237 ),
238 correct_pattern=(
239 'def process_data(items: list) -> None:\n'
240 ' func = "process_data"\n'
241 ' _dbg("MY-DOMAIN", func, "processing", 2)'
242 ),
243 common_mistake=(
244 "Calling _dbg() without defining func first, or defining func "
245 "with a name that doesn't match the enclosing function."
246 ),
247 spec_reference="Spec S9 — Function Structure and Helper Extraction",
248 fixable=False,
249 profiles=["compact", "strict"],
250 ),
251 "tests_exist": RuleInfo(
252 name="tests_exist",
253 label="Regression test exists",
254 rationale=(
255 "Every module must have a corresponding test file. Testing is "
256 "non-negotiable in Option C — no untested code reaches production."
257 ),
258 correct_pattern=(
259 "Module: src/my_module.py\n"
260 "Test: tests/test_my_module.py"
261 ),
262 common_mistake=(
263 "Creating a module without a companion test_<name>.py file, "
264 "or placing the test in the wrong directory."
265 ),
266 spec_reference="Spec S3 — Testing Philosophy and Regression Requirements",
267 fixable=False,
268 profiles=["strict"],
269 # FS-C4: advisory at strict. Missing tests should be visible but
270 # not block routine development; use `oct git commit` with strict
271 # profile to escalate to a blocking gate.
272 severity=SEVERITY_ADVISORY,
273 ),
274 "type_hints": RuleInfo(
275 name="type_hints",
276 label="Type hint annotations",
277 rationale=(
278 "All public functions must have full type annotations on "
279 "parameters and return values. This enables static analysis, "
280 "improves documentation, and supports AI-assisted development."
281 ),
282 correct_pattern=(
283 "def calculate_total(items: list[dict], tax_rate: float = 0.0) -> float:\n"
284 " ..."
285 ),
286 common_mistake=(
287 "Missing return type annotation (use -> None for void), "
288 "omitting parameter types, or using bare 'list' instead of "
289 "'list[T]'."
290 ),
291 spec_reference="Spec S14 — Type hints enforcement",
292 fixable=False,
293 profiles=["compact", "strict"],
294 ),
295 "no_hardcoded_secrets": RuleInfo(
296 name="no_hardcoded_secrets",
297 label="No hardcoded secrets",
298 rationale=(
299 "String literals assigned to variables whose names suggest "
300 "secrets (password, token, api_key, etc.) must not contain "
301 "hardcoded values. Secrets belong in environment variables or "
302 "secure vaults."
303 ),
304 correct_pattern=(
305 'import os\n'
306 'api_key = os.environ["API_KEY"]'
307 ),
308 common_mistake=(
309 'Assigning a literal string to a variable named api_key, '
310 'password, secret, or token: api_key = "sk-abc123..."'
311 ),
312 spec_reference="Spec S6b — Secret Hygiene",
313 fixable=False,
314 profiles=["compact", "strict"],
315 ),
316 "dbg_assert_safety": RuleInfo(
317 name="dbg_assert_safety",
318 label="_dbg assert safety",
319 rationale=(
320 "Calls to _dbg() must not have side effects that would change "
321 "program behaviour if diagnostics are disabled. The level "
322 "argument must be a literal integer, and the message must not "
323 "contain function calls with side effects."
324 ),
325 correct_pattern=(
326 '_dbg("DOMAIN", func, f"count={len(items)}", 3)'
327 ),
328 common_mistake=(
329 "Passing a function call with side effects as the message "
330 "argument, or using a variable instead of a literal for the "
331 "level parameter."
332 ),
333 spec_reference="Spec S6a — Safety Rules",
334 fixable=False,
335 profiles=["strict"],
336 ),
337}
338
339
340def get_rule_info(name: str) -> RuleInfo | None:
341 """Return the ``RuleInfo`` for *name*, or ``None`` if unknown."""
342 return RULE_REGISTRY.get(name)
343
344
345def list_rules() -> list[RuleInfo]:
346 """Return all registered rules in registry order."""
347 return list(RULE_REGISTRY.values())
348
349
350def get_severity(rule_name: str, profile: str = "strict") -> str:
351 """Return the severity tier (``blocking`` / ``advisory`` / ``info``)
352 for *rule_name* under *profile* (FS-C4).
353
354 Resolution order: profile-specific override → rule's default severity
355 → ``"blocking"`` for unknown rules (fail-safe).
356 """
357 info = RULE_REGISTRY.get(rule_name)
358 if info is None:
359 return SEVERITY_BLOCKING
360 if info.severity_per_profile and profile in info.severity_per_profile:
361 candidate = info.severity_per_profile[profile]
362 if candidate in _VALID_SEVERITIES:
363 return candidate
364 return info.severity if info.severity in _VALID_SEVERITIES else SEVERITY_BLOCKING
365
366
367def is_blocking(rule_name: str, profile: str = "strict") -> bool:
368 """Convenience predicate: return True iff a violation of *rule_name*
369 should flip the linter / quality-gate exit code at *profile*.
370 """
371 return get_severity(rule_name, profile) == SEVERITY_BLOCKING
bool is_blocking(str rule_name, str profile="strict")
RuleInfo|None get_rule_info(str name)
str get_severity(str rule_name, str profile="strict")
list[RuleInfo] list_rules()