bubbles-lint 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.
- bubbles_lint/__init__.py +3 -0
- bubbles_lint/cli.py +43 -0
- bubbles_lint/config.py +79 -0
- bubbles_lint/models.py +76 -0
- bubbles_lint/report.py +52 -0
- bubbles_lint/rules/__init__.py +1 -0
- bubbles_lint/rules/ai_smells.py +113 -0
- bubbles_lint/rules/boundaries.py +94 -0
- bubbles_lint/rules/imports.py +14 -0
- bubbles_lint/rules/leaks.py +216 -0
- bubbles_lint/rules/registry.py +13 -0
- bubbles_lint/rules/size.py +111 -0
- bubbles_lint/scanner.py +88 -0
- bubbles_lint-0.1.0.dist-info/METADATA +211 -0
- bubbles_lint-0.1.0.dist-info/RECORD +18 -0
- bubbles_lint-0.1.0.dist-info/WHEEL +4 -0
- bubbles_lint-0.1.0.dist-info/entry_points.txt +2 -0
- bubbles_lint-0.1.0.dist-info/licenses/LICENSE +21 -0
bubbles_lint/__init__.py
ADDED
bubbles_lint/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from bubbles_lint.config import load_config
|
|
8
|
+
from bubbles_lint.report import format_human, format_json
|
|
9
|
+
from bubbles_lint.scanner import scan_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = build_parser()
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
if args.command == "scan":
|
|
17
|
+
target = Path(args.path)
|
|
18
|
+
config = load_config(target)
|
|
19
|
+
result = scan_path(target, config=config)
|
|
20
|
+
output = format_json(result) if args.json else format_human(result)
|
|
21
|
+
print(output)
|
|
22
|
+
return 1 if result.has_findings else 0
|
|
23
|
+
|
|
24
|
+
parser.print_help()
|
|
25
|
+
return 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
prog="bubbles-lint",
|
|
31
|
+
description="Architectural linting for small, composable Python code.",
|
|
32
|
+
)
|
|
33
|
+
subcommands = parser.add_subparsers(dest="command")
|
|
34
|
+
|
|
35
|
+
scan = subcommands.add_parser("scan", help="Scan Python files for architecture findings.")
|
|
36
|
+
scan.add_argument("path", nargs="?", default=".", help="File or directory to scan.")
|
|
37
|
+
scan.add_argument("--json", action="store_true", help="Emit machine-readable JSON output.")
|
|
38
|
+
|
|
39
|
+
return parser
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
sys.exit(main())
|
bubbles_lint/config.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_EXCLUDES = frozenset({
|
|
10
|
+
".git",
|
|
11
|
+
".venv",
|
|
12
|
+
"__pycache__",
|
|
13
|
+
"build",
|
|
14
|
+
"dist",
|
|
15
|
+
"venv",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Config:
|
|
21
|
+
max_file_lines: int = 500
|
|
22
|
+
max_function_lines: int = 50
|
|
23
|
+
max_class_methods: int = 10
|
|
24
|
+
max_function_params: int = 5
|
|
25
|
+
max_imports_per_module: int = 20
|
|
26
|
+
max_nesting_depth: int = 4
|
|
27
|
+
max_side_effect_kinds: int = 3
|
|
28
|
+
max_ai_module_lines: int = 200
|
|
29
|
+
max_ai_class_dependencies: int = 8
|
|
30
|
+
allow_private_imports: bool = False
|
|
31
|
+
excludes: frozenset[str] = DEFAULT_EXCLUDES
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_config(start: Path) -> Config:
|
|
35
|
+
pyproject = find_pyproject(start)
|
|
36
|
+
if pyproject is None:
|
|
37
|
+
return Config()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
41
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
42
|
+
return Config()
|
|
43
|
+
|
|
44
|
+
tool_config = data.get("tool", {})
|
|
45
|
+
if not isinstance(tool_config, dict):
|
|
46
|
+
return Config()
|
|
47
|
+
|
|
48
|
+
values = tool_config.get("bubbles-lint", tool_config.get("bubbles", {}))
|
|
49
|
+
if not isinstance(values, dict):
|
|
50
|
+
return Config()
|
|
51
|
+
|
|
52
|
+
return build_config(values)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_pyproject(start: Path) -> Path | None:
|
|
56
|
+
current = start.resolve()
|
|
57
|
+
if current.is_file():
|
|
58
|
+
current = current.parent
|
|
59
|
+
|
|
60
|
+
for directory in (current, *current.parents):
|
|
61
|
+
pyproject = directory / "pyproject.toml"
|
|
62
|
+
if pyproject.exists():
|
|
63
|
+
return pyproject
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_config(values: dict[str, Any]) -> Config:
|
|
68
|
+
defaults = Config()
|
|
69
|
+
kwargs: dict[str, Any] = {}
|
|
70
|
+
for field_name in defaults.__dataclass_fields__:
|
|
71
|
+
if field_name not in values:
|
|
72
|
+
continue
|
|
73
|
+
value = values[field_name]
|
|
74
|
+
if field_name == "excludes":
|
|
75
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
76
|
+
kwargs[field_name] = DEFAULT_EXCLUDES | frozenset(value)
|
|
77
|
+
continue
|
|
78
|
+
kwargs[field_name] = value
|
|
79
|
+
return Config(**kwargs)
|
bubbles_lint/models.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from bubbles_lint.config import Config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Severity(str, Enum):
|
|
12
|
+
INFO = "info"
|
|
13
|
+
WARNING = "warning"
|
|
14
|
+
ERROR = "error"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Finding:
|
|
19
|
+
rule: str
|
|
20
|
+
severity: Severity
|
|
21
|
+
path: Path
|
|
22
|
+
line: int
|
|
23
|
+
message: str
|
|
24
|
+
suggestion: str
|
|
25
|
+
|
|
26
|
+
def to_json(self) -> dict[str, object]:
|
|
27
|
+
return {
|
|
28
|
+
"rule": self.rule,
|
|
29
|
+
"severity": self.severity.value,
|
|
30
|
+
"path": self.path.as_posix(),
|
|
31
|
+
"line": self.line,
|
|
32
|
+
"message": self.message,
|
|
33
|
+
"suggestion": self.suggestion,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ModuleContext:
|
|
39
|
+
path: Path
|
|
40
|
+
root: Path
|
|
41
|
+
source: str
|
|
42
|
+
tree: object
|
|
43
|
+
line_count: int
|
|
44
|
+
config: Config
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def relative_path(self) -> Path:
|
|
48
|
+
try:
|
|
49
|
+
return self.path.relative_to(self.root)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return self.path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Rule(Protocol):
|
|
55
|
+
id: str
|
|
56
|
+
title: str
|
|
57
|
+
|
|
58
|
+
def check(self, context: ModuleContext) -> list[Finding]:
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ScanResult:
|
|
64
|
+
findings: list[Finding] = field(default_factory=list)
|
|
65
|
+
files_scanned: int = 0
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def has_errors(self) -> bool:
|
|
69
|
+
return any(finding.severity is Severity.ERROR for finding in self.findings)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def has_findings(self) -> bool:
|
|
73
|
+
return bool(self.findings)
|
|
74
|
+
|
|
75
|
+
def extend(self, findings: list[Finding]) -> None:
|
|
76
|
+
self.findings.extend(findings)
|
bubbles_lint/report.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
|
|
7
|
+
from bubbles_lint.models import Finding, ScanResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RULE_TITLES = MappingProxyType({
|
|
11
|
+
"ai-smells": "AI Smells",
|
|
12
|
+
"bubble-boundary": "Bubble Boundary",
|
|
13
|
+
"bubble-burst": "Bubble Burst",
|
|
14
|
+
"bubble-leak": "Bubble Leak",
|
|
15
|
+
"parser": "Parser",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_json(result: ScanResult) -> str:
|
|
20
|
+
return json.dumps(
|
|
21
|
+
{
|
|
22
|
+
"findings": [finding.to_json() for finding in result.findings],
|
|
23
|
+
"files_scanned": result.files_scanned,
|
|
24
|
+
},
|
|
25
|
+
indent=2,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def format_human(result: ScanResult) -> str:
|
|
30
|
+
if not result.findings:
|
|
31
|
+
return f"No bubbles burst. Scanned {result.files_scanned} Python file(s)."
|
|
32
|
+
|
|
33
|
+
grouped: dict[str, list[Finding]] = defaultdict(list)
|
|
34
|
+
for finding in result.findings:
|
|
35
|
+
grouped[_family(finding.rule)].append(finding)
|
|
36
|
+
|
|
37
|
+
chunks: list[str] = []
|
|
38
|
+
for family in sorted(grouped):
|
|
39
|
+
chunks.append(f"Bubble: {RULE_TITLES.get(family, family)}")
|
|
40
|
+
for finding in grouped[family]:
|
|
41
|
+
chunks.append("")
|
|
42
|
+
chunks.append(f"{finding.path}:{finding.line}")
|
|
43
|
+
chunks.append(f"{finding.severity.value}: {finding.message}")
|
|
44
|
+
chunks.append("")
|
|
45
|
+
chunks.append("Suggestion:")
|
|
46
|
+
chunks.append(finding.suggestion)
|
|
47
|
+
|
|
48
|
+
return "\n".join(chunks)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _family(rule: str) -> str:
|
|
52
|
+
return rule.split("/", 1)[0]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Architecture rules shipped with Bubbles Lint."""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from bubbles_lint.models import Finding, ModuleContext, Severity
|
|
6
|
+
from bubbles_lint.rules.imports import imports
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
SMELLY_MODULES = frozenset({"utils.py", "helpers.py", "manager.py", "service.py"})
|
|
10
|
+
SMELLY_CLASS_SUFFIXES = ("Manager", "Service", "Handler")
|
|
11
|
+
BRANCH_NODES = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.With, ast.AsyncWith, ast.Try, ast.Match)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AiSmellRule:
|
|
15
|
+
id = "ai-smells"
|
|
16
|
+
title = "AI Smells"
|
|
17
|
+
|
|
18
|
+
def check(self, context: ModuleContext) -> list[Finding]:
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
|
|
21
|
+
generic_finding = _generic_module_finding(context)
|
|
22
|
+
if generic_finding:
|
|
23
|
+
findings.append(generic_finding)
|
|
24
|
+
|
|
25
|
+
for node in ast.walk(context.tree):
|
|
26
|
+
if isinstance(node, ast.ClassDef) and node.name.endswith(SMELLY_CLASS_SUFFIXES):
|
|
27
|
+
class_finding = _class_smell_finding(context, node)
|
|
28
|
+
if class_finding:
|
|
29
|
+
findings.append(class_finding)
|
|
30
|
+
|
|
31
|
+
max_depth, line = _max_nesting(context.tree)
|
|
32
|
+
if max_depth > context.config.max_nesting_depth:
|
|
33
|
+
findings.append(Finding(
|
|
34
|
+
rule="ai-smells/deep-nesting",
|
|
35
|
+
severity=Severity.WARNING,
|
|
36
|
+
path=context.relative_path,
|
|
37
|
+
line=line,
|
|
38
|
+
message=(
|
|
39
|
+
f"Code nesting depth is {max_depth}; recommended maximum "
|
|
40
|
+
f"is {context.config.max_nesting_depth}."
|
|
41
|
+
),
|
|
42
|
+
suggestion="Use guard clauses, smaller functions, or a simple pipeline to flatten control flow.",
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
return findings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _generic_module_finding(context: ModuleContext) -> Finding | None:
|
|
49
|
+
if context.path.name not in SMELLY_MODULES:
|
|
50
|
+
return None
|
|
51
|
+
module_imports = imports(context.tree)
|
|
52
|
+
is_large = context.line_count > context.config.max_ai_module_lines
|
|
53
|
+
has_many_imports = len(module_imports) > context.config.max_imports_per_module
|
|
54
|
+
if not is_large and not has_many_imports:
|
|
55
|
+
return None
|
|
56
|
+
return Finding(
|
|
57
|
+
rule="ai-smells/generic-module",
|
|
58
|
+
severity=Severity.WARNING,
|
|
59
|
+
path=context.relative_path,
|
|
60
|
+
line=1,
|
|
61
|
+
message=(
|
|
62
|
+
f"Generic module '{context.path.name}' has {context.line_count} lines "
|
|
63
|
+
f"and {len(module_imports)} imports."
|
|
64
|
+
),
|
|
65
|
+
suggestion="Rename around a specific responsibility and split unrelated helpers apart.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _class_smell_finding(context: ModuleContext, node: ast.ClassDef) -> Finding | None:
|
|
70
|
+
methods = [
|
|
71
|
+
item for item in node.body
|
|
72
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
73
|
+
]
|
|
74
|
+
dependencies = _class_dependency_count(node)
|
|
75
|
+
is_broad = (
|
|
76
|
+
len(methods) > context.config.max_class_methods
|
|
77
|
+
or dependencies > context.config.max_ai_class_dependencies
|
|
78
|
+
)
|
|
79
|
+
if not is_broad:
|
|
80
|
+
return None
|
|
81
|
+
return Finding(
|
|
82
|
+
rule="ai-smells/god-class-name",
|
|
83
|
+
severity=Severity.WARNING,
|
|
84
|
+
path=context.relative_path,
|
|
85
|
+
line=node.lineno,
|
|
86
|
+
message=f"Class '{node.name}' looks broad: {len(methods)} methods, {dependencies} dependencies.",
|
|
87
|
+
suggestion="Replace broad coordinator classes with smaller objects and explicit functions.",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _class_dependency_count(node: ast.ClassDef) -> int:
|
|
92
|
+
names: set[str] = set()
|
|
93
|
+
for item in ast.walk(node):
|
|
94
|
+
if isinstance(item, ast.Attribute) and isinstance(item.value, ast.Name) and item.value.id == "self":
|
|
95
|
+
names.add(item.attr)
|
|
96
|
+
return len(names)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _max_nesting(tree: ast.AST) -> tuple[int, int]:
|
|
100
|
+
best_depth = 0
|
|
101
|
+
best_line = 1
|
|
102
|
+
|
|
103
|
+
def visit(node: ast.AST, depth: int) -> None:
|
|
104
|
+
nonlocal best_depth, best_line
|
|
105
|
+
next_depth = depth + 1 if isinstance(node, BRANCH_NODES) else depth
|
|
106
|
+
if next_depth > best_depth:
|
|
107
|
+
best_depth = next_depth
|
|
108
|
+
best_line = getattr(node, "lineno", best_line)
|
|
109
|
+
for child in ast.iter_child_nodes(node):
|
|
110
|
+
visit(child, next_depth)
|
|
111
|
+
|
|
112
|
+
visit(tree, 0)
|
|
113
|
+
return best_depth, best_line
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from bubbles_lint.models import Finding, ModuleContext, Severity
|
|
8
|
+
from bubbles_lint.rules.imports import imports
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BoundaryRule:
|
|
12
|
+
id = "bubble-boundary"
|
|
13
|
+
title = "Bubble Boundary"
|
|
14
|
+
|
|
15
|
+
def __init__(self, import_graph: dict[str, set[str]] | None = None) -> None:
|
|
16
|
+
self.import_graph = import_graph or {}
|
|
17
|
+
|
|
18
|
+
def check(self, context: ModuleContext) -> list[Finding]:
|
|
19
|
+
findings: list[Finding] = []
|
|
20
|
+
module_imports = imports(context.tree)
|
|
21
|
+
|
|
22
|
+
if len(module_imports) > context.config.max_imports_per_module:
|
|
23
|
+
findings.append(Finding(
|
|
24
|
+
rule="bubble-boundary/too-many-imports",
|
|
25
|
+
severity=Severity.WARNING,
|
|
26
|
+
path=context.relative_path,
|
|
27
|
+
line=1,
|
|
28
|
+
message=(
|
|
29
|
+
f"Module imports {len(module_imports)} dependencies; recommended maximum "
|
|
30
|
+
f"is {context.config.max_imports_per_module}."
|
|
31
|
+
),
|
|
32
|
+
suggestion="Narrow this module's responsibilities or move integration code to a boundary.",
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
if not context.config.allow_private_imports:
|
|
36
|
+
for node in ast.walk(context.tree):
|
|
37
|
+
if isinstance(node, ast.ImportFrom) and node.module and _has_private_part(node.module):
|
|
38
|
+
findings.append(Finding(
|
|
39
|
+
rule="bubble-boundary/private-import",
|
|
40
|
+
severity=Severity.WARNING,
|
|
41
|
+
path=context.relative_path,
|
|
42
|
+
line=node.lineno,
|
|
43
|
+
message=f"Import reaches into private module '{node.module}'.",
|
|
44
|
+
suggestion="Depend on a public interface instead of a private implementation module.",
|
|
45
|
+
))
|
|
46
|
+
|
|
47
|
+
module_name = module_name_for_path(context.root, context.path)
|
|
48
|
+
for target in self.import_graph.get(module_name, set()):
|
|
49
|
+
if module_name in self.import_graph.get(target, set()):
|
|
50
|
+
findings.append(Finding(
|
|
51
|
+
rule="bubble-boundary/circular-import",
|
|
52
|
+
severity=Severity.ERROR,
|
|
53
|
+
path=context.relative_path,
|
|
54
|
+
line=1,
|
|
55
|
+
message=f"Module '{module_name}' and '{target}' import each other.",
|
|
56
|
+
suggestion="Extract shared contracts into a smaller third module or invert the dependency.",
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
return findings
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_import_graph(files: list[Path], root: Path) -> dict[str, set[str]]:
|
|
63
|
+
modules = {module_name_for_path(root, path): path for path in files}
|
|
64
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
65
|
+
for module, path in modules.items():
|
|
66
|
+
try:
|
|
67
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, SyntaxError, UnicodeDecodeError):
|
|
69
|
+
continue
|
|
70
|
+
for imported in imports(tree):
|
|
71
|
+
candidates = _candidate_modules(imported)
|
|
72
|
+
for candidate in candidates:
|
|
73
|
+
if candidate in modules and candidate != module:
|
|
74
|
+
graph[module].add(candidate)
|
|
75
|
+
return graph
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def module_name_for_path(root: Path, path: Path) -> str:
|
|
79
|
+
relative = path.relative_to(root).with_suffix("")
|
|
80
|
+
parts = list(relative.parts)
|
|
81
|
+
if parts[-1] == "__init__":
|
|
82
|
+
parts = parts[:-1]
|
|
83
|
+
return ".".join(parts)
|
|
84
|
+
|
|
85
|
+
def _candidate_modules(name: str) -> list[str]:
|
|
86
|
+
parts = name.split(".")
|
|
87
|
+
return [".".join(parts[:index]) for index in range(len(parts), 0, -1)]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _has_private_part(module: str) -> bool:
|
|
91
|
+
return any(
|
|
92
|
+
part.startswith("_") and not (part.startswith("__") and part.endswith("__"))
|
|
93
|
+
for part in module.split(".")
|
|
94
|
+
)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def imports(tree: ast.AST) -> set[str]:
|
|
7
|
+
names: set[str] = set()
|
|
8
|
+
for node in ast.walk(tree):
|
|
9
|
+
if isinstance(node, ast.Import):
|
|
10
|
+
for alias in node.names:
|
|
11
|
+
names.add(alias.name)
|
|
12
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
13
|
+
names.add(node.module)
|
|
14
|
+
return names
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from bubbles_lint.models import Finding, ModuleContext, Severity
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MUTABLE_LITERALS = (ast.List, ast.Dict, ast.Set)
|
|
10
|
+
MUTABLE_CALLS = frozenset({"dict", "list", "set", "defaultdict", "Counter"})
|
|
11
|
+
CONFIG_MODULE_NAMES = frozenset({"config", "settings", "environment", "env"})
|
|
12
|
+
SIDE_EFFECT_MODULES = {
|
|
13
|
+
"filesystem": frozenset({"open", "pathlib", "shutil", "glob", "os.path"}),
|
|
14
|
+
"network": frozenset({"requests", "httpx", "urllib", "socket", "aiohttp"}),
|
|
15
|
+
"database": frozenset({"sqlite3", "sqlalchemy", "psycopg2", "pymongo", "redis"}),
|
|
16
|
+
"subprocess": frozenset({"subprocess"}),
|
|
17
|
+
"logging": frozenset({"logging"}),
|
|
18
|
+
"rendering": frozenset({"jinja2", "render", "template", "matplotlib", "PIL"}),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LeakRule:
|
|
23
|
+
id = "bubble-leak"
|
|
24
|
+
title = "Bubble Leak"
|
|
25
|
+
|
|
26
|
+
def check(self, context: ModuleContext) -> list[Finding]:
|
|
27
|
+
visitor = _LeakVisitor(context)
|
|
28
|
+
visitor.visit(context.tree)
|
|
29
|
+
return visitor.findings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _LeakVisitor(ast.NodeVisitor):
|
|
33
|
+
def __init__(self, context: ModuleContext) -> None:
|
|
34
|
+
self.context = context
|
|
35
|
+
self.findings: list[Finding] = []
|
|
36
|
+
self.function_depth = 0
|
|
37
|
+
self.import_aliases = _import_aliases(context.tree)
|
|
38
|
+
|
|
39
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
40
|
+
if self.function_depth == 0 and _is_mutable_value(node.value) and not _all_constants(node.targets):
|
|
41
|
+
self.findings.append(Finding(
|
|
42
|
+
rule="bubble-leak/global-mutable-state",
|
|
43
|
+
severity=Severity.WARNING,
|
|
44
|
+
path=self.context.relative_path,
|
|
45
|
+
line=node.lineno,
|
|
46
|
+
message="Module-level assignment creates mutable global state.",
|
|
47
|
+
suggestion="Move mutable state behind an explicit object or pass it through function calls.",
|
|
48
|
+
))
|
|
49
|
+
self.generic_visit(node)
|
|
50
|
+
|
|
51
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
52
|
+
is_mutable_global = (
|
|
53
|
+
self.function_depth == 0
|
|
54
|
+
and node.value is not None
|
|
55
|
+
and _is_mutable_value(node.value)
|
|
56
|
+
and not _all_constants([node.target])
|
|
57
|
+
)
|
|
58
|
+
if is_mutable_global:
|
|
59
|
+
self.findings.append(Finding(
|
|
60
|
+
rule="bubble-leak/global-mutable-state",
|
|
61
|
+
severity=Severity.WARNING,
|
|
62
|
+
path=self.context.relative_path,
|
|
63
|
+
line=node.lineno,
|
|
64
|
+
message="Module-level assignment creates mutable global state.",
|
|
65
|
+
suggestion="Move mutable state behind an explicit object or pass it through function calls.",
|
|
66
|
+
))
|
|
67
|
+
self.generic_visit(node)
|
|
68
|
+
|
|
69
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
70
|
+
self._visit_function(node)
|
|
71
|
+
|
|
72
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
73
|
+
self._visit_function(node)
|
|
74
|
+
|
|
75
|
+
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
76
|
+
self.function_depth += 1
|
|
77
|
+
side_effects = _side_effect_kinds(node, self.import_aliases)
|
|
78
|
+
if len(side_effects) > self.context.config.max_side_effect_kinds:
|
|
79
|
+
kinds = ", ".join(sorted(side_effects))
|
|
80
|
+
self.findings.append(Finding(
|
|
81
|
+
rule="bubble-leak/mixed-side-effects",
|
|
82
|
+
severity=Severity.WARNING,
|
|
83
|
+
path=self.context.relative_path,
|
|
84
|
+
line=node.lineno,
|
|
85
|
+
message=f"Function '{node.name}' mixes side effects: {kinds}.",
|
|
86
|
+
suggestion="Separate orchestration from filesystem, network, database, logging, and rendering work.",
|
|
87
|
+
))
|
|
88
|
+
self.generic_visit(node)
|
|
89
|
+
self.function_depth -= 1
|
|
90
|
+
|
|
91
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
92
|
+
if not _is_config_module(self.context.path) and _is_os_environ_read(node):
|
|
93
|
+
self.findings.append(Finding(
|
|
94
|
+
rule="bubble-leak/env-read-outside-config",
|
|
95
|
+
severity=Severity.WARNING,
|
|
96
|
+
path=self.context.relative_path,
|
|
97
|
+
line=node.lineno,
|
|
98
|
+
message="Environment variable read appears outside a config module.",
|
|
99
|
+
suggestion="Read environment variables in a config boundary and pass values explicitly.",
|
|
100
|
+
))
|
|
101
|
+
self.generic_visit(node)
|
|
102
|
+
|
|
103
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
104
|
+
if not _is_config_module(self.context.path) and _is_os_getenv_call(node):
|
|
105
|
+
self.findings.append(Finding(
|
|
106
|
+
rule="bubble-leak/env-read-outside-config",
|
|
107
|
+
severity=Severity.WARNING,
|
|
108
|
+
path=self.context.relative_path,
|
|
109
|
+
line=node.lineno,
|
|
110
|
+
message="Environment variable read appears outside a config module.",
|
|
111
|
+
suggestion="Read environment variables in a config boundary and pass values explicitly.",
|
|
112
|
+
))
|
|
113
|
+
self.generic_visit(node)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_mutable_value(node: ast.AST) -> bool:
|
|
117
|
+
if isinstance(node, MUTABLE_LITERALS):
|
|
118
|
+
return True
|
|
119
|
+
if isinstance(node, ast.Call):
|
|
120
|
+
name = _call_name(node.func)
|
|
121
|
+
return name in MUTABLE_CALLS
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _all_constants(nodes: list[ast.expr]) -> bool:
|
|
126
|
+
names = [node.id for node in nodes if isinstance(node, ast.Name)]
|
|
127
|
+
return bool(names) and all(name.isupper() for name in names)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_config_module(path) -> bool:
|
|
131
|
+
stem = path.stem.lower()
|
|
132
|
+
return stem in CONFIG_MODULE_NAMES or stem.endswith("_config") or stem.endswith("_settings")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_os_environ_read(node: ast.Subscript) -> bool:
|
|
136
|
+
return _dotted_name(node.value) == "os.environ"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_os_getenv_call(node: ast.Call) -> bool:
|
|
140
|
+
return _call_name(node.func) in {"os.getenv", "os.environ.get"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _side_effect_kinds(function: ast.AST, aliases: dict[str, str]) -> set[str]:
|
|
144
|
+
kinds: set[str] = set()
|
|
145
|
+
for node in ast.walk(function):
|
|
146
|
+
if isinstance(node, ast.Call):
|
|
147
|
+
kinds.update(_side_effects_for_call(node, aliases))
|
|
148
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
149
|
+
kinds.update(_side_effects_for_import(node))
|
|
150
|
+
return kinds
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _side_effects_for_call(node: ast.Call, aliases: dict[str, str]) -> set[str]:
|
|
154
|
+
call = _call_name(node.func)
|
|
155
|
+
kinds: set[str] = set()
|
|
156
|
+
if call == "open":
|
|
157
|
+
kinds.add("filesystem")
|
|
158
|
+
if call == "print":
|
|
159
|
+
kinds.add("rendering")
|
|
160
|
+
|
|
161
|
+
resolved = _resolve_alias(call, aliases)
|
|
162
|
+
return kinds | _matching_side_effects(resolved)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _side_effects_for_import(node: ast.Import | ast.ImportFrom) -> set[str]:
|
|
166
|
+
kinds: set[str] = set()
|
|
167
|
+
for name in _imported_modules(node):
|
|
168
|
+
kinds.update(_matching_side_effects(name))
|
|
169
|
+
return kinds
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _matching_side_effects(name: str) -> set[str]:
|
|
173
|
+
return {
|
|
174
|
+
kind
|
|
175
|
+
for kind, markers in SIDE_EFFECT_MODULES.items()
|
|
176
|
+
if any(name == marker or name.startswith(f"{marker}.") for marker in markers)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _import_aliases(tree: ast.AST) -> dict[str, str]:
|
|
181
|
+
aliases: dict[str, str] = {}
|
|
182
|
+
for node in ast.walk(tree):
|
|
183
|
+
if isinstance(node, ast.Import):
|
|
184
|
+
for alias in node.names:
|
|
185
|
+
aliases[alias.asname or alias.name.split(".")[0]] = alias.name
|
|
186
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
187
|
+
for alias in node.names:
|
|
188
|
+
aliases[alias.asname or alias.name] = f"{node.module}.{alias.name}"
|
|
189
|
+
return aliases
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _imported_modules(node: ast.Import | ast.ImportFrom) -> Iterable[str]:
|
|
193
|
+
if isinstance(node, ast.Import):
|
|
194
|
+
for alias in node.names:
|
|
195
|
+
yield alias.name
|
|
196
|
+
elif node.module:
|
|
197
|
+
yield node.module
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _resolve_alias(name: str, aliases: dict[str, str]) -> str:
|
|
201
|
+
head, _, tail = name.partition(".")
|
|
202
|
+
resolved = aliases.get(head, head)
|
|
203
|
+
return f"{resolved}.{tail}" if tail else resolved
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _call_name(node: ast.AST) -> str:
|
|
207
|
+
return _dotted_name(node)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _dotted_name(node: ast.AST) -> str:
|
|
211
|
+
if isinstance(node, ast.Name):
|
|
212
|
+
return node.id
|
|
213
|
+
if isinstance(node, ast.Attribute):
|
|
214
|
+
parent = _dotted_name(node.value)
|
|
215
|
+
return f"{parent}.{node.attr}" if parent else node.attr
|
|
216
|
+
return ""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from bubbles_lint.rules.ai_smells import AiSmellRule
|
|
2
|
+
from bubbles_lint.rules.boundaries import BoundaryRule
|
|
3
|
+
from bubbles_lint.rules.leaks import LeakRule
|
|
4
|
+
from bubbles_lint.rules.size import SizeRule
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_rules():
|
|
8
|
+
return [
|
|
9
|
+
SizeRule(),
|
|
10
|
+
LeakRule(),
|
|
11
|
+
BoundaryRule(),
|
|
12
|
+
AiSmellRule(),
|
|
13
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from bubbles_lint.models import Finding, ModuleContext, Severity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SizeRule:
|
|
9
|
+
id = "bubble-burst"
|
|
10
|
+
title = "Bubble Burst"
|
|
11
|
+
|
|
12
|
+
def check(self, context: ModuleContext) -> list[Finding]:
|
|
13
|
+
findings: list[Finding] = []
|
|
14
|
+
|
|
15
|
+
file_finding = _file_size_finding(context)
|
|
16
|
+
if file_finding:
|
|
17
|
+
findings.append(file_finding)
|
|
18
|
+
|
|
19
|
+
for node in ast.walk(context.tree):
|
|
20
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
21
|
+
findings.extend(_function_findings(context, node))
|
|
22
|
+
|
|
23
|
+
if isinstance(node, ast.ClassDef):
|
|
24
|
+
class_finding = _class_finding(context, node)
|
|
25
|
+
if class_finding:
|
|
26
|
+
findings.append(class_finding)
|
|
27
|
+
|
|
28
|
+
return findings
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _file_size_finding(context: ModuleContext) -> Finding | None:
|
|
32
|
+
if context.line_count <= context.config.max_file_lines:
|
|
33
|
+
return None
|
|
34
|
+
return Finding(
|
|
35
|
+
rule="bubble-burst/file-too-large",
|
|
36
|
+
severity=Severity.WARNING,
|
|
37
|
+
path=context.relative_path,
|
|
38
|
+
line=1,
|
|
39
|
+
message=(
|
|
40
|
+
f"File has {context.line_count} lines; recommended maximum "
|
|
41
|
+
f"is {context.config.max_file_lines}."
|
|
42
|
+
),
|
|
43
|
+
suggestion="Split this module into smaller bubbles with one responsibility each.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _function_findings(
|
|
48
|
+
context: ModuleContext,
|
|
49
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
50
|
+
) -> list[Finding]:
|
|
51
|
+
findings: list[Finding] = []
|
|
52
|
+
length = _node_length(node)
|
|
53
|
+
if length > context.config.max_function_lines:
|
|
54
|
+
findings.append(Finding(
|
|
55
|
+
rule="bubble-burst/function-too-large",
|
|
56
|
+
severity=Severity.WARNING,
|
|
57
|
+
path=context.relative_path,
|
|
58
|
+
line=node.lineno,
|
|
59
|
+
message=(
|
|
60
|
+
f"Function '{node.name}' has {length} lines; recommended "
|
|
61
|
+
f"maximum is {context.config.max_function_lines}."
|
|
62
|
+
),
|
|
63
|
+
suggestion="Extract smaller functions with explicit inputs and outputs.",
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
param_count = _parameter_count(node.args)
|
|
67
|
+
if param_count > context.config.max_function_params:
|
|
68
|
+
findings.append(Finding(
|
|
69
|
+
rule="bubble-burst/too-many-parameters",
|
|
70
|
+
severity=Severity.WARNING,
|
|
71
|
+
path=context.relative_path,
|
|
72
|
+
line=node.lineno,
|
|
73
|
+
message=(
|
|
74
|
+
f"Function '{node.name}' has {param_count} parameters; "
|
|
75
|
+
f"recommended maximum is {context.config.max_function_params}."
|
|
76
|
+
),
|
|
77
|
+
suggestion="Group related data behind a clear interface or split responsibilities.",
|
|
78
|
+
))
|
|
79
|
+
return findings
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _class_finding(context: ModuleContext, node: ast.ClassDef) -> Finding | None:
|
|
83
|
+
methods = [
|
|
84
|
+
item for item in node.body
|
|
85
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
86
|
+
]
|
|
87
|
+
if len(methods) <= context.config.max_class_methods:
|
|
88
|
+
return None
|
|
89
|
+
return Finding(
|
|
90
|
+
rule="bubble-burst/class-too-many-methods",
|
|
91
|
+
severity=Severity.WARNING,
|
|
92
|
+
path=context.relative_path,
|
|
93
|
+
line=node.lineno,
|
|
94
|
+
message=(
|
|
95
|
+
f"Class '{node.name}' has {len(methods)} methods; recommended "
|
|
96
|
+
f"maximum is {context.config.max_class_methods}."
|
|
97
|
+
),
|
|
98
|
+
suggestion="Split the class into smaller collaborators with narrower roles.",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _node_length(node: ast.AST) -> int:
|
|
103
|
+
end = getattr(node, "end_lineno", None) or getattr(node, "lineno", 1)
|
|
104
|
+
return end - getattr(node, "lineno", 1) + 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parameter_count(args: ast.arguments) -> int:
|
|
108
|
+
positional = list(args.posonlyargs) + list(args.args)
|
|
109
|
+
if positional and positional[0].arg in {"self", "cls"}:
|
|
110
|
+
positional = positional[1:]
|
|
111
|
+
return len(positional) + len(args.kwonlyargs) + (1 if args.vararg else 0) + (1 if args.kwarg else 0)
|
bubbles_lint/scanner.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Iterable
|
|
6
|
+
|
|
7
|
+
from bubbles_lint.config import Config, load_config
|
|
8
|
+
from bubbles_lint.models import Finding, ModuleContext, Rule, ScanResult, Severity
|
|
9
|
+
from bubbles_lint.rules.boundaries import BoundaryRule, build_import_graph
|
|
10
|
+
from bubbles_lint.rules.registry import default_rules
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_path(path: Path, config: Config | None = None, rules: Iterable[Rule] | None = None) -> ScanResult:
|
|
14
|
+
root = path.resolve()
|
|
15
|
+
if root.is_file():
|
|
16
|
+
root = root.parent
|
|
17
|
+
|
|
18
|
+
active_config = config or load_config(root)
|
|
19
|
+
files = list(iter_python_files(path.resolve(), active_config))
|
|
20
|
+
active_rules = list(rules) if rules is not None else _rules_for_files(files, root)
|
|
21
|
+
result = ScanResult(files_scanned=0)
|
|
22
|
+
|
|
23
|
+
for file_path in files:
|
|
24
|
+
result.files_scanned += 1
|
|
25
|
+
try:
|
|
26
|
+
source = file_path.read_text(encoding="utf-8")
|
|
27
|
+
tree = ast.parse(source, filename=str(file_path))
|
|
28
|
+
except SyntaxError as error:
|
|
29
|
+
result.findings.append(Finding(
|
|
30
|
+
rule="parser/syntax-error",
|
|
31
|
+
severity=Severity.ERROR,
|
|
32
|
+
path=_relative(file_path, root),
|
|
33
|
+
line=error.lineno or 1,
|
|
34
|
+
message=f"Could not parse Python file: {error.msg}.",
|
|
35
|
+
suggestion="Fix the syntax error before Bubbles Lint can inspect this module's architecture.",
|
|
36
|
+
))
|
|
37
|
+
continue
|
|
38
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
39
|
+
result.findings.append(Finding(
|
|
40
|
+
rule="parser/read-error",
|
|
41
|
+
severity=Severity.ERROR,
|
|
42
|
+
path=_relative(file_path, root),
|
|
43
|
+
line=1,
|
|
44
|
+
message=f"Could not read Python file: {error}.",
|
|
45
|
+
suggestion="Ensure the file is readable UTF-8 Python source.",
|
|
46
|
+
))
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
context = ModuleContext(
|
|
50
|
+
path=file_path,
|
|
51
|
+
root=root,
|
|
52
|
+
source=source,
|
|
53
|
+
tree=tree,
|
|
54
|
+
line_count=len(source.splitlines()),
|
|
55
|
+
config=active_config,
|
|
56
|
+
)
|
|
57
|
+
for rule in active_rules:
|
|
58
|
+
result.extend(rule.check(context))
|
|
59
|
+
|
|
60
|
+
result.findings.sort(key=lambda item: (item.path.as_posix(), item.line, item.rule))
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_python_files(path: Path, config: Config) -> Iterable[Path]:
|
|
65
|
+
if path.is_file():
|
|
66
|
+
if path.suffix == ".py":
|
|
67
|
+
yield path
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
for item in sorted(path.rglob("*.py")):
|
|
71
|
+
if any(part in config.excludes for part in item.parts):
|
|
72
|
+
continue
|
|
73
|
+
yield item
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _rules_for_files(files: list[Path], root: Path) -> list[Rule]:
|
|
77
|
+
graph = build_import_graph(files, root)
|
|
78
|
+
return [
|
|
79
|
+
BoundaryRule(import_graph=graph) if isinstance(rule, BoundaryRule) else rule
|
|
80
|
+
for rule in default_rules()
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _relative(path: Path, root: Path) -> Path:
|
|
85
|
+
try:
|
|
86
|
+
return path.relative_to(root)
|
|
87
|
+
except ValueError:
|
|
88
|
+
return path
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bubbles-lint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python-first architectural linter for small, composable, Unix-like code.
|
|
5
|
+
Author: Bubbles Lint contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Syed (Sadat) Nazrul
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Keywords: architecture,linter,python,static-analysis,unix-philosophy
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
40
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Provides-Extra: dev
|
|
43
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
<p align="center">
|
|
47
|
+
<img src="assets/bubbles-lint-logo.png" alt="Bubbles Lint logo" width="220">
|
|
48
|
+
</p>
|
|
49
|
+
|
|
50
|
+
Bubbles Lint is an architectural linter for Python code. It is inspired by Unix and Linux software design principles: do one thing well, keep modules small, compose simple parts, and make boundaries easy to inspect.
|
|
51
|
+
|
|
52
|
+
Think of it as Ruff for architecture. It does not compete with Ruff, Black, Flake8, or Pylint. Bubbles Lint looks for software shape problems: code that is too large, too coupled, too magical, or too monolithic.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install bubbles-lint
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For local development from this repository:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install -e ".[dev]"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Usage
|
|
67
|
+
|
|
68
|
+
Scan the current repository:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
bubbles-lint scan .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Emit JSON for CI systems:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bubbles-lint scan . --json
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Bubbles Lint exits with status `1` when findings are present and `0` when the scan is clean.
|
|
81
|
+
|
|
82
|
+
## Philosophy
|
|
83
|
+
|
|
84
|
+
Bubbles Lint encourages codebases where:
|
|
85
|
+
|
|
86
|
+
- modules have one clear responsibility
|
|
87
|
+
- functions and classes stay small enough to replace
|
|
88
|
+
- dependencies flow through explicit interfaces
|
|
89
|
+
- configuration is loaded at the edge
|
|
90
|
+
- side effects are isolated from pure logic
|
|
91
|
+
- text and data move through well-defined boundaries
|
|
92
|
+
- components are easy to inspect, test, and swap
|
|
93
|
+
|
|
94
|
+
The goal is not style enforcement. The goal is software design discipline, especially in Python codebases that have grown quickly with help from AI coding assistants.
|
|
95
|
+
|
|
96
|
+
## Rules
|
|
97
|
+
|
|
98
|
+
### Bubble Burst
|
|
99
|
+
|
|
100
|
+
Flags code that has grown too large:
|
|
101
|
+
|
|
102
|
+
- files over `max_file_lines`
|
|
103
|
+
- functions over `max_function_lines`
|
|
104
|
+
- classes with more than `max_class_methods`
|
|
105
|
+
- functions with more than `max_function_params`
|
|
106
|
+
|
|
107
|
+
### Bubble Leak
|
|
108
|
+
|
|
109
|
+
Flags hidden coupling and mixed side effects:
|
|
110
|
+
|
|
111
|
+
- global mutable state
|
|
112
|
+
- direct environment variable reads outside config modules
|
|
113
|
+
- functions mixing too many side-effect categories, such as filesystem, network, database, subprocess, logging, and rendering
|
|
114
|
+
|
|
115
|
+
### Bubble Boundary
|
|
116
|
+
|
|
117
|
+
Flags dependency boundary problems:
|
|
118
|
+
|
|
119
|
+
- circular imports where practical
|
|
120
|
+
- modules importing too many dependencies
|
|
121
|
+
- imports from private modules like `from package._internal import thing`
|
|
122
|
+
|
|
123
|
+
### AI Smells
|
|
124
|
+
|
|
125
|
+
Flags common broad abstractions produced in rushed or generated code:
|
|
126
|
+
|
|
127
|
+
- oversized `utils.py`, `helpers.py`, `manager.py`, and `service.py` modules
|
|
128
|
+
- broad classes ending in `Manager`, `Service`, or `Handler`
|
|
129
|
+
- deeply nested control flow
|
|
130
|
+
|
|
131
|
+
## Configuration
|
|
132
|
+
|
|
133
|
+
Configure Bubbles Lint in `pyproject.toml`:
|
|
134
|
+
|
|
135
|
+
```toml
|
|
136
|
+
[tool.bubbles-lint]
|
|
137
|
+
max_file_lines = 500
|
|
138
|
+
max_function_lines = 50
|
|
139
|
+
max_class_methods = 10
|
|
140
|
+
max_function_params = 5
|
|
141
|
+
max_imports_per_module = 20
|
|
142
|
+
max_nesting_depth = 4
|
|
143
|
+
allow_private_imports = false
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Additional knobs:
|
|
147
|
+
|
|
148
|
+
```toml
|
|
149
|
+
[tool.bubbles-lint]
|
|
150
|
+
max_side_effect_kinds = 3
|
|
151
|
+
max_ai_module_lines = 200
|
|
152
|
+
max_ai_class_dependencies = 8
|
|
153
|
+
excludes = ["generated"]
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Bubbles Lint always ignores `.venv`, `venv`, `.git`, `__pycache__`, `build`, and `dist`.
|
|
157
|
+
|
|
158
|
+
Existing `[tool.bubbles]` configs are still read for compatibility, but new projects should use `[tool.bubbles-lint]`.
|
|
159
|
+
|
|
160
|
+
## Human Output
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
Bubble: Bubble Burst
|
|
164
|
+
|
|
165
|
+
src/payment_service.py:1
|
|
166
|
+
warning: File has 1437 lines; recommended maximum is 500.
|
|
167
|
+
|
|
168
|
+
Suggestion:
|
|
169
|
+
Split this module into smaller bubbles with one responsibility each.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## JSON Output
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"findings": [
|
|
177
|
+
{
|
|
178
|
+
"rule": "bubble-burst/file-too-large",
|
|
179
|
+
"severity": "warning",
|
|
180
|
+
"path": "src/payment_service.py",
|
|
181
|
+
"line": 1,
|
|
182
|
+
"message": "File has 1437 lines; recommended maximum is 500.",
|
|
183
|
+
"suggestion": "Split this module into smaller bubbles with one responsibility each."
|
|
184
|
+
}
|
|
185
|
+
],
|
|
186
|
+
"files_scanned": 1
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## CI
|
|
191
|
+
|
|
192
|
+
Example GitHub Actions step:
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
- name: Install Bubbles Lint
|
|
196
|
+
run: pip install .
|
|
197
|
+
|
|
198
|
+
- name: Scan architecture
|
|
199
|
+
run: bubbles-lint scan . --json
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Development
|
|
203
|
+
|
|
204
|
+
Run tests:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
pip install -e ".[dev]"
|
|
208
|
+
pytest
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The rule engine is intentionally small. New rules implement a `check(context)` method and return `Finding` objects. Parsing, configuration, scanning, reporting, and CLI code are separated so the tool can grow without becoming the kind of monolith it warns about.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
bubbles_lint/__init__.py,sha256=Czd291KJEuH1LavswVQYGzZN_y-jyyBABVSn9RKH8vM,99
|
|
2
|
+
bubbles_lint/cli.py,sha256=WD0R92wYYKI1HRQOgxdZt_PIcz1pGI1fG3_m2AUbXeA,1290
|
|
3
|
+
bubbles_lint/config.py,sha256=0KMuo89xKRX3xx_0_3Ag74HFOzAQoyJ-8urz6yyRU84,2094
|
|
4
|
+
bubbles_lint/models.py,sha256=8pwjn-q--qIMuha-Wn7mgUQgSQU1fq2dSm2CTvFMEkU,1606
|
|
5
|
+
bubbles_lint/report.py,sha256=GFnSzrA1-a55R7VOGStJ-oWoKDEXBjez1OtG8NEyTSA,1473
|
|
6
|
+
bubbles_lint/scanner.py,sha256=zvcDZKljQkc0l_HuSihVeoAOOgB99TOTqQgDTMBDvQ4,3021
|
|
7
|
+
bubbles_lint/rules/__init__.py,sha256=PQD1t3A7SxjPsLUaplODyU0Pi6kDYtRB7CGfAPy2CA0,52
|
|
8
|
+
bubbles_lint/rules/ai_smells.py,sha256=YysDHErSQ9eHl_5Dt_fNQF1J8iZcENvJBW1nK5v7RLo,4095
|
|
9
|
+
bubbles_lint/rules/boundaries.py,sha256=byl38_TZDS-tzFbxS5gsQBqj22bqRmSXj1CkZ2T5jks,3778
|
|
10
|
+
bubbles_lint/rules/imports.py,sha256=XfRO4zWjBnI0ZWLM9q9_x87jhg3pDqOzF2uiP_PC-Wc,380
|
|
11
|
+
bubbles_lint/rules/leaks.py,sha256=BBE6c94vgn9-Fhq_JJSW0N0esq2P93UTZu34c6Fwkog,8154
|
|
12
|
+
bubbles_lint/rules/registry.py,sha256=kXgG49M2NO6SQpejRSADoGiLo9Zl9nX5f1iauT2Kz8Y,328
|
|
13
|
+
bubbles_lint/rules/size.py,sha256=1Phl5l7RWFRLumEtJbTc_aYCZ7r-GBOuABdMgnwzXvU,3873
|
|
14
|
+
bubbles_lint-0.1.0.dist-info/METADATA,sha256=wCsn1SQI_KR5XU6wmUvN51uxwYgSjlxQeAUsZ8MgWNA,6380
|
|
15
|
+
bubbles_lint-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
bubbles_lint-0.1.0.dist-info/entry_points.txt,sha256=8i6NCvjaxfUeemNfXiKgGmmaU7pi9i4aYmdnWhRJ_uo,55
|
|
17
|
+
bubbles_lint-0.1.0.dist-info/licenses/LICENSE,sha256=ntzBqSx5kLY1uLtYQwpo1nEZURJ7XO78NGIaiNCjaVc,1076
|
|
18
|
+
bubbles_lint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Syed (Sadat) Nazrul
|
|
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.
|