argusf 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. argusf/__init__.py +3 -0
  2. argusf/analysis/__init__.py +14 -0
  3. argusf/analysis/engine.py +100 -0
  4. argusf/analysis/rule.py +45 -0
  5. argusf/analysis/rules/__init__.py +5 -0
  6. argusf/analysis/rules/documentation.py +30 -0
  7. argusf/analysis/rules/findings.py +67 -0
  8. argusf/analysis/rules/registry.py +22 -0
  9. argusf/analysis/rules/rulesets/__init__.py +4 -0
  10. argusf/analysis/rules/rulesets/rgus/__init__.py +22 -0
  11. argusf/analysis/rules/rulesets/rgus/rgus001_no_goto.py +54 -0
  12. argusf/analysis/rules/rulesets/rgus/rgus002_common_block.py +55 -0
  13. argusf/analysis/rules/rulesets/rgus/rgus003_equivalence.py +55 -0
  14. argusf/analysis/rules/rulesets/rgus/rgus004_implicit_typing.py +129 -0
  15. argusf/analysis/rules/rulesets/rgus/rgus005_arithmetic_if.py +62 -0
  16. argusf/analysis/rules/rulesets/rgus/rgus006_entry_statement.py +67 -0
  17. argusf/analysis/rules/rulesets/rgus/rgus007_pause_statement.py +93 -0
  18. argusf/analysis/rules/rulesets/rgus/rgus008_todo_comment.py +100 -0
  19. argusf/analysis/rules/rulesets/rgus/rgus009_unsorted_use_statements.py +258 -0
  20. argusf/analysis/rules/selection.py +83 -0
  21. argusf/analysis/suppression.py +92 -0
  22. argusf/analysis/syntax_errors.py +41 -0
  23. argusf/autofix/__init__.py +26 -0
  24. argusf/autofix/applier.py +130 -0
  25. argusf/autofix/context.py +82 -0
  26. argusf/autofix/diff.py +39 -0
  27. argusf/autofix/edits.py +31 -0
  28. argusf/autofix/engine.py +192 -0
  29. argusf/autofix/models.py +39 -0
  30. argusf/autofix/source.py +68 -0
  31. argusf/autofix/writer.py +53 -0
  32. argusf/cache/__init__.py +5 -0
  33. argusf/cache/backend.py +294 -0
  34. argusf/cli.py +428 -0
  35. argusf/config/__init__.py +1 -0
  36. argusf/config/config_resolver.py +212 -0
  37. argusf/config/errors.py +8 -0
  38. argusf/config/file_reader/__init__.py +3 -0
  39. argusf/config/file_reader/reader.py +58 -0
  40. argusf/config/models.py +110 -0
  41. argusf/config/validation.py +113 -0
  42. argusf/constants.py +66 -0
  43. argusf/diagnostics.py +45 -0
  44. argusf/discovery/__init__.py +3 -0
  45. argusf/discovery/backend.py +250 -0
  46. argusf/discovery/gitignore.py +125 -0
  47. argusf/discovery/repo.py +30 -0
  48. argusf/hashing/__init__.py +5 -0
  49. argusf/hashing/hash.py +48 -0
  50. argusf/ir/__init__.py +1 -0
  51. argusf/ir/models/__init__.py +49 -0
  52. argusf/ir/models/findings.py +74 -0
  53. argusf/ir/models/source.py +199 -0
  54. argusf/orchestrator.py +127 -0
  55. argusf/parser/__init__.py +1 -0
  56. argusf/parser/backend.py +24 -0
  57. argusf/parser/treesitter/__init__.py +1 -0
  58. argusf/parser/treesitter/backend.py +157 -0
  59. argusf/parser/treesitter/walker/__init__.py +5 -0
  60. argusf/parser/treesitter/walker/handlers.py +209 -0
  61. argusf/parser/treesitter/walker/walker.py +82 -0
  62. argusf/registry/__init__.py +3 -0
  63. argusf/registry/registry.py +69 -0
  64. argusf/reporting/__init__.py +22 -0
  65. argusf/reporting/formatting.py +162 -0
  66. argusf/reporting/registry.py +20 -0
  67. argusf/reporting/reporters/__init__.py +15 -0
  68. argusf/reporting/reporters/base.py +29 -0
  69. argusf/reporting/reporters/concise.py +35 -0
  70. argusf/reporting/reporters/diff.py +30 -0
  71. argusf/reporting/reporters/json.py +59 -0
  72. argusf/reporting/reporters/null.py +21 -0
  73. argusf/reporting/reporters/standard.py +78 -0
  74. argusf/reporting/reporters/statistics.py +99 -0
  75. argusf-0.1.0.dev0.dist-info/METADATA +166 -0
  76. argusf-0.1.0.dev0.dist-info/RECORD +79 -0
  77. argusf-0.1.0.dev0.dist-info/WHEEL +4 -0
  78. argusf-0.1.0.dev0.dist-info/entry_points.txt +3 -0
  79. argusf-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
