chisel-checker 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. chisel/__init__.py +4 -0
  2. chisel/checker/__init__.py +11 -0
  3. chisel/checker/config.py +9 -0
  4. chisel/checker/controllers/check_controller.py +124 -0
  5. chisel/checker/errors.py +16 -0
  6. chisel/checker/factory.py +45 -0
  7. chisel/checker/models/__init__.py +20 -0
  8. chisel/checker/models/exemption.py +8 -0
  9. chisel/checker/models/file_info.py +14 -0
  10. chisel/checker/models/import_edge.py +10 -0
  11. chisel/checker/models/layer.py +19 -0
  12. chisel/checker/models/project_info.py +12 -0
  13. chisel/checker/models/result.py +17 -0
  14. chisel/checker/models/severity.py +8 -0
  15. chisel/checker/models/violation.py +13 -0
  16. chisel/checker/reporter.py +70 -0
  17. chisel/checker/repositories/exception_registry.py +60 -0
  18. chisel/checker/repositories/file_discovery.py +119 -0
  19. chisel/checker/repositories/file_reader.py +8 -0
  20. chisel/checker/repositories/import_graph.py +100 -0
  21. chisel/checker/repositories/protocols.py +25 -0
  22. chisel/checker/services/app_file.py +125 -0
  23. chisel/checker/services/check_test_structure.py +331 -0
  24. chisel/checker/services/complexity.py +203 -0
  25. chisel/checker/services/concurrency.py +80 -0
  26. chisel/checker/services/config_startup.py +67 -0
  27. chisel/checker/services/error_flow.py +95 -0
  28. chisel/checker/services/import_boundary.py +285 -0
  29. chisel/checker/services/project_structure.py +216 -0
  30. chisel/checker/services/protocols.py +72 -0
  31. chisel/checker/services/session.py +76 -0
  32. chisel/checker/services/structural.py +799 -0
  33. chisel/checker/services/suppression.py +98 -0
  34. chisel/cli/main.py +126 -0
  35. chisel/py.typed +0 -0
  36. chisel_checker-0.1.1.dist-info/METADATA +107 -0
  37. chisel_checker-0.1.1.dist-info/RECORD +39 -0
  38. chisel_checker-0.1.1.dist-info/WHEEL +4 -0
  39. chisel_checker-0.1.1.dist-info/entry_points.txt +2 -0
