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,67 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.ir.models import EntryStatement, Finding, Severity, SourceFile
5
+
6
+
7
+ @register
8
+ class NoEntryStatement(Rule):
9
+ """## What it does
10
+
11
+ Checks for use of `ENTRY` statements.
12
+
13
+ ## Why is this bad?
14
+
15
+ `ENTRY` adds an extra entry point partway through a subroutine or
16
+ function, so the same body serves several procedures with different
17
+ argument lists and, in functions, different result variables. Which
18
+ code executes and which arguments are defined depends on where control
19
+ entered, which makes the procedure very hard to reason about. `ENTRY`
20
+ has been obsolescent since Fortran 2008. Separate procedures sharing
21
+ code through a module express the same design explicitly.
22
+
23
+ ## Example
24
+
25
+ ```fortran
26
+ subroutine setup(n)
27
+ integer :: n, m
28
+ n = 1
29
+ return
30
+ entry teardown(m)
31
+ m = 0
32
+ end subroutine setup
33
+ ```
34
+
35
+ Use instead:
36
+
37
+ ```fortran
38
+ module lifecycle
39
+ contains
40
+ subroutine setup(n)
41
+ integer :: n
42
+ n = 1
43
+ end subroutine setup
44
+ subroutine teardown(m)
45
+ integer :: m
46
+ m = 0
47
+ end subroutine teardown
48
+ end module lifecycle
49
+ ```
50
+ """
51
+
52
+ metadata = RuleMetadata(
53
+ id="RGUS006",
54
+ name="entry-statement",
55
+ message="Use of ENTRY statement detected",
56
+ description="Flags ENTRY statements, which create multiple entry points into one procedure.",
57
+ default_severity=Severity.MEDIUM,
58
+ suggestion="Split each entry point into its own procedure, sharing code via a module",
59
+ )
60
+
61
+ def check(self, source_file: SourceFile) -> list[Finding]:
62
+ return [
63
+ build_statement_finding(self.metadata, source_file, statement)
64
+ for unit in source_file.walk_units()
65
+ for statement in unit.statements
66
+ if isinstance(statement, EntryStatement)
67
+ ]
@@ -0,0 +1,93 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.autofix.edits import deletion, replacement
5
+ from argusf.autofix.models import Applicability, Fix
6
+ from argusf.autofix.source import full_lines_span, span_fits
7
+ from argusf.ir.models import Finding, PauseStatement, Severity, SourceFile
8
+
9
+
10
+ @register
11
+ class NoPauseStatement(Rule):
12
+ """## What it does
13
+
14
+ Checks for use of `PAUSE` statements.
15
+
16
+ ## Why is this bad?
17
+
18
+ `PAUSE` suspends execution until the operator resumes it, a relic of
19
+ batch operation. How (and whether) execution can be resumed is
20
+ processor dependent, batch and HPC environments have no operator to
21
+ resume anything, and the statement was deleted from the standard in
22
+ Fortran 95 — compilers accept it only as a legacy extension. An
23
+ explicit `WRITE`/`READ` pair makes the interaction visible and
24
+ portable.
25
+
26
+ ## Example
27
+
28
+ ```fortran
29
+ pause 'check the intermediate output'
30
+ ```
31
+
32
+ Use instead:
33
+
34
+ ```fortran
35
+ write (*, *) 'check the intermediate output; press Enter'
36
+ read (*, *)
37
+ ```
38
+
39
+ ## Fix safety
40
+
41
+ The fix deletes the `PAUSE` line (or substitutes `CONTINUE` when the
42
+ line also carries a statement label or further statements), so the
43
+ program no longer stops there. Removing a deliberate operator pause
44
+ changes runtime behaviour, so the fix is always marked unsafe.
45
+ """
46
+
47
+ metadata = RuleMetadata(
48
+ id="RGUS007",
49
+ name="pause-statement",
50
+ message="Use of PAUSE statement detected",
51
+ description="Flags PAUSE statements, deleted from the standard in Fortran 95.",
52
+ default_severity=Severity.HIGH,
53
+ suggestion="Replace PAUSE with an explicit WRITE/READ prompt, or remove it",
54
+ fixable=True,
55
+ )
56
+
57
+ def check(self, source_file: SourceFile) -> list[Finding]:
58
+ return [
59
+ build_statement_finding(self.metadata, source_file, statement, fix=self._build_fix(source_file, statement))
60
+ for unit in source_file.walk_units()
61
+ for statement in unit.statements
62
+ if isinstance(statement, PauseStatement)
63
+ ]
64
+
65
+ def _build_fix(self, source_file: SourceFile, statement: PauseStatement) -> Fix | None:
66
+ """Build the PAUSE-removal fix, or None.
67
+
68
+ Unsafe either way: removing a PAUSE changes runtime behaviour
69
+ (the program no longer stops for the operator).
70
+ """
71
+ source = source_file.source
72
+ span = statement.span
73
+ if not span_fits(source, span):
74
+ return None
75
+ lines = full_lines_span(source, span)
76
+ before = source[lines.start_byte : span.start_byte].strip()
77
+ after = source[span.end_byte : lines.end_byte].strip()
78
+ if before or (after and not after.startswith(b"!")):
79
+ # The line carries more than the PAUSE — a statement label
80
+ # (possibly a GOTO target) before it, or further statements
81
+ # after a semicolon. Substitute CONTINUE so the line's other
82
+ # meaning stays intact. A trailing !-comment does not force
83
+ # this path: it describes the PAUSE and goes with it.
84
+ return Fix(
85
+ applicability=Applicability.UNSAFE,
86
+ edits=(replacement(span, "continue"),),
87
+ message="Replace `pause` with `continue`",
88
+ )
89
+ return Fix(
90
+ applicability=Applicability.UNSAFE,
91
+ edits=(deletion(lines),),
92
+ message="Remove `pause` statement",
93
+ )
@@ -0,0 +1,100 @@
1
+ import re
2
+ from typing import TYPE_CHECKING
3
+
4
+ from argusf.analysis.rule import ConfigurableRule, RuleMetadata
5
+ from argusf.analysis.rules.registry import register
6
+ from argusf.ir.models import Comment, Finding, Severity, SourceFile
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Sequence
10
+
11
+ from argusf.config.models import LintConfig
12
+
13
+
14
+ @register
15
+ class TodoComment(ConfigurableRule):
16
+ """## What it does
17
+
18
+ Checks for comments that begin with a task tag (`TODO`, `FIXME` and
19
+ `XXX` by default).
20
+
21
+ ## Why is this bad?
22
+
23
+ Task tags mark work the author deferred: a missing edge case, a
24
+ known inaccuracy, a planned clean-up. Left in the code they rot
25
+ silently — nothing ensures the task is ever resolved, revisited, or
26
+ even seen again. Surfacing them keeps deferred work visible so it
27
+ can be resolved, or tracked somewhere with more accountability than
28
+ a comment.
29
+
30
+ ## Example
31
+
32
+ ```fortran
33
+ ! TODO: handle the singular matrix case
34
+ call invert(a)
35
+ ```
36
+
37
+ ## Options
38
+
39
+ - `lint.task_tags`
40
+ """
41
+
42
+ metadata = RuleMetadata(
43
+ id="RGUS008",
44
+ name="todo-comment",
45
+ message="Comment contains a task tag",
46
+ description="Flags comments that begin with a configured task tag such as TODO or FIXME.",
47
+ default_severity=Severity.INFO,
48
+ suggestion="Resolve the task, or move it to an issue tracker and remove the comment",
49
+ preview=True,
50
+ )
51
+
52
+ def __init__(self) -> None:
53
+ """Construct the rule unconfigured.
54
+
55
+ The engine injects the resolved lint config via configure()
56
+ before any check() call.
57
+ """
58
+ self._pattern: re.Pattern[str] | None = None
59
+
60
+ def configure(self, config: LintConfig) -> None:
61
+ self._pattern = self._tag_pattern(config.task_tags)
62
+
63
+ def _tag_pattern(self, tags: Sequence[str]) -> re.Pattern[str] | None:
64
+ """Compile the task-tag match pattern, or None if disabled.
65
+
66
+ The tag must open the comment (any number of bangs, then
67
+ optional whitespace); prose that merely mentions a tag
68
+ mid-comment does not fire. The lookahead stops a tag matching
69
+ as the prefix of a longer word (TODO vs TODOS). Matching is
70
+ case-sensitive. An empty tag list disables the rule.
71
+ """
72
+ if not tags:
73
+ return None
74
+ alternation = "|".join(re.escape(tag) for tag in tags)
75
+ return re.compile(rf"^!+\s*({alternation})(?!\w)")
76
+
77
+ def check(self, source_file: SourceFile) -> list[Finding]:
78
+ pattern = self._pattern
79
+ if pattern is None:
80
+ return []
81
+ return [
82
+ self._build_finding(source_file, comment, match)
83
+ for comment in source_file.walk_comments()
84
+ if (match := pattern.match(comment.text)) is not None
85
+ ]
86
+
87
+ def _build_finding(self, source_file: SourceFile, comment: Comment, match: re.Match[str]) -> Finding:
88
+ tag = match.group(1)
89
+ span = comment.span
90
+ return Finding(
91
+ rule_id=self.metadata.id,
92
+ message=f"Comment contains task tag {tag}",
93
+ severity=self.metadata.default_severity,
94
+ file_path=source_file.file_path,
95
+ line=span.start_row,
96
+ column=span.start_col + match.start(1),
97
+ end_line=span.start_row,
98
+ end_column=span.start_col + match.end(1),
99
+ suggestion=self.metadata.suggestion,
100
+ )
@@ -0,0 +1,258 @@
1
+ from itertools import pairwise
2
+
3
+ from argusf.analysis.rule import Rule, RuleMetadata
4
+ from argusf.analysis.rules.registry import register
5
+ from argusf.autofix.edits import replacement
6
+ from argusf.autofix.models import Applicability, Fix
7
+ from argusf.autofix.source import full_lines_span, line_indent, slice_of, span_between, span_fits
8
+ from argusf.ir.models import Comment, Finding, Severity, SourceFile, Statement, UseStatement
9
+
10
+
11
+ @register
12
+ class UnsortedUseStatements(Rule):
13
+ """## What it does
14
+
15
+ Checks for blocks of consecutive `USE` statements that are not sorted
16
+ alphabetically by module name.
17
+
18
+ ## Why is this bad?
19
+
20
+ A unit's `USE` statements are its import list. Keeping each block
21
+ sorted makes a dependency findable at a glance, keeps diffs that add a
22
+ module small and conflict-free, and stops duplicate imports from
23
+ hiding. The order has no semantic meaning in Fortran, so sorting is
24
+ free.
25
+
26
+ Only runs of directly adjacent `USE` statements are compared; a blank
27
+ line or a comment line starts a new block, so deliberate grouping is
28
+ preserved (sort within your groups). Module names compare
29
+ case-insensitively, as Fortran itself treats them.
30
+
31
+ ## Example
32
+
33
+ ```fortran
34
+ use solver_mod
35
+ use kinds_mod
36
+ ```
37
+
38
+ Use instead:
39
+
40
+ ```fortran
41
+ use kinds_mod
42
+ use solver_mod
43
+ ```
44
+
45
+ ## Fix safety
46
+
47
+ The fix reorders whole lines (continuations and trailing comments move
48
+ with their statement) and never crosses a blank line, comment line, or
49
+ non-USE statement. A line holding several `;`-separated USE statements
50
+ is split so each statement gets its own line — `;` is a pure statement
51
+ separator, so the split is as semantically inert as the reordering.
52
+ Because `USE` order is semantically inert, the fix is safe. A shared
53
+ line carrying anything besides its USE statements and `;` separators
54
+ (a trailing comment, a statement label) is reported without a fix.
55
+ """
56
+
57
+ metadata = RuleMetadata(
58
+ id="RGUS009",
59
+ name="unsorted-use-statements",
60
+ message="Block of USE statements is not sorted by module name",
61
+ description="Flags consecutive USE statements that are not sorted alphabetically by module name.",
62
+ default_severity=Severity.LOW,
63
+ suggestion="Sort the USE statements alphabetically by module name",
64
+ preview=True, # Must survey how real codebases group their USE blocks first
65
+ fixable=True,
66
+ )
67
+
68
+ def check(self, source_file: SourceFile) -> list[Finding]:
69
+ return [
70
+ self._build_finding(source_file, run)
71
+ for unit in source_file.walk_units()
72
+ for run in self._use_runs(unit.statements)
73
+ if not self._is_sorted(run)
74
+ ]
75
+
76
+ def _use_runs(self, statements: list[Statement]) -> list[list[UseStatement]]:
77
+ """Group statements into runs of adjacent USE statements.
78
+
79
+ A run is a maximal sequence of USE statements on directly
80
+ adjacent lines — the unit of sorting. Statements arrive in
81
+ document order, so one pass suffices: each statement either
82
+ rides along, extends the current run, or ends it (and a USE
83
+ after a gap immediately starts the next one).
84
+ """
85
+ runs: list[list[UseStatement]] = []
86
+ current: list[UseStatement] = []
87
+ for statement in statements:
88
+ if self._is_trailing_comment(statement, current):
89
+ # `use m ! why`: the comment is a separate statement on
90
+ # the USE's own line. The whole line moves as one, so it
91
+ # neither breaks nor extends the run.
92
+ continue
93
+ if not self._extends_run(statement, current):
94
+ # Anything else ends the run: a non-USE statement, a
95
+ # comment on its own line, or a USE after a blank line
96
+ # (blank lines produce no statement, so they are visible
97
+ # only as a row gap).
98
+ if current:
99
+ runs.append(current)
100
+ current = []
101
+ if isinstance(statement, UseStatement):
102
+ current.append(statement)
103
+ if current:
104
+ runs.append(current)
105
+ return runs
106
+
107
+ def _is_trailing_comment(self, statement: Statement, current: list[UseStatement]) -> bool:
108
+ return isinstance(statement, Comment) and bool(current) and statement.span.start_row == current[-1].span.end_row
109
+
110
+ def _extends_run(self, statement: Statement, current: list[UseStatement]) -> bool:
111
+ """Whether `statement` continues the current run.
112
+
113
+ True when it is a USE on the line directly below the run's last
114
+ line (or the run is empty and it starts one).
115
+ """
116
+ if not isinstance(statement, UseStatement):
117
+ return False
118
+ return not current or statement.span.start_row <= current[-1].span.end_row + 1
119
+
120
+ def _is_sorted(self, run: list[UseStatement]) -> bool:
121
+ return all(self._sort_key(a) <= self._sort_key(b) for a, b in pairwise(run))
122
+
123
+ def _sort_key(self, statement: UseStatement) -> str:
124
+ """The casefolded module name a block is sorted by.
125
+
126
+ Fortran names are case-insensitive, so comparison casefolds.
127
+ """
128
+ return statement.module_name.casefold()
129
+
130
+ def _build_finding(self, source_file: SourceFile, run: list[UseStatement]) -> Finding:
131
+ first, last = run[0], run[-1]
132
+ return Finding(
133
+ rule_id=self.metadata.id,
134
+ message=self.metadata.message,
135
+ severity=self.metadata.default_severity,
136
+ file_path=source_file.file_path,
137
+ line=first.span.start_row,
138
+ column=first.span.start_col,
139
+ end_line=last.span.end_row,
140
+ end_column=last.span.end_col,
141
+ suggestion=self.metadata.suggestion,
142
+ fix=self._build_fix(source_file, run),
143
+ )
144
+
145
+ def _build_fix(self, source_file: SourceFile, run: list[UseStatement]) -> Fix | None:
146
+ """Build the block-reordering fix for a run, or None.
147
+
148
+ One edit replacing the whole block with its statements
149
+ reordered, one per line. A statement alone on its line moves
150
+ with its whole line (trailing comments and continuation lines
151
+ travel with it); `;`-separated statements sharing a line are
152
+ split onto a line each.
153
+ """
154
+ source = source_file.source
155
+ if not span_fits(source, run[-1].span):
156
+ return None
157
+ pieces = self._pieces(source, run)
158
+ if pieces is None:
159
+ return None
160
+ # Stable sort: duplicate imports of the same module keep their
161
+ # document order.
162
+ pieces.sort(key=lambda piece: piece[0])
163
+ content = "".join(self._terminated(text) for _, text in pieces)
164
+ block = full_lines_span(source, span_between(source, run[0].span.start_byte, run[-1].span.end_byte))
165
+ if not slice_of(source, block).endswith(b"\n"):
166
+ # The block sat at end-of-file without a final newline; a pure
167
+ # reorder must preserve that, so drop the one _terminated added.
168
+ content = content.removesuffix("\n")
169
+ return Fix(
170
+ applicability=Applicability.SAFE,
171
+ edits=(replacement(block, content),),
172
+ message="Sort `use` statements",
173
+ )
174
+
175
+ def _pieces(self, source: bytes, run: list[UseStatement]) -> list[tuple[str, str]] | None:
176
+ """The (sort key, text) pairs the block is rebuilt from.
177
+
178
+ One per statement, or None when the block cannot be rebuilt
179
+ faithfully.
180
+ """
181
+ pieces: list[tuple[str, str]] = []
182
+ try:
183
+ for line_statements in self._statements_by_line(source, run):
184
+ if len(line_statements) == 1:
185
+ pieces.append(self._whole_line(source, line_statements[0]))
186
+ elif self._splits_cleanly(source, line_statements):
187
+ pieces.extend(self._split_line(source, line_statements))
188
+ else:
189
+ # The shared line carries something besides its USE
190
+ # statements and `;` separators (a trailing comment, a
191
+ # statement label) that has no unambiguous home once
192
+ # the line splits; report without a fix.
193
+ return None
194
+ except UnicodeDecodeError:
195
+ # Moving lines we cannot decode risks mangling them; report
196
+ # without a fix.
197
+ return None
198
+ return pieces
199
+
200
+ def _statements_by_line(self, source: bytes, run: list[UseStatement]) -> list[list[UseStatement]]:
201
+ """Group the run's statements by physical line.
202
+
203
+ Statements whose whole-line extents overlap are `;`-separated
204
+ on a shared line and must be rewritten together.
205
+ """
206
+ groups: list[list[UseStatement]] = []
207
+ previous_line_end = 0
208
+ for statement in run:
209
+ line = full_lines_span(source, statement.span)
210
+ if groups and line.start_byte < previous_line_end:
211
+ groups[-1].append(statement)
212
+ else:
213
+ groups.append([statement])
214
+ previous_line_end = line.end_byte
215
+ return groups
216
+
217
+ def _whole_line(self, source: bytes, statement: UseStatement) -> tuple[str, str]:
218
+ """The (sort key, text) for a statement that owns its line(s).
219
+
220
+ It moves whole, so its trailing comment and continuation lines
221
+ travel with it.
222
+ """
223
+ return self._sort_key(statement), slice_of(source, full_lines_span(source, statement.span)).decode()
224
+
225
+ def _splits_cleanly(self, source: bytes, line_statements: list[UseStatement]) -> bool:
226
+ """Whether a shared line holds only USE statements and `;`.
227
+
228
+ Blanks out the statements' own bytes and checks that nothing
229
+ but `;` separators and whitespace remains.
230
+ """
231
+ lines = full_lines_span(
232
+ source, span_between(source, line_statements[0].span.start_byte, line_statements[-1].span.end_byte)
233
+ )
234
+ residue = bytearray(slice_of(source, lines))
235
+ for statement in line_statements:
236
+ start = statement.span.start_byte - lines.start_byte
237
+ end = statement.span.end_byte - lines.start_byte
238
+ residue[start:end] = b" " * (end - start)
239
+ return not residue.translate(None, delete=b" \t;\r\n")
240
+
241
+ def _split_line(self, source: bytes, line_statements: list[UseStatement]) -> list[tuple[str, str]]:
242
+ """Move each statement of a shared line onto its own line.
243
+
244
+ Each keeps the shared line's indentation.
245
+ """
246
+ indent = line_indent(source, line_statements[0].span.start_byte)
247
+ return [
248
+ (self._sort_key(statement), f"{indent}{slice_of(source, statement.span).decode()}")
249
+ for statement in line_statements
250
+ ]
251
+
252
+ def _terminated(self, line: str) -> str:
253
+ """Ensure `line` ends with a newline.
254
+
255
+ The block's last line may sit at end-of-file without one; once
256
+ it moves up it needs a newline.
257
+ """
258
+ return line if line.endswith("\n") else f"{line}\n"
@@ -0,0 +1,83 @@
1
+ """Resolving select/ignore selectors to a set of rule ids."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from argusf.analysis.rules import RuleRegistry
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Iterable
9
+
10
+ ALL_SELECTOR = "ALL"
11
+
12
+
13
+ class UnknownRuleSelectorError(ValueError):
14
+ """Raised when a selector matches no known rule."""
15
+
16
+ def __init__(self, selector: str) -> None:
17
+ """Build the error for an unrecognised `selector`.
18
+
19
+ Args:
20
+ selector: The selector that matched no rule.
21
+ """
22
+ super().__init__(f"Unknown rule selector: {selector!r}")
23
+
24
+
25
+ def _matches(selector: str, rule_id: str) -> bool:
26
+ return selector == ALL_SELECTOR or rule_id.startswith(selector)
27
+
28
+
29
+ def _specificity(selector: str) -> int:
30
+ return 0 if selector == ALL_SELECTOR else len(selector)
31
+
32
+
33
+ class RuleSelectionResolver:
34
+ """Resolves select/ignore selectors against known rule ids."""
35
+
36
+ def __init__(self, known_rule_ids: Iterable[str] | None = None) -> None:
37
+ """Bind the resolver to a set of known rule ids.
38
+
39
+ Args:
40
+ known_rule_ids: Rule ids to resolve against; defaults to
41
+ the registered rules. Passing an explicit iterable
42
+ keeps unit tests independent of the registry.
43
+ """
44
+ self._known_rule_ids = list(RuleRegistry.keys() if known_rule_ids is None else known_rule_ids)
45
+
46
+ def resolve(self, select: Iterable[str], ignore: Iterable[str]) -> set[str]:
47
+ """Return the rule ids enabled by `select` minus `ignore`.
48
+
49
+ Per rule, the most specific matching selector on each side
50
+ wins; on a tie, ignore beats select. ALL is least specific.
51
+
52
+ Args:
53
+ select: Enabling selectors (codes, prefixes, or ALL).
54
+ ignore: Disabling selectors, same forms.
55
+
56
+ Returns:
57
+ The set of enabled rule ids.
58
+
59
+ Raises:
60
+ UnknownRuleSelectorError: On a selector matching no known
61
+ rule.
62
+ """
63
+ select = list(select)
64
+ ignore = list(ignore)
65
+ self._validate(select)
66
+ self._validate(ignore)
67
+ return {rule_id for rule_id in self._known_rule_ids if self._is_selected(rule_id, select, ignore)}
68
+
69
+ def _validate(self, selectors: list[str]) -> None:
70
+ for selector in selectors:
71
+ if selector == ALL_SELECTOR:
72
+ continue
73
+ if not selector or not any(rule_id.startswith(selector) for rule_id in self._known_rule_ids):
74
+ raise UnknownRuleSelectorError(selector)
75
+
76
+ def _is_selected(self, rule_id: str, select: list[str], ignore: list[str]) -> bool:
77
+ # The most specific (longest) matching selector on each side
78
+ # decides; on a specificity tie, ignore takes precedence over
79
+ # select. ALL is the least specific selector. A rule matched by
80
+ # neither side is off.
81
+ best_select = max((_specificity(s) for s in select if _matches(s, rule_id)), default=-1)
82
+ best_ignore = max((_specificity(s) for s in ignore if _matches(s, rule_id)), default=-1)
83
+ return best_select > best_ignore