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,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))