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.
@@ -0,0 +1,3 @@
1
+ """Bubbles Lint keeps Python modules small, composable, and inspectable."""
2
+
3
+ __version__ = "0.1.0"
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)
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bubbles-lint = bubbles_lint.cli:main
@@ -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.