ruff-legibility 0.1.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.
@@ -0,0 +1,13 @@
1
+ """Ruff-adjacent legibility checks for Python."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .core import Diagnostic, check_path, check_source
6
+ from .rules import RULES
7
+
8
+ __all__ = ["Diagnostic", "RULES", "check_path", "check_source"]
9
+
10
+ try:
11
+ __version__ = version("ruff-legibility")
12
+ except PackageNotFoundError:
13
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
ruff_legibility/cli.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from collections.abc import Sequence
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import apply_overrides, load_settings, parse_selectors
11
+ from .core import Diagnostic, check_path, discover_python_files
12
+ from .rules import RULES
13
+
14
+
15
+ def main(argv: Sequence[str] | None = None) -> int:
16
+ args = _parse_args(list(argv) if argv is not None else sys.argv[1:])
17
+
18
+ if args.command == "rules":
19
+ _print_rules()
20
+ return 0
21
+
22
+ try:
23
+ settings = load_settings(Path(args.config) if args.config else None)
24
+ settings = apply_overrides(
25
+ settings,
26
+ select=parse_selectors(args.select),
27
+ ignore=parse_selectors(args.ignore),
28
+ max_expression_operators=args.max_expression_operators,
29
+ max_if_operators=args.max_if_operators,
30
+ max_ternary_operators=args.max_ternary_operators,
31
+ max_control_flow_depth=args.max_control_flow_depth,
32
+ )
33
+ except ValueError as error:
34
+ print(f"ruff-legibility: {error}", file=sys.stderr)
35
+ return 2
36
+
37
+ paths = [Path(path) for path in args.paths]
38
+ files = discover_python_files(paths, settings)
39
+ diagnostics = _check_files(files, settings)
40
+ _print_diagnostics(diagnostics, output_format=args.output_format)
41
+
42
+ if args.exit_zero:
43
+ return 0
44
+ return 1 if diagnostics else 0
45
+
46
+
47
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
48
+ top_level_flags = {"-h", "--help", "--version"}
49
+ if not argv or (argv[0] not in {"check", "rules"} and argv[0] not in top_level_flags):
50
+ argv = ["check", *argv]
51
+
52
+ parser = argparse.ArgumentParser(prog="ruff-legibility")
53
+ parser.add_argument("--version", action="version", version=f"ruff-legibility {__version__}")
54
+ subparsers = parser.add_subparsers(dest="command", required=True)
55
+
56
+ check = subparsers.add_parser("check", help="check Python files")
57
+ check.add_argument("paths", nargs="*", default=["."], help="files or directories to check")
58
+ check.add_argument("--config", help="path to ruff-legibility.toml or pyproject.toml")
59
+ check.add_argument("--select", help="comma-separated rule selectors to enable")
60
+ check.add_argument("--ignore", help="comma-separated rule selectors to ignore")
61
+ check.add_argument(
62
+ "--output-format",
63
+ "--format",
64
+ choices=("text", "json", "github"),
65
+ default="text",
66
+ help="diagnostic output format",
67
+ )
68
+ check.add_argument("--exit-zero", action="store_true", help="always exit successfully")
69
+ check.add_argument("--max-expression-operators", type=int)
70
+ check.add_argument("--max-if-operators", type=int)
71
+ check.add_argument("--max-ternary-operators", type=int)
72
+ check.add_argument("--max-control-flow-depth", type=int)
73
+
74
+ subparsers.add_parser("rules", help="list available rules")
75
+ return parser.parse_args(argv)
76
+
77
+
78
+ def _check_files(files: list[Path], settings) -> list[Diagnostic]:
79
+ diagnostics: list[Diagnostic] = []
80
+ for file in files:
81
+ diagnostics.extend(check_path(file, settings))
82
+ return sorted(diagnostics)
83
+
84
+
85
+ def _print_diagnostics(diagnostics: list[Diagnostic], *, output_format: str) -> None:
86
+ if output_format == "json":
87
+ print(json.dumps([diagnostic.to_json() for diagnostic in diagnostics], indent=2))
88
+ return
89
+
90
+ for diagnostic in diagnostics:
91
+ if output_format == "github":
92
+ file_name = _escape_github_command_property(diagnostic.path.as_posix())
93
+ message = _escape_github_command_data(f"{diagnostic.code} {diagnostic.message}")
94
+ print(
95
+ f"::warning file={file_name},"
96
+ f"line={diagnostic.line},col={diagnostic.column}::"
97
+ f"{message}"
98
+ )
99
+ else:
100
+ print(
101
+ f"{diagnostic.path.as_posix()}:{diagnostic.line}:{diagnostic.column}: "
102
+ f"{diagnostic.code} {diagnostic.message}"
103
+ )
104
+
105
+
106
+ def _escape_github_command_data(value: str) -> str:
107
+ return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
108
+
109
+
110
+ def _escape_github_command_property(value: str) -> str:
111
+ return _escape_github_command_data(value).replace(":", "%3A").replace(",", "%2C")
112
+
113
+
114
+ def _print_rules() -> None:
115
+ for code, rule in RULES.items():
116
+ print(f"{code} {rule.name}: {rule.summary}")
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import tomllib
5
+ from dataclasses import dataclass, field, replace
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .rules import DEFAULT_SELECT, RULES
10
+
11
+ DEFAULT_EXCLUDE = (
12
+ ".bzr",
13
+ ".direnv",
14
+ ".eggs",
15
+ ".git",
16
+ ".hg",
17
+ ".mypy_cache",
18
+ ".nox",
19
+ ".pants.d",
20
+ ".pytype",
21
+ ".ruff_cache",
22
+ ".svn",
23
+ ".tox",
24
+ ".venv",
25
+ "__pypackages__",
26
+ "_build",
27
+ "build",
28
+ "dist",
29
+ "node_modules",
30
+ "site-packages",
31
+ "venv",
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Settings:
37
+ select: tuple[str, ...] = DEFAULT_SELECT
38
+ ignore: tuple[str, ...] = ()
39
+ exclude: tuple[str, ...] = DEFAULT_EXCLUDE
40
+ per_file_ignores: dict[str, tuple[str, ...]] = field(default_factory=dict)
41
+ max_expression_operators: int = 4
42
+ max_if_operators: int = 0
43
+ max_ternary_operators: int = 2
44
+ max_control_flow_depth: int = 3
45
+
46
+ def enabled(self, code: str) -> bool:
47
+ return selector_matches(code, self.select) and not selector_matches(code, self.ignore)
48
+
49
+ def ignored_for_path(self, code: str, path: Path) -> bool:
50
+ path_text = path.as_posix()
51
+ for pattern, selectors in self.per_file_ignores.items():
52
+ if fnmatch.fnmatch(path_text, pattern) and selector_matches(code, selectors):
53
+ return True
54
+ return False
55
+
56
+
57
+ def selector_matches(code: str, selectors: tuple[str, ...] | list[str] | set[str]) -> bool:
58
+ return any(code.startswith(selector) for selector in selectors)
59
+
60
+
61
+ def parse_selectors(value: str | None) -> tuple[str, ...] | None:
62
+ if value is None:
63
+ return None
64
+
65
+ selectors = tuple(part.strip().upper() for part in value.split(",") if part.strip())
66
+ return selectors
67
+
68
+
69
+ def validate_selectors(selectors: tuple[str, ...]) -> None:
70
+ unknown_rules = [selector for selector in selectors if selector != "LEG" and selector not in RULES]
71
+ if unknown_rules:
72
+ joined = ", ".join(sorted(set(unknown_rules)))
73
+ raise ValueError(f"Unknown rule selector(s): {joined}")
74
+
75
+
76
+ def find_config(start: Path | None = None) -> Path | None:
77
+ current = (start or Path.cwd()).resolve()
78
+ if current.is_file():
79
+ current = current.parent
80
+
81
+ for candidate in _candidate_config_paths(current):
82
+ if not candidate.is_file():
83
+ continue
84
+ if candidate.name == "pyproject.toml" and not _pyproject_has_config(candidate):
85
+ continue
86
+ return candidate
87
+
88
+ return None
89
+
90
+
91
+ def load_settings(config_path: Path | None = None, cwd: Path | None = None) -> Settings:
92
+ settings = Settings()
93
+ path = config_path or find_config(cwd)
94
+ if path is None:
95
+ return settings
96
+
97
+ data = tomllib.loads(path.read_text())
98
+ table = _config_table(data, path)
99
+ return apply_config(settings, table)
100
+
101
+
102
+ def apply_overrides(
103
+ settings: Settings,
104
+ *,
105
+ select: tuple[str, ...] | None = None,
106
+ ignore: tuple[str, ...] | None = None,
107
+ max_expression_operators: int | None = None,
108
+ max_if_operators: int | None = None,
109
+ max_ternary_operators: int | None = None,
110
+ max_control_flow_depth: int | None = None,
111
+ ) -> Settings:
112
+ updates: dict[str, Any] = {}
113
+ if select is not None:
114
+ validate_selectors(select)
115
+ updates["select"] = select
116
+ if ignore is not None:
117
+ validate_selectors(ignore)
118
+ updates["ignore"] = ignore
119
+ if max_expression_operators is not None:
120
+ updates["max_expression_operators"] = max_expression_operators
121
+ if max_if_operators is not None:
122
+ updates["max_if_operators"] = max_if_operators
123
+ if max_ternary_operators is not None:
124
+ updates["max_ternary_operators"] = max_ternary_operators
125
+ if max_control_flow_depth is not None:
126
+ updates["max_control_flow_depth"] = max_control_flow_depth
127
+ return replace(settings, **updates)
128
+
129
+
130
+ def apply_config(settings: Settings, table: dict[str, Any]) -> Settings:
131
+ updates: dict[str, Any] = {}
132
+
133
+ selectors = _string_list(table.get("select"), "select")
134
+ if selectors is not None:
135
+ updates["select"] = tuple(selector.upper() for selector in selectors)
136
+
137
+ extend_selectors = _string_list(table.get("extend-select", table.get("extend_select")), "extend-select")
138
+ if extend_selectors is not None:
139
+ existing_selectors = updates.get("select", settings.select)
140
+ updates["select"] = (*existing_selectors, *(selector.upper() for selector in extend_selectors))
141
+
142
+ ignored = _string_list(table.get("ignore"), "ignore")
143
+ if ignored is not None:
144
+ updates["ignore"] = tuple(selector.upper() for selector in ignored)
145
+
146
+ extend_ignored = _string_list(table.get("extend-ignore", table.get("extend_ignore")), "extend-ignore")
147
+ if extend_ignored is not None:
148
+ existing_ignored = updates.get("ignore", settings.ignore)
149
+ updates["ignore"] = (*existing_ignored, *(selector.upper() for selector in extend_ignored))
150
+
151
+ excluded = _string_list(table.get("exclude"), "exclude")
152
+ if excluded is not None:
153
+ updates["exclude"] = tuple(excluded)
154
+
155
+ per_file_ignores = table.get("per-file-ignores", table.get("per_file_ignores"))
156
+ if per_file_ignores is not None:
157
+ if not isinstance(per_file_ignores, dict):
158
+ raise ValueError("per-file-ignores must be a table")
159
+ updates["per_file_ignores"] = {
160
+ str(pattern): tuple(code.upper() for code in _required_string_list(codes, str(pattern)))
161
+ for pattern, codes in per_file_ignores.items()
162
+ }
163
+
164
+ int_options = {
165
+ "max-expression-operators": "max_expression_operators",
166
+ "max-if-operators": "max_if_operators",
167
+ "max-ternary-operators": "max_ternary_operators",
168
+ "max-control-flow-depth": "max_control_flow_depth",
169
+ }
170
+ for option_name, field_name in int_options.items():
171
+ if option_name not in table:
172
+ continue
173
+ value = table[option_name]
174
+ if not isinstance(value, int):
175
+ raise ValueError(f"{option_name} must be an integer")
176
+ updates[field_name] = value
177
+
178
+ validate_selectors(updates.get("select", settings.select))
179
+ validate_selectors(updates.get("ignore", settings.ignore))
180
+
181
+ return replace(settings, **updates)
182
+
183
+
184
+ def _pyproject_has_config(path: Path) -> bool:
185
+ try:
186
+ data = tomllib.loads(path.read_text())
187
+ except tomllib.TOMLDecodeError:
188
+ return False
189
+
190
+ tool = data.get("tool")
191
+ return isinstance(tool, dict) and isinstance(tool.get("ruff-legibility"), dict)
192
+
193
+
194
+ def _candidate_config_paths(current: Path) -> tuple[Path, ...]:
195
+ directories = (current, *current.parents)
196
+ names = ("ruff-legibility.toml", ".ruff-legibility.toml", "pyproject.toml")
197
+ return tuple(directory / name for directory in directories for name in names)
198
+
199
+
200
+ def _config_table(data: dict[str, Any], path: Path) -> dict[str, Any]:
201
+ if path.name == "pyproject.toml":
202
+ tool = data.get("tool", {})
203
+ if not isinstance(tool, dict):
204
+ return {}
205
+ table = tool.get("ruff-legibility", {})
206
+ if not isinstance(table, dict):
207
+ raise ValueError("[tool.ruff-legibility] must be a table")
208
+ return table
209
+
210
+ return data
211
+
212
+
213
+ def _string_list(value: Any, name: str) -> list[str] | None:
214
+ if value is None:
215
+ return None
216
+ return _required_string_list(value, name)
217
+
218
+
219
+ def _required_string_list(value: Any, name: str) -> list[str]:
220
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
221
+ raise ValueError(f"{name} must be a list of strings")
222
+ return value
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Iterable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .config import Settings
9
+ from .noqa import is_noqa_suppressed
10
+ from .rules import LegibilityVisitor
11
+
12
+
13
+ @dataclass(frozen=True, order=True)
14
+ class Diagnostic:
15
+ path: Path
16
+ line: int
17
+ column: int
18
+ code: str
19
+ message: str
20
+ end_line: int | None = None
21
+ end_column: int | None = None
22
+
23
+ def to_json(self) -> dict[str, object]:
24
+ return {
25
+ "filename": self.path.as_posix(),
26
+ "location": {"row": self.line, "column": self.column},
27
+ "end_location": {
28
+ "row": self.end_line or self.line,
29
+ "column": self.end_column or self.column,
30
+ },
31
+ "code": self.code,
32
+ "message": self.message,
33
+ }
34
+
35
+
36
+ def check_path(path: Path, settings: Settings) -> list[Diagnostic]:
37
+ source = path.read_text(encoding="utf-8")
38
+ return check_source(source, path=path, settings=settings)
39
+
40
+
41
+ def check_source(source: str, *, path: Path, settings: Settings) -> list[Diagnostic]:
42
+ try:
43
+ tree = ast.parse(source, filename=path.as_posix(), type_comments=True)
44
+ except SyntaxError as error:
45
+ line = error.lineno or 1
46
+ column = (error.offset or 1) - 1
47
+ return [
48
+ Diagnostic(
49
+ path=path,
50
+ line=line,
51
+ column=column + 1,
52
+ code="LEG999",
53
+ message=f"SyntaxError: {error.msg}",
54
+ )
55
+ ]
56
+
57
+ visitor = LegibilityVisitor(path=path, settings=settings)
58
+ visitor.visit(tree)
59
+ lines = source.splitlines()
60
+ diagnostics = [
61
+ diagnostic
62
+ for diagnostic in visitor.diagnostics
63
+ if not settings.ignored_for_path(diagnostic.code, path)
64
+ and not is_noqa_suppressed(lines, diagnostic.line, diagnostic.code)
65
+ ]
66
+ return sorted(diagnostics)
67
+
68
+
69
+ def discover_python_files(paths: Iterable[Path], settings: Settings) -> list[Path]:
70
+ files: list[Path] = []
71
+ for path in paths:
72
+ if path.is_file():
73
+ if path.suffix == ".py" and not _is_excluded(path, settings):
74
+ files.append(path)
75
+ continue
76
+
77
+ if path.is_dir():
78
+ files.extend(_discover_directory_python_files(path, settings))
79
+
80
+ return sorted(set(files))
81
+
82
+
83
+ def _discover_directory_python_files(path: Path, settings: Settings) -> list[Path]:
84
+ return [candidate for candidate in path.rglob("*.py") if not _is_excluded(candidate, settings)]
85
+
86
+
87
+ def _is_excluded(path: Path, settings: Settings) -> bool:
88
+ parts = set(path.parts)
89
+ return any(excluded in parts or path.match(excluded) for excluded in settings.exclude)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ NOQA_PATTERN = re.compile(r"#\s*noqa(?::\s*(?P<codes>[A-Z0-9,\s]+))?", re.IGNORECASE)
6
+
7
+
8
+ def is_noqa_suppressed(lines: list[str], line_number: int, code: str) -> bool:
9
+ if line_number < 1 or line_number > len(lines):
10
+ return False
11
+
12
+ line = lines[line_number - 1]
13
+ match = NOQA_PATTERN.search(line)
14
+ if match is None:
15
+ return False
16
+
17
+ codes = match.group("codes")
18
+ if codes is None:
19
+ return True
20
+
21
+ selectors = [part.strip().upper() for part in codes.split(",") if part.strip()]
22
+ return any(code.startswith(selector) for selector in selectors)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,354 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from .config import Settings
12
+ from .core import Diagnostic
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Rule:
17
+ code: str
18
+ name: str
19
+ summary: str
20
+
21
+
22
+ RULES = {
23
+ "LEG001": Rule(
24
+ "LEG001",
25
+ "max-expression-operators",
26
+ "Limit readability operators inside a single expression.",
27
+ ),
28
+ "LEG002": Rule(
29
+ "LEG002",
30
+ "hoist-if-operators",
31
+ "Prefer named booleans before operator-heavy conditions.",
32
+ ),
33
+ "LEG003": Rule(
34
+ "LEG003",
35
+ "max-control-flow-depth",
36
+ "Limit nested control-flow depth.",
37
+ ),
38
+ "LEG004": Rule(
39
+ "LEG004",
40
+ "no-complex-ternary",
41
+ "Avoid complex ternary expressions.",
42
+ ),
43
+ "LEG005": Rule(
44
+ "LEG005",
45
+ "no-quadratic-patterns",
46
+ "Flag likely quadratic loops and repeated searches.",
47
+ ),
48
+ "LEG006": Rule(
49
+ "LEG006",
50
+ "no-redundant-boolean-logic",
51
+ "Avoid redundant boolean comparisons.",
52
+ ),
53
+ "LEG007": Rule(
54
+ "LEG007",
55
+ "prefer-positive-condition-names",
56
+ "Prefer positive condition names.",
57
+ ),
58
+ }
59
+
60
+ DEFAULT_SELECT = ("LEG",)
61
+
62
+ CONTROL_FLOW_NODES = (
63
+ ast.AsyncFor,
64
+ ast.AsyncWith,
65
+ ast.For,
66
+ ast.If,
67
+ ast.Match,
68
+ ast.Try,
69
+ ast.While,
70
+ ast.With,
71
+ )
72
+
73
+ BOOLEAN_NAME_PATTERN = re.compile(r"^(?:is|are|was|were|has|have|had|can|could|should|will|would|did|does)_")
74
+ NEGATIVE_CONDITION_PATTERN = re.compile(
75
+ r"^(?:is|are|was|were|has|have|had|can|could|should|will|would|did|does)_"
76
+ r"(?:not|no|without)_"
77
+ )
78
+
79
+
80
+ class LegibilityVisitor(ast.NodeVisitor):
81
+ def __init__(self, *, path: Path, settings: Settings) -> None:
82
+ self.path = path
83
+ self.settings = settings
84
+ self.diagnostics: list[Diagnostic] = []
85
+ self.control_depth = 0
86
+ self.loop_depth = 0
87
+
88
+ def visit_If(self, node: ast.If) -> None:
89
+ self._check_condition(node.test)
90
+ self._visit_control_body(node, body=node.body, orelse=node.orelse)
91
+
92
+ def visit_While(self, node: ast.While) -> None:
93
+ self._check_condition(node.test)
94
+ self._visit_loop(node)
95
+
96
+ def visit_For(self, node: ast.For) -> None:
97
+ self._visit_loop(node)
98
+
99
+ def visit_AsyncFor(self, node: ast.AsyncFor) -> None:
100
+ self._visit_loop(node)
101
+
102
+ def visit_With(self, node: ast.With) -> None:
103
+ self._visit_control_body(node, body=node.body)
104
+
105
+ def visit_AsyncWith(self, node: ast.AsyncWith) -> None:
106
+ self._visit_control_body(node, body=node.body)
107
+
108
+ def visit_Try(self, node: ast.Try) -> None:
109
+ self._enter_control(node)
110
+ self._visit_many(node.body)
111
+ for handler in node.handlers:
112
+ self.visit(handler)
113
+ self._visit_many(node.orelse)
114
+ self._visit_many(node.finalbody)
115
+ self._leave_control()
116
+
117
+ def visit_Match(self, node: ast.Match) -> None:
118
+ self._enter_control(node)
119
+ for case in node.cases:
120
+ self._visit_many(case.body)
121
+ self._leave_control()
122
+
123
+ def visit_IfExp(self, node: ast.IfExp) -> None:
124
+ if self.settings.enabled("LEG004"):
125
+ count = count_readability_operators(node)
126
+ if count > self.settings.max_ternary_operators:
127
+ self._add(
128
+ node,
129
+ "LEG004",
130
+ f"Ternary expression has {count} readability operators "
131
+ f"(max {self.settings.max_ternary_operators}). Extract it into named branches.",
132
+ )
133
+ self.generic_visit(node)
134
+
135
+ def visit_Assign(self, node: ast.Assign) -> None:
136
+ self._check_expression(node.value)
137
+ for target in node.targets:
138
+ self._check_condition_name(target)
139
+ self.generic_visit(node)
140
+
141
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
142
+ if node.value is not None:
143
+ self._check_expression(node.value)
144
+ self._check_condition_name(node.target)
145
+ self.generic_visit(node)
146
+
147
+ def visit_NamedExpr(self, node: ast.NamedExpr) -> None:
148
+ self._check_condition_name(node.target)
149
+ self._check_expression(node.value)
150
+ self.generic_visit(node)
151
+
152
+ def visit_Return(self, node: ast.Return) -> None:
153
+ if node.value is not None:
154
+ self._check_expression(node.value)
155
+ self.generic_visit(node)
156
+
157
+ def visit_Expr(self, node: ast.Expr) -> None:
158
+ self._check_expression(node.value)
159
+ self.generic_visit(node)
160
+
161
+ def visit_arg(self, node: ast.arg) -> None:
162
+ self._check_name(node.arg, node)
163
+
164
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
165
+ self._check_name(node.name, node)
166
+ self.generic_visit(node)
167
+
168
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
169
+ self._check_name(node.name, node)
170
+ self.generic_visit(node)
171
+
172
+ def visit_Compare(self, node: ast.Compare) -> None:
173
+ if self.settings.enabled("LEG006") and _has_redundant_boolean_compare(node):
174
+ self._add(
175
+ node,
176
+ "LEG006",
177
+ "Avoid redundant boolean comparisons. Use the boolean value directly.",
178
+ )
179
+
180
+ if self.loop_depth > 0 and self.settings.enabled("LEG005") and _has_membership_search(node):
181
+ self._add(
182
+ node,
183
+ "LEG005",
184
+ "Membership test inside a loop can become O(n^2). Use a set or dict for repeated lookups.",
185
+ )
186
+ self.generic_visit(node)
187
+
188
+ def visit_BoolOp(self, node: ast.BoolOp) -> None:
189
+ if self.settings.enabled("LEG006") and _has_redundant_boolean_operand(node):
190
+ self._add(
191
+ node,
192
+ "LEG006",
193
+ "Avoid redundant boolean operands like `and True` or `or False`.",
194
+ )
195
+ self.generic_visit(node)
196
+
197
+ def _visit_loop(self, node: ast.For | ast.AsyncFor | ast.While) -> None:
198
+ if self.loop_depth > 0 and self.settings.enabled("LEG005"):
199
+ self._add(
200
+ node,
201
+ "LEG005",
202
+ "Nested loop detected. Consider restructuring around a set, dict, or precomputed lookup.",
203
+ )
204
+
205
+ self.loop_depth += 1
206
+ self._visit_control_body(node, body=node.body, orelse=node.orelse)
207
+ self.loop_depth -= 1
208
+
209
+ def _visit_control_body(
210
+ self,
211
+ node: ast.AST,
212
+ *,
213
+ body: Iterable[ast.stmt],
214
+ orelse: Iterable[ast.stmt] = (),
215
+ ) -> None:
216
+ self._enter_control(node)
217
+ self._visit_many(body)
218
+ self._visit_many(orelse)
219
+ self._leave_control()
220
+
221
+ def _enter_control(self, node: ast.AST) -> None:
222
+ self.control_depth += 1
223
+ if self.settings.enabled("LEG003") and self.control_depth > self.settings.max_control_flow_depth:
224
+ self._add(
225
+ node,
226
+ "LEG003",
227
+ f"Control-flow depth is {self.control_depth} "
228
+ f"(max {self.settings.max_control_flow_depth}). Prefer guard clauses or extraction.",
229
+ )
230
+
231
+ def _leave_control(self) -> None:
232
+ self.control_depth -= 1
233
+
234
+ def _visit_many(self, nodes: Iterable[ast.AST]) -> None:
235
+ for node in nodes:
236
+ self.visit(node)
237
+
238
+ def _check_condition(self, expression: ast.expr) -> None:
239
+ if not self.settings.enabled("LEG002"):
240
+ return
241
+
242
+ count = count_condition_operators(expression)
243
+ if count > self.settings.max_if_operators:
244
+ self._add(
245
+ expression,
246
+ "LEG002",
247
+ f"If condition has {count} boolean operators "
248
+ f"(max {self.settings.max_if_operators}). Hoist it into a named boolean.",
249
+ )
250
+
251
+ def _check_expression(self, expression: ast.expr) -> None:
252
+ if not self.settings.enabled("LEG001"):
253
+ return
254
+
255
+ if isinstance(expression, (ast.Constant, ast.Name)):
256
+ return
257
+
258
+ count = count_readability_operators(expression)
259
+ if count > self.settings.max_expression_operators:
260
+ self._add(
261
+ expression,
262
+ "LEG001",
263
+ f"Expression has {count} readability operators "
264
+ f"(max {self.settings.max_expression_operators}). Extract named sub-expressions.",
265
+ )
266
+
267
+ def _check_condition_name(self, target: ast.AST) -> None:
268
+ if isinstance(target, ast.Name):
269
+ self._check_name(target.id, target)
270
+
271
+ def _check_name(self, name: str, node: ast.AST) -> None:
272
+ if not self.settings.enabled("LEG007"):
273
+ return
274
+
275
+ if BOOLEAN_NAME_PATTERN.match(name) and NEGATIVE_CONDITION_PATTERN.match(name):
276
+ self._add(
277
+ node,
278
+ "LEG007",
279
+ f"Prefer a positive condition name instead of `{name}`.",
280
+ )
281
+
282
+ def _add(self, node: ast.AST, code: str, message: str) -> None:
283
+ if not self.settings.enabled(code):
284
+ return
285
+
286
+ from .core import Diagnostic
287
+
288
+ self.diagnostics.append(
289
+ Diagnostic(
290
+ path=self.path,
291
+ line=getattr(node, "lineno", 1),
292
+ column=getattr(node, "col_offset", 0) + 1,
293
+ end_line=getattr(node, "end_lineno", None),
294
+ end_column=(getattr(node, "end_col_offset", 0) or 0) + 1,
295
+ code=code,
296
+ message=message,
297
+ )
298
+ )
299
+
300
+
301
+ def count_condition_operators(node: ast.AST) -> int:
302
+ count = 0
303
+ for child in _walk_expression(node):
304
+ if isinstance(child, ast.BoolOp):
305
+ count += max(len(child.values) - 1, 0)
306
+ elif isinstance(child, ast.IfExp) or (isinstance(child, ast.UnaryOp) and isinstance(child.op, ast.Not)):
307
+ count += 1
308
+ return count
309
+
310
+
311
+ def count_readability_operators(node: ast.AST) -> int:
312
+ count = 0
313
+ for child in _walk_expression(node):
314
+ if isinstance(child, ast.BoolOp):
315
+ count += max(len(child.values) - 1, 0)
316
+ elif isinstance(child, ast.BinOp):
317
+ count += 1
318
+ elif isinstance(child, ast.Compare):
319
+ count += len(child.ops)
320
+ elif isinstance(child, ast.IfExp) or (
321
+ isinstance(child, ast.UnaryOp) and isinstance(child.op, (ast.Not, ast.Invert))
322
+ ):
323
+ count += 1
324
+ return count
325
+
326
+
327
+ def _walk_expression(node: ast.AST) -> Iterable[ast.AST]:
328
+ for child in ast.walk(node):
329
+ if child is not node and isinstance(child, (ast.Lambda, ast.FunctionDef, ast.AsyncFunctionDef)):
330
+ continue
331
+ yield child
332
+
333
+
334
+ def _has_redundant_boolean_compare(node: ast.Compare) -> bool:
335
+ comparisons = zip(node.ops, node.comparators, strict=False)
336
+ return any(
337
+ isinstance(operator, (ast.Eq, ast.NotEq))
338
+ and isinstance(comparator, ast.Constant)
339
+ and isinstance(comparator.value, bool)
340
+ for operator, comparator in comparisons
341
+ )
342
+
343
+
344
+ def _has_redundant_boolean_operand(node: ast.BoolOp) -> bool:
345
+ constants = [value for value in node.values if isinstance(value, ast.Constant)]
346
+ if isinstance(node.op, ast.And):
347
+ return any(value.value is True for value in constants)
348
+ if isinstance(node.op, ast.Or):
349
+ return any(value.value is False for value in constants)
350
+ return False
351
+
352
+
353
+ def _has_membership_search(node: ast.Compare) -> bool:
354
+ return any(isinstance(operator, (ast.In, ast.NotIn)) for operator in node.ops)
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: ruff-legibility
3
+ Version: 0.1.0
4
+ Summary: Ruff-adjacent Python legibility rules for readable, reviewable code.
5
+ Project-URL: Homepage, https://github.com/yowainwright/ruff-legibility
6
+ Project-URL: Issues, https://github.com/yowainwright/ruff-legibility/issues
7
+ Project-URL: Repository, https://github.com/yowainwright/ruff-legibility
8
+ Author: Jeffry Wainwright
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: legibility,lint,python,readability,ruff
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ruff-legibility
26
+
27
+ [![PyPI version](https://img.shields.io/pypi/v/ruff-legibility.svg)](https://pypi.org/project/ruff-legibility/)
28
+ [![CI](https://github.com/yowainwright/ruff-legibility/actions/workflows/ci.yml/badge.svg)](https://github.com/yowainwright/ruff-legibility/actions/workflows/ci.yml)
29
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/yowainwright/ruff-legibility/badge)](https://scorecard.dev/viewer/?uri=github.com/yowainwright/ruff-legibility)
30
+
31
+ `ruff-legibility` is a Ruff-adjacent Python linter for readability and reviewability rules inspired by `eslint-plugin-legibility`.
32
+
33
+ Ruff does not currently load third-party rule implementations from Python packages. This package therefore runs beside Ruff and emits Ruff-style diagnostics with `LEG###` codes.
34
+
35
+ ```sh
36
+ ruff check .
37
+ ruff-legibility check .
38
+ ```
39
+
40
+ To keep `# noqa: LEG001` comments valid when Ruff checks unused or unknown `noqa` codes, add `LEG` as an external prefix in Ruff:
41
+
42
+ ```toml
43
+ [tool.ruff.lint]
44
+ external = ["LEG"]
45
+ ```
46
+
47
+ ## Install
48
+
49
+ ```sh
50
+ pip install ruff-legibility
51
+ ```
52
+
53
+ For local development:
54
+
55
+ ```sh
56
+ uv sync --all-groups
57
+ make check
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ ```sh
63
+ ruff-legibility check .
64
+ ruff-legibility check src tests --output-format json
65
+ ruff-legibility check . --select LEG001,LEG002 --ignore LEG007
66
+ ruff-legibility check . --exit-zero
67
+ ```
68
+
69
+ Default text output is intentionally close to Ruff:
70
+
71
+ ```text
72
+ example.py:4:8: LEG002 If condition has 2 boolean operators (max 0). Hoist it into a named boolean.
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ Configuration can live in `pyproject.toml` under `[tool.ruff-legibility]`, or in `ruff-legibility.toml` / `.ruff-legibility.toml`.
78
+
79
+ ```toml
80
+ [tool.ruff-legibility]
81
+ select = ["LEG"]
82
+ extend-select = []
83
+ ignore = ["LEG007"]
84
+ extend-ignore = []
85
+ exclude = [".venv", "build", "dist"]
86
+ max-expression-operators = 4
87
+ max-if-operators = 0
88
+ max-ternary-operators = 2
89
+ max-control-flow-depth = 3
90
+
91
+ [tool.ruff-legibility.per-file-ignores]
92
+ "tests/*" = ["LEG003"]
93
+ ```
94
+
95
+ Standalone config files omit the `tool.ruff-legibility` wrapper:
96
+
97
+ ```toml
98
+ select = ["LEG"]
99
+ ignore = ["LEG007"]
100
+ ```
101
+
102
+ This repository includes a `ruff-legibility.toml` for its own source. The default package thresholds stay stricter than the project-local development config.
103
+
104
+ ## Rules
105
+
106
+ | Code | Rule | Default |
107
+ | --- | --- | --- |
108
+ | `LEG001` | Limit readability operators inside a single expression. | on |
109
+ | `LEG002` | Prefer a named boolean before operator-heavy `if` / `while` conditions. | on |
110
+ | `LEG003` | Limit nested control-flow depth. | on |
111
+ | `LEG004` | Avoid complex ternary expressions. | on |
112
+ | `LEG005` | Flag likely quadratic patterns such as nested loops and repeated membership checks in loops. | on |
113
+ | `LEG006` | Avoid redundant boolean comparisons like `flag == True`. | on |
114
+ | `LEG007` | Prefer positive condition names over names like `is_not_ready`. | on |
115
+
116
+ ## Pre-commit
117
+
118
+ ```yaml
119
+ repos:
120
+ - repo: local
121
+ hooks:
122
+ - id: ruff-legibility
123
+ name: ruff-legibility
124
+ entry: ruff-legibility check
125
+ language: system
126
+ types: [python]
127
+ ```
128
+
129
+ ## Development
130
+
131
+ Common commands:
132
+
133
+ ```sh
134
+ uv sync --all-groups
135
+ uv run ruff check .
136
+ uv run ruff-legibility check src tests
137
+ uv run pytest
138
+ uv build
139
+ ```
140
+
141
+ Release builds should use:
142
+
143
+ ```sh
144
+ uv build --no-sources
145
+ ```
146
+
147
+ Publishing is configured for PyPI Trusted Publishing:
148
+
149
+ ```sh
150
+ uv publish
151
+ ```
@@ -0,0 +1,13 @@
1
+ ruff_legibility/__init__.py,sha256=O0T487GI-lHC7ONZEVvX_nmGeOZLlp6oqEf1LjtKtmU,364
2
+ ruff_legibility/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ ruff_legibility/cli.py,sha256=u8uUh6zZ89PP8-K_PnZ7KZgzUpMkM-zlZsbsNr0bPU8,4387
4
+ ruff_legibility/config.py,sha256=FwlXFs7vHINECyE4pwG_MQI2ndQ9ccPtorO0UBFlL8s,7533
5
+ ruff_legibility/core.py,sha256=fvjeHBKPsYp6w7HzKi636aEN3ZbOnExxO7IVR_2_vYM,2723
6
+ ruff_legibility/noqa.py,sha256=m6PkvGLA4VDv_09Yi7Raj7YxVqBc8Hna1_JWCNxBxhg,632
7
+ ruff_legibility/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
+ ruff_legibility/rules.py,sha256=zuCVYbbH_wWZT3irUoQc1TOBlVzLVX8CmepgzjTSgK8,11551
9
+ ruff_legibility-0.1.0.dist-info/METADATA,sha256=jGdLGJ4wxhTmAm2sa3c81339C20nL1bezAbtsJJcbfU,4369
10
+ ruff_legibility-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ ruff_legibility-0.1.0.dist-info/entry_points.txt,sha256=x_ubGENVPXMQ1lk294NEFVRLxL8wF8MZ76PUr8sBmW8,61
12
+ ruff_legibility-0.1.0.dist-info/licenses/LICENSE,sha256=YfsKGD4MeecsEoE27qIRRfgbcN_LPOWTPnjU5rkRgSw,1074
13
+ ruff_legibility-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ruff-legibility = ruff_legibility.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeffry Wainwright
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.