linti 0.4.2__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.
- linti/__init__.py +9 -0
- linti/cli/__init__.py +1 -0
- linti/cli/auto_fixer.py +218 -0
- linti/cli/config_loader.py +56 -0
- linti/cli/file_linter.py +280 -0
- linti/cli/issue_reporter.py +210 -0
- linti/cli/main.py +130 -0
- linti/cli/rule_explainer.py +126 -0
- linti/config.py +246 -0
- linti/lexer/__init__.py +0 -0
- linti/lexer/lexer.py +317 -0
- linti/lexer/token.py +151 -0
- linti/lexer/token_window.py +31 -0
- linti/linter/__init__.py +0 -0
- linti/linter/lint_context.py +72 -0
- linti/linter/lint_issue.py +27 -0
- linti/linter/linter.py +115 -0
- linti/linter/noqa.py +201 -0
- linti/loader/__init__.py +35 -0
- linti/loader/base.py +83 -0
- linti/loader/ti_loader.py +41 -0
- linti/loader/yaml_loader.py +297 -0
- linti/parser/ast.py +205 -0
- linti/parser/parser.py +605 -0
- linti/rules/Rule.py +157 -0
- linti/rules/__init__.py +15 -0
- linti/rules/documentation/__init__.py +1 -0
- linti/rules/documentation/docstring_region_rule.py +222 -0
- linti/rules/format/__init__.py +0 -0
- linti/rules/format/indentation_rule.py +181 -0
- linti/rules/format/keyword_casing_rule.py +195 -0
- linti/rules/format/newline_per_statement_rule.py +88 -0
- linti/rules/format/no_multiple_spaces_rule.py +67 -0
- linti/rules/format/no_space_before_semicolon_rule.py +76 -0
- linti/rules/format/no_trailing_whitespace_rule.py +65 -0
- linti/rules/format/one_space_inside_parentheses_rule.py +122 -0
- linti/rules/format/whitespace_after_comma_rule.py +84 -0
- linti/rules/format/whitespace_around_operators_rule.py +119 -0
- linti/rules/naming/__init__.py +0 -0
- linti/rules/naming/naming_rule.py +215 -0
- linti/rules/naming/parameter_naming_rule.py +104 -0
- linti/rules/naming/variable_naming_rule.py +49 -0
- linti/rules/rule_factory.py +118 -0
- linti/rules/semantic/__init__.py +0 -0
- linti/rules/semantic/constant_rule.py +83 -0
- linti/rules/semantic/docstring_region_rule.py +10 -0
- linti/rules/semantic/execute_command_rule.py +71 -0
- linti/rules/semantic/item_skip_rule.py +80 -0
- linti/rules/semantic/odbc_open_parameter_rule.py +114 -0
- linti/rules/semantic/process_call_literal_rule.py +79 -0
- linti/rules/semantic/process_quit_rule.py +131 -0
- linti/rules/semantic/readonly_parameter_variable_rule.py +110 -0
- linti/semantic/type_inference.py +103 -0
- linti-0.4.2.dist-info/METADATA +323 -0
- linti-0.4.2.dist-info/RECORD +59 -0
- linti-0.4.2.dist-info/WHEEL +5 -0
- linti-0.4.2.dist-info/entry_points.txt +2 -0
- linti-0.4.2.dist-info/licenses/LICENSE +202 -0
- linti-0.4.2.dist-info/top_level.txt +1 -0
linti/__init__.py
ADDED
linti/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for linti."""
|
linti/cli/auto_fixer.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Auto-fixing functionality for TM1 linter."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from linti.lexer.lexer import Lexer
|
|
7
|
+
from linti.linter.lint_context import LintContext
|
|
8
|
+
from linti.linter.lint_issue import LintIssue
|
|
9
|
+
from linti.linter.linter import Linter
|
|
10
|
+
|
|
11
|
+
MAX_AUTO_FIX_PASSES = 10
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def apply_fixes(code: str, issues: list[LintIssue]) -> tuple[str, int]:
|
|
15
|
+
"""
|
|
16
|
+
Apply all fixable issues to code.
|
|
17
|
+
|
|
18
|
+
Replaces text at positions specified by each issue's Fix object.
|
|
19
|
+
Works with any rule that provides a Fix — no rule-specific logic needed.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
code: The original source code
|
|
23
|
+
issues: List of LintIssue objects (only those with a fix are applied)
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Tuple of (fixed_code, num_fixes_applied)
|
|
27
|
+
"""
|
|
28
|
+
fixable = [issue for issue in issues if issue.fix is not None]
|
|
29
|
+
|
|
30
|
+
if not fixable:
|
|
31
|
+
return code, 0
|
|
32
|
+
|
|
33
|
+
# Sort fixes from back to front.
|
|
34
|
+
# At the same position, apply replacements before insertions.
|
|
35
|
+
fixable.sort(key=lambda i: (-i.fix.position, len(i.fix.old_value) == 0))
|
|
36
|
+
|
|
37
|
+
fixed_code = code
|
|
38
|
+
for issue in fixable:
|
|
39
|
+
fix = issue.fix
|
|
40
|
+
start = fix.position
|
|
41
|
+
end = start + len(fix.old_value)
|
|
42
|
+
|
|
43
|
+
# Verify the old value is at the expected position
|
|
44
|
+
if fixed_code[start:end] == fix.old_value:
|
|
45
|
+
fixed_code = fixed_code[:start] + fix.new_value + fixed_code[end:]
|
|
46
|
+
|
|
47
|
+
return fixed_code, len(fixable)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def collect_fixable_issues(
|
|
51
|
+
code: str, linter: Linter, lint_context: Optional[LintContext] = None
|
|
52
|
+
) -> list[LintIssue]:
|
|
53
|
+
"""
|
|
54
|
+
Lint code and return only the fixable issues.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
code: The TI code to analyze
|
|
58
|
+
linter: Configured linter instance
|
|
59
|
+
lint_context: Optional LintContext
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of LintIssue objects that have a Fix
|
|
63
|
+
"""
|
|
64
|
+
tokens = Lexer(code).tokenize()
|
|
65
|
+
issues = linter.lint(tokens, lint_context)
|
|
66
|
+
return [issue for issue in issues if issue.fix is not None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_fixes_iteratively(
|
|
70
|
+
code: str,
|
|
71
|
+
linter: Linter,
|
|
72
|
+
lint_context: Optional[LintContext] = None,
|
|
73
|
+
max_passes: int = MAX_AUTO_FIX_PASSES,
|
|
74
|
+
) -> tuple[str, int]:
|
|
75
|
+
"""Apply fixable issues repeatedly until no more fixes remain.
|
|
76
|
+
|
|
77
|
+
This is needed when one fix creates the structure required for later fixes,
|
|
78
|
+
for example splitting statements onto new lines before indentation can be
|
|
79
|
+
computed correctly.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
code: The original source code
|
|
83
|
+
linter: Configured linter instance
|
|
84
|
+
lint_context: Optional lint context
|
|
85
|
+
max_passes: Safety limit to avoid infinite fix loops
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (fixed_code, total_num_fixes_applied)
|
|
89
|
+
"""
|
|
90
|
+
fixed_code = code
|
|
91
|
+
total_fixes = 0
|
|
92
|
+
|
|
93
|
+
for _ in range(max_passes):
|
|
94
|
+
issues = collect_fixable_issues(fixed_code, linter, lint_context)
|
|
95
|
+
if not issues:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
next_code, num_fixes = apply_fixes(fixed_code, issues)
|
|
99
|
+
if num_fixes == 0 or next_code == fixed_code:
|
|
100
|
+
break
|
|
101
|
+
|
|
102
|
+
fixed_code = next_code
|
|
103
|
+
total_fixes += num_fixes
|
|
104
|
+
|
|
105
|
+
return fixed_code, total_fixes
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def auto_fix_ti_file(
|
|
109
|
+
file_path: Path, linter: Linter, lint_context: Optional[LintContext] = None
|
|
110
|
+
) -> int:
|
|
111
|
+
"""
|
|
112
|
+
Auto-fix a TI file in place.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_path: Path to the TI file
|
|
116
|
+
linter: Configured linter instance
|
|
117
|
+
lint_context: Optional LintContext
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Number of fixes applied
|
|
121
|
+
"""
|
|
122
|
+
with open(file_path, "r") as f:
|
|
123
|
+
original_code = f.read()
|
|
124
|
+
|
|
125
|
+
fixed_code, num_fixes = apply_fixes_iteratively(original_code, linter, lint_context)
|
|
126
|
+
|
|
127
|
+
if num_fixes > 0:
|
|
128
|
+
with open(file_path, "w") as f:
|
|
129
|
+
f.write(fixed_code)
|
|
130
|
+
|
|
131
|
+
return num_fixes
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def auto_fix_yaml_procedures(
|
|
135
|
+
file_path: Path, process, linter: Linter
|
|
136
|
+
) -> dict[str, int]:
|
|
137
|
+
"""
|
|
138
|
+
Auto-fix procedures in a YAML ProcessObject file.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
file_path: Path to the YAML file
|
|
142
|
+
process: TM1Process with procedures
|
|
143
|
+
linter: Configured linter instance
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Dict mapping procedure names to number of fixes applied
|
|
147
|
+
"""
|
|
148
|
+
from linti.loader.base import extract_procedures
|
|
149
|
+
|
|
150
|
+
procedures = extract_procedures(process)
|
|
151
|
+
fixes_by_proc = {}
|
|
152
|
+
|
|
153
|
+
# Read original YAML content
|
|
154
|
+
with open(file_path, "r") as f:
|
|
155
|
+
yaml_lines = f.readlines()
|
|
156
|
+
|
|
157
|
+
indent_prefix = " " * process.content_indent
|
|
158
|
+
|
|
159
|
+
# Process each procedure
|
|
160
|
+
for proc_name, proc_info in procedures.items():
|
|
161
|
+
code = proc_info.code
|
|
162
|
+
yaml_start_line = proc_info.source_line
|
|
163
|
+
|
|
164
|
+
# Create context for this procedure
|
|
165
|
+
lint_ctx = LintContext(
|
|
166
|
+
block=proc_name,
|
|
167
|
+
parameters=process.parameters,
|
|
168
|
+
parameter_lines=process.parameter_lines,
|
|
169
|
+
variables=process.variables,
|
|
170
|
+
variable_lines=process.variable_lines,
|
|
171
|
+
block_start_line=proc_info.source_line,
|
|
172
|
+
block_end_line=proc_info.source_end_line,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Apply fixes
|
|
176
|
+
fixed_code, num_fixes = apply_fixes_iteratively(code, linter, lint_ctx)
|
|
177
|
+
|
|
178
|
+
if num_fixes > 0:
|
|
179
|
+
fixes_by_proc[proc_name] = num_fixes
|
|
180
|
+
|
|
181
|
+
# Replace the procedure content in YAML
|
|
182
|
+
# yaml_start_line points to where the procedure content starts (after "ProcedureName: |-")
|
|
183
|
+
# We need to find where the content ends (next line at lower indent or end of file)
|
|
184
|
+
|
|
185
|
+
content_start = yaml_start_line # This is 1-based line number
|
|
186
|
+
content_start_idx = content_start - 1 # Convert to 0-based index
|
|
187
|
+
|
|
188
|
+
# Find end of content (next line not indented at the content level or end of file)
|
|
189
|
+
content_end_idx = content_start_idx
|
|
190
|
+
for j in range(content_start_idx, len(yaml_lines)):
|
|
191
|
+
line = yaml_lines[j]
|
|
192
|
+
# If line has content and is not indented at (or beyond) the content level, we've hit the end
|
|
193
|
+
if line.strip() and not line.startswith(indent_prefix):
|
|
194
|
+
content_end_idx = j
|
|
195
|
+
break
|
|
196
|
+
else:
|
|
197
|
+
content_end_idx = len(yaml_lines)
|
|
198
|
+
|
|
199
|
+
# Replace the content with fixed code
|
|
200
|
+
fixed_lines = []
|
|
201
|
+
for line in fixed_code.split("\n"):
|
|
202
|
+
if not line and not fixed_lines:
|
|
203
|
+
# Skip leading empty lines
|
|
204
|
+
continue
|
|
205
|
+
if line:
|
|
206
|
+
fixed_lines.append(f"{indent_prefix}{line}\n")
|
|
207
|
+
else:
|
|
208
|
+
# Preserve blank lines without adding trailing spaces
|
|
209
|
+
fixed_lines.append("\n")
|
|
210
|
+
|
|
211
|
+
yaml_lines[content_start_idx:content_end_idx] = fixed_lines
|
|
212
|
+
|
|
213
|
+
# Write back the modified YAML
|
|
214
|
+
if fixes_by_proc:
|
|
215
|
+
with open(file_path, "w") as f:
|
|
216
|
+
f.writelines(yaml_lines)
|
|
217
|
+
|
|
218
|
+
return fixes_by_proc
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Configuration loading with CLI output."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from linti.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_config(file_path: Path, config_override: Optional[Path] = None) -> Config:
|
|
12
|
+
"""
|
|
13
|
+
Load configuration from file or discover default.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
file_path: Path to a TI file, YAML file, or directory being linted
|
|
17
|
+
config_override: Optional explicit config file path
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Loaded Config object
|
|
21
|
+
"""
|
|
22
|
+
if config_override:
|
|
23
|
+
cfg = Config.load_from_file(config_override)
|
|
24
|
+
typer.echo(f"Loaded config from: {config_override}")
|
|
25
|
+
return cfg
|
|
26
|
+
|
|
27
|
+
if file_path.is_dir():
|
|
28
|
+
# For directories, walk upward from the directory itself
|
|
29
|
+
target_sentinel = file_path / "_dummy"
|
|
30
|
+
cfg = Config.find_and_load(target_sentinel)
|
|
31
|
+
# Check if a config was actually found (walk upward)
|
|
32
|
+
directory = file_path.resolve()
|
|
33
|
+
while True:
|
|
34
|
+
config_file = directory / "linti.yaml"
|
|
35
|
+
if config_file.exists():
|
|
36
|
+
typer.echo(f"Loaded config from: {config_file}")
|
|
37
|
+
break
|
|
38
|
+
parent = directory.parent
|
|
39
|
+
if parent == directory:
|
|
40
|
+
break
|
|
41
|
+
directory = parent
|
|
42
|
+
return cfg
|
|
43
|
+
|
|
44
|
+
# For files, walk upward from the file's directory
|
|
45
|
+
cfg = Config.find_and_load(file_path)
|
|
46
|
+
directory = file_path.parent.resolve()
|
|
47
|
+
while True:
|
|
48
|
+
config_file = directory / "linti.yaml"
|
|
49
|
+
if config_file.exists():
|
|
50
|
+
typer.echo(f"Loaded config from: {config_file}")
|
|
51
|
+
break
|
|
52
|
+
parent = directory.parent
|
|
53
|
+
if parent == directory:
|
|
54
|
+
break
|
|
55
|
+
directory = parent
|
|
56
|
+
return cfg
|
linti/cli/file_linter.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""File linting operations for TI files, YAML files, and directories."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from linti.cli.auto_fixer import (
|
|
9
|
+
auto_fix_ti_file,
|
|
10
|
+
auto_fix_yaml_procedures,
|
|
11
|
+
)
|
|
12
|
+
from linti.cli.config_loader import load_config
|
|
13
|
+
from linti.cli.issue_reporter import (
|
|
14
|
+
report_directory_issues,
|
|
15
|
+
report_issues,
|
|
16
|
+
report_yaml_issues,
|
|
17
|
+
)
|
|
18
|
+
from linti.lexer.lexer import Lexer
|
|
19
|
+
from linti.linter.lint_context import LintContext
|
|
20
|
+
from linti.linter.linter import Linter
|
|
21
|
+
from linti.loader.base import extract_procedures
|
|
22
|
+
from linti.loader.yaml_loader import load_yaml_process
|
|
23
|
+
from linti.parser.ast import UnknownStatement
|
|
24
|
+
from linti.parser.parser import Parser
|
|
25
|
+
from linti.rules.rule_factory import create_rules
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def analyze_and_lint_code(
|
|
29
|
+
code: str,
|
|
30
|
+
linter: Linter,
|
|
31
|
+
show_tokens: bool,
|
|
32
|
+
show_ast: bool,
|
|
33
|
+
context_label: Optional[str] = None,
|
|
34
|
+
lint_context: Optional[LintContext] = None,
|
|
35
|
+
) -> list:
|
|
36
|
+
"""
|
|
37
|
+
Tokenize, parse, and lint code.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
code: The TI code to analyze
|
|
41
|
+
linter: Configured linter instance
|
|
42
|
+
show_tokens: Whether to display tokens
|
|
43
|
+
show_ast: Whether to display AST
|
|
44
|
+
context_label: Optional context label for output (e.g., procedure name)
|
|
45
|
+
lint_context: Optional LintContext with block, parameters, variables
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of linting issues
|
|
49
|
+
"""
|
|
50
|
+
lexer = Lexer(code)
|
|
51
|
+
tokens = lexer.tokenize()
|
|
52
|
+
|
|
53
|
+
if show_tokens:
|
|
54
|
+
label = f" ({context_label})" if context_label else ""
|
|
55
|
+
typer.echo(f"\nTokens{label}:")
|
|
56
|
+
for token in tokens:
|
|
57
|
+
if token.type.name not in ["WHITESPACE", "NEWLINE"]:
|
|
58
|
+
typer.echo(f"{token.type.name:15} {token.value!r}")
|
|
59
|
+
|
|
60
|
+
# Build AST
|
|
61
|
+
parser = Parser(tokens)
|
|
62
|
+
ast = parser.parse()
|
|
63
|
+
|
|
64
|
+
if show_ast:
|
|
65
|
+
label = f" ({context_label})" if context_label else ""
|
|
66
|
+
typer.echo(f"\nAST{label}:")
|
|
67
|
+
typer.echo(f"Program with {len(ast.statements)} statements:")
|
|
68
|
+
for i, stmt in enumerate(ast.statements, 1):
|
|
69
|
+
stmt_name = stmt.__class__.__name__
|
|
70
|
+
if isinstance(stmt, UnknownStatement):
|
|
71
|
+
typer.echo(
|
|
72
|
+
f" {i}. {stmt_name} (error: {stmt.error_message})",
|
|
73
|
+
err=True,
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
typer.echo(f" {i}. {stmt_name}")
|
|
77
|
+
|
|
78
|
+
# Run linter and return issues
|
|
79
|
+
return linter.lint(tokens, lint_context, ast=ast)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def lint_ti_file(
|
|
83
|
+
file_path: Path,
|
|
84
|
+
show_tokens: bool,
|
|
85
|
+
show_ast: bool,
|
|
86
|
+
config: Optional[Path],
|
|
87
|
+
auto_fix: bool = False,
|
|
88
|
+
select: Optional[str] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Lint a TI (.ti) file.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
file_path: Path to the TI file
|
|
95
|
+
show_tokens: Whether to display tokens
|
|
96
|
+
show_ast: Whether to display AST
|
|
97
|
+
config: Optional config file path
|
|
98
|
+
auto_fix: Whether to automatically fix supported auto-fixable issues
|
|
99
|
+
select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
typer.Exit: With appropriate exit code
|
|
103
|
+
"""
|
|
104
|
+
cfg = load_config(file_path, config)
|
|
105
|
+
token_rules, statement_rules = create_rules(cfg, select=select)
|
|
106
|
+
linter = Linter(rules=token_rules, statement_rules=statement_rules)
|
|
107
|
+
|
|
108
|
+
if auto_fix:
|
|
109
|
+
# Apply auto-fixes
|
|
110
|
+
num_fixes = auto_fix_ti_file(file_path, linter, lint_context=None)
|
|
111
|
+
if num_fixes > 0:
|
|
112
|
+
typer.echo(f"Fixed {num_fixes} auto-fixable issue(s) in {file_path}")
|
|
113
|
+
else:
|
|
114
|
+
typer.echo(f"No auto-fixable issues to fix in {file_path}")
|
|
115
|
+
|
|
116
|
+
# Read the file (potentially after fixes have been applied)
|
|
117
|
+
with open(file_path, "r") as f:
|
|
118
|
+
ti_code = f.read()
|
|
119
|
+
|
|
120
|
+
issues = analyze_and_lint_code(
|
|
121
|
+
ti_code,
|
|
122
|
+
linter,
|
|
123
|
+
show_tokens,
|
|
124
|
+
show_ast,
|
|
125
|
+
context_label=None,
|
|
126
|
+
lint_context=LintContext(block="prolog", process_name=file_path.stem),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
raise typer.Exit(code=report_issues(file_path, issues))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def lint_yaml_file(
|
|
133
|
+
file_path: Path,
|
|
134
|
+
show_tokens: bool,
|
|
135
|
+
show_ast: bool,
|
|
136
|
+
config: Optional[Path],
|
|
137
|
+
linter: Optional[Linter] = None,
|
|
138
|
+
return_issues: bool = False,
|
|
139
|
+
silent_errors: bool = False,
|
|
140
|
+
auto_fix: bool = False,
|
|
141
|
+
select: Optional[str] = None,
|
|
142
|
+
) -> Optional[list]:
|
|
143
|
+
"""
|
|
144
|
+
Lint a YAML ProcessObject file (TM1py format).
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
file_path: Path to the YAML file
|
|
148
|
+
show_tokens: Whether to display tokens
|
|
149
|
+
show_ast: Whether to display AST
|
|
150
|
+
config: Optional config file path
|
|
151
|
+
linter: Optional pre-created Linter instance (if None, will be created)
|
|
152
|
+
return_issues: If True, return issues instead of reporting and exiting
|
|
153
|
+
silent_errors: If True, return None on error instead of raising Exit
|
|
154
|
+
auto_fix: Whether to automatically fix supported auto-fixable issues
|
|
155
|
+
select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
List of (proc_name, issue, yaml_line) tuples if return_issues=True, else None
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
typer.Exit: With appropriate exit code (unless return_issues=True)
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
process = load_yaml_process(file_path)
|
|
165
|
+
except Exception as e:
|
|
166
|
+
if silent_errors:
|
|
167
|
+
return None
|
|
168
|
+
typer.echo(f"Error loading YAML file: {e}", err=True)
|
|
169
|
+
raise typer.Exit(code=1)
|
|
170
|
+
|
|
171
|
+
typer.echo(f"Process: {process.name}")
|
|
172
|
+
|
|
173
|
+
# Use provided linter or create a new one
|
|
174
|
+
if linter is None:
|
|
175
|
+
cfg = load_config(file_path, config)
|
|
176
|
+
token_rules, statement_rules = create_rules(cfg, select=select)
|
|
177
|
+
linter = Linter(rules=token_rules, statement_rules=statement_rules)
|
|
178
|
+
|
|
179
|
+
# Apply auto-fixes if requested
|
|
180
|
+
if auto_fix:
|
|
181
|
+
fixes_by_proc = auto_fix_yaml_procedures(file_path, process, linter)
|
|
182
|
+
if fixes_by_proc:
|
|
183
|
+
total_fixes = sum(fixes_by_proc.values())
|
|
184
|
+
typer.echo(f"Fixed {total_fixes} auto-fixable issue(s) in {file_path}:")
|
|
185
|
+
for proc_name, count in fixes_by_proc.items():
|
|
186
|
+
typer.echo(f" - {proc_name}: {count} fix(es)")
|
|
187
|
+
# Reload process after fixes
|
|
188
|
+
process = load_yaml_process(file_path)
|
|
189
|
+
else:
|
|
190
|
+
typer.echo(f"No auto-fixable issues to fix in {file_path}")
|
|
191
|
+
|
|
192
|
+
# Extract and lint each procedure
|
|
193
|
+
procedures = extract_procedures(process)
|
|
194
|
+
all_issues = []
|
|
195
|
+
|
|
196
|
+
for proc_name, proc_info in procedures.items():
|
|
197
|
+
lint_ctx = LintContext(
|
|
198
|
+
block=proc_name,
|
|
199
|
+
process_name=process.name,
|
|
200
|
+
parameters=process.parameters,
|
|
201
|
+
parameter_lines=process.parameter_lines,
|
|
202
|
+
variables=process.variables,
|
|
203
|
+
variable_lines=process.variable_lines,
|
|
204
|
+
block_start_line=proc_info.source_line,
|
|
205
|
+
block_end_line=proc_info.source_end_line,
|
|
206
|
+
)
|
|
207
|
+
issues = analyze_and_lint_code(
|
|
208
|
+
proc_info.code,
|
|
209
|
+
linter,
|
|
210
|
+
show_tokens,
|
|
211
|
+
show_ast,
|
|
212
|
+
context_label=proc_name,
|
|
213
|
+
lint_context=lint_ctx,
|
|
214
|
+
)
|
|
215
|
+
for issue in issues:
|
|
216
|
+
# Adjust line number to source file location
|
|
217
|
+
adjusted_issue = (proc_name, issue, proc_info.source_line)
|
|
218
|
+
all_issues.append(adjusted_issue)
|
|
219
|
+
|
|
220
|
+
if return_issues:
|
|
221
|
+
return all_issues
|
|
222
|
+
else:
|
|
223
|
+
raise typer.Exit(code=report_yaml_issues(file_path, all_issues))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def lint_directory(
|
|
227
|
+
directory: Path,
|
|
228
|
+
show_tokens: bool,
|
|
229
|
+
show_ast: bool,
|
|
230
|
+
config: Optional[Path],
|
|
231
|
+
auto_fix: bool = False,
|
|
232
|
+
select: Optional[str] = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""
|
|
235
|
+
Lint all YAML process files in a directory and all subdirectories.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
directory: Path to the directory
|
|
239
|
+
show_tokens: Whether to display tokens
|
|
240
|
+
show_ast: Whether to display AST
|
|
241
|
+
config: Optional config file path
|
|
242
|
+
auto_fix: Whether to automatically fix supported auto-fixable issues
|
|
243
|
+
select: Optional rule IDs/patterns to select (e.g., "F110" or "F,N1")
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
typer.Exit: With appropriate exit code
|
|
247
|
+
"""
|
|
248
|
+
yaml_files = sorted({*directory.rglob("*.yaml"), *directory.rglob("*.yml")})
|
|
249
|
+
|
|
250
|
+
if not yaml_files:
|
|
251
|
+
typer.echo(f"No YAML files found in {directory}", err=False)
|
|
252
|
+
raise typer.Exit(code=0)
|
|
253
|
+
|
|
254
|
+
# Load configuration once for the directory
|
|
255
|
+
cfg = load_config(directory, config)
|
|
256
|
+
|
|
257
|
+
# Lint each YAML file and collect all issues
|
|
258
|
+
all_file_issues = [] # List of (file_path, proc_name, issue, yaml_line) tuples
|
|
259
|
+
|
|
260
|
+
for yaml_file in yaml_files:
|
|
261
|
+
# Create fresh rule instances per file to avoid cross-file state contamination
|
|
262
|
+
token_rules, statement_rules = create_rules(cfg, select=select)
|
|
263
|
+
linter = Linter(rules=token_rules, statement_rules=statement_rules)
|
|
264
|
+
|
|
265
|
+
file_issues = lint_yaml_file(
|
|
266
|
+
yaml_file,
|
|
267
|
+
show_tokens,
|
|
268
|
+
show_ast,
|
|
269
|
+
config,
|
|
270
|
+
linter,
|
|
271
|
+
return_issues=True,
|
|
272
|
+
silent_errors=True,
|
|
273
|
+
auto_fix=auto_fix,
|
|
274
|
+
select=select,
|
|
275
|
+
)
|
|
276
|
+
if file_issues:
|
|
277
|
+
for proc_name, issue, yaml_line in file_issues:
|
|
278
|
+
all_file_issues.append((yaml_file, proc_name, issue, yaml_line))
|
|
279
|
+
|
|
280
|
+
raise typer.Exit(code=report_directory_issues(directory, all_file_issues))
|