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,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)
@@ -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)
@@ -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
+ ]
@@ -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