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.
Files changed (79) hide show
  1. argusf/__init__.py +3 -0
  2. argusf/analysis/__init__.py +14 -0
  3. argusf/analysis/engine.py +100 -0
  4. argusf/analysis/rule.py +45 -0
  5. argusf/analysis/rules/__init__.py +5 -0
  6. argusf/analysis/rules/documentation.py +30 -0
  7. argusf/analysis/rules/findings.py +67 -0
  8. argusf/analysis/rules/registry.py +22 -0
  9. argusf/analysis/rules/rulesets/__init__.py +4 -0
  10. argusf/analysis/rules/rulesets/rgus/__init__.py +22 -0
  11. argusf/analysis/rules/rulesets/rgus/rgus001_no_goto.py +54 -0
  12. argusf/analysis/rules/rulesets/rgus/rgus002_common_block.py +55 -0
  13. argusf/analysis/rules/rulesets/rgus/rgus003_equivalence.py +55 -0
  14. argusf/analysis/rules/rulesets/rgus/rgus004_implicit_typing.py +129 -0
  15. argusf/analysis/rules/rulesets/rgus/rgus005_arithmetic_if.py +62 -0
  16. argusf/analysis/rules/rulesets/rgus/rgus006_entry_statement.py +67 -0
  17. argusf/analysis/rules/rulesets/rgus/rgus007_pause_statement.py +93 -0
  18. argusf/analysis/rules/rulesets/rgus/rgus008_todo_comment.py +100 -0
  19. argusf/analysis/rules/rulesets/rgus/rgus009_unsorted_use_statements.py +258 -0
  20. argusf/analysis/rules/selection.py +83 -0
  21. argusf/analysis/suppression.py +92 -0
  22. argusf/analysis/syntax_errors.py +41 -0
  23. argusf/autofix/__init__.py +26 -0
  24. argusf/autofix/applier.py +130 -0
  25. argusf/autofix/context.py +82 -0
  26. argusf/autofix/diff.py +39 -0
  27. argusf/autofix/edits.py +31 -0
  28. argusf/autofix/engine.py +192 -0
  29. argusf/autofix/models.py +39 -0
  30. argusf/autofix/source.py +68 -0
  31. argusf/autofix/writer.py +53 -0
  32. argusf/cache/__init__.py +5 -0
  33. argusf/cache/backend.py +294 -0
  34. argusf/cli.py +428 -0
  35. argusf/config/__init__.py +1 -0
  36. argusf/config/config_resolver.py +212 -0
  37. argusf/config/errors.py +8 -0
  38. argusf/config/file_reader/__init__.py +3 -0
  39. argusf/config/file_reader/reader.py +58 -0
  40. argusf/config/models.py +110 -0
  41. argusf/config/validation.py +113 -0
  42. argusf/constants.py +66 -0
  43. argusf/diagnostics.py +45 -0
  44. argusf/discovery/__init__.py +3 -0
  45. argusf/discovery/backend.py +250 -0
  46. argusf/discovery/gitignore.py +125 -0
  47. argusf/discovery/repo.py +30 -0
  48. argusf/hashing/__init__.py +5 -0
  49. argusf/hashing/hash.py +48 -0
  50. argusf/ir/__init__.py +1 -0
  51. argusf/ir/models/__init__.py +49 -0
  52. argusf/ir/models/findings.py +74 -0
  53. argusf/ir/models/source.py +199 -0
  54. argusf/orchestrator.py +127 -0
  55. argusf/parser/__init__.py +1 -0
  56. argusf/parser/backend.py +24 -0
  57. argusf/parser/treesitter/__init__.py +1 -0
  58. argusf/parser/treesitter/backend.py +157 -0
  59. argusf/parser/treesitter/walker/__init__.py +5 -0
  60. argusf/parser/treesitter/walker/handlers.py +209 -0
  61. argusf/parser/treesitter/walker/walker.py +82 -0
  62. argusf/registry/__init__.py +3 -0
  63. argusf/registry/registry.py +69 -0
  64. argusf/reporting/__init__.py +22 -0
  65. argusf/reporting/formatting.py +162 -0
  66. argusf/reporting/registry.py +20 -0
  67. argusf/reporting/reporters/__init__.py +15 -0
  68. argusf/reporting/reporters/base.py +29 -0
  69. argusf/reporting/reporters/concise.py +35 -0
  70. argusf/reporting/reporters/diff.py +30 -0
  71. argusf/reporting/reporters/json.py +59 -0
  72. argusf/reporting/reporters/null.py +21 -0
  73. argusf/reporting/reporters/standard.py +78 -0
  74. argusf/reporting/reporters/statistics.py +99 -0
  75. argusf-0.1.0.dev0.dist-info/METADATA +166 -0
  76. argusf-0.1.0.dev0.dist-info/RECORD +79 -0
  77. argusf-0.1.0.dev0.dist-info/WHEEL +4 -0
  78. argusf-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  79. argusf-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -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]
@@ -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
+ )
@@ -0,0 +1,5 @@
1
+ """Persistent caching of per-file findings."""
2
+
3
+ from .backend import CacheBackend, Cacher
4
+
5
+ __all__ = ["CacheBackend", "Cacher"]
@@ -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)