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
argusf/cli.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""The argusf command-line interface (Click entry point)."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from argusf.analysis.engine import AnalysisEngine
|
|
13
|
+
from argusf.analysis.rules import RuleRegistry
|
|
14
|
+
from argusf.analysis.rules.documentation import RuleDocumentationRenderer
|
|
15
|
+
from argusf.analysis.suppression import SuppressionFilter
|
|
16
|
+
from argusf.autofix import FixContext, FixEngine, FixMode, build_fix_context
|
|
17
|
+
from argusf.cache.backend import CacheBackend
|
|
18
|
+
from argusf.config.config_resolver import ConfigResolver
|
|
19
|
+
from argusf.config.errors import ConfigError
|
|
20
|
+
from argusf.config.models import ArgusConfig, CliArgs, PartialConfig, PartialLintConfig
|
|
21
|
+
from argusf.diagnostics import ClickEchoDiagnostics, Diagnostics, NullDiagnostics
|
|
22
|
+
from argusf.discovery.backend import FileTreeWalker
|
|
23
|
+
from argusf.hashing.hash import HashBackend
|
|
24
|
+
from argusf.orchestrator import Orchestrator
|
|
25
|
+
from argusf.parser.treesitter.backend import TreesitterParsingEngine
|
|
26
|
+
from argusf.reporting import DiffReporter, NullReporter, Reporter, ReporterRegistry, StatisticsReporter
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from argusf.ir.models import RunResult
|
|
30
|
+
|
|
31
|
+
FINDINGS_EXIT_CODE = 1
|
|
32
|
+
ERROR_EXIT_CODE = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_selectors(values: tuple[str, ...]) -> list[str] | None:
|
|
36
|
+
if not values:
|
|
37
|
+
return None
|
|
38
|
+
return [code.strip() for value in values for code in value.split(",") if code.strip()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_config(cli_args: CliArgs) -> ArgusConfig:
|
|
42
|
+
try:
|
|
43
|
+
return ConfigResolver().resolve_config(cli_args)
|
|
44
|
+
except ConfigError as error:
|
|
45
|
+
click.echo(f"argusf: {error}", err=True)
|
|
46
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_reporter(config: ArgusConfig, silent: bool, statistics: bool, fix_context: FixContext) -> Reporter:
|
|
50
|
+
if silent:
|
|
51
|
+
return NullReporter()
|
|
52
|
+
|
|
53
|
+
# In diff mode the diffs are the report, whatever --output-format
|
|
54
|
+
# says; --statistics + --diff is rejected upstream.
|
|
55
|
+
if fix_context.mode is FixMode.DIFF:
|
|
56
|
+
return DiffReporter(fix_context=fix_context)
|
|
57
|
+
|
|
58
|
+
if statistics:
|
|
59
|
+
return StatisticsReporter(config.output_format, fix_context=fix_context)
|
|
60
|
+
|
|
61
|
+
reporter_cls = ReporterRegistry.get(config.output_format)
|
|
62
|
+
if reporter_cls is None:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Got invalid output format {config.output_format!r}. Must be one of {ReporterRegistry.keys()!s}"
|
|
65
|
+
)
|
|
66
|
+
# The Reporter protocol deliberately types behaviour, not
|
|
67
|
+
# construction; every registered reporter accepts this keyword.
|
|
68
|
+
return reporter_cls(fix_context=fix_context) # type: ignore[call-arg]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _check_exit_code(result: RunResult, fix_context: FixContext, *, exit_non_zero_on_fix: bool, exit_zero: bool) -> int:
|
|
72
|
+
if fix_context.mode is FixMode.DIFF:
|
|
73
|
+
return FINDINGS_EXIT_CODE if result.diffs and not exit_zero else 0
|
|
74
|
+
if exit_non_zero_on_fix and result.fixed_count:
|
|
75
|
+
return FINDINGS_EXIT_CODE
|
|
76
|
+
if fix_context.mode is FixMode.FIX_ONLY:
|
|
77
|
+
return 0
|
|
78
|
+
if result.active_findings() and not exit_zero:
|
|
79
|
+
return FINDINGS_EXIT_CODE
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_orchestrator( # noqa: PLR0913
|
|
84
|
+
config: ArgusConfig,
|
|
85
|
+
target: Path,
|
|
86
|
+
diagnostics: Diagnostics,
|
|
87
|
+
reporter: Reporter,
|
|
88
|
+
config_path: Path | None,
|
|
89
|
+
fix_context: FixContext,
|
|
90
|
+
) -> Orchestrator:
|
|
91
|
+
hasher = HashBackend()
|
|
92
|
+
parsing_engine = TreesitterParsingEngine(diagnostics)
|
|
93
|
+
analysis_engine = AnalysisEngine(config, SuppressionFilter(config, target))
|
|
94
|
+
return Orchestrator(
|
|
95
|
+
file_collector=FileTreeWalker(hasher, config, diagnostics, ClickEchoDiagnostics(), config_path),
|
|
96
|
+
parsing_engine=parsing_engine,
|
|
97
|
+
analysis_engine=analysis_engine,
|
|
98
|
+
cache_backend=CacheBackend(
|
|
99
|
+
importlib.metadata.version("argusf"), platform.python_version(), config, diagnostics, hasher
|
|
100
|
+
),
|
|
101
|
+
fix_engine=FixEngine(
|
|
102
|
+
parsing_engine=parsing_engine,
|
|
103
|
+
analyser=analysis_engine,
|
|
104
|
+
hasher=hasher,
|
|
105
|
+
context=fix_context,
|
|
106
|
+
diagnostics=diagnostics,
|
|
107
|
+
),
|
|
108
|
+
diagnostics=diagnostics,
|
|
109
|
+
reporter=reporter,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@click.group()
|
|
114
|
+
@click.version_option(version=importlib.metadata.version("argusf"), prog_name="argusf")
|
|
115
|
+
@click.option(
|
|
116
|
+
"--config",
|
|
117
|
+
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
|
|
118
|
+
help="Path to a TOML configuration file to use instead of discovering argusf.toml.",
|
|
119
|
+
)
|
|
120
|
+
@click.option(
|
|
121
|
+
"--isolated",
|
|
122
|
+
is_flag=True,
|
|
123
|
+
default=False,
|
|
124
|
+
help="Ignore all configuration files.",
|
|
125
|
+
)
|
|
126
|
+
@click.option(
|
|
127
|
+
"--verbose",
|
|
128
|
+
"-v",
|
|
129
|
+
is_flag=True,
|
|
130
|
+
default=False,
|
|
131
|
+
help="Print diagnostics (skipped files, parse failures) to stderr.",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--silent",
|
|
135
|
+
"-s",
|
|
136
|
+
is_flag=True,
|
|
137
|
+
default=False,
|
|
138
|
+
help="Suppress the findings report on stdout; exit codes are unaffected.",
|
|
139
|
+
)
|
|
140
|
+
@click.pass_context
|
|
141
|
+
def cli(ctx: click.Context, config: Path | None, isolated: bool, verbose: bool, silent: bool) -> None:
|
|
142
|
+
"""Argus — A static code analyser for Fortran 90."""
|
|
143
|
+
ctx.obj = CliArgs(config=config, isolated=isolated, verbose=verbose, silent=silent)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.command()
|
|
147
|
+
@click.argument(
|
|
148
|
+
"target",
|
|
149
|
+
required=False,
|
|
150
|
+
type=click.Path(exists=True, file_okay=True, dir_okay=True, resolve_path=True, path_type=Path),
|
|
151
|
+
)
|
|
152
|
+
@click.option(
|
|
153
|
+
"--preview/--no-preview",
|
|
154
|
+
default=None,
|
|
155
|
+
help="Enable rules marked as being in preview.",
|
|
156
|
+
)
|
|
157
|
+
@click.option(
|
|
158
|
+
"--no-cache",
|
|
159
|
+
"-n",
|
|
160
|
+
is_flag=True,
|
|
161
|
+
default=False,
|
|
162
|
+
help="Disable cache reads.",
|
|
163
|
+
)
|
|
164
|
+
@click.option(
|
|
165
|
+
"--output-format",
|
|
166
|
+
type=click.Choice(list(ReporterRegistry.keys())),
|
|
167
|
+
default=None,
|
|
168
|
+
help="Format for the findings report.",
|
|
169
|
+
)
|
|
170
|
+
@click.option(
|
|
171
|
+
"--show-files",
|
|
172
|
+
is_flag=True,
|
|
173
|
+
default=False,
|
|
174
|
+
help="List the files that would be analysed, without analysing them.",
|
|
175
|
+
)
|
|
176
|
+
@click.option(
|
|
177
|
+
"--exclude",
|
|
178
|
+
multiple=True,
|
|
179
|
+
metavar="PATTERN",
|
|
180
|
+
help="Replace the exclude list with these fnmatch patterns. May be repeated; not comma-split.",
|
|
181
|
+
)
|
|
182
|
+
@click.option(
|
|
183
|
+
"--extend-exclude",
|
|
184
|
+
multiple=True,
|
|
185
|
+
metavar="PATTERN",
|
|
186
|
+
help="Add an fnmatch pattern on top of the resolved exclude list. May be repeated; not comma-split.",
|
|
187
|
+
)
|
|
188
|
+
@click.option(
|
|
189
|
+
"--include",
|
|
190
|
+
multiple=True,
|
|
191
|
+
metavar="PATTERN",
|
|
192
|
+
help="Restrict discovery to Fortran files whose name matches these fnmatch patterns. "
|
|
193
|
+
"May be repeated; not comma-split.",
|
|
194
|
+
)
|
|
195
|
+
@click.option(
|
|
196
|
+
"--extend-include",
|
|
197
|
+
multiple=True,
|
|
198
|
+
metavar="PATTERN",
|
|
199
|
+
help="Add an fnmatch pattern on top of the resolved include list. May be repeated; not comma-split.",
|
|
200
|
+
)
|
|
201
|
+
@click.option(
|
|
202
|
+
"--respect-gitignore/--no-respect-gitignore",
|
|
203
|
+
default=None,
|
|
204
|
+
help="Honour .gitignore files during file discovery.",
|
|
205
|
+
)
|
|
206
|
+
@click.option(
|
|
207
|
+
"--select",
|
|
208
|
+
multiple=True,
|
|
209
|
+
metavar="SELECTORS",
|
|
210
|
+
help="Enable only these rules. Selectors are rule codes (RGUS001), prefixes (RGUS), or ALL; "
|
|
211
|
+
"comma-separated and repeatable.",
|
|
212
|
+
)
|
|
213
|
+
@click.option(
|
|
214
|
+
"--extend-select",
|
|
215
|
+
multiple=True,
|
|
216
|
+
metavar="SELECTORS",
|
|
217
|
+
help="Rules to enable on top of the resolved select without replacing it.",
|
|
218
|
+
)
|
|
219
|
+
@click.option(
|
|
220
|
+
"--ignore",
|
|
221
|
+
multiple=True,
|
|
222
|
+
metavar="SELECTORS",
|
|
223
|
+
help="Disable these rules. The most specific selector wins; on a tie, ignore beats select.",
|
|
224
|
+
)
|
|
225
|
+
@click.option(
|
|
226
|
+
"--fixable",
|
|
227
|
+
multiple=True,
|
|
228
|
+
metavar="SELECTORS",
|
|
229
|
+
help="Rules whose fixes may be applied. Same selector syntax as --select; defaults to ALL.",
|
|
230
|
+
)
|
|
231
|
+
@click.option(
|
|
232
|
+
"--unfixable",
|
|
233
|
+
multiple=True,
|
|
234
|
+
metavar="SELECTORS",
|
|
235
|
+
help="Rules whose fixes are never applied (their findings are still reported).",
|
|
236
|
+
)
|
|
237
|
+
@click.option(
|
|
238
|
+
"--extend-fixable",
|
|
239
|
+
multiple=True,
|
|
240
|
+
metavar="SELECTORS",
|
|
241
|
+
help="Rules to treat as fixable on top of the resolved fixable list without replacing it.",
|
|
242
|
+
)
|
|
243
|
+
@click.option(
|
|
244
|
+
"--fix/--no-fix",
|
|
245
|
+
default=None,
|
|
246
|
+
help="Apply safe fixes for the resolved fixable rules; add --unsafe-fixes to widen.",
|
|
247
|
+
)
|
|
248
|
+
@click.option(
|
|
249
|
+
"--unsafe-fixes/--no-unsafe-fixes",
|
|
250
|
+
default=None,
|
|
251
|
+
help="Also display and (with --fix) apply fixes that may change program behaviour.",
|
|
252
|
+
)
|
|
253
|
+
@click.option(
|
|
254
|
+
"--fix-only/--no-fix-only",
|
|
255
|
+
default=None,
|
|
256
|
+
help="Apply fixes but do not report or exit non-zero for leftover findings. Implies --fix.",
|
|
257
|
+
)
|
|
258
|
+
@click.option(
|
|
259
|
+
"--show-fixes/--no-show-fixes",
|
|
260
|
+
default=None,
|
|
261
|
+
help="Enumerate the applied fixes after a fixing run.",
|
|
262
|
+
)
|
|
263
|
+
@click.option(
|
|
264
|
+
"--diff",
|
|
265
|
+
is_flag=True,
|
|
266
|
+
default=False,
|
|
267
|
+
help="Show what --fix would change as a diff, without writing any file. Exits 1 if a diff exists.",
|
|
268
|
+
)
|
|
269
|
+
@click.option(
|
|
270
|
+
"--exit-non-zero-on-fix",
|
|
271
|
+
is_flag=True,
|
|
272
|
+
default=False,
|
|
273
|
+
help="Exit 1 if any file was modified by fixes, even when no findings remain.",
|
|
274
|
+
)
|
|
275
|
+
@click.option(
|
|
276
|
+
"--exit-zero",
|
|
277
|
+
"-e",
|
|
278
|
+
is_flag=True,
|
|
279
|
+
default=False,
|
|
280
|
+
help="Exit 0 even when findings remain (or a --diff would change files). Tool errors still exit 2.",
|
|
281
|
+
)
|
|
282
|
+
@click.option(
|
|
283
|
+
"--statistics",
|
|
284
|
+
is_flag=True,
|
|
285
|
+
default=False,
|
|
286
|
+
help="Report per-rule finding counts instead of individual findings.",
|
|
287
|
+
)
|
|
288
|
+
@click.pass_obj
|
|
289
|
+
def check( # noqa: PLR0913
|
|
290
|
+
cli_args: CliArgs,
|
|
291
|
+
preview: bool | None,
|
|
292
|
+
no_cache: bool,
|
|
293
|
+
output_format: str | None,
|
|
294
|
+
show_files: bool,
|
|
295
|
+
exclude: tuple[str, ...],
|
|
296
|
+
extend_exclude: tuple[str, ...],
|
|
297
|
+
include: tuple[str, ...],
|
|
298
|
+
extend_include: tuple[str, ...],
|
|
299
|
+
respect_gitignore: bool | None,
|
|
300
|
+
select: tuple[str, ...],
|
|
301
|
+
extend_select: tuple[str, ...],
|
|
302
|
+
ignore: tuple[str, ...],
|
|
303
|
+
fixable: tuple[str, ...],
|
|
304
|
+
unfixable: tuple[str, ...],
|
|
305
|
+
extend_fixable: tuple[str, ...],
|
|
306
|
+
fix: bool | None,
|
|
307
|
+
unsafe_fixes: bool | None,
|
|
308
|
+
fix_only: bool | None,
|
|
309
|
+
show_fixes: bool | None,
|
|
310
|
+
diff: bool,
|
|
311
|
+
exit_non_zero_on_fix: bool,
|
|
312
|
+
exit_zero: bool,
|
|
313
|
+
statistics: bool,
|
|
314
|
+
target: Path | None,
|
|
315
|
+
) -> None:
|
|
316
|
+
"""Analyse Fortran source files and report findings.
|
|
317
|
+
|
|
318
|
+
TARGET is a file or directory to analyse; defaults to the current
|
|
319
|
+
directory. Directories are searched recursively for Fortran sources.
|
|
320
|
+
|
|
321
|
+
Exits 0 when no active findings are found, 1 when there are active
|
|
322
|
+
findings, and 2 on a tool error (e.g. invalid options).
|
|
323
|
+
"""
|
|
324
|
+
target = target or Path.cwd()
|
|
325
|
+
diagnostics: Diagnostics = ClickEchoDiagnostics() if cli_args.verbose else NullDiagnostics()
|
|
326
|
+
|
|
327
|
+
if statistics and diff:
|
|
328
|
+
click.echo("argusf: --statistics and --diff are mutually exclusive", err=True)
|
|
329
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
330
|
+
|
|
331
|
+
# Contradictory exit-code demands.
|
|
332
|
+
if exit_zero and exit_non_zero_on_fix:
|
|
333
|
+
click.echo("argusf: --exit-zero and --exit-non-zero-on-fix are mutually exclusive", err=True)
|
|
334
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
335
|
+
|
|
336
|
+
cli_args.options = PartialConfig(
|
|
337
|
+
lint=PartialLintConfig(
|
|
338
|
+
preview=preview,
|
|
339
|
+
select=_parse_selectors(select),
|
|
340
|
+
extend_select=_parse_selectors(extend_select),
|
|
341
|
+
ignore=_parse_selectors(ignore),
|
|
342
|
+
fixable=_parse_selectors(fixable),
|
|
343
|
+
unfixable=_parse_selectors(unfixable),
|
|
344
|
+
extend_fixable=_parse_selectors(extend_fixable),
|
|
345
|
+
),
|
|
346
|
+
exclude=list(exclude) or None,
|
|
347
|
+
extend_exclude=list(extend_exclude) or None,
|
|
348
|
+
include=list(include) or None,
|
|
349
|
+
extend_include=list(extend_include) or None,
|
|
350
|
+
respect_gitignore=respect_gitignore,
|
|
351
|
+
no_cache=no_cache or None,
|
|
352
|
+
output_format=output_format,
|
|
353
|
+
fix=fix,
|
|
354
|
+
unsafe_fixes=unsafe_fixes,
|
|
355
|
+
fix_only=fix_only,
|
|
356
|
+
show_fixes=show_fixes,
|
|
357
|
+
)
|
|
358
|
+
config = _resolve_config(cli_args)
|
|
359
|
+
config_path = ConfigResolver().config_path(cli_args)
|
|
360
|
+
fix_context = build_fix_context(config, diff=diff)
|
|
361
|
+
|
|
362
|
+
if show_files:
|
|
363
|
+
walker = FileTreeWalker(HashBackend(), config, diagnostics, ClickEchoDiagnostics(), config_path)
|
|
364
|
+
for path in walker.discover_paths(target):
|
|
365
|
+
click.echo(path)
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
reporter = _resolve_reporter(config, cli_args.silent, statistics, fix_context)
|
|
370
|
+
orchestrator = _build_orchestrator(config, target, diagnostics, reporter, config_path, fix_context)
|
|
371
|
+
except ValueError as error:
|
|
372
|
+
click.echo(f"argusf: {error}", err=True)
|
|
373
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
374
|
+
|
|
375
|
+
result = orchestrator.run(target=target, config=config)
|
|
376
|
+
exit_code = _check_exit_code(result, fix_context, exit_non_zero_on_fix=exit_non_zero_on_fix, exit_zero=exit_zero)
|
|
377
|
+
if exit_code:
|
|
378
|
+
sys.exit(exit_code)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@cli.command()
|
|
382
|
+
@click.pass_obj
|
|
383
|
+
def clean(cli_args: CliArgs) -> None:
|
|
384
|
+
"""Remove the cache directory."""
|
|
385
|
+
config = _resolve_config(cli_args)
|
|
386
|
+
cache_dir = Path(config.cache_dir)
|
|
387
|
+
if cache_dir.exists():
|
|
388
|
+
click.echo(f"Removing cache at: {click.style(str(cache_dir), bold=True)}")
|
|
389
|
+
shutil.rmtree(cache_dir)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@cli.command()
|
|
393
|
+
@click.argument(
|
|
394
|
+
"code",
|
|
395
|
+
required=False,
|
|
396
|
+
)
|
|
397
|
+
@click.option(
|
|
398
|
+
"--all",
|
|
399
|
+
"show_all",
|
|
400
|
+
is_flag=True,
|
|
401
|
+
default=False,
|
|
402
|
+
help="Show the documentation for every rule.",
|
|
403
|
+
)
|
|
404
|
+
def rule(code: str | None, show_all: bool) -> None:
|
|
405
|
+
"""Show the documentation for a rule (or all rules with --all).
|
|
406
|
+
|
|
407
|
+
CODE is an exact rule code, e.g. RGUS001; prefixes are not accepted.
|
|
408
|
+
"""
|
|
409
|
+
renderer = RuleDocumentationRenderer()
|
|
410
|
+
if show_all:
|
|
411
|
+
if code is not None:
|
|
412
|
+
click.echo("argusf: Provide either a rule code or --all, not both", err=True)
|
|
413
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
414
|
+
click.echo("\n\n".join(renderer.render(rule_cls) for rule_cls in RuleRegistry.values()))
|
|
415
|
+
return
|
|
416
|
+
if code is None:
|
|
417
|
+
click.echo("argusf: Provide a rule code or --all", err=True)
|
|
418
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
419
|
+
|
|
420
|
+
rule_cls = RuleRegistry.get(code)
|
|
421
|
+
if rule_cls is None:
|
|
422
|
+
click.echo(f"argusf: Unknown rule code: {code!r}", err=True)
|
|
423
|
+
sys.exit(ERROR_EXIT_CODE)
|
|
424
|
+
click.echo(renderer.render(rule_cls))
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
cli()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration resolution, models, and validation."""
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Resolving the final config from CLI args and config files."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import fields, is_dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
10
|
+
|
|
11
|
+
from argusf.constants import FILE_SIZE_UNITS, ON_LARGE_FILE_ACTIONS
|
|
12
|
+
from argusf.discovery.repo import find_repo_root
|
|
13
|
+
|
|
14
|
+
from .errors import ConfigError
|
|
15
|
+
from .file_reader import ConfigFileReaderRegistry
|
|
16
|
+
from .models import ArgusConfig, CliArgs, LintConfig, PartialConfig, PartialLintConfig
|
|
17
|
+
from .validation import LINT_SECTION, ConfigSchemaValidator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConfigResolver:
|
|
21
|
+
"""Merges CLI arguments with config-file settings into a config."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, version: str | None = None) -> None:
|
|
24
|
+
"""Bind the resolver to a running argusf version.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
version: Version used to enforce required_version;
|
|
28
|
+
defaults to the installed argusf version.
|
|
29
|
+
"""
|
|
30
|
+
self._version = version or importlib.metadata.version("argusf")
|
|
31
|
+
|
|
32
|
+
def resolve_config(self, cli_args: CliArgs) -> ArgusConfig:
|
|
33
|
+
"""Resolve the final config for a run.
|
|
34
|
+
|
|
35
|
+
Reads the governing config file (unless isolated), merges it
|
|
36
|
+
under the CLI arguments (CLI > file > default), and enforces
|
|
37
|
+
required_version when the file sets it.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
cli_args: Parsed CLI arguments, including inline options.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The fully resolved configuration.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ConfigError: On an unreadable/invalid file or an
|
|
47
|
+
unsatisfied required_version.
|
|
48
|
+
"""
|
|
49
|
+
config_path = self.config_path(cli_args)
|
|
50
|
+
file_options = self._load_file(config_path) if config_path is not None else PartialConfig()
|
|
51
|
+
|
|
52
|
+
config = self._merge(cli_args.options, file_options)
|
|
53
|
+
# required_version has no CLI flag, so it can only be set when a
|
|
54
|
+
# config file was read; config_path is never None here.
|
|
55
|
+
if config.required_version is not None and config_path is not None:
|
|
56
|
+
self._enforce_required_version(config.required_version, config_path)
|
|
57
|
+
return config
|
|
58
|
+
|
|
59
|
+
def config_path(self, cli_args: CliArgs) -> Path | None:
|
|
60
|
+
"""Return the config file that governs this run, or None.
|
|
61
|
+
|
|
62
|
+
Exposed so discovery can anchor include/exclude patterns to
|
|
63
|
+
the config file's directory.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cli_args: Parsed CLI arguments.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The `--config` path, else the discovered argusf.toml,
|
|
70
|
+
else None (isolated mode, or none found).
|
|
71
|
+
"""
|
|
72
|
+
if cli_args.isolated:
|
|
73
|
+
return None
|
|
74
|
+
return cli_args.config or self._discover_config_file()
|
|
75
|
+
|
|
76
|
+
def _load_file(self, config_path: Path) -> PartialConfig:
|
|
77
|
+
file_format = config_path.suffix.replace(".", "")
|
|
78
|
+
reader = ConfigFileReaderRegistry.get(file_format)
|
|
79
|
+
if reader is None:
|
|
80
|
+
raise ConfigError(f"unsupported config file format: {file_format!r}")
|
|
81
|
+
|
|
82
|
+
raw = reader().read(config_path)
|
|
83
|
+
ConfigSchemaValidator().validate(raw, config_path)
|
|
84
|
+
raw_lint = raw.pop(LINT_SECTION, {})
|
|
85
|
+
|
|
86
|
+
if "cache_dir" in raw:
|
|
87
|
+
raw["cache_dir"] = Path(raw["cache_dir"])
|
|
88
|
+
if "extend" in raw:
|
|
89
|
+
raw["extend"] = Path(raw["extend"])
|
|
90
|
+
if "on_large_file" in raw and raw["on_large_file"] not in ON_LARGE_FILE_ACTIONS:
|
|
91
|
+
raise ConfigError(
|
|
92
|
+
f"invalid value for 'on_large_file' in {config_path}:"
|
|
93
|
+
f" expected one of {ON_LARGE_FILE_ACTIONS!s}, got {raw['on_large_file']!r}"
|
|
94
|
+
)
|
|
95
|
+
if "large_file_threshold" in raw:
|
|
96
|
+
raw["large_file_threshold"] = self._parse_file_size(raw["large_file_threshold"], config_path)
|
|
97
|
+
|
|
98
|
+
return PartialConfig(**raw, lint=PartialLintConfig(**raw_lint))
|
|
99
|
+
|
|
100
|
+
def _parse_file_size(self, value: str, config_path: Path) -> int:
|
|
101
|
+
"""Parse a size string like "500KB" or "10MB" into bytes.
|
|
102
|
+
|
|
103
|
+
A number with an optional binary-unit suffix; bare digits are
|
|
104
|
+
bytes. Converted at the load boundary, like cache_dir to Path,
|
|
105
|
+
so a malformed value fails here (exit 2) instead of wherever
|
|
106
|
+
the walker first compares sizes.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
ConfigError: On a value that is not a valid size.
|
|
110
|
+
"""
|
|
111
|
+
match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*([A-Za-z]+)?", value.strip())
|
|
112
|
+
unit = None if match is None else match.group(2)
|
|
113
|
+
if match is None or (unit is not None and unit.upper() not in FILE_SIZE_UNITS):
|
|
114
|
+
raise ConfigError(
|
|
115
|
+
f"invalid value for 'large_file_threshold' in {config_path}:"
|
|
116
|
+
f' expected a size like "500KB" or "10MB", got {value!r}'
|
|
117
|
+
)
|
|
118
|
+
scale = 1 if unit is None else FILE_SIZE_UNITS[unit.upper()]
|
|
119
|
+
return int(float(match.group(1)) * scale)
|
|
120
|
+
|
|
121
|
+
def _enforce_required_version(self, required_version: str, config_path: Path) -> None:
|
|
122
|
+
try:
|
|
123
|
+
specifiers = SpecifierSet(required_version)
|
|
124
|
+
except InvalidSpecifier:
|
|
125
|
+
# A bare version ("0.1.0") means an exact match.
|
|
126
|
+
try:
|
|
127
|
+
specifiers = SpecifierSet(f"=={required_version}")
|
|
128
|
+
except InvalidSpecifier as error:
|
|
129
|
+
raise ConfigError(
|
|
130
|
+
f"invalid required_version {required_version!r} in {config_path}: not a valid version specifier"
|
|
131
|
+
) from error
|
|
132
|
+
if not specifiers.contains(self._version, prereleases=True):
|
|
133
|
+
raise ConfigError(
|
|
134
|
+
f"argusf {self._version} does not satisfy required_version {required_version!r} in {config_path}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _merge(self, cli: PartialConfig, file: PartialConfig) -> ArgusConfig:
|
|
138
|
+
lint = LintConfig(**self._resolve_fields(LintConfig, cli.lint, file.lint))
|
|
139
|
+
return ArgusConfig(**self._resolve_fields(ArgusConfig, cli, file), lint=lint)
|
|
140
|
+
|
|
141
|
+
def _resolve_fields(
|
|
142
|
+
self,
|
|
143
|
+
resolved_cls: type[ArgusConfig | LintConfig],
|
|
144
|
+
cli: PartialConfig | PartialLintConfig,
|
|
145
|
+
file: PartialConfig | PartialLintConfig,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Resolve each field with CLI > file > default precedence.
|
|
148
|
+
|
|
149
|
+
Keyed by field name, so newly added config fields merge without
|
|
150
|
+
touching this code. Nested dataclass fields (e.g. lint) hold
|
|
151
|
+
partial sub-configs on the input side and are merged by their
|
|
152
|
+
own _resolve_fields call, so they are skipped here.
|
|
153
|
+
"""
|
|
154
|
+
defaults = resolved_cls()
|
|
155
|
+
resolved: dict[str, Any] = {}
|
|
156
|
+
for field in fields(resolved_cls):
|
|
157
|
+
default_value = getattr(defaults, field.name)
|
|
158
|
+
if is_dataclass(default_value):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
cli_value = getattr(cli, field.name)
|
|
162
|
+
file_value = getattr(file, field.name)
|
|
163
|
+
if cli_value is not None:
|
|
164
|
+
resolved[field.name] = cli_value
|
|
165
|
+
elif file_value is not None:
|
|
166
|
+
resolved[field.name] = file_value
|
|
167
|
+
else:
|
|
168
|
+
resolved[field.name] = default_value
|
|
169
|
+
return resolved
|
|
170
|
+
|
|
171
|
+
def _discover_config_file(self) -> Path | None:
|
|
172
|
+
"""Find the argusf.toml for this run by walking up from cwd.
|
|
173
|
+
|
|
174
|
+
Starts at the current working directory and ascends, nearest
|
|
175
|
+
file winning, stopping at the repo root (nearest ancestor with a
|
|
176
|
+
`.git` entry) or at cwd outside a repository — so a stray
|
|
177
|
+
~/argusf.toml can never apply (same boundary semantics as
|
|
178
|
+
gitignore discovery).
|
|
179
|
+
|
|
180
|
+
This is anchored on **cwd, not the analysis target**: argusf
|
|
181
|
+
uses a single config for the whole run, found relative to where
|
|
182
|
+
the tool is invoked, rather than resolving configuration
|
|
183
|
+
per-target file (walking up from each linted path, which is what
|
|
184
|
+
lets a monorepo carry per-directory configs).
|
|
185
|
+
|
|
186
|
+
Consequence: running from the project root or any subdirectory
|
|
187
|
+
(including under CI or a pre-commit hook, which cd into the
|
|
188
|
+
repo) discovers the same file. The single-config model shows
|
|
189
|
+
only when running from *outside* a project and targeting into it
|
|
190
|
+
(that project's config is not found, so defaults apply), or for
|
|
191
|
+
monorepos with per-package configs (only the cwd-relative one
|
|
192
|
+
applies).
|
|
193
|
+
|
|
194
|
+
Revisiting this (per-target / hierarchical resolution) is
|
|
195
|
+
deliberately deferred: it is a config-layer change rather than a
|
|
196
|
+
tweak, and the case that most wants it is editor/LSP
|
|
197
|
+
integration, where tools invoke argusf with absolute paths from
|
|
198
|
+
an arbitrary cwd. Pattern anchoring does not depend on it —
|
|
199
|
+
``_anchor`` (discovery backend) anchors to wherever the config
|
|
200
|
+
was found — so switching the discovery mechanism later needs no
|
|
201
|
+
rework there.
|
|
202
|
+
"""
|
|
203
|
+
# TODO: .ini support
|
|
204
|
+
start = Path.cwd().absolute()
|
|
205
|
+
boundary = find_repo_root(start) or start
|
|
206
|
+
for directory in (start, *start.parents):
|
|
207
|
+
candidate = directory / "argusf.toml"
|
|
208
|
+
if candidate.exists():
|
|
209
|
+
return candidate
|
|
210
|
+
if directory == boundary:
|
|
211
|
+
return None
|
|
212
|
+
return None
|
argusf/config/errors.py
ADDED