anti-slop-python 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.

Potentially problematic release.


This version of anti-slop-python might be problematic. Click here for more details.

@@ -0,0 +1,6 @@
1
+ """Opinionated checks for Python patterns that weaken code evidence."""
2
+
3
+ from anti_slop_python.checker import check_file, check_source
4
+ from anti_slop_python.diagnostics import Diagnostic
5
+
6
+ __all__ = ["Diagnostic", "check_file", "check_source"]
@@ -0,0 +1,3 @@
1
+ from anti_slop_python.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import tokenize
5
+ from pathlib import Path
6
+
7
+ from anti_slop_python.diagnostics import Diagnostic
8
+ from anti_slop_python.rules import RULES
9
+ from anti_slop_python.rules.base import RuleContext
10
+
11
+
12
+ def check_source(source: str, path: str | Path = "<unknown>") -> list[Diagnostic]:
13
+ """Check source text and return diagnostics in source order."""
14
+
15
+ source_path = Path(path)
16
+ try:
17
+ tree = ast.parse(source, filename=str(source_path))
18
+ except SyntaxError as error:
19
+ return [_syntax_error(source_path, error)]
20
+
21
+ context = RuleContext(path=source_path, tree=tree)
22
+ diagnostics = [diagnostic for rule in RULES for diagnostic in rule.check(context)]
23
+ return sorted(diagnostics)
24
+
25
+
26
+ def check_file(path: str | Path) -> list[Diagnostic]:
27
+ """Read and check one Python source file."""
28
+
29
+ source_path = Path(path)
30
+ try:
31
+ with tokenize.open(source_path) as source_file:
32
+ source = source_file.read()
33
+ except (OSError, SyntaxError) as error:
34
+ return [
35
+ Diagnostic(
36
+ path=source_path,
37
+ line=1,
38
+ column=1,
39
+ code="IOError",
40
+ message=str(error),
41
+ )
42
+ ]
43
+ return check_source(source, source_path)
44
+
45
+
46
+ def _syntax_error(path: Path, error: SyntaxError) -> Diagnostic:
47
+ return Diagnostic(
48
+ path=path,
49
+ line=error.lineno or 1,
50
+ column=error.offset or 1,
51
+ code="SyntaxError",
52
+ message=error.msg,
53
+ )
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from collections.abc import Iterable, Sequence
7
+ from pathlib import Path
8
+
9
+ from anti_slop_python.checker import check_file
10
+ from anti_slop_python.ruff_integration import RuffFailure, check_with_ruff
11
+
12
+ _IGNORED_DIRECTORIES = {
13
+ ".git",
14
+ ".venv",
15
+ "__pycache__",
16
+ "build",
17
+ "dist",
18
+ "venv",
19
+ }
20
+
21
+
22
+ def main(argv: Sequence[str] | None = None) -> int:
23
+ parser = _parser()
24
+ arguments = parser.parse_args(argv)
25
+
26
+ try:
27
+ files = list(_python_files(arguments.paths))
28
+ except ValueError as error:
29
+ parser.error(str(error))
30
+
31
+ try:
32
+ ruff_result = check_with_ruff(arguments.paths, files)
33
+ except RuffFailure as error:
34
+ print(f"anti-slop-python: {error}", file=sys.stderr)
35
+ return 2
36
+
37
+ for warning in ruff_result.warnings:
38
+ print(warning, file=sys.stderr)
39
+ for notice in ruff_result.notices:
40
+ print(f"anti-slop-python policy notice: {notice}", file=sys.stderr)
41
+
42
+ diagnostics = [diagnostic for path in files for diagnostic in check_file(path)]
43
+ diagnostics.extend(ruff_result.diagnostics)
44
+ for diagnostic in sorted(diagnostics):
45
+ print(diagnostic)
46
+ return 1 if diagnostics else 0
47
+
48
+
49
+ def _parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(
51
+ prog="anti-slop-python",
52
+ description="Reject Python patterns that weaken architectural evidence.",
53
+ )
54
+ parser.add_argument(
55
+ "paths",
56
+ nargs="*",
57
+ type=Path,
58
+ default=[Path(".")],
59
+ help="Python files or directories to check (default: current directory)",
60
+ )
61
+ return parser
62
+
63
+
64
+ def _python_files(paths: Iterable[Path]) -> Iterable[Path]:
65
+ seen: set[Path] = set()
66
+ for path in paths:
67
+ if not path.exists():
68
+ raise ValueError(f"path does not exist: {path}")
69
+ candidates = [path] if path.is_file() else _walk_python_files(path)
70
+ for candidate in candidates:
71
+ if candidate.suffix != ".py":
72
+ continue
73
+ identity = candidate.resolve()
74
+ if identity not in seen:
75
+ seen.add(identity)
76
+ yield candidate
77
+
78
+
79
+ def _walk_python_files(root: Path) -> Iterable[Path]:
80
+ for directory, subdirectories, filenames in os.walk(root):
81
+ subdirectories[:] = sorted(
82
+ name for name in subdirectories if name not in _IGNORED_DIRECTORIES
83
+ )
84
+ for filename in sorted(filenames):
85
+ if filename.endswith(".py"):
86
+ yield Path(directory, filename)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+
7
+ @dataclass(frozen=True, order=True)
8
+ class Diagnostic:
9
+ """A source location and the check that failed there."""
10
+
11
+ path: Path
12
+ line: int
13
+ column: int
14
+ code: str
15
+ message: str
16
+
17
+ def __str__(self) -> str:
18
+ return f"{self.path}:{self.line}:{self.column} {self.code} {self.message}"
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from anti_slop_python.diagnostics import Diagnostic
12
+ from anti_slop_python.ruff_policy import (
13
+ _RECOMMENDED_RULES,
14
+ RuffFailure,
15
+ RuffSettings,
16
+ configuration_arguments,
17
+ default_arguments,
18
+ parse_settings,
19
+ policy_notices_for_scopes,
20
+ settings_scopes,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class RuffCheckResult:
26
+ """Ruff diagnostics and advisory policy output for one invocation."""
27
+
28
+ diagnostics: tuple[Diagnostic, ...]
29
+ notices: tuple[str, ...]
30
+ warnings: tuple[str, ...]
31
+
32
+
33
+ def check_with_ruff(
34
+ _paths: Sequence[Path], python_files: Sequence[Path]
35
+ ) -> RuffCheckResult:
36
+ """Run Ruff with anti-slop-python defaults and project-configured overrides."""
37
+
38
+ diagnostics: list[Diagnostic] = []
39
+ warnings: list[str] = []
40
+ noqa_notices: list[str] = []
41
+ scopes = settings_scopes(python_files)
42
+ effective_settings: list[RuffSettings] = []
43
+ for configuration, files in scopes:
44
+ config_arguments = configuration_arguments(configuration)
45
+ included_files = _included_files(files, config_arguments)
46
+ if not included_files:
47
+ continue
48
+ baseline = _resolved_settings(included_files[0], config_arguments)
49
+ arguments = (*config_arguments, *default_arguments(baseline))
50
+ for targets in _path_batches(included_files):
51
+ normal = _ruff_diagnostics(targets, arguments, python_files)
52
+ diagnostics.extend(normal.diagnostics)
53
+ warnings.extend(normal.warnings)
54
+ audit = _ruff_diagnostics(
55
+ targets, ("--ignore-noqa", *arguments), python_files
56
+ )
57
+ noqa_notices.extend(_noqa_notices(normal.diagnostics, audit.diagnostics))
58
+ effective_settings.append(_resolved_settings(included_files[0], arguments))
59
+
60
+ notices = (*policy_notices_for_scopes(effective_settings), *noqa_notices)
61
+ return RuffCheckResult(
62
+ tuple(sorted(set(diagnostics))),
63
+ notices,
64
+ tuple(dict.fromkeys(warnings)),
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class _RuffDiagnostics:
70
+ diagnostics: tuple[Diagnostic, ...]
71
+ warnings: tuple[str, ...]
72
+
73
+
74
+ def _ruff_diagnostics(
75
+ targets: Sequence[Path],
76
+ arguments: Sequence[str],
77
+ python_files: Sequence[Path],
78
+ ) -> _RuffDiagnostics:
79
+ completed = _run_ruff(
80
+ "check",
81
+ "--output-format",
82
+ "json",
83
+ "--no-fix",
84
+ "--no-fix-only",
85
+ "--exit-zero",
86
+ "--force-exclude",
87
+ *arguments,
88
+ "--",
89
+ *(str(path) for path in targets),
90
+ )
91
+ return _RuffDiagnostics(
92
+ diagnostics=_parse_diagnostics(completed.stdout, python_files),
93
+ warnings=tuple(line for line in completed.stderr.splitlines() if line),
94
+ )
95
+
96
+
97
+ def _noqa_notices(
98
+ normal: Sequence[Diagnostic], audit: Sequence[Diagnostic]
99
+ ) -> tuple[str, ...]:
100
+ normal_keys = {(item.path, item.line, item.column, item.code) for item in normal}
101
+ return tuple(
102
+ f"{item.code} is suppressed by noqa at {item.path}:{item.line}; "
103
+ "recommended for checked files"
104
+ for item in audit
105
+ if item.code in _RECOMMENDED_RULES
106
+ and (item.path, item.line, item.column, item.code) not in normal_keys
107
+ )
108
+
109
+
110
+ def _run_ruff(*arguments: str) -> subprocess.CompletedProcess[str]:
111
+ environment = os.environ.copy()
112
+ for name in ("RUFF_FIX", "RUFF_FIX_ONLY", "RUFF_OUTPUT_FILE", "RUFF_OUTPUT_FORMAT"):
113
+ environment.pop(name, None)
114
+
115
+ try:
116
+ completed = subprocess.run(
117
+ [sys.executable, "-m", "ruff", *arguments],
118
+ capture_output=True,
119
+ check=False,
120
+ env=environment,
121
+ text=True,
122
+ )
123
+ except OSError as error:
124
+ raise RuffFailure(f"Ruff failed: {error}") from error
125
+
126
+ if completed.returncode != 0:
127
+ detail = completed.stderr.strip() or completed.stdout.strip()
128
+ raise RuffFailure(f"Ruff failed: {detail or 'unknown error'}")
129
+ return completed
130
+
131
+
132
+ def _parse_diagnostics(
133
+ output: str, python_files: Sequence[Path]
134
+ ) -> tuple[Diagnostic, ...]:
135
+ try:
136
+ items = json.loads(output)
137
+ except json.JSONDecodeError as error:
138
+ raise RuffFailure(f"Ruff returned invalid JSON: {error.msg}") from error
139
+ if not isinstance(items, list):
140
+ raise RuffFailure("Ruff returned invalid JSON: expected a diagnostic list")
141
+
142
+ display_paths = {path.resolve(): path for path in python_files}
143
+ diagnostics: list[Diagnostic] = []
144
+ for item in items:
145
+ if not isinstance(item, dict):
146
+ continue
147
+ filename = Path(str(item.get("filename", "<unknown>")))
148
+ if item.get("code") == "invalid-syntax" and filename.resolve() in display_paths:
149
+ continue
150
+ location = item.get("location")
151
+ if not isinstance(location, dict):
152
+ raise RuffFailure("Ruff returned a diagnostic without a source location")
153
+ path = display_paths.get(filename.resolve(), _display_path(filename))
154
+ diagnostics.append(
155
+ Diagnostic(
156
+ path=path,
157
+ line=int(location["row"]),
158
+ column=int(location["column"]),
159
+ code=str(item.get("code") or "Ruff"),
160
+ message=str(item.get("message") or "Ruff violation"),
161
+ )
162
+ )
163
+ return tuple(sorted(diagnostics))
164
+
165
+
166
+ def _display_path(path: Path) -> Path:
167
+ try:
168
+ return path.relative_to(Path.cwd())
169
+ except ValueError:
170
+ return path
171
+
172
+
173
+ def _included_files(
174
+ files: Sequence[Path], configuration_arguments: Sequence[str]
175
+ ) -> tuple[Path, ...]:
176
+ included_paths: set[Path] = set()
177
+ for targets in _path_batches(files):
178
+ completed = _run_ruff(
179
+ "check",
180
+ "--show-files",
181
+ "--force-exclude",
182
+ *configuration_arguments,
183
+ "--",
184
+ *(str(path) for path in targets),
185
+ )
186
+ included_paths.update(
187
+ Path(line).resolve() for line in completed.stdout.splitlines()
188
+ )
189
+ return tuple(path for path in files if path.resolve() in included_paths)
190
+
191
+
192
+ def _path_batches(
193
+ files: Sequence[Path], batch_size: int = 500
194
+ ) -> tuple[tuple[Path, ...], ...]:
195
+ return tuple(
196
+ tuple(files[index : index + batch_size])
197
+ for index in range(0, len(files), batch_size)
198
+ )
199
+
200
+
201
+ def _resolved_settings(file: Path, extra_arguments: Sequence[str] = ()) -> RuffSettings:
202
+ completed = _run_ruff("check", "--show-settings", *extra_arguments, "--", str(file))
203
+ return parse_settings(completed.stdout)
@@ -0,0 +1,398 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import tomllib
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ _RECOMMENDED_RULES = ("C901", "PLR0915", "TID251", "E722", "BLE001")
11
+ _RECOMMENDED_BANNED_APIS = {
12
+ "mock.patch": "Avoid patching state. Pass the dependency explicitly instead.",
13
+ "unittest.mock.patch": (
14
+ "Avoid patching state. Pass the dependency explicitly instead."
15
+ ),
16
+ }
17
+ _MAX_COMPLEXITY = 10
18
+ _MAX_STATEMENTS = 40
19
+ _RULE_CODE = re.compile(r"\(([A-Z][A-Z0-9]*\d+)\),?$")
20
+
21
+
22
+ class RuffFailure(RuntimeError):
23
+ """Raised when Ruff cannot complete a check or expose its settings."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class RuffSettings:
28
+ enabled_rules: frozenset[str]
29
+ max_complexity: int
30
+ max_statements: int
31
+ banned_apis: frozenset[str]
32
+ banned_api_messages: tuple[tuple[str, str], ...]
33
+ per_file_ignores: tuple[tuple[str, frozenset[str]], ...]
34
+ path: Path | None
35
+
36
+
37
+ def settings_scopes(
38
+ files: Sequence[Path],
39
+ ) -> tuple[tuple[Path | None, tuple[Path, ...]], ...]:
40
+ scopes: dict[Path | None, list[Path]] = {}
41
+ config_cache: dict[Path, Path | None] = {}
42
+ for file in files:
43
+ scopes.setdefault(ruff_config_for(file, config_cache), []).append(file)
44
+ return tuple(
45
+ (configuration, tuple(scope)) for configuration, scope in scopes.items()
46
+ )
47
+
48
+
49
+ def configuration_arguments(path: Path | None) -> tuple[str, ...]:
50
+ if path is None:
51
+ return ("--isolated",)
52
+ return ("--config", str(path))
53
+
54
+
55
+ def ruff_config_for(file: Path, cache: dict[Path, Path | None]) -> Path | None:
56
+ return ruff_config_for_directory(file.resolve().parent, cache)
57
+
58
+
59
+ def ruff_config_for_directory(
60
+ directory: Path, cache: dict[Path, Path | None]
61
+ ) -> Path | None:
62
+ if directory in cache:
63
+ return cache[directory]
64
+
65
+ for filename in (".ruff.toml", "ruff.toml"):
66
+ candidate = directory / filename
67
+ if candidate.is_file():
68
+ cache[directory] = candidate
69
+ return candidate
70
+
71
+ pyproject = directory / "pyproject.toml"
72
+ if pyproject.is_file():
73
+ try:
74
+ configuration = tomllib.loads(pyproject.read_text())
75
+ except (OSError, tomllib.TOMLDecodeError):
76
+ cache[directory] = pyproject
77
+ return pyproject
78
+ tool = configuration.get("tool")
79
+ if isinstance(tool, dict) and isinstance(tool.get("ruff"), dict):
80
+ cache[directory] = pyproject
81
+ return pyproject
82
+ parent = directory.parent
83
+ resolved = None if parent == directory else ruff_config_for_directory(parent, cache)
84
+ cache[directory] = resolved
85
+ return resolved
86
+
87
+
88
+ def parse_settings(output: str) -> RuffSettings:
89
+ enabled_rules = _rule_codes(
90
+ _setting_block(output, "linter.rules.enabled", "[", "]")
91
+ )
92
+ banned_api_block = _setting_block(
93
+ output, "linter.flake8_tidy_imports.banned_api", "{", "}"
94
+ )
95
+ banned_api_messages = _parse_banned_apis(banned_api_block)
96
+ per_file_block = _setting_block(output, "linter.per_file_ignores", "{", "}")
97
+ raw_path = _optional_setting_value(output, "Settings path:")
98
+ return RuffSettings(
99
+ enabled_rules=enabled_rules,
100
+ max_complexity=int(_setting_value(output, "linter.mccabe.max_complexity")),
101
+ max_statements=int(_setting_value(output, "linter.pylint.max_statements")),
102
+ banned_apis=frozenset(api for api, _ in banned_api_messages),
103
+ banned_api_messages=banned_api_messages,
104
+ per_file_ignores=_parse_per_file_ignores(per_file_block),
105
+ path=Path(_quoted_value(raw_path)) if raw_path is not None else None,
106
+ )
107
+
108
+
109
+ def default_arguments(settings: RuffSettings) -> tuple[str, ...]:
110
+ ignored_selectors = _configured_ignore_selectors(settings.path)
111
+ default_rules = [
112
+ code
113
+ for code in _RECOMMENDED_RULES
114
+ if not any(_selector_matches(code, selector) for selector in ignored_selectors)
115
+ ]
116
+ arguments: list[str] = []
117
+ if default_rules:
118
+ arguments.extend(["--extend-select", ",".join(default_rules)])
119
+ if not _configuration_defines_any(
120
+ settings.path,
121
+ (("lint", "mccabe", "max-complexity"), ("mccabe", "max-complexity")),
122
+ ):
123
+ arguments.extend(
124
+ ["--config", f"lint.mccabe.max-complexity = {_MAX_COMPLEXITY}"]
125
+ )
126
+ if not _configuration_defines_any(
127
+ settings.path,
128
+ (("lint", "pylint", "max-statements"), ("pylint", "max-statements")),
129
+ ):
130
+ arguments.extend(
131
+ ["--config", f"lint.pylint.max-statements = {_MAX_STATEMENTS}"]
132
+ )
133
+
134
+ if not _configuration_defines_any(
135
+ settings.path,
136
+ (
137
+ ("lint", "flake8-tidy-imports", "banned-api"),
138
+ ("flake8-tidy-imports", "banned-api"),
139
+ ),
140
+ ):
141
+ entries = ", ".join(
142
+ f"{json.dumps(api)} = {{ msg = {json.dumps(message)} }}"
143
+ for api, message in sorted(_RECOMMENDED_BANNED_APIS.items())
144
+ )
145
+ arguments.extend(
146
+ ["--config", f"lint.flake8-tidy-imports.banned-api = {{{entries}}}"]
147
+ )
148
+ return tuple(arguments)
149
+
150
+
151
+ def policy_notices_for_scopes(
152
+ settings: Sequence[RuffSettings],
153
+ ) -> tuple[str, ...]:
154
+ multiple_scopes = len(settings) > 1
155
+ notices: list[str] = []
156
+ for resolved in settings:
157
+ suffix = ""
158
+ if multiple_scopes and resolved.path is not None:
159
+ suffix = f" [Ruff settings: {resolved.path}]"
160
+ notices.extend(f"{notice}{suffix}" for notice in _policy_notices(resolved))
161
+ return tuple(notices)
162
+
163
+
164
+ def _configured_ignore_selectors(path: Path | None) -> tuple[str, ...]:
165
+ selectors: list[str] = []
166
+ for configuration in _configuration_chain(path):
167
+ replacement = _lint_setting(configuration, "ignore")
168
+ if isinstance(replacement, list):
169
+ selectors = [str(value) for value in replacement]
170
+ additions = _lint_setting(configuration, "extend-ignore")
171
+ if isinstance(additions, list):
172
+ selectors.extend(str(value) for value in additions)
173
+ return tuple(selectors)
174
+
175
+
176
+ def _selector_matches(code: str, selector: str) -> bool:
177
+ return selector == "ALL" or code.startswith(selector)
178
+
179
+
180
+ def _lint_setting(configuration: dict[str, object], name: str) -> object:
181
+ lint = configuration.get("lint")
182
+ hyphenated = name.replace("_", "-")
183
+ underscored = name.replace("-", "_")
184
+ if isinstance(lint, dict):
185
+ if hyphenated in lint:
186
+ return lint[hyphenated]
187
+ if underscored in lint:
188
+ return lint[underscored]
189
+ if hyphenated in configuration:
190
+ return configuration[hyphenated]
191
+ if underscored in configuration:
192
+ return configuration[underscored]
193
+ return None
194
+
195
+
196
+ def _configuration_chain(
197
+ path: Path | None, seen: frozenset[Path] = frozenset()
198
+ ) -> tuple[dict[str, object], ...]:
199
+ if path is None:
200
+ return ()
201
+ resolved_path = path.resolve()
202
+ if resolved_path in seen:
203
+ return ()
204
+ try:
205
+ document = tomllib.loads(resolved_path.read_text())
206
+ except (OSError, tomllib.TOMLDecodeError):
207
+ return ()
208
+
209
+ configuration: object = document
210
+ if resolved_path.name == "pyproject.toml":
211
+ configuration = document.get("tool", {}).get("ruff", {})
212
+ if not isinstance(configuration, dict):
213
+ return ()
214
+
215
+ base: tuple[dict[str, object], ...] = ()
216
+ extended = configuration.get("extend")
217
+ if isinstance(extended, str):
218
+ extended_path = Path(extended)
219
+ if not extended_path.is_absolute():
220
+ extended_path = resolved_path.parent / extended_path
221
+ base = _configuration_chain(extended_path, seen | {resolved_path})
222
+ return (*base, configuration)
223
+
224
+
225
+ def _configuration_defines_any(
226
+ path: Path | None, setting_paths: Sequence[tuple[str, ...]]
227
+ ) -> bool:
228
+ return any(
229
+ _configuration_defines(path, setting_path) for setting_path in setting_paths
230
+ )
231
+
232
+
233
+ def _configuration_defines(
234
+ path: Path | None,
235
+ setting_path: tuple[str, ...],
236
+ ) -> bool:
237
+ return any(
238
+ _mapping_has_path(configuration, setting_path)
239
+ for configuration in _configuration_chain(path)
240
+ )
241
+
242
+
243
+ def _mapping_has_path(mapping: dict[str, object], path: tuple[str, ...]) -> bool:
244
+ current: object = mapping
245
+ for part in path:
246
+ if not isinstance(current, dict):
247
+ return False
248
+ hyphenated = part.replace("_", "-")
249
+ underscored = part.replace("-", "_")
250
+ if hyphenated in current:
251
+ current = current[hyphenated]
252
+ elif underscored in current:
253
+ current = current[underscored]
254
+ else:
255
+ return False
256
+ return True
257
+
258
+
259
+ def _setting_block(output: str, name: str, opener: str, closer: str) -> str:
260
+ lines = output.splitlines()
261
+ marker = f"{name} = {opener}"
262
+ empty_marker = f"{marker}{closer}"
263
+ for index, line in enumerate(lines):
264
+ if line.strip() == empty_marker:
265
+ return ""
266
+ if line.strip() != marker:
267
+ continue
268
+ block: list[str] = []
269
+ for candidate in lines[index + 1 :]:
270
+ if candidate.strip() == closer:
271
+ return "\n".join(block)
272
+ block.append(candidate)
273
+ raise RuffFailure("Ruff returned an unsupported resolved-settings format")
274
+
275
+
276
+ def _setting_value(output: str, name: str) -> str:
277
+ value = _optional_setting_value(output, f"{name} =")
278
+ if value is None:
279
+ raise RuffFailure("Ruff returned an unsupported resolved-settings format")
280
+ return value
281
+
282
+
283
+ def _optional_setting_value(output: str, marker: str) -> str | None:
284
+ for line in output.splitlines():
285
+ if line.strip().startswith(marker):
286
+ return line.strip().removeprefix(marker).strip()
287
+ return None
288
+
289
+
290
+ def _rule_codes(block: str) -> frozenset[str]:
291
+ rules: set[str] = set()
292
+ for line in block.splitlines():
293
+ _add_rule_code(line.strip(), rules)
294
+ return frozenset(rules)
295
+
296
+
297
+ def _parse_banned_apis(block: str) -> tuple[tuple[str, str], ...]:
298
+ banned_apis: list[tuple[str, str]] = []
299
+ for line in block.splitlines():
300
+ api, separator, message = line.strip().removesuffix(",").partition(" = ")
301
+ if separator:
302
+ banned_apis.append((api, message))
303
+ return tuple(banned_apis)
304
+
305
+
306
+ def _parse_per_file_ignores(
307
+ block: str,
308
+ ) -> tuple[tuple[str, frozenset[str]], ...]:
309
+ ignores: list[tuple[str, frozenset[str]]] = []
310
+ current_pattern: str | None = None
311
+ current_rules: set[str] = set()
312
+ for line in block.splitlines():
313
+ stripped = line.strip()
314
+ if stripped.startswith("basename_matcher = "):
315
+ if current_pattern is not None:
316
+ ignores.append((current_pattern, frozenset(current_rules)))
317
+ current_pattern = _quoted_value(stripped.partition(" = ")[2])
318
+ current_rules = set()
319
+ else:
320
+ _add_rule_code(stripped, current_rules)
321
+ if current_pattern is not None:
322
+ ignores.append((current_pattern, frozenset(current_rules)))
323
+ return tuple(ignores)
324
+
325
+
326
+ def _quoted_value(value: str) -> str:
327
+ try:
328
+ parsed = json.loads(value)
329
+ except json.JSONDecodeError:
330
+ return value
331
+ return str(parsed)
332
+
333
+
334
+ def _add_rule_code(line: str, destination: set[str]) -> None:
335
+ match = _RULE_CODE.search(line)
336
+ if match is not None:
337
+ destination.add(match.group(1))
338
+
339
+
340
+ def _policy_notices(settings: RuffSettings) -> tuple[str, ...]:
341
+ notices = _disabled_rule_notices(settings)
342
+ notices.extend(_limit_notices(settings))
343
+ notices.extend(_banned_api_notices(settings))
344
+ notices.extend(_per_file_ignore_notices(settings))
345
+ return tuple(notices)
346
+
347
+
348
+ def _disabled_rule_notices(settings: RuffSettings) -> list[str]:
349
+ return [
350
+ f"{code} is disabled; recommended: enabled"
351
+ for code in _RECOMMENDED_RULES
352
+ if code not in settings.enabled_rules
353
+ ]
354
+
355
+
356
+ def _limit_notices(settings: RuffSettings) -> list[str]:
357
+ notices: list[str] = []
358
+ if "C901" in settings.enabled_rules and settings.max_complexity > _MAX_COMPLEXITY:
359
+ notices.append(
360
+ f"C901 allows complexity {settings.max_complexity}; "
361
+ f"recommended maximum: {_MAX_COMPLEXITY}"
362
+ )
363
+ if (
364
+ "PLR0915" in settings.enabled_rules
365
+ and settings.max_statements > _MAX_STATEMENTS
366
+ ):
367
+ notices.append(
368
+ f"PLR0915 allows {settings.max_statements} statements; "
369
+ f"recommended maximum: {_MAX_STATEMENTS}"
370
+ )
371
+ return notices
372
+
373
+
374
+ def _banned_api_notices(settings: RuffSettings) -> list[str]:
375
+ if "TID251" not in settings.enabled_rules:
376
+ return []
377
+ return [
378
+ f"TID251 does not ban {api}; recommended: ban this API"
379
+ for api in _RECOMMENDED_BANNED_APIS
380
+ if not _api_is_banned(api, settings.banned_apis)
381
+ ]
382
+
383
+
384
+ def _api_is_banned(api: str, banned_apis: frozenset[str]) -> bool:
385
+ return any(api == banned or api.startswith(f"{banned}.") for banned in banned_apis)
386
+
387
+
388
+ def _per_file_ignore_notices(settings: RuffSettings) -> list[str]:
389
+ notices: list[str] = []
390
+ enabled_recommendations = settings.enabled_rules.intersection(_RECOMMENDED_RULES)
391
+ for pattern, ignored_rules in settings.per_file_ignores:
392
+ for code in _RECOMMENDED_RULES:
393
+ if code in enabled_recommendations and code in ignored_rules:
394
+ notices.append(
395
+ f"{code} is ignored for {pattern}; "
396
+ "recommended for all checked files"
397
+ )
398
+ return notices
@@ -0,0 +1,12 @@
1
+ from anti_slop_python.rules.base import Rule
2
+ from anti_slop_python.rules.no_any_containers import RULE as NO_ANY_CONTAINERS
3
+ from anti_slop_python.rules.no_dynamic_attribute_access import (
4
+ RULE as NO_DYNAMIC_ATTRIBUTE_ACCESS,
5
+ )
6
+
7
+ RULES: tuple[Rule, ...] = (
8
+ NO_ANY_CONTAINERS,
9
+ NO_DYNAMIC_ATTRIBUTE_ACCESS,
10
+ )
11
+
12
+ __all__ = ["RULES", "Rule"]
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from anti_slop_python.diagnostics import Diagnostic
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Rule:
13
+ """Metadata and checker function for one rule."""
14
+
15
+ code: str
16
+ name: str
17
+ message: str
18
+ check: Callable[[RuleContext], Iterable[Diagnostic]]
19
+
20
+
21
+ @dataclass
22
+ class RuleContext:
23
+ """Parsed source and the shared import analysis used by rules."""
24
+
25
+ path: Path
26
+ tree: ast.AST
27
+ imports: dict[str, str] = field(init=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ self.imports = _collect_imports(self.tree)
31
+
32
+ def qualified_name(self, node: ast.AST) -> str | None:
33
+ """Resolve a simple imported name without full semantic analysis."""
34
+
35
+ name = _dotted_name(node)
36
+ if name is None:
37
+ return None
38
+
39
+ first, separator, remainder = name.partition(".")
40
+ resolved = self.imports.get(first, first)
41
+ if separator:
42
+ return f"{resolved}.{remainder}"
43
+ return resolved
44
+
45
+ def diagnostic(self, rule: Rule, node: ast.AST) -> Diagnostic:
46
+ return Diagnostic(
47
+ path=self.path,
48
+ line=node.lineno,
49
+ column=node.col_offset + 1,
50
+ code=rule.code,
51
+ message=rule.message,
52
+ )
53
+
54
+
55
+ def _collect_imports(tree: ast.AST) -> dict[str, str]:
56
+ imports: dict[str, str] = {}
57
+ for node in ast.walk(tree):
58
+ if isinstance(node, ast.Import):
59
+ for alias in node.names:
60
+ local_name = alias.asname or alias.name.split(".", 1)[0]
61
+ target = alias.name if alias.asname else local_name
62
+ imports[local_name] = target
63
+ elif isinstance(node, ast.ImportFrom):
64
+ dots = "." * (node.level or 0)
65
+ if node.module:
66
+ module_prefix = f"{dots}{node.module}."
67
+ elif dots:
68
+ module_prefix = f"{dots}"
69
+ else:
70
+ module_prefix = ""
71
+ for alias in node.names:
72
+ if alias.name == "*":
73
+ continue
74
+ local_name = alias.asname or alias.name
75
+ imports[local_name] = f"{module_prefix}{alias.name}"
76
+ return imports
77
+
78
+
79
+ def _dotted_name(node: ast.AST) -> str | None:
80
+ if isinstance(node, ast.Name):
81
+ return node.id
82
+ if isinstance(node, ast.Attribute):
83
+ parent = _dotted_name(node.value)
84
+ if parent is not None:
85
+ return f"{parent}.{node.attr}"
86
+ return None
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Iterable
5
+
6
+ from anti_slop_python.diagnostics import Diagnostic
7
+ from anti_slop_python.rules.base import Rule, RuleContext
8
+
9
+ _CONTAINERS = {
10
+ "dict",
11
+ "list",
12
+ "set",
13
+ "tuple",
14
+ "typing.Dict",
15
+ "typing.List",
16
+ "typing.Set",
17
+ "typing.Tuple",
18
+ "typing_extensions.Dict",
19
+ "typing_extensions.List",
20
+ "typing_extensions.Set",
21
+ "typing_extensions.Tuple",
22
+ }
23
+ _ANY_NAMES = {"Any", "typing.Any", "typing_extensions.Any"}
24
+
25
+
26
+ def _check(context: RuleContext) -> Iterable[Diagnostic]:
27
+ for node in ast.walk(context.tree):
28
+ if not isinstance(node, ast.Subscript):
29
+ continue
30
+ if context.qualified_name(node.value) not in _CONTAINERS:
31
+ continue
32
+ if _contains_any(context, node.slice):
33
+ yield context.diagnostic(RULE, node)
34
+
35
+
36
+ def _contains_any(context: RuleContext, node: ast.AST) -> bool:
37
+ if (
38
+ isinstance(node, ast.Subscript)
39
+ and context.qualified_name(node.value) in _CONTAINERS
40
+ ):
41
+ return False
42
+ if (
43
+ isinstance(node, (ast.Name, ast.Attribute))
44
+ and context.qualified_name(node) in _ANY_NAMES
45
+ ):
46
+ return True
47
+ return any(_contains_any(context, child) for child in ast.iter_child_nodes(node))
48
+
49
+
50
+ RULE = Rule(
51
+ code="SPY001",
52
+ name="no-any-containers",
53
+ message="Avoid containers parameterized with Any",
54
+ check=_check,
55
+ )
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from collections.abc import Iterable
5
+
6
+ from anti_slop_python.diagnostics import Diagnostic
7
+ from anti_slop_python.rules.base import Rule, RuleContext
8
+
9
+ _DYNAMIC_ATTRIBUTE_FUNCTIONS = {
10
+ "builtins.delattr",
11
+ "builtins.getattr",
12
+ "builtins.setattr",
13
+ "delattr",
14
+ "getattr",
15
+ "setattr",
16
+ }
17
+
18
+
19
+ def _check(context: RuleContext) -> Iterable[Diagnostic]:
20
+ for node in ast.walk(context.tree):
21
+ if not isinstance(node, ast.Call):
22
+ continue
23
+ if context.qualified_name(node.func) in _DYNAMIC_ATTRIBUTE_FUNCTIONS:
24
+ yield context.diagnostic(RULE, node)
25
+
26
+
27
+ RULE = Rule(
28
+ code="SPY002",
29
+ name="no-dynamic-attribute-access",
30
+ message="Avoid dynamic attribute access",
31
+ check=_check,
32
+ )
@@ -0,0 +1,349 @@
1
+ Metadata-Version: 2.5
2
+ Name: anti-slop-python
3
+ Version: 0.1.0
4
+ Summary: An opinionated architectural linter for Python
5
+ Project-URL: Homepage, https://github.com/ruarfff/anti-slop-python
6
+ Project-URL: Repository, https://github.com/ruarfff/anti-slop-python
7
+ Project-URL: Issues, https://github.com/ruarfff/anti-slop-python/issues
8
+ Author: Ruairí O'Brien
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anti-slop,architecture,code-quality,linter,pre-commit,ruff
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: ruff<0.17,>=0.16.5
20
+ Description-Content-Type: text/markdown
21
+
22
+ # anti-slop-python
23
+
24
+ [![skills.sh](https://skills.sh/b/ruarfff/anti-slop-python)](https://skills.sh/ruarfff/anti-slop-python)
25
+
26
+ `anti-slop-python` is a small, opinionated architectural
27
+ linter for Python inspired by and largely copied from
28
+ [dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop).
29
+
30
+ It requires [ruff](https://github.com/astral-sh/ruff) so it is not a standalone linter.
31
+
32
+ `anti-slop-python` does not attempt to determine whether code was written by a human
33
+ or an agent. It rejects patterns that weaken evidence about types, invariants,
34
+ boundaries, and dependencies.
35
+
36
+ It also catches very common issues when using LLMs to generate Python code like functions
37
+ and files getting way too big.
38
+
39
+ ## Setup
40
+
41
+ ### Install it using the agent skill
42
+
43
+ The repository includes `install-anti-slop-python`, an Agent Skill that
44
+ installs and configures anti-slop-python in a target Python repository.
45
+
46
+ The [`skills` CLI](https://github.com/vercel-labs/skills) supports project and
47
+ global installs for many coding agents.
48
+
49
+ ```bash
50
+ # Install interactively for the current project
51
+ npx skills add ruarfff/anti-slop-python --skill install-anti-slop-python
52
+ ```
53
+
54
+ Make the skill available everywhere on your system:
55
+
56
+ ```bash
57
+ npx skills add ruarfff/anti-slop-python --skill install-anti-slop-python --agent '*' --global --yes
58
+ ```
59
+
60
+ See the [`skills` CLI documentation](https://github.com/vercel-labs/skills#supported-agents).
61
+
62
+ ### Install it with pre-commit
63
+
64
+ This repository applies `anti-slop-python` to itself through the local hook in
65
+ `.pre-commit-config.yaml`.
66
+
67
+ The repository includes hook metadata. Add this entry to
68
+ `.pre-commit-config.yaml` and replace the revision with the release to use:
69
+
70
+ ```yaml
71
+ repos:
72
+ - repo: https://github.com/ruarfff/anti-slop-python
73
+ rev: v0.1.0
74
+ hooks:
75
+ - id: anti-slop-python
76
+ ```
77
+
78
+ Run against an entire project:
79
+
80
+ ```bash
81
+ uv run pre-commit run anti-slop-python --all-files
82
+ ```
83
+
84
+ #### Adopt it for selected directories
85
+
86
+ Use pre-commit's `files` regular expression to limit the hook to selected
87
+ parts of a project:
88
+
89
+ ```yaml
90
+ repos:
91
+ - repo: https://github.com/ruarfff/anti-slop-python
92
+ rev: v0.1.0
93
+ hooks:
94
+ - id: anti-slop-python
95
+ files: ^(?:src/a-specific-module/|tests/tests-for-that-module/)
96
+ ```
97
+
98
+ Useful if you want to gradually introduce `anti-slop-python` to a project.
99
+
100
+ Expand the `files` expression as more directories adopt the policy.
101
+ This does not override the project's Ruff configuration for files outside the selected directories.
102
+
103
+ ## Rules
104
+
105
+ Native `anti-slop-python` rules cover some checks that Ruff does not provide:
106
+
107
+ | Rule | Name | Rejected pattern |
108
+ | --- | --- | --- |
109
+ | SPY001 | `no-any-containers` | `dict`, `list`, `set`, or `tuple` parameterized with `Any` (including their `typing` aliases) |
110
+ | SPY002 | `no-dynamic-attribute-access` | Calls to `getattr()`, `setattr()`, or `delattr()` |
111
+
112
+ ### Ruff-backed policy
113
+
114
+ `anti-slop-python` uses Ruff for checks that Ruff already provides. It enables this
115
+ policy by default:
116
+
117
+ | Rule | Default policy |
118
+ | --- | --- |
119
+ | [`C901`](https://docs.astral.sh/ruff/rules/complex-structure/) | Cyclomatic complexity of at most 10 |
120
+ | [`PLR0915`](https://docs.astral.sh/ruff/rules/too-many-statements/) | At most 40 statements per function or method |
121
+ | [`TID251`](https://docs.astral.sh/ruff/rules/banned-api/) | Ban `unittest.mock.patch` and `mock.patch` |
122
+ | [`E722`](https://docs.astral.sh/ruff/rules/bare-except/) | Reject bare exception handlers |
123
+ | [`BLE001`](https://docs.astral.sh/ruff/rules/blind-except/) | Reject broad exception handlers |
124
+
125
+ No Ruff configuration is required when you run `anti-slop-python`. Its defaults are
126
+ equivalent to:
127
+
128
+ ```toml
129
+ [tool.ruff.lint]
130
+ extend-select = ["BLE001", "C901", "E722", "PLR0915", "TID251"]
131
+
132
+ [tool.ruff.lint.mccabe]
133
+ max-complexity = 10
134
+
135
+ [tool.ruff.lint.pylint]
136
+ max-statements = 40
137
+
138
+ [tool.ruff.lint.flake8-tidy-imports.banned-api]
139
+ "mock.patch".msg = "Avoid patching state. Pass the dependency explicitly instead."
140
+ "unittest.mock.patch".msg = "Avoid patching state. Pass the dependency explicitly instead."
141
+ ```
142
+
143
+ Project Ruff settings remain authoritative. Use them only when the project
144
+ must change or extend the defaults. For example:
145
+
146
+ ```toml
147
+ [tool.ruff.lint]
148
+ extend-ignore = ["C901"]
149
+
150
+ [tool.ruff.lint.pylint]
151
+ max-statements = 50
152
+ ```
153
+
154
+ `ignore` and `extend-ignore` disable default rules. Explicit thresholds,
155
+ exclusions, per-file ignores, and `noqa` comments also apply.
156
+
157
+ If the project defines the `TID251` banned-API table, it replaces the default
158
+ provided by `anti-slop-python`.
159
+
160
+ Inline `noqa` comments can suppress a recommended Ruff diagnostic, so
161
+ `anti-slop-python` audits them and prints a non-failing policy notice with the
162
+ source location. Ruff exclusions apply to Ruff-backed checks. Native SPY rules
163
+ still check every Python file in the paths passed to `anti-slop-python`; use the
164
+ command paths or pre-commit `files`/`exclude` settings to control that scope.
165
+
166
+ When an override is weaker than the default policy, `anti-slop-python` prints a
167
+ non-failing policy notice. Stricter settings do not produce a notice. The
168
+ defaults apply only to Ruff runs started by `anti-slop-python`; add the equivalent
169
+ configuration above if a separate `ruff check` command must enforce the same
170
+ policy.
171
+
172
+ ## Why these rules exist
173
+
174
+ These rules do not identify whether an LLM wrote the code. Humans produce the
175
+ same patterns.
176
+
177
+ The rules here are an opinionated set of guidelines that cover common issues
178
+ with LLM generated code and are an attempt to reduce the review burden.
179
+
180
+ An agent can add a large amount of locally plausible code without first "learning" the
181
+ project's types, boundaries, or dependency design. The rules help against some common
182
+ shortcuts LLMs use to make code fulfill an immediate goal while making later changes
183
+ harder to reason about.
184
+
185
+ The rules also try to encourage better design by pushing back on the most annoying
186
+ things LLMs tend to do like writing massive functions, files with thousands of lines,
187
+ tests that patch and mock everything, etc.
188
+
189
+ The following are some example rules and why they were added:
190
+
191
+ ### SPY001 — Do not put `Any` in containers
192
+
193
+ `SPY001` rejects `dict`, `list`, `set`, and `tuple` types parameterized with
194
+ `Any`, including the equivalent aliases from `typing`. A container usually
195
+ carries data across several lines, functions, or layers. Once its element type
196
+ becomes `Any`, every value taken from it escapes useful static checking and can
197
+ spread uncertainty through the rest of the program.
198
+
199
+ An agent often introduces this pattern when it can see that a value is a list
200
+ or dictionary but has not established the shape of the contents. `Any` makes
201
+ the immediate type error disappear without resolving that missing knowledge.
202
+ The rule forces the implementation to name the real type. A burden you probably
203
+ wouldn't always want for yourself but an LLM can deal with it and maybe produce
204
+ better code because of it.
205
+
206
+ Ruff's [`ANN401`](https://docs.astral.sh/ruff/rules/any-type/) rule is related
207
+ but does not replace `SPY001`: `ANN401` checks function parameters annotated
208
+ directly with `Any`, while `SPY001` checks for `Any` inside container types such
209
+ as `list[Any]` and `dict[str, Any]`.
210
+
211
+ ### SPY002 — Do not hide attributes behind strings
212
+
213
+ `SPY002` rejects calls to `getattr()`, `setattr()`, and `delattr()`. Dynamic
214
+ attribute access turns an interface into a runtime string convention. Type
215
+ checkers and refactoring tools have less evidence, misspelled names fail late,
216
+ and a default passed to `getattr()` can hide a missing invariant.
217
+
218
+ Generated code often uses `getattr(value, "name", None)` to support several
219
+ assumed object shapes without checking which shapes the application actually
220
+ allows. The rule requires direct attribute access for a known interface.
221
+
222
+ Ruff has related [`B009`](https://docs.astral.sh/ruff/rules/get-attr-with-constant/),
223
+ [`B010`](https://docs.astral.sh/ruff/rules/set-attr-with-constant/), and
224
+ preview-only [`B043`](https://docs.astral.sh/ruff/rules/del-attr-with-constant/)
225
+ rules. They flag `getattr()`, `setattr()`, and `delattr()` only when the
226
+ attribute name is a constant string. They therefore allow calls such as
227
+ `getattr(value, name)`, which may be intentional but still make an interface
228
+ depend on runtime strings. `SPY002` rejects
229
+ the built-ins regardless of whether the name is constant.
230
+
231
+ ### [`C901`](https://docs.astral.sh/ruff/rules/complex-structure/) — Limit decision complexity
232
+
233
+ `C901` measures the number of paths through a function. Anti-slop-python uses a
234
+ maximum McCabe complexity of 10. A function can be short and still be complex
235
+ when it contains many branches, loops, or exception paths. Each added path
236
+ increases the number of states that a reader and a test suite must consider.
237
+
238
+ Coding agents tend to tack on more complexity to achieve a goal like making tests pass.
239
+
240
+ ### [`PLR0915`](https://docs.astral.sh/ruff/rules/too-many-statements/) — Limit function size
241
+
242
+ `PLR0915` rejects functions or methods with more than 40 statements. This
243
+ complements `C901`: a long function can have simple control flow and still do
244
+ too much.
245
+
246
+ Generated implementations tend to keep the full requested workflow in one
247
+ function because that is the easiest shape to produce in one pass. The
248
+ statement limit makes things like mixed responsibilities more visible.
249
+
250
+ ### [`TID251`](https://docs.astral.sh/ruff/rules/banned-api/) — Do not patch dependencies
251
+
252
+ Ruff's `TID251` rule can ban project-selected APIs. Anti-slop-python uses it to ban
253
+ `unittest.mock.patch` and `mock.patch`. Patching replaces module or object
254
+ state at runtime, so a test depends on where a symbol happens to be imported
255
+ rather than on an explicit interface. Refactoring an import can then break the
256
+ test even when behavior has not changed.
257
+
258
+ Agents frequently reach for `patch()` because it can isolate almost any call
259
+ without changing production design. There's a tendency to test implementation rather than behavior
260
+ when trying to improve test coverage.
261
+
262
+ This setting helps a little with directing a clearer interface-based design, although it is generally
263
+ not enough by itself and LLMs need a lot of direction to get to this kind of design.
264
+
265
+ ### [`E722`](https://docs.astral.sh/ruff/rules/bare-except/) — Do not use bare exception handlers
266
+
267
+ `E722` rejects a bare `except:` handler. A bare handler catches
268
+ `BaseException`, including `KeyboardInterrupt` and `SystemExit`. It can prevent
269
+ a process from stopping and can disguise failures that the code cannot
270
+ actually recover from.
271
+
272
+ LLMs tend to be good at building exception handling but occasionally they take shortcuts and
273
+ this helps avoid that.
274
+
275
+ ### [`BLE001`](https://docs.astral.sh/ruff/rules/blind-except/) — Do not catch broad exceptions
276
+
277
+ `BLE001` flags broad named handlers such as `except Exception` and
278
+ `except BaseException` when they handle or swallow the error.
279
+
280
+ Ruff permits broad handlers that re-raise and recognized logging patterns
281
+ that retain the exception trace.
282
+
283
+ A coding agent may wrap a large generated block in `except Exception` because it
284
+ does not know the operation's failure contract. This setting forces the agent to
285
+ work through the possible errors and make them clear.
286
+
287
+ ## Usage
288
+
289
+ Run Ruff and the native checker together on directories or individual Python
290
+ files:
291
+
292
+ ```console
293
+ uvx anti-slop-python .
294
+ uvx anti-slop-python src/
295
+ uvx anti-slop-python src/anti_slop_python/cli.py
296
+ ```
297
+
298
+ Diagnostics use a conventional source format, and any diagnostic makes the
299
+ command exit with status 1:
300
+
301
+ ```text
302
+ src/api/parser.py:41:12 SPY001 Avoid containers parameterized with Any
303
+ src/api/parser.py:45:8 SPY002 Avoid dynamic attribute access
304
+ src/orders/service.py:18:5 C901 `create_order` is too complex (14 > 10)
305
+ ```
306
+
307
+ Policy notices are written to standard error and do not change the exit code:
308
+
309
+ ```text
310
+ anti-slop-python policy notice: C901 is disabled; recommended: enabled
311
+ ```
312
+
313
+ Ruff runs in check-only mode even if the project has `fix = true`.
314
+
315
+ For local development:
316
+
317
+ ```console
318
+ uv sync --dev
319
+ uv run pre-commit install
320
+ uv run anti-slop-python src tests
321
+ uv run pre-commit run --all-files
322
+ uv run pytest
323
+ uv run ruff format --check .
324
+ uv run ruff check .
325
+ ```
326
+
327
+ ## Examples
328
+
329
+ [`examples/basic_project`](examples/basic_project) is a small Python project
330
+ with intentional native and Ruff-backed violations, plus preferred
331
+ alternatives. This repository's Ruff and pre-commit checks exclude `examples/`;
332
+ its direct self-check targets `src` and `tests`.
333
+
334
+ Run the example explicitly to see its diagnostics:
335
+
336
+ ```console
337
+ uv run anti-slop-python examples/basic_project
338
+ ```
339
+
340
+ ## Scope and limitations
341
+
342
+ Native checks use Python's built-in `ast` and a pragmatic import alias map.
343
+ They do not perform scope-aware name resolution, type checking, cross-file
344
+ analysis, configuration, suppressions, or autofixes. Ruff-backed checks use
345
+ the project's effective Ruff configuration and suppression behavior.
346
+
347
+ ## License
348
+
349
+ [MIT](LICENSE)
@@ -0,0 +1,16 @@
1
+ anti_slop_python/__init__.py,sha256=oSNUe5E1IfvjdHknDmnTbQRwHhJhDFM1YhWM2s7ZnEM,243
2
+ anti_slop_python/__main__.py,sha256=yv0XL1KDDfArsZW2K7WnRsrd3yT1QYtfsMpbXdLkYQQ,64
3
+ anti_slop_python/checker.py,sha256=lO2ysGZNC0fHcwuEiaPCkA0rkEGGu2Fnl0ukSiA8BaA,1526
4
+ anti_slop_python/cli.py,sha256=A1EuYu-yNimswzxBUVtm7K4aijRItwzIu7qGJQCRzkw,2566
5
+ anti_slop_python/diagnostics.py,sha256=34UxfZPmUkHl6Oeq1QnGXtKpSFdjGpvH9-3vypUxoUE,403
6
+ anti_slop_python/ruff_integration.py,sha256=W9k5u38AORFU6xuWQYMq97_yIb3QD9X4Ej62Bfju-cc,6646
7
+ anti_slop_python/ruff_policy.py,sha256=8AJv62Xsw8GvrzZxyUBad-tkBLzxf8JuRXm5wf7WfkM,13461
8
+ anti_slop_python/rules/__init__.py,sha256=fVfcMMdM3FHF0VX0Gh2azu725rATRKtLppKw-JhSswk,348
9
+ anti_slop_python/rules/base.py,sha256=eR7GS_-Ays1uZUfRLATpQhvItTDKjxpcnCjtfKPkz9A,2572
10
+ anti_slop_python/rules/no_any_containers.py,sha256=pG3D4nfImnhq3M9zoH3ST-r6qxYK5Ua6RE58cwemPDk,1443
11
+ anti_slop_python/rules/no_dynamic_attribute_access.py,sha256=4b0OEGpXlM_AuoU5lTwlommPNb_BKxvkh990FhnCI3A,779
12
+ anti_slop_python-0.1.0.dist-info/METADATA,sha256=u26KSgk9J7jkudKVwI5niRaqpwfNYF_EPKIRvGP6p4k,13755
13
+ anti_slop_python-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
14
+ anti_slop_python-0.1.0.dist-info/entry_points.txt,sha256=2LuCaWUA9_X8B3dT-ccfKZ-prhl7pNwGn9NanzNG9i0,63
15
+ anti_slop_python-0.1.0.dist-info/licenses/LICENSE,sha256=-DUrK07cTAr9HVwvRNoUZHJc9gfE2X-CuFkrIG5FvjQ,1072
16
+ anti_slop_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ anti-slop-python = anti_slop_python.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ruairí O'Brien
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.