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,58 @@
|
|
|
1
|
+
"""Config-file readers, dispatched by file format."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
5
|
+
|
|
6
|
+
from argusf.config.errors import ConfigError
|
|
7
|
+
from argusf.registry import Registry
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigFileReader(Protocol):
|
|
14
|
+
"""Reads a config file into a raw settings dict."""
|
|
15
|
+
|
|
16
|
+
def read(self, file_path: Path) -> dict[str, Any]:
|
|
17
|
+
"""Read and parse `file_path` into a raw settings dict.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
file_path: Path of the config file to read.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
The parsed top-level table.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ConfigError: If the file cannot be read or parsed.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ConfigFileReaderRegistry: Registry[str, type[ConfigFileReader]] = Registry()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@ConfigFileReaderRegistry.register("toml")
|
|
34
|
+
class TomlConfigFileReader(ConfigFileReader):
|
|
35
|
+
"""Reads TOML config files via tomllib."""
|
|
36
|
+
|
|
37
|
+
def read(self, file_path: Path) -> dict[str, Any]:
|
|
38
|
+
"""Read and parse a TOML config file.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
file_path: Path of the `.toml` file to read.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The parsed top-level table.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ConfigError: On invalid TOML, non-UTF-8 bytes, or an
|
|
48
|
+
unreadable file.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
with open(file_path, "rb") as f:
|
|
52
|
+
return tomllib.load(f)
|
|
53
|
+
except tomllib.TOMLDecodeError as error:
|
|
54
|
+
raise ConfigError(f"invalid {file_path}: {error}") from error
|
|
55
|
+
except UnicodeDecodeError as error:
|
|
56
|
+
raise ConfigError(f"invalid {file_path}: not valid UTF-8 ({error})") from error
|
|
57
|
+
except OSError as error:
|
|
58
|
+
raise ConfigError(f"cannot read {file_path}: {error}") from error
|
argusf/config/models.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Config data models: CLI/file partials and resolved configs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from argusf.constants import (
|
|
7
|
+
DEFAULT_EXCLUDES,
|
|
8
|
+
DEFAULT_FIXABLE,
|
|
9
|
+
DEFAULT_INCLUDE,
|
|
10
|
+
DEFAULT_LARGE_FILE_THRESHOLD,
|
|
11
|
+
DEFAULT_SELECT,
|
|
12
|
+
DEFAULT_TASK_TAGS,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PartialLintConfig:
|
|
18
|
+
"""Optional [lint] settings from one source (None = unset)."""
|
|
19
|
+
|
|
20
|
+
extend_fixable: list[str] | None = None
|
|
21
|
+
extend_select: list[str] | None = None
|
|
22
|
+
fixable: list[str] | None = None
|
|
23
|
+
ignore: list[str] | None = None
|
|
24
|
+
per_file_ignores: dict[str, list[str]] | None = None
|
|
25
|
+
preview: bool | None = None
|
|
26
|
+
select: list[str] | None = None
|
|
27
|
+
task_tags: list[str] | None = None
|
|
28
|
+
unfixable: list[str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PartialConfig:
|
|
33
|
+
"""Optional top-level settings from one source (None = unset)."""
|
|
34
|
+
|
|
35
|
+
cache_dir: Path | None = None
|
|
36
|
+
exclude: list[str] | None = None
|
|
37
|
+
extend: Path | None = None
|
|
38
|
+
extend_exclude: list[str] | None = None
|
|
39
|
+
extend_include: list[str] | None = None
|
|
40
|
+
fix: bool | None = None
|
|
41
|
+
fix_only: bool | None = None
|
|
42
|
+
include: list[str] | None = None
|
|
43
|
+
large_file_threshold: int | None = None
|
|
44
|
+
no_cache: bool | None = None
|
|
45
|
+
on_large_file: str | None = None
|
|
46
|
+
output_format: str | None = None
|
|
47
|
+
required_version: str | None = None
|
|
48
|
+
respect_gitignore: bool | None = None
|
|
49
|
+
show_fixes: bool | None = None
|
|
50
|
+
unsafe_fixes: bool | None = None
|
|
51
|
+
lint: PartialLintConfig = field(default_factory=PartialLintConfig)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CliArgs:
|
|
56
|
+
"""Parsed CLI arguments plus inline config option overrides."""
|
|
57
|
+
|
|
58
|
+
config: Path | None = None
|
|
59
|
+
isolated: bool = False
|
|
60
|
+
verbose: bool = False
|
|
61
|
+
silent: bool = False
|
|
62
|
+
options: PartialConfig = field(default_factory=PartialConfig)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class LintConfig:
|
|
67
|
+
"""Resolved lint settings (rule selection, fixes, suppressions)."""
|
|
68
|
+
|
|
69
|
+
extend_fixable: list[str] = field(default_factory=list)
|
|
70
|
+
extend_select: list[str] = field(default_factory=list)
|
|
71
|
+
# fixable/unfixable/extend_fixable affect the fix payload cached
|
|
72
|
+
# with each finding, so living here (unlike the runtime
|
|
73
|
+
# fix/unsafe_fixes flags) also enrols them in the cache fingerprint.
|
|
74
|
+
fixable: list[str] = field(default_factory=lambda: list(DEFAULT_FIXABLE))
|
|
75
|
+
ignore: list[str] = field(default_factory=list)
|
|
76
|
+
per_file_ignores: dict[str, list[str]] = field(default_factory=dict)
|
|
77
|
+
preview: bool = False
|
|
78
|
+
select: list[str] = field(default_factory=lambda: list(DEFAULT_SELECT))
|
|
79
|
+
task_tags: list[str] = field(default_factory=lambda: list(DEFAULT_TASK_TAGS))
|
|
80
|
+
unfixable: list[str] = field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class ArgusConfig:
|
|
85
|
+
"""The fully resolved configuration for a run."""
|
|
86
|
+
|
|
87
|
+
cache_dir: Path = field(default_factory=lambda: Path(".argusf_cache"))
|
|
88
|
+
exclude: list[str] = field(default_factory=lambda: sorted(DEFAULT_EXCLUDES))
|
|
89
|
+
extend_exclude: list[str] = field(default_factory=list)
|
|
90
|
+
extend_include: list[str] = field(default_factory=list)
|
|
91
|
+
extend: Path | None = None
|
|
92
|
+
# Runtime behaviour flags: they change what is done with findings,
|
|
93
|
+
# not the findings themselves, so they stay out of the [lint]
|
|
94
|
+
# fingerprint.
|
|
95
|
+
fix: bool = False
|
|
96
|
+
fix_only: bool = False
|
|
97
|
+
include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE))
|
|
98
|
+
# Bytes; the TOML value is a unit-suffixed string ("10MB"),
|
|
99
|
+
# converted at load time. Inert unless on_large_file is set.
|
|
100
|
+
large_file_threshold: int = DEFAULT_LARGE_FILE_THRESHOLD
|
|
101
|
+
no_cache: bool = False
|
|
102
|
+
# One of "warn", "skip", or None (off). File-only, like
|
|
103
|
+
# large_file_threshold.
|
|
104
|
+
on_large_file: str | None = None
|
|
105
|
+
output_format: str = "standard"
|
|
106
|
+
required_version: str | None = None
|
|
107
|
+
respect_gitignore: bool = True
|
|
108
|
+
show_fixes: bool = False
|
|
109
|
+
unsafe_fixes: bool = False
|
|
110
|
+
lint: LintConfig = field(default_factory=LintConfig)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Schema validation of raw config-file content."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from argusf.config.errors import ConfigError
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
LINT_SECTION = "lint"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class _ValueType:
|
|
17
|
+
check: Callable[[object], bool]
|
|
18
|
+
description: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_STRING = _ValueType(lambda value: isinstance(value, str), "a string")
|
|
22
|
+
_BOOL = _ValueType(lambda value: isinstance(value, bool), "a boolean")
|
|
23
|
+
_STRING_ARRAY = _ValueType(
|
|
24
|
+
lambda value: isinstance(value, list) and all(isinstance(item, str) for item in value),
|
|
25
|
+
"an array of strings",
|
|
26
|
+
)
|
|
27
|
+
_PATTERN_TABLE = _ValueType(
|
|
28
|
+
lambda value: isinstance(value, dict) and all(_STRING_ARRAY.check(selectors) for selectors in value.values()),
|
|
29
|
+
"a table of pattern = selectors entries",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Keys mirror PartialConfig/PartialLintConfig fields; TOML-side types,
|
|
33
|
+
# so Path-valued fields are strings here (converted after validation). A
|
|
34
|
+
# test asserts the schemas stay in sync with the dataclasses.
|
|
35
|
+
_TOP_LEVEL_SCHEMA: dict[str, _ValueType] = {
|
|
36
|
+
"cache_dir": _STRING,
|
|
37
|
+
"exclude": _STRING_ARRAY,
|
|
38
|
+
"extend": _STRING,
|
|
39
|
+
"extend_exclude": _STRING_ARRAY,
|
|
40
|
+
"extend_include": _STRING_ARRAY,
|
|
41
|
+
"fix": _BOOL,
|
|
42
|
+
"fix_only": _BOOL,
|
|
43
|
+
"include": _STRING_ARRAY,
|
|
44
|
+
"large_file_threshold": _STRING,
|
|
45
|
+
"no_cache": _BOOL,
|
|
46
|
+
"on_large_file": _STRING,
|
|
47
|
+
"output_format": _STRING,
|
|
48
|
+
"required_version": _STRING,
|
|
49
|
+
"respect_gitignore": _BOOL,
|
|
50
|
+
"show_fixes": _BOOL,
|
|
51
|
+
"unsafe_fixes": _BOOL,
|
|
52
|
+
}
|
|
53
|
+
_LINT_SCHEMA: dict[str, _ValueType] = {
|
|
54
|
+
"extend_fixable": _STRING_ARRAY,
|
|
55
|
+
"extend_select": _STRING_ARRAY,
|
|
56
|
+
"fixable": _STRING_ARRAY,
|
|
57
|
+
"ignore": _STRING_ARRAY,
|
|
58
|
+
"per_file_ignores": _PATTERN_TABLE,
|
|
59
|
+
"preview": _BOOL,
|
|
60
|
+
"select": _STRING_ARRAY,
|
|
61
|
+
"task_tags": _STRING_ARRAY,
|
|
62
|
+
"unfixable": _STRING_ARRAY,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigSchemaValidator:
|
|
67
|
+
"""Validates raw config-file content before dataclass conversion.
|
|
68
|
+
|
|
69
|
+
Rejects unknown keys (so typos fail loudly instead of silently not
|
|
70
|
+
applying) and wrong-typed values (so shape errors surface as one
|
|
71
|
+
clean ConfigError at load time rather than a traceback wherever the
|
|
72
|
+
value first gets used).
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def validate(self, raw: dict[str, Any], config_path: Path) -> None:
|
|
76
|
+
"""Validate top-level and [lint] keys and value types.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
raw: Parsed config-file content.
|
|
80
|
+
config_path: Path of the file, used in error messages.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ConfigError: On an unknown key or a wrong-typed value.
|
|
84
|
+
"""
|
|
85
|
+
for key, value in raw.items():
|
|
86
|
+
if key == LINT_SECTION:
|
|
87
|
+
self._validate_lint(value, config_path)
|
|
88
|
+
continue
|
|
89
|
+
expected = _TOP_LEVEL_SCHEMA.get(key)
|
|
90
|
+
if expected is None:
|
|
91
|
+
raise ConfigError(f"unknown key {key!r} in {config_path}")
|
|
92
|
+
self._check_value(key, value, expected, config_path)
|
|
93
|
+
|
|
94
|
+
def _validate_lint(self, section: Any, config_path: Path) -> None:
|
|
95
|
+
if not isinstance(section, dict):
|
|
96
|
+
raise ConfigError(f"'{LINT_SECTION}' in {config_path} must be a table")
|
|
97
|
+
for key, value in section.items():
|
|
98
|
+
expected = _LINT_SCHEMA.get(key)
|
|
99
|
+
if expected is None:
|
|
100
|
+
raise ConfigError(f"unknown key {key!r} in [{LINT_SECTION}] section of {config_path}")
|
|
101
|
+
self._check_value(f"{LINT_SECTION}.{key}", value, expected, config_path)
|
|
102
|
+
|
|
103
|
+
def _check_value(self, dotted_key: str, value: object, expected: _ValueType, config_path: Path) -> None:
|
|
104
|
+
if expected.check(value):
|
|
105
|
+
return
|
|
106
|
+
# TOML array-of-tables ([[lint.per_file_ignores]]) parses to a
|
|
107
|
+
# list of dicts; it is a documented-syntax trap, so name it
|
|
108
|
+
# explicitly rather than emitting the generic type message.
|
|
109
|
+
if expected is _PATTERN_TABLE and isinstance(value, list):
|
|
110
|
+
raise ConfigError(f"'{dotted_key}' in {config_path} must be a table, not an array of tables")
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
f"invalid value for {dotted_key!r} in {config_path}: expected {expected.description}, got {value!r}"
|
|
113
|
+
)
|
argusf/constants.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Project-wide constants and defaults."""
|
|
2
|
+
|
|
3
|
+
# Extensions whose contents a Fortran 90 compiler accepts: free-form
|
|
4
|
+
# .f90 plus the fixed-form FORTRAN 77 family, which F90 includes as a
|
|
5
|
+
# strict superset. .fpp is deliberately excluded: it denotes source that
|
|
6
|
+
# requires preprocessing, which argusf does not perform.
|
|
7
|
+
FORTRAN_EXTENSIONS: frozenset[str] = frozenset({".f", ".f77", ".f90", ".for", ".ftn"})
|
|
8
|
+
|
|
9
|
+
DEFAULT_INCLUDE: tuple[str, ...] = tuple(
|
|
10
|
+
sorted({f"*{ext}" for ext in FORTRAN_EXTENSIONS} | {f"*{ext.upper()}" for ext in FORTRAN_EXTENSIONS})
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
# Binary units, insertion-ordered smallest to largest so consumers can
|
|
14
|
+
# scan for the best fit in either direction.
|
|
15
|
+
FILE_SIZE_UNITS: dict[str, int] = {"KB": 1024, "MB": 1024**2, "GB": 1024**3}
|
|
16
|
+
|
|
17
|
+
# Actions for the `on_large_file` guardrail; unset means the guardrail
|
|
18
|
+
# is off. File-only options — no CLI flags.
|
|
19
|
+
ON_LARGE_FILE_WARN = "warn"
|
|
20
|
+
ON_LARGE_FILE_SKIP = "skip"
|
|
21
|
+
ON_LARGE_FILE_ACTIONS: tuple[str, ...] = (ON_LARGE_FILE_WARN, ON_LARGE_FILE_SKIP)
|
|
22
|
+
DEFAULT_LARGE_FILE_THRESHOLD: int = FILE_SIZE_UNITS["MB"]
|
|
23
|
+
|
|
24
|
+
DEFAULT_SELECT: tuple[str, ...] = ("RGUS",)
|
|
25
|
+
|
|
26
|
+
DEFAULT_FIXABLE: tuple[str, ...] = ("ALL",)
|
|
27
|
+
|
|
28
|
+
MAX_FIX_ITERATIONS: int = 100
|
|
29
|
+
|
|
30
|
+
# Default lint.task_tags: the labels the todo-comment rule (RGUS008)
|
|
31
|
+
# scans for.
|
|
32
|
+
DEFAULT_TASK_TAGS: tuple[str, ...] = ("TODO", "FIXME", "XXX")
|
|
33
|
+
|
|
34
|
+
# Discovery-level fnmatch patterns skipped when no `exclude` is
|
|
35
|
+
# configured. A user-provided `exclude` replaces this set entirely while
|
|
36
|
+
# `extend_exclude` adds patterns on top instead. To lint inside one of
|
|
37
|
+
# these (e.g. a directory literally named `build`), target it directly:
|
|
38
|
+
# `argusf check build/` bypasses the default skips at the top level.
|
|
39
|
+
DEFAULT_EXCLUDES: frozenset[str] = frozenset(
|
|
40
|
+
{
|
|
41
|
+
# Version control metadata
|
|
42
|
+
".git",
|
|
43
|
+
".hg",
|
|
44
|
+
".svn",
|
|
45
|
+
# argusf's own cache (default location)
|
|
46
|
+
".argusf_cache",
|
|
47
|
+
# Python dev artifacts commonly present in mixed projects
|
|
48
|
+
".venv",
|
|
49
|
+
"venv",
|
|
50
|
+
"__pycache__",
|
|
51
|
+
".mypy_cache",
|
|
52
|
+
".pytest_cache",
|
|
53
|
+
".ruff_cache",
|
|
54
|
+
# direnv
|
|
55
|
+
".direnv",
|
|
56
|
+
# Common build output directories
|
|
57
|
+
"build",
|
|
58
|
+
"_build",
|
|
59
|
+
"dist",
|
|
60
|
+
"bin",
|
|
61
|
+
"obj",
|
|
62
|
+
# Editor/IDE metadata
|
|
63
|
+
".vscode",
|
|
64
|
+
".idea",
|
|
65
|
+
}
|
|
66
|
+
)
|
argusf/diagnostics.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Developer-facing diagnostics channels, separate from the report."""
|
|
2
|
+
|
|
3
|
+
from typing import IO, Protocol
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Diagnostics(Protocol):
|
|
9
|
+
"""Surfaces developer-facing messages, separate from the report.
|
|
10
|
+
|
|
11
|
+
Carries commentary a developer running argusf wants to see
|
|
12
|
+
(skipped files, parse failures) on stderr, so it never
|
|
13
|
+
contaminates the parseable findings report on stdout.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def emit(self, message: str) -> None:
|
|
17
|
+
"""Emit a diagnostic message."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClickEchoDiagnostics(Diagnostics):
|
|
22
|
+
"""Writes diagnostics to stderr via click.echo."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, stream: IO[str] | None = None) -> None:
|
|
25
|
+
"""Bind the channel to an output stream.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
stream: Destination stream; None writes to stderr.
|
|
29
|
+
"""
|
|
30
|
+
self._stream = stream
|
|
31
|
+
|
|
32
|
+
def emit(self, message: str) -> None:
|
|
33
|
+
"""Write the message to stderr."""
|
|
34
|
+
click.echo(message, file=self._stream, err=True)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NullDiagnostics(Diagnostics):
|
|
38
|
+
"""Discards diagnostics; used when verbose output is off.
|
|
39
|
+
|
|
40
|
+
Lets consumers stay decoupled from the on/off decision — they
|
|
41
|
+
always emit, and this simply swallows the message.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def emit(self, message: str) -> None:
|
|
45
|
+
"""Discard the message."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Walking the directory tree to discover Fortran source files."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
import pathspec
|
|
8
|
+
|
|
9
|
+
from argusf.constants import FILE_SIZE_UNITS, ON_LARGE_FILE_SKIP
|
|
10
|
+
from argusf.discovery.gitignore import NullGitignoreMatcher, PathspecGitignoreMatcher
|
|
11
|
+
from argusf.ir.models import SourceFileMetadata
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from argusf.config.models import ArgusConfig
|
|
17
|
+
from argusf.diagnostics import Diagnostics
|
|
18
|
+
from argusf.hashing.hash import Hasher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class FileCollector(Protocol):
|
|
23
|
+
"""Streams discovered source files for the orchestrator.
|
|
24
|
+
|
|
25
|
+
`collect` yields hashed metadata for the analysis pipeline;
|
|
26
|
+
`discover_paths` yields bare paths for callers (such as
|
|
27
|
+
--show-files) that only need the file list.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def collect(self, root: Path) -> Iterator[SourceFileMetadata]:
|
|
31
|
+
"""Yield identity metadata for each discovered file.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
root: File or directory to discover under.
|
|
35
|
+
|
|
36
|
+
Yields:
|
|
37
|
+
One `SourceFileMetadata` per file, in the order walked.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def discover_paths(self, root: Path) -> Iterator[Path]:
|
|
41
|
+
"""Yield the path of each discovered file without reading it.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
root: File or directory to discover under.
|
|
45
|
+
|
|
46
|
+
Yields:
|
|
47
|
+
One path per discovered file, in the order walked.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _human_size(size: int) -> str:
|
|
52
|
+
for unit, scale in reversed(FILE_SIZE_UNITS.items()):
|
|
53
|
+
if size >= scale:
|
|
54
|
+
return f"{size / scale:.1f}{unit}"
|
|
55
|
+
return f"{size}B"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _pattern_spec(base: list[str], extend: list[str]) -> pathspec.GitIgnoreSpec:
|
|
59
|
+
"""Build the shared include/exclude gitignore-style matcher.
|
|
60
|
+
|
|
61
|
+
Built from the base + extend pattern lists and matched against each
|
|
62
|
+
path relative to the walk root (the same matcher the .gitignore
|
|
63
|
+
support uses). A slashless pattern (*.f90, build) matches a basename
|
|
64
|
+
at any depth; a pattern containing a slash (src/*.f90, tests/**) is
|
|
65
|
+
anchored to the root and supports **. An empty pattern list matches
|
|
66
|
+
nothing.
|
|
67
|
+
"""
|
|
68
|
+
return pathspec.GitIgnoreSpec.from_lines([*base, *extend])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _relative(path: Path, root: Path) -> str:
|
|
72
|
+
"""Return `path` relative to `root`, POSIX-normalised.
|
|
73
|
+
|
|
74
|
+
os.path.relpath is lexical and never yields a leading "./";
|
|
75
|
+
children of the walk are always below root, so no "../" creeps in.
|
|
76
|
+
as_posix() normalises separators so the patterns match on every
|
|
77
|
+
platform.
|
|
78
|
+
"""
|
|
79
|
+
return Path(os.path.relpath(path, root)).as_posix()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class FileTreeWalker(FileCollector):
|
|
83
|
+
"""Discovers Fortran source files by walking the directory tree.
|
|
84
|
+
|
|
85
|
+
`diagnostics` is the --verbose-gated developer channel; `warnings`
|
|
86
|
+
carries the on_large_file notices and is always on — an explicitly
|
|
87
|
+
configured guardrail should not need --verbose to be visible, and a
|
|
88
|
+
silently skipped file would hide results.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
hasher: Hasher,
|
|
94
|
+
config: ArgusConfig,
|
|
95
|
+
diagnostics: Diagnostics,
|
|
96
|
+
warnings: Diagnostics,
|
|
97
|
+
config_path: Path | None = None,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Build a walker from its collaborators and config.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
hasher: Hashes file bytes for content identity.
|
|
103
|
+
config: Resolved config supplying include/exclude, the
|
|
104
|
+
gitignore toggle, and the large-file guardrail.
|
|
105
|
+
diagnostics: --verbose-gated channel for skipped or
|
|
106
|
+
unreadable files.
|
|
107
|
+
warnings: Always-on channel for on_large_file notices.
|
|
108
|
+
config_path: Path of the config file whose patterns
|
|
109
|
+
these are; its directory anchors include/exclude
|
|
110
|
+
matching. None when no config file applies.
|
|
111
|
+
"""
|
|
112
|
+
self._hasher = hasher
|
|
113
|
+
self._diagnostics = diagnostics
|
|
114
|
+
self._warnings = warnings
|
|
115
|
+
self._exclude_spec = _pattern_spec(config.exclude, config.extend_exclude)
|
|
116
|
+
self._include_spec = _pattern_spec(config.include, config.extend_include)
|
|
117
|
+
# The config file whose patterns these are; its directory is the
|
|
118
|
+
# anchor for include/exclude matching (see _anchor). None when
|
|
119
|
+
# no config file applies (isolated, or none found).
|
|
120
|
+
self._config_path = config_path
|
|
121
|
+
self._respect_gitignore = config.respect_gitignore
|
|
122
|
+
self._on_large_file = config.on_large_file
|
|
123
|
+
self._large_file_threshold = config.large_file_threshold
|
|
124
|
+
|
|
125
|
+
def collect(self, root: Path) -> Iterator[SourceFileMetadata]:
|
|
126
|
+
"""Discover files under `root` and hash each one.
|
|
127
|
+
|
|
128
|
+
Metadata is streamed one file at a time so source bytes never
|
|
129
|
+
accumulate across the project — memory stays O(largest file in
|
|
130
|
+
flight). Each file's bytes are read once to compute its content
|
|
131
|
+
hash, then discarded; downstream consumers (parser, fix stage)
|
|
132
|
+
re-read the file when they need the contents. Unreadable files
|
|
133
|
+
are skipped with a diagnostic.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
root: File or directory to discover under.
|
|
137
|
+
|
|
138
|
+
Yields:
|
|
139
|
+
Lean identity metadata (path, size, mtime, hash) per
|
|
140
|
+
readable file.
|
|
141
|
+
"""
|
|
142
|
+
for path in self.discover_paths(root):
|
|
143
|
+
try:
|
|
144
|
+
stat = path.stat()
|
|
145
|
+
source = path.read_bytes()
|
|
146
|
+
except OSError:
|
|
147
|
+
self._diagnostics.emit(f"could not read {path}, skipping")
|
|
148
|
+
continue
|
|
149
|
+
# The bytes are read for hashing only; downstream consumers
|
|
150
|
+
# that need them (parser, fix stage) read the file
|
|
151
|
+
# themselves.
|
|
152
|
+
yield SourceFileMetadata(
|
|
153
|
+
file_path=path,
|
|
154
|
+
size=stat.st_size,
|
|
155
|
+
mtime=stat.st_mtime,
|
|
156
|
+
content_hash=self._hasher.hash_bytes(source),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def discover_paths(self, root: Path) -> Iterator[Path]:
|
|
160
|
+
"""Yield discovered paths without reading file contents.
|
|
161
|
+
|
|
162
|
+
A single-file `root` is yielded as-is, bypassing
|
|
163
|
+
include/exclude, gitignore, and the large-file guardrail. A
|
|
164
|
+
directory is walked with excluded and gitignored subtrees
|
|
165
|
+
pruned from descent; each surviving file is yielded when it
|
|
166
|
+
matches the include spec and clears the size guardrail.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
root: File or directory to discover under.
|
|
170
|
+
|
|
171
|
+
Yields:
|
|
172
|
+
One path per discovered file.
|
|
173
|
+
"""
|
|
174
|
+
if root.is_file():
|
|
175
|
+
# An explicitly named file is analysed as-is —
|
|
176
|
+
# include/exclude and the extension opinion are all
|
|
177
|
+
# bypassed, since the user asked for this one specifically.
|
|
178
|
+
# Downstream just parses it.
|
|
179
|
+
yield root
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
anchor = self._anchor(root)
|
|
183
|
+
gitignore = (
|
|
184
|
+
PathspecGitignoreMatcher(root, self._diagnostics) if self._respect_gitignore else NullGitignoreMatcher()
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# os.walk lets us prune in-place: dropping a directory from
|
|
188
|
+
# `dirnames` stops descent into it entirely, rather than walking
|
|
189
|
+
# and then filtering. Avoids scanning vendored trees (.git,
|
|
190
|
+
# node_modules, .venv) that contain no Fortran sources. Pruning
|
|
191
|
+
# gitignored directories also gives git's semantics for free: a
|
|
192
|
+
# negation deeper in an ignored directory cannot re-include it.
|
|
193
|
+
for current_dir, dirnames, filenames in os.walk(root):
|
|
194
|
+
current_path = Path(current_dir)
|
|
195
|
+
# Absolute so the matcher can relate walked directories to
|
|
196
|
+
# .gitignore locations even when the walk root is relative.
|
|
197
|
+
abs_dir = current_path.absolute()
|
|
198
|
+
dirnames[:] = sorted(
|
|
199
|
+
d
|
|
200
|
+
for d in dirnames
|
|
201
|
+
if not self._is_excluded(current_path / d, anchor) and not gitignore.is_ignored(abs_dir, d, is_dir=True)
|
|
202
|
+
)
|
|
203
|
+
for filename in sorted(filenames):
|
|
204
|
+
file_path = current_path / filename
|
|
205
|
+
if self._is_excluded(file_path, anchor) or gitignore.is_ignored(abs_dir, filename, is_dir=False):
|
|
206
|
+
continue
|
|
207
|
+
if self._is_included(file_path, anchor) and not self._skip_for_size(file_path):
|
|
208
|
+
yield file_path
|
|
209
|
+
|
|
210
|
+
def _skip_for_size(self, path: Path) -> bool:
|
|
211
|
+
"""Whether the large-file guardrail skips `path`.
|
|
212
|
+
|
|
213
|
+
Checked during discovery (not collect) so `skip` keeps the
|
|
214
|
+
file out of --show-files too, and skipped files are never
|
|
215
|
+
read — the point of the guardrail is dodging the parse of
|
|
216
|
+
pathological files. Like exclude and gitignore, explicitly
|
|
217
|
+
named single-file targets bypass the check.
|
|
218
|
+
"""
|
|
219
|
+
if self._on_large_file is None:
|
|
220
|
+
return False
|
|
221
|
+
try:
|
|
222
|
+
size = path.stat().st_size
|
|
223
|
+
except OSError:
|
|
224
|
+
return False # collect() surfaces the unreadable file
|
|
225
|
+
if size <= self._large_file_threshold:
|
|
226
|
+
return False
|
|
227
|
+
if self._on_large_file == ON_LARGE_FILE_SKIP:
|
|
228
|
+
self._warnings.emit(f"skipping large file ({_human_size(size)}): {path}")
|
|
229
|
+
return True
|
|
230
|
+
self._warnings.emit(f"large file ({_human_size(size)}): {path}")
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
def _anchor(self, target: Path) -> Path:
|
|
234
|
+
base = (self._config_path.parent if self._config_path is not None else Path.cwd()).absolute()
|
|
235
|
+
target = target.absolute()
|
|
236
|
+
return base if target.is_relative_to(base) else target
|
|
237
|
+
|
|
238
|
+
def _is_excluded(self, path: Path, anchor: Path) -> bool:
|
|
239
|
+
return self._exclude_spec.match_file(_relative(path, anchor))
|
|
240
|
+
|
|
241
|
+
def _is_included(self, path: Path, anchor: Path) -> bool:
|
|
242
|
+
"""Whether `path` matches the include spec (the file gate).
|
|
243
|
+
|
|
244
|
+
The sole file-level gate: a path is discovered only if it
|
|
245
|
+
matches the resolved include spec (Fortran globs by default).
|
|
246
|
+
An empty include therefore matches nothing — there is no
|
|
247
|
+
implicit extension fallback, so downstream can parse whatever
|
|
248
|
+
discovery yields. Single-file targets bypass this, like exclude.
|
|
249
|
+
"""
|
|
250
|
+
return self._include_spec.match_file(_relative(path, anchor))
|