oct
Loading...
Searching...
No Matches
lint_baseline.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/linter/lint_baseline.py
4
5"""
6Purpose
7-------
8FS-508 - lint baseline: snapshot current violations so subsequent runs
9can report only new vs resolved issues.
10
11Responsibilities
12----------------
13- Save a baseline from lint results to ``.option_c/cache/lint-baseline.json``.
14- Load an existing baseline from disk.
15- Diff current results against a baseline to find new and resolved violations.
16- Pick the blocking ones out of the new violations; only they fail
17 ``--against-baseline`` (operator ruling 2026-09-28).
18
19Diagnostics
20-----------
21Domain: OCT-LINTER
22Levels:
23 L2 - (none) the save, load and diff are reported at L3
24 L3 - baseline save/load/diff
25 L4 - (none)
26
27Contracts
28---------
29- Baseline file is JSON with ``_schema_version``, ``entries``, metadata.
30- Corrupt or missing file returns ``None`` (no crash).
31- Violations are keyed by ``(path, rule)`` for diff stability.
32
33Dependencies
34------------
35- oct (__version__, recorded in the baseline)
36- oct.core.diagnostics (_dbg structured logger)
37- oct.linter.rule_registry (SEVERITY_BLOCKING)
38"""
39
40from __future__ import annotations
41
42import json
43from dataclasses import dataclass, field, asdict
44from datetime import datetime
45from pathlib import Path
46
47from oct import __version__
48from oct.core.diagnostics import _dbg
49from oct.linter.rule_registry import SEVERITY_BLOCKING
50
51
52_SCHEMA_VERSION = "1.0"
53
54
55@dataclass
57 """One baselined violation."""
58
59 path: str
60 rule: str
61 message: str
62
63
64@dataclass
66 """A full baseline snapshot."""
67
68 entries: list[BaselineEntry]
69 oct_version: str
70 timestamp: str
71 profile: str
72
73
75 results: list[dict],
76 baseline_path: Path,
77 profile: str,
78) -> int:
79 """Extract violations from lint *results* and write to *baseline_path*.
80
81 Returns the number of violations saved.
82 """
83 func = "save_baseline"
84 _dbg("OCT-LINTER", func, f"saving baseline for profile {profile} to {baseline_path}", 3)
85 entries: list[BaselineEntry] = []
86 for r in results:
87 detail = r.get("json_detail", {})
88 rel_path = detail.get("path", r.get("rel", ""))
89 checks = detail.get("checks", {})
90 for rule_name, check in checks.items():
91 if not check.get("pass", True):
92 entries.append(BaselineEntry(
93 path=rel_path,
94 rule=rule_name,
95 message=check.get("message", ""),
96 ))
97
98 baseline = LintBaseline(
99 entries=entries,
100 oct_version=__version__,
101 timestamp=datetime.now().isoformat(),
102 profile=profile,
103 )
104
105 baseline_path.parent.mkdir(parents=True, exist_ok=True)
106 baseline_path.write_text(
107 json.dumps(
108 {
109 "_schema_version": _SCHEMA_VERSION,
110 **asdict(baseline),
111 },
112 indent=2,
113 ),
114 encoding="utf-8",
115 )
116 return len(entries)
117
118
119def load_baseline(baseline_path: Path) -> LintBaseline | None:
120 """Load a baseline from disk. Returns ``None`` on missing/corrupt file."""
121 func = "load_baseline"
122 _dbg("OCT-LINTER", func, f"loading baseline from {baseline_path}", 3)
123 if not baseline_path.is_file():
124 return None
125 try:
126 raw = json.loads(baseline_path.read_text(encoding="utf-8"))
127 if not isinstance(raw, dict):
128 return None
129 if raw.get("_schema_version") != _SCHEMA_VERSION:
130 return None
131 entries_raw = raw.get("entries", [])
132 if not isinstance(entries_raw, list):
133 return None
134 entries = [
135 BaselineEntry(path=e["path"], rule=e["rule"], message=e["message"])
136 for e in entries_raw
137 if isinstance(e, dict) and "path" in e and "rule" in e
138 ]
139 return LintBaseline(
140 entries=entries,
141 oct_version=raw.get("oct_version", ""),
142 timestamp=raw.get("timestamp", ""),
143 profile=raw.get("profile", ""),
144 )
145 except (json.JSONDecodeError, OSError, KeyError):
146 return None
147
148
150 results: list[dict],
151 baseline: LintBaseline,
152) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
153 """Compare current *results* against *baseline*.
154
155 Returns ``(new_violations, resolved_violations)`` where each item is
156 ``(path, rule, message)``.
157
158 A violation is identified by its ``(path, rule)`` pair. If a
159 ``(path, rule)`` appears in current results but not in the baseline,
160 it is "new". If it appears in the baseline but not in current results,
161 it is "resolved".
162 """
163 func = "diff_against_baseline"
164 _dbg("OCT-LINTER", func, "diffing results against the baseline", 3)
165 baseline_set: set[tuple[str, str]] = set()
166 baseline_msgs: dict[tuple[str, str], str] = {}
167 for entry in baseline.entries:
168 key = (entry.path, entry.rule)
169 baseline_set.add(key)
170 baseline_msgs[key] = entry.message
171
172 current_set: set[tuple[str, str]] = set()
173 current_msgs: dict[tuple[str, str], str] = {}
174 for r in results:
175 detail = r.get("json_detail", {})
176 rel_path = detail.get("path", r.get("rel", ""))
177 checks = detail.get("checks", {})
178 for rule_name, check in checks.items():
179 if not check.get("pass", True):
180 key = (rel_path, rule_name)
181 current_set.add(key)
182 current_msgs[key] = check.get("message", "")
183
184 new_keys = current_set - baseline_set
185 resolved_keys = baseline_set - current_set
186
187 new_violations = sorted(
188 [(p, r, current_msgs[(p, r)]) for p, r in new_keys],
189 )
190 resolved_violations = sorted(
191 [(p, r, baseline_msgs[(p, r)]) for p, r in resolved_keys],
192 )
193 return new_violations, resolved_violations
194
195
197 violations: list[tuple[str, str, str]],
198 results: list[dict],
199) -> list[tuple[str, str, str]]:
200 """The *violations* whose check is blocking in *results*.
201
202 ``oct lint --against-baseline`` exits 1 only for these (operator ruling
203 2026-09-28); a new advisory failure is listed but does not fail the
204 command. A check recording no severity counts as blocking, as the
205 compliance rule (FS-C4) treats it.
206 """
207 severity: dict[tuple[str, str], str] = {}
208 for r in results:
209 detail = r.get("json_detail", {})
210 rel_path = detail.get("path", r.get("rel", ""))
211 for rule_name, check in detail.get("checks", {}).items():
212 severity[(rel_path, rule_name)] = check.get("severity", SEVERITY_BLOCKING)
213 return [
214 v for v in violations
215 if severity.get((v[0], v[1]), SEVERITY_BLOCKING) == SEVERITY_BLOCKING
216 ]
LintBaseline|None load_baseline(Path baseline_path)
int save_baseline(list[dict] results, Path baseline_path, str profile)
list[tuple[str, str, str]] blocking_violations(list[tuple[str, str, str]] violations, list[dict] results)
tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]] diff_against_baseline(list[dict] results, LintBaseline baseline)