chisel/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+
2
+ from chisel.checker import check_project
3
+
4
+ __all__ = ["check_project"]
@@ -0,0 +1,11 @@
1
+
2
+ from chisel.checker.factory import CheckerFactory
3
+ from chisel.checker.reporter import Reporter
4
+
5
+
6
+ def check_project(project_path: str) -> None:
7
+ factory = CheckerFactory()
8
+ controller = factory.create_controller()
9
+ result = controller.check(project_path)
10
+ reporter = Reporter()
11
+ reporter.report(result)
@@ -0,0 +1,9 @@
1
+
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class CheckerConfig:
8
+ target_path: Path = field(default_factory=Path.cwd)
9
+ strict: bool = True
@@ -0,0 +1,124 @@
1
+
2
+ import ast
3
+ import sys
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from chisel.checker.errors import ImportGraphError
8
+ from chisel.checker.models.project_info import ProjectInfo
9
+ from chisel.checker.models.result import CheckResult
10
+ from chisel.checker.models.severity import Severity
11
+ from chisel.checker.models.violation import Violation
12
+ from chisel.checker.repositories.file_discovery import FileDiscovery
13
+ from chisel.checker.repositories.file_reader import FileReader
14
+ from chisel.checker.repositories.import_graph import ImportGraph
15
+ from chisel.checker.repositories.protocols import IImportGraph
16
+ from chisel.checker.services.protocols import ICheckerService, ISuppressionService
17
+
18
+
19
+ from chisel.checker.repositories.exception_registry import ExceptionRegistry
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class CheckController:
24
+ _suppression: ISuppressionService
25
+ _services: list[ICheckerService] = field(default_factory=list)
26
+ _import_graph: IImportGraph = field(default_factory=ImportGraph) # noqa
27
+
28
+ def check(self, project_path: str) -> CheckResult:
29
+ root = Path(project_path).resolve()
30
+ project = self._prepare_project(root)
31
+ violations: list[Violation] = []
32
+
33
+ if project.package_name:
34
+ try:
35
+ self._build_import_graph(root, project.package_name)
36
+ except ImportGraphError as exc:
37
+ violations.append(
38
+ Violation(
39
+ file="<import-graph>",
40
+ line=0,
41
+ severity=Severity.WARNING,
42
+ rule_id="import-graph:build-failed",
43
+ message=str(exc),
44
+ )
45
+ )
46
+
47
+ violations.extend(self._run_services(project))
48
+ violations = self._apply_exceptions(root, violations)
49
+ sources = self._collect_sources(project)
50
+ active = self._suppression.check(violations, sources)
51
+ return self._summarize(active, len(project.files))
52
+
53
+ def _prepare_project(self, root: Path) -> ProjectInfo:
54
+ discovery = FileDiscovery()
55
+ project = discovery.discover(root)
56
+ reader = FileReader()
57
+ populated = [
58
+ self._load_file(f, root, reader) for f in project.files
59
+ ]
60
+ return ProjectInfo(
61
+ root_path=project.root_path,
62
+ files=populated,
63
+ package_name=project.package_name,
64
+ )
65
+
66
+ def _load_file(
67
+ self, file_info, root: Path, reader: FileReader
68
+ ):
69
+ try:
70
+ source = reader.read(root / file_info.path)
71
+ return type(file_info)(
72
+ path=file_info.path,
73
+ layer=file_info.layer,
74
+ source=source,
75
+ ast_tree=ast.parse(source),
76
+ )
77
+ except Exception:
78
+ return file_info
79
+
80
+ def _build_import_graph(self, root: Path, package_name: str) -> None:
81
+ src = root / "src"
82
+ src_path = str(src) if src.is_dir() else str(root)
83
+ original_path = list(sys.path)
84
+ try:
85
+ if src_path not in sys.path:
86
+ sys.path.insert(0, src_path)
87
+ self._import_graph.build(root, package_name)
88
+ except Exception as exc:
89
+ raise ImportGraphError(
90
+ f"Failed to build import graph for "
91
+ f"'{package_name}': {exc}"
92
+ ) from exc
93
+ finally:
94
+ sys.path = original_path
95
+
96
+ def _run_services(self, project: ProjectInfo) -> list[Violation]:
97
+ violations: list[Violation] = []
98
+ for service in self._services:
99
+ violations.extend(service.check(project))
100
+ return violations
101
+
102
+ def _collect_sources(self, project: ProjectInfo) -> dict[str, str]:
103
+ return {str(f.path): f.source for f in project.files}
104
+
105
+ def _apply_exceptions(
106
+ self, root: Path, violations: list[Violation]
107
+ ) -> list[Violation]:
108
+ registry = ExceptionRegistry()
109
+ registry.load(root)
110
+ return registry.filter(violations)
111
+
112
+ def _summarize(
113
+ self, violations: list[Violation], files_checked: int
114
+ ) -> CheckResult:
115
+ errors = sum(1 for v in violations if v.severity == Severity.ERROR)
116
+ warnings = sum(1 for v in violations if v.severity == Severity.WARNING)
117
+ info = sum(1 for v in violations if v.severity == Severity.INFO)
118
+ return CheckResult(
119
+ violations=violations,
120
+ errors=errors,
121
+ warnings=warnings,
122
+ info=info,
123
+ files_checked=files_checked,
124
+ )
@@ -0,0 +1,16 @@
1
+
2
+
3
+ class CheckerError(Exception):
4
+ pass
5
+
6
+
7
+ class FileNotFoundError(CheckerError):
8
+ pass
9
+
10
+
11
+ class ImportGraphError(CheckerError):
12
+ pass
13
+
14
+
15
+ class ConfigError(CheckerError):
16
+ pass
@@ -0,0 +1,45 @@
1
+
2
+ from dataclasses import dataclass, field
3
+
4
+ from chisel.checker.controllers.check_controller import CheckController
5
+ from chisel.checker.repositories.import_graph import ImportGraph
6
+ from chisel.checker.repositories.protocols import IImportGraph
7
+ from chisel.checker.services.app_file import AppFileService
8
+ from chisel.checker.services.complexity import ComplexityService
9
+ from chisel.checker.services.concurrency import ConcurrencyService
10
+ from chisel.checker.services.config_startup import ConfigStartupService
11
+ from chisel.checker.services.error_flow import ErrorFlowService
12
+ from chisel.checker.services.import_boundary import ImportBoundaryService
13
+ from chisel.checker.services.project_structure import ProjectStructureService
14
+ from chisel.checker.services.protocols import ICheckerService
15
+ from chisel.checker.services.session import SessionService
16
+ from chisel.checker.services.structural import StructuralService
17
+ from chisel.checker.services.suppression import SuppressionService
18
+ from chisel.checker.services.check_test_structure import CheckTestStructureService
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class CheckerFactory:
23
+ _import_graph: IImportGraph = field(default_factory=ImportGraph)
24
+ _suppression: SuppressionService = field(default_factory=SuppressionService)
25
+ strict: bool = True
26
+
27
+ def create_controller(self) -> CheckController:
28
+ services: list[ICheckerService] = [
29
+ ImportBoundaryService(_import_graph=self._import_graph),
30
+ StructuralService(),
31
+ ComplexityService(),
32
+ ConcurrencyService(),
33
+ SessionService(),
34
+ ErrorFlowService(),
35
+ ConfigStartupService(),
36
+ ProjectStructureService(strict=self.strict),
37
+ AppFileService(),
38
+ CheckTestStructureService(),
39
+ ]
40
+
41
+ return CheckController(
42
+ _services=services,
43
+ _suppression=self._suppression,
44
+ _import_graph=self._import_graph,
45
+ )
@@ -0,0 +1,20 @@
1
+
2
+ from chisel.checker.models.exemption import Exemption
3
+ from chisel.checker.models.file_info import FileInfo
4
+ from chisel.checker.models.import_edge import ImportEdge
5
+ from chisel.checker.models.layer import Layer
6
+ from chisel.checker.models.project_info import ProjectInfo
7
+ from chisel.checker.models.result import CheckResult
8
+ from chisel.checker.models.severity import Severity
9
+ from chisel.checker.models.violation import Violation
10
+
11
+ __all__ = [
12
+ "CheckResult",
13
+ "Exemption",
14
+ "FileInfo",
15
+ "ImportEdge",
16
+ "Layer",
17
+ "ProjectInfo",
18
+ "Severity",
19
+ "Violation",
20
+ ]
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass(frozen=True, slots=True)
5
+ class Exemption:
6
+ file_patterns: list[str] = field(default_factory=list)
7
+ rule_ids: list[str] = field(default_factory=list)
8
+ reason: str = ""
@@ -0,0 +1,14 @@
1
+
2
+ import ast
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from chisel.checker.models.layer import Layer
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class FileInfo:
11
+ path: Path
12
+ layer: Layer
13
+ source: str = ""
14
+ ast_tree: ast.Module | None = None
@@ -0,0 +1,10 @@
1
+
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True, slots=True)
6
+ class ImportEdge:
7
+ importer: str
8
+ imported: str
9
+ line_number: int
10
+ line_contents: str
@@ -0,0 +1,19 @@
1
+
2
+ from enum import Enum
3
+
4
+
5
+ class Layer(Enum):
6
+ MODELS = "models"
7
+ ERRORS = "errors"
8
+ CONFIG = "config"
9
+ SERVICES = "services"
10
+ REPOSITORIES = "repositories"
11
+ CONTROLLERS = "controllers"
12
+ FACTORY = "factory"
13
+ ROUTES = "routes"
14
+ DEPENDENCIES = "dependencies"
15
+ ERROR_HANDLERS = "error_handlers"
16
+ APP_FILE = "app_file"
17
+ UTILS = "utils"
18
+ TESTS = "tests"
19
+ UNKNOWN = "unknown"
@@ -0,0 +1,12 @@
1
+
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+ from chisel.checker.models.file_info import FileInfo
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class ProjectInfo:
10
+ root_path: Path
11
+ files: list[FileInfo] = field(default_factory=list)
12
+ package_name: str = ""
@@ -0,0 +1,17 @@
1
+
2
+ from dataclasses import dataclass, field
3
+
4
+ from chisel.checker.models.violation import Violation
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class CheckResult:
9
+ violations: list[Violation] = field(default_factory=list)
10
+ errors: int = 0
11
+ warnings: int = 0
12
+ info: int = 0
13
+ files_checked: int = 0
14
+
15
+ @property
16
+ def has_errors(self) -> bool:
17
+ return self.errors > 0
@@ -0,0 +1,8 @@
1
+
2
+ from enum import Enum
3
+
4
+
5
+ class Severity(Enum):
6
+ ERROR = "error"
7
+ WARNING = "warning"
8
+ INFO = "info"
@@ -0,0 +1,13 @@
1
+
2
+ from dataclasses import dataclass
3
+
4
+ from chisel.checker.models.severity import Severity
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Violation:
9
+ file: str
10
+ line: int
11
+ severity: Severity
12
+ rule_id: str
13
+ message: str
@@ -0,0 +1,70 @@
1
+
2
+ import json
3
+ from dataclasses import dataclass
4
+
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+
8
+ from chisel.checker.models.result import CheckResult
9
+ from chisel.checker.models.severity import Severity
10
+ from chisel.checker.models.violation import Violation
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class Reporter:
15
+ _console: Console = Console()
16
+
17
+ def report(self, result: CheckResult) -> None:
18
+ table = Table(title="Chisel Architecture Check")
19
+ table.add_column("File", style="cyan", no_wrap=False)
20
+ table.add_column("Line", style="dim", justify="right")
21
+ table.add_column("Severity")
22
+ table.add_column("Rule")
23
+ table.add_column("Message", style="white")
24
+
25
+ for v in result.violations:
26
+ sev = self._severity_label(v.severity)
27
+ table.add_row(
28
+ v.file,
29
+ str(v.line),
30
+ sev,
31
+ v.rule_id,
32
+ v.message,
33
+ )
34
+
35
+ self._console.print(table)
36
+
37
+ self._console.print(
38
+ f"\n[fine]: {result.files_checked} files checked | "
39
+ f"[red]{result.errors} errors[/red] | "
40
+ f"[yellow]{result.warnings} warnings[/yellow] | "
41
+ f"[blue]{result.info} info[/blue]"
42
+ )
43
+
44
+ def report_json(self, result: CheckResult) -> str:
45
+ data = {
46
+ "summary": {
47
+ "files_checked": result.files_checked,
48
+ "errors": result.errors,
49
+ "warnings": result.warnings,
50
+ "info": result.info,
51
+ },
52
+ "violations": [
53
+ {
54
+ "file": v.file,
55
+ "line": v.line,
56
+ "severity": v.severity.value,
57
+ "rule_id": v.rule_id,
58
+ "message": v.message,
59
+ }
60
+ for v in result.violations
61
+ ],
62
+ }
63
+ return json.dumps(data, indent=2)
64
+
65
+ def _severity_label(self, severity: Severity) -> str:
66
+ if severity == Severity.ERROR:
67
+ return "[red]ERROR[/red]"
68
+ if severity == Severity.WARNING:
69
+ return "[yellow]WARNING[/yellow]"
70
+ return "[blue]INFO[/blue]"
@@ -0,0 +1,60 @@
1
+ import fnmatch
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+ try:
6
+ import tomllib
7
+ except ImportError:
8
+ import tomli as tomllib
9
+
10
+ from chisel.checker.models.exemption import Exemption
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ExceptionRegistry:
15
+ _exemptions: list[Exemption] = field(default_factory=list)
16
+
17
+ def load(self, root: Path) -> None:
18
+ config_path = root / "chisel-exceptions.toml"
19
+ if not config_path.exists():
20
+ return
21
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
22
+ for entry in data.get("exceptions", []):
23
+ self._exemptions.append(
24
+ Exemption(
25
+ file_patterns=list(entry.get("files", [])),
26
+ rule_ids=list(entry.get("rules", [])),
27
+ reason=entry.get("reason", ""),
28
+ )
29
+ )
30
+
31
+ def is_exempted(self, file: str, rule_id: str) -> bool:
32
+ for exemption in self._exemptions:
33
+ if not self._file_matches(file, exemption.file_patterns):
34
+ continue
35
+ if not self._rule_matches(rule_id, exemption.rule_ids):
36
+ continue
37
+ return True
38
+ return False
39
+
40
+ def _file_matches(self, file: str, patterns: list[str]) -> bool:
41
+ for pattern in patterns:
42
+ if fnmatch.fnmatch(file, pattern):
43
+ return True
44
+ return False
45
+
46
+ def _rule_matches(self, rule_id: str, rules: list[str]) -> bool:
47
+ for rule in rules:
48
+ if rule == "*":
49
+ return True
50
+ if rule_id == rule:
51
+ return True
52
+ if rule_id.startswith(rule + ":") or rule_id.startswith(rule + "."):
53
+ return True
54
+ return False
55
+
56
+ def filter(self, violations: list) -> list:
57
+ return [
58
+ v for v in violations
59
+ if not self.is_exempted(v.file, v.rule_id)
60
+ ]
@@ -0,0 +1,119 @@
1
+
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+
5
+ from chisel.checker.models.file_info import FileInfo
6
+ from chisel.checker.models.layer import Layer
7
+ from chisel.checker.models.project_info import ProjectInfo
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class FileDiscovery:
12
+ _LAYER_MAP = {
13
+ "models": Layer.MODELS,
14
+ "errors": Layer.ERRORS,
15
+ "config": Layer.CONFIG,
16
+ "services": Layer.SERVICES,
17
+ "repositories": Layer.REPOSITORIES,
18
+ "controllers": Layer.CONTROLLERS,
19
+ "factory": Layer.FACTORY,
20
+ "routes": Layer.ROUTES,
21
+ "dependencies": Layer.DEPENDENCIES,
22
+ "error_handlers": Layer.ERROR_HANDLERS,
23
+ "utils": Layer.UTILS,
24
+ "tests": Layer.TESTS,
25
+ "app": Layer.APP_FILE,
26
+ }
27
+
28
+ _LAYER_FILENAMES = {
29
+ "errors.py": Layer.ERRORS,
30
+ "config.py": Layer.CONFIG,
31
+ "factory.py": Layer.FACTORY,
32
+ "dependencies.py": Layer.DEPENDENCIES,
33
+ "error_handlers.py": Layer.ERROR_HANDLERS,
34
+ "app.py": Layer.APP_FILE,
35
+ }
36
+
37
+ def discover(self, root_path: Path) -> ProjectInfo:
38
+ root_path = root_path.resolve()
39
+ package_name = self._find_package_name(root_path)
40
+ src_root = self._find_src_root(root_path, package_name)
41
+
42
+ files: list[FileInfo] = []
43
+ for py_file in sorted(src_root.rglob("*.py")):
44
+ if self._is_ignored(py_file, src_root):
45
+ continue
46
+ relative = py_file.relative_to(root_path)
47
+ layer = self._classify_file(py_file, src_root, package_name)
48
+ files.append(FileInfo(path=relative, layer=layer))
49
+
50
+ tests_root = root_path / "tests"
51
+ if tests_root.is_dir():
52
+ for py_file in sorted(tests_root.rglob("*.py")):
53
+ if self._is_ignored(py_file, tests_root):
54
+ continue
55
+ relative = py_file.relative_to(root_path)
56
+ files.append(FileInfo(path=relative, layer=Layer.TESTS))
57
+
58
+ return ProjectInfo(root_path=root_path, files=files, package_name=package_name)
59
+
60
+ def _find_package_name(self, root_path: Path) -> str:
61
+ src = root_path / "src"
62
+ if src.is_dir():
63
+ for child in sorted(src.iterdir()):
64
+ if child.is_dir() and (child / "__init__.py").exists():
65
+ return child.name
66
+
67
+ for child in sorted(root_path.iterdir()):
68
+ if not child.is_dir():
69
+ continue
70
+ if child.name in ("tests", "__pycache__", ".mypy_cache", ".venv", "venv", "node_modules"):
71
+ continue
72
+ if child.name.startswith("."):
73
+ continue
74
+ if (child / "__init__.py").exists():
75
+ return child.name
76
+
77
+ return ""
78
+
79
+ def _find_src_root(self, root_path: Path, package_name: str) -> Path:
80
+ if package_name:
81
+ candidate = root_path / "src" / package_name
82
+ if candidate.is_dir():
83
+ return candidate
84
+ candidate = root_path / package_name
85
+ if candidate.is_dir():
86
+ return candidate
87
+ src = root_path / "src"
88
+ if src.is_dir():
89
+ return src
90
+ return root_path
91
+
92
+ def _classify_file(self, file_path: Path, src_root: Path, package_name: str) -> Layer:
93
+ relative = file_path.relative_to(src_root)
94
+
95
+ filename = file_path.name
96
+ if filename in self._LAYER_FILENAMES:
97
+ return self._LAYER_FILENAMES[filename]
98
+
99
+ parents = [p.name for p in file_path.parents if str(p) != str(src_root)]
100
+ parts = relative.parts
101
+
102
+ for part in parts[:-1]:
103
+ if part in self._LAYER_MAP:
104
+ return self._LAYER_MAP[part]
105
+
106
+ for part in reversed(parts[:-1]):
107
+ if part in self._LAYER_MAP:
108
+ return self._LAYER_MAP[part]
109
+
110
+ return Layer.UNKNOWN
111
+
112
+ def _is_ignored(self, path: Path, src_root: Path) -> bool:
113
+ parts = path.parts
114
+ for part in parts:
115
+ if part.startswith(".") or part in ("__pycache__", "node_modules", ".venv", "venv"):
116
+ return True
117
+ if part == "migrations":
118
+ return True
119
+ return False
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass(slots=True)
6
+ class FileReader:
7
+ def read(self, path: Path) -> str:
8
+ return path.read_text(encoding="utf-8")