thailint 0.4.6__py3-none-any.whl → 0.7.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.
Files changed (31) hide show
  1. src/cli.py +228 -1
  2. src/core/cli_utils.py +16 -1
  3. src/core/registry.py +1 -1
  4. src/formatters/__init__.py +22 -0
  5. src/formatters/sarif.py +202 -0
  6. src/linters/file_header/atemporal_detector.py +11 -11
  7. src/linters/file_header/base_parser.py +89 -0
  8. src/linters/file_header/bash_parser.py +58 -0
  9. src/linters/file_header/config.py +76 -16
  10. src/linters/file_header/css_parser.py +70 -0
  11. src/linters/file_header/field_validator.py +35 -29
  12. src/linters/file_header/linter.py +113 -121
  13. src/linters/file_header/markdown_parser.py +124 -0
  14. src/linters/file_header/python_parser.py +14 -58
  15. src/linters/file_header/typescript_parser.py +73 -0
  16. src/linters/file_header/violation_builder.py +13 -12
  17. src/linters/file_placement/linter.py +9 -11
  18. src/linters/magic_numbers/typescript_analyzer.py +1 -0
  19. src/linters/nesting/typescript_analyzer.py +1 -0
  20. src/linters/print_statements/__init__.py +53 -0
  21. src/linters/print_statements/config.py +78 -0
  22. src/linters/print_statements/linter.py +428 -0
  23. src/linters/print_statements/python_analyzer.py +149 -0
  24. src/linters/print_statements/typescript_analyzer.py +130 -0
  25. src/linters/print_statements/violation_builder.py +96 -0
  26. src/templates/thailint_config_template.yaml +26 -0
  27. {thailint-0.4.6.dist-info → thailint-0.7.0.dist-info}/METADATA +149 -3
  28. {thailint-0.4.6.dist-info → thailint-0.7.0.dist-info}/RECORD +31 -18
  29. {thailint-0.4.6.dist-info → thailint-0.7.0.dist-info}/WHEEL +0 -0
  30. {thailint-0.4.6.dist-info → thailint-0.7.0.dist-info}/entry_points.txt +0 -0
  31. {thailint-0.4.6.dist-info → thailint-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,428 @@
1
+ """
2
+ Purpose: Main print statements linter rule implementation
3
+
4
+ Scope: Print and console statement detection for Python, TypeScript, and JavaScript files
5
+
6
+ Overview: Implements print statements linter rule following MultiLanguageLintRule interface. Orchestrates
7
+ configuration loading, Python AST analysis for print() calls, TypeScript tree-sitter analysis
8
+ for console.* calls, and violation building through focused helper classes. Detects print and
9
+ console statements that should be replaced with proper logging. Supports configurable
10
+ allow_in_scripts option to permit print() in __main__ blocks and configurable console_methods
11
+ set for TypeScript/JavaScript. Handles ignore directives for suppressing specific violations
12
+ through inline comments and configuration patterns.
13
+
14
+ Dependencies: BaseLintContext and MultiLanguageLintRule from core, ast module, pathlib,
15
+ analyzer classes, config classes
16
+
17
+ Exports: PrintStatementRule class implementing MultiLanguageLintRule interface
18
+
19
+ Interfaces: check(context) -> list[Violation] for rule validation, standard rule properties
20
+ (rule_id, rule_name, description)
21
+
22
+ Implementation: Composition pattern with helper classes (analyzers, violation builder),
23
+ AST-based analysis for Python, tree-sitter for TypeScript/JavaScript
24
+ """
25
+
26
+ import ast
27
+ from pathlib import Path
28
+
29
+ from src.core.base import BaseLintContext, MultiLanguageLintRule
30
+ from src.core.linter_utils import load_linter_config
31
+ from src.core.types import Violation
32
+ from src.linter_config.ignore import IgnoreDirectiveParser
33
+
34
+ from .config import PrintStatementConfig
35
+ from .python_analyzer import PythonPrintStatementAnalyzer
36
+ from .typescript_analyzer import TypeScriptPrintStatementAnalyzer
37
+ from .violation_builder import ViolationBuilder
38
+
39
+
40
+ class PrintStatementRule(MultiLanguageLintRule): # thailint: ignore[srp]
41
+ """Detects print/console statements that should be replaced with proper logging."""
42
+
43
+ def __init__(self) -> None:
44
+ """Initialize the print statements rule."""
45
+ self._ignore_parser = IgnoreDirectiveParser()
46
+ self._violation_builder = ViolationBuilder(self.rule_id)
47
+
48
+ @property
49
+ def rule_id(self) -> str:
50
+ """Unique identifier for this rule."""
51
+ return "print-statements.detected"
52
+
53
+ @property
54
+ def rule_name(self) -> str:
55
+ """Human-readable name for this rule."""
56
+ return "Print Statements"
57
+
58
+ @property
59
+ def description(self) -> str:
60
+ """Description of what this rule checks."""
61
+ return "Print/console statements should be replaced with proper logging"
62
+
63
+ def _load_config(self, context: BaseLintContext) -> PrintStatementConfig:
64
+ """Load configuration from context.
65
+
66
+ Args:
67
+ context: Lint context
68
+
69
+ Returns:
70
+ PrintStatementConfig instance
71
+ """
72
+ test_config = self._try_load_test_config(context)
73
+ if test_config is not None:
74
+ return test_config
75
+
76
+ prod_config = self._try_load_production_config(context)
77
+ if prod_config is not None:
78
+ return prod_config
79
+
80
+ return PrintStatementConfig()
81
+
82
+ def _try_load_test_config(self, context: BaseLintContext) -> PrintStatementConfig | None:
83
+ """Try to load test-style configuration."""
84
+ if not hasattr(context, "config"):
85
+ return None
86
+ config_attr = context.config
87
+ if config_attr is None or not isinstance(config_attr, dict):
88
+ return None
89
+ return PrintStatementConfig.from_dict(config_attr, context.language)
90
+
91
+ def _try_load_production_config(self, context: BaseLintContext) -> PrintStatementConfig | None:
92
+ """Try to load production configuration."""
93
+ if not hasattr(context, "metadata") or not isinstance(context.metadata, dict):
94
+ return None
95
+
96
+ metadata = context.metadata
97
+
98
+ if "print_statements" in metadata:
99
+ return load_linter_config(context, "print_statements", PrintStatementConfig)
100
+
101
+ if "print-statements" in metadata:
102
+ return load_linter_config(context, "print-statements", PrintStatementConfig)
103
+
104
+ return None
105
+
106
+ def _is_file_ignored(self, context: BaseLintContext, config: PrintStatementConfig) -> bool:
107
+ """Check if file matches ignore patterns.
108
+
109
+ Args:
110
+ context: Lint context
111
+ config: Print statements configuration
112
+
113
+ Returns:
114
+ True if file should be ignored
115
+ """
116
+ if not config.ignore:
117
+ return False
118
+
119
+ if not context.file_path:
120
+ return False
121
+
122
+ file_path = Path(context.file_path)
123
+ for pattern in config.ignore:
124
+ if self._matches_pattern(file_path, pattern):
125
+ return True
126
+ return False
127
+
128
+ def _matches_pattern(self, file_path: Path, pattern: str) -> bool:
129
+ """Check if file path matches a glob pattern.
130
+
131
+ Args:
132
+ file_path: Path to check
133
+ pattern: Glob pattern
134
+
135
+ Returns:
136
+ True if path matches pattern
137
+ """
138
+ if file_path.match(pattern):
139
+ return True
140
+ if pattern in str(file_path):
141
+ return True
142
+ return False
143
+
144
+ def _check_python(
145
+ self, context: BaseLintContext, config: PrintStatementConfig
146
+ ) -> list[Violation]:
147
+ """Check Python code for print() violations.
148
+
149
+ Args:
150
+ context: Lint context with Python file information
151
+ config: Print statements configuration
152
+
153
+ Returns:
154
+ List of violations found in Python code
155
+ """
156
+ if self._is_file_ignored(context, config):
157
+ return []
158
+
159
+ tree = self._parse_python_code(context.file_content)
160
+ if tree is None:
161
+ return []
162
+
163
+ analyzer = PythonPrintStatementAnalyzer()
164
+ print_calls = analyzer.find_print_calls(tree)
165
+ return self._collect_python_violations(print_calls, context, config, analyzer)
166
+
167
+ def _parse_python_code(self, code: str | None) -> ast.AST | None:
168
+ """Parse Python code into AST."""
169
+ try:
170
+ return ast.parse(code or "")
171
+ except SyntaxError:
172
+ return None
173
+
174
+ def _collect_python_violations(
175
+ self,
176
+ print_calls: list,
177
+ context: BaseLintContext,
178
+ config: PrintStatementConfig,
179
+ analyzer: PythonPrintStatementAnalyzer,
180
+ ) -> list[Violation]:
181
+ """Collect violations from Python print() calls.
182
+
183
+ Args:
184
+ print_calls: List of (node, parent, line_number) tuples
185
+ context: Lint context
186
+ config: Configuration
187
+ analyzer: Python analyzer instance
188
+
189
+ Returns:
190
+ List of violations
191
+ """
192
+ violations = []
193
+ for node, _parent, line_number in print_calls:
194
+ violation = self._try_create_python_violation(
195
+ node, line_number, context, config, analyzer
196
+ )
197
+ if violation is not None:
198
+ violations.append(violation)
199
+ return violations
200
+
201
+ def _try_create_python_violation( # pylint: disable=too-many-arguments,too-many-positional-arguments
202
+ self,
203
+ node: ast.Call,
204
+ line_number: int,
205
+ context: BaseLintContext,
206
+ config: PrintStatementConfig,
207
+ analyzer: PythonPrintStatementAnalyzer,
208
+ ) -> Violation | None:
209
+ """Try to create a violation for a Python print() call.
210
+
211
+ Args:
212
+ node: AST Call node
213
+ line_number: Line number
214
+ context: Lint context
215
+ config: Configuration
216
+ analyzer: Python analyzer
217
+
218
+ Returns:
219
+ Violation or None if should not flag
220
+ """
221
+ # Check if in __main__ block and allow_in_scripts is enabled
222
+ if config.allow_in_scripts and analyzer.is_in_main_block(node):
223
+ return None
224
+
225
+ violation = self._violation_builder.create_python_violation(
226
+ node, line_number, context.file_path
227
+ )
228
+
229
+ if self._should_ignore(violation, context):
230
+ return None
231
+
232
+ return violation
233
+
234
+ def _should_ignore(self, violation: Violation, context: BaseLintContext) -> bool:
235
+ """Check if violation should be ignored based on inline directives.
236
+
237
+ Args:
238
+ violation: Violation to check
239
+ context: Lint context with file content
240
+
241
+ Returns:
242
+ True if violation should be ignored
243
+ """
244
+ if self._ignore_parser.should_ignore_violation(violation, context.file_content or ""):
245
+ return True
246
+ return self._check_generic_ignore(violation, context)
247
+
248
+ def _check_generic_ignore(self, violation: Violation, context: BaseLintContext) -> bool:
249
+ """Check for generic ignore directives.
250
+
251
+ Args:
252
+ violation: Violation to check
253
+ context: Lint context
254
+
255
+ Returns:
256
+ True if line has generic ignore directive
257
+ """
258
+ line_text = self._get_violation_line(violation, context)
259
+ if line_text is None:
260
+ return False
261
+ return self._has_generic_ignore_directive(line_text)
262
+
263
+ def _get_violation_line(self, violation: Violation, context: BaseLintContext) -> str | None:
264
+ """Get the line text for a violation."""
265
+ if not context.file_content:
266
+ return None
267
+
268
+ lines = context.file_content.splitlines()
269
+ if violation.line <= 0 or violation.line > len(lines):
270
+ return None
271
+
272
+ return lines[violation.line - 1].lower()
273
+
274
+ def _has_generic_ignore_directive(self, line_text: str) -> bool:
275
+ """Check if line has generic ignore directive."""
276
+ if self._has_generic_thailint_ignore(line_text):
277
+ return True
278
+ return self._has_noqa_directive(line_text)
279
+
280
+ def _has_generic_thailint_ignore(self, line_text: str) -> bool:
281
+ """Check for generic thailint: ignore (no brackets)."""
282
+ if "# thailint: ignore" not in line_text:
283
+ return False
284
+ after_ignore = line_text.split("# thailint: ignore")[1].split("#")[0]
285
+ return "[" not in after_ignore
286
+
287
+ def _has_noqa_directive(self, line_text: str) -> bool:
288
+ """Check for noqa-style comments."""
289
+ return "# noqa" in line_text
290
+
291
+ def _check_typescript(
292
+ self, context: BaseLintContext, config: PrintStatementConfig
293
+ ) -> list[Violation]:
294
+ """Check TypeScript/JavaScript code for console.* violations.
295
+
296
+ Args:
297
+ context: Lint context with TypeScript/JavaScript file information
298
+ config: Print statements configuration
299
+
300
+ Returns:
301
+ List of violations found in TypeScript/JavaScript code
302
+ """
303
+ if self._is_file_ignored(context, config):
304
+ return []
305
+
306
+ analyzer = TypeScriptPrintStatementAnalyzer()
307
+ root_node = analyzer.parse_typescript(context.file_content or "")
308
+ if root_node is None:
309
+ return []
310
+
311
+ console_calls = analyzer.find_console_calls(root_node, config.console_methods)
312
+ return self._collect_typescript_violations(console_calls, context)
313
+
314
+ def _collect_typescript_violations(
315
+ self,
316
+ console_calls: list,
317
+ context: BaseLintContext,
318
+ ) -> list[Violation]:
319
+ """Collect violations from TypeScript console.* calls.
320
+
321
+ Args:
322
+ console_calls: List of (node, method_name, line_number) tuples
323
+ context: Lint context
324
+
325
+ Returns:
326
+ List of violations
327
+ """
328
+ violations = []
329
+ for _node, method_name, line_number in console_calls:
330
+ violation = self._try_create_typescript_violation(method_name, line_number, context)
331
+ if violation is not None:
332
+ violations.append(violation)
333
+ return violations
334
+
335
+ def _try_create_typescript_violation(
336
+ self,
337
+ method_name: str,
338
+ line_number: int,
339
+ context: BaseLintContext,
340
+ ) -> Violation | None:
341
+ """Try to create a violation for a TypeScript console.* call.
342
+
343
+ Args:
344
+ method_name: Console method name (log, warn, etc.)
345
+ line_number: Line number
346
+ context: Lint context
347
+
348
+ Returns:
349
+ Violation or None if should not flag
350
+ """
351
+ # Check if test file (skip test files)
352
+ if self._is_test_file(context.file_path):
353
+ return None
354
+
355
+ violation = self._violation_builder.create_typescript_violation(
356
+ method_name, line_number, context.file_path
357
+ )
358
+
359
+ if self._should_ignore_typescript(violation, context):
360
+ return None
361
+
362
+ return violation
363
+
364
+ def _is_test_file(self, file_path: object) -> bool:
365
+ """Check if file is a test file.
366
+
367
+ Args:
368
+ file_path: Path to check
369
+
370
+ Returns:
371
+ True if test file
372
+ """
373
+ path_str = str(file_path)
374
+ return any(
375
+ pattern in path_str
376
+ for pattern in [".test.", ".spec.", "test_", "_test.", "/tests/", "/test/"]
377
+ )
378
+
379
+ def _should_ignore_typescript(self, violation: Violation, context: BaseLintContext) -> bool:
380
+ """Check if TypeScript violation should be ignored.
381
+
382
+ Args:
383
+ violation: Violation to check
384
+ context: Lint context
385
+
386
+ Returns:
387
+ True if should ignore
388
+ """
389
+ if self._ignore_parser.should_ignore_violation(violation, context.file_content or ""):
390
+ return True
391
+ return self._check_typescript_ignore(violation, context)
392
+
393
+ def _check_typescript_ignore(self, violation: Violation, context: BaseLintContext) -> bool:
394
+ """Check for TypeScript-style ignore directives.
395
+
396
+ Args:
397
+ violation: Violation to check
398
+ context: Lint context
399
+
400
+ Returns:
401
+ True if line has ignore directive
402
+ """
403
+ line_text = self._get_violation_line(violation, context)
404
+ if line_text is None:
405
+ return False
406
+ return self._has_typescript_ignore_directive(line_text)
407
+
408
+ def _has_typescript_ignore_directive(self, line_text: str) -> bool:
409
+ """Check if line has TypeScript-style ignore directive.
410
+
411
+ Args:
412
+ line_text: Line text to check
413
+
414
+ Returns:
415
+ True if has ignore directive
416
+ """
417
+ if "// thailint: ignore[print-statements]" in line_text:
418
+ return True
419
+
420
+ if "// thailint: ignore" in line_text:
421
+ after_ignore = line_text.split("// thailint: ignore")[1].split("//")[0]
422
+ if "[" not in after_ignore:
423
+ return True
424
+
425
+ if "// noqa" in line_text:
426
+ return True
427
+
428
+ return False
@@ -0,0 +1,149 @@
1
+ """
2
+ Purpose: Python AST analysis for finding print() call nodes
3
+
4
+ Scope: Python print() statement detection and __main__ block context analysis
5
+
6
+ Overview: Provides PythonPrintStatementAnalyzer class that traverses Python AST to find all
7
+ print() function calls. Uses ast.walk() to traverse the syntax tree and collect
8
+ Call nodes where the function is 'print'. Tracks parent nodes to detect if print calls
9
+ are within __main__ blocks (if __name__ == "__main__":) for allow_in_scripts filtering.
10
+ Returns structured data about each print call including the AST node, parent context,
11
+ and line number for violation reporting. Handles both simple print() and builtins.print() calls.
12
+
13
+ Dependencies: ast module for AST parsing and node types
14
+
15
+ Exports: PythonPrintStatementAnalyzer class
16
+
17
+ Interfaces: find_print_calls(tree) -> list[tuple[Call, AST | None, int]], is_in_main_block(node) -> bool
18
+
19
+ Implementation: AST walk pattern with parent map for context detection and __main__ block identification
20
+ """
21
+
22
+ import ast
23
+
24
+
25
+ class PythonPrintStatementAnalyzer: # thailint: ignore[srp]
26
+ """Analyzes Python AST to find print() calls."""
27
+
28
+ def __init__(self) -> None:
29
+ """Initialize the analyzer."""
30
+ self.print_calls: list[tuple[ast.Call, ast.AST | None, int]] = []
31
+ self.parent_map: dict[ast.AST, ast.AST] = {}
32
+
33
+ def find_print_calls(self, tree: ast.AST) -> list[tuple[ast.Call, ast.AST | None, int]]:
34
+ """Find all print() calls in the AST.
35
+
36
+ Args:
37
+ tree: The AST to analyze
38
+
39
+ Returns:
40
+ List of tuples (node, parent, line_number)
41
+ """
42
+ self.print_calls = []
43
+ self.parent_map = {}
44
+ self._build_parent_map(tree)
45
+ self._collect_print_calls(tree)
46
+ return self.print_calls
47
+
48
+ def _build_parent_map(self, node: ast.AST, parent: ast.AST | None = None) -> None:
49
+ """Build a map of nodes to their parents.
50
+
51
+ Args:
52
+ node: Current AST node
53
+ parent: Parent of current node
54
+ """
55
+ if parent is not None:
56
+ self.parent_map[node] = parent
57
+
58
+ for child in ast.iter_child_nodes(node):
59
+ self._build_parent_map(child, node)
60
+
61
+ def _collect_print_calls(self, tree: ast.AST) -> None:
62
+ """Walk tree and collect all print() calls.
63
+
64
+ Args:
65
+ tree: AST to traverse
66
+ """
67
+ for node in ast.walk(tree):
68
+ if isinstance(node, ast.Call) and self._is_print_call(node):
69
+ parent = self.parent_map.get(node)
70
+ line_number = node.lineno if hasattr(node, "lineno") else 0
71
+ self.print_calls.append((node, parent, line_number))
72
+
73
+ def _is_print_call(self, node: ast.Call) -> bool:
74
+ """Check if a Call node is calling print().
75
+
76
+ Args:
77
+ node: The Call node to check
78
+
79
+ Returns:
80
+ True if this is a print() call
81
+ """
82
+ return self._is_simple_print(node) or self._is_builtins_print(node)
83
+
84
+ def _is_simple_print(self, node: ast.Call) -> bool:
85
+ """Check for simple print() call."""
86
+ return isinstance(node.func, ast.Name) and node.func.id == "print"
87
+
88
+ def _is_builtins_print(self, node: ast.Call) -> bool:
89
+ """Check for builtins.print() call."""
90
+ if not isinstance(node.func, ast.Attribute):
91
+ return False
92
+ if node.func.attr != "print":
93
+ return False
94
+ return isinstance(node.func.value, ast.Name) and node.func.value.id == "builtins"
95
+
96
+ def is_in_main_block(self, node: ast.AST) -> bool:
97
+ """Check if node is within `if __name__ == "__main__":` block.
98
+
99
+ Args:
100
+ node: AST node to check
101
+
102
+ Returns:
103
+ True if node is inside a __main__ block
104
+ """
105
+ current = node
106
+ while current in self.parent_map:
107
+ parent = self.parent_map[current]
108
+ if self._is_main_if_block(parent):
109
+ return True
110
+ current = parent
111
+ return False
112
+
113
+ def _is_main_if_block(self, node: ast.AST) -> bool:
114
+ """Check if node is an `if __name__ == "__main__":` statement.
115
+
116
+ Args:
117
+ node: AST node to check
118
+
119
+ Returns:
120
+ True if this is a __main__ if block
121
+ """
122
+ if not isinstance(node, ast.If):
123
+ return False
124
+ if not isinstance(node.test, ast.Compare):
125
+ return False
126
+ return self._is_main_comparison(node.test)
127
+
128
+ def _is_main_comparison(self, test: ast.Compare) -> bool:
129
+ """Check if comparison is __name__ == '__main__'."""
130
+ if not self._is_name_identifier(test.left):
131
+ return False
132
+ if not self._has_single_eq_operator(test):
133
+ return False
134
+ return self._compares_to_main(test)
135
+
136
+ def _is_name_identifier(self, node: ast.expr) -> bool:
137
+ """Check if node is the __name__ identifier."""
138
+ return isinstance(node, ast.Name) and node.id == "__name__"
139
+
140
+ def _has_single_eq_operator(self, test: ast.Compare) -> bool:
141
+ """Check if comparison has single == operator."""
142
+ return len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)
143
+
144
+ def _compares_to_main(self, test: ast.Compare) -> bool:
145
+ """Check if comparison is to '__main__' string."""
146
+ if len(test.comparators) != 1:
147
+ return False
148
+ comparator = test.comparators[0]
149
+ return isinstance(comparator, ast.Constant) and comparator.value == "__main__"