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,125 @@
1
+ """Matching paths against `.gitignore` files during discovery."""
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ import pathspec
6
+
7
+ from argusf.discovery.repo import find_repo_root
8
+
9
+ if TYPE_CHECKING:
10
+ from pathlib import Path
11
+
12
+ from argusf.diagnostics import Diagnostics
13
+
14
+
15
+ @runtime_checkable
16
+ class GitignoreMatcher(Protocol):
17
+ """Decides whether discovery should ignore a walked path.
18
+
19
+ Implementations back the `respect_gitignore` setting: the real
20
+ matcher consults `.gitignore` files, the null matcher ignores
21
+ nothing.
22
+ """
23
+
24
+ def is_ignored(self, directory: Path, name: str, *, is_dir: bool) -> bool:
25
+ """Report whether `name` under `directory` is gitignored.
26
+
27
+ Args:
28
+ directory: Absolute directory holding the entry.
29
+ name: Basename of the file or directory being tested.
30
+ is_dir: Whether `name` is a directory; directory-only
31
+ patterns such as `build/` match only when true.
32
+
33
+ Returns:
34
+ True if the entry should be skipped during discovery.
35
+ """
36
+
37
+
38
+ class NullGitignoreMatcher(GitignoreMatcher):
39
+ """Used when respect_gitignore is off — nothing is ever ignored."""
40
+
41
+ def is_ignored(self, directory: Path, name: str, *, is_dir: bool) -> bool: # noqa: ARG002 - null object ignores nothing
42
+ """Ignore nothing — every path clears the gitignore check.
43
+
44
+ Returns:
45
+ Always False.
46
+ """
47
+ return False
48
+
49
+
50
+ class PathspecGitignoreMatcher(GitignoreMatcher):
51
+ """Matches paths against nested `.gitignore` files, git-style.
52
+
53
+ `directory` arguments must be absolute so they are comparable
54
+ with the boundary computed from the walk root.
55
+ """
56
+
57
+ def __init__(self, root: Path, diagnostics: Diagnostics) -> None:
58
+ """Bound the matcher to the walk root's repository.
59
+
60
+ Git only honours .gitignore files inside the repository, so
61
+ those between the walk root and the repo root still apply;
62
+ outside a repository nothing above the walk root is consulted,
63
+ or a stray ~/.gitignore could change results.
64
+
65
+ Args:
66
+ root: Directory the discovery walk starts from.
67
+ diagnostics: Channel for reporting unreadable
68
+ `.gitignore` files.
69
+ """
70
+ self._diagnostics = diagnostics
71
+ self._boundary = find_repo_root(root.absolute()) or root.absolute()
72
+ self._specs: dict[Path, pathspec.GitIgnoreSpec | None] = {}
73
+
74
+ def is_ignored(self, directory: Path, name: str, *, is_dir: bool) -> bool:
75
+ """Report whether `name` under `directory` is gitignored.
76
+
77
+ Ascends from `directory` to the boundary (repo root, or the
78
+ walk root outside a repo), consulting each `.gitignore` along
79
+ the way. The deepest file wins, so a child `!pattern`
80
+ negation can re-include what a parent ignored; the ascent
81
+ stops as soon as a file decides the path either way.
82
+
83
+ Args:
84
+ directory: Absolute directory holding the entry.
85
+ name: Basename of the file or directory being tested.
86
+ is_dir: Whether `name` is a directory; directory-only
87
+ patterns such as `build/` match only when true.
88
+
89
+ Returns:
90
+ True if some `.gitignore` ignores the entry and no
91
+ deeper negation re-includes it.
92
+ """
93
+ path = directory / name
94
+ current = directory
95
+ while True:
96
+ spec = self._spec_for(current)
97
+ if spec is not None:
98
+ relative = str(path.relative_to(current))
99
+ # Directory-only patterns ("build/") only match paths
100
+ # presented with a trailing slash.
101
+ result = spec.check_file(f"{relative}/" if is_dir else relative)
102
+ if result.include is not None:
103
+ # Deepest .gitignore wins. include=False means a
104
+ # "!pattern" negation re-included the path,
105
+ # overriding any shallower ignore — so stop
106
+ # ascending either way.
107
+ return result.include
108
+ if current == self._boundary:
109
+ return False
110
+ current = current.parent
111
+
112
+ def _spec_for(self, directory: Path) -> pathspec.GitIgnoreSpec | None:
113
+ if directory not in self._specs:
114
+ self._specs[directory] = self._load(directory / ".gitignore")
115
+ return self._specs[directory]
116
+
117
+ def _load(self, gitignore: Path) -> pathspec.GitIgnoreSpec | None:
118
+ try:
119
+ lines = gitignore.read_text(encoding="utf-8").splitlines()
120
+ except FileNotFoundError:
121
+ return None
122
+ except OSError, UnicodeDecodeError:
123
+ self._diagnostics.emit(f"could not read {gitignore}, ignoring it")
124
+ return None
125
+ return pathspec.GitIgnoreSpec.from_lines(lines)
@@ -0,0 +1,30 @@
1
+ """Locating the repository root that bounds discovery searches."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from pathlib import Path
7
+
8
+
9
+ def find_repo_root(start: Path) -> Path | None:
10
+ """Find the nearest ancestor directory holding a `.git` entry.
11
+
12
+ Searches `start` and each of its parents, closest first, and
13
+ returns the first directory that contains a `.git` entry. Existence
14
+ is the test, not is_dir, since `.git` may be a file (worktrees,
15
+ submodules). The walk is finite (Path.parents ends at the
16
+ filesystem root), so it terminates even outside a repository. This
17
+ is the boundary at which upward searches for config files and
18
+ `.gitignore` files stop.
19
+
20
+ Args:
21
+ start: Directory to begin the upward search from.
22
+
23
+ Returns:
24
+ The repository root, or None when no ancestor contains a
25
+ `.git` entry (i.e. `start` is not inside a repository).
26
+ """
27
+ for candidate in (start, *start.parents):
28
+ if (candidate / ".git").exists():
29
+ return candidate
30
+ return None
@@ -0,0 +1,5 @@
1
+ """Content and cache-version hashing."""
2
+
3
+ from .hash import HashBackend, Hasher
4
+
5
+ __all__ = ["HashBackend", "Hasher"]
argusf/hashing/hash.py ADDED
@@ -0,0 +1,48 @@
1
+ """SHA256 hashing for content identity and cache versioning."""
2
+
3
+ import hashlib
4
+ from typing import Protocol
5
+
6
+
7
+ class Hasher(Protocol):
8
+ """Hashes bytes for content identity and cache versioning.
9
+
10
+ Injected into the file collector (per-file content hashing) and
11
+ the cache (naming the versioned cache subdirectory).
12
+ """
13
+
14
+ def hash_bytes(self, content: bytes) -> str:
15
+ """Return a stable hex digest of `content`.
16
+
17
+ Args:
18
+ content: Raw file bytes to hash.
19
+
20
+ Returns:
21
+ Hex digest identifying the content.
22
+ """
23
+
24
+ def get_cache_version_hash(self, argus_version: str, python_version: str, settings_fingerprint: str) -> str:
25
+ """Return the cache-version key for the given inputs.
26
+
27
+ Args:
28
+ argus_version: Running argusf version.
29
+ python_version: Running Python version.
30
+ settings_fingerprint: Fingerprint of the resolved lint
31
+ config.
32
+
33
+ Returns:
34
+ Hex digest naming the cache subdirectory; changing any
35
+ input invalidates previously cached findings.
36
+ """
37
+
38
+
39
+ class HashBackend(Hasher):
40
+ """SHA256 implementation of `Hasher`."""
41
+
42
+ def hash_bytes(self, content: bytes) -> str:
43
+ """Return the SHA256 hex digest of `content`."""
44
+ return hashlib.sha256(content).hexdigest()
45
+
46
+ def get_cache_version_hash(self, argus_version: str, python_version: str, settings_fingerprint: str) -> str:
47
+ """Return the SHA256 digest of the concatenated inputs."""
48
+ return hashlib.sha256(f"{argus_version}{python_version}{settings_fingerprint}".encode()).hexdigest()
argusf/ir/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Intermediate representation of parsed source and findings."""
@@ -0,0 +1,49 @@
1
+ from .findings import Finding, RunResult, Severity
2
+ from .source import (
3
+ ArithmeticIfStatement,
4
+ BlockData,
5
+ Comment,
6
+ CommonStatement,
7
+ EntryStatement,
8
+ EquivalenceStatement,
9
+ Function,
10
+ GotoStatement,
11
+ ImplicitStatement,
12
+ Module,
13
+ PauseStatement,
14
+ Program,
15
+ ProgramUnit,
16
+ SourceFile,
17
+ SourceFileMetadata,
18
+ SourceSpan,
19
+ Statement,
20
+ Submodule,
21
+ Subroutine,
22
+ UseStatement,
23
+ )
24
+
25
+ __all__ = [
26
+ "ArithmeticIfStatement",
27
+ "BlockData",
28
+ "Comment",
29
+ "CommonStatement",
30
+ "EntryStatement",
31
+ "EquivalenceStatement",
32
+ "Finding",
33
+ "Function",
34
+ "GotoStatement",
35
+ "ImplicitStatement",
36
+ "Module",
37
+ "PauseStatement",
38
+ "Program",
39
+ "ProgramUnit",
40
+ "RunResult",
41
+ "Severity",
42
+ "SourceFile",
43
+ "SourceFileMetadata",
44
+ "SourceSpan",
45
+ "Statement",
46
+ "Submodule",
47
+ "Subroutine",
48
+ "UseStatement",
49
+ ]
@@ -0,0 +1,74 @@
1
+ """IR models for findings and the aggregate outcome of a run."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from collections import Counter
9
+ from pathlib import Path
10
+
11
+ from argusf.autofix.models import Fix
12
+ from argusf.ir.models.source import SourceFileMetadata
13
+
14
+
15
+ class Severity(StrEnum):
16
+ """Severity level of a finding."""
17
+
18
+ ERROR = "error"
19
+ CRITICAL = "critical"
20
+ HIGH = "high"
21
+ MEDIUM = "medium"
22
+ LOW = "low"
23
+ INFO = "info"
24
+
25
+
26
+ @dataclass
27
+ class Finding:
28
+ """A single issue found in a source file."""
29
+
30
+ rule_id: str
31
+ message: str
32
+ severity: Severity
33
+ file_path: Path
34
+ line: int
35
+ column: int | None = None
36
+ end_line: int | None = None
37
+ end_column: int | None = None
38
+ suggestion: str | None = None
39
+ # None either because the rule offers no fix or because the engine
40
+ # stripped it (rule outside the resolved fixable set) — cached
41
+ # findings then match what is reported.
42
+ fix: Fix | None = None
43
+ suppressed: bool = False
44
+
45
+
46
+ @dataclass
47
+ class RunResult:
48
+ """Findings and counters produced by a single analysis run."""
49
+
50
+ findings: list[Finding] = field(default_factory=list)
51
+ failed_parse_files: list[SourceFileMetadata] = field(default_factory=list)
52
+ files_analysed: int = 0
53
+ files_from_cache: int = 0
54
+ elapsed: float = 0.0
55
+ # Fixes applied per file, counted per rule id; feeds the
56
+ # fixed/remaining summary and the --show-fixes enumeration.
57
+ fixed_by_file: dict[Path, Counter[str]] = field(default_factory=dict)
58
+ # Rendered unified diffs per file, in discovery order (--diff mode
59
+ # only).
60
+ diffs: list[tuple[Path, str]] = field(default_factory=list)
61
+
62
+ @property
63
+ def total_files(self) -> int:
64
+ """Total files processed (freshly analysed plus cache hits)."""
65
+ return self.files_analysed + self.files_from_cache
66
+
67
+ @property
68
+ def fixed_count(self) -> int:
69
+ """Total number of fixes applied across all files."""
70
+ return sum(sum(counts.values()) for counts in self.fixed_by_file.values())
71
+
72
+ def active_findings(self) -> list[Finding]:
73
+ """Return the findings that were not suppressed."""
74
+ return [finding for finding in self.findings if not finding.suppressed]
@@ -0,0 +1,199 @@
1
+ """IR models for source files and their parsed program structure."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from collections.abc import Iterator
8
+ from pathlib import Path
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SourceSpan:
13
+ """A region of source, in both byte and row/column coordinates."""
14
+
15
+ start_row: int
16
+ start_col: int
17
+ end_row: int
18
+ end_col: int
19
+ start_byte: int
20
+ end_byte: int
21
+
22
+
23
+ @dataclass
24
+ class Statement:
25
+ """Base class for a statement located by its source span."""
26
+
27
+ span: SourceSpan
28
+
29
+
30
+ @dataclass
31
+ class GotoStatement(Statement):
32
+ """A GOTO statement."""
33
+
34
+
35
+ @dataclass
36
+ class CommonStatement(Statement):
37
+ """A COMMON block statement."""
38
+
39
+
40
+ @dataclass
41
+ class Comment(Statement):
42
+ """A source comment and its text."""
43
+
44
+ text: str
45
+
46
+
47
+ @dataclass
48
+ class EquivalenceStatement(Statement):
49
+ """An EQUIVALENCE statement."""
50
+
51
+
52
+ @dataclass
53
+ class ArithmeticIfStatement(Statement):
54
+ """An arithmetic IF statement."""
55
+
56
+
57
+ @dataclass
58
+ class EntryStatement(Statement):
59
+ """An ENTRY statement."""
60
+
61
+
62
+ @dataclass
63
+ class PauseStatement(Statement):
64
+ """A PAUSE statement."""
65
+
66
+
67
+ @dataclass
68
+ class UseStatement(Statement):
69
+ """A USE statement, carrying the referenced module name."""
70
+
71
+ # The referenced module, as written (Fortran names are
72
+ # case-insensitive, so consumers comparing names must casefold). The
73
+ # span covers the whole statement including any ONLY clause and
74
+ # continuation lines.
75
+ module_name: str
76
+
77
+
78
+ @dataclass
79
+ class ImplicitStatement(Statement):
80
+ """An IMPLICIT statement; `is_none` disables implicit typing."""
81
+
82
+ # True when the statement disables implicit typing:
83
+ # implicit none -> True
84
+ # implicit none (type) -> True
85
+ # implicit none (type, external) -> True
86
+ # implicit none (external) -> False*
87
+ # implicit real(a-h) -> False
88
+ #
89
+ # * `implicit none (external)` only forbids implicit interfaces.
90
+ is_none: bool
91
+
92
+
93
+ @dataclass
94
+ class ProgramUnit:
95
+ """A program unit (program, module, procedure) and its body."""
96
+
97
+ span: SourceSpan
98
+ # None for anonymous units: Fortran allows a main program without a
99
+ # PROGRAM statement, which tree-sitter surfaces as a nameless node.
100
+ name: str | None = None
101
+ statements: list[Statement] = field(init=False, default_factory=list)
102
+ subunits: list[ProgramUnit] = field(init=False, default_factory=list)
103
+
104
+ def add_statement(self, statement: Statement) -> None:
105
+ """Append a statement to this unit's body."""
106
+ self.statements.append(statement)
107
+
108
+ def add_subunit(self, unit: ProgramUnit) -> None:
109
+ """Append a contained (nested) program unit."""
110
+ self.subunits.append(unit)
111
+
112
+
113
+ @dataclass
114
+ class Program(ProgramUnit):
115
+ """A main program unit."""
116
+
117
+
118
+ @dataclass
119
+ class Module(ProgramUnit):
120
+ """A module unit."""
121
+
122
+
123
+ @dataclass
124
+ class Subroutine(ProgramUnit):
125
+ """A subroutine unit."""
126
+
127
+
128
+ @dataclass
129
+ class Function(ProgramUnit):
130
+ """A function unit."""
131
+
132
+
133
+ @dataclass
134
+ class Submodule(ProgramUnit):
135
+ """A submodule unit."""
136
+
137
+
138
+ @dataclass
139
+ class BlockData(ProgramUnit):
140
+ """A BLOCK DATA unit."""
141
+
142
+
143
+ @dataclass
144
+ class SourceFileMetadata:
145
+ """A source file's identity: the cache lookup and validation key.
146
+
147
+ Deliberately lean — never carries file contents, so it can be stored
148
+ and compared freely. Consumers that need the bytes (the parser, the
149
+ fix stage) read the file themselves.
150
+ """
151
+
152
+ file_path: Path
153
+ size: int
154
+ mtime: float
155
+ content_hash: str
156
+
157
+
158
+ @dataclass
159
+ class SourceFile:
160
+ """A parsed source file: program units, comments, and raw bytes."""
161
+
162
+ file_path: Path
163
+ program_units: list[ProgramUnit] = field(default_factory=list)
164
+ # Comments outside any program unit (file headers, text between
165
+ # units); a comment inside a unit is a statement of that unit.
166
+ # Fortran permits no statements outside units, so comments are the
167
+ # only possible top-level content besides program units.
168
+ comments: list[Comment] = field(default_factory=list)
169
+ # Spans of unparseable regions (tree-sitter ERROR/missing nodes and
170
+ # units whose handler failed); each becomes a syntax-error finding.
171
+ syntax_errors: list[SourceSpan] = field(default_factory=list)
172
+ # The exact bytes the parse tree's spans index into. Rules need them
173
+ # to build fixes (line extraction, indentation); excluded from repr
174
+ # so file contents never leak into tracebacks/logs.
175
+ source: bytes = field(default=b"", repr=False)
176
+
177
+ def add_program_unit(self, unit: ProgramUnit) -> None:
178
+ """Append a top-level program unit."""
179
+ self.program_units.append(unit)
180
+
181
+ def add_comment(self, comment: Comment) -> None:
182
+ """Append a file-level comment."""
183
+ self.comments.append(comment)
184
+
185
+ def walk_units(self) -> Iterator[ProgramUnit]:
186
+ """Yield every program unit depth-first, subunits included."""
187
+ stack = list(reversed(self.program_units))
188
+ while stack:
189
+ unit = stack.pop()
190
+ yield unit
191
+ stack.extend(reversed(unit.subunits))
192
+
193
+ def walk_comments(self) -> Iterator[Comment]:
194
+ """Yield comments (file-level and unit) in document order."""
195
+ comments = list(self.comments)
196
+ for unit in self.walk_units():
197
+ comments.extend(statement for statement in unit.statements if isinstance(statement, Comment))
198
+ comments.sort(key=lambda comment: comment.span.start_byte)
199
+ yield from comments
argusf/orchestrator.py ADDED
@@ -0,0 +1,127 @@
1
+ """The Orchestrator: coordinates the per-file analysis pipeline."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import TYPE_CHECKING
6
+
7
+ from argusf.analysis.syntax_errors import syntax_error_finding
8
+ from argusf.ir.models import RunResult
9
+ from argusf.parser.backend import ParseError, ParsingEngine
10
+
11
+ if TYPE_CHECKING:
12
+ from pathlib import Path
13
+
14
+ from argusf.analysis.engine import AnalysisEngine
15
+ from argusf.autofix.engine import FixEngine
16
+ from argusf.cache.backend import Cacher
17
+ from argusf.config.models import ArgusConfig
18
+ from argusf.diagnostics import Diagnostics
19
+ from argusf.discovery.backend import FileCollector
20
+ from argusf.reporting import Reporter
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class Orchestrator:
26
+ """Coordinates discovery, caching, analysis, fixing, reporting."""
27
+
28
+ def __init__( # noqa: PLR0913
29
+ self,
30
+ file_collector: FileCollector,
31
+ parsing_engine: ParsingEngine,
32
+ analysis_engine: AnalysisEngine,
33
+ cache_backend: Cacher,
34
+ fix_engine: FixEngine,
35
+ diagnostics: Diagnostics,
36
+ reporter: Reporter,
37
+ ) -> None:
38
+ """Wire the orchestrator to its pipeline collaborators.
39
+
40
+ Args:
41
+ file_collector: Discovers and hashes source files.
42
+ parsing_engine: Parses files into IR.
43
+ analysis_engine: Produces findings for a parsed file.
44
+ cache_backend: Caches and restores per-file findings.
45
+ fix_engine: Runs the autofix stage per file.
46
+ diagnostics: Channel for parse-failure notices.
47
+ reporter: Writes the run's results.
48
+ """
49
+ self._file_collector = file_collector
50
+ self._parsing_engine = parsing_engine
51
+ self._analysis_engine = analysis_engine
52
+ self._cache_backend = cache_backend
53
+ self._fix_engine = fix_engine
54
+ self._diagnostics = diagnostics
55
+ self._reporter = reporter
56
+
57
+ def run(self, target: Path, config: ArgusConfig) -> RunResult:
58
+ """Run the full pipeline over a target and report results.
59
+
60
+ Streams each discovered file through cache lookup or analysis,
61
+ the fix stage, and caching, then writes the report.
62
+
63
+ Args:
64
+ target: File or directory to analyse.
65
+ config: Resolved configuration for the run.
66
+
67
+ Returns:
68
+ The aggregate run result.
69
+ """
70
+ start = time.perf_counter()
71
+ result = RunResult()
72
+
73
+ for file in self._file_collector.collect(target):
74
+ cached = None if config.no_cache else self._cache_backend.get(file)
75
+ if cached is not None:
76
+ # Counts where the initial findings came from. A cache
77
+ # hit that the fix stage later rewrites is deliberately
78
+ # not reclassified as analysed: the fix loop's in-memory
79
+ # re-analysis happens for freshly-analysed files too,
80
+ # without recounting them either.
81
+ result.files_from_cache += 1
82
+ findings = cached.findings
83
+ logger.debug("cache hit: %s", file.file_path)
84
+ else:
85
+ try:
86
+ source_file = self._parsing_engine.parse(file)
87
+ except ParseError:
88
+ # Near-dead with the tree-sitter engine (it records
89
+ # error regions instead of raising), but any
90
+ # ParsingEngine may still fail outright: the file
91
+ # becomes one whole-file syntax-error finding and is
92
+ # cached like any other. Never fixed — the fix stage
93
+ # refuses syntax-error files anyway.
94
+ self._diagnostics.emit(f"parse failed: {file.file_path}")
95
+ result.failed_parse_files.append(file)
96
+ findings = [syntax_error_finding(file.file_path, span=None)]
97
+ result.findings.extend(findings)
98
+ result.files_analysed += 1
99
+ if not config.no_cache:
100
+ self._cache_backend.put(file, findings)
101
+ continue
102
+
103
+ findings = self._analysis_engine.analyse(source_file)
104
+ result.files_analysed += 1
105
+ logger.debug("analysed: %s", file.file_path)
106
+
107
+ # The fix stage owns all autofix policy (eligibility, the
108
+ # fix loop, writes vs diffs, what to report and cache per
109
+ # mode); in report mode it hands everything straight back.
110
+ outcome = self._fix_engine.process(file, findings)
111
+ result.findings.extend(outcome.findings)
112
+ if outcome.fixed_by_rule:
113
+ result.fixed_by_file[file.file_path] = outcome.fixed_by_rule
114
+ if outcome.diff is not None:
115
+ result.diffs.append((file.file_path, outcome.diff))
116
+ # A cache hit stays valid unless the fix stage rewrote the
117
+ # file, in which case the entry is re-keyed to the fixed
118
+ # content's identity.
119
+ if not config.no_cache and (cached is None or outcome.changed):
120
+ self._cache_backend.put(outcome.metadata, outcome.findings)
121
+
122
+ if not config.no_cache:
123
+ self._cache_backend.write_to_cache()
124
+
125
+ result.elapsed = time.perf_counter() - start
126
+ self._reporter.write_results(result, target)
127
+ return result
@@ -0,0 +1 @@
1
+ """Parsing Fortran source into the intermediate representation."""
@@ -0,0 +1,24 @@
1
+ """The parsing-engine protocol and its error type."""
2
+
3
+ from typing import TYPE_CHECKING, Protocol
4
+
5
+ if TYPE_CHECKING:
6
+ from pathlib import Path
7
+
8
+ from argusf.ir.models import SourceFile, SourceFileMetadata
9
+
10
+
11
+ class ParsingEngine(Protocol):
12
+ """Parses a source file into a `SourceFile` IR tree."""
13
+
14
+ def parse(self, file: SourceFileMetadata) -> SourceFile:
15
+ """Read the file from disk and parse it."""
16
+ ...
17
+
18
+ def parse_source(self, path: Path, source: bytes) -> SourceFile:
19
+ """Parse source already held in memory (e.g. the fix loop)."""
20
+ ...
21
+
22
+
23
+ class ParseError(Exception):
24
+ """Raised when a file cannot be read or parsed into IR."""