oct
Loading...
Searching...
No Matches
waivers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/waivers.py
4
5"""
6Purpose
7-------
8OI-540 - shared inline-waiver parsing for OCT-LINT comments.
9Plan 193 - waiver SCOPE: a waiver excuses the finding beside it, not the
10whole file.
11
12Extracted from :mod:`oct.linter.oct_lint` so that both the linter and
13the quality gate (:mod:`oct.git.quality_gate`) can import the same
14parser without the quality gate depending on the linter.
15
16Plan 193 (sec) closed a hole this module sat at the centre of. The
17parser always returned line numbers, but BOTH consumers threw them away
18and treated any waiver as whole-file suppression - so a real credential
19pasted anywhere into one of the ~200 files carrying a
20``no_hardcoded_secrets`` waiver committed clean. Scope is now:
21
22- ``# OCT-LINT: disable=<rule> reason="…"`` - LINE scope. Covers its own
23 line and the immediately following line (:data:`WAIVER_LINE_SPAN`), so
24 both trailing-comment and comment-above placements work and the waiver
25 travels with the code it excuses.
26- ``# OCT-LINT: disable-file=<rule> reason="…"`` - FILE scope. Whole-file
27 blindness stays available, but as a deliberate, greppable choice.
28- A bare ``disable=`` waiver adjacent to NO finding of its rule keeps its
29 historical file-wide power (so the pre-193 tree does not storm the gate
30 in one landing) and reports its line via
31 :attr:`WaiverOutcome.legacy_lines`, which the linter surfaces as the
32 advisory ``legacy_file_scoped_waiver`` burn-down finding. When that
33 count reaches zero the legacy branch can be deleted as a no-op.
34
35Reasonless waivers remain inert in both forms (pre-existing rule).
36
37Responsibilities
38----------------
39- Host :data:`_WAIVER_RE` - the canonical regex for
40 ``# OCT-LINT: disable[-file]=<rule> [reason="..."]`` comments.
41- Provide :func:`_comment_tokens` - tokenise source so only real
42 comments can carry a waiver.
43- Provide :func:`parse_inline_waivers` - returns a mapping of line
44 numbers to ``{"rule": str, "reason": str|None, "scope": str}`` dicts.
45- Provide :func:`inline_disabled_rules` - the FILE-WIDE answer the
46 linter reaches from that mapping, written once so every consumer
47 reaches it by the same route (plan 277).
48- Provide :func:`split_scanner_findings` - recover per-finding line
49 numbers from the secret scanner's ``"; "``-joined message.
50- Provide :func:`apply_waivers` - scope waivers over those findings and
51 return a :class:`WaiverOutcome`.
52
53Diagnostics
54-----------
55Domain: OCT-CORE
56Levels:
57 L2 - (none) pure parsing helper; it reports no lifecycle of its own
58 L3 - (none) callers report waiver outcomes in their own domains
59 L4 - parse and scope entry: input size, and the rule being scoped
60
61Contracts
62---------
63- Must not import :mod:`oct.linter.oct_lint` (no circular dependency).
64- Return shape is stable and additive - both linter and quality gate
65 depend on it. Plan 193 added the ``"scope"`` key; existing readers of
66 ``"rule"`` / ``"reason"`` are unaffected.
67- This module decides waiver SCOPE only. It contains no threshold, no
68 heuristic, and no secret pattern, and it never changes what the
69 scanner detects - only which detected findings are excused.
70- :func:`inline_disabled_rules` can never return a member of
71 :data:`LINE_SCOPED_RULES`, by construction. Those rules are scoped per
72 finding by :func:`apply_waivers` and must never be switched off
73 wholesale by a consumer that reads this set (plan 277 - the reason
74 G-D4 is untouched by the format pass honouring waivers).
75- A waiver is only honoured when it appears in a real ``COMMENT``
76 token (193 R1). Waiver-shaped text inside a string literal is inert,
77 so a file that documents or tests the waiver syntax cannot waive
78 itself. Unparseable input yields NO waivers (fail closed).
79- :func:`apply_waivers` is fail-closed: any finding whose line cannot be
80 recovered (line ``0``) is unreachable by line scope and survives
81 unless an explicit file-scope waiver covers it.
82
83Dependencies
84------------
85- re (waiver + finding-prefix pattern matching)
86- tokenize (comment-token extraction - 193 R1)
87- io (StringIO line reader fed to tokenize - 193 R1)
88- dataclasses (the frozen WaiverOutcome record)
89- oct.core.diagnostics (_dbg structured logger)
90"""
91
92from __future__ import annotations
93
94import io
95import re
96import tokenize
97from dataclasses import dataclass
98from oct.core.diagnostics import _dbg
99
100
101#: A line-scoped waiver covers its own line and the next one. Two lines
102#: is what makes both placements work: a trailing comment sits ON the
103#: offending line, a standalone comment sits ABOVE it.
104WAIVER_LINE_SPAN = 2
105
106#: Rules whose findings carry recoverable line numbers, and which are
107#: therefore scoped per-line. Every other rule keeps whole-file waiver
108#: semantics - its check reports a verdict, not located findings, so
109#: there is nothing to scope against.
110LINE_SCOPED_RULES = frozenset({"no_hardcoded_secrets"})
111
112_WAIVER_RE = re.compile(
113 # OCT-LINT: disable=no_hardcoded_secrets reason="Waiver regex pattern, not a secret"
114 r'#\s*OCT-LINT:\s*disable(?P<file_scope>-file)?\s*=\s*(?P<rule>[a-z_]+)(?:\s+(?P<reason>.*))?',
115 re.IGNORECASE,
116)
117
118#: The scanner prefixes every violation with ``L<lineno>: ``.
119_FINDING_PREFIX_RE = re.compile(r'^L(?P<lineno>\d+):\s')
120
121#: Cheap pre-filter for :func:`inline_disabled_rules`. Every waiver must
122#: reach :data:`_WAIVER_RE`, which requires this marker, so text without
123#: it cannot carry one and need not be tokenised at all. Case-insensitive
124#: because ``_WAIVER_RE`` is: the two must agree, or the fast path would
125#: silently drop a waiver the slow path honours.
126_WAIVER_MARKER_RE = re.compile(r'OCT-LINT', re.IGNORECASE)
127
128
129@dataclass(frozen=True)
131 """The result of scoping a rule's waivers over a scanner message.
132
133 ``surviving`` and ``suppressed`` are ``(lineno, text)`` pairs; a
134 ``lineno`` of ``0`` means the scanner reported no location (e.g. the
135 unparseable-file message), which no line waiver can reach.
136
137 ``legacy_lines`` lists the bare ``disable=`` waivers that suppressed
138 findings ONLY by falling back to pre-193 whole-file semantics. It is
139 empty whenever the legacy branch was not exercised - including when
140 a stale waiver had nothing to suppress - so it counts exactly the
141 files that would change verdict if the legacy branch were deleted.
142 """
143
144 surviving: tuple[tuple[int, str], ...]
145 suppressed: tuple[tuple[int, str], ...]
146 legacy_lines: tuple[int, ...]
147
148 @property
149 def blocked(self) -> bool:
150 """True when at least one finding survived every waiver."""
151 return bool(self.surviving)
152
153 @property
154 def message(self) -> str:
155 """The surviving findings re-joined in the scanner's own format."""
156 return "; ".join(text for _lineno, text in self.surviving)
157
158 @property
159 def first_line(self) -> int:
160 """Line of the first surviving finding (``0`` when none survive)."""
161 return self.surviving[0][0] if self.surviving else 0
162
163
164def _comment_tokens(text: str) -> list[tuple[int, str]]:
165 """Return ``(lineno, comment_text)`` for every real comment in *text*.
166
167 Plan 193 R1 - a waiver is a COMMENT, so only comment tokens are
168 offered to the waiver regex. Waiver-shaped text inside a string
169 literal (a docstring that documents the syntax, a test fixture that
170 exercises it, a rule-registry example) is therefore inert, and can
171 no longer silently waive the file that merely talks about waivers.
172
173 FAIL CLOSED: if the source cannot be tokenised, this returns no
174 comments at all, so the caller honours NO waivers and every finding
175 survives. That matches the 170/176 doctrine - an unparseable file
176 must never gain suppression it could not earn.
177 """
178 comments: list[tuple[int, str]] = []
179 try:
180 for token in tokenize.generate_tokens(io.StringIO(text).readline):
181 if token.type == tokenize.COMMENT:
182 comments.append((token.start[0], token.string))
183 except (tokenize.TokenError, SyntaxError, IndentationError,
184 ValueError, UnicodeDecodeError):
185 return []
186 return comments
187
188
189def parse_inline_waivers(text: str) -> dict[int, dict]:
190 """Parse ``# OCT-LINT: disable[-file]=<rule> [reason]`` comments from source.
191
192 Returns a dict mapping line numbers (1-based) to
193 ``{"rule": str, "reason": str|None, "scope": "line"|"file"}``.
194
195 ``scope`` is ``"file"`` only for the explicit ``disable-file=`` form;
196 the bare ``disable=`` form is ``"line"``. Waivers with no reason are
197 still returned (the linter warns about them) but callers must treat
198 them as inert.
199
200 Plan 193 R1: only real comment tokens are considered - see
201 :func:`_comment_tokens` for the rationale and the fail-closed
202 behaviour on unparseable input.
203 """
204 func = "parse_inline_waivers"
205 _dbg("OCT-CORE", func, f"scanning {len(text)} chars for waivers", 4)
206 waivers: dict[int, dict] = {}
207 for lineno, comment in _comment_tokens(text):
208 m = _WAIVER_RE.search(comment)
209 if m:
210 reason = (m.group("reason") or "").strip() or None
211 waivers[lineno] = {
212 "rule": m.group("rule").lower(),
213 "reason": reason,
214 "scope": "file" if m.group("file_scope") else "line",
215 }
216 return waivers
217
218
219def inline_disabled_rules(text: str) -> frozenset[str]:
220 """Return the rules *text*'s inline waivers switch off for the whole file.
221
222 This is the linter's own set, and the ONE place it is written. It is
223 :func:`oct.linter.oct_lint._lint_single_file`'s ``waived_rules``
224 verbatim - a waiver counts when it carries a reason and names a rule
225 outside :data:`LINE_SCOPED_RULES`, and its ``scope`` is not consulted,
226 because for a rule whose check reports a verdict rather than located
227 findings there is nothing to scope against. That is why the bare
228 ``disable=`` form disables its rule file-wide here exactly as the
229 linter's ``_rule_active`` gate has always let it.
230
231 Plan 277 added this so the quality gate's format pass
232 (:func:`oct.git.quality_gate._run_format_pass`) and the health
233 dashboard (:mod:`oct.health.oct_health`) can reach the linter's
234 answer without restating it. Before, each pass knew a different
235 subset of what excuses a file, and a module the operator had ruled
236 exempt from ``dbg_import`` linted clean and was refused by the format
237 pass on the same rule, in the same commit.
238
239 A member of :data:`LINE_SCOPED_RULES` can never appear in the result.
240 Those rules are excused per finding by :func:`apply_waivers` and a
241 consumer that switched one off file-wide from here would be handing
242 back exactly the whole-file blindness plan 193 (sec) closed.
243
244 FAIL CLOSED on unparseable input: :func:`parse_inline_waivers` yields
245 no waivers when the source cannot be tokenised, so this yields no
246 disabled rules and every check runs.
247 """
248 func = "inline_disabled_rules"
249 if not _WAIVER_MARKER_RE.search(text):
250 return frozenset()
251 disabled = frozenset(
252 waiver["rule"] for waiver in parse_inline_waivers(text).values()
253 if waiver["reason"] is not None
254 and waiver["rule"] not in LINE_SCOPED_RULES
255 )
256 _dbg("OCT-CORE", func, f"waivers disable {sorted(disabled)}", 4)
257 return disabled
258
259
260def split_scanner_findings(message: str) -> list[tuple[int, str]]:
261 """Split a scanner message into ``(lineno, text)`` findings.
262
263 :func:`oct.tools.secret_scanner.check_no_hardcoded_secrets` joins its
264 violations with ``"; "`` and prefixes each with ``L<lineno>: ``. A
265 truncated preview may itself contain ``"; "``, so a fragment without
266 that prefix is folded back into the preceding finding rather than
267 being mistaken for a new one. A leading fragment with no prefix (the
268 unparseable-file message) is reported at line ``0``, which no line
269 waiver can reach.
270 """
271 findings: list[tuple[int, str]] = []
272 if not message:
273 return findings
274 for fragment in message.split("; "):
275 m = _FINDING_PREFIX_RE.match(fragment)
276 if m is None and findings:
277 lineno, text = findings[-1]
278 findings[-1] = (lineno, f"{text}; {fragment}")
279 continue
280 findings.append((int(m.group("lineno")) if m else 0, fragment))
281 return findings
282
283
284def apply_waivers(message: str, waivers: dict[int, dict], rule: str) -> WaiverOutcome:
285 """Scope *rule*'s waivers over the findings in *message*.
286
287 *waivers* is the mapping returned by :func:`parse_inline_waivers`.
288 Resolution order:
289
290 1. An explicit ``disable-file=`` waiver for *rule* suppresses every
291 finding (deliberate whole-file blindness).
292 2. Each line waiver suppresses findings on its own line and the next
293 (:data:`WAIVER_LINE_SPAN`).
294 3. If findings survive AND at least one bare waiver matched nothing,
295 that waiver falls back to pre-193 whole-file suppression and is
296 reported in :attr:`WaiverOutcome.legacy_lines`.
297
298 Reasonless waivers are ignored at every step.
299 """
300 func = "apply_waivers"
301 _dbg("OCT-CORE", func, f"scoping {rule} waivers over the findings", 4)
302 findings = tuple(split_scanner_findings(message))
303 if not findings:
304 return WaiverOutcome((), (), ())
305
306 active = {
307 lineno: w for lineno, w in waivers.items()
308 if w.get("rule") == rule and w.get("reason")
309 }
310 if any(w.get("scope", "line") == "file" for w in active.values()):
311 return WaiverOutcome((), findings, ())
312
313 line_waivers = sorted(
314 lineno for lineno, w in active.items()
315 if w.get("scope", "line") == "line"
316 )
317
318 covered: set[int] = set()
319 matched: set[int] = set()
320 for waiver_line in line_waivers:
321 for index, (finding_line, _text) in enumerate(findings):
322 if finding_line and waiver_line <= finding_line < waiver_line + WAIVER_LINE_SPAN:
323 covered.add(index)
324 matched.add(waiver_line)
325
326 surviving = tuple(f for i, f in enumerate(findings) if i not in covered)
327 legacy_lines = tuple(ln for ln in line_waivers if ln not in matched)
328
329 # Legacy ramp: a bare waiver that matched nothing keeps its pre-193
330 # file-wide power, but only while findings would otherwise survive -
331 # so a stale waiver with nothing to suppress is never counted.
332 if surviving and legacy_lines:
333 return WaiverOutcome((), findings, legacy_lines)
334
335 suppressed = tuple(f for i, f in enumerate(findings) if i in covered)
336 return WaiverOutcome(surviving, suppressed, ())
bool blocked(self)
Definition waivers.py:149
int first_line(self)
Definition waivers.py:159
dict[int, dict] parse_inline_waivers(str text)
Definition waivers.py:189
list[tuple[int, str]] _comment_tokens(str text)
Definition waivers.py:164
WaiverOutcome apply_waivers(str message, dict[int, dict] waivers, str rule)
Definition waivers.py:284
list[tuple[int, str]] split_scanner_findings(str message)
Definition waivers.py:260
frozenset[str] inline_disabled_rules(str text)
Definition waivers.py:219