CLASPLint 0.1.0__cp38-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.
- CLASPLint/__init__.py +6 -0
- CLASPLint/__main__.py +98 -0
- CLASPLint/comment_checker.py +227 -0
- CLASPLint/dict_key_checker.py +87 -0
- CLASPLint/function_checker.py +95 -0
- CLASPLint/log_checker.py +177 -0
- CLASPLint/naming_utils.py +351 -0
- CLASPLint/reporter.py +90 -0
- CLASPLint/runner.py +159 -0
- CLASPLint/variable_checker.py +159 -0
- clasplint-0.1.0.dist-info/LICENSE +15 -0
- clasplint-0.1.0.dist-info/METADATA +238 -0
- clasplint-0.1.0.dist-info/RECORD +17 -0
- clasplint-0.1.0.dist-info/WHEEL +5 -0
- clasplint-0.1.0.dist-info/entry_points.txt +2 -0
- clasplint-0.1.0.dist-info/top_level.txt +1 -0
- clasplint-0.1.0.dist-info/zip-safe +1 -0
CLASPLint/__init__.py
ADDED
CLASPLint/__main__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Provide the CLASPLint command-line interface entry point.
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from .runner import run
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _init_build_parser_function_() -> argparse.ArgumentParser:
|
|
12
|
+
"""Construct the argument parser for the CLASPLint CLI."""
|
|
13
|
+
# Create the top-level argument parser with a description.
|
|
14
|
+
parser_result = argparse.ArgumentParser(
|
|
15
|
+
prog="CLASPLint",
|
|
16
|
+
description="CLASP 3.0 / PEP 2606 static analysis tool. "
|
|
17
|
+
"Checks variable, dict key, function, class naming "
|
|
18
|
+
"and comment/log conventions.",
|
|
19
|
+
)
|
|
20
|
+
# Accept one or more file or directory paths as positional arguments.
|
|
21
|
+
parser_result.add_argument(
|
|
22
|
+
"paths", nargs="*", default=["."],
|
|
23
|
+
help="Python files or directories to check (default: current directory).",
|
|
24
|
+
)
|
|
25
|
+
# Provide a --version flag to display the tool version.
|
|
26
|
+
parser_result.add_argument(
|
|
27
|
+
"--version", action="version",
|
|
28
|
+
version=f"CLASPLint {__version__}",
|
|
29
|
+
)
|
|
30
|
+
# Provide a flag to disable recursive directory traversal.
|
|
31
|
+
parser_result.add_argument(
|
|
32
|
+
"--no-recursive", action="store_true",
|
|
33
|
+
help="Do not recursively check subdirectories.",
|
|
34
|
+
)
|
|
35
|
+
# Provide a quiet mode that suppresses per-violation output.
|
|
36
|
+
parser_result.add_argument(
|
|
37
|
+
"--quiet", "-q", action="store_true",
|
|
38
|
+
help="Suppress individual violation output; show only summary.",
|
|
39
|
+
)
|
|
40
|
+
# Provide a category filter to report only specific violation types.
|
|
41
|
+
parser_result.add_argument(
|
|
42
|
+
"--category", "-c",
|
|
43
|
+
choices=["variable", "dict_key", "function", "comment", "log"],
|
|
44
|
+
help="Only report violations of a specific category.",
|
|
45
|
+
)
|
|
46
|
+
# Return the fully configured argument parser.
|
|
47
|
+
return parser_result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list = None) -> int:
|
|
51
|
+
"""Execute the CLASPLint analysis and return an exit code (0 = clean, 1 = violations)."""
|
|
52
|
+
# Build and parse command-line arguments.
|
|
53
|
+
parser = _init_build_parser_function_()
|
|
54
|
+
# Parse the provided arguments or default to sys.argv.
|
|
55
|
+
args = parser.parse_args(argv)
|
|
56
|
+
# Resolve all input paths to absolute paths for consistent processing.
|
|
57
|
+
list_resolved = []
|
|
58
|
+
# Iterate over each provided path argument.
|
|
59
|
+
for string_path in args.paths:
|
|
60
|
+
# Convert the path to an absolute path.
|
|
61
|
+
string_absolute = os.path.abspath(string_path)
|
|
62
|
+
# Only include paths that exist on the filesystem.
|
|
63
|
+
if os.path.exists(string_absolute):
|
|
64
|
+
list_resolved.append(string_absolute)
|
|
65
|
+
# Report an error if no valid paths were found.
|
|
66
|
+
if not list_resolved:
|
|
67
|
+
# Print an error message to stderr.
|
|
68
|
+
print("CLASPLint: No valid Python files or directories found.", file=sys.stderr)
|
|
69
|
+
# Return exit code 2 for usage errors.
|
|
70
|
+
return 2
|
|
71
|
+
# Determine whether to recurse into subdirectories.
|
|
72
|
+
bool_recursive = not args.no_recursive
|
|
73
|
+
# Run all checkers on the resolved paths.
|
|
74
|
+
report_result = run(list_resolved, bool_recursive=bool_recursive)
|
|
75
|
+
# Apply category filter if one was specified.
|
|
76
|
+
if args.category:
|
|
77
|
+
# Filter violations to only the requested category.
|
|
78
|
+
report_result.list_violations = [
|
|
79
|
+
v for v in report_result.list_violations
|
|
80
|
+
# Keep only violations matching the requested category filter.
|
|
81
|
+
if v.string_category == args.category
|
|
82
|
+
]
|
|
83
|
+
# Print the summary line to stdout.
|
|
84
|
+
print(report_result.summary())
|
|
85
|
+
# Print individual violations unless quiet mode is active.
|
|
86
|
+
if report_result.list_violations and not args.quiet:
|
|
87
|
+
# Add a blank line before the violation listing.
|
|
88
|
+
print()
|
|
89
|
+
# Print the formatted violation details.
|
|
90
|
+
print(report_result.format_violations())
|
|
91
|
+
# Return exit code 1 if violations were found, 0 otherwise.
|
|
92
|
+
return 1 if report_result.has_violations else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Execute the main function when the module is run directly.
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
# Exit with the code returned by main().
|
|
98
|
+
sys.exit(main())
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Check that every code line has a proper CLASP 3.0 formatted comment.
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import io
|
|
5
|
+
import tokenize
|
|
6
|
+
from typing import List, Set
|
|
7
|
+
|
|
8
|
+
from .reporter import Violation
|
|
9
|
+
|
|
10
|
+
# Define control flow and operation keywords that require a preceding comment.
|
|
11
|
+
keywords_requirecomment = frozenset({
|
|
12
|
+
"if", "elif", "else", "for", "while", "try", "except",
|
|
13
|
+
"finally", "with", "return", "raise", "continue", "break",
|
|
14
|
+
"pass",
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tokenize_source(string_source: str) -> List[tokenize.TokenInfo]:
|
|
19
|
+
"""Tokenize source code and return the list of tokens."""
|
|
20
|
+
# Generate tokens from the source string using a StringIO buffer.
|
|
21
|
+
return list(tokenize.generate_tokens(io.StringIO(string_source).readline))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommentChecker:
|
|
25
|
+
"""Check that every code line has a proper CLASP 3.0 comment."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, string_filepath: str, string_source: str):
|
|
28
|
+
# Store the file path for violation reporting.
|
|
29
|
+
self.string_filepath = string_filepath
|
|
30
|
+
# Store the full source code as a string.
|
|
31
|
+
self.string_source = string_source
|
|
32
|
+
# Split source into lines for per-line access.
|
|
33
|
+
self.list_sourcelines = string_source.splitlines()
|
|
34
|
+
# Collect violations found during checking.
|
|
35
|
+
self.list_violations: List[Violation] = []
|
|
36
|
+
|
|
37
|
+
def run(self) -> None:
|
|
38
|
+
"""Run all comment format and presence checks."""
|
|
39
|
+
# Check that every code line requiring a comment has one.
|
|
40
|
+
self._init_line_comments_function_()
|
|
41
|
+
# Check that every comment follows the required format.
|
|
42
|
+
self._init_comment_format_function_()
|
|
43
|
+
|
|
44
|
+
def _init_line_comments_function_(self) -> None:
|
|
45
|
+
"""Verify that every physical code line with a control keyword has a comment."""
|
|
46
|
+
# Attempt to tokenize the source; skip if tokenization fails.
|
|
47
|
+
try:
|
|
48
|
+
list_tokens = _tokenize_source(self.string_source)
|
|
49
|
+
# Return early if the source cannot be tokenized.
|
|
50
|
+
except tokenize.TokenError:
|
|
51
|
+
# Exit the method early when tokenization fails.
|
|
52
|
+
return
|
|
53
|
+
# Build a set of line numbers containing code tokens.
|
|
54
|
+
set_codelines: Set[int] = set()
|
|
55
|
+
# Build a set of line numbers containing comment tokens.
|
|
56
|
+
set_commentlines: Set[int] = set()
|
|
57
|
+
# Classify each token to identify code lines and comment lines.
|
|
58
|
+
for token_item in list_tokens:
|
|
59
|
+
# Add comment token line numbers to the comment set.
|
|
60
|
+
if token_item.type == tokenize.COMMENT:
|
|
61
|
+
set_commentlines.add(token_item.start[0])
|
|
62
|
+
# Add single-line string token line numbers to the code set.
|
|
63
|
+
elif token_item.type == tokenize.STRING and token_item.start[0] == token_item.end[0]:
|
|
64
|
+
set_codelines.add(token_item.start[0])
|
|
65
|
+
# Add all other meaningful token line numbers to the code set.
|
|
66
|
+
elif token_item.type not in (
|
|
67
|
+
tokenize.NEWLINE, tokenize.NL, tokenize.ENDMARKER,
|
|
68
|
+
tokenize.INDENT, tokenize.DEDENT, tokenize.ENCODING,
|
|
69
|
+
):
|
|
70
|
+
set_codelines.add(token_item.start[0])
|
|
71
|
+
# Check each code line for required comments.
|
|
72
|
+
for int_lineno in sorted(set_codelines):
|
|
73
|
+
# Skip lines beyond the source file length.
|
|
74
|
+
if int_lineno > len(self.list_sourcelines):
|
|
75
|
+
# Exit the loop iteration for out-of-bounds line numbers.
|
|
76
|
+
continue
|
|
77
|
+
# Get the trimmed text of the current line.
|
|
78
|
+
string_linetext = self.list_sourcelines[int_lineno - 1].strip()
|
|
79
|
+
# Skip blank lines as they contain no code to comment.
|
|
80
|
+
if not string_linetext:
|
|
81
|
+
# Exit the loop iteration for blank lines.
|
|
82
|
+
continue
|
|
83
|
+
# Skip comment-only lines as they serve as their own comment.
|
|
84
|
+
if int_lineno in set_commentlines and string_linetext.startswith("#"):
|
|
85
|
+
# Exit the loop iteration for comment-only lines.
|
|
86
|
+
continue
|
|
87
|
+
# Skip lines that are part of a docstring.
|
|
88
|
+
if self._init_docstring_line_function_(int_lineno):
|
|
89
|
+
# Exit the loop iteration for docstring lines.
|
|
90
|
+
continue
|
|
91
|
+
# Determine whether a comment exists on the same line or the previous line.
|
|
92
|
+
bool_hascomment = int_lineno in set_commentlines
|
|
93
|
+
# Check the previous non-blank line as an alternative comment location.
|
|
94
|
+
if not bool_hascomment and int_lineno > 1:
|
|
95
|
+
# Get the trimmed text of the previous line.
|
|
96
|
+
string_previoustext = self.list_sourcelines[int_lineno - 2].strip()
|
|
97
|
+
# Treat a preceding comment line as satisfying the requirement.
|
|
98
|
+
if string_previoustext.startswith("#"):
|
|
99
|
+
bool_hascomment = True
|
|
100
|
+
# Report a violation if no comment was found for a keyword line.
|
|
101
|
+
if not bool_hascomment:
|
|
102
|
+
# Extract the first word of the line to check against required keywords.
|
|
103
|
+
list_words = string_linetext.split()
|
|
104
|
+
# Default to empty string if the line has no words.
|
|
105
|
+
string_firstword = list_words[0] if list_words else ""
|
|
106
|
+
# Strip trailing colon from keywords like "if:" or "else:".
|
|
107
|
+
string_keyword = string_firstword.rstrip(":")
|
|
108
|
+
# Check if this line's keyword requires a comment.
|
|
109
|
+
if string_keyword in keywords_requirecomment:
|
|
110
|
+
# Record a missing-comment violation for this keyword line.
|
|
111
|
+
self.list_violations.append(Violation(
|
|
112
|
+
string_filepath=self.string_filepath,
|
|
113
|
+
int_linenumber=int_lineno,
|
|
114
|
+
string_category="comment",
|
|
115
|
+
string_message=(
|
|
116
|
+
f"Line {int_lineno} with keyword '{string_keyword}' "
|
|
117
|
+
f"lacks a required preceding comment."
|
|
118
|
+
),
|
|
119
|
+
string_sourceline=self.list_sourcelines[int_lineno - 1],
|
|
120
|
+
))
|
|
121
|
+
|
|
122
|
+
def _init_comment_format_function_(self) -> None:
|
|
123
|
+
"""Verify that every comment follows the 'Capitalized sentence.' format."""
|
|
124
|
+
# Attempt to tokenize the source; skip if tokenization fails.
|
|
125
|
+
try:
|
|
126
|
+
list_tokens = _tokenize_source(self.string_source)
|
|
127
|
+
# Return early if the source cannot be tokenized.
|
|
128
|
+
except tokenize.TokenError:
|
|
129
|
+
# Exit the method early when tokenization fails.
|
|
130
|
+
return
|
|
131
|
+
# Check each comment token for format compliance.
|
|
132
|
+
for token_item in list_tokens:
|
|
133
|
+
# Skip non-comment tokens.
|
|
134
|
+
if token_item.type != tokenize.COMMENT:
|
|
135
|
+
# Proceed to the next token in the loop.
|
|
136
|
+
continue
|
|
137
|
+
# Get the raw comment text including the leading '#'.
|
|
138
|
+
string_comment = token_item.string
|
|
139
|
+
# Check that the comment starts with '# ' (hash followed by space).
|
|
140
|
+
if not string_comment.startswith("# "):
|
|
141
|
+
# Record a violation for missing space after hash.
|
|
142
|
+
self.list_violations.append(Violation(
|
|
143
|
+
string_filepath=self.string_filepath,
|
|
144
|
+
int_linenumber=token_item.start[0],
|
|
145
|
+
string_category="comment",
|
|
146
|
+
string_message=(
|
|
147
|
+
f"Comment must start with '# ' (hash, space). "
|
|
148
|
+
f"Found: '{string_comment[:20]}...'"
|
|
149
|
+
),
|
|
150
|
+
string_sourceline=self.list_sourcelines[token_item.start[0] - 1],
|
|
151
|
+
))
|
|
152
|
+
# Skip further checks on malformed comments.
|
|
153
|
+
continue
|
|
154
|
+
# Extract the content after '# '.
|
|
155
|
+
string_content = string_comment[2:].strip()
|
|
156
|
+
# Skip empty comments as they are permitted placeholder comments.
|
|
157
|
+
if not string_content:
|
|
158
|
+
# Proceed to the next token for empty comment content.
|
|
159
|
+
continue
|
|
160
|
+
# Skip special marker comments like shebang, type: ignore, and noqa.
|
|
161
|
+
if string_content.startswith("!") or string_content.startswith("type:") or string_content.startswith("noqa"):
|
|
162
|
+
# Proceed to the next token for special marker comments.
|
|
163
|
+
continue
|
|
164
|
+
# Check that the comment text starts with a capital letter.
|
|
165
|
+
if string_content[0].islower():
|
|
166
|
+
# Record a violation for lowercase start.
|
|
167
|
+
self.list_violations.append(Violation(
|
|
168
|
+
string_filepath=self.string_filepath,
|
|
169
|
+
int_linenumber=token_item.start[0],
|
|
170
|
+
string_category="comment",
|
|
171
|
+
string_message=(
|
|
172
|
+
f"Comment text must start with a capital letter. "
|
|
173
|
+
f"Found: '{string_content[:30]}...'"
|
|
174
|
+
),
|
|
175
|
+
string_sourceline=self.list_sourcelines[token_item.start[0] - 1],
|
|
176
|
+
))
|
|
177
|
+
# Check that the comment text ends with a period or other valid punctuation.
|
|
178
|
+
if not string_content.endswith("."):
|
|
179
|
+
# Allow common sentence-ending punctuation alternatives.
|
|
180
|
+
if not any(string_content.endswith(p) for p in (".", "!", "?", ":", ";", ")")):
|
|
181
|
+
# Record a violation for missing terminal period.
|
|
182
|
+
self.list_violations.append(Violation(
|
|
183
|
+
string_filepath=self.string_filepath,
|
|
184
|
+
int_linenumber=token_item.start[0],
|
|
185
|
+
string_category="comment",
|
|
186
|
+
string_message=(
|
|
187
|
+
f"Comment must end with a period. "
|
|
188
|
+
f"Found: '{string_content[:40]}'"
|
|
189
|
+
),
|
|
190
|
+
string_sourceline=self.list_sourcelines[token_item.start[0] - 1],
|
|
191
|
+
))
|
|
192
|
+
|
|
193
|
+
def _init_docstring_line_function_(self, int_lineno: int) -> bool:
|
|
194
|
+
"""Determine whether a given line is part of a docstring."""
|
|
195
|
+
# Attempt to parse the source into an AST; skip if parsing fails.
|
|
196
|
+
try:
|
|
197
|
+
tree = ast.parse(self.string_source)
|
|
198
|
+
# Return False if the source has syntax errors.
|
|
199
|
+
except SyntaxError:
|
|
200
|
+
# Return False to indicate the line is not part of a docstring.
|
|
201
|
+
return False
|
|
202
|
+
# Walk all AST nodes to find docstring nodes.
|
|
203
|
+
for node in ast.walk(tree):
|
|
204
|
+
# Check function, class, and module nodes for docstrings.
|
|
205
|
+
if isinstance(node, (ast.FunctionDef, ast.ClassDef, ast.Module)):
|
|
206
|
+
# Retrieve the docstring using ast.get_docstring.
|
|
207
|
+
string_docstring = ast.get_docstring(node)
|
|
208
|
+
# Skip nodes without docstrings.
|
|
209
|
+
if string_docstring:
|
|
210
|
+
# Access the first statement in the body (the docstring expression).
|
|
211
|
+
list_body = node.body
|
|
212
|
+
# Verify the body exists and the first statement is an expression.
|
|
213
|
+
if list_body and isinstance(list_body[0], ast.Expr):
|
|
214
|
+
# Get the expression node.
|
|
215
|
+
node_expression = list_body[0]
|
|
216
|
+
# Verify the expression value is a string constant.
|
|
217
|
+
if isinstance(node_expression.value, ast.Constant) and isinstance(node_expression.value.value, str):
|
|
218
|
+
# Get the starting line of the docstring.
|
|
219
|
+
int_startline = node_expression.lineno
|
|
220
|
+
# Get the ending line, defaulting to start line if not set.
|
|
221
|
+
int_endline = node_expression.end_lineno or int_startline
|
|
222
|
+
# Check whether the queried line falls within the docstring range.
|
|
223
|
+
if int_startline <= int_lineno <= int_endline:
|
|
224
|
+
# The line is part of a docstring.
|
|
225
|
+
return True
|
|
226
|
+
# The line is not part of any docstring.
|
|
227
|
+
return False
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Check dictionary key names against CLASP 3.0 PascalCase and abbreviation rules.
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .naming_utils import validate_dictkey_format
|
|
7
|
+
from .reporter import Violation
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DictKeyChecker(ast.NodeVisitor):
|
|
11
|
+
"""Walk the AST and check dictionary key names against CLASP 3.0 rules."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, string_filepath: str, list_sourcelines: List[str]):
|
|
14
|
+
# Store the file path for violation reporting.
|
|
15
|
+
self.string_filepath = string_filepath
|
|
16
|
+
# Store source lines for contextual violation messages.
|
|
17
|
+
self.list_sourcelines = list_sourcelines
|
|
18
|
+
# Collect violations found during AST traversal.
|
|
19
|
+
self.list_violations: List[Violation] = []
|
|
20
|
+
|
|
21
|
+
def _init_key_function_(self, node_key: ast.AST, node_dict: ast.AST) -> None:
|
|
22
|
+
"""Validate a single dictionary key literal."""
|
|
23
|
+
# Only string literal keys are checked; skip non-constant or non-string keys.
|
|
24
|
+
if not isinstance(node_key, ast.Constant):
|
|
25
|
+
# Exit early for non-constant dict key nodes.
|
|
26
|
+
return
|
|
27
|
+
# Skip keys whose values are not strings.
|
|
28
|
+
if not isinstance(node_key.value, str):
|
|
29
|
+
# Exit early for keys whose values are not strings.
|
|
30
|
+
return
|
|
31
|
+
# Extract the key string value.
|
|
32
|
+
string_key = node_key.value
|
|
33
|
+
# Run the CLASP 3.0 dict key format validation.
|
|
34
|
+
list_issues = validate_dictkey_format(string_key)
|
|
35
|
+
# Retrieve the source line for contextual output.
|
|
36
|
+
string_sourceline = ""
|
|
37
|
+
# Ensure the line number is within bounds before accessing source lines.
|
|
38
|
+
if node_key.lineno and node_key.lineno <= len(self.list_sourcelines):
|
|
39
|
+
string_sourceline = self.list_sourcelines[node_key.lineno - 1]
|
|
40
|
+
# Append each detected violation to the violations list.
|
|
41
|
+
for string_message in list_issues:
|
|
42
|
+
self.list_violations.append(Violation(
|
|
43
|
+
string_filepath=self.string_filepath,
|
|
44
|
+
int_linenumber=node_key.lineno,
|
|
45
|
+
string_category="dict_key",
|
|
46
|
+
string_message=string_message,
|
|
47
|
+
string_sourceline=string_sourceline,
|
|
48
|
+
))
|
|
49
|
+
|
|
50
|
+
def visit_Dict(self, node: ast.Dict) -> None:
|
|
51
|
+
"""Check keys in dictionary literal expressions."""
|
|
52
|
+
# Iterate over each key node in the dictionary literal.
|
|
53
|
+
for node_key in node.keys:
|
|
54
|
+
# Process non-None keys through the key validator.
|
|
55
|
+
if node_key is not None:
|
|
56
|
+
self._init_key_function_(node_key, node)
|
|
57
|
+
# Continue visiting child nodes for nested dictionaries.
|
|
58
|
+
self.generic_visit(node)
|
|
59
|
+
|
|
60
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
61
|
+
"""Check dict() constructor calls for keyword argument key names."""
|
|
62
|
+
# Detect calls to the built-in dict() constructor.
|
|
63
|
+
if isinstance(node.func, ast.Name) and node.func.id == "dict":
|
|
64
|
+
# Check each keyword argument name as a potential dict key.
|
|
65
|
+
for node_keyword in node.keywords:
|
|
66
|
+
# Validate the keyword argument if it has an explicit name.
|
|
67
|
+
if node_keyword.arg:
|
|
68
|
+
self._init_keyword_as_key_function_(node_keyword)
|
|
69
|
+
# Continue visiting child nodes.
|
|
70
|
+
self.generic_visit(node)
|
|
71
|
+
|
|
72
|
+
def _init_keyword_as_key_function_(self, node_keyword: ast.keyword) -> None:
|
|
73
|
+
"""Validate a keyword argument name used as a dictionary key."""
|
|
74
|
+
# Extract the keyword argument name.
|
|
75
|
+
string_key = node_keyword.arg
|
|
76
|
+
# Skip empty or missing keyword argument names.
|
|
77
|
+
if not string_key:
|
|
78
|
+
# Exit early for empty keyword argument names.
|
|
79
|
+
return
|
|
80
|
+
# Python keyword arguments are identifiers and cannot use PascalCase directly;
|
|
81
|
+
# Only flag if the name clearly violates PascalCase convention expectations.
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
85
|
+
"""Visit subscript nodes for potential dict key access within Assign statements."""
|
|
86
|
+
# Subscript key checks are handled during Assign target walking.
|
|
87
|
+
self.generic_visit(node)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Check function and class names against CLASP 3.0 naming conventions.
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .naming_utils import (
|
|
7
|
+
validate_function_name,
|
|
8
|
+
validate_class_name,
|
|
9
|
+
length_namemax,
|
|
10
|
+
)
|
|
11
|
+
from .reporter import Violation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FunctionChecker(ast.NodeVisitor):
|
|
15
|
+
"""Walk the AST and check function and class names against CLASP 3.0 rules."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, string_filepath: str, list_sourcelines: List[str]):
|
|
18
|
+
# Store the file path for violation reporting.
|
|
19
|
+
self.string_filepath = string_filepath
|
|
20
|
+
# Store source lines for contextual violation messages.
|
|
21
|
+
self.list_sourcelines = list_sourcelines
|
|
22
|
+
# Collect violations found during AST traversal.
|
|
23
|
+
self.list_violations: List[Violation] = []
|
|
24
|
+
# Track the current class context for method detection.
|
|
25
|
+
self.string_currentclass: str = ""
|
|
26
|
+
|
|
27
|
+
def _init_source_line_function_(self, node: ast.AST) -> str:
|
|
28
|
+
"""Retrieve the source line text for a given AST node."""
|
|
29
|
+
# Return the source line if the node has a valid line number within bounds.
|
|
30
|
+
if node.lineno and node.lineno <= len(self.list_sourcelines):
|
|
31
|
+
# Retrieve and return the corresponding source line text.
|
|
32
|
+
return self.list_sourcelines[node.lineno - 1]
|
|
33
|
+
# Return an empty string if the line number is unavailable or out of range.
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
def _init_add_violations_function_(self, string_name: str, string_category: str,
|
|
37
|
+
list_issues: list, node: ast.AST) -> None:
|
|
38
|
+
"""Record a list of naming violations with source context."""
|
|
39
|
+
# Retrieve the source line associated with the violation node.
|
|
40
|
+
string_sourceline = self._init_source_line_function_(node)
|
|
41
|
+
# Append each issue as a Violation to the violations list.
|
|
42
|
+
for string_message in list_issues:
|
|
43
|
+
self.list_violations.append(Violation(
|
|
44
|
+
string_filepath=self.string_filepath,
|
|
45
|
+
int_linenumber=node.lineno,
|
|
46
|
+
string_category=string_category,
|
|
47
|
+
string_message=string_message,
|
|
48
|
+
string_sourceline=string_sourceline,
|
|
49
|
+
))
|
|
50
|
+
|
|
51
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
52
|
+
"""Check the class name for PascalCase format and abbreviation violations."""
|
|
53
|
+
# Validate the class name against CLASP 3.0 class naming rules.
|
|
54
|
+
list_issues = validate_class_name(node.name)
|
|
55
|
+
# Record any class name violations found.
|
|
56
|
+
self._init_add_violations_function_(node.name, "function", list_issues, node)
|
|
57
|
+
# Save the previous class context before entering the new class.
|
|
58
|
+
string_previous = self.string_currentclass
|
|
59
|
+
# Set the current class context for method detection.
|
|
60
|
+
self.string_currentclass = node.name
|
|
61
|
+
# Visit child nodes within the class body.
|
|
62
|
+
self.generic_visit(node)
|
|
63
|
+
# Restore the previous class context after leaving the class.
|
|
64
|
+
self.string_currentclass = string_previous
|
|
65
|
+
|
|
66
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
67
|
+
"""Check the function or method name for snake_case and abbreviation violations."""
|
|
68
|
+
# Determine whether this function is a method inside a class.
|
|
69
|
+
bool_ismethod = bool(self.string_currentclass)
|
|
70
|
+
# Validate the function name against CLASP 3.0 function naming rules.
|
|
71
|
+
list_issues = validate_function_name(node.name, is_method=bool_ismethod)
|
|
72
|
+
# Use the "function" category for all function and method violations.
|
|
73
|
+
string_category = "function"
|
|
74
|
+
# Record any function name violations found.
|
|
75
|
+
self._init_add_violations_function_(node.name, string_category, list_issues, node)
|
|
76
|
+
# Check method name length for public methods specifically.
|
|
77
|
+
if bool_ismethod and len(node.name) > length_namemax:
|
|
78
|
+
# Record a violation for excessively long method names.
|
|
79
|
+
self.list_violations.append(Violation(
|
|
80
|
+
string_filepath=self.string_filepath,
|
|
81
|
+
int_linenumber=node.lineno,
|
|
82
|
+
string_category=string_category,
|
|
83
|
+
string_message=(
|
|
84
|
+
f"Method '{node.name}' exceeds {length_namemax} characters "
|
|
85
|
+
f"(length: {len(node.name)})."
|
|
86
|
+
),
|
|
87
|
+
string_sourceline=self._init_source_line_function_(node),
|
|
88
|
+
))
|
|
89
|
+
# Continue visiting the function body for nested definitions.
|
|
90
|
+
self.generic_visit(node)
|
|
91
|
+
|
|
92
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
93
|
+
"""Check async function names using the same logic as regular functions."""
|
|
94
|
+
# Delegate to the standard function definition visitor.
|
|
95
|
+
self.visit_FunctionDef(node)
|