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
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
@@ -0,0 +1,8 @@
1
+ """The error type raised for bad configuration files."""
2
+
3
+
4
+ class ConfigError(ValueError):
5
+ """A config file is unreadable, malformed, or has invalid values.
6
+
7
+ Surfaced by the CLI as a clean error message with exit code 2.
8
+ """
@@ -0,0 +1,3 @@
1
+ from .reader import ConfigFileReaderRegistry
2
+
3
+ __all__ = ["ConfigFileReaderRegistry"]