argusf/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Argusf: a static code analyzer for Fortran 90."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,14 @@
1
+ """Rule-based analysis of parsed source into findings."""
2
+
3
+ from .engine import AnalysisEngine
4
+ from .rule import Rule, RuleMetadata
5
+ from .rules import RuleRegistry
6
+ from .suppression import SuppressionFilter
7
+
8
+ __all__ = [
9
+ "AnalysisEngine",
10
+ "Rule",
11
+ "RuleMetadata",
12
+ "RuleRegistry",
13
+ "SuppressionFilter",
14
+ ]
@@ -0,0 +1,100 @@
1
+ """The analysis engine: runs rules over a parsed source file."""
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING
5
+
6
+ from argusf.analysis.rule import ConfigurableRule, Rule
7
+ from argusf.analysis.rules import RuleRegistry
8
+ from argusf.analysis.rules.selection import RuleSelectionResolver
9
+ from argusf.analysis.syntax_errors import syntax_error_finding
10
+
11
+ if TYPE_CHECKING:
12
+ from argusf.analysis.suppression import SuppressionFilter
13
+ from argusf.config.models import ArgusConfig
14
+ from argusf.ir.models import Finding, SourceFile
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class AnalysisEngine:
20
+ """Runs the configured rules over a source file."""
21
+
22
+ def __init__(self, config: ArgusConfig, suppression_filter: SuppressionFilter) -> None:
23
+ """Build the engine's rule set from config.
24
+
25
+ Constructs every registered rule, configures the ones that
26
+ read settings, filters by select/ignore and preview, and
27
+ resolves which rules may attach fixes.
28
+
29
+ Args:
30
+ config: Resolved configuration; its lint section drives
31
+ rule selection and fixability.
32
+ suppression_filter: Filter applied to findings after the
33
+ rules run.
34
+ """
35
+ self._config = config.lint
36
+ self._suppression_filter = suppression_filter
37
+ rules: list[Rule] = []
38
+ for rule_cls in RuleRegistry.values():
39
+ rule = rule_cls()
40
+ if isinstance(rule, ConfigurableRule):
41
+ rule.configure(self._config)
42
+ rules.append(rule)
43
+ self._rules = self._filter_rules(rules)
44
+ # A rule's fix survives only if the rule declares itself fixable
45
+ # (metadata is authoritative) AND the fixable/unfixable
46
+ # selectors resolve it in.
47
+ declared_fixable = {rule.metadata.id for rule in rules if rule.metadata.fixable}
48
+ self._fixable = declared_fixable & RuleSelectionResolver().resolve(
49
+ [*self._config.fixable, *self._config.extend_fixable], self._config.unfixable
50
+ )
51
+
52
+ def analyse(self, source_file: SourceFile) -> list[Finding]:
53
+ """Produce all findings for a parsed source file.
54
+
55
+ Seeds syntax-error findings from the parser, runs each enabled
56
+ rule, strips fixes from rules outside the fixable set, and
57
+ marks suppressed findings.
58
+
59
+ Args:
60
+ source_file: The parsed file to analyse.
61
+
62
+ Returns:
63
+ Findings in document order, some flagged suppressed.
64
+ """
65
+ # Syntax errors precede rule findings; they come from the
66
+ # parser, not a rule, so rule selection never applies to them.
67
+ findings: list[Finding] = [
68
+ syntax_error_finding(source_file.file_path, span) for span in source_file.syntax_errors
69
+ ]
70
+ for rule in self._rules:
71
+ rule_findings = rule.check(source_file)
72
+ if rule_findings:
73
+ logger.debug(
74
+ "%s: %d finding(s) from %s",
75
+ source_file.file_path,
76
+ len(rule_findings),
77
+ rule.metadata.id,
78
+ )
79
+ findings.extend(rule_findings)
80
+
81
+ # Strip fixes for rules outside the resolved fixable set before
82
+ # findings are cached or reported, so a finding's payload always
83
+ # matches what fixable/unfixable said at analysis time — an
84
+ # unfixable rule's findings carry no fix anywhere, including the
85
+ # JSON report's `fix: null`.
86
+ for finding in findings:
87
+ if finding.fix is not None and finding.rule_id not in self._fixable:
88
+ finding.fix = None
89
+
90
+ self._suppression_filter.apply(source_file, findings)
91
+ return findings
92
+
93
+ def _filter_rules(self, rules: list[Rule]) -> list[Rule]:
94
+ select = [*self._config.select, *self._config.extend_select]
95
+ enabled = RuleSelectionResolver().resolve(select, self._config.ignore)
96
+ return [
97
+ rule
98
+ for rule in rules
99
+ if rule.metadata.id in enabled and (self._config.preview or not rule.metadata.preview)
100
+ ]
@@ -0,0 +1,45 @@
1
+ """Rule protocols and the metadata attached to each rule."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
5
+
6
+ if TYPE_CHECKING:
7
+ from argusf.config.models import LintConfig
8
+ from argusf.ir.models import Finding, Severity, SourceFile
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class RuleMetadata:
13
+ """Static metadata describing a rule."""
14
+
15
+ id: str
16
+ name: str
17
+ message: str
18
+ description: str
19
+ default_severity: Severity
20
+ suggestion: str | None = None
21
+ preview: bool = False
22
+ # The fixable-rule contract. A rule that offers fixes declares it
23
+ # here and must document the classification in a `## Fix safety`
24
+ # docstring section (a registry test enforces both directions). The
25
+ # declaration is authoritative: the engine strips fixes from any
26
+ # rule that does not make it, so an undeclared fix can never reach a
27
+ # report, the cache, or a file.
28
+ fixable: bool = False
29
+
30
+
31
+ class Rule(Protocol):
32
+ """A lint rule: metadata plus a check over a source file."""
33
+
34
+ metadata: RuleMetadata
35
+
36
+ def check(self, source_file: SourceFile) -> list[Finding]:
37
+ """Return the findings this rule detects in `source_file`."""
38
+
39
+
40
+ @runtime_checkable
41
+ class ConfigurableRule(Rule, Protocol):
42
+ """A rule that reads lint settings via `configure`."""
43
+
44
+ def configure(self, config: LintConfig) -> None:
45
+ """Receive the resolved lint config before analysis."""
@@ -0,0 +1,5 @@
1
+ # Import rulesets to trigger registration of the built-in rules.
2
+ from . import rulesets
3
+ from .registry import RuleRegistry, register
4
+
5
+ __all__ = ["RuleRegistry", "register", "rulesets"]
@@ -0,0 +1,30 @@
1
+ """Rendering a rule's long-form documentation for the CLI."""
2
+
3
+ import inspect
4
+ from typing import TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from argusf.analysis.rule import Rule
8
+
9
+
10
+ class RuleDocumentationRenderer:
11
+ """Renders a rule's docstring for the `argusf rule` command."""
12
+
13
+ def render(self, rule_cls: type[Rule]) -> str:
14
+ """Render a rule's documentation as markdown.
15
+
16
+ Uses the rule class's own docstring, falling back to its
17
+ metadata description when it has none.
18
+
19
+ Args:
20
+ rule_cls: The rule class to document.
21
+
22
+ Returns:
23
+ A markdown document with a heading, severity, and body.
24
+ """
25
+ metadata = rule_cls.metadata
26
+ # Read __doc__ rather than inspect.getdoc(): getdoc walks the
27
+ # MRO and would surface an inherited docstring for an
28
+ # undocumented rule.
29
+ documentation = inspect.cleandoc(rule_cls.__doc__) if rule_cls.__doc__ else metadata.description
30
+ return f"# {metadata.name} ({metadata.id})\n\nDefault severity: {metadata.default_severity}\n\n{documentation}"
@@ -0,0 +1,67 @@
1
+ """Shared finding constructors used by rules."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from argusf.ir.models import Finding, ProgramUnit, SourceFile, Statement
6
+
7
+ if TYPE_CHECKING:
8
+ from argusf.analysis.rule import RuleMetadata
9
+ from argusf.autofix.models import Fix
10
+
11
+
12
+ def build_statement_finding(
13
+ metadata: RuleMetadata, source_file: SourceFile, statement: Statement, fix: Fix | None = None
14
+ ) -> Finding:
15
+ """Build a finding anchored on a statement's span.
16
+
17
+ Args:
18
+ metadata: The reporting rule's metadata.
19
+ source_file: File the statement belongs to.
20
+ statement: Statement the finding points at.
21
+ fix: Optional fix to attach.
22
+
23
+ Returns:
24
+ A finding located at the statement.
25
+ """
26
+ return Finding(
27
+ rule_id=metadata.id,
28
+ message=metadata.message,
29
+ severity=metadata.default_severity,
30
+ file_path=source_file.file_path,
31
+ line=statement.span.start_row,
32
+ column=statement.span.start_col,
33
+ end_line=statement.span.end_row,
34
+ end_column=statement.span.end_col,
35
+ suggestion=metadata.suggestion,
36
+ fix=fix,
37
+ )
38
+
39
+
40
+ def build_unit_finding(
41
+ metadata: RuleMetadata, source_file: SourceFile, unit: ProgramUnit, fix: Fix | None = None
42
+ ) -> Finding:
43
+ """Build a finding anchored on a program unit's first line.
44
+
45
+ Anchoring on the first line (rather than the whole unit) keeps the
46
+ report and inline suppressions on the declaration statement,
47
+ instead of underlining the entire body.
48
+
49
+ Args:
50
+ metadata: The reporting rule's metadata.
51
+ source_file: File the unit belongs to.
52
+ unit: Program unit the finding points at.
53
+ fix: Optional fix to attach.
54
+
55
+ Returns:
56
+ A finding located at the unit's declaration line.
57
+ """
58
+ return Finding(
59
+ rule_id=metadata.id,
60
+ message=metadata.message,
61
+ severity=metadata.default_severity,
62
+ file_path=source_file.file_path,
63
+ line=unit.span.start_row,
64
+ column=unit.span.start_col,
65
+ suggestion=metadata.suggestion,
66
+ fix=fix,
67
+ )
@@ -0,0 +1,22 @@
1
+ """The rule registry and its id-aware `register` decorator."""
2
+
3
+ from argusf.analysis.rule import Rule
4
+ from argusf.registry import Registry
5
+
6
+ RuleRegistry: Registry[str, type[Rule]] = Registry()
7
+
8
+
9
+ def register[T: type[Rule]](cls: T) -> T:
10
+ """Register a rule class under its metadata id.
11
+
12
+ A thin wrapper over the registry's own decorator that reads the id
13
+ from ``cls.metadata``, so it need not be written a second time.
14
+
15
+ Args:
16
+ cls: The rule class to register.
17
+
18
+ Returns:
19
+ The same class, unchanged.
20
+ """
21
+ RuleRegistry.register(cls.metadata.id)(cls)
22
+ return cls
@@ -0,0 +1,4 @@
1
+ # Import rulesets to trigger rule registration with the registry.
2
+ from . import rgus
3
+
4
+ __all__ = ["rgus"]
@@ -0,0 +1,22 @@
1
+ # Importing the rule modules registers each rule with the RuleRegistry.
2
+ from .rgus001_no_goto import NoGoto
3
+ from .rgus002_common_block import NoCommonBlock
4
+ from .rgus003_equivalence import NoEquivalence
5
+ from .rgus004_implicit_typing import ImplicitTyping
6
+ from .rgus005_arithmetic_if import NoArithmeticIf
7
+ from .rgus006_entry_statement import NoEntryStatement
8
+ from .rgus007_pause_statement import NoPauseStatement
9
+ from .rgus008_todo_comment import TodoComment
10
+ from .rgus009_unsorted_use_statements import UnsortedUseStatements
11
+
12
+ __all__ = [
13
+ "ImplicitTyping",
14
+ "NoArithmeticIf",
15
+ "NoCommonBlock",
16
+ "NoEntryStatement",
17
+ "NoEquivalence",
18
+ "NoGoto",
19
+ "NoPauseStatement",
20
+ "TodoComment",
21
+ "UnsortedUseStatements",
22
+ ]
@@ -0,0 +1,54 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.ir.models import Finding, GotoStatement, Severity, SourceFile
5
+
6
+
7
+ @register
8
+ class NoGoto(Rule):
9
+ """## What it does
10
+
11
+ Checks for use of the `GO TO` statement.
12
+
13
+ ## Why is this bad?
14
+
15
+ `GO TO` jumps to a labelled statement unconditionally, so the flow of
16
+ control cannot be understood from the program's block structure alone.
17
+ Code that accumulates `GO TO` jumps degrades into paths that are hard
18
+ to follow, test, and modify. Fortran 90's structured constructs
19
+ (`DO`, `SELECT CASE`, `EXIT`, `CYCLE`) express the same logic without
20
+ labels.
21
+
22
+ ## Example
23
+
24
+ ```fortran
25
+ i = 0
26
+ 10 continue
27
+ i = i + 1
28
+ if (i < 10) go to 10
29
+ ```
30
+
31
+ Use instead:
32
+
33
+ ```fortran
34
+ do i = 1, 10
35
+ end do
36
+ ```
37
+ """
38
+
39
+ metadata = RuleMetadata(
40
+ id="RGUS001",
41
+ name="no-goto",
42
+ message="Use of GOTO statement detected",
43
+ description="Flags any use of GOTO, which hinders readability and structured control flow.",
44
+ default_severity=Severity.MEDIUM,
45
+ suggestion="Replace GOTO with structured control flow (DO, IF, EXIT)",
46
+ )
47
+
48
+ def check(self, source_file: SourceFile) -> list[Finding]:
49
+ return [
50
+ build_statement_finding(self.metadata, source_file, statement)
51
+ for unit in source_file.walk_units()
52
+ for statement in unit.statements
53
+ if isinstance(statement, GotoStatement)
54
+ ]
@@ -0,0 +1,55 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.ir.models import CommonStatement, Finding, Severity, SourceFile
5
+
6
+
7
+ @register
8
+ class NoCommonBlock(Rule):
9
+ """## What it does
10
+
11
+ Checks for use of `COMMON` blocks.
12
+
13
+ ## Why is this bad?
14
+
15
+ A `COMMON` block shares storage between program units by position
16
+ rather than by name: every declaration must repeat the block's layout,
17
+ and a mismatch in type, kind, or order silently corrupts data instead
18
+ of failing to compile. The result is implicit, untyped global state.
19
+ Module variables provide the same sharing with explicit names and
20
+ compiler-checked types.
21
+
22
+ ## Example
23
+
24
+ ```fortran
25
+ subroutine step()
26
+ common /state/ x, y
27
+ x = x + y
28
+ end subroutine step
29
+ ```
30
+
31
+ Use instead:
32
+
33
+ ```fortran
34
+ module state
35
+ real :: x, y
36
+ end module state
37
+ ```
38
+ """
39
+
40
+ metadata = RuleMetadata(
41
+ id="RGUS002",
42
+ name="common-block",
43
+ message="Use of COMMON block detected",
44
+ description="Flags any use of COMMON blocks, which introduce implicit shared state.",
45
+ default_severity=Severity.MEDIUM,
46
+ suggestion="Replace COMMON block with MODULE variables",
47
+ )
48
+
49
+ def check(self, source_file: SourceFile) -> list[Finding]:
50
+ return [
51
+ build_statement_finding(self.metadata, source_file, statement)
52
+ for unit in source_file.walk_units()
53
+ for statement in unit.statements
54
+ if isinstance(statement, CommonStatement)
55
+ ]
@@ -0,0 +1,55 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.ir.models import EquivalenceStatement, Finding, Severity, SourceFile
5
+
6
+
7
+ @register
8
+ class NoEquivalence(Rule):
9
+ """## What it does
10
+
11
+ Checks for use of `EQUIVALENCE` statements.
12
+
13
+ ## Why is this bad?
14
+
15
+ `EQUIVALENCE` forces two or more variables to share the same storage,
16
+ creating hidden aliases: writing through one name silently changes the
17
+ value read through another, and the sharing is invisible at every use
18
+ site. It also defeats compiler optimisation and type checking. Modern
19
+ Fortran expresses the underlying intent directly — `TRANSFER` for type
20
+ reinterpretation, `POINTER` association for aliasing, allocatables or
21
+ derived types for storage reuse.
22
+
23
+ ## Example
24
+
25
+ ```fortran
26
+ real :: buffer(100)
27
+ integer :: ibuffer(100)
28
+ equivalence (buffer, ibuffer)
29
+ ```
30
+
31
+ Use instead:
32
+
33
+ ```fortran
34
+ real :: buffer(100)
35
+ integer :: ibuffer(100)
36
+ ibuffer = transfer(buffer, ibuffer)
37
+ ```
38
+ """
39
+
40
+ metadata = RuleMetadata(
41
+ id="RGUS003",
42
+ name="equivalence",
43
+ message="Use of EQUIVALENCE statement detected",
44
+ description="Flags any use of EQUIVALENCE, which aliases storage between variables.",
45
+ default_severity=Severity.MEDIUM,
46
+ suggestion="Replace EQUIVALENCE with TRANSFER, POINTER association, or a derived type",
47
+ )
48
+
49
+ def check(self, source_file: SourceFile) -> list[Finding]:
50
+ return [
51
+ build_statement_finding(self.metadata, source_file, statement)
52
+ for unit in source_file.walk_units()
53
+ for statement in unit.statements
54
+ if isinstance(statement, EquivalenceStatement)
55
+ ]
@@ -0,0 +1,129 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_unit_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.autofix.edits import insertion
5
+ from argusf.autofix.models import Applicability, Fix
6
+ from argusf.autofix.source import line_end, line_indent, span_fits
7
+ from argusf.ir.models import Finding, ImplicitStatement, ProgramUnit, Severity, SourceFile, Submodule, UseStatement
8
+
9
+
10
+ @register
11
+ class ImplicitTyping(Rule):
12
+ """## What it does
13
+
14
+ Checks for program units that do not disable implicit typing with
15
+ `IMPLICIT NONE`.
16
+
17
+ ## Why is this bad?
18
+
19
+ Without `IMPLICIT NONE`, any undeclared name is implicitly typed from
20
+ its first letter (`I` to `N` integer, everything else real). A typo in a
21
+ variable name then compiles cleanly as a brand-new variable instead of
22
+ failing, which is a classic source of silent numerical bugs.
23
+
24
+ A unit is not flagged when a host scope in the same file already
25
+ declares `IMPLICIT NONE` (contained procedures inherit the host's
26
+ implicit rules). Submodules and the procedures they contain are never
27
+ flagged: their implicit rules come from the ancestor module, which
28
+ usually lives in another file. Note that `IMPLICIT NONE (EXTERNAL)`
29
+ does not disable implicit typing and so does not satisfy this rule.
30
+
31
+ ## Example
32
+
33
+ ```fortran
34
+ program simulate
35
+ total = 0.0
36
+ end program simulate
37
+ ```
38
+
39
+ Use instead:
40
+
41
+ ```fortran
42
+ program simulate
43
+ implicit none
44
+ real :: total
45
+ total = 0.0
46
+ end program simulate
47
+ ```
48
+
49
+ ## Fix safety
50
+
51
+ The fix inserts `IMPLICIT NONE` after the unit's USE statements. Any
52
+ name the unit was implicitly typing then becomes a compile error until
53
+ it is declared — deliberate, but it means the fixed code may not
54
+ compile as-is, so the fix is always marked unsafe.
55
+ """
56
+
57
+ metadata = RuleMetadata(
58
+ id="RGUS004",
59
+ name="implicit-typing",
60
+ message="Program unit does not declare IMPLICIT NONE",
61
+ description="Flags program units without IMPLICIT NONE, which leaves undeclared names implicitly typed.",
62
+ default_severity=Severity.HIGH,
63
+ suggestion="Add IMPLICIT NONE to the unit's specification section",
64
+ preview=True, # Must confirm rule is stable on nested units
65
+ fixable=True,
66
+ )
67
+
68
+ def check(self, source_file: SourceFile) -> list[Finding]:
69
+ findings: list[Finding] = []
70
+ for unit in source_file.program_units:
71
+ self._check_unit(unit, source_file, host_covered=False, findings=findings)
72
+ return findings
73
+
74
+ def _check_unit(
75
+ self, unit: ProgramUnit, source_file: SourceFile, *, host_covered: bool, findings: list[Finding]
76
+ ) -> None:
77
+ covered = host_covered or any(
78
+ isinstance(statement, ImplicitStatement) and statement.is_none for statement in unit.statements
79
+ )
80
+ # A submodule's implicit rules come from its ancestor module, which
81
+ # usually lives in another file: treat the whole subtree as covered
82
+ # rather than risk false positives.
83
+ if isinstance(unit, Submodule):
84
+ covered = True
85
+ elif not covered:
86
+ findings.append(
87
+ build_unit_finding(self.metadata, source_file, unit, fix=self._build_fix(source_file, unit))
88
+ )
89
+ for subunit in unit.subunits:
90
+ self._check_unit(subunit, source_file, host_covered=covered, findings=findings)
91
+
92
+ def _build_fix(self, source_file: SourceFile, unit: ProgramUnit) -> Fix | None:
93
+ """Build the `implicit none` insertion fix, or None.
94
+
95
+ Fortran's statement ordering puts USE statements before the
96
+ implicit part, so the insertion point is the line after the
97
+ last USE statement, or after the unit's declaration line when
98
+ there are none.
99
+ """
100
+ source = source_file.source
101
+ if not span_fits(source, unit.span):
102
+ return None
103
+ uses = [statement for statement in unit.statements if isinstance(statement, UseStatement)]
104
+ if uses:
105
+ last_use = max(uses, key=lambda statement: statement.span.end_byte)
106
+ anchor = line_end(source, max(last_use.span.start_byte, last_use.span.end_byte - 1))
107
+ else:
108
+ anchor = line_end(source, unit.span.start_byte)
109
+ indent = self._probe_indent(source, anchor, unit)
110
+ return Fix(
111
+ applicability=Applicability.UNSAFE,
112
+ edits=(insertion(source, anchor, f"{indent}implicit none\n"),),
113
+ message="Insert `implicit none`",
114
+ )
115
+
116
+ def _probe_indent(self, source: bytes, anchor: int, unit: ProgramUnit) -> str:
117
+ """Probe the indentation to give the inserted statement.
118
+
119
+ Matches the indentation of the first non-blank line after the
120
+ insertion point (the unit's own body style); falls back to the
121
+ declaration line.
122
+ """
123
+ offset = anchor
124
+ while offset < min(len(source), unit.span.end_byte):
125
+ end = line_end(source, offset)
126
+ if source[offset:end].strip():
127
+ return line_indent(source, offset)
128
+ offset = end
129
+ return line_indent(source, unit.span.start_byte)
@@ -0,0 +1,62 @@
1
+ from argusf.analysis.rule import Rule, RuleMetadata
2
+ from argusf.analysis.rules.findings import build_statement_finding
3
+ from argusf.analysis.rules.registry import register
4
+ from argusf.ir.models import ArithmeticIfStatement, Finding, Severity, SourceFile
5
+
6
+
7
+ @register
8
+ class NoArithmeticIf(Rule):
9
+ """## What it does
10
+
11
+ Checks for use of the arithmetic `IF` statement.
12
+
13
+ ## Why is this bad?
14
+
15
+ The arithmetic `IF` — `IF (expr) 10, 20, 30` — branches to one of
16
+ three labels depending on the sign of the expression. It is a labelled
17
+ jump in disguise, with all of `GO TO`'s readability problems plus an
18
+ easily-misread three-way convention. It has been obsolescent since
19
+ Fortran 90 and was deleted from the standard in Fortran 2018. Block
20
+ `IF`/`ELSE IF` constructs express the same logic structurally.
21
+
22
+ ## Example
23
+
24
+ ```fortran
25
+ if (x) 10, 20, 30
26
+ 10 y = -1.0
27
+ go to 40
28
+ 20 y = 0.0
29
+ go to 40
30
+ 30 y = 1.0
31
+ 40 continue
32
+ ```
33
+
34
+ Use instead:
35
+
36
+ ```fortran
37
+ if (x < 0.0) then
38
+ y = -1.0
39
+ else if (x == 0.0) then
40
+ y = 0.0
41
+ else
42
+ y = 1.0
43
+ end if
44
+ ```
45
+ """
46
+
47
+ metadata = RuleMetadata(
48
+ id="RGUS005",
49
+ name="arithmetic-if",
50
+ message="Use of arithmetic IF statement detected",
51
+ description="Flags the three-way arithmetic IF, deleted from the standard in Fortran 2018.",
52
+ default_severity=Severity.MEDIUM,
53
+ suggestion="Replace arithmetic IF with an IF/ELSE IF construct",
54
+ )
55
+
56
+ def check(self, source_file: SourceFile) -> list[Finding]:
57
+ return [
58
+ build_statement_finding(self.metadata, source_file, statement)
59
+ for unit in source_file.walk_units()
60
+ for statement in unit.statements
61
+ if isinstance(statement, ArithmeticIfStatement)
62
+ ]