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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Marking findings suppressed by config and inline directives."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from argusf.analysis.rules import RuleRegistry
|
|
8
|
+
from argusf.analysis.rules.selection import ALL_SELECTOR, RuleSelectionResolver
|
|
9
|
+
from argusf.analysis.syntax_errors import SYNTAX_ERROR_CODE
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from argusf.config.models import ArgusConfig
|
|
15
|
+
from argusf.ir.models import Finding, SourceFile
|
|
16
|
+
|
|
17
|
+
# Example suppression text: ! argusf: ignore[RGUS001]
|
|
18
|
+
_INLINE_PATTERN = re.compile(r"!\s*argusf:\s*ignore\[([^\]]+)\]")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SuppressionFilter:
|
|
22
|
+
"""Marks findings suppressed per config and inline directives."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: ArgusConfig, source_root: Path) -> None:
|
|
25
|
+
"""Resolve per-file-ignore patterns up front.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Resolved config; its per_file_ignores and rule
|
|
29
|
+
lists drive suppression.
|
|
30
|
+
source_root: Root that per-file patterns match against;
|
|
31
|
+
its parent is used when a single file is given.
|
|
32
|
+
"""
|
|
33
|
+
self._config = config.lint
|
|
34
|
+
self._source_root = source_root.parent if source_root.is_file() else source_root
|
|
35
|
+
self._suppressed_by_pattern = self._resolve_per_file_ignores()
|
|
36
|
+
|
|
37
|
+
def apply(self, source_file: SourceFile, findings: list[Finding]) -> None:
|
|
38
|
+
"""Mark matching findings suppressed, in place.
|
|
39
|
+
|
|
40
|
+
Applies per-file-ignore patterns and inline `argusf: ignore`
|
|
41
|
+
directives; syntax-error findings are never suppressed.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
source_file: The file the findings came from.
|
|
45
|
+
findings: Findings to update; matched ones get
|
|
46
|
+
`suppressed = True`.
|
|
47
|
+
"""
|
|
48
|
+
inline_suppressions = self._inline_suppressions(source_file)
|
|
49
|
+
suppressed_rules = self._per_file_suppressed_rules(source_file)
|
|
50
|
+
|
|
51
|
+
for finding in findings:
|
|
52
|
+
# Syntax errors cannot be suppressed: the code is unknown to
|
|
53
|
+
# per-file-ignore selectors by construction, but an inline
|
|
54
|
+
# `! argusf: ignore[ERROR]` would string-match here.
|
|
55
|
+
if finding.rule_id == SYNTAX_ERROR_CODE:
|
|
56
|
+
continue
|
|
57
|
+
if finding.rule_id in suppressed_rules or finding.rule_id in inline_suppressions.get(finding.line, set()):
|
|
58
|
+
finding.suppressed = True
|
|
59
|
+
|
|
60
|
+
def _resolve_per_file_ignores(self) -> dict[str, set[str]]:
|
|
61
|
+
# Each pattern's selectors are ignore-only, so they resolve
|
|
62
|
+
# against a select of ALL: the complement of what survives is
|
|
63
|
+
# suppressed for files matching the pattern. Resolving eagerly
|
|
64
|
+
# also fails fast on unknown selectors before any file is
|
|
65
|
+
# analysed.
|
|
66
|
+
known_rules = set(RuleRegistry.keys())
|
|
67
|
+
resolver = RuleSelectionResolver()
|
|
68
|
+
return {
|
|
69
|
+
pattern: known_rules - resolver.resolve((ALL_SELECTOR,), selectors)
|
|
70
|
+
for pattern, selectors in self._config.per_file_ignores.items()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def _per_file_suppressed_rules(self, source_file: SourceFile) -> set[str]:
|
|
74
|
+
rel_path = str(source_file.file_path.relative_to(self._source_root))
|
|
75
|
+
suppressed: set[str] = set()
|
|
76
|
+
for pattern, rule_ids in self._suppressed_by_pattern.items():
|
|
77
|
+
if fnmatch(rel_path, pattern):
|
|
78
|
+
suppressed.update(rule_ids)
|
|
79
|
+
return suppressed
|
|
80
|
+
|
|
81
|
+
def _inline_suppressions(self, source_file: SourceFile) -> dict[int, set[str]]:
|
|
82
|
+
# A suppression directive applies to findings on the same line
|
|
83
|
+
# as the comment, e.g. a trailing `GOTO 10 ! argusf:
|
|
84
|
+
# ignore[RGUS001]`.
|
|
85
|
+
suppressions: dict[int, set[str]] = {}
|
|
86
|
+
for comment in source_file.walk_comments():
|
|
87
|
+
match = _INLINE_PATTERN.search(comment.text)
|
|
88
|
+
if match is None:
|
|
89
|
+
continue
|
|
90
|
+
rule_ids = {rule_id.strip() for rule_id in match.group(1).split(",")}
|
|
91
|
+
suppressions.setdefault(comment.span.start_row, set()).update(rule_ids)
|
|
92
|
+
return suppressions
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The reserved syntax-error finding and its constants."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from argusf.ir.models import Finding, Severity, SourceSpan
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Reserved code for syntax-error findings. Deliberately NOT registered
|
|
11
|
+
# in the RuleRegistry: selectors don't know it (so
|
|
12
|
+
# select/ignore/per-file ignores can never disable it) and no rule
|
|
13
|
+
# produces it — the parser does.
|
|
14
|
+
SYNTAX_ERROR_CODE = "ERROR"
|
|
15
|
+
SYNTAX_ERROR_MESSAGE = "unparseable code"
|
|
16
|
+
# Display name for statistics rows; real rules take theirs from
|
|
17
|
+
# metadata.
|
|
18
|
+
SYNTAX_ERROR_NAME = "syntax-error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def syntax_error_finding(file_path: Path, span: SourceSpan | None) -> Finding:
|
|
22
|
+
"""Build the pseudo-finding for a syntax error.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
file_path: File the error was found in.
|
|
26
|
+
span: Error location, or None for a whole-file error
|
|
27
|
+
(reported at 1:1).
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A finding carrying the reserved syntax-error code.
|
|
31
|
+
"""
|
|
32
|
+
return Finding(
|
|
33
|
+
rule_id=SYNTAX_ERROR_CODE,
|
|
34
|
+
message=SYNTAX_ERROR_MESSAGE,
|
|
35
|
+
severity=Severity.ERROR,
|
|
36
|
+
file_path=file_path,
|
|
37
|
+
line=span.start_row if span else 0,
|
|
38
|
+
column=span.start_col if span else 0,
|
|
39
|
+
end_line=span.end_row if span else None,
|
|
40
|
+
end_column=span.end_col if span else None,
|
|
41
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The autofix stage: applying rule fixes to source."""
|
|
2
|
+
|
|
3
|
+
from .applier import ApplyOutcome, FixApplier, OverlappingEditsError
|
|
4
|
+
from .context import FixContext, FixMode, build_fix_context, resolve_fix_mode
|
|
5
|
+
from .diff import DiffRenderer
|
|
6
|
+
from .engine import FixEngine, FixOutcome
|
|
7
|
+
from .models import Applicability, Edit, Fix
|
|
8
|
+
from .writer import FileSystemSourceWriter, SourceWriter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Applicability",
|
|
12
|
+
"ApplyOutcome",
|
|
13
|
+
"DiffRenderer",
|
|
14
|
+
"Edit",
|
|
15
|
+
"FileSystemSourceWriter",
|
|
16
|
+
"Fix",
|
|
17
|
+
"FixApplier",
|
|
18
|
+
"FixContext",
|
|
19
|
+
"FixEngine",
|
|
20
|
+
"FixMode",
|
|
21
|
+
"FixOutcome",
|
|
22
|
+
"OverlappingEditsError",
|
|
23
|
+
"SourceWriter",
|
|
24
|
+
"build_fix_context",
|
|
25
|
+
"resolve_fix_mode",
|
|
26
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Applying the non-overlapping subset of fix edits to source bytes."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from itertools import pairwise
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
|
|
10
|
+
from argusf.autofix.models import Edit, Fix
|
|
11
|
+
from argusf.ir.models import Finding
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ApplyOutcome:
|
|
16
|
+
"""The spliced source and the findings whose fixes applied."""
|
|
17
|
+
|
|
18
|
+
source: bytes
|
|
19
|
+
applied: list[Finding]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FixInvariantError(ValueError):
|
|
23
|
+
"""A fix's edits are malformed and cannot be applied to the source.
|
|
24
|
+
|
|
25
|
+
Either the edits overlap each other, or an edit's span does not
|
|
26
|
+
index into the source it applies to. Both are defects the applier
|
|
27
|
+
cannot resolve by reordering — a rule bug, or a fix built against a
|
|
28
|
+
different version of the file — so it raises. The fix stage catches
|
|
29
|
+
this at the file boundary and leaves that one file unfixed rather
|
|
30
|
+
than aborting the whole run.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OverlappingEditsError(FixInvariantError):
|
|
35
|
+
"""A single fix's edits overlap each other (a rule bug)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, fix: Fix) -> None:
|
|
38
|
+
"""Build the error for a fix with overlapping edits.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
fix: The fix whose edits overlap.
|
|
42
|
+
"""
|
|
43
|
+
super().__init__(f"edits within one fix overlap: {fix!r}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EditOutOfBoundsError(FixInvariantError):
|
|
47
|
+
"""A fix edit's span does not index into its source."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, fix: Fix, edit: Edit, source_len: int) -> None:
|
|
50
|
+
"""Build the error for an out-of-bounds edit span.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
fix: The fix the edit belongs to.
|
|
54
|
+
edit: The edit whose span is out of range.
|
|
55
|
+
source_len: Length of the source in bytes.
|
|
56
|
+
"""
|
|
57
|
+
super().__init__(f"fix edit span out of bounds for {source_len}-byte source: {edit.span!r} in {fix!r}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FixApplier:
|
|
61
|
+
"""Applies the non-overlapping subset of fixes to source.
|
|
62
|
+
|
|
63
|
+
Candidate fixes are taken in document order (position of their first
|
|
64
|
+
edit). A fix whose edits would overlap an already-accepted fix is
|
|
65
|
+
skipped, not failed: the fix loop re-analyses after applying, so the
|
|
66
|
+
skipped fix re-emerges next iteration against the new source.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def apply(self, source: bytes, findings: Iterable[Finding]) -> ApplyOutcome:
|
|
70
|
+
"""Apply every non-conflicting fix to `source`.
|
|
71
|
+
|
|
72
|
+
Fixes are considered in document order; one whose edits
|
|
73
|
+
overlap an already-accepted fix is skipped (to re-emerge next
|
|
74
|
+
fix-loop iteration), not failed.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
source: The current source bytes.
|
|
78
|
+
findings: Findings whose fixes are candidates.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The spliced source and the findings that applied.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
FixInvariantError: On edits malformed within one fix.
|
|
85
|
+
"""
|
|
86
|
+
candidates = [finding for finding in findings if finding.fix is not None and finding.fix.edits]
|
|
87
|
+
candidates.sort(key=lambda finding: min(edit.span.start_byte for edit in finding.fix.edits)) # type: ignore[union-attr]
|
|
88
|
+
|
|
89
|
+
accepted: list[Edit] = []
|
|
90
|
+
applied: list[Finding] = []
|
|
91
|
+
for finding in candidates:
|
|
92
|
+
fix = finding.fix
|
|
93
|
+
if fix is None: # narrowed away above; keeps mypy honest
|
|
94
|
+
continue
|
|
95
|
+
edits = sorted(fix.edits, key=lambda edit: (edit.span.start_byte, edit.span.end_byte))
|
|
96
|
+
self._check_fix_invariants(fix, edits, source)
|
|
97
|
+
if any(self._conflicts(edit, other) for edit in edits for other in accepted):
|
|
98
|
+
continue
|
|
99
|
+
accepted.extend(edits)
|
|
100
|
+
applied.append(finding)
|
|
101
|
+
|
|
102
|
+
return ApplyOutcome(source=self._splice(source, accepted), applied=applied)
|
|
103
|
+
|
|
104
|
+
def _check_fix_invariants(self, fix: Fix, sorted_edits: list[Edit], source: bytes) -> None:
|
|
105
|
+
for previous, current in pairwise(sorted_edits):
|
|
106
|
+
if self._conflicts(previous, current):
|
|
107
|
+
raise OverlappingEditsError(fix)
|
|
108
|
+
# Every edit must index into the source, not just the last by
|
|
109
|
+
# sort order: end_byte is not monotonic with start_byte, and an
|
|
110
|
+
# inverted span (start > end) can sit anywhere in the run.
|
|
111
|
+
for edit in sorted_edits:
|
|
112
|
+
span = edit.span
|
|
113
|
+
if span.start_byte > span.end_byte or span.end_byte > len(source):
|
|
114
|
+
raise EditOutOfBoundsError(fix, edit, len(source))
|
|
115
|
+
|
|
116
|
+
def _conflicts(self, a: Edit, b: Edit) -> bool:
|
|
117
|
+
# Touching edits are fine (half-open intervals); two insertions
|
|
118
|
+
# at the same point are not — their relative order would be
|
|
119
|
+
# arbitrary.
|
|
120
|
+
if a.span.start_byte == a.span.end_byte == b.span.start_byte == b.span.end_byte:
|
|
121
|
+
return True
|
|
122
|
+
return a.span.start_byte < b.span.end_byte and b.span.start_byte < a.span.end_byte
|
|
123
|
+
|
|
124
|
+
def _splice(self, source: bytes, edits: list[Edit]) -> bytes:
|
|
125
|
+
# Splicing back-to-front keeps every remaining edit's offsets
|
|
126
|
+
# valid.
|
|
127
|
+
result = bytearray(source)
|
|
128
|
+
for edit in sorted(edits, key=lambda edit: (edit.span.start_byte, edit.span.end_byte), reverse=True):
|
|
129
|
+
result[edit.span.start_byte : edit.span.end_byte] = edit.content.encode()
|
|
130
|
+
return bytes(result)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Fix-run modes and the shared runtime fix context."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from argusf.autofix.models import Applicability, Fix
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from argusf.config.models import ArgusConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FixMode(StrEnum):
|
|
14
|
+
"""What the fix stage does with findings this run."""
|
|
15
|
+
|
|
16
|
+
REPORT = "report"
|
|
17
|
+
FIX = "fix"
|
|
18
|
+
FIX_ONLY = "fix_only"
|
|
19
|
+
DIFF = "diff"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def resolve_fix_mode(config: ArgusConfig, *, diff: bool) -> FixMode:
|
|
23
|
+
"""Resolve the fix mode from config and the --diff flag.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config: Resolved config (its fix and fix_only flags).
|
|
27
|
+
diff: Whether --diff was passed.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The mode, with diff > fix_only > fix > report precedence.
|
|
31
|
+
"""
|
|
32
|
+
# --diff implies fix-only reporting and never writes; fix_only
|
|
33
|
+
# implies fix.
|
|
34
|
+
if diff:
|
|
35
|
+
return FixMode.DIFF
|
|
36
|
+
if config.fix_only:
|
|
37
|
+
return FixMode.FIX_ONLY
|
|
38
|
+
if config.fix:
|
|
39
|
+
return FixMode.FIX
|
|
40
|
+
return FixMode.REPORT
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_fix_context(config: ArgusConfig, *, diff: bool) -> FixContext:
|
|
44
|
+
"""Build the run's FixContext from config and --diff.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
config: Resolved config supplying unsafe_fixes/show_fixes.
|
|
48
|
+
diff: Whether --diff was passed.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The fix context for the run.
|
|
52
|
+
"""
|
|
53
|
+
return FixContext(
|
|
54
|
+
mode=resolve_fix_mode(config, diff=diff),
|
|
55
|
+
unsafe_fixes=config.unsafe_fixes,
|
|
56
|
+
show_fixes=config.show_fixes,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class FixContext:
|
|
62
|
+
"""Runtime fix settings shared by the engine and reporters."""
|
|
63
|
+
|
|
64
|
+
mode: FixMode = FixMode.REPORT
|
|
65
|
+
unsafe_fixes: bool = False
|
|
66
|
+
show_fixes: bool = False
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def writes_files(self) -> bool:
|
|
70
|
+
"""Whether this mode writes fixed files back to disk."""
|
|
71
|
+
return self.mode in (FixMode.FIX, FixMode.FIX_ONLY)
|
|
72
|
+
|
|
73
|
+
def applies(self, fix: Fix) -> bool:
|
|
74
|
+
"""Whether the fix is applicable at this run's safety level.
|
|
75
|
+
|
|
76
|
+
Also the [*] marker predicate in reports: deliberately
|
|
77
|
+
independent of mode, so a plain report marks exactly what --fix
|
|
78
|
+
would apply.
|
|
79
|
+
"""
|
|
80
|
+
if fix.applicability is Applicability.SAFE:
|
|
81
|
+
return True
|
|
82
|
+
return fix.applicability is Applicability.UNSAFE and self.unsafe_fixes
|
argusf/autofix/diff.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Rendering fix results as a unified diff."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DiffRenderer:
|
|
8
|
+
"""Renders what a fix run would change as a unified diff.
|
|
9
|
+
|
|
10
|
+
Headers carry the bare path on both sides (no a/ b/ prefixes),
|
|
11
|
+
relative to the working directory when possible.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def render(self, path: Path, original: bytes, fixed: bytes) -> str:
|
|
15
|
+
"""Render the change from `original` to `fixed` as a diff.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: File path, shown bare in the diff headers.
|
|
19
|
+
original: Bytes before fixing.
|
|
20
|
+
fixed: Bytes after fixing.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A unified diff, empty when the two are identical.
|
|
24
|
+
"""
|
|
25
|
+
label = self._label(path)
|
|
26
|
+
return "".join(
|
|
27
|
+
difflib.unified_diff(
|
|
28
|
+
original.decode(errors="replace").splitlines(keepends=True),
|
|
29
|
+
fixed.decode(errors="replace").splitlines(keepends=True),
|
|
30
|
+
fromfile=label,
|
|
31
|
+
tofile=label,
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _label(self, path: Path) -> str:
|
|
36
|
+
try:
|
|
37
|
+
return str(path.relative_to(Path.cwd()))
|
|
38
|
+
except ValueError:
|
|
39
|
+
return str(path)
|
argusf/autofix/edits.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Edit constructors: how rules express a source change.
|
|
2
|
+
|
|
3
|
+
Every modification is one shape — replace the bytes a span covers with
|
|
4
|
+
the supplied content — so these constructors only differ in how they
|
|
5
|
+
frame that: `insertion` replaces a zero-width span, `deletion` replaces
|
|
6
|
+
with nothing, `replacement` swaps content in place. The measurement
|
|
7
|
+
helpers for choosing the right span live in `source.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from argusf.autofix.models import Edit
|
|
13
|
+
from argusf.autofix.source import span_between
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from argusf.ir.models import SourceSpan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def insertion(source: bytes, at_byte: int, content: str) -> Edit:
|
|
20
|
+
"""Insert content at a byte offset (a zero-width replacement)."""
|
|
21
|
+
return Edit(span=span_between(source, at_byte, at_byte), content=content)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def deletion(span: SourceSpan) -> Edit:
|
|
25
|
+
"""Remove the bytes the span covers."""
|
|
26
|
+
return Edit(span=span, content="")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def replacement(span: SourceSpan, content: str) -> Edit:
|
|
30
|
+
"""Swap the bytes the span covers for the supplied content."""
|
|
31
|
+
return Edit(span=span, content=content)
|
argusf/autofix/engine.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""The autofix stage: the fix loop and per-mode outcomes."""
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
from argusf.analysis.syntax_errors import SYNTAX_ERROR_CODE
|
|
8
|
+
from argusf.autofix.applier import FixApplier, FixInvariantError
|
|
9
|
+
from argusf.autofix.context import FixContext, FixMode
|
|
10
|
+
from argusf.autofix.diff import DiffRenderer
|
|
11
|
+
from argusf.autofix.writer import FileSystemSourceWriter
|
|
12
|
+
from argusf.constants import MAX_FIX_ITERATIONS
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from argusf.diagnostics import Diagnostics
|
|
16
|
+
from argusf.hashing.hash import Hasher
|
|
17
|
+
from argusf.ir.models import Finding, SourceFile, SourceFileMetadata
|
|
18
|
+
from argusf.parser.backend import ParsingEngine
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Analyser(Protocol):
|
|
22
|
+
"""Structural stand-in for the analysis engine.
|
|
23
|
+
|
|
24
|
+
Importing the real class here would close an import cycle
|
|
25
|
+
(analysis -> rules -> autofix models), and the fix loop only needs
|
|
26
|
+
"findings for a parsed file" anyway.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def analyse(self, source_file: SourceFile) -> list[Finding]:
|
|
30
|
+
"""Return the findings for a parsed source file."""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class FixOutcome:
|
|
36
|
+
"""What the fix stage decided for one file.
|
|
37
|
+
|
|
38
|
+
findings/metadata are what the caller should report and cache: the
|
|
39
|
+
post-fix state after a write, the untouched original state otherwise
|
|
40
|
+
(including --diff runs, where the disk never changes).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
findings: list[Finding]
|
|
44
|
+
metadata: SourceFileMetadata
|
|
45
|
+
fixed_by_rule: Counter[str]
|
|
46
|
+
diff: str | None
|
|
47
|
+
changed: bool
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FixEngine:
|
|
51
|
+
"""The autofix stage of a run, self-contained per file.
|
|
52
|
+
|
|
53
|
+
Owns everything between "findings are known" and "results are
|
|
54
|
+
reported": fix eligibility, the apply -> re-parse -> re-lint loop,
|
|
55
|
+
writing fixed source back (or rendering a diff instead), and
|
|
56
|
+
deciding which findings/metadata the caller should report and cache
|
|
57
|
+
for each mode. In REPORT mode it is a pass-through.
|
|
58
|
+
|
|
59
|
+
Source bytes are read here, lazily, only once a file actually has
|
|
60
|
+
applicable fixes — the run's other stages carry lean metadata only.
|
|
61
|
+
A content-hash check guards the read: edits computed against one
|
|
62
|
+
version of a file are never applied to another.
|
|
63
|
+
|
|
64
|
+
The fix-only moving parts (applier, writer, diff renderer) are
|
|
65
|
+
composed here rather than injected: nothing outside the autofix
|
|
66
|
+
stage has any business varying them. Injected collaborators are the
|
|
67
|
+
ones shared with the rest of the run.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
parsing_engine: ParsingEngine,
|
|
73
|
+
analyser: Analyser,
|
|
74
|
+
hasher: Hasher,
|
|
75
|
+
context: FixContext,
|
|
76
|
+
diagnostics: Diagnostics,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Compose the fix stage from its collaborators.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
parsing_engine: Re-parses fixed source in memory.
|
|
82
|
+
analyser: Re-analyses re-parsed source each loop.
|
|
83
|
+
hasher: Verifies source identity before editing.
|
|
84
|
+
context: The run's fix mode and safety settings.
|
|
85
|
+
diagnostics: Channel for skipped-file notices.
|
|
86
|
+
"""
|
|
87
|
+
self._parsing_engine = parsing_engine
|
|
88
|
+
self._analyser = analyser
|
|
89
|
+
self._hasher = hasher
|
|
90
|
+
self._context = context
|
|
91
|
+
self._diagnostics = diagnostics
|
|
92
|
+
self._applier = FixApplier()
|
|
93
|
+
self._source_writer = FileSystemSourceWriter(hasher)
|
|
94
|
+
self._diff_renderer = DiffRenderer()
|
|
95
|
+
|
|
96
|
+
def process(self, file: SourceFileMetadata, findings: list[Finding]) -> FixOutcome:
|
|
97
|
+
"""Run the fix stage for one file.
|
|
98
|
+
|
|
99
|
+
In REPORT mode (or when nothing is eligible) passes findings
|
|
100
|
+
through unchanged. Otherwise runs the fix loop, then writes
|
|
101
|
+
the file (FIX/FIX_ONLY) or renders a diff (DIFF), leaving
|
|
102
|
+
disk and cache consistent per mode.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
file: Identity metadata of the file.
|
|
106
|
+
findings: The file's current findings.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The per-mode fix outcome.
|
|
110
|
+
"""
|
|
111
|
+
if self._context.mode is FixMode.REPORT or not self._eligible(findings):
|
|
112
|
+
return self._passthrough(file, findings)
|
|
113
|
+
original = self._read_source(file)
|
|
114
|
+
if original is None:
|
|
115
|
+
return self._passthrough(file, findings)
|
|
116
|
+
try:
|
|
117
|
+
source, final_findings, fixed_by_rule = self._fix_loop(file, original, findings)
|
|
118
|
+
except FixInvariantError as error:
|
|
119
|
+
# A malformed fix (a rule bug, or a fix stale against this
|
|
120
|
+
# source) must not abort the whole run: leave this one file
|
|
121
|
+
# unfixed, on disk untouched, and surface it as a
|
|
122
|
+
# diagnostic.
|
|
123
|
+
self._diagnostics.emit(f"skipping fixes for {file.file_path}: {error}")
|
|
124
|
+
return self._passthrough(file, findings)
|
|
125
|
+
if not fixed_by_rule:
|
|
126
|
+
return self._passthrough(file, findings)
|
|
127
|
+
if self._context.writes_files:
|
|
128
|
+
metadata = self._source_writer.write(file.file_path, source)
|
|
129
|
+
return FixOutcome(
|
|
130
|
+
findings=final_findings, metadata=metadata, fixed_by_rule=fixed_by_rule, diff=None, changed=True
|
|
131
|
+
)
|
|
132
|
+
# DIFF mode: the disk is untouched, so the original state is
|
|
133
|
+
# what gets reported and cached; the rendered diff and the
|
|
134
|
+
# would-fix counts carry the outcome.
|
|
135
|
+
diff = self._diff_renderer.render(file.file_path, original, source)
|
|
136
|
+
return FixOutcome(findings=findings, metadata=file, fixed_by_rule=fixed_by_rule, diff=diff, changed=False)
|
|
137
|
+
|
|
138
|
+
def _passthrough(self, file: SourceFileMetadata, findings: list[Finding]) -> FixOutcome:
|
|
139
|
+
return FixOutcome(findings=findings, metadata=file, fixed_by_rule=Counter(), diff=None, changed=False)
|
|
140
|
+
|
|
141
|
+
def _read_source(self, file: SourceFileMetadata) -> bytes | None:
|
|
142
|
+
"""Return the bytes the findings were computed from, or None.
|
|
143
|
+
|
|
144
|
+
A file that vanished, or whose content no longer matches the
|
|
145
|
+
hash the findings were derived from, is left alone (returns
|
|
146
|
+
None): byte edits against drifted content would corrupt it.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
source = file.file_path.read_bytes()
|
|
150
|
+
except OSError:
|
|
151
|
+
self._diagnostics.emit(f"could not read {file.file_path}, not fixing it")
|
|
152
|
+
return None
|
|
153
|
+
if self._hasher.hash_bytes(source) != file.content_hash:
|
|
154
|
+
self._diagnostics.emit(f"{file.file_path} changed since it was analysed, not fixing it")
|
|
155
|
+
return None
|
|
156
|
+
return source
|
|
157
|
+
|
|
158
|
+
def _fix_loop(
|
|
159
|
+
self, file: SourceFileMetadata, source: bytes, findings: list[Finding]
|
|
160
|
+
) -> tuple[bytes, list[Finding], Counter[str]]:
|
|
161
|
+
fixed_by_rule: Counter[str] = Counter()
|
|
162
|
+
for _ in range(MAX_FIX_ITERATIONS):
|
|
163
|
+
eligible = self._eligible(findings)
|
|
164
|
+
if not eligible:
|
|
165
|
+
return source, findings, fixed_by_rule
|
|
166
|
+
outcome = self._applier.apply(source, eligible)
|
|
167
|
+
if not outcome.applied:
|
|
168
|
+
return source, findings, fixed_by_rule
|
|
169
|
+
fixed_by_rule.update(finding.rule_id for finding in outcome.applied)
|
|
170
|
+
source = outcome.source
|
|
171
|
+
findings = self._recompute_findings(file, source)
|
|
172
|
+
self._diagnostics.emit(f"autofix did not converge after {MAX_FIX_ITERATIONS} iterations: {file.file_path}")
|
|
173
|
+
return source, findings, fixed_by_rule
|
|
174
|
+
|
|
175
|
+
def _recompute_findings(self, file: SourceFileMetadata, source: bytes) -> list[Finding]:
|
|
176
|
+
"""Re-parse and re-analyse the fixed source in memory.
|
|
177
|
+
|
|
178
|
+
Fixes changed the source, so the findings are recomputed for
|
|
179
|
+
the file as it now stands.
|
|
180
|
+
"""
|
|
181
|
+
return self._analyser.analyse(self._parsing_engine.parse_source(file.file_path, source))
|
|
182
|
+
|
|
183
|
+
def _eligible(self, findings: list[Finding]) -> list[Finding]:
|
|
184
|
+
# A file with syntax errors is never fixed: spans inside and
|
|
185
|
+
# after the broken region are not trustworthy enough to edit by.
|
|
186
|
+
if any(finding.rule_id == SYNTAX_ERROR_CODE for finding in findings):
|
|
187
|
+
return []
|
|
188
|
+
return [
|
|
189
|
+
finding
|
|
190
|
+
for finding in findings
|
|
191
|
+
if not finding.suppressed and finding.fix is not None and self._context.applies(finding.fix)
|
|
192
|
+
]
|
argusf/autofix/models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""The fix data model: Applicability, Edit, and Fix."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from argusf.ir.models import SourceSpan
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Applicability(StrEnum):
|
|
12
|
+
"""How safe a fix is to apply automatically."""
|
|
13
|
+
|
|
14
|
+
SAFE = "safe"
|
|
15
|
+
UNSAFE = "unsafe"
|
|
16
|
+
DISPLAY = "display"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Edit:
|
|
21
|
+
"""A single edit: replace a span's bytes with content."""
|
|
22
|
+
|
|
23
|
+
# A zero-width span (start_byte == end_byte) is an insertion; empty
|
|
24
|
+
# content is a deletion. Byte offsets drive application; the row/col
|
|
25
|
+
# fields feed reporting.
|
|
26
|
+
span: SourceSpan
|
|
27
|
+
content: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Fix:
|
|
32
|
+
"""A fix: an applicability, its edits, and a message."""
|
|
33
|
+
|
|
34
|
+
applicability: Applicability
|
|
35
|
+
# Edits within one fix must not overlap; the applier treats that as
|
|
36
|
+
# a rule bug. Fixes from different findings may overlap — the fix
|
|
37
|
+
# loop defers the loser to its next iteration.
|
|
38
|
+
edits: tuple[Edit, ...]
|
|
39
|
+
message: str | None = None
|