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,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)
|
argusf/discovery/repo.py
ADDED
|
@@ -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
|
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."""
|
argusf/parser/backend.py
ADDED
|
@@ -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."""
|