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,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
@@ -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,3 @@
1
+ """Discovery and filtering of source files to analyse."""
2
+
3
+ from .backend import FileCollector, FileTreeWalker
@@ -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))