argusf 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- argusf/__init__.py +3 -0
- argusf/analysis/__init__.py +14 -0
- argusf/analysis/engine.py +100 -0
- argusf/analysis/rule.py +45 -0
- argusf/analysis/rules/__init__.py +5 -0
- argusf/analysis/rules/documentation.py +30 -0
- argusf/analysis/rules/findings.py +67 -0
- argusf/analysis/rules/registry.py +22 -0
- argusf/analysis/rules/rulesets/__init__.py +4 -0
- argusf/analysis/rules/rulesets/rgus/__init__.py +22 -0
- argusf/analysis/rules/rulesets/rgus/rgus001_no_goto.py +54 -0
- argusf/analysis/rules/rulesets/rgus/rgus002_common_block.py +55 -0
- argusf/analysis/rules/rulesets/rgus/rgus003_equivalence.py +55 -0
- argusf/analysis/rules/rulesets/rgus/rgus004_implicit_typing.py +129 -0
- argusf/analysis/rules/rulesets/rgus/rgus005_arithmetic_if.py +62 -0
- argusf/analysis/rules/rulesets/rgus/rgus006_entry_statement.py +67 -0
- argusf/analysis/rules/rulesets/rgus/rgus007_pause_statement.py +93 -0
- argusf/analysis/rules/rulesets/rgus/rgus008_todo_comment.py +100 -0
- argusf/analysis/rules/rulesets/rgus/rgus009_unsorted_use_statements.py +258 -0
- argusf/analysis/rules/selection.py +83 -0
- argusf/analysis/suppression.py +92 -0
- argusf/analysis/syntax_errors.py +41 -0
- argusf/autofix/__init__.py +26 -0
- argusf/autofix/applier.py +130 -0
- argusf/autofix/context.py +82 -0
- argusf/autofix/diff.py +39 -0
- argusf/autofix/edits.py +31 -0
- argusf/autofix/engine.py +192 -0
- argusf/autofix/models.py +39 -0
- argusf/autofix/source.py +68 -0
- argusf/autofix/writer.py +53 -0
- argusf/cache/__init__.py +5 -0
- argusf/cache/backend.py +294 -0
- argusf/cli.py +428 -0
- argusf/config/__init__.py +1 -0
- argusf/config/config_resolver.py +212 -0
- argusf/config/errors.py +8 -0
- argusf/config/file_reader/__init__.py +3 -0
- argusf/config/file_reader/reader.py +58 -0
- argusf/config/models.py +110 -0
- argusf/config/validation.py +113 -0
- argusf/constants.py +66 -0
- argusf/diagnostics.py +45 -0
- argusf/discovery/__init__.py +3 -0
- argusf/discovery/backend.py +250 -0
- argusf/discovery/gitignore.py +125 -0
- argusf/discovery/repo.py +30 -0
- argusf/hashing/__init__.py +5 -0
- argusf/hashing/hash.py +48 -0
- argusf/ir/__init__.py +1 -0
- argusf/ir/models/__init__.py +49 -0
- argusf/ir/models/findings.py +74 -0
- argusf/ir/models/source.py +199 -0
- argusf/orchestrator.py +127 -0
- argusf/parser/__init__.py +1 -0
- argusf/parser/backend.py +24 -0
- argusf/parser/treesitter/__init__.py +1 -0
- argusf/parser/treesitter/backend.py +157 -0
- argusf/parser/treesitter/walker/__init__.py +5 -0
- argusf/parser/treesitter/walker/handlers.py +209 -0
- argusf/parser/treesitter/walker/walker.py +82 -0
- argusf/registry/__init__.py +3 -0
- argusf/registry/registry.py +69 -0
- argusf/reporting/__init__.py +22 -0
- argusf/reporting/formatting.py +162 -0
- argusf/reporting/registry.py +20 -0
- argusf/reporting/reporters/__init__.py +15 -0
- argusf/reporting/reporters/base.py +29 -0
- argusf/reporting/reporters/concise.py +35 -0
- argusf/reporting/reporters/diff.py +30 -0
- argusf/reporting/reporters/json.py +59 -0
- argusf/reporting/reporters/null.py +21 -0
- argusf/reporting/reporters/standard.py +78 -0
- argusf/reporting/reporters/statistics.py +99 -0
- argusf-0.1.0.dev0.dist-info/METADATA +166 -0
- argusf-0.1.0.dev0.dist-info/RECORD +79 -0
- argusf-0.1.0.dev0.dist-info/WHEEL +4 -0
- argusf-0.1.0.dev0.dist-info/entry_points.txt +3 -0
- argusf-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
argusf/autofix/source.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Measurement helpers over raw source bytes.
|
|
2
|
+
|
|
3
|
+
Where lines begin and end, what a span covers, how a line is indented.
|
|
4
|
+
Nothing here modifies anything — the edit constructors in `edits.py`
|
|
5
|
+
build on these.
|
|
6
|
+
|
|
7
|
+
Byte offsets are the working coordinates (the same ones tree-sitter
|
|
8
|
+
spans use); rows and columns are derived so produced spans stay
|
|
9
|
+
consistent with parser-produced ones.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from argusf.ir.models import SourceSpan
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def line_start(source: bytes, byte_offset: int) -> int:
|
|
16
|
+
"""Offset of the first byte of the line containing byte_offset."""
|
|
17
|
+
return source.rfind(b"\n", 0, byte_offset) + 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def line_end(source: bytes, byte_offset: int) -> int:
|
|
21
|
+
"""Offset just past the line containing byte_offset."""
|
|
22
|
+
index = source.find(b"\n", byte_offset)
|
|
23
|
+
return len(source) if index == -1 else index + 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def line_indent(source: bytes, byte_offset: int) -> str:
|
|
27
|
+
"""The leading whitespace of the line containing byte_offset."""
|
|
28
|
+
start = line_start(source, byte_offset)
|
|
29
|
+
index = start
|
|
30
|
+
while index < len(source) and source[index : index + 1] in (b" ", b"\t"):
|
|
31
|
+
index += 1
|
|
32
|
+
return source[start:index].decode("ascii")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def span_between(source: bytes, start_byte: int, end_byte: int) -> SourceSpan:
|
|
36
|
+
"""A span over [start_byte, end_byte) with derived rows/cols."""
|
|
37
|
+
start_row = source.count(b"\n", 0, start_byte)
|
|
38
|
+
end_row = start_row + source.count(b"\n", start_byte, end_byte)
|
|
39
|
+
return SourceSpan(
|
|
40
|
+
start_row=start_row,
|
|
41
|
+
start_col=start_byte - line_start(source, start_byte),
|
|
42
|
+
end_row=end_row,
|
|
43
|
+
end_col=end_byte - line_start(source, end_byte),
|
|
44
|
+
start_byte=start_byte,
|
|
45
|
+
end_byte=end_byte,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def full_lines_span(source: bytes, span: SourceSpan) -> SourceSpan:
|
|
50
|
+
"""The span widened to whole lines, trailing newline included."""
|
|
51
|
+
start = line_start(source, span.start_byte)
|
|
52
|
+
end = line_end(source, max(span.start_byte, span.end_byte - 1))
|
|
53
|
+
return span_between(source, start, end)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def span_fits(source: bytes, span: SourceSpan) -> bool:
|
|
57
|
+
"""Whether the span's byte range indexes into this source at all.
|
|
58
|
+
|
|
59
|
+
A rule offering a fix must check this first: a hand-built SourceFile
|
|
60
|
+
without its bytes (or one whose spans disagree with the bytes)
|
|
61
|
+
should yield a finding without a fix, never an out-of-range edit.
|
|
62
|
+
"""
|
|
63
|
+
return span.end_byte <= len(source)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def slice_of(source: bytes, span: SourceSpan) -> bytes:
|
|
67
|
+
"""The bytes the span covers."""
|
|
68
|
+
return source[span.start_byte : span.end_byte]
|
argusf/autofix/writer.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Writing fixed source back to disk with fresh identity."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol
|
|
4
|
+
|
|
5
|
+
from argusf.ir.models import SourceFileMetadata
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from argusf.hashing.hash import Hasher
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceWriter(Protocol):
|
|
14
|
+
"""Persists fixed source and returns its new identity."""
|
|
15
|
+
|
|
16
|
+
def write(self, path: Path, content: bytes) -> SourceFileMetadata:
|
|
17
|
+
"""Persist content at path; return the file's new identity."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileSystemSourceWriter(SourceWriter):
|
|
22
|
+
"""Writes fixed source back in place.
|
|
23
|
+
|
|
24
|
+
Re-stats and re-hashes after the write so the caller can cache the
|
|
25
|
+
fixed file's findings against its real post-fix identity.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, hasher: Hasher) -> None:
|
|
29
|
+
"""Build the writer with a hasher for post-write identity.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
hasher: Hashes written bytes for the returned metadata.
|
|
33
|
+
"""
|
|
34
|
+
self._hasher = hasher
|
|
35
|
+
|
|
36
|
+
def write(self, path: Path, content: bytes) -> SourceFileMetadata:
|
|
37
|
+
"""Write `content` to `path` and return its new identity.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
path: File to overwrite.
|
|
41
|
+
content: Fixed bytes to write.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Metadata (size/mtime/hash) of the file after writing.
|
|
45
|
+
"""
|
|
46
|
+
path.write_bytes(content)
|
|
47
|
+
stat = path.stat()
|
|
48
|
+
return SourceFileMetadata(
|
|
49
|
+
file_path=path,
|
|
50
|
+
size=stat.st_size,
|
|
51
|
+
mtime=stat.st_mtime,
|
|
52
|
+
content_hash=self._hasher.hash_bytes(content),
|
|
53
|
+
)
|
argusf/cache/__init__.py
ADDED
argusf/cache/backend.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""msgpack-backed cache of per-file findings keyed by identity."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import pathlib
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import asdict, dataclass, field
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
import msgpack
|
|
12
|
+
|
|
13
|
+
from argusf.autofix.models import Applicability, Edit, Fix
|
|
14
|
+
from argusf.hashing.hash import HashBackend, Hasher
|
|
15
|
+
from argusf.ir.models import Finding, Severity, SourceFileMetadata, SourceSpan
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from argusf.config.models import ArgusConfig, LintConfig
|
|
19
|
+
from argusf.diagnostics import Diagnostics
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _settings_fingerprint(lint: LintConfig) -> str:
|
|
23
|
+
"""Stably serialise the analysis-affecting lint settings.
|
|
24
|
+
|
|
25
|
+
Reordering fields within lists (e.g. `ignore = ["B", "A"]` vs
|
|
26
|
+
`["A", "B"]`) costs a one-time re-analysis but is the conservative
|
|
27
|
+
correct default. New LintConfig fields are picked up automatically
|
|
28
|
+
via asdict.
|
|
29
|
+
"""
|
|
30
|
+
return json.dumps(asdict(lint), sort_keys=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CachedFileEntry:
|
|
35
|
+
"""A cached file's identity metadata and its findings."""
|
|
36
|
+
|
|
37
|
+
metadata: SourceFileMetadata
|
|
38
|
+
findings: list[Finding] = field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Cacher(Protocol):
|
|
43
|
+
"""Stores and retrieves per-file findings across runs."""
|
|
44
|
+
|
|
45
|
+
def get(self, file: SourceFileMetadata) -> CachedFileEntry | None:
|
|
46
|
+
"""Return a cache entry if it exists and is still valid."""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
def put(self, file: SourceFileMetadata, findings: list[Finding]) -> None:
|
|
50
|
+
"""Store a cache entry in memory for the given file."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
def invalidate(self, file: SourceFileMetadata) -> None:
|
|
54
|
+
"""Remove a cache entry from the cache."""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
def clear(self) -> None:
|
|
58
|
+
"""Remove cache and all contained entries (if exists)."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
def write_to_cache(self) -> None:
|
|
62
|
+
"""Write all new entries from memory to the cache."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
def _is_cached_entry_valid(self, file: SourceFileMetadata, cache_entry: CachedFileEntry) -> bool:
|
|
66
|
+
"""Compare file and cache entry metadata for validity."""
|
|
67
|
+
...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _span_to_dict(span: SourceSpan) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"start_row": span.start_row,
|
|
73
|
+
"start_col": span.start_col,
|
|
74
|
+
"end_row": span.end_row,
|
|
75
|
+
"end_col": span.end_col,
|
|
76
|
+
"start_byte": span.start_byte,
|
|
77
|
+
"end_byte": span.end_byte,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _span_from_dict(data: dict[str, Any]) -> SourceSpan:
|
|
82
|
+
return SourceSpan(
|
|
83
|
+
start_row=data["start_row"],
|
|
84
|
+
start_col=data["start_col"],
|
|
85
|
+
end_row=data["end_row"],
|
|
86
|
+
end_col=data["end_col"],
|
|
87
|
+
start_byte=data["start_byte"],
|
|
88
|
+
end_byte=data["end_byte"],
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _fix_to_dict(fix: Fix) -> dict[str, Any]:
|
|
93
|
+
return {
|
|
94
|
+
"applicability": fix.applicability.value,
|
|
95
|
+
"edits": [{"span": _span_to_dict(edit.span), "content": edit.content} for edit in fix.edits],
|
|
96
|
+
"message": fix.message,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fix_from_dict(data: dict[str, Any]) -> Fix:
|
|
101
|
+
return Fix(
|
|
102
|
+
applicability=Applicability(data["applicability"]),
|
|
103
|
+
edits=tuple(Edit(span=_span_from_dict(edit["span"]), content=edit["content"]) for edit in data["edits"]),
|
|
104
|
+
message=data["message"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _finding_to_dict(finding: Finding) -> dict[str, Any]:
|
|
109
|
+
return {
|
|
110
|
+
"rule_id": finding.rule_id,
|
|
111
|
+
"message": finding.message,
|
|
112
|
+
"severity": finding.severity.value,
|
|
113
|
+
"file_path": str(finding.file_path),
|
|
114
|
+
"line": finding.line,
|
|
115
|
+
"column": finding.column,
|
|
116
|
+
"end_line": finding.end_line,
|
|
117
|
+
"end_column": finding.end_column,
|
|
118
|
+
"suggestion": finding.suggestion,
|
|
119
|
+
"fix": None if finding.fix is None else _fix_to_dict(finding.fix),
|
|
120
|
+
"suppressed": finding.suppressed,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _finding_from_dict(data: dict[str, Any]) -> Finding:
|
|
125
|
+
# data["fix"], not data.get("fix"): an entry written before the fix
|
|
126
|
+
# field existed raises KeyError, which get() treats as format drift
|
|
127
|
+
# and rebuilds — restoring it as fix-less would silently drop fixes.
|
|
128
|
+
return Finding(
|
|
129
|
+
rule_id=data["rule_id"],
|
|
130
|
+
message=data["message"],
|
|
131
|
+
severity=Severity(data["severity"]),
|
|
132
|
+
file_path=pathlib.Path(data["file_path"]),
|
|
133
|
+
line=data["line"],
|
|
134
|
+
column=data["column"],
|
|
135
|
+
end_line=data["end_line"],
|
|
136
|
+
end_column=data["end_column"],
|
|
137
|
+
suggestion=data["suggestion"],
|
|
138
|
+
fix=None if data["fix"] is None else _fix_from_dict(data["fix"]),
|
|
139
|
+
suppressed=data["suppressed"],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CacheBackend(Cacher):
|
|
144
|
+
"""Caches per-file findings alongside file metadata.
|
|
145
|
+
|
|
146
|
+
Entries are serialised with msgpack since the findings list is
|
|
147
|
+
variable in size and content.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
argus_version: str,
|
|
153
|
+
python_version: str,
|
|
154
|
+
config: ArgusConfig,
|
|
155
|
+
diagnostics: Diagnostics,
|
|
156
|
+
hash_backend: Hasher | None = None,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Configure the cache directory from version and settings.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
argus_version: Running argusf version (part of the cache
|
|
162
|
+
version key).
|
|
163
|
+
python_version: Running Python version (part of the key).
|
|
164
|
+
config: Resolved config providing the cache directory and
|
|
165
|
+
the lint settings fingerprint.
|
|
166
|
+
diagnostics: Channel for reporting corrupt cache entries.
|
|
167
|
+
hash_backend: Hasher for the version key; defaults to a
|
|
168
|
+
fresh HashBackend.
|
|
169
|
+
"""
|
|
170
|
+
self._argus_version = argus_version
|
|
171
|
+
self._python_version = python_version
|
|
172
|
+
self._config = config
|
|
173
|
+
self._diagnostics = diagnostics
|
|
174
|
+
|
|
175
|
+
self._hash_backend = hash_backend or HashBackend()
|
|
176
|
+
cache_version = self._hash_backend.get_cache_version_hash(
|
|
177
|
+
argus_version, python_version, _settings_fingerprint(config.lint)
|
|
178
|
+
)
|
|
179
|
+
self._cache_dir_path = pathlib.Path(f"{config.cache_dir}/{cache_version}")
|
|
180
|
+
self._new_cache_entries: list[CachedFileEntry] = []
|
|
181
|
+
|
|
182
|
+
def _cache_file_path(self, file: SourceFileMetadata) -> pathlib.Path:
|
|
183
|
+
name = hashlib.sha256(str(file.file_path).encode()).hexdigest()
|
|
184
|
+
|
|
185
|
+
return self._cache_dir_path / name
|
|
186
|
+
|
|
187
|
+
def _pack(self, entry: CachedFileEntry) -> bytes:
|
|
188
|
+
payload = {
|
|
189
|
+
"file_path": str(entry.metadata.file_path),
|
|
190
|
+
"size": entry.metadata.size,
|
|
191
|
+
"mtime": entry.metadata.mtime,
|
|
192
|
+
"content_hash": entry.metadata.content_hash,
|
|
193
|
+
"findings": [_finding_to_dict(finding) for finding in entry.findings],
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
packed: bytes = msgpack.packb(payload)
|
|
197
|
+
return packed
|
|
198
|
+
|
|
199
|
+
def _unpack(self, data: bytes) -> CachedFileEntry:
|
|
200
|
+
payload = msgpack.unpackb(data)
|
|
201
|
+
|
|
202
|
+
return CachedFileEntry(
|
|
203
|
+
metadata=SourceFileMetadata(
|
|
204
|
+
file_path=pathlib.Path(payload["file_path"]),
|
|
205
|
+
size=payload["size"],
|
|
206
|
+
mtime=payload["mtime"],
|
|
207
|
+
content_hash=payload["content_hash"],
|
|
208
|
+
),
|
|
209
|
+
findings=[_finding_from_dict(finding) for finding in payload["findings"]],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _is_cached_entry_valid(self, file: SourceFileMetadata, cache_entry: CachedFileEntry) -> bool:
|
|
213
|
+
"""Whether the cached entry still matches the file's identity.
|
|
214
|
+
|
|
215
|
+
Dataclass equality compares path/size/mtime/content_hash —
|
|
216
|
+
exactly the identity and validation fields. The caller's
|
|
217
|
+
metadata was populated by the collector from the same read the
|
|
218
|
+
parser sees, closing the TOCTOU window between cache validation
|
|
219
|
+
and parsing.
|
|
220
|
+
"""
|
|
221
|
+
return file == cache_entry.metadata
|
|
222
|
+
|
|
223
|
+
def get(self, file: SourceFileMetadata) -> CachedFileEntry | None:
|
|
224
|
+
"""Return a valid cache entry for `file`, or None.
|
|
225
|
+
|
|
226
|
+
Returns None when no entry exists, the stored bytes are
|
|
227
|
+
unreadable (corrupt or format drift — the entry is dropped),
|
|
228
|
+
or the stored metadata no longer matches `file`.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
file: Identity metadata of the file to look up.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
The cached entry when present and still valid, else None.
|
|
235
|
+
"""
|
|
236
|
+
cache_path = self._cache_file_path(file)
|
|
237
|
+
if not cache_path.exists():
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
entry = self._unpack(cache_path.read_bytes())
|
|
242
|
+
except KeyError, TypeError, ValueError:
|
|
243
|
+
# Cache entry is unreadable: corrupted msgpack bytes,
|
|
244
|
+
# truncated write, or format drift between versions. Drop it
|
|
245
|
+
# so the next analysis writes a fresh entry over the top.
|
|
246
|
+
self._diagnostics.emit(f"cache entry corrupt, rebuilding: {file.file_path}")
|
|
247
|
+
self.invalidate(file)
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
return entry if self._is_cached_entry_valid(file, entry) else None
|
|
251
|
+
|
|
252
|
+
def put(self, file: SourceFileMetadata, findings: list[Finding]) -> None:
|
|
253
|
+
"""Stage a cache entry in memory for `file`.
|
|
254
|
+
|
|
255
|
+
The entry is not persisted until `write_to_cache`.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
file: Identity metadata to key the entry by.
|
|
259
|
+
findings: Findings to cache for the file.
|
|
260
|
+
"""
|
|
261
|
+
self._new_cache_entries.append(CachedFileEntry(metadata=file, findings=findings))
|
|
262
|
+
|
|
263
|
+
def write_to_cache(self) -> None:
|
|
264
|
+
"""Persist all staged entries to the cache directory.
|
|
265
|
+
|
|
266
|
+
Entries are written to a temporary directory and then moved
|
|
267
|
+
into place, so a crash mid-write never leaves a partial
|
|
268
|
+
entry. Clears the in-memory staging list.
|
|
269
|
+
"""
|
|
270
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
271
|
+
tmp_path = pathlib.Path(tmp)
|
|
272
|
+
staged: list[tuple[pathlib.Path, pathlib.Path]] = []
|
|
273
|
+
for entry in self._new_cache_entries:
|
|
274
|
+
name = hashlib.sha256(str(entry.metadata.file_path).encode()).hexdigest()
|
|
275
|
+
staged_file = tmp_path / name
|
|
276
|
+
staged_file.write_bytes(self._pack(entry))
|
|
277
|
+
staged.append((staged_file, self._cache_dir_path / name))
|
|
278
|
+
self._cache_dir_path.mkdir(parents=True, exist_ok=True) # no-op if already exists
|
|
279
|
+
for src, dst in staged:
|
|
280
|
+
shutil.move(str(src), dst)
|
|
281
|
+
|
|
282
|
+
self._new_cache_entries.clear()
|
|
283
|
+
|
|
284
|
+
def invalidate(self, file: SourceFileMetadata) -> None:
|
|
285
|
+
"""Delete the on-disk cache entry for `file`, if any.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
file: Identity metadata of the entry to remove.
|
|
289
|
+
"""
|
|
290
|
+
self._cache_file_path(file).unlink(missing_ok=True)
|
|
291
|
+
|
|
292
|
+
def clear(self) -> None:
|
|
293
|
+
"""Remove the entire cache directory for this version."""
|
|
294
|
+
shutil.rmtree(self._cache_dir_path, ignore_errors=True)
|