oct
Loading...
Searching...
No Matches
lint_cache.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/linter/lint_cache.py
4
5"""
6Purpose
7-------
8FS-510 - incremental lint cache. Content-hash keyed so unchanged files
9are not re-linted on subsequent runs.
10
11Responsibilities
12----------------
13- Compute deterministic content hashes for source files.
14- Compute the linter's own code identity, so an edit to a module that
15 decides a verdict invalidates every cached verdict (plan 265).
16- Store and retrieve per-file lint results from a JSON cache file.
17- Invalidate entries when file content, active rules, the OCT version or
18 the linter's own code change.
19- Thread-safe read/write via ``threading.Lock``.
20
21Diagnostics
22-----------
23Domain: OCT-LINTER
24Levels:
25 L2 - (none) the cache save is reported at L3
26 L3 - cache hits/misses, the entry count at save, the header field
27 that caused a whole-store drop, and an unreadable linter package
28 L4 - cache I/O details, including the path loaded from
29
30Contracts
31---------
32- Cache file lives at ``.option_c/cache/lint-cache.json``.
33- Corrupt or missing cache file is silently replaced (no crash).
34- Cache is never authoritative - a ``--no-cache`` flag bypasses reads.
35- The linter identity is a STORE-level property, not a per-entry one: a
36 linter edit invalidates everything, so ``get``/``put`` keep their
37 signatures and the per-entry key is unchanged.
38- An unreadable linter package yields the stable string ``"unavailable"``
39 rather than a guessed digest, so a frozen install still caches.
40- Every user of the shared store (``oct lint`` and ``oct health``) builds
41 it with :func:`current_linter_identity`. Two users with different
42 identities discard each other's entries on every load.
43
44Dependencies
45------------
46- oct.core.diagnostics (_dbg structured logger)
47"""
48
49from __future__ import annotations
50
51import hashlib
52import json
53import threading
54from dataclasses import dataclass, field, asdict
55from pathlib import Path
56from typing import Any
57from oct.core.diagnostics import _dbg
58
59
60_SCHEMA_VERSION = "1.0"
61
62#: Plan 265: the identity of a linter package that cannot be read - a
63#: frozen or zipped install. Stable, so such an install still caches;
64#: never a guessed digest.
65IDENTITY_UNAVAILABLE = "unavailable"
66
67#: Plan 265: verdict-deciding files that live outside the linter package,
68#: as paths relative to the package's parent (``oct/oct/``). The three
69#: ``deps/`` modules decide the ``dependencies_match`` verdict (2026-09-28).
70_EXTRA_IDENTITY_FILES = (
71 "tools/secret_scanner.py",
72 "deps/dependency_check.py",
73 "deps/import_graph.py",
74 "deps/oct_deps.py",
75)
76
77
78def content_hash(text: str) -> str:
79 """Return a SHA-256 hex digest of *text*."""
80 return hashlib.sha256(text.encode("utf-8")).hexdigest()
81
82
83def rules_hash(active_rules: dict[str, bool]) -> str:
84 """Return a SHA-256 hex digest of the sorted active-rules dict."""
85 canonical = json.dumps(active_rules, sort_keys=True)
86 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
87
88
89def linter_identity_hash(package_dir: Path) -> str:
90 """Return a SHA-256 hex digest of the linter's own code (plan 265).
91
92 The digest covers every ``*.py`` directly under *package_dir* plus
93 the verdict-deciding files named in ``_EXTRA_IDENTITY_FILES``, sorted
94 by their path relative to *package_dir*'s parent. Each file's
95 contribution is prefixed by that relative path, so a rename moves the
96 digest even when no byte of any file changes.
97
98 Returns ``IDENTITY_UNAVAILABLE`` when the package cannot be read
99 (a frozen or zipped install), logging the reason once; a guessed
100 digest would be worse than a stable sentinel.
101 """
102 func = "linter_identity_hash"
103 anchor = package_dir.parent
104 if not package_dir.is_dir():
105 _dbg(
106 "OCT-LINTER", func,
107 f"linter identity unavailable: {package_dir} is not a directory", 3,
108 )
109 return IDENTITY_UNAVAILABLE
110 try:
111 members = [
112 (f"{package_dir.name}/{path.name}", path)
113 for path in package_dir.glob("*.py")
114 if path.is_file()
115 ]
116 for relative in _EXTRA_IDENTITY_FILES:
117 candidate = anchor / relative
118 if candidate.is_file():
119 members.append((relative, candidate))
120 members.sort(key=lambda member: member[0])
121 digest = hashlib.sha256()
122 for relative, path in members:
123 digest.update(relative.encode("utf-8"))
124 digest.update(b"\0")
125 digest.update(path.read_bytes())
126 digest.update(b"\0")
127 except OSError as exc:
128 _dbg("OCT-LINTER", func, f"linter identity unavailable: {exc}", 3)
129 return IDENTITY_UNAVAILABLE
130 _dbg("OCT-LINTER", func, f"linter identity over {len(members)} file(s)", 4)
131 return digest.hexdigest()
132
133
135 """Return the identity of the linter package this module belongs to.
136
137 The one place that names the package :func:`linter_identity_hash`
138 covers, so every user of the shared store writes the same header.
139 Before 2026-09-28 ``oct health`` built its store without an identity,
140 so the header flipped between the digest and ``"unavailable"``, and
141 ``oct lint`` and ``oct health`` discarded each other's entries on
142 every alternation.
143 """
144 return linter_identity_hash(Path(__file__).resolve().parent)
145
146
147@dataclass
149 """One cached lint result for a single file."""
150
151 content_hash: str
152 rule_results: dict[str, bool]
153 json_detail: dict[str, Any]
154 oct_version: str
155 timestamp: str
156
157
159 """Thread-safe, content-addressed lint result cache.
160
161 Loads from *cache_path* on init; call :meth:`save` to persist.
162
163 *linter_identity* is the digest of the linter's own code (plan 265),
164 computed once per construction by :func:`linter_identity_hash`. It is
165 a whole-store property: a linter edit invalidates every entry, so it
166 lives in the header beside *oct_version* rather than in the key.
167 """
168
170 self,
171 cache_path: Path,
172 oct_version: str,
173 linter_identity: str = IDENTITY_UNAVAILABLE,
174 ) -> None:
175 self._path = cache_path
176 self._oct_version = oct_version
177 self._linter_identity = linter_identity
178 self._lock = threading.Lock()
179 self._entries: dict[str, dict] = {}
180 self._load()
181
182 def _load(self) -> None:
183 func = "_load"
184 _dbg("OCT-LINTER", func, f"loading lint cache from {self._path}", 4)
185 if not self._path.is_file():
186 return
187 try:
188 raw = json.loads(self._path.read_text(encoding="utf-8"))
189 if not isinstance(raw, dict):
190 return
191 if raw.get("_schema_version") != _SCHEMA_VERSION:
192 return
193 if raw.get("oct_version") != self._oct_version:
194 _dbg(
195 "OCT-LINTER", func,
196 "dropping the whole lint cache: oct_version differs", 3,
197 )
198 return
199 if raw.get("linter_identity") != self._linter_identity:
200 _dbg(
201 "OCT-LINTER", func,
202 "dropping the whole lint cache: linter_identity differs", 3,
203 )
204 return
205 entries = raw.get("entries")
206 if isinstance(entries, dict):
207 self._entries = entries
208 except (json.JSONDecodeError, OSError):
209 self._entries = {}
210
211 def get(self, file_hash: str, active_rules_hash: str) -> LintCacheEntry | None:
212 """Return cached entry if content + rules match, else ``None``."""
213 with self._lock:
214 key = f"{file_hash}:{active_rules_hash}"
215 entry = self._entries.get(key)
216 if entry is None:
217 return None
218 if entry.get("content_hash") != file_hash:
219 return None
220 return LintCacheEntry(
221 content_hash=entry["content_hash"],
222 rule_results=entry["rule_results"],
223 json_detail=entry["json_detail"],
224 oct_version=entry["oct_version"],
225 timestamp=entry["timestamp"],
226 )
227
228 def put(
229 self,
230 file_hash: str,
231 active_rules_hash: str,
232 entry: LintCacheEntry,
233 ) -> None:
234 """Store a lint result in the cache."""
235 with self._lock:
236 key = f"{file_hash}:{active_rules_hash}"
237 self._entries[key] = asdict(entry)
238
239 def save(self) -> None:
240 """Write the cache to disk. Creates parent directories if needed."""
241 func = "save"
242 _dbg("OCT-LINTER", func, f"saving lint cache: {self.entry_count} entries", 3)
243 with self._lock:
244 self._path.parent.mkdir(parents=True, exist_ok=True)
245 payload = {
246 "_schema_version": _SCHEMA_VERSION,
247 "oct_version": self._oct_version,
248 "linter_identity": self._linter_identity,
249 "entries": self._entries,
250 }
251 self._path.write_text(
252 json.dumps(payload, indent=2), encoding="utf-8",
253 )
254
255 @property
256 def entry_count(self) -> int:
257 """Number of cached entries (for diagnostics)."""
258 with self._lock:
259 return len(self._entries)
LintCacheEntry|None get(self, str file_hash, str active_rules_hash)
None put(self, str file_hash, str active_rules_hash, LintCacheEntry entry)
None __init__(self, Path cache_path, str oct_version, str linter_identity=IDENTITY_UNAVAILABLE)
str content_hash(str text)
Definition lint_cache.py:78
str rules_hash(dict[str, bool] active_rules)
Definition lint_cache.py:83
str linter_identity_hash(Path package_dir)
Definition lint_cache.py:89