oct
Loading...
Searching...
No Matches
thresholds.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/complexity/thresholds.py
4
5"""
6Purpose
7-------
8Turn measurements into a gate-callable verdict with a real exit code.
9
10Responsibilities
11----------------
12- Resolve thresholds from explicit flags over an ``.octrc.json`` section.
13- Apply the don't-get-worse ratchet against a recorded baseline.
14- Name every breaching function as ``relpath:line qualname CC=n [CRAP=m]``.
15- Carry the verdict as both machine fields and compact ASCII lines.
16
17Diagnostics
18-----------
19Domain: OCT-COMPLEXITY
20Levels:
21 L2 - verdict lifecycle
22 L3 - threshold and breach details
23 L4 - deep tracing
24
25Contracts
26---------
27- No active threshold means no verdict, so today's output is unchanged.
28- Exit 0 is clean, exit 2 is at least one breach, exit 3 is a named refusal.
29- A malformed threshold is refused by name and never silently ignored.
30- The module imports only the Python standard library and sibling measurement
31 modules, so it stays callable from a zero-dependency context.
32
33Dependencies
34------------
35- oct.complexity.service (result rows and relative-path display)
36- oct.complexity.baseline (lazy; BaselineEntry type hints for the ratchet comparison)
37"""
38
39# OCT-LINT: disable-file=dbg_usage reason="The verdict layer emits only its explicit report and must not add diagnostics noise to JSON or ASCII output"
40# OCT-LINT: disable-file=dbg_import reason="Diagnostics would contaminate explicit JSON and ASCII verdict output"
41
42from __future__ import annotations
43
44from dataclasses import dataclass
45from pathlib import Path
46from typing import TYPE_CHECKING, Mapping, Sequence
47
48from oct.complexity.service import (
49 FunctionResult,
50 function_identity,
51 relative_display_path,
52)
53
54if TYPE_CHECKING: # pragma: no cover - typing only, avoids an import cycle
55 from oct.complexity.baseline import BaselineEntry
56
57
58#: Advisory starting values quoted in the CLI help text. They are conventions
59#: for operators to adopt deliberately, never defaults applied on their behalf.
60ADVISORY_MAX_CC = 15
61ADVISORY_MAX_CRAP = 30
62
63EXIT_CLEAN = 0
64EXIT_BREACH = 2
65EXIT_REFUSAL = 3
66
67SOURCE_FLAG = "flag"
68SOURCE_CONFIG = "octrc"
69
70CONFIG_SECTION = "complexity"
71
72
73class GateRefusalError(ValueError):
74 """Raised when a gate-mode invocation cannot be honestly evaluated."""
75
76
77@dataclass(frozen=True, slots=True)
79 """The effective CC and CRAP limits plus where each one came from."""
80
81 max_cc: int | None = None
82 max_crap: float | None = None
83 max_cc_source: str | None = None
84 max_crap_source: str | None = None
85
86 @property
87 def active(self) -> bool:
88 """Return whether any threshold is in force for this invocation."""
89 return self.max_cc is not None or self.max_crap is not None
90
91 def summary(self) -> str:
92 """Return a compact ASCII description of the active limits."""
93 parts = []
94 if self.max_cc is not None:
95 parts.append(f"max CC {self.max_cc}")
96 if self.max_crap is not None:
97 parts.append(f"max CRAP {_number(self.max_crap)}")
98 return ", ".join(parts) if parts else "no thresholds"
99
100
101@dataclass(frozen=True, slots=True)
102class Breach:
103 """One function that fails the active thresholds, with its numbers."""
104
105 file: str
106 lineno: int
107 qualname: str
108 complexity: int
109 crap: float | None
110 metrics: tuple[str, ...]
111 reason: str
112 baseline_complexity: int | None = None
113 baseline_crap: float | None = None
114
115 def to_dict(self) -> dict[str, object]:
116 """Return a JSON-ready breach record."""
117 return {
118 "file": self.file,
119 "lineno": self.lineno,
120 "qualname": self.qualname,
121 "complexity": self.complexity,
122 "crap": self.crap,
123 "metrics": list(self.metrics),
124 "reason": self.reason,
125 "baseline_complexity": self.baseline_complexity,
126 "baseline_crap": self.baseline_crap,
127 }
128
129 def format_line(self) -> str:
130 """Return the pinned ``relpath:line qualname CC=n [CRAP=m]`` line."""
131 line = f"{self.file}:{self.lineno} {self.qualname} CC={self.complexity}"
132 if self.crap is not None:
133 line = f"{line} CRAP={self.crap:.2f}"
134 return f"{line} ({self.reason})"
135
136
137@dataclass(frozen=True, slots=True)
139 """Everything a gate or an agent needs to branch on one measurement run."""
140
141 status: str
142 exit_code: int
143 thresholds: Thresholds
144 breaches: tuple[Breach, ...]
145 baseline: str | None = None
146 baseline_functions: int | None = None
147 baseline_written: str | None = None
148 baseline_written_functions: int | None = None
149
150 def to_dict(self) -> dict[str, object]:
151 """Return the machine-readable verdict envelope."""
152 return {
153 "status": self.status,
154 "exit_code": self.exit_code,
155 "max_cc": self.thresholds.max_cc,
156 "max_crap": self.thresholds.max_crap,
157 "max_cc_source": self.thresholds.max_cc_source,
158 "max_crap_source": self.thresholds.max_crap_source,
159 "baseline": self.baseline,
160 "baseline_functions": self.baseline_functions,
161 "baseline_written": self.baseline_written,
162 "baseline_written_functions": self.baseline_written_functions,
163 "breach_count": len(self.breaches),
164 "breaches": [breach.to_dict() for breach in self.breaches],
165 }
166
167
169 flag_max_cc: int | None,
170 flag_max_crap: float | None,
171 config: dict | None,
172) -> Thresholds:
173 """Resolve the effective thresholds, with flags overriding the config.
174
175 ``config`` is the whole ``.octrc.json`` document; only its optional
176 ``complexity`` object is read. A malformed value raises
177 :class:`GateRefusalError` rather than degrading to "no threshold",
178 because a silently inactive gate reads exactly like a passing one.
179 """
180 section = config.get(CONFIG_SECTION) if isinstance(config, dict) else None
181 if section is not None and not isinstance(section, dict):
182 raise GateRefusalError(
183 f"octrc '{CONFIG_SECTION}' must be a JSON object, got "
184 f"{type(section).__name__}"
185 )
186 settings = section or {}
187 config_cc = _coerce_limit(settings.get("max_cc"), "max_cc", as_int=True)
188 config_crap = _coerce_limit(settings.get("max_crap"), "max_crap", as_int=False)
189 max_cc = flag_max_cc if flag_max_cc is not None else config_cc
190 max_crap = flag_max_crap if flag_max_crap is not None else config_crap
191 return Thresholds(
192 max_cc=max_cc,
193 max_crap=max_crap,
194 max_cc_source=_source(flag_max_cc, config_cc),
195 max_crap_source=_source(flag_max_crap, config_crap),
196 )
197
198
199def _source(flag_value: object, config_value: object) -> str | None:
200 """Return which input supplied the effective limit, if any did."""
201 if flag_value is not None:
202 return SOURCE_FLAG
203 if config_value is not None:
204 return SOURCE_CONFIG
205 return None
206
207
209 value: object,
210 key: str,
211 as_int: bool,
212) -> int | float | None:
213 """Validate one configured limit or refuse it by name."""
214 if value is None:
215 return None
216 if isinstance(value, bool) or not isinstance(value, (int, float)):
217 raise GateRefusalError(
218 f"octrc '{CONFIG_SECTION}.{key}' must be a positive number, got "
219 f"{type(value).__name__}"
220 )
221 if as_int:
222 if not isinstance(value, int) or value < 1:
223 raise GateRefusalError(
224 f"octrc '{CONFIG_SECTION}.{key}' must be an integer of at "
225 f"least 1, got {value!r}"
226 )
227 return value
228 if value <= 0:
229 raise GateRefusalError(
230 f"octrc '{CONFIG_SECTION}.{key}' must be greater than 0, got {value!r}"
231 )
232 return float(value)
233
234
236 results: Sequence[FunctionResult],
237 thresholds: Thresholds,
238 root: str | Path,
239 baseline: Mapping[str, "BaselineEntry"] | None = None,
240) -> tuple[Breach, ...]:
241 """Return every function that fails the active thresholds, worst first.
242
243 Without a baseline, failing means exceeding a threshold. With one, the
244 ratchet applies: a function the baseline never saw fails on the threshold
245 alone, and a recorded function fails only when it is BOTH over the
246 threshold and strictly worse than its recorded value. Existing debt held
247 at or below its recorded value never fails - this is hygiene, not
248 rigidity.
249
250 ``results`` arrives already ordered worst-first by the service, so the
251 breach list names the most severe function first.
252 """
253 if not thresholds.active:
254 return ()
255 breaches = []
256 for item in results:
257 metrics = _breached_metrics(item, thresholds)
258 if not metrics:
259 continue
260 recorded = None
261 if baseline is not None:
262 recorded = baseline.get(
263 function_identity(item.file, item.qualname, root)
264 )
265 if recorded is not None:
266 metrics = _worsened_metrics(item, metrics, recorded)
267 if not metrics:
268 continue
269 breaches.append(
270 Breach(
271 file=relative_display_path(item.file, root),
272 lineno=item.lineno,
273 qualname=item.qualname,
274 complexity=item.complexity,
275 crap=item.crap,
276 metrics=metrics,
277 reason=_reason(baseline, recorded, metrics),
278 baseline_complexity=None if recorded is None else recorded.complexity,
279 baseline_crap=None if recorded is None else recorded.crap,
280 )
281 )
282 return tuple(breaches)
283
284
286 item: FunctionResult,
287 metrics: tuple[str, ...],
288 recorded: "BaselineEntry",
289) -> tuple[str, ...]:
290 """Keep only the over-threshold metrics that are worse than recorded.
291
292 A recorded ``crap`` of ``None`` means the baseline was written without
293 coverage data, so there is no accepted value to sit at or below. That
294 fails closed: an unrecorded metric is never grandfathered.
295 """
296 kept = []
297 for metric in metrics:
298 if metric == "cc" and item.complexity > recorded.complexity:
299 kept.append(metric)
300 elif metric == "crap" and (
301 recorded.crap is None
302 or (item.crap is not None and item.crap > recorded.crap)
303 ):
304 kept.append(metric)
305 return tuple(kept)
306
307
309 baseline: Mapping[str, "BaselineEntry"] | None,
310 recorded: "BaselineEntry | None",
311 metrics: tuple[str, ...],
312) -> str:
313 """Return the human-readable cause of one breach."""
314 if baseline is None:
315 return "over-threshold"
316 if recorded is None:
317 return "not in baseline"
318 parts = []
319 if "cc" in metrics:
320 parts.append(f"worse than baseline CC={recorded.complexity}")
321 if "crap" in metrics:
322 parts.append(
323 "no baseline CRAP recorded"
324 if recorded.crap is None
325 else f"worse than baseline CRAP={recorded.crap:.2f}"
326 )
327 return "; ".join(parts)
328
329
331 item: FunctionResult,
332 thresholds: Thresholds,
333) -> tuple[str, ...]:
334 """Return which active metrics this one function exceeds."""
335 metrics = []
336 if thresholds.max_cc is not None and item.complexity > thresholds.max_cc:
337 metrics.append("cc")
338 if (
339 thresholds.max_crap is not None
340 and item.crap is not None
341 and item.crap > thresholds.max_crap
342 ):
343 metrics.append("crap")
344 return tuple(metrics)
345
346
348 thresholds: Thresholds,
349 breaches: Sequence[Breach],
350 baseline: str | None = None,
351 baseline_functions: int | None = None,
352 baseline_written: str | None = None,
353 baseline_written_functions: int | None = None,
354) -> Verdict:
355 """Return the verdict for one gate-mode invocation."""
356 breached = bool(breaches)
357 return Verdict(
358 status="breached" if breached else "clean",
359 exit_code=EXIT_BREACH if breached else EXIT_CLEAN,
360 thresholds=thresholds,
361 breaches=tuple(breaches),
362 baseline=baseline,
363 baseline_functions=baseline_functions,
364 baseline_written=baseline_written,
365 baseline_written_functions=baseline_written_functions,
366 )
367
368
369def render_verdict(verdict: Verdict) -> str:
370 """Render the verdict as compact ASCII lines for terminal users."""
371 lines = []
372 if verdict.baseline_written is not None:
373 lines.append(
374 f"Baseline written: {verdict.baseline_written} "
375 f"({verdict.baseline_written_functions} function(s))."
376 )
377 if verdict.baseline is not None:
378 lines.append(
379 f"Baseline: {verdict.baseline} "
380 f"({verdict.baseline_functions} function(s) recorded)."
381 )
382 lines.extend(_render_outcome(verdict))
383 return "\n".join(lines)
384
385
386def _render_outcome(verdict: Verdict) -> list[str]:
387 """Return the verdict's outcome lines, breaches included."""
388 if not verdict.thresholds.active:
389 return ["No thresholds active; measurement only."]
390 limits = verdict.thresholds.summary()
391 if not verdict.breaches:
392 return [f"No threshold breaches ({limits})."]
393 lines = [f"Threshold breaches ({limits}):"]
394 lines.extend(f" {breach.format_line()}" for breach in verdict.breaches)
395 lines.append(
396 f"{len(verdict.breaches)} function(s) over the active thresholds."
397 )
398 return lines
399
400
401def _number(value: float) -> str:
402 """Render a limit without a trailing ``.0`` on whole numbers."""
403 return str(int(value)) if float(value).is_integer() else str(value)
dict[str, object] to_dict(self)
dict[str, object] to_dict(self)
tuple[str,...] _breached_metrics(FunctionResult item, Thresholds thresholds)
str _number(float value)
tuple[str,...] _worsened_metrics(FunctionResult item, tuple[str,...] metrics, "BaselineEntry" recorded)
str|None _source(object flag_value, object config_value)
Thresholds resolve_thresholds(int|None flag_max_cc, float|None flag_max_crap, dict|None config)
str _reason(Mapping[str, "BaselineEntry"]|None baseline, "BaselineEntry | None" recorded, tuple[str,...] metrics)
list[str] _render_outcome(Verdict verdict)
int|float|None _coerce_limit(object value, str key, bool as_int)
tuple[Breach,...] evaluate(Sequence[FunctionResult] results, Thresholds thresholds, str|Path root, Mapping[str, "BaselineEntry"]|None baseline=None)
str render_verdict(Verdict verdict)
Verdict build_verdict(Thresholds thresholds, Sequence[Breach] breaches, str|None baseline=None, int|None baseline_functions=None, str|None baseline_written=None, int|None baseline_written_functions=None)