thailint 0.15.0__py3-none-any.whl → 0.15.1__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 (56) hide show
  1. src/analyzers/rust_base.py +155 -0
  2. src/analyzers/rust_context.py +141 -0
  3. src/cli/config.py +6 -4
  4. src/cli/linters/code_patterns.py +64 -16
  5. src/cli/linters/code_smells.py +23 -14
  6. src/cli/linters/documentation.py +5 -3
  7. src/cli/linters/performance.py +23 -10
  8. src/cli/linters/shared.py +22 -6
  9. src/cli/linters/structure.py +13 -4
  10. src/cli/linters/structure_quality.py +9 -4
  11. src/cli/utils.py +4 -4
  12. src/config.py +34 -21
  13. src/core/python_lint_rule.py +101 -0
  14. src/linter_config/ignore.py +2 -1
  15. src/linters/cqs/__init__.py +54 -0
  16. src/linters/cqs/config.py +55 -0
  17. src/linters/cqs/function_analyzer.py +201 -0
  18. src/linters/cqs/input_detector.py +139 -0
  19. src/linters/cqs/linter.py +159 -0
  20. src/linters/cqs/output_detector.py +84 -0
  21. src/linters/cqs/python_analyzer.py +54 -0
  22. src/linters/cqs/types.py +82 -0
  23. src/linters/cqs/typescript_cqs_analyzer.py +61 -0
  24. src/linters/cqs/typescript_function_analyzer.py +192 -0
  25. src/linters/cqs/typescript_input_detector.py +203 -0
  26. src/linters/cqs/typescript_output_detector.py +117 -0
  27. src/linters/cqs/violation_builder.py +94 -0
  28. src/linters/dry/typescript_value_extractor.py +2 -1
  29. src/linters/file_header/linter.py +2 -1
  30. src/linters/file_placement/linter.py +6 -6
  31. src/linters/file_placement/pattern_validator.py +6 -5
  32. src/linters/file_placement/rule_checker.py +10 -5
  33. src/linters/lazy_ignores/config.py +5 -3
  34. src/linters/lazy_ignores/python_analyzer.py +5 -1
  35. src/linters/lazy_ignores/types.py +2 -1
  36. src/linters/lbyl/__init__.py +3 -1
  37. src/linters/lbyl/linter.py +67 -0
  38. src/linters/lbyl/pattern_detectors/__init__.py +30 -2
  39. src/linters/lbyl/pattern_detectors/base.py +24 -7
  40. src/linters/lbyl/pattern_detectors/dict_key_detector.py +107 -0
  41. src/linters/lbyl/pattern_detectors/division_check_detector.py +232 -0
  42. src/linters/lbyl/pattern_detectors/file_exists_detector.py +220 -0
  43. src/linters/lbyl/pattern_detectors/hasattr_detector.py +119 -0
  44. src/linters/lbyl/pattern_detectors/isinstance_detector.py +119 -0
  45. src/linters/lbyl/pattern_detectors/len_check_detector.py +173 -0
  46. src/linters/lbyl/pattern_detectors/none_check_detector.py +146 -0
  47. src/linters/lbyl/pattern_detectors/string_validator_detector.py +145 -0
  48. src/linters/lbyl/python_analyzer.py +215 -0
  49. src/linters/lbyl/violation_builder.py +354 -0
  50. src/linters/stringly_typed/ignore_checker.py +4 -6
  51. src/orchestrator/language_detector.py +5 -3
  52. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/METADATA +4 -2
  53. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/RECORD +56 -29
  54. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/WHEEL +0 -0
  55. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/entry_points.txt +0 -0
  56. {thailint-0.15.0.dist-info → thailint-0.15.1.dist-info}/licenses/LICENSE +0 -0
@@ -74,17 +74,19 @@ def _setup_and_validate(params: ExecuteParams) -> "Orchestrator":
74
74
 
75
75
 
76
76
  def _run_string_concat_lint(
77
- orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool
77
+ orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool, parallel: bool = False
78
78
  ) -> list[Violation]:
79
79
  """Execute string-concat-loop lint on files or directories."""
80
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
80
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
81
81
  return [v for v in all_violations if v.rule_id == "performance.string-concat-loop"]
82
82
 
83
83
 
84
84
  def _execute_string_concat_lint(params: ExecuteParams) -> NoReturn:
85
85
  """Execute string-concat-loop lint."""
86
86
  orchestrator = _setup_and_validate(params)
87
- violations = _run_string_concat_lint(orchestrator, params.path_objs, params.recursive)
87
+ violations = _run_string_concat_lint(
88
+ orchestrator, params.path_objs, params.recursive, params.parallel
89
+ )
88
90
 
89
91
  if params.verbose:
90
92
  logger.info(f"Found {len(violations)} string-concat-loop violation(s)")
@@ -108,17 +110,19 @@ string_concat_loop = create_linter_command(
108
110
 
109
111
 
110
112
  def _run_regex_in_loop_lint(
111
- orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool
113
+ orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool, parallel: bool = False
112
114
  ) -> list[Violation]:
113
115
  """Execute regex-in-loop lint on files or directories."""
114
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
116
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
115
117
  return [v for v in all_violations if v.rule_id == "performance.regex-in-loop"]
116
118
 
117
119
 
118
120
  def _execute_regex_in_loop_lint(params: ExecuteParams) -> NoReturn:
119
121
  """Execute regex-in-loop lint."""
120
122
  orchestrator = _setup_and_validate(params)
121
- violations = _run_regex_in_loop_lint(orchestrator, params.path_objs, params.recursive)
123
+ violations = _run_regex_in_loop_lint(
124
+ orchestrator, params.path_objs, params.recursive, params.parallel
125
+ )
122
126
 
123
127
  if params.verbose:
124
128
  logger.info(f"Found {len(violations)} regex-in-loop violation(s)")
@@ -173,7 +177,11 @@ def _filter_by_rule(violations: list[Violation], rule: str | None) -> list[Viola
173
177
 
174
178
 
175
179
  def _run_all_perf_lint(
176
- orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool, rule: str | None
180
+ orchestrator: "Orchestrator",
181
+ path_objs: list[Path],
182
+ recursive: bool,
183
+ rule: str | None,
184
+ parallel: bool = False,
177
185
  ) -> list[Violation]:
178
186
  """Execute all performance lints on files or directories.
179
187
 
@@ -182,11 +190,12 @@ def _run_all_perf_lint(
182
190
  path_objs: List of paths to analyze
183
191
  recursive: Whether to scan directories recursively
184
192
  rule: Optional rule filter (string-concat, regex-loop, or full rule names)
193
+ parallel: Whether to use parallel processing
185
194
 
186
195
  Returns:
187
196
  List of performance-related violations
188
197
  """
189
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
198
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
190
199
  perf_violations = [v for v in all_violations if v.rule_id.startswith("performance.")]
191
200
  return _filter_by_rule(perf_violations, rule)
192
201
 
@@ -194,7 +203,9 @@ def _run_all_perf_lint(
194
203
  def _execute_perf_lint(params: ExecuteParams, rule: str | None) -> NoReturn:
195
204
  """Execute combined performance lint."""
196
205
  orchestrator = _setup_and_validate(params)
197
- violations = _run_all_perf_lint(orchestrator, params.path_objs, params.recursive, rule)
206
+ violations = _run_all_perf_lint(
207
+ orchestrator, params.path_objs, params.recursive, rule, params.parallel
208
+ )
198
209
 
199
210
  if params.verbose:
200
211
  logger.info(f"Found {len(violations)} performance violation(s)")
@@ -254,6 +265,7 @@ def perf( # pylint: disable=too-many-arguments,too-many-positional-arguments
254
265
  config_file: str | None,
255
266
  format: str,
256
267
  recursive: bool,
268
+ parallel: bool,
257
269
  rule: str | None,
258
270
  ) -> None:
259
271
  """Run all performance linters.
@@ -264,9 +276,10 @@ def perf( # pylint: disable=too-many-arguments,too-many-positional-arguments
264
276
  config_file: Optional path to config file
265
277
  format: Output format (text, json, sarif)
266
278
  recursive: Whether to scan directories recursively
279
+ parallel: Whether to use parallel processing
267
280
  rule: Optional rule filter (string-concat or regex-loop)
268
281
  """
269
- params = prepare_standard_command(ctx, paths, config_file, format, recursive)
282
+ params = prepare_standard_command(ctx, paths, config_file, format, recursive, parallel)
270
283
 
271
284
  def execute_with_rule(p: ExecuteParams) -> None:
272
285
  _execute_perf_lint(p, rule)
src/cli/linters/shared.py CHANGED
@@ -21,6 +21,10 @@ Interfaces: Orchestrator config dict manipulation, violation list filtering, CLI
21
21
  help text generation
22
22
 
23
23
  Implementation: Pure helper functions with no side effects beyond config mutation and logging
24
+
25
+ Suppressions:
26
+ - too-many-arguments,too-many-positional-arguments: CLI helper functions require many parameters
27
+ by Click framework design (ctx, paths, config_file, format, recursive, parallel = 6 params)
24
28
  """
25
29
 
26
30
  import logging
@@ -30,7 +34,12 @@ from typing import TYPE_CHECKING, Any
30
34
 
31
35
  import click
32
36
 
33
- from src.cli.utils import format_option, get_project_root_from_context, handle_linting_error
37
+ from src.cli.utils import (
38
+ format_option,
39
+ get_project_root_from_context,
40
+ handle_linting_error,
41
+ parallel_option,
42
+ )
34
43
  from src.core.types import Violation
35
44
 
36
45
  if TYPE_CHECKING:
@@ -47,15 +56,17 @@ def standard_linter_options(f: Any) -> Any:
47
56
  - config file option
48
57
  - format option
49
58
  - recursive option
59
+ - parallel option
50
60
  - pass_context
51
61
 
52
62
  Usage:
53
63
  @cli.command("my-linter")
54
64
  @standard_linter_options
55
- def my_linter(ctx, paths, config_file, format, recursive):
65
+ def my_linter(ctx, paths, config_file, format, recursive, parallel):
56
66
  ...
57
67
  """
58
68
  f = click.pass_context(f)
69
+ f = parallel_option(f)
59
70
  f = click.option(
60
71
  "--recursive/--no-recursive", default=True, help="Scan directories recursively"
61
72
  )(f)
@@ -93,6 +104,7 @@ class ExecuteParams:
93
104
  recursive: bool
94
105
  verbose: bool
95
106
  project_root: Path | None
107
+ parallel: bool = False
96
108
 
97
109
 
98
110
  def extract_command_context(ctx: click.Context, paths: tuple[str, ...]) -> CommandContext:
@@ -119,17 +131,18 @@ def extract_command_context(ctx: click.Context, paths: tuple[str, ...]) -> Comma
119
131
  return CommandContext(verbose=verbose, project_root=project_root, path_objs=path_objs)
120
132
 
121
133
 
122
- def prepare_standard_command(
134
+ def prepare_standard_command( # pylint: disable=too-many-arguments,too-many-positional-arguments
123
135
  ctx: click.Context,
124
136
  paths: tuple[str, ...],
125
137
  config_file: str | None,
126
138
  format: str,
127
139
  recursive: bool,
140
+ parallel: bool = False,
128
141
  ) -> ExecuteParams:
129
142
  """Prepare standard linter command execution parameters.
130
143
 
131
144
  Combines context extraction and ExecuteParams creation into a single call.
132
- Use with commands that have the standard options (config, format, recursive).
145
+ Use with commands that have the standard options (config, format, recursive, parallel).
133
146
 
134
147
  Args:
135
148
  ctx: Click context from command invocation
@@ -137,6 +150,7 @@ def prepare_standard_command(
137
150
  config_file: Optional config file path
138
151
  format: Output format
139
152
  recursive: Whether to scan recursively
153
+ parallel: Whether to use parallel processing
140
154
 
141
155
  Returns:
142
156
  ExecuteParams ready for _execute_*_lint function
@@ -149,6 +163,7 @@ def prepare_standard_command(
149
163
  recursive=recursive,
150
164
  verbose=cmd_ctx.verbose,
151
165
  project_root=cmd_ctx.project_root,
166
+ parallel=parallel,
152
167
  )
153
168
 
154
169
 
@@ -258,14 +273,15 @@ def create_linter_command(
258
273
 
259
274
  @cli.command(name, help=make_linter_help(name, brief, description))
260
275
  @standard_linter_options
261
- def command(
276
+ def command( # pylint: disable=too-many-arguments,too-many-positional-arguments
262
277
  ctx: click.Context,
263
278
  paths: tuple[str, ...],
264
279
  config_file: str | None,
265
280
  format: str,
266
281
  recursive: bool,
282
+ parallel: bool,
267
283
  ) -> None:
268
- params = prepare_standard_command(ctx, paths, config_file, format, recursive)
284
+ params = prepare_standard_command(ctx, paths, config_file, format, recursive, parallel)
269
285
  run_linter_command(execute_fn, params)
270
286
 
271
287
  return command
@@ -44,6 +44,7 @@ from src.cli.utils import (
44
44
  get_or_detect_project_root,
45
45
  handle_linting_error,
46
46
  load_config_file,
47
+ parallel_option,
47
48
  setup_base_orchestrator,
48
49
  validate_paths_exist,
49
50
  )
@@ -112,6 +113,7 @@ def _parse_json_rules(rules: str) -> dict[str, Any]:
112
113
  @click.option("--rules", "-r", help="Inline JSON rules configuration")
113
114
  @format_option
114
115
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
116
+ @parallel_option
115
117
  @click.pass_context
116
118
  def file_placement( # pylint: disable=too-many-arguments,too-many-positional-arguments
117
119
  ctx: click.Context,
@@ -120,6 +122,7 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
120
122
  rules: str | None,
121
123
  format: str,
122
124
  recursive: bool,
125
+ parallel: bool,
123
126
  ) -> None:
124
127
  # Justification for Pylint disables:
125
128
  # - too-many-arguments/positional: CLI requires 1 ctx + 1 arg + 4 options = 6 params
@@ -166,6 +169,7 @@ def file_placement( # pylint: disable=too-many-arguments,too-many-positional-ar
166
169
  rules,
167
170
  format,
168
171
  recursive,
172
+ parallel,
169
173
  cmd_ctx.verbose,
170
174
  cmd_ctx.project_root,
171
175
  )
@@ -179,13 +183,14 @@ def _execute_file_placement_lint( # pylint: disable=too-many-arguments,too-many
179
183
  rules: str | None,
180
184
  format: str,
181
185
  recursive: bool,
186
+ parallel: bool,
182
187
  verbose: bool,
183
188
  project_root: Path | None = None,
184
189
  ) -> NoReturn:
185
190
  """Execute file placement linting."""
186
191
  validate_paths_exist(path_objs)
187
192
  orchestrator = _setup_orchestrator(path_objs, config_file, rules, verbose, project_root)
188
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
193
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
189
194
 
190
195
  # Filter to only file-placement violations
191
196
  violations = [v for v in all_violations if v.rule_id.startswith("file-placement")]
@@ -221,10 +226,10 @@ def _apply_pipeline_config_override(
221
226
 
222
227
 
223
228
  def _run_pipeline_lint(
224
- orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool
229
+ orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool, parallel: bool = False
225
230
  ) -> list[Violation]:
226
231
  """Execute collection-pipeline lint on files or directories."""
227
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
232
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
228
233
  return [v for v in all_violations if "collection-pipeline" in v.rule_id]
229
234
 
230
235
 
@@ -234,6 +239,7 @@ def _run_pipeline_lint(
234
239
  @format_option
235
240
  @click.option("--min-continues", type=int, help="Override min continue guards to flag (default: 1)")
236
241
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
242
+ @parallel_option
237
243
  @click.pass_context
238
244
  def pipeline( # pylint: disable=too-many-arguments,too-many-positional-arguments
239
245
  ctx: click.Context,
@@ -242,6 +248,7 @@ def pipeline( # pylint: disable=too-many-arguments,too-many-positional-argument
242
248
  format: str,
243
249
  min_continues: int | None,
244
250
  recursive: bool,
251
+ parallel: bool,
245
252
  ) -> None:
246
253
  """Check for collection pipeline anti-patterns in code.
247
254
 
@@ -289,6 +296,7 @@ def pipeline( # pylint: disable=too-many-arguments,too-many-positional-argument
289
296
  format,
290
297
  min_continues,
291
298
  recursive,
299
+ parallel,
292
300
  cmd_ctx.verbose,
293
301
  cmd_ctx.project_root,
294
302
  )
@@ -302,6 +310,7 @@ def _execute_pipeline_lint( # pylint: disable=too-many-arguments,too-many-posit
302
310
  format: str,
303
311
  min_continues: int | None,
304
312
  recursive: bool,
313
+ parallel: bool,
305
314
  verbose: bool,
306
315
  project_root: Path | None = None,
307
316
  ) -> NoReturn:
@@ -309,7 +318,7 @@ def _execute_pipeline_lint( # pylint: disable=too-many-arguments,too-many-posit
309
318
  validate_paths_exist(path_objs)
310
319
  orchestrator = _setup_pipeline_orchestrator(path_objs, config_file, verbose, project_root)
311
320
  _apply_pipeline_config_override(orchestrator, min_continues, verbose)
312
- pipeline_violations = _run_pipeline_lint(orchestrator, path_objs, recursive)
321
+ pipeline_violations = _run_pipeline_lint(orchestrator, path_objs, recursive, parallel)
313
322
 
314
323
  if verbose:
315
324
  logger.info(f"Found {len(pipeline_violations)} collection-pipeline violation(s)")
@@ -26,6 +26,7 @@ Suppressions:
26
26
 
27
27
  import logging
28
28
  import sys
29
+ from contextlib import suppress
29
30
  from pathlib import Path
30
31
  from typing import TYPE_CHECKING, NoReturn
31
32
 
@@ -85,7 +86,7 @@ def _apply_nesting_config_override(
85
86
  def _apply_nesting_to_languages(nesting_config: dict, max_depth: int) -> None:
86
87
  """Apply max_depth to language-specific configs."""
87
88
  for lang in ["python", "typescript", "javascript"]:
88
- if lang in nesting_config:
89
+ with suppress(KeyError):
89
90
  nesting_config[lang]["max_nesting_depth"] = max_depth
90
91
 
91
92
 
@@ -220,10 +221,10 @@ def _apply_srp_config_override(
220
221
 
221
222
 
222
223
  def _run_srp_lint(
223
- orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool
224
+ orchestrator: "Orchestrator", path_objs: list[Path], recursive: bool, parallel: bool = False
224
225
  ) -> list[Violation]:
225
226
  """Execute SRP lint on files or directories."""
226
- all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive)
227
+ all_violations = execute_linting_on_paths(orchestrator, path_objs, recursive, parallel)
227
228
  return [v for v in all_violations if "srp" in v.rule_id]
228
229
 
229
230
 
@@ -234,6 +235,7 @@ def _run_srp_lint(
234
235
  @click.option("--max-methods", type=int, help="Override max methods per class (default: 7)")
235
236
  @click.option("--max-loc", type=int, help="Override max lines of code per class (default: 200)")
236
237
  @click.option("--recursive/--no-recursive", default=True, help="Scan directories recursively")
238
+ @parallel_option
237
239
  @click.pass_context
238
240
  def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
239
241
  ctx: click.Context,
@@ -243,6 +245,7 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
243
245
  max_methods: int | None,
244
246
  max_loc: int | None,
245
247
  recursive: bool,
248
+ parallel: bool,
246
249
  ) -> None:
247
250
  """Check for Single Responsibility Principle violations.
248
251
 
@@ -293,6 +296,7 @@ def srp( # pylint: disable=too-many-arguments,too-many-positional-arguments
293
296
  max_methods,
294
297
  max_loc,
295
298
  recursive,
299
+ parallel,
296
300
  cmd_ctx.verbose,
297
301
  cmd_ctx.project_root,
298
302
  )
@@ -307,6 +311,7 @@ def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional
307
311
  max_methods: int | None,
308
312
  max_loc: int | None,
309
313
  recursive: bool,
314
+ parallel: bool,
310
315
  verbose: bool,
311
316
  project_root: Path | None = None,
312
317
  ) -> NoReturn:
@@ -314,7 +319,7 @@ def _execute_srp_lint( # pylint: disable=too-many-arguments,too-many-positional
314
319
  validate_paths_exist(path_objs)
315
320
  orchestrator = _setup_srp_orchestrator(path_objs, config_file, verbose, project_root)
316
321
  _apply_srp_config_override(orchestrator, max_methods, max_loc, verbose)
317
- srp_violations = _run_srp_lint(orchestrator, path_objs, recursive)
322
+ srp_violations = _run_srp_lint(orchestrator, path_objs, recursive, parallel)
318
323
 
319
324
  if verbose:
320
325
  logger.info(f"Found {len(srp_violations)} SRP violation(s)")
src/cli/utils.py CHANGED
@@ -24,8 +24,9 @@ Implementation: Uses Click decorators for option definitions, deferred imports f
24
24
  import logging
25
25
  import sys
26
26
  from collections.abc import Callable
27
+ from contextlib import suppress
27
28
  from pathlib import Path
28
- from typing import TYPE_CHECKING, Any, TypeVar
29
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
29
30
 
30
31
  import click
31
32
 
@@ -188,9 +189,8 @@ def get_project_root_from_context(ctx: click.Context) -> Path | None:
188
189
  Path to determined project root, or None for auto-detection from target paths
189
190
  """
190
191
  # Check if already determined and cached
191
- if "project_root" in ctx.obj:
192
- cached: Path | None = ctx.obj["project_root"]
193
- return cached
192
+ with suppress(KeyError):
193
+ return cast(Path | None, ctx.obj["project_root"])
194
194
 
195
195
  project_root = _determine_project_root_for_context(ctx)
196
196
  ctx.obj["project_root"] = project_root
src/config.py CHANGED
@@ -237,37 +237,47 @@ def _validate_required_keys(config: dict[str, Any], errors: list[str]) -> None:
237
237
  def _validate_log_level(config: dict[str, Any], errors: list[str]) -> None:
238
238
  """Validate log level is a valid value."""
239
239
  valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
240
- if "log_level" in config:
241
- if config["log_level"] not in valid_log_levels:
242
- errors.append(
243
- f"Invalid log_level: {config['log_level']}. "
244
- f"Must be one of: {', '.join(valid_log_levels)}"
245
- )
240
+ try:
241
+ log_level = config["log_level"]
242
+ except KeyError:
243
+ return # Optional key not present
244
+ if log_level not in valid_log_levels:
245
+ errors.append(
246
+ f"Invalid log_level: {log_level}. Must be one of: {', '.join(valid_log_levels)}"
247
+ )
246
248
 
247
249
 
248
250
  def _validate_output_format(config: dict[str, Any], errors: list[str]) -> None:
249
251
  """Validate output format is a valid value."""
250
252
  valid_formats = ["text", "json", "yaml"]
251
- if "output_format" in config:
252
- if config["output_format"] not in valid_formats:
253
- errors.append(
254
- f"Invalid output_format: {config['output_format']}. "
255
- f"Must be one of: {', '.join(valid_formats)}"
256
- )
253
+ try:
254
+ output_format = config["output_format"]
255
+ except KeyError:
256
+ return # Optional key not present
257
+ if output_format not in valid_formats:
258
+ errors.append(
259
+ f"Invalid output_format: {output_format}. Must be one of: {', '.join(valid_formats)}"
260
+ )
257
261
 
258
262
 
259
263
  def _validate_max_retries(config: dict[str, Any], errors: list[str]) -> None:
260
264
  """Validate max_retries configuration value."""
261
- if "max_retries" in config:
262
- if not isinstance(config["max_retries"], int) or config["max_retries"] < 0:
263
- errors.append("max_retries must be a non-negative integer")
265
+ try:
266
+ max_retries = config["max_retries"]
267
+ except KeyError:
268
+ return # Optional key not present
269
+ if not isinstance(max_retries, int) or max_retries < 0:
270
+ errors.append("max_retries must be a non-negative integer")
264
271
 
265
272
 
266
273
  def _validate_timeout(config: dict[str, Any], errors: list[str]) -> None:
267
274
  """Validate timeout configuration value."""
268
- if "timeout" in config:
269
- if not isinstance(config["timeout"], (int, float)) or config["timeout"] <= 0:
270
- errors.append("timeout must be a positive number")
275
+ try:
276
+ timeout = config["timeout"]
277
+ except KeyError:
278
+ return # Optional key not present
279
+ if not isinstance(timeout, (int, float)) or timeout <= 0:
280
+ errors.append("timeout must be a positive number")
271
281
 
272
282
 
273
283
  def _validate_numeric_values(config: dict[str, Any], errors: list[str]) -> None:
@@ -278,9 +288,12 @@ def _validate_numeric_values(config: dict[str, Any], errors: list[str]) -> None:
278
288
 
279
289
  def _validate_string_values(config: dict[str, Any], errors: list[str]) -> None:
280
290
  """Validate string configuration values."""
281
- if "app_name" in config:
282
- if not isinstance(config["app_name"], str) or not config["app_name"].strip():
283
- errors.append("app_name must be a non-empty string")
291
+ try:
292
+ app_name = config["app_name"]
293
+ except KeyError:
294
+ return # Optional key not present
295
+ if not isinstance(app_name, str) or not app_name.strip():
296
+ errors.append("app_name must be a non-empty string")
284
297
 
285
298
 
286
299
  def validate_config(config: dict[str, Any]) -> tuple[bool, list[str]]:
@@ -0,0 +1,101 @@
1
+ """
2
+ Purpose: Base class for Python-only linters with common boilerplate
3
+
4
+ Scope: Shared infrastructure for Python-only lint rules
5
+
6
+ Overview: Provides PythonOnlyLintRule abstract base class that handles common boilerplate
7
+ for Python-only linters. Subclasses implement the abstract properties and analysis
8
+ method while the base class handles language checking, config loading, and enabled
9
+ checking. This eliminates duplicate code across Python-only linters like CQS and LBYL.
10
+
11
+ Dependencies: BaseLintRule, BaseLintContext, Language, load_linter_config, has_file_content
12
+
13
+ Exports: PythonOnlyLintRule
14
+
15
+ Interfaces: Subclasses implement _config_key, _config_class, _analyze, and rule metadata
16
+
17
+ Implementation: Template method pattern for Python linter boilerplate
18
+ """
19
+
20
+ from abc import abstractmethod
21
+ from typing import Any, Generic
22
+
23
+ from .base import BaseLintContext, BaseLintRule
24
+ from .constants import Language
25
+ from .linter_utils import ConfigType, has_file_content, load_linter_config
26
+ from .types import Violation
27
+
28
+
29
+ class PythonOnlyLintRule(BaseLintRule, Generic[ConfigType]):
30
+ """Base class for Python-only linters with common boilerplate.
31
+
32
+ Handles language checking, config loading, and enabled checking.
33
+ Subclasses provide the config key, config class, and analysis logic.
34
+ """
35
+
36
+ def __init__(self, config: ConfigType | None = None) -> None:
37
+ """Initialize with optional config override.
38
+
39
+ Args:
40
+ config: Optional configuration override for testing
41
+ """
42
+ self._config_override = config
43
+
44
+ @property
45
+ @abstractmethod
46
+ def _config_key(self) -> str:
47
+ """Configuration key in metadata (e.g., 'cqs', 'lbyl')."""
48
+ raise NotImplementedError
49
+
50
+ @property
51
+ @abstractmethod
52
+ def _config_class(self) -> type[ConfigType]:
53
+ """Configuration class type."""
54
+ raise NotImplementedError
55
+
56
+ @abstractmethod
57
+ def _analyze(self, code: str, file_path: str, config: ConfigType) -> list[Violation]:
58
+ """Perform linter-specific analysis.
59
+
60
+ Args:
61
+ code: Python source code
62
+ file_path: Path to the file
63
+ config: Loaded configuration
64
+
65
+ Returns:
66
+ List of violations found
67
+ """
68
+ raise NotImplementedError
69
+
70
+ def check(self, context: BaseLintContext) -> list[Violation]:
71
+ """Check for violations in the given context.
72
+
73
+ Args:
74
+ context: The lint context containing file information.
75
+
76
+ Returns:
77
+ List of violations found.
78
+ """
79
+ if not self._should_analyze(context):
80
+ return []
81
+
82
+ config = self._get_config(context)
83
+ if not self._is_enabled(config):
84
+ return []
85
+
86
+ file_path = str(context.file_path) if context.file_path else "unknown"
87
+ return self._analyze(context.file_content or "", file_path, config)
88
+
89
+ def _should_analyze(self, context: BaseLintContext) -> bool:
90
+ """Check if context should be analyzed."""
91
+ return context.language == Language.PYTHON and has_file_content(context)
92
+
93
+ def _get_config(self, context: BaseLintContext) -> ConfigType:
94
+ """Get configuration, using override if provided."""
95
+ if self._config_override is not None:
96
+ return self._config_override
97
+ return load_linter_config(context, self._config_key, self._config_class)
98
+
99
+ def _is_enabled(self, config: Any) -> bool:
100
+ """Check if linter is enabled in config."""
101
+ return getattr(config, "enabled", True)
@@ -27,6 +27,7 @@ Suppressions:
27
27
 
28
28
  import logging
29
29
  import re
30
+ from contextlib import suppress
30
31
  from pathlib import Path
31
32
  from typing import TYPE_CHECKING
32
33
 
@@ -66,7 +67,7 @@ class IgnoreDirectiveParser:
66
67
  def is_ignored(self, file_path: Path) -> bool:
67
68
  """Check if file matches repository-level ignore patterns (cached)."""
68
69
  path_str = str(file_path)
69
- if path_str in self._ignore_cache:
70
+ with suppress(KeyError):
70
71
  return self._ignore_cache[path_str]
71
72
  try:
72
73
  check_path = str(file_path.relative_to(self.project_root))
@@ -0,0 +1,54 @@
1
+ """
2
+ Purpose: CQS (Command-Query Separation) linter package exports
3
+
4
+ Scope: Detect CQS violations in Python and TypeScript code
5
+
6
+ Overview: Package providing CQS violation detection for Python and TypeScript code.
7
+ Identifies functions that mix INPUT operations (queries that return values captured
8
+ in variables) and OUTPUT operations (commands that perform side effects without
9
+ capturing return values). Functions should either query state and return a value,
10
+ or command a change and return nothing. Mixing these violates CQS principles and
11
+ makes code harder to reason about.
12
+
13
+ Dependencies: ast module for Python parsing, tree-sitter for TypeScript parsing
14
+
15
+ Exports: CQSConfig, CQSPattern, CQSRule, FunctionAnalyzer, InputOperation, OutputOperation,
16
+ PythonCQSAnalyzer, TypeScriptCQSAnalyzer, TypeScriptFunctionAnalyzer,
17
+ TypeScriptInputDetector, TypeScriptOutputDetector, build_cqs_violation
18
+
19
+ Interfaces: CQSConfig.from_dict() for YAML configuration loading,
20
+ CQSRule.check() for BaseLintRule interface
21
+
22
+ Implementation: AST-based pattern detection for Python, tree-sitter for TypeScript,
23
+ with configurable ignore rules
24
+ """
25
+
26
+ from .config import CQSConfig
27
+ from .function_analyzer import FunctionAnalyzer
28
+ from .input_detector import InputDetector
29
+ from .linter import CQSRule
30
+ from .output_detector import OutputDetector
31
+ from .python_analyzer import PythonCQSAnalyzer
32
+ from .types import CQSPattern, InputOperation, OutputOperation
33
+ from .typescript_cqs_analyzer import TypeScriptCQSAnalyzer
34
+ from .typescript_function_analyzer import TypeScriptFunctionAnalyzer
35
+ from .typescript_input_detector import TypeScriptInputDetector
36
+ from .typescript_output_detector import TypeScriptOutputDetector
37
+ from .violation_builder import build_cqs_violation
38
+
39
+ __all__ = [
40
+ "CQSConfig",
41
+ "CQSPattern",
42
+ "CQSRule",
43
+ "FunctionAnalyzer",
44
+ "InputDetector",
45
+ "InputOperation",
46
+ "OutputDetector",
47
+ "OutputOperation",
48
+ "PythonCQSAnalyzer",
49
+ "TypeScriptCQSAnalyzer",
50
+ "TypeScriptFunctionAnalyzer",
51
+ "TypeScriptInputDetector",
52
+ "TypeScriptOutputDetector",
53
+ "build_cqs_violation",
54
+ ]