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,162 @@
|
|
|
1
|
+
"""Shared formatting helpers for the text and JSON reporters."""
|
|
2
|
+
|
|
3
|
+
from typing import IO, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from argusf.analysis.rules import RuleRegistry
|
|
8
|
+
from argusf.analysis.syntax_errors import SYNTAX_ERROR_CODE, SYNTAX_ERROR_NAME
|
|
9
|
+
from argusf.autofix.context import FixContext, FixMode
|
|
10
|
+
from argusf.autofix.models import Applicability
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from argusf.ir.models import Finding, RunResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def display_root(source_root: Path) -> Path:
|
|
20
|
+
"""The directory findings' paths are shown relative to."""
|
|
21
|
+
return source_root.parent if source_root.is_file() else source_root
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def summary_line(result: RunResult, active_findings: list[Finding], context: FixContext) -> str:
|
|
25
|
+
"""Build the run's trailing summary line for text reports."""
|
|
26
|
+
fixed = result.fixed_count
|
|
27
|
+
remaining = len(active_findings)
|
|
28
|
+
findings_part = f"{remaining} finding(s)."
|
|
29
|
+
if context.mode in (FixMode.FIX, FixMode.FIX_ONLY) and fixed:
|
|
30
|
+
findings_part = f"{fixed + remaining} finding(s) ({fixed} fixed, {remaining} remaining)."
|
|
31
|
+
return (
|
|
32
|
+
f"\nChecked {result.total_files} file(s) in {result.elapsed:.2f}s"
|
|
33
|
+
f" ({result.files_from_cache} from cache)."
|
|
34
|
+
f" {findings_part}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def display_position(finding: Finding) -> tuple[int, int]:
|
|
39
|
+
"""Return a finding's 1-based (row, column).
|
|
40
|
+
|
|
41
|
+
The single source of truth for the 0-based (stored) to 1-based
|
|
42
|
+
(displayed) conversion shared by every text and JSON format.
|
|
43
|
+
"""
|
|
44
|
+
return finding.line + 1, (finding.column or 0) + 1
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def echo(stream: IO[str] | None, message: str = "") -> None:
|
|
48
|
+
"""Write a line to the given output stream."""
|
|
49
|
+
click.echo(message, file=stream)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def rule_names() -> dict[str, str]:
|
|
53
|
+
"""Map every rule id (plus ERROR) to its display name."""
|
|
54
|
+
names = {rule_cls.metadata.id: rule_cls.metadata.name for rule_cls in RuleRegistry.values()}
|
|
55
|
+
names[SYNTAX_ERROR_CODE] = SYNTAX_ERROR_NAME
|
|
56
|
+
return names
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def fix_applies(finding: Finding, context: FixContext) -> bool:
|
|
60
|
+
"""Whether the finding's fix applies at this safety level.
|
|
61
|
+
|
|
62
|
+
The single predicate behind the [*] marker and every fixable
|
|
63
|
+
tally; deliberately mode-independent, so a plain report marks
|
|
64
|
+
exactly what --fix would apply.
|
|
65
|
+
"""
|
|
66
|
+
return finding.fix is not None and context.applies(finding.fix)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fix_marker(finding: Finding, context: FixContext) -> str:
|
|
70
|
+
"""The `[*] ` marker for a fixable finding, else empty."""
|
|
71
|
+
return "[*] " if fix_applies(finding, context) else ""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _applicable_count(active_findings: list[Finding], context: FixContext) -> int:
|
|
75
|
+
return sum(1 for finding in active_findings if fix_applies(finding, context))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _hidden_count(active_findings: list[Finding], context: FixContext) -> int:
|
|
79
|
+
"""Count fixes one --unsafe-fixes away: unsafe fixes while off."""
|
|
80
|
+
if context.unsafe_fixes:
|
|
81
|
+
return 0
|
|
82
|
+
return sum(
|
|
83
|
+
1
|
|
84
|
+
for finding in active_findings
|
|
85
|
+
if finding.fix is not None and finding.fix.applicability is Applicability.UNSAFE
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _hidden_clause(active_findings: list[Finding], context: FixContext) -> str:
|
|
90
|
+
hidden = _hidden_count(active_findings, context)
|
|
91
|
+
if not hidden:
|
|
92
|
+
return ""
|
|
93
|
+
return f" ({hidden} hidden fix(es) can be enabled with the `--unsafe-fixes` option)"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def fixable_hint_line(active_findings: list[Finding], context: FixContext) -> str | None:
|
|
97
|
+
"""Build the fixable-count hint line, or None if none apply."""
|
|
98
|
+
applicable = _applicable_count(active_findings, context)
|
|
99
|
+
hidden_clause = _hidden_clause(active_findings, context)
|
|
100
|
+
if applicable:
|
|
101
|
+
return f"[*] {applicable} fixable with the `--fix` option{hidden_clause}."
|
|
102
|
+
if hidden_clause:
|
|
103
|
+
return f"No fixes available{hidden_clause}."
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _additional_clause(active_findings: list[Finding], context: FixContext) -> str:
|
|
108
|
+
hidden = _hidden_count(active_findings, context)
|
|
109
|
+
if not hidden:
|
|
110
|
+
return ""
|
|
111
|
+
return f" ({hidden} additional fix(es) available with `--unsafe-fixes`)"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fixed_only_line(result: RunResult, active_findings: list[Finding], context: FixContext) -> str | None:
|
|
115
|
+
"""The FIX_ONLY summary line, or None when nothing was fixed."""
|
|
116
|
+
if not result.fixed_count:
|
|
117
|
+
return None
|
|
118
|
+
return f"Fixed {result.fixed_count} finding(s){_additional_clause(active_findings, context)}."
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def would_fix_line(result: RunResult, active_findings: list[Finding], context: FixContext) -> str | None:
|
|
122
|
+
"""The --diff would-fix summary line, or None."""
|
|
123
|
+
if not result.fixed_count:
|
|
124
|
+
hidden_clause = _hidden_clause(active_findings, context)
|
|
125
|
+
return f"No fixes available{hidden_clause}." if hidden_clause else None
|
|
126
|
+
return f"Would fix {result.fixed_count} finding(s){_additional_clause(active_findings, context)}."
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _write_show_fixes(write: Callable[[str], None], result: RunResult, root: Path) -> None:
|
|
130
|
+
names = rule_names()
|
|
131
|
+
write(f"Fixed {result.fixed_count} finding(s):")
|
|
132
|
+
for path, counts in result.fixed_by_file.items():
|
|
133
|
+
write(f"- {path.relative_to(root)}:")
|
|
134
|
+
rows = sorted(((count, code) for code, count in counts.items()), key=lambda row: (-row[0], row[1]))
|
|
135
|
+
for count, code in rows:
|
|
136
|
+
write(f" {count} \u00d7 {code} ({names[code]})")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def write_text_epilogue(
|
|
140
|
+
write: Callable[[str], None],
|
|
141
|
+
result: RunResult,
|
|
142
|
+
active_findings: list[Finding],
|
|
143
|
+
context: FixContext,
|
|
144
|
+
root: Path,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Write the shared tail of a text report.
|
|
147
|
+
|
|
148
|
+
Emits the --show-fixes enumeration, then either the fix-only
|
|
149
|
+
summary (the whole report in FIX_ONLY mode) or the summary line
|
|
150
|
+
plus the fixable hint.
|
|
151
|
+
"""
|
|
152
|
+
if context.show_fixes and result.fixed_by_file:
|
|
153
|
+
_write_show_fixes(write, result, root)
|
|
154
|
+
if context.mode is FixMode.FIX_ONLY:
|
|
155
|
+
line = fixed_only_line(result, active_findings, context)
|
|
156
|
+
if line:
|
|
157
|
+
write(line)
|
|
158
|
+
return
|
|
159
|
+
write(summary_line(result, active_findings, context))
|
|
160
|
+
hint = fixable_hint_line(active_findings, context)
|
|
161
|
+
if hint:
|
|
162
|
+
write(hint)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The reporter protocol and its registry."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Protocol
|
|
4
|
+
|
|
5
|
+
from argusf.registry import Registry
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from argusf.ir.models import RunResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Reporter(Protocol):
|
|
14
|
+
"""Writes a run's results to some destination."""
|
|
15
|
+
|
|
16
|
+
def write_results(self, result: RunResult, source_root: Path) -> None:
|
|
17
|
+
"""Write the run's results for `source_root`."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ReporterRegistry: Registry[str, type[Reporter]] = Registry()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .concise import ConciseReporter
|
|
2
|
+
from .diff import DiffReporter
|
|
3
|
+
from .json import JsonReporter
|
|
4
|
+
from .null import NullReporter
|
|
5
|
+
from .standard import StandardReporter
|
|
6
|
+
from .statistics import StatisticsReporter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConciseReporter",
|
|
10
|
+
"DiffReporter",
|
|
11
|
+
"JsonReporter",
|
|
12
|
+
"NullReporter",
|
|
13
|
+
"StandardReporter",
|
|
14
|
+
"StatisticsReporter",
|
|
15
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared base class for stream-writing reporters."""
|
|
2
|
+
|
|
3
|
+
from typing import IO
|
|
4
|
+
|
|
5
|
+
from argusf.autofix.context import FixContext
|
|
6
|
+
from argusf.reporting.formatting import echo
|
|
7
|
+
from argusf.reporting.registry import Reporter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StreamReporter(Reporter):
|
|
11
|
+
"""Shared construction and line-echo for concrete reporters.
|
|
12
|
+
|
|
13
|
+
Purely an implementation convenience — consumers stay typed against
|
|
14
|
+
the Reporter protocol, and subclasses still owe write_results.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, stream: IO[str] | None = None, fix_context: FixContext | None = None) -> None:
|
|
18
|
+
"""Store the output stream and fix context.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
stream: Destination stream; None writes to stdout.
|
|
22
|
+
fix_context: The run's fix context; defaults to a plain
|
|
23
|
+
report context.
|
|
24
|
+
"""
|
|
25
|
+
self._stream = stream
|
|
26
|
+
self._context = fix_context or FixContext()
|
|
27
|
+
|
|
28
|
+
def _echo(self, message: str = "") -> None:
|
|
29
|
+
echo(self._stream, message)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The concise (path:line:) findings reporter."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from argusf.autofix.context import FixMode
|
|
8
|
+
from argusf.reporting.formatting import display_position, display_root, fix_marker, write_text_epilogue
|
|
9
|
+
from argusf.reporting.registry import ReporterRegistry
|
|
10
|
+
from argusf.reporting.reporters.base import StreamReporter
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from argusf.ir.models import RunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@ReporterRegistry.register("concise")
|
|
19
|
+
class ConciseReporter(StreamReporter):
|
|
20
|
+
"""Minimal reporter: one `path:line: code message` per finding."""
|
|
21
|
+
|
|
22
|
+
def write_results(self, result: RunResult, source_root: Path) -> None:
|
|
23
|
+
"""Write each finding as one line, then the epilogue."""
|
|
24
|
+
active_findings = result.active_findings()
|
|
25
|
+
|
|
26
|
+
root = display_root(source_root)
|
|
27
|
+
if self._context.mode is not FixMode.FIX_ONLY:
|
|
28
|
+
for finding in active_findings:
|
|
29
|
+
rel_path = finding.file_path.relative_to(root)
|
|
30
|
+
row, _ = display_position(finding)
|
|
31
|
+
rule_id = click.style(finding.rule_id, fg="red")
|
|
32
|
+
marker = fix_marker(finding, self._context)
|
|
33
|
+
self._echo(f"{rel_path}:{row}: {rule_id} {marker}{finding.message}")
|
|
34
|
+
|
|
35
|
+
write_text_epilogue(self._echo, result, active_findings, self._context, root)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The --diff reporter: diffs plus a would-fix summary."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from argusf.reporting.formatting import would_fix_line
|
|
8
|
+
from argusf.reporting.reporters.base import StreamReporter
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from argusf.ir.models import RunResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DiffReporter(StreamReporter):
|
|
17
|
+
"""Prints per-file diffs then the would-fix summary.
|
|
18
|
+
|
|
19
|
+
The report for --diff runs; selected by mode in the CLI, so it is
|
|
20
|
+
left unregistered.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def write_results(self, result: RunResult, source_root: Path) -> None: # noqa: ARG002 - diff labels are pre-rendered
|
|
24
|
+
"""Write the rendered diffs, then the would-fix line."""
|
|
25
|
+
for _, diff in result.diffs:
|
|
26
|
+
# Diff text is newline-terminated already.
|
|
27
|
+
click.echo(diff, file=self._stream, nl=False)
|
|
28
|
+
line = would_fix_line(result, result.active_findings(), self._context)
|
|
29
|
+
if line:
|
|
30
|
+
self._echo(f"\n{line}" if result.diffs else line)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The ruff-style JSON findings reporter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from argusf.autofix.context import FixMode
|
|
7
|
+
from argusf.reporting.formatting import display_position
|
|
8
|
+
from argusf.reporting.registry import ReporterRegistry
|
|
9
|
+
from argusf.reporting.reporters.base import StreamReporter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from argusf.ir.models import Finding, RunResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@ReporterRegistry.register("json")
|
|
18
|
+
class JsonReporter(StreamReporter):
|
|
19
|
+
"""Emits findings as a ruff-style JSON array of violations."""
|
|
20
|
+
|
|
21
|
+
def write_results(self, result: RunResult, source_root: Path) -> None: # noqa: ARG002 - filenames are absolute, so the root is not needed
|
|
22
|
+
"""Write the findings as a JSON array (empty in FIX_ONLY)."""
|
|
23
|
+
if self._context.mode is FixMode.FIX_ONLY:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
violations = [self._violation(finding) for finding in result.active_findings()]
|
|
27
|
+
self._echo(json.dumps(violations, indent=2))
|
|
28
|
+
|
|
29
|
+
def _violation(self, finding: Finding) -> dict[str, object]:
|
|
30
|
+
row, column = display_position(finding)
|
|
31
|
+
end_row = (finding.line if finding.end_line is None else finding.end_line) + 1
|
|
32
|
+
end_column = column if finding.end_column is None else finding.end_column + 1
|
|
33
|
+
return {
|
|
34
|
+
"code": finding.rule_id,
|
|
35
|
+
"end_location": {"column": end_column, "row": end_row},
|
|
36
|
+
"filename": str(finding.file_path),
|
|
37
|
+
"fix": self._fix_payload(finding),
|
|
38
|
+
"location": {"column": column, "row": row},
|
|
39
|
+
"message": finding.message,
|
|
40
|
+
"severity": finding.severity,
|
|
41
|
+
"suggestion": finding.suggestion,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def _fix_payload(self, finding: Finding) -> dict[str, object] | None:
|
|
45
|
+
fix = finding.fix
|
|
46
|
+
if fix is None:
|
|
47
|
+
return None
|
|
48
|
+
return {
|
|
49
|
+
"applicability": fix.applicability,
|
|
50
|
+
"edits": [
|
|
51
|
+
{
|
|
52
|
+
"content": edit.content,
|
|
53
|
+
"end_location": {"column": edit.span.end_col + 1, "row": edit.span.end_row + 1},
|
|
54
|
+
"location": {"column": edit.span.start_col + 1, "row": edit.span.start_row + 1},
|
|
55
|
+
}
|
|
56
|
+
for edit in fix.edits
|
|
57
|
+
],
|
|
58
|
+
"message": fix.message,
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""A no-op reporter for programmatic and test use."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from argusf.reporting.registry import Reporter
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from argusf.ir.models import RunResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NullReporter(Reporter):
|
|
14
|
+
"""Discards results; for programmatic and test use.
|
|
15
|
+
|
|
16
|
+
Deliberately unregistered, so a user can never pick it as their
|
|
17
|
+
--output-format.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def write_results(self, result: RunResult, source_root: Path) -> None:
|
|
21
|
+
"""Do nothing with the results."""
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""The standard detailed findings reporter."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from argusf.autofix.context import FixMode
|
|
8
|
+
from argusf.reporting.formatting import display_position, display_root, fix_marker, write_text_epilogue
|
|
9
|
+
from argusf.reporting.registry import ReporterRegistry
|
|
10
|
+
from argusf.reporting.reporters.base import StreamReporter
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from argusf.ir.models import Finding, RunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@ReporterRegistry.register("standard")
|
|
19
|
+
class StandardReporter(StreamReporter):
|
|
20
|
+
"""Detailed reporter with code context and suggestions."""
|
|
21
|
+
|
|
22
|
+
def write_results(self, result: RunResult, source_root: Path) -> None:
|
|
23
|
+
"""Write each finding with context, then the epilogue."""
|
|
24
|
+
active_findings = result.active_findings()
|
|
25
|
+
|
|
26
|
+
root = display_root(source_root)
|
|
27
|
+
if self._context.mode is not FixMode.FIX_ONLY:
|
|
28
|
+
for finding in active_findings:
|
|
29
|
+
rel_path = finding.file_path.relative_to(root)
|
|
30
|
+
self._write_finding(finding, rel_path)
|
|
31
|
+
|
|
32
|
+
write_text_epilogue(self._echo, result, active_findings, self._context, root)
|
|
33
|
+
|
|
34
|
+
def _write_finding(self, finding: Finding, rel_path: Path) -> None:
|
|
35
|
+
row, col = display_position(finding)
|
|
36
|
+
rule_id = click.style(finding.rule_id, fg="red", bold=True)
|
|
37
|
+
self._echo(f"{rule_id} {fix_marker(finding, self._context)}{finding.message}")
|
|
38
|
+
self._echo(f" --> {rel_path}:{row}:{col}")
|
|
39
|
+
|
|
40
|
+
source_line = self._read_line(finding.file_path, finding.line)
|
|
41
|
+
if source_line is not None:
|
|
42
|
+
gutter = f"{row:>4} | "
|
|
43
|
+
self._echo(" |")
|
|
44
|
+
self._echo(f"{gutter}{source_line}")
|
|
45
|
+
zero_col = finding.column or 0
|
|
46
|
+
underline = " " * (len(gutter) + zero_col) + "^" * self._underline_width(finding, source_line, zero_col)
|
|
47
|
+
self._echo(click.style(underline, fg="red"))
|
|
48
|
+
|
|
49
|
+
if finding.suggestion:
|
|
50
|
+
self._echo(f"{click.style('help', fg='cyan')}: {finding.suggestion}")
|
|
51
|
+
|
|
52
|
+
self._echo()
|
|
53
|
+
|
|
54
|
+
def _underline_width(self, finding: Finding, source_line: str, col: int) -> int:
|
|
55
|
+
"""Width of the caret underline for a finding on this line.
|
|
56
|
+
|
|
57
|
+
Columns are 0-based with an exclusive end. Multi-line spans
|
|
58
|
+
underline to end-of-line; everything is clamped to the visible
|
|
59
|
+
line, with a one-caret floor for zero-width spans (e.g. a
|
|
60
|
+
missing token at EOL).
|
|
61
|
+
"""
|
|
62
|
+
if finding.end_line is not None and finding.end_line != finding.line:
|
|
63
|
+
width = len(source_line) - col
|
|
64
|
+
elif finding.end_column is not None:
|
|
65
|
+
width = finding.end_column - col
|
|
66
|
+
else:
|
|
67
|
+
width = 1
|
|
68
|
+
return max(1, min(width, len(source_line) - col))
|
|
69
|
+
|
|
70
|
+
def _read_line(self, file_path: Path, line_no: int) -> str | None:
|
|
71
|
+
try:
|
|
72
|
+
with open(file_path, errors="replace") as f:
|
|
73
|
+
for i, line in enumerate(f):
|
|
74
|
+
if i == line_no:
|
|
75
|
+
return line.rstrip("\n")
|
|
76
|
+
except OSError:
|
|
77
|
+
pass
|
|
78
|
+
return None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The --statistics reporter: per-rule finding counts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import IO, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from argusf.reporting.formatting import display_root, fix_applies, rule_names, write_text_epilogue
|
|
9
|
+
from argusf.reporting.reporters.base import StreamReporter
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from argusf.autofix.context import FixContext
|
|
15
|
+
from argusf.ir.models import Finding, RunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class _StatisticsRow:
|
|
20
|
+
count: int
|
|
21
|
+
code: str
|
|
22
|
+
name: str
|
|
23
|
+
fixable_count: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StatisticsReporter(StreamReporter):
|
|
27
|
+
"""Reports per-rule finding counts instead of the findings.
|
|
28
|
+
|
|
29
|
+
Takes the shape of a reporter but is its own CLI option, so it is
|
|
30
|
+
left unregistered.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
_TEXT_FORMATS = ("standard", "concise")
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, output_format: str, stream: IO[str] | None = None, fix_context: FixContext | None = None
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Bind the reporter to a text or JSON output format.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
output_format: One of "standard", "concise", or "json".
|
|
42
|
+
stream: Destination stream; None writes to stdout.
|
|
43
|
+
fix_context: The run's fix context.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: On an unsupported output format.
|
|
47
|
+
"""
|
|
48
|
+
if output_format != "json" and output_format not in self._TEXT_FORMATS:
|
|
49
|
+
supported = (*self._TEXT_FORMATS, "json")
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"Statistics output does not support format {output_format!r}. Must be one of {supported!s}"
|
|
52
|
+
)
|
|
53
|
+
super().__init__(stream, fix_context)
|
|
54
|
+
self._output_format = output_format
|
|
55
|
+
|
|
56
|
+
def write_results(self, result: RunResult, source_root: Path) -> None:
|
|
57
|
+
"""Write per-rule statistics in the chosen format."""
|
|
58
|
+
if self._output_format == "json":
|
|
59
|
+
self._write_json(result)
|
|
60
|
+
else:
|
|
61
|
+
self._write_text(result, source_root)
|
|
62
|
+
|
|
63
|
+
def _write_text(self, result: RunResult, source_root: Path) -> None:
|
|
64
|
+
active_findings = result.active_findings()
|
|
65
|
+
for row in self._statistics_rows(active_findings):
|
|
66
|
+
marker = "[*]" if row.fixable_count else "[ ]"
|
|
67
|
+
self._echo(f"{row.count}\t{row.code}\t{marker} {row.name}")
|
|
68
|
+
|
|
69
|
+
write_text_epilogue(self._echo, result, active_findings, self._context, display_root(source_root))
|
|
70
|
+
|
|
71
|
+
def _write_json(self, result: RunResult) -> None:
|
|
72
|
+
rows = [
|
|
73
|
+
{
|
|
74
|
+
"code": row.code,
|
|
75
|
+
"name": row.name,
|
|
76
|
+
"count": row.count,
|
|
77
|
+
"fixable": row.fixable_count > 0,
|
|
78
|
+
"fixable_count": row.fixable_count,
|
|
79
|
+
}
|
|
80
|
+
for row in self._statistics_rows(result.active_findings())
|
|
81
|
+
]
|
|
82
|
+
self._echo(json.dumps(rows, indent=2))
|
|
83
|
+
|
|
84
|
+
def _statistics_rows(self, active_findings: list[Finding]) -> list[_StatisticsRow]:
|
|
85
|
+
"""Build the per-rule statistics rows, sorted for display.
|
|
86
|
+
|
|
87
|
+
Rows sort by count descending with ties broken by code
|
|
88
|
+
ascending ("ERROR" < "RGUS...", so syntax errors lead). The
|
|
89
|
+
fixable count is how many of a rule's findings have a fix
|
|
90
|
+
applicable at this run's safety level.
|
|
91
|
+
"""
|
|
92
|
+
counts = Counter(finding.rule_id for finding in active_findings)
|
|
93
|
+
fixable_counts = Counter(finding.rule_id for finding in active_findings if fix_applies(finding, self._context))
|
|
94
|
+
names = rule_names()
|
|
95
|
+
rows = [
|
|
96
|
+
_StatisticsRow(count=count, code=code, name=names[code], fixable_count=fixable_counts[code])
|
|
97
|
+
for code, count in counts.items()
|
|
98
|
+
]
|
|
99
|
+
return sorted(rows, key=lambda row: (-row.count, row.code))
|