8FS-510 - incremental lint cache. Content-hash keyed so unchanged files
9are not re-linted on subsequent runs.
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``.
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
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.
46- oct.core.diagnostics (_dbg structured logger)
49from __future__
import annotations
54from dataclasses
import dataclass, field, asdict
55from pathlib
import Path
57from oct.core.diagnostics
import _dbg
60_SCHEMA_VERSION =
"1.0"
65IDENTITY_UNAVAILABLE =
"unavailable"
70_EXTRA_IDENTITY_FILES = (
71 "tools/secret_scanner.py",
72 "deps/dependency_check.py",
73 "deps/import_graph.py",
79 """Return a SHA-256 hex digest of *text*."""
80 return hashlib.sha256(text.encode(
"utf-8")).hexdigest()
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()
90 """Return a SHA-256 hex digest of the linter's own code (plan 265).
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.
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.
102 func =
"linter_identity_hash"
103 anchor = package_dir.parent
104 if not package_dir.is_dir():
107 f
"linter identity unavailable: {package_dir} is not a directory", 3,
109 return IDENTITY_UNAVAILABLE
112 (f
"{package_dir.name}/{path.name}", path)
113 for path
in package_dir.glob(
"*.py")
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"))
125 digest.update(path.read_bytes())
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()
135 """Return the identity of the linter package this module belongs to.
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
149 """One cached lint result for a single file."""
152 rule_results: dict[str, bool]
153 json_detail: dict[str, Any]
159 """Thread-safe, content-addressed lint result cache.
161 Loads from *cache_path* on init; call :meth:`save` to persist.
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.
173 linter_identity: str = IDENTITY_UNAVAILABLE,
184 _dbg(
"OCT-LINTER", func, f
"loading lint cache from {self._path}", 4)
185 if not self.
_path.is_file():
188 raw = json.loads(self.
_path.read_text(encoding=
"utf-8"))
189 if not isinstance(raw, dict):
191 if raw.get(
"_schema_version") != _SCHEMA_VERSION:
196 "dropping the whole lint cache: oct_version differs", 3,
202 "dropping the whole lint cache: linter_identity differs", 3,
205 entries = raw.get(
"entries")
206 if isinstance(entries, dict):
208 except (json.JSONDecodeError, OSError):
211 def get(self, file_hash: str, active_rules_hash: str) -> LintCacheEntry |
None:
212 """Return cached entry if content + rules match, else ``None``."""
214 key = f
"{file_hash}:{active_rules_hash}"
218 if entry.get(
"content_hash") != file_hash:
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"],
231 active_rules_hash: str,
232 entry: LintCacheEntry,
234 """Store a lint result in the cache."""
236 key = f
"{file_hash}:{active_rules_hash}"
240 """Write the cache to disk. Creates parent directories if needed."""
242 _dbg(
"OCT-LINTER", func, f
"saving lint cache: {self.entry_count} entries", 3)
244 self.
_path.parent.mkdir(parents=
True, exist_ok=
True)
246 "_schema_version": _SCHEMA_VERSION,
251 self.
_path.write_text(
252 json.dumps(payload, indent=2), encoding=
"utf-8",
257 """Number of cached entries (for diagnostics)."""
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 current_linter_identity()
str content_hash(str text)
str rules_hash(dict[str, bool] active_rules)
str linter_identity_hash(Path package_dir)