58 """Immutable documentation record for a single lint rule.
60 The ``severity`` field (FS-C4) classifies each rule's failure mode:
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).
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).
82 severity: str = SEVERITY_BLOCKING
83 severity_per_profile: dict[str, str] = field(default_factory=dict)
86RULE_REGISTRY: dict[str, RuleInfo] = {
89 label=
"Source must parse (no SyntaxError)",
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."
98 "Ensure the file parses: `python -m py_compile path/to/file.py` "
99 "(or `ast.parse(open(path).read())`) must not raise."
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."
106 spec_reference=
"Spec S14 - Linter Specification (workflow §11 R2)",
108 profiles=[
"proto",
"compact",
"strict"],
112 label=
"File header block",
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."
119 "#!/usr/bin/env python3\n"
120 "# -*- coding: utf-8 -*-\n"
121 "# path/to/module.py\n"
125 "Missing shebang, wrong encoding line, or file-identity path "
126 "that does not match the file's actual location in the project."
128 spec_reference=
"Spec S7 - File Identity and Self-Location",
130 profiles=[
"proto",
"compact",
"strict"],
134 label=
"Module docstring structure",
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."
144 "Brief description of what this module does.\n\n"
147 "- Bullet list of responsibilities.\n\n"
150 "Domain: MY-DOMAIN\n\n"
153 "- Invariants and guarantees.\n"
157 "Missing one or more required sections (Purpose, "
158 "Responsibilities, Diagnostics, Contracts) or using "
159 "non-standard section headers."
161 spec_reference=
"Spec S8 - Module Docstring Specification",
163 profiles=[
"proto",
"compact",
"strict"],
166 name=
"dependencies_section",
167 label=
"Module Dependencies docstring section",
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."
178 "- oct.core.exclusions (directory exclusion lists)\n"
179 "- oc_diagnostics (structured debug logger)"
182 "Omitting the Dependencies section, missing the underline, or "
183 "writing free-form bullets without the ``(purpose)`` parenthetical."
185 spec_reference=
"Spec Appendix C - Dependencies Docstring Format",
191 severity=SEVERITY_ADVISORY,
194 name=
"dependencies_match",
195 label=
"Dependencies section matches the imports",
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."
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)"
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."
223 spec_reference=
"Spec Appendix C - Dependencies Docstring Format",
225 profiles=[
"proto",
"compact",
"strict"],
228 severity=SEVERITY_ADVISORY,
231 name=
"diagnostics_profile",
232 label=
"Diagnostics profile block",
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."
241 "Domain: MY-DOMAIN\n"
243 " L2 - lifecycle events\n"
244 " L3 - semantic details\n"
248 "Omitting the Domain line, missing level descriptions, or "
249 "placing the Diagnostics block outside the module docstring."
251 spec_reference=
"Spec S11 - Diagnostics System",
253 profiles=[
"compact",
"strict"],
257 label=
"_dbg() usage in non-trivial modules",
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."
264 'from oc_diagnostics import _dbg\n\n'
265 'func = "my_function"\n'
266 '_dbg("MY-DOMAIN", func, "starting processing", 2)'
269 "Using print() instead of _dbg(), or having a non-trivial "
270 "module with no diagnostics calls at all."
272 spec_reference=
"Spec S11 - Diagnostics System, S14 - Linter Specification",
274 profiles=[
"compact",
"strict"],
278 label=
"_dbg import source",
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."
284 correct_pattern=
'from oc_diagnostics import _dbg',
286 "Importing _dbg from a local module, aliasing it "
287 "(import _dbg as dbg), or defining a local _dbg function."
289 spec_reference=
"Spec S11 - Diagnostics System",
291 profiles=[
"compact",
"strict"],
295 label=
'func = "..." variable pattern',
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."
303 'def process_data(items: list) -> None:\n'
304 ' func = "process_data"\n'
305 ' _dbg("MY-DOMAIN", func, "processing", 2)'
308 "Calling _dbg() without defining func first, or defining func "
309 "with a name that doesn't match the enclosing function."
311 spec_reference=
"Spec S9 - Function Structure and Helper Extraction",
313 profiles=[
"compact",
"strict"],
317 label=
"Regression test exists",
319 "Every module must have a corresponding test file. Testing is "
320 "non-negotiable in Option C - no untested code reaches production."
323 "Module: src/my_module.py\n"
324 "Test: tests/test_my_module.py"
327 "Creating a module without a companion test_<name>.py file, "
328 "or placing the test in the wrong directory."
330 spec_reference=
"Spec S3 - Testing Philosophy and Regression Requirements",
336 severity=SEVERITY_ADVISORY,
340 label=
"Type hint annotations",
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."
347 "def calculate_total(items: list[dict], tax_rate: float = 0.0) -> float:\n"
351 "Missing return type annotation (use -> None for void), "
352 "omitting parameter types, or using bare 'list' instead of "
355 spec_reference=
"Spec S14 - Type hints enforcement",
357 profiles=[
"compact",
"strict"],
360 name=
"no_hardcoded_secrets",
361 label=
"No hardcoded secrets",
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 "
370 'api_key = os.environ["API_KEY"]'
373 'Assigning a literal string to a variable named api_key, '
374 'password, secret, or token: api_key = "sk-abc123..."'
376 spec_reference=
"Spec S6b - Secret Hygiene",
378 profiles=[
"compact",
"strict"],
381 name=
"dbg_assert_safety",
382 label=
"_dbg assert safety",
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."
390 '_dbg("DOMAIN", func, f"count={len(items)}", 3)'
393 "Passing a function call with side effects as the message "
394 "argument, or using a variable instead of a literal for the "
397 spec_reference=
"Spec S6a - Safety Rules",
402 name=
"prompt_invariants",
403 label=
"OC Chat prompt invariants",
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."
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."
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."
426 "Plan 135 section 4.7; inner-loop.md section 8; Plan 140 "
427 "(invariant lint rules)"
436 severity=SEVERITY_ADVISORY,
438 "legacy_file_scoped_waiver":
RuleInfo(
439 name=
"legacy_file_scoped_waiver",
440 label=
"Secret waiver relies on legacy whole-file scope",
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."
460 'PATTERN = "..." # OCT-LINT: disable=<rule> reason="regex, not a credential"\n'
462 "# ...or, for deliberate and greppable whole-file scope:\n"
463 '# OCT-LINT: disable-file=<rule> reason="generated fixture module"'
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."
471 spec_reference=
"Spec S6b - Secret Hygiene; Plan 193 (line-scoped waivers)",
473 profiles=[
"compact",
"strict"],
477 severity=SEVERITY_ADVISORY,
497 """Return the severity tier (``blocking`` / ``advisory`` / ``info``)
498 for *rule_name* under *profile* (FS-C4).
500 Resolution order: profile-specific override → rule's default severity
501 → ``"blocking"`` for unknown rules (fail-safe).
503 info = RULE_REGISTRY.get(rule_name)
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:
510 return info.severity
if info.severity
in _VALID_SEVERITIES
else SEVERITY_BLOCKING