oct
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 L2 - (none) module-load-time registry construction
26 L3 - rule registry queries
27 L4 - (none)
28
29Contracts
30---------
31- Every key in ``_DEFAULT_RULES`` must have a corresponding ``RuleInfo``.
32- ``RuleInfo`` is frozen - registry is immutable after module load.
33- ``severity`` is one of ``"blocking"``, ``"advisory"``, or ``"info"``.
34 ``blocking`` means a violation flips the run to non-zero exit;
35 ``advisory`` is reported but does not affect exit code; ``info`` is
36 reserved for future low-priority hints.
37
38Dependencies
39------------
40- oct.core.diagnostics (_dbg structured logger)
41"""
42
43from __future__ import annotations
44
45from dataclasses import dataclass, field
46from oct.core.diagnostics import _dbg
47
48
49# FS-C4 / Drift item C4 - three-tier severity classification.
50SEVERITY_BLOCKING = "blocking"
51SEVERITY_ADVISORY = "advisory"
52SEVERITY_INFO = "info"
53_VALID_SEVERITIES = frozenset({SEVERITY_BLOCKING, SEVERITY_ADVISORY, SEVERITY_INFO})
54
55
56@dataclass(frozen=True)
58 """Immutable documentation record for a single lint rule.
59
60 The ``severity`` field (FS-C4) classifies each rule's failure mode:
61
62 - ``"blocking"`` (default) - a violation flips the linter exit code
63 to 1 and the quality gate to non-zero.
64 - ``"advisory"`` - a violation is reported in JSON output and logs
65 but does not affect exit codes. AI agents and CI can use this tier
66 to surface improvement opportunities without blocking merges.
67 - ``"info"`` - reserved for low-priority hints (future use).
68
69 The ``severity_per_profile`` map allows a rule to be ``advisory`` at
70 one profile and ``blocking`` at another (e.g. ``tests_exist`` is
71 advisory at compact, blocking at strict).
72 """
73
74 name: str
75 label: str
76 rationale: str
77 correct_pattern: str
78 common_mistake: str
79 spec_reference: str
80 fixable: bool
81 profiles: list[str]
82 severity: str = SEVERITY_BLOCKING
83 severity_per_profile: dict[str, str] = field(default_factory=dict)
84
85
86RULE_REGISTRY: dict[str, RuleInfo] = {
87 "syntax_error": RuleInfo(
88 name="syntax_error",
89 label="Source must parse (no SyntaxError)",
90 rationale=(
91 "A file that fails to parse is unparseable and unimportable. "
92 "Every other check that relies on the parsed tree is "
93 "meaningless on such a file and must not report a pass. "
94 "This rule blocks at every profile so a broken file can never "
95 "reach the commit gate looking compliant."
96 ),
97 correct_pattern=(
98 "Ensure the file parses: `python -m py_compile path/to/file.py` "
99 "(or `ast.parse(open(path).read())`) must not raise."
100 ),
101 common_mistake=(
102 "An unterminated string literal, mismatched bracket, or bad "
103 "indentation introduced by a manual/scripted edit that was "
104 "never compiled or imported before being staged."
105 ),
106 spec_reference="Spec S14 - Linter Specification (workflow §11 R2)",
107 fixable=False,
108 profiles=["proto", "compact", "strict"],
109 ),
110 "header": RuleInfo(
111 name="header",
112 label="File header block",
113 rationale=(
114 "Every Python file must start with a 4-line header: shebang, "
115 "encoding declaration, file-identity comment, and a blank line. "
116 "This makes files self-locating and ensures consistent encoding."
117 ),
118 correct_pattern=(
119 "#!/usr/bin/env python3\n"
120 "# -*- coding: utf-8 -*-\n"
121 "# path/to/module.py\n"
122 ""
123 ),
124 common_mistake=(
125 "Missing shebang, wrong encoding line, or file-identity path "
126 "that does not match the file's actual location in the project."
127 ),
128 spec_reference="Spec S7 - File Identity and Self-Location",
129 fixable=True,
130 profiles=["proto", "compact", "strict"],
131 ),
132 "docstring": RuleInfo(
133 name="docstring",
134 label="Module docstring structure",
135 rationale=(
136 "Module docstrings must contain Purpose, Responsibilities, "
137 "Diagnostics, and Contracts sections so that both humans and "
138 "AI assistants can understand the module's role at a glance."
139 ),
140 correct_pattern=(
141 '"""\n'
142 "Purpose\n"
143 "-------\n"
144 "Brief description of what this module does.\n\n"
145 "Responsibilities\n"
146 "----------------\n"
147 "- Bullet list of responsibilities.\n\n"
148 "Diagnostics\n"
149 "-----------\n"
150 "Domain: MY-DOMAIN\n\n"
151 "Contracts\n"
152 "---------\n"
153 "- Invariants and guarantees.\n"
154 '"""'
155 ),
156 common_mistake=(
157 "Missing one or more required sections (Purpose, "
158 "Responsibilities, Diagnostics, Contracts) or using "
159 "non-standard section headers."
160 ),
161 spec_reference="Spec S8 - Module Docstring Specification",
162 fixable=False,
163 profiles=["proto", "compact", "strict"],
164 ),
165 "dependencies_section": RuleInfo(
166 name="dependencies_section",
167 label="Module Dependencies docstring section",
168 rationale=(
169 "At strict profile, every module docstring must include a "
170 "Dependencies section listing project-internal and notable "
171 "third-party imports as ``- module_name (purpose)``. The "
172 "machine-parsable form is what ``oct deps`` consumes to build "
173 "the project-wide dependency graph and Sphinx documentation."
174 ),
175 correct_pattern=(
176 "Dependencies\n"
177 "------------\n"
178 "- oct.core.exclusions (directory exclusion lists)\n"
179 "- oc_diagnostics (structured debug logger)"
180 ),
181 common_mistake=(
182 "Omitting the Dependencies section, missing the underline, or "
183 "writing free-form bullets without the ``(purpose)`` parenthetical."
184 ),
185 spec_reference="Spec Appendix C - Dependencies Docstring Format",
186 fixable=False,
187 profiles=["strict"],
188 # FS-C4: advisory at strict - surface missing sections without
189 # blocking the build until the project's modules are fully
190 # annotated. Promote to "blocking" once dog-fooding is complete.
191 severity=SEVERITY_ADVISORY,
192 ),
193 "dependencies_match": RuleInfo(
194 name="dependencies_match",
195 label="Dependencies section matches the imports",
196 rationale=(
197 # OCT-LINT: disable=no_hardcoded_secrets reason="Prose rationale of the dependencies_match rule; no credential material (operator-ruled 2026-09-28)"
198 "The Dependencies section is the module's declared coupling, so "
199 "it must say what the module really imports: every first-party "
200 "(any workspace package) and third-party import, standard "
201 "library optional, and nothing it no longer imports. An import "
202 "made only inside a function or under TYPE_CHECKING is declared "
203 "with a purpose starting 'lazy'; oct deps leaves lazy entries out "
204 "of cycle detection, because lazy imports are how the code breaks "
205 "cycles. A dependency used without an import (a CLI, a subprocess, "
206 "a plugin, a module loaded by name) starts its purpose with 'not "
207 "imported'. Operator rulings of 2026-09-28."
208 ),
209 correct_pattern=(
210 "Dependencies\n"
211 "------------\n"
212 "- click (command-line parsing)\n"
213 "- oct.core.diagnostics (_dbg structured logger)\n"
214 "- oct.git.quality_gate (lazy; the commit gate, imported on use)\n"
215 "- uv (not imported; runs 'uv venv' for the worktree)"
216 ),
217 common_mistake=(
218 "Declaring a package (oct.core) where the import names a module "
219 "(oct.core.git); a lazy import without the 'lazy' marker; a "
220 "distribution name (pyyaml) instead of the import name (yaml); "
221 "an entry left behind after its import was removed."
222 ),
223 spec_reference="Spec Appendix C - Dependencies Docstring Format",
224 fixable=False,
225 profiles=["proto", "compact", "strict"],
226 # Advisory at every profile: reported, never blocking. Test modules
227 # are exempt inside the check itself.
228 severity=SEVERITY_ADVISORY,
229 ),
230 "diagnostics_profile": RuleInfo(
231 name="diagnostics_profile",
232 label="Diagnostics profile block",
233 rationale=(
234 "The Diagnostics section in the module docstring must declare "
235 "a Domain and at least L2/L3/L4 level descriptions so that "
236 "debug output is traceable to a specific subsystem and severity."
237 ),
238 correct_pattern=(
239 "Diagnostics\n"
240 "-----------\n"
241 "Domain: MY-DOMAIN\n"
242 "Levels:\n"
243 " L2 - lifecycle events\n"
244 " L3 - semantic details\n"
245 " L4 - deep tracing"
246 ),
247 common_mistake=(
248 "Omitting the Domain line, missing level descriptions, or "
249 "placing the Diagnostics block outside the module docstring."
250 ),
251 spec_reference="Spec S11 - Diagnostics System",
252 fixable=False,
253 profiles=["compact", "strict"],
254 ),
255 "dbg_usage": RuleInfo(
256 name="dbg_usage",
257 label="_dbg() usage in non-trivial modules",
258 rationale=(
259 "Non-trivial modules (>= 20 lines) must call _dbg() at least "
260 "once to ensure runtime diagnostics coverage. Silent modules "
261 "are invisible to the diagnostics system."
262 ),
263 correct_pattern=(
264 'from oc_diagnostics import _dbg\n\n'
265 'func = "my_function"\n'
266 '_dbg("MY-DOMAIN", func, "starting processing", 2)'
267 ),
268 common_mistake=(
269 "Using print() instead of _dbg(), or having a non-trivial "
270 "module with no diagnostics calls at all."
271 ),
272 spec_reference="Spec S11 - Diagnostics System, S14 - Linter Specification",
273 fixable=False,
274 profiles=["compact", "strict"],
275 ),
276 "dbg_import": RuleInfo(
277 name="dbg_import",
278 label="_dbg import source",
279 rationale=(
280 "The _dbg function must be imported from oc_diagnostics, not "
281 "defined locally or imported from another location. This "
282 "ensures a single canonical diagnostics pipeline."
283 ),
284 correct_pattern='from oc_diagnostics import _dbg',
285 common_mistake=(
286 "Importing _dbg from a local module, aliasing it "
287 "(import _dbg as dbg), or defining a local _dbg function."
288 ),
289 spec_reference="Spec S11 - Diagnostics System",
290 fixable=False,
291 profiles=["compact", "strict"],
292 ),
293 "func_pattern": RuleInfo(
294 name="func_pattern",
295 label='func = "..." variable pattern',
296 rationale=(
297 "Functions that call _dbg() must define a local "
298 'func = "function_name" variable so that diagnostics output '
299 "includes the originating function name without relying on "
300 "stack introspection."
301 ),
302 correct_pattern=(
303 'def process_data(items: list) -> None:\n'
304 ' func = "process_data"\n'
305 ' _dbg("MY-DOMAIN", func, "processing", 2)'
306 ),
307 common_mistake=(
308 "Calling _dbg() without defining func first, or defining func "
309 "with a name that doesn't match the enclosing function."
310 ),
311 spec_reference="Spec S9 - Function Structure and Helper Extraction",
312 fixable=False,
313 profiles=["compact", "strict"],
314 ),
315 "tests_exist": RuleInfo(
316 name="tests_exist",
317 label="Regression test exists",
318 rationale=(
319 "Every module must have a corresponding test file. Testing is "
320 "non-negotiable in Option C - no untested code reaches production."
321 ),
322 correct_pattern=(
323 "Module: src/my_module.py\n"
324 "Test: tests/test_my_module.py"
325 ),
326 common_mistake=(
327 "Creating a module without a companion test_<name>.py file, "
328 "or placing the test in the wrong directory."
329 ),
330 spec_reference="Spec S3 - Testing Philosophy and Regression Requirements",
331 fixable=False,
332 profiles=["strict"],
333 # FS-C4: advisory at strict. Missing tests should be visible but
334 # not block routine development; use `oct git commit` with strict
335 # profile to escalate to a blocking gate.
336 severity=SEVERITY_ADVISORY,
337 ),
338 "type_hints": RuleInfo(
339 name="type_hints",
340 label="Type hint annotations",
341 rationale=(
342 "All public functions must have full type annotations on "
343 "parameters and return values. This enables static analysis, "
344 "improves documentation, and supports AI-assisted development."
345 ),
346 correct_pattern=(
347 "def calculate_total(items: list[dict], tax_rate: float = 0.0) -> float:\n"
348 " ..."
349 ),
350 common_mistake=(
351 "Missing return type annotation (use -> None for void), "
352 "omitting parameter types, or using bare 'list' instead of "
353 "'list[T]'."
354 ),
355 spec_reference="Spec S14 - Type hints enforcement",
356 fixable=False,
357 profiles=["compact", "strict"],
358 ),
359 "no_hardcoded_secrets": RuleInfo(
360 name="no_hardcoded_secrets",
361 label="No hardcoded secrets",
362 rationale=(
363 "String literals assigned to variables whose names suggest "
364 "secrets (password, token, api_key, etc.) must not contain "
365 "hardcoded values. Secrets belong in environment variables or "
366 "secure vaults."
367 ),
368 correct_pattern=(
369 'import os\n'
370 'api_key = os.environ["API_KEY"]'
371 ),
372 common_mistake=(
373 'Assigning a literal string to a variable named api_key, '
374 'password, secret, or token: api_key = "sk-abc123..."'
375 ),
376 spec_reference="Spec S6b - Secret Hygiene",
377 fixable=False,
378 profiles=["compact", "strict"],
379 ),
380 "dbg_assert_safety": RuleInfo(
381 name="dbg_assert_safety",
382 label="_dbg assert safety",
383 rationale=(
384 "Calls to _dbg() must not have side effects that would change "
385 "program behaviour if diagnostics are disabled. The level "
386 "argument must be a literal integer, and the message must not "
387 "contain function calls with side effects."
388 ),
389 correct_pattern=(
390 '_dbg("DOMAIN", func, f"count={len(items)}", 3)'
391 ),
392 common_mistake=(
393 "Passing a function call with side effects as the message "
394 "argument, or using a variable instead of a literal for the "
395 "level parameter."
396 ),
397 spec_reference="Spec S6a - Safety Rules",
398 fixable=False,
399 profiles=["strict"],
400 ),
401 "prompt_invariants": RuleInfo(
402 name="prompt_invariants",
403 label="OC Chat prompt invariants",
404 rationale=(
405 "The Plan 135 prompt-composition invariants (no raw tool-schema "
406 "dump into the system prompt, model switches routed through "
407 "ChatSession.set_model, declarative composition modes, no retired "
408 "composition symbols, cache-hit vs context-saturation kept "
409 "distinct) protect provider prompt-cache stability and prompt "
410 "ownership. Lifted from the OC Chat pytest wrapper (Plan 140) into "
411 "the strict profile at the Plan 151 monorepo migration."
412 ),
413 correct_pattern=(
414 "Route runtime model switches through ChatSession.set_model; keep "
415 "full tool schemas in the API tools parameter; use declarative "
416 "mode profiles; use build_system_prompt / CANONICAL_POLICY; keep "
417 "cache_hit_pct and context_used_pct on separate computations."
418 ),
419 common_mistake=(
420 "json.dumps(tool_schemas) inside prompt construction; raw "
421 "session.model assignment; if mode == 'option-c' branches; "
422 "reintroducing retired composition symbols; sharing one "
423 "computation for cache-hit and context-window saturation."
424 ),
425 spec_reference=(
426 "Plan 135 section 4.7; inner-loop.md section 8; Plan 140 "
427 "(invariant lint rules)"
428 ),
429 fixable=False,
430 profiles=["strict"],
431 # Plan 151 T6: enters strict as ADVISORY (matching dependencies_section
432 # and tests_exist) - findings are surfaced without flipping the build
433 # red, so the aggregated per-subproject strict-lint baseline is not
434 # regressed. Promote to blocking once the OC Chat runtime tree is
435 # confirmed clean under the migrated layout and dog-fooding completes.
436 severity=SEVERITY_ADVISORY,
437 ),
438 "legacy_file_scoped_waiver": RuleInfo(
439 name="legacy_file_scoped_waiver",
440 label="Secret waiver relies on legacy whole-file scope",
441 rationale=(
442 "Plan 193 made secret waivers LINE-scoped: a "
443 "`# OCT-LINT: disable=no_hardcoded_secrets` comment excuses "
444 "its own line and the next one. Before 193 a single waiver "
445 "anywhere blinded the scanner to the WHOLE file, so a real "
446 "credential pasted into any waived file committed clean. "
447 "Files written under the old semantics keep working - a bare "
448 "waiver adjacent to none of the findings it excuses still "
449 "suppresses file-wide - but each one is counted here so the "
450 "migration has a visible burn-down. When this rule reports "
451 "zero across the tree, the legacy fallback can be deleted "
452 "as a behavioural no-op."
453 ),
454 # Belt-and-braces (193 R1): the example uses the ``<rule>``
455 # placeholder rather than a literal rule name. ``_WAIVER_RE``'s
456 # rule group is ``[a-z_]+``, so ``<rule>`` cannot match and this
457 # illustration stays inert even to a line-based reader that has
458 # not been taught to skip string literals.
459 correct_pattern=(
460 'PATTERN = "..." # OCT-LINT: disable=<rule> reason="regex, not a credential"\n'
461 "\n"
462 "# ...or, for deliberate and greppable whole-file scope:\n"
463 '# OCT-LINT: disable-file=<rule> reason="generated fixture module"'
464 ),
465 common_mistake=(
466 "Parking a bare `# OCT-LINT: disable=no_hardcoded_secrets` "
467 "header at the top of a file to silence one false positive "
468 "further down - which also silences every real secret the "
469 "file may later acquire."
470 ),
471 spec_reference="Spec S6b - Secret Hygiene; Plan 193 (line-scoped waivers)",
472 fixable=False,
473 profiles=["compact", "strict"],
474 # Advisory by construction: this rule exists to make a migration
475 # visible, not to block one. Promoting it to blocking would
476 # storm every file it is meant to count.
477 severity=SEVERITY_ADVISORY,
478 ),
479}
480
481
482def get_rule_info(name: str) -> RuleInfo | None:
483 """Return the ``RuleInfo`` for *name*, or ``None`` if unknown."""
484 func = "get_rule_info"
485 _dbg("OCT-LINTER", func, f"rule lookup: {name}", 4)
486 return RULE_REGISTRY.get(name)
487
488
489def list_rules() -> list[RuleInfo]:
490 """Return all registered rules in registry order."""
491 func = "list_rules"
492 _dbg("OCT-LINTER", func, "listing every registered rule", 4)
493 return list(RULE_REGISTRY.values())
494
495
496def get_severity(rule_name: str, profile: str = "strict") -> str:
497 """Return the severity tier (``blocking`` / ``advisory`` / ``info``)
498 for *rule_name* under *profile* (FS-C4).
499
500 Resolution order: profile-specific override → rule's default severity
501 → ``"blocking"`` for unknown rules (fail-safe).
502 """
503 info = RULE_REGISTRY.get(rule_name)
504 if info is None:
505 return SEVERITY_BLOCKING
506 if info.severity_per_profile and profile in info.severity_per_profile:
507 candidate = info.severity_per_profile[profile]
508 if candidate in _VALID_SEVERITIES:
509 return candidate
510 return info.severity if info.severity in _VALID_SEVERITIES else SEVERITY_BLOCKING
511
512
513def is_blocking(rule_name: str, profile: str = "strict") -> bool:
514 """Convenience predicate: return True iff a violation of *rule_name*
515 should flip the linter / quality-gate exit code at *profile*.
516 """
517 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()