thailint 0.7.0__py3-none-any.whl → 0.8.0__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.
- src/cli.py +118 -1
- src/linter_config/loader.py +5 -4
- src/linters/dry/block_filter.py +11 -8
- src/linters/dry/cache.py +3 -2
- src/linters/dry/duplicate_storage.py +5 -4
- src/linters/dry/violation_generator.py +1 -1
- src/linters/method_property/__init__.py +49 -0
- src/linters/method_property/config.py +135 -0
- src/linters/method_property/linter.py +419 -0
- src/linters/method_property/python_analyzer.py +472 -0
- src/linters/method_property/violation_builder.py +116 -0
- {thailint-0.7.0.dist-info → thailint-0.8.0.dist-info}/METADATA +9 -3
- {thailint-0.7.0.dist-info → thailint-0.8.0.dist-info}/RECORD +16 -11
- {thailint-0.7.0.dist-info → thailint-0.8.0.dist-info}/WHEEL +0 -0
- {thailint-0.7.0.dist-info → thailint-0.8.0.dist-info}/entry_points.txt +0 -0
- {thailint-0.7.0.dist-info → thailint-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Python AST analysis for finding method-should-be-property candidates
|
|
3
|
+
|
|
4
|
+
Scope: Python method detection and property candidacy analysis
|
|
5
|
+
|
|
6
|
+
Overview: Provides PythonMethodAnalyzer class that traverses Python AST to find methods that
|
|
7
|
+
should be converted to @property decorators. Identifies simple accessor methods (returning
|
|
8
|
+
self._attribute), get_* prefixed methods (Java-style), and simple computed values. Implements
|
|
9
|
+
comprehensive exclusion rules to minimize false positives: methods with parameters, side
|
|
10
|
+
effects (assignments, loops, try/except), external function calls, decorators, complex bodies,
|
|
11
|
+
dunder methods, and async definitions. Returns structured data about each candidate including
|
|
12
|
+
method name, class name, line number, and column for violation reporting.
|
|
13
|
+
|
|
14
|
+
Dependencies: ast module for AST parsing and node types, config module for exclusion defaults
|
|
15
|
+
|
|
16
|
+
Exports: PythonMethodAnalyzer class, PropertyCandidate dataclass
|
|
17
|
+
|
|
18
|
+
Interfaces: find_property_candidates(tree) -> list[PropertyCandidate]
|
|
19
|
+
|
|
20
|
+
Implementation: AST walk pattern with comprehensive method body analysis and exclusion checks
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
|
|
26
|
+
from .config import DEFAULT_EXCLUDE_NAMES, DEFAULT_EXCLUDE_PREFIXES
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PropertyCandidate:
|
|
31
|
+
"""Represents a method that should be a property."""
|
|
32
|
+
|
|
33
|
+
method_name: str
|
|
34
|
+
class_name: str
|
|
35
|
+
line: int
|
|
36
|
+
column: int
|
|
37
|
+
is_get_prefix: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PythonMethodAnalyzer: # thailint: ignore[srp]
|
|
41
|
+
"""Analyzes Python AST to find methods that should be properties."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
max_body_statements: int = 3,
|
|
46
|
+
exclude_prefixes: tuple[str, ...] | None = None,
|
|
47
|
+
exclude_names: frozenset[str] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize the analyzer.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
max_body_statements: Maximum statements in method body
|
|
53
|
+
exclude_prefixes: Action verb prefixes to exclude (uses defaults if None)
|
|
54
|
+
exclude_names: Action verb names to exclude (uses defaults if None)
|
|
55
|
+
"""
|
|
56
|
+
self.max_body_statements = max_body_statements
|
|
57
|
+
self.exclude_prefixes = exclude_prefixes or DEFAULT_EXCLUDE_PREFIXES
|
|
58
|
+
self.exclude_names = exclude_names or DEFAULT_EXCLUDE_NAMES
|
|
59
|
+
self.candidates: list[PropertyCandidate] = []
|
|
60
|
+
self._visited_classes: set[int] = set()
|
|
61
|
+
|
|
62
|
+
def find_property_candidates(self, tree: ast.AST) -> list[PropertyCandidate]:
|
|
63
|
+
"""Find all methods that should be properties.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
tree: The AST to analyze
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of PropertyCandidate objects
|
|
70
|
+
"""
|
|
71
|
+
self.candidates = []
|
|
72
|
+
self._visit_classes(tree)
|
|
73
|
+
return self.candidates
|
|
74
|
+
|
|
75
|
+
def _visit_classes(self, tree: ast.AST) -> None:
|
|
76
|
+
"""Visit all top-level and nested classes in the AST.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tree: AST to traverse
|
|
80
|
+
"""
|
|
81
|
+
self._visited_classes.clear()
|
|
82
|
+
self._visit_node(tree)
|
|
83
|
+
|
|
84
|
+
def _visit_node(self, node: ast.AST) -> None:
|
|
85
|
+
"""Visit a node and its children for classes.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
node: AST node to visit
|
|
89
|
+
"""
|
|
90
|
+
if isinstance(node, ast.ClassDef):
|
|
91
|
+
class_id = id(node)
|
|
92
|
+
if class_id not in self._visited_classes:
|
|
93
|
+
self._visited_classes.add(class_id)
|
|
94
|
+
self._analyze_class(node)
|
|
95
|
+
else:
|
|
96
|
+
for child in ast.iter_child_nodes(node):
|
|
97
|
+
self._visit_node(child)
|
|
98
|
+
|
|
99
|
+
def _analyze_class(self, class_node: ast.ClassDef) -> None:
|
|
100
|
+
"""Analyze a class for property candidates.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
class_node: The ClassDef node
|
|
104
|
+
"""
|
|
105
|
+
for item in class_node.body:
|
|
106
|
+
self._process_class_item(item, class_node.name)
|
|
107
|
+
|
|
108
|
+
def _process_class_item(self, item: ast.stmt, class_name: str) -> None:
|
|
109
|
+
"""Process a single item in a class body.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
item: Item in the class body
|
|
113
|
+
class_name: Name of the containing class
|
|
114
|
+
"""
|
|
115
|
+
if isinstance(item, ast.FunctionDef):
|
|
116
|
+
self._check_method(item, class_name)
|
|
117
|
+
elif isinstance(item, ast.ClassDef):
|
|
118
|
+
self._process_nested_class(item)
|
|
119
|
+
|
|
120
|
+
def _process_nested_class(self, class_node: ast.ClassDef) -> None:
|
|
121
|
+
"""Process a nested class, avoiding duplicates.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
class_node: The nested class node
|
|
125
|
+
"""
|
|
126
|
+
class_id = id(class_node)
|
|
127
|
+
if class_id in self._visited_classes:
|
|
128
|
+
return
|
|
129
|
+
self._visited_classes.add(class_id)
|
|
130
|
+
self._analyze_class(class_node)
|
|
131
|
+
|
|
132
|
+
def _check_method(self, method: ast.FunctionDef, class_name: str) -> None:
|
|
133
|
+
"""Check if method should be a property.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
method: The FunctionDef node
|
|
137
|
+
class_name: Name of the containing class
|
|
138
|
+
"""
|
|
139
|
+
if not self._is_property_candidate(method):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
is_get_prefix = method.name.startswith("get_") and len(method.name) > 4
|
|
143
|
+
candidate = PropertyCandidate(
|
|
144
|
+
method_name=method.name,
|
|
145
|
+
class_name=class_name,
|
|
146
|
+
line=method.lineno,
|
|
147
|
+
column=method.col_offset,
|
|
148
|
+
is_get_prefix=is_get_prefix,
|
|
149
|
+
)
|
|
150
|
+
self.candidates.append(candidate)
|
|
151
|
+
|
|
152
|
+
def _is_property_candidate(self, method: ast.FunctionDef) -> bool:
|
|
153
|
+
"""Check if method should be a property.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
method: The FunctionDef node
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
True if method is a property candidate
|
|
160
|
+
"""
|
|
161
|
+
# All conditions must be met for property candidacy
|
|
162
|
+
checks = [
|
|
163
|
+
not self._is_dunder_method(method),
|
|
164
|
+
not self._is_action_verb_method(method),
|
|
165
|
+
not self._has_decorators(method),
|
|
166
|
+
self._takes_only_self(method),
|
|
167
|
+
self._has_simple_body(method),
|
|
168
|
+
self._returns_value(method),
|
|
169
|
+
not self._has_side_effects(method),
|
|
170
|
+
not self._has_control_flow(method),
|
|
171
|
+
not self._has_external_calls(method),
|
|
172
|
+
]
|
|
173
|
+
return all(checks)
|
|
174
|
+
|
|
175
|
+
def _is_dunder_method(self, method: ast.FunctionDef) -> bool:
|
|
176
|
+
"""Check if method is a dunder method.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
method: The method node
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if dunder method
|
|
183
|
+
"""
|
|
184
|
+
name = method.name
|
|
185
|
+
return name.startswith("__") and name.endswith("__")
|
|
186
|
+
|
|
187
|
+
def _is_action_verb_method(self, method: ast.FunctionDef) -> bool:
|
|
188
|
+
"""Check if method is an action verb (transformation/lifecycle method).
|
|
189
|
+
|
|
190
|
+
Methods like to_dict(), to_json(), finalize() represent actions, not
|
|
191
|
+
property access. These should remain as methods following Python idioms.
|
|
192
|
+
Also handles private method variants like _to_dict(), _generate_html().
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
method: The method node
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if method is an action verb
|
|
199
|
+
"""
|
|
200
|
+
name = method.name
|
|
201
|
+
|
|
202
|
+
# Strip leading underscores to handle private method variants
|
|
203
|
+
# e.g., _generate_legend_section should match generate_* pattern
|
|
204
|
+
stripped_name = name.lstrip("_")
|
|
205
|
+
|
|
206
|
+
# Check for action verb prefixes like to_*, generate_*, etc.
|
|
207
|
+
for prefix in self.exclude_prefixes:
|
|
208
|
+
if stripped_name.startswith(prefix) and len(stripped_name) > len(prefix):
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
# Check for specific action verb names (also check stripped version)
|
|
212
|
+
return name in self.exclude_names or stripped_name in self.exclude_names
|
|
213
|
+
|
|
214
|
+
def _has_decorators(self, method: ast.FunctionDef) -> bool:
|
|
215
|
+
"""Check if method has any decorators.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
method: The method node
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True if method has decorators
|
|
222
|
+
"""
|
|
223
|
+
return len(method.decorator_list) > 0
|
|
224
|
+
|
|
225
|
+
def _takes_only_self(self, method: ast.FunctionDef) -> bool:
|
|
226
|
+
"""Check if method takes only self parameter.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
method: The method node
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
True if only self parameter
|
|
233
|
+
"""
|
|
234
|
+
args = method.args
|
|
235
|
+
has_only_self_arg = len(args.args) == 1
|
|
236
|
+
has_extra_args = self._has_extra_args(args)
|
|
237
|
+
return has_only_self_arg and not has_extra_args
|
|
238
|
+
|
|
239
|
+
def _has_extra_args(self, args: ast.arguments) -> bool:
|
|
240
|
+
"""Check if arguments has extra parameters beyond self.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
args: Method arguments node
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if extra arguments present
|
|
247
|
+
"""
|
|
248
|
+
has_positional_only = bool(args.posonlyargs)
|
|
249
|
+
has_vararg = args.vararg is not None
|
|
250
|
+
has_keyword_only = bool(args.kwonlyargs)
|
|
251
|
+
has_kwarg = args.kwarg is not None
|
|
252
|
+
has_defaults = bool(args.defaults)
|
|
253
|
+
has_kw_defaults = args.kw_defaults and any(d is not None for d in args.kw_defaults)
|
|
254
|
+
|
|
255
|
+
return any(
|
|
256
|
+
[
|
|
257
|
+
has_positional_only,
|
|
258
|
+
has_vararg,
|
|
259
|
+
has_keyword_only,
|
|
260
|
+
has_kwarg,
|
|
261
|
+
has_defaults,
|
|
262
|
+
has_kw_defaults,
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _has_simple_body(self, method: ast.FunctionDef) -> bool:
|
|
267
|
+
"""Check if method body is simple (1-3 statements).
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
method: The method node
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
True if body is simple enough
|
|
274
|
+
"""
|
|
275
|
+
# Filter out docstrings
|
|
276
|
+
body = self._get_non_docstring_body(method)
|
|
277
|
+
|
|
278
|
+
# Check statement count
|
|
279
|
+
if len(body) > self.max_body_statements:
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
if len(body) == 0:
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def _get_non_docstring_body(self, method: ast.FunctionDef) -> list[ast.stmt]:
|
|
288
|
+
"""Get method body excluding docstrings.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
method: The method node
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
List of statements excluding docstrings
|
|
295
|
+
"""
|
|
296
|
+
body = method.body
|
|
297
|
+
if not body:
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
# Check if first statement is a docstring
|
|
301
|
+
first = body[0]
|
|
302
|
+
if isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant):
|
|
303
|
+
if isinstance(first.value.value, str):
|
|
304
|
+
return body[1:]
|
|
305
|
+
|
|
306
|
+
return body
|
|
307
|
+
|
|
308
|
+
def _returns_value(self, method: ast.FunctionDef) -> bool:
|
|
309
|
+
"""Check if method returns a non-None value.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
method: The method node
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True if method returns a value
|
|
316
|
+
"""
|
|
317
|
+
body = self._get_non_docstring_body(method)
|
|
318
|
+
if not body:
|
|
319
|
+
return False
|
|
320
|
+
last = body[-1]
|
|
321
|
+
return self._is_value_return(last)
|
|
322
|
+
|
|
323
|
+
def _is_value_return(self, node: ast.stmt) -> bool:
|
|
324
|
+
"""Check if node is a return statement with a non-None value.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
node: Statement node to check
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
True if return statement with value
|
|
331
|
+
"""
|
|
332
|
+
if not isinstance(node, ast.Return):
|
|
333
|
+
return False
|
|
334
|
+
if node.value is None:
|
|
335
|
+
return False
|
|
336
|
+
if isinstance(node.value, ast.Constant) and node.value.value is None:
|
|
337
|
+
return False
|
|
338
|
+
return True
|
|
339
|
+
|
|
340
|
+
def _has_side_effects(self, method: ast.FunctionDef) -> bool:
|
|
341
|
+
"""Check if method has side effects (assignments to self.*).
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
method: The method node
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
True if has side effects
|
|
348
|
+
"""
|
|
349
|
+
return any(self._is_side_effect_node(node) for node in ast.walk(method))
|
|
350
|
+
|
|
351
|
+
def _is_side_effect_node(self, node: ast.AST) -> bool:
|
|
352
|
+
"""Check if a node represents a side effect.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
node: AST node to check
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
True if node is a side effect
|
|
359
|
+
"""
|
|
360
|
+
return (
|
|
361
|
+
self._is_self_assign(node)
|
|
362
|
+
or self._is_self_aug_assign(node)
|
|
363
|
+
or self._is_self_ann_assign(node)
|
|
364
|
+
or self._is_self_delete(node)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _is_self_assign(self, node: ast.AST) -> bool:
|
|
368
|
+
"""Check if node is assignment to self."""
|
|
369
|
+
return isinstance(node, ast.Assign) and self._assigns_to_self(node.targets)
|
|
370
|
+
|
|
371
|
+
def _is_self_aug_assign(self, node: ast.AST) -> bool:
|
|
372
|
+
"""Check if node is augmented assignment to self."""
|
|
373
|
+
return isinstance(node, ast.AugAssign) and self._is_self_target(node.target)
|
|
374
|
+
|
|
375
|
+
def _is_self_ann_assign(self, node: ast.AST) -> bool:
|
|
376
|
+
"""Check if node is annotated assignment to self."""
|
|
377
|
+
if not isinstance(node, ast.AnnAssign):
|
|
378
|
+
return False
|
|
379
|
+
return node.value is not None and self._is_self_target(node.target)
|
|
380
|
+
|
|
381
|
+
def _is_self_delete(self, node: ast.AST) -> bool:
|
|
382
|
+
"""Check if node is delete of self attribute."""
|
|
383
|
+
return isinstance(node, ast.Delete) and self._assigns_to_self(node.targets)
|
|
384
|
+
|
|
385
|
+
def _assigns_to_self(self, targets: list[ast.expr]) -> bool:
|
|
386
|
+
"""Check if any target is a self attribute.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
targets: Assignment targets
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
True if assigning to self.*
|
|
393
|
+
"""
|
|
394
|
+
for target in targets:
|
|
395
|
+
if self._is_self_target(target):
|
|
396
|
+
return True
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
def _is_self_target(self, target: ast.expr) -> bool:
|
|
400
|
+
"""Check if target is a self attribute (self.* or self._*).
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
target: Assignment target
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
True if target is self.*
|
|
407
|
+
"""
|
|
408
|
+
if isinstance(target, ast.Attribute):
|
|
409
|
+
if isinstance(target.value, ast.Name) and target.value.id == "self":
|
|
410
|
+
return True
|
|
411
|
+
return False
|
|
412
|
+
|
|
413
|
+
# Node types that indicate complex control flow
|
|
414
|
+
_CONTROL_FLOW_TYPES: tuple[type, ...] = (
|
|
415
|
+
ast.For,
|
|
416
|
+
ast.While,
|
|
417
|
+
ast.Try,
|
|
418
|
+
ast.If,
|
|
419
|
+
ast.With,
|
|
420
|
+
ast.Raise,
|
|
421
|
+
ast.ListComp,
|
|
422
|
+
ast.DictComp,
|
|
423
|
+
ast.SetComp,
|
|
424
|
+
ast.GeneratorExp,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def _has_control_flow(self, method: ast.FunctionDef) -> bool:
|
|
428
|
+
"""Check if method has complex control flow.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
method: The method node
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
True if has complex control flow
|
|
435
|
+
"""
|
|
436
|
+
return any(isinstance(node, self._CONTROL_FLOW_TYPES) for node in ast.walk(method))
|
|
437
|
+
|
|
438
|
+
def _has_external_calls(self, method: ast.FunctionDef) -> bool:
|
|
439
|
+
"""Check if method has external function calls.
|
|
440
|
+
|
|
441
|
+
External calls are top-level function calls like print(), format_date().
|
|
442
|
+
Method calls on objects like self._name.upper() or v.strip() are OK.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
method: The method node
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
True if has external calls
|
|
449
|
+
"""
|
|
450
|
+
call_nodes = (node for node in ast.walk(method) if isinstance(node, ast.Call))
|
|
451
|
+
return any(self._is_external_function_call(node) for node in call_nodes)
|
|
452
|
+
|
|
453
|
+
def _is_external_function_call(self, call: ast.Call) -> bool:
|
|
454
|
+
"""Check if call is an external function (not a method on an object).
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
call: The Call node
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
True if external function call like print(), format_date()
|
|
461
|
+
"""
|
|
462
|
+
func = call.func
|
|
463
|
+
|
|
464
|
+
# Simple name call like print(), format_date()
|
|
465
|
+
if isinstance(func, ast.Name):
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
# Method call like obj.method() - these are OK
|
|
469
|
+
if isinstance(func, ast.Attribute):
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
return False
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Builds Violation objects for method-should-be-property detection
|
|
3
|
+
|
|
4
|
+
Scope: Violation creation for methods that should be @property decorators
|
|
5
|
+
|
|
6
|
+
Overview: Provides ViolationBuilder class that creates Violation objects for method-property
|
|
7
|
+
detections. Generates descriptive messages indicating which methods should be converted to
|
|
8
|
+
@property decorators, with special handling for get_* prefix methods (Java-style) that
|
|
9
|
+
suggests removing the prefix. Constructs complete Violation instances with rule_id,
|
|
10
|
+
file_path, line number, column, message, and suggestions for Pythonic refactoring.
|
|
11
|
+
|
|
12
|
+
Dependencies: pathlib.Path for file paths, src.core.types.Violation for violation structure
|
|
13
|
+
|
|
14
|
+
Exports: ViolationBuilder class
|
|
15
|
+
|
|
16
|
+
Interfaces: create_violation(method_name, line, column, file_path, is_get_prefix, class_name)
|
|
17
|
+
|
|
18
|
+
Implementation: Builder pattern with message templates suggesting @property decorator conversion
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from src.core.types import Violation
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ViolationBuilder:
|
|
27
|
+
"""Builds violations for method-should-be-property detections."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, rule_id: str) -> None:
|
|
30
|
+
"""Initialize the violation builder.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
rule_id: The rule ID to use in violations
|
|
34
|
+
"""
|
|
35
|
+
self.rule_id = rule_id
|
|
36
|
+
|
|
37
|
+
def create_violation( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
|
38
|
+
self,
|
|
39
|
+
method_name: str,
|
|
40
|
+
line: int,
|
|
41
|
+
column: int,
|
|
42
|
+
file_path: Path | None,
|
|
43
|
+
is_get_prefix: bool = False,
|
|
44
|
+
class_name: str | None = None,
|
|
45
|
+
) -> Violation:
|
|
46
|
+
"""Create a violation for a method that should be a property.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
method_name: Name of the method
|
|
50
|
+
line: Line number where the violation occurs
|
|
51
|
+
column: Column number where the violation occurs
|
|
52
|
+
file_path: Path to the file
|
|
53
|
+
is_get_prefix: Whether method has get_ prefix
|
|
54
|
+
class_name: Optional class name for context
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Violation object with details about the method
|
|
58
|
+
"""
|
|
59
|
+
message = self._build_message(method_name, is_get_prefix, class_name)
|
|
60
|
+
suggestion = self._build_suggestion(method_name, is_get_prefix)
|
|
61
|
+
|
|
62
|
+
return Violation(
|
|
63
|
+
rule_id=self.rule_id,
|
|
64
|
+
file_path=str(file_path) if file_path else "",
|
|
65
|
+
line=line,
|
|
66
|
+
column=column,
|
|
67
|
+
message=message,
|
|
68
|
+
suggestion=suggestion,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def _build_message(
|
|
72
|
+
self,
|
|
73
|
+
method_name: str,
|
|
74
|
+
is_get_prefix: bool,
|
|
75
|
+
class_name: str | None,
|
|
76
|
+
) -> str:
|
|
77
|
+
"""Build the violation message.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
method_name: Name of the method
|
|
81
|
+
is_get_prefix: Whether method has get_ prefix
|
|
82
|
+
class_name: Optional class name
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Human-readable message describing the violation
|
|
86
|
+
"""
|
|
87
|
+
if is_get_prefix:
|
|
88
|
+
property_name = method_name[4:] # Remove 'get_' prefix
|
|
89
|
+
if class_name:
|
|
90
|
+
return (
|
|
91
|
+
f"Method '{method_name}' in class '{class_name}' should be "
|
|
92
|
+
f"a @property named '{property_name}'"
|
|
93
|
+
)
|
|
94
|
+
return f"Method '{method_name}' should be a @property named '{property_name}'"
|
|
95
|
+
|
|
96
|
+
if class_name:
|
|
97
|
+
return f"Method '{method_name}' in class '{class_name}' should be a @property"
|
|
98
|
+
return f"Method '{method_name}' should be a @property"
|
|
99
|
+
|
|
100
|
+
def _build_suggestion(self, method_name: str, is_get_prefix: bool) -> str:
|
|
101
|
+
"""Build the suggestion for fixing the violation.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
method_name: Name of the method
|
|
105
|
+
is_get_prefix: Whether method has get_ prefix
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Actionable suggestion for fixing
|
|
109
|
+
"""
|
|
110
|
+
if is_get_prefix:
|
|
111
|
+
property_name = method_name[4:] # Remove 'get_' prefix
|
|
112
|
+
return (
|
|
113
|
+
f"Add @property decorator and rename to '{property_name}' "
|
|
114
|
+
f"for Pythonic attribute access"
|
|
115
|
+
)
|
|
116
|
+
return "Add @property decorator for Pythonic attribute access"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thailint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: The AI Linter - Enterprise-grade linting and governance for AI-generated code across multiple languages
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
7
7
|
Keywords: linter,ai,code-quality,static-analysis,file-placement,governance,multi-language,cli,docker,python
|
|
8
8
|
Author: Steve Jackson
|
|
9
9
|
Requires-Python: >=3.11,<4.0
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -37,7 +37,7 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
|
|
38
38
|
[](https://opensource.org/licenses/MIT)
|
|
39
39
|
[](https://www.python.org/downloads/)
|
|
40
|
-
[](tests/)
|
|
41
41
|
[](htmlcov/)
|
|
42
42
|
[](https://thai-lint.readthedocs.io/en/latest/?badge=latest)
|
|
43
43
|
[](docs/sarif-output.md)
|
|
@@ -98,6 +98,12 @@ thailint complements your existing linting stack by catching the patterns AI too
|
|
|
98
98
|
- Configurable thresholds (lines, tokens, occurrences)
|
|
99
99
|
- Language-specific detection (Python, TypeScript, JavaScript)
|
|
100
100
|
- False positive filtering (keyword args, imports)
|
|
101
|
+
- **Method Property Linting** - Detect methods that should be @property decorators
|
|
102
|
+
- Python AST-based detection
|
|
103
|
+
- get_* prefix detection (Java-style getters)
|
|
104
|
+
- Simple computed value detection
|
|
105
|
+
- Action verb exclusion (to_*, finalize, serialize)
|
|
106
|
+
- Test file detection
|
|
101
107
|
- **Pluggable Architecture** - Easy to extend with custom linters
|
|
102
108
|
- **Multi-Language Support** - Python, TypeScript, JavaScript, and more
|
|
103
109
|
- **Flexible Configuration** - YAML/JSON configs with pattern matching
|
|
@@ -2,7 +2,7 @@ src/__init__.py,sha256=f601zncODr2twrUHqTLS5wyOdZqZi9tMjAe2INhRKqU,2175
|
|
|
2
2
|
src/analyzers/__init__.py,sha256=fFloZtjkBGwYbAhKTxS3Qy3yDr2_3i3WSfKTw1mAioo,972
|
|
3
3
|
src/analyzers/typescript_base.py,sha256=4I7fAcMOAY9vY1AXh52QpohgFmguBECwOkvBRP4zCS4,5054
|
|
4
4
|
src/api.py,sha256=pJ5l3qxccKBEY-BkANwzTgLAl1ZFq7OP6hx6LSxbhDw,4664
|
|
5
|
-
src/cli.py,sha256=
|
|
5
|
+
src/cli.py,sha256=E0PEazl-fuuNu8psnUBAQ53PihtLwgKyAOL3v193Z-o,59424
|
|
6
6
|
src/config.py,sha256=2ebAjIpAhw4bHbOxViEA5nCjfBlDEIrMR59DBrzcYzM,12460
|
|
7
7
|
src/core/__init__.py,sha256=5FtsDvhMt4SNRx3pbcGURrxn135XRbeRrjSUxiXwkNc,381
|
|
8
8
|
src/core/base.py,sha256=Eklcagi2ktfY4Kytl_ObXov2U49N9OGDpw4cu4PUzGY,7824
|
|
@@ -17,18 +17,18 @@ src/formatters/__init__.py,sha256=yE1yIL8lplTMEjsmQm7F-kOMaYq7OjmbFuiwwK0D-gM,81
|
|
|
17
17
|
src/formatters/sarif.py,sha256=gGOwb_v7j4mx4bpvV1NNDd-JyHH8i8XX89iQ6uRSvG4,7050
|
|
18
18
|
src/linter_config/__init__.py,sha256=_I2VVlZlfKyT-tKukuUA5-aVcHLOe3m6C2cev43AiEc,298
|
|
19
19
|
src/linter_config/ignore.py,sha256=S2Ub0CCOOC-wpU5Y_EodMprciw18fgWcnp4z_h1MYNk,19638
|
|
20
|
-
src/linter_config/loader.py,sha256=
|
|
20
|
+
src/linter_config/loader.py,sha256=BM4GJZqkEJSKxR3jIMgz0WqB_uWxXNiHsKWtwUA43AE,2545
|
|
21
21
|
src/linters/__init__.py,sha256=-nnNsL8E5-2p9qlLKp_TaShHAjPH-NacOEU1sXmAR9k,77
|
|
22
22
|
src/linters/dry/__init__.py,sha256=p58tN3z_VbulfTkRm1kLZJ43Bemt66T2sro1teirUY8,826
|
|
23
23
|
src/linters/dry/base_token_analyzer.py,sha256=SgnsX4a4kUzOsA9hTkLkg4yB8I77ewAyiUp6cAybUrg,2684
|
|
24
|
-
src/linters/dry/block_filter.py,sha256=
|
|
24
|
+
src/linters/dry/block_filter.py,sha256=8Sc4tZqHj5GpVxJTQRMCtbMLt6zscuylF2fccEwPo2w,8248
|
|
25
25
|
src/linters/dry/block_grouper.py,sha256=gBvSPgy8jumChZ53px3P7hDCJfi5PDrKhwxLTgNy7ig,1810
|
|
26
|
-
src/linters/dry/cache.py,sha256=
|
|
26
|
+
src/linters/dry/cache.py,sha256=dedkGU5SolTjmTTsmj-dwPEnxyhWZ2aBiHGkQy1JwXo,5881
|
|
27
27
|
src/linters/dry/cache_query.py,sha256=NTBh4ISy76LJb9tJ6G94fE0R2OiE0eQ-zVvB08WG2bA,1802
|
|
28
28
|
src/linters/dry/config.py,sha256=XaHX5SnbH8cEBHiNKMSuYQJwCbKL-Q9XgZVRcc9UBgQ,5439
|
|
29
29
|
src/linters/dry/config_loader.py,sha256=wikqnigOp6p1h9jaAATV_3bDXSiaIUFaf9xg1jQMDpo,1313
|
|
30
30
|
src/linters/dry/deduplicator.py,sha256=a1TRvldxCszf5QByo1ihXF3W98dpGuyaRT74jPfQftM,3988
|
|
31
|
-
src/linters/dry/duplicate_storage.py,sha256=
|
|
31
|
+
src/linters/dry/duplicate_storage.py,sha256=9pIALnwAuz5BJUYNXrPbObbP932CE9x0vgUkICryT_s,1970
|
|
32
32
|
src/linters/dry/file_analyzer.py,sha256=ufSQ85ddsGTqGnBHZNTdV_5DGfTpUmJOB58sIdJNV0I,2928
|
|
33
33
|
src/linters/dry/inline_ignore.py,sha256=ASfA-fp_1aPpkakN2e0T6qdTh8S7Jqj89ovxXJLmFlc,4439
|
|
34
34
|
src/linters/dry/linter.py,sha256=XMLwCgGrFX0l0dVUJs1jpsXOfgxeKKDbxOtN5h5Emhk,5835
|
|
@@ -38,7 +38,7 @@ src/linters/dry/token_hasher.py,sha256=71njBzUsWvQjIWo38AKeRHQsG8K4jrjLTKuih-i6G
|
|
|
38
38
|
src/linters/dry/typescript_analyzer.py,sha256=ShNoB2KfPe010wKEZoFxn-ZKh0MnRUwgADDQKQtfedI,21627
|
|
39
39
|
src/linters/dry/violation_builder.py,sha256=EUiEQIOZjzAoHEqZiIR8WZP8m4dgqJjcveR5mdMyClI,2803
|
|
40
40
|
src/linters/dry/violation_filter.py,sha256=aTOMz8kXG2sZlSVcf3cAxgxHs7f2kBXInfr1V_04fUQ,3125
|
|
41
|
-
src/linters/dry/violation_generator.py,sha256=
|
|
41
|
+
src/linters/dry/violation_generator.py,sha256=hbMs8Fo2hS8JCXicZcZni6NEkv4fJRsyrsjzrqN6qVw,6122
|
|
42
42
|
src/linters/file_header/__init__.py,sha256=S3a2xrOlxnNWD02To5K2ZwILsNEvSj1IvUAH8RjgOV4,791
|
|
43
43
|
src/linters/file_header/atemporal_detector.py,sha256=bgQJPDuJj1J5gHKIIOz1TYbBwu8GHrcMafWFVqZ_zZE,3192
|
|
44
44
|
src/linters/file_header/base_parser.py,sha256=HbuJpXQ4V3zTDTP_D0iFqoT7kab6gk8A1lZdlqCb6tc,3202
|
|
@@ -67,6 +67,11 @@ src/linters/magic_numbers/linter.py,sha256=maj4NgrDapv0RurKvaVgOI1BUujixZv4E7UeY
|
|
|
67
67
|
src/linters/magic_numbers/python_analyzer.py,sha256=0u1cFaaFCqOW5yeW-YbmPoZuVIeN_KtmkFyyxup6aR0,2803
|
|
68
68
|
src/linters/magic_numbers/typescript_analyzer.py,sha256=NTU1XY-Hudse5oxOtEiG6nA0Rs5Icn9HXELnyPj8OZU,7554
|
|
69
69
|
src/linters/magic_numbers/violation_builder.py,sha256=SqIQv3N9lpP2GRC1TC5InrvaEdrAq24V7Ec2Xj5olb0,3308
|
|
70
|
+
src/linters/method_property/__init__.py,sha256=t0C6zD5WLm-McgmvVajQJg4HQfOi7_4YzNLhKNA484w,1415
|
|
71
|
+
src/linters/method_property/config.py,sha256=lcKfeCIeWBcD91j0AchccWhGvc68epiQeKbgL6DCEA8,5359
|
|
72
|
+
src/linters/method_property/linter.py,sha256=zdXUqx76A8J7Ryvg333jrAUDFWx4I4n3dzayAcN2k8w,12721
|
|
73
|
+
src/linters/method_property/python_analyzer.py,sha256=QQTpKrdCJzFqu7gdQfAqno9_wbzHlOQYQdCzMPriRM0,15244
|
|
74
|
+
src/linters/method_property/violation_builder.py,sha256=GKZC8TS9vZq_lKK5ffOVUHUlMpZN3_-Mv3inRE-v2pE,4141
|
|
70
75
|
src/linters/nesting/__init__.py,sha256=tszmyCEQMpEwB5H84WcAUfRYDQl7jpsn04es5DtAHsM,3200
|
|
71
76
|
src/linters/nesting/config.py,sha256=PfPA2wJn3i6HHXeM0qu6Qx-v1KJdRwlRkFOdpf7NhS8,2405
|
|
72
77
|
src/linters/nesting/linter.py,sha256=-klbXIbg145beICop81CNQ5J1OInQaeycDT8u3Ff2Ww,6236
|
|
@@ -96,8 +101,8 @@ src/orchestrator/language_detector.py,sha256=rHyVMApit80NTTNyDH1ObD1usKD8LjGmH3D
|
|
|
96
101
|
src/templates/thailint_config_template.yaml,sha256=vxyhRRi25_xOnHDRx0jzz69dgPqKU2IU5-YFGUoX5lM,4953
|
|
97
102
|
src/utils/__init__.py,sha256=NiBtKeQ09Y3kuUzeN4O1JNfUIYPQDS2AP1l5ODq-Dec,125
|
|
98
103
|
src/utils/project_root.py,sha256=b3YTEGTa9RPcOeHn1IByMMWyRiUabfVlpnlektL0A0o,6156
|
|
99
|
-
thailint-0.
|
|
100
|
-
thailint-0.
|
|
101
|
-
thailint-0.
|
|
102
|
-
thailint-0.
|
|
103
|
-
thailint-0.
|
|
104
|
+
thailint-0.8.0.dist-info/METADATA,sha256=JLoEBO87HaZBzNk9wlkn1MeCO_S2wG5dKnL6itrbDe0,42115
|
|
105
|
+
thailint-0.8.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
106
|
+
thailint-0.8.0.dist-info/entry_points.txt,sha256=l7DQJgU18sVLDpSaXOXY3lLhnQHQIRrSJZTQjG1cEAk,62
|
|
107
|
+
thailint-0.8.0.dist-info/licenses/LICENSE,sha256=kxh1J0Sb62XvhNJ6MZsVNe8PqNVJ7LHRn_EWa-T3djw,1070
|
|
108
|
+
thailint-0.8.0.dist-info/RECORD,,
|