smelt-cli 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.
Files changed (81) hide show
  1. smelt/__init__.py +6 -0
  2. smelt/__main__.py +5 -0
  3. smelt/analysis/__init__.py +27 -0
  4. smelt/analysis/context.py +106 -0
  5. smelt/analysis/files.py +198 -0
  6. smelt/analysis/graphs.py +77 -0
  7. smelt/analysis/imports.py +303 -0
  8. smelt/analysis/parsing.py +110 -0
  9. smelt/analysis/roles.py +122 -0
  10. smelt/analysis/syntax.py +246 -0
  11. smelt/analysis/types.py +181 -0
  12. smelt/cli/__init__.py +3 -0
  13. smelt/cli/app.py +140 -0
  14. smelt/cli/commands/__init__.py +18 -0
  15. smelt/cli/commands/adopt.py +116 -0
  16. smelt/cli/commands/check.py +80 -0
  17. smelt/cli/commands/discover.py +45 -0
  18. smelt/cli/commands/info.py +107 -0
  19. smelt/cli/commands/verify.py +60 -0
  20. smelt/cli/support.py +64 -0
  21. smelt/config/__init__.py +24 -0
  22. smelt/config/discovery.py +49 -0
  23. smelt/config/errors.py +51 -0
  24. smelt/config/loader.py +182 -0
  25. smelt/config/models.py +452 -0
  26. smelt/config/patterns.py +47 -0
  27. smelt/config/schema.py +23 -0
  28. smelt/config/validation.py +193 -0
  29. smelt/diagnostics/__init__.py +0 -0
  30. smelt/diagnostics/debt.py +119 -0
  31. smelt/diagnostics/dedupe.py +44 -0
  32. smelt/diagnostics/render/__init__.py +0 -0
  33. smelt/diagnostics/render/machine.py +137 -0
  34. smelt/diagnostics/render/text.py +197 -0
  35. smelt/diagnostics/report.py +61 -0
  36. smelt/diagnostics/suppressions.py +80 -0
  37. smelt/diagnostics/violation.py +109 -0
  38. smelt/docs.py +97 -0
  39. smelt/engine/__init__.py +0 -0
  40. smelt/engine/architecture_map.py +125 -0
  41. smelt/engine/briefing.py +364 -0
  42. smelt/engine/changes.py +81 -0
  43. smelt/engine/check.py +393 -0
  44. smelt/engine/inference.py +457 -0
  45. smelt/engine/verify.py +77 -0
  46. smelt/model/__init__.py +9 -0
  47. smelt/model/architecture.py +237 -0
  48. smelt/py.typed +0 -0
  49. smelt/rules/__init__.py +0 -0
  50. smelt/rules/base.py +142 -0
  51. smelt/rules/code/__init__.py +0 -0
  52. smelt/rules/code/common.py +119 -0
  53. smelt/rules/code/construction.py +172 -0
  54. smelt/rules/code/container.py +104 -0
  55. smelt/rules/code/inheritance.py +99 -0
  56. smelt/rules/code/roles.py +100 -0
  57. smelt/rules/code/self_reference.py +104 -0
  58. smelt/rules/common.py +127 -0
  59. smelt/rules/dependencies/__init__.py +0 -0
  60. smelt/rules/dependencies/composition_root.py +123 -0
  61. smelt/rules/dependencies/cycles.py +207 -0
  62. smelt/rules/dependencies/features.py +132 -0
  63. smelt/rules/dependencies/layers.py +220 -0
  64. smelt/rules/dependencies/third_party.py +107 -0
  65. smelt/rules/meta.py +72 -0
  66. smelt/rules/registry.py +135 -0
  67. smelt/rules/structure/__init__.py +0 -0
  68. smelt/rules/structure/layout.py +213 -0
  69. smelt/rules/structure/naming.py +63 -0
  70. smelt/rules/structure/roles.py +63 -0
  71. smelt/rules/testing/__init__.py +0 -0
  72. smelt/rules/testing/api.py +115 -0
  73. smelt/rules/testing/bloat.py +84 -0
  74. smelt/rules/testing/common.py +238 -0
  75. smelt/rules/testing/location.py +253 -0
  76. smelt/rules/testing/mocks.py +294 -0
  77. smelt/rules/testing/patching.py +152 -0
  78. smelt_cli-0.1.0.dist-info/METADATA +199 -0
  79. smelt_cli-0.1.0.dist-info/RECORD +81 -0
  80. smelt_cli-0.1.0.dist-info/WHEEL +4 -0
  81. smelt_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,303 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import sys
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass
7
+ from importlib.machinery import PathFinder
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import grimp
12
+ from grimp.application.config import settings as grimp_settings
13
+ from grimp.application.ports.modulefinder import (
14
+ AbstractModuleFinder,
15
+ FoundPackage,
16
+ ModuleFile,
17
+ )
18
+ from grimp.application.ports.packagefinder import AbstractPackageFinder
19
+ from grimp.domain.valueobjects import Module
20
+ from grimp.exceptions import SourceSyntaxError
21
+
22
+ from smelt.analysis.parsing import (
23
+ AnalysisError,
24
+ AstCache,
25
+ python_note,
26
+ resolve_relative,
27
+ type_checking_lines,
28
+ )
29
+ from smelt.model import is_within
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterator
33
+
34
+ from grimp.application.ports.filesystem import AbstractFileSystem
35
+
36
+ from smelt.analysis.files import FileIndex
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class ImportDetail:
41
+ importer: str
42
+ # Grimp's resolved target; external packages are squashed to their top level.
43
+ imported: str
44
+ line: int
45
+ line_contents: str
46
+ # Fully qualified names the statement imports, e.g. ``sqlalchemy.orm.Session``.
47
+ names: tuple[str, ...]
48
+ type_checking: bool
49
+ external: bool
50
+ column: int
51
+ end_column: int
52
+
53
+ @property
54
+ def top_level(self) -> str:
55
+ return self.imported.split(".")[0]
56
+
57
+
58
+ def is_stdlib(module: str) -> bool:
59
+ return module.split(".", maxsplit=1)[0] in sys.stdlib_module_names
60
+
61
+
62
+ class _SourceRootPackageFinder(AbstractPackageFinder):
63
+ def __init__(self, search_paths: list[str]) -> None:
64
+ self._search_paths = search_paths
65
+
66
+ def determine_package_directories(
67
+ self, package_name: str, file_system: AbstractFileSystem
68
+ ) -> set[str]:
69
+ spec = PathFinder.find_spec(package_name, self._search_paths)
70
+ if spec is None or not spec.submodule_search_locations:
71
+ msg = f"Could not find package '{package_name}'."
72
+ raise ValueError(msg)
73
+ return set(spec.submodule_search_locations)
74
+
75
+
76
+ class _IndexedModuleFinder(AbstractModuleFinder):
77
+ """Give Grimp the same modules and namespace packages as Smelt's file index."""
78
+
79
+ def __init__(self, files: FileIndex) -> None:
80
+ self._files = files
81
+
82
+ def find_package(
83
+ self,
84
+ package_name: str,
85
+ package_directory: str,
86
+ file_system: AbstractFileSystem,
87
+ ) -> FoundPackage:
88
+ directory = Path(package_directory)
89
+ module_files = frozenset(
90
+ ModuleFile(
91
+ Module(source.module), file_system.get_mtime(str(source.absolute))
92
+ )
93
+ for source in self._files.sources.values()
94
+ if is_within(source.module, package_name)
95
+ and source.absolute.is_relative_to(directory)
96
+ )
97
+ namespace_packages = frozenset(
98
+ package.module
99
+ for package in self._files.packages.values()
100
+ if is_within(package.module, package_name)
101
+ and not package.has_init
102
+ and (self._files.root / package.path).is_relative_to(directory)
103
+ )
104
+ return FoundPackage(
105
+ name=package_name,
106
+ directory=package_directory,
107
+ module_files=module_files,
108
+ namespace_packages=namespace_packages,
109
+ )
110
+
111
+
112
+ class ImportIndex:
113
+ def __init__(
114
+ self, graph: grimp.ImportGraph, files: FileIndex, asts: AstCache
115
+ ) -> None:
116
+ self.graph = graph
117
+ self.files = files
118
+ self._asts = asts
119
+ roots = files.config.project.root_packages
120
+ self.first_party = frozenset(
121
+ m for m in graph.modules if any(is_within(m, r) for r in roots)
122
+ )
123
+ self._outgoing: dict[str, list[ImportDetail]] = {}
124
+ self._incoming: dict[str, list[ImportDetail]] | None = None
125
+
126
+ @classmethod
127
+ def build(cls, files: FileIndex, asts: AstCache) -> ImportIndex:
128
+ project = files.config.project
129
+ packages = [
130
+ p for p in project.root_packages if p not in files.missing_root_packages
131
+ ]
132
+ if not packages:
133
+ msg = "none of project.root_packages were found under project.source_roots"
134
+ raise AnalysisError(msg)
135
+ search_paths = [
136
+ str((files.root / root).resolve()) for root in project.source_roots
137
+ ]
138
+ previous_package_finder = grimp_settings.PACKAGE_FINDER
139
+ previous_module_finder = grimp_settings.MODULE_FINDER
140
+ grimp_settings.configure(
141
+ PACKAGE_FINDER=_SourceRootPackageFinder(search_paths),
142
+ MODULE_FINDER=_IndexedModuleFinder(files),
143
+ )
144
+ try:
145
+ graph = grimp.build_graph(
146
+ packages[0],
147
+ *packages[1:],
148
+ include_external_packages=True,
149
+ cache_dir=None,
150
+ )
151
+ except SourceSyntaxError as exc:
152
+ raise AnalysisError(str(exc) + python_note(files.root)) from exc
153
+ finally:
154
+ grimp_settings.configure(
155
+ PACKAGE_FINDER=previous_package_finder,
156
+ MODULE_FINDER=previous_module_finder,
157
+ )
158
+ for module in list(graph.modules):
159
+ if (
160
+ any(is_within(module, p) for p in packages)
161
+ and module not in files.sources
162
+ and module not in files.packages
163
+ ):
164
+ graph.remove_module(module)
165
+ missing = files.sources.keys() - graph.modules
166
+ if missing:
167
+ examples = ", ".join(sorted(missing)[:3])
168
+ raise AnalysisError(
169
+ f"import graph omitted {len(missing)} source module(s): {examples}"
170
+ )
171
+ return cls(graph, files, asts)
172
+
173
+ def is_external(self, module: str) -> bool:
174
+ return module not in self.first_party
175
+
176
+ def modules(self) -> list[str]:
177
+ return sorted(m for m in self.first_party if m in self.files.sources)
178
+
179
+ def imports_of(self, module: str) -> list[ImportDetail]:
180
+ cached = self._outgoing.get(module)
181
+ if cached is None:
182
+ cached = self._details_for(module)
183
+ self._outgoing[module] = cached
184
+ return cached
185
+
186
+ def importers_of(self, module: str) -> list[ImportDetail]:
187
+ if self._incoming is None:
188
+ incoming: dict[str, list[ImportDetail]] = defaultdict(list)
189
+ for detail in self.all_imports():
190
+ incoming[detail.imported].append(detail)
191
+ self._incoming = dict(incoming)
192
+ return self._incoming.get(module, [])
193
+
194
+ def all_imports(self) -> Iterator[ImportDetail]:
195
+ for module in self.modules():
196
+ yield from self.imports_of(module)
197
+
198
+ def first_party_edges(
199
+ self, *, include_type_checking: bool = True
200
+ ) -> dict[str, set[str]]:
201
+ adjacency: dict[str, set[str]] = defaultdict(set)
202
+ for detail in self.all_imports():
203
+ if detail.external or (detail.type_checking and not include_type_checking):
204
+ continue
205
+ adjacency[detail.importer].add(detail.imported)
206
+ return adjacency
207
+
208
+ def _details_for(self, module: str) -> list[ImportDetail]:
209
+ source = self.files.sources.get(module)
210
+ if source is None or module not in self.first_party:
211
+ return []
212
+ tree = self._asts.parse(source.path)
213
+ statements = _statements_by_line(tree)
214
+ guarded = type_checking_lines(tree)
215
+ details: list[ImportDetail] = []
216
+ for imported in sorted(self.graph.find_modules_directly_imported_by(module)):
217
+ external = imported not in self.first_party
218
+ for raw in self.graph.get_import_details(
219
+ importer=module, imported=imported
220
+ ):
221
+ line = int(raw["line_number"])
222
+ node = statements.get(line)
223
+ names, column, end_column = _describe(
224
+ node, module, source.is_package, imported, raw["line_contents"]
225
+ )
226
+ details.append(
227
+ ImportDetail(
228
+ importer=module,
229
+ imported=imported,
230
+ line=line,
231
+ line_contents=str(raw["line_contents"]),
232
+ names=names,
233
+ type_checking=line in guarded,
234
+ external=external,
235
+ column=column,
236
+ end_column=end_column,
237
+ )
238
+ )
239
+ details.sort(key=lambda d: (d.line, d.column, d.imported))
240
+ return details
241
+
242
+
243
+ def _statements_by_line(tree: ast.Module) -> dict[int, ast.Import | ast.ImportFrom]:
244
+ return {
245
+ node.lineno: node
246
+ for node in ast.walk(tree)
247
+ if isinstance(node, ast.Import | ast.ImportFrom)
248
+ }
249
+
250
+
251
+ def _describe(
252
+ node: ast.Import | ast.ImportFrom | None,
253
+ module: str,
254
+ is_package: bool,
255
+ imported: str,
256
+ line_contents: str,
257
+ ) -> tuple[tuple[str, ...], int, int]:
258
+ """Imported names and the 1-based column span to underline on the first line."""
259
+ if node is None:
260
+ return (imported,), 1, len(line_contents) + 1
261
+ if isinstance(node, ast.Import):
262
+ return _describe_import(node, imported, line_contents)
263
+ return _describe_import_from(node, module, is_package, imported)
264
+
265
+
266
+ def _describe_import(
267
+ node: ast.Import, imported: str, line_contents: str
268
+ ) -> tuple[tuple[str, ...], int, int]:
269
+ first_line_end = (
270
+ node.end_col_offset if node.end_lineno == node.lineno else len(line_contents)
271
+ )
272
+ names: tuple[str, ...] = (imported,)
273
+ span = (node.col_offset + 1, (first_line_end or 0) + 1)
274
+ for alias in node.names:
275
+ if is_within(alias.name, imported) or is_within(imported, alias.name):
276
+ names = (alias.name,)
277
+ if alias.lineno == node.lineno:
278
+ span = (alias.col_offset + 1, (alias.end_col_offset or 0) + 1)
279
+ break
280
+ return names, *span
281
+
282
+
283
+ def _describe_import_from(
284
+ node: ast.ImportFrom, module: str, is_package: bool, imported: str
285
+ ) -> tuple[tuple[str, ...], int, int]:
286
+ base = resolve_relative(
287
+ module, is_package=is_package, level=node.level, target=node.module
288
+ )
289
+ names = tuple(
290
+ f"{base}.{alias.name}" if base else alias.name for alias in node.names
291
+ )
292
+ for alias, name in zip(node.names, names, strict=True):
293
+ if name == imported and alias.lineno == node.lineno:
294
+ return (name,), alias.col_offset + 1, (alias.end_col_offset or 0) + 1
295
+ matching = tuple(
296
+ n for n in names if is_within(n, imported) or is_within(imported, n)
297
+ )
298
+ if len(node.names) == 1 and node.names[0].lineno == node.lineno:
299
+ alias = node.names[0]
300
+ return matching or names, alias.col_offset + 1, (alias.end_col_offset or 0) + 1
301
+ start = node.col_offset + len("from ")
302
+ text = "." * node.level + (node.module or "")
303
+ return matching or names, start + 1, start + len(text) + 1
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import re
5
+ import sys
6
+ import tomllib
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from pathlib import Path
12
+
13
+ from smelt.analysis.files import FileIndex
14
+
15
+
16
+ class AnalysisError(Exception):
17
+ """The project cannot be analyzed (missing packages, syntax errors, ...)."""
18
+
19
+
20
+ @dataclass
21
+ class AstCache:
22
+ files: FileIndex
23
+ _trees: dict[str, ast.Module] = field(default_factory=dict)
24
+
25
+ def parse(self, path: str) -> ast.Module:
26
+ tree = self._trees.get(path)
27
+ if tree is None:
28
+ source = self.files.read_text(path)
29
+ try:
30
+ tree = ast.parse(source, filename=path)
31
+ except SyntaxError as exc:
32
+ msg = f"{path}:{exc.lineno}: syntax error: {exc.msg}"
33
+ raise AnalysisError(msg + python_note(self.files.root)) from exc
34
+ self._trees[path] = tree
35
+ return tree
36
+
37
+
38
+ _VERSION_BOUND = re.compile(r"(?:>=|~=|==)\s*3\.(\d+)")
39
+
40
+
41
+ def python_note(root: Path) -> str:
42
+ """Explain a syntax error that may just be newer syntax than the running Python.
43
+
44
+ ``ast`` only knows the grammar of the interpreter smelt runs on, so a 3.14
45
+ project checked by smelt on 3.12 fails on ``except A, B:`` or t-strings.
46
+ """
47
+ running = sys.version_info[:2]
48
+ note = f" (parsed by Python {running[0]}.{running[1]})"
49
+ target = _target_python(root)
50
+ if target is not None and target > running:
51
+ version = f"{target[0]}.{target[1]}"
52
+ note += (
53
+ f"; the project targets Python {version}, so run smelt on it, "
54
+ f"e.g. `uvx -p {version} --from smelt-cli smelt check`"
55
+ )
56
+ return note
57
+
58
+
59
+ def _target_python(root: Path) -> tuple[int, int] | None:
60
+ found: list[tuple[int, int]] = []
61
+ try:
62
+ data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
63
+ except (OSError, tomllib.TOMLDecodeError):
64
+ data = {}
65
+ requires = data.get("project", {}).get("requires-python", "")
66
+ if isinstance(requires, str) and (match := _VERSION_BOUND.search(requires)):
67
+ found.append((3, int(match.group(1))))
68
+ try:
69
+ pinned = (root / ".python-version").read_text(encoding="utf-8").split()
70
+ except OSError:
71
+ pinned = []
72
+ if pinned and (match := re.match(r"3\.(\d+)", pinned[0])):
73
+ found.append((3, int(match.group(1))))
74
+ return max(found, default=None)
75
+
76
+
77
+ def type_checking_lines(tree: ast.Module) -> frozenset[int]:
78
+ """Line numbers inside ``if TYPE_CHECKING:`` blocks."""
79
+ lines: set[int] = set()
80
+ for node in ast.walk(tree):
81
+ if isinstance(node, ast.If) and _is_type_checking_test(node.test):
82
+ for child in node.body:
83
+ end = child.end_lineno or child.lineno
84
+ lines.update(range(child.lineno, end + 1))
85
+ return frozenset(lines)
86
+
87
+
88
+ def _is_type_checking_test(test: ast.expr) -> bool:
89
+ if isinstance(test, ast.Name):
90
+ return test.id == "TYPE_CHECKING"
91
+ if isinstance(test, ast.Attribute):
92
+ return test.attr == "TYPE_CHECKING"
93
+ return False
94
+
95
+
96
+ def resolve_relative(
97
+ module: str, *, is_package: bool, level: int, target: str | None
98
+ ) -> str:
99
+ """Resolve ``from <level dots><target> import ...`` inside ``module``."""
100
+ if level == 0:
101
+ return target or ""
102
+ parts = module.split(".")
103
+ if not is_package:
104
+ parts = parts[:-1]
105
+ if level > 1:
106
+ parts = parts[: len(parts) - (level - 1)]
107
+ base = ".".join(parts)
108
+ if target:
109
+ return f"{base}.{target}" if base else target
110
+ return base
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from smelt.analysis.syntax import ClassInfo, SyntaxIndex
9
+ from smelt.analysis.types import TypeIndex
10
+ from smelt.config.models import SmeltConfig
11
+
12
+ # Subclassing a Protocol without listing Protocol again makes a concrete class.
13
+ _DIRECT_ONLY_BASES = frozenset({"typing.Protocol", "typing_extensions.Protocol"})
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class RoleMatch:
18
+ role: str
19
+ cls: ClassInfo
20
+
21
+
22
+ class RoleIndex:
23
+ def __init__(
24
+ self,
25
+ config: SmeltConfig,
26
+ syntax: SyntaxIndex,
27
+ types: TypeIndex | None = None,
28
+ ) -> None:
29
+ self.config = config
30
+ self.syntax = syntax
31
+ self.types = types
32
+ self.by_class: dict[str, frozenset[str]] = self._detect()
33
+
34
+ def _detect(self) -> dict[str, frozenset[str]]:
35
+ roles: dict[str, set[str]] = defaultdict(set)
36
+ ancestors = {
37
+ name: self.syntax.ancestors(info)
38
+ for name, info in self.syntax.classes.items()
39
+ }
40
+ self._detect_by_base(roles, ancestors)
41
+ self._detect_by_ancestry(roles, ancestors)
42
+ if self.types is not None:
43
+ self._detect_structurally(roles)
44
+ return {name: frozenset(found) for name, found in roles.items() if found}
45
+
46
+ def _detect_by_base(
47
+ self, roles: dict[str, set[str]], ancestors: dict[str, list[str]]
48
+ ) -> None:
49
+ for role_name, role in self.config.architecture.roles.items():
50
+ base = role.detect.base
51
+ if base is None:
52
+ continue
53
+ for name, info in self.syntax.classes.items():
54
+ direct = {self.syntax.canonical(b) for b in info.bases if b}
55
+ wanted = direct if base in _DIRECT_ONLY_BASES else ancestors[name]
56
+ if base in wanted:
57
+ roles[name].add(role_name)
58
+
59
+ def _detect_by_ancestry(
60
+ self, roles: dict[str, set[str]], ancestors: dict[str, list[str]]
61
+ ) -> None:
62
+ """Propagate ``implements`` roles down the inheritance chains, until stable."""
63
+ changed = True
64
+ while changed:
65
+ changed = False
66
+ for role_name, target in self._implement_roles():
67
+ for name in self.syntax.classes:
68
+ if {role_name, target} & roles[name]:
69
+ continue
70
+ if any(target in roles.get(a, ()) for a in ancestors[name]):
71
+ roles[name].add(role_name)
72
+ changed = True
73
+
74
+ def _implement_roles(self) -> list[tuple[str, str]]:
75
+ return [
76
+ (name, role.detect.implements)
77
+ for name, role in self.config.architecture.roles.items()
78
+ if role.detect.implements is not None
79
+ ]
80
+
81
+ def _detect_structurally(self, roles: dict[str, set[str]]) -> None:
82
+ """With type information, a class can implement a port without inheriting it."""
83
+ types = self.types
84
+ if types is None: # pragma: no cover - guarded by the caller
85
+ return
86
+ for role_name, target in self._implement_roles():
87
+ ports = sorted(name for name, found in roles.items() if target in found)
88
+ candidates = [
89
+ name
90
+ for name in self.syntax.classes
91
+ if not {role_name, target} & roles[name]
92
+ ]
93
+ pairs = [(name, port) for name in candidates for port in ports]
94
+ types.prepare(pairs)
95
+ for name, port in pairs:
96
+ if types.implements(name, port):
97
+ roles[name].add(role_name)
98
+
99
+ def roles_of(self, qualname: str) -> frozenset[str]:
100
+ return self.by_class.get(qualname, frozenset())
101
+
102
+ def classes_with(self, role: str) -> list[ClassInfo]:
103
+ classes = self.syntax.classes
104
+ return [
105
+ classes[name]
106
+ for name, roles in sorted(self.by_class.items())
107
+ if role in roles
108
+ ]
109
+
110
+ def matches(self) -> list[RoleMatch]:
111
+ classes = self.syntax.classes
112
+ return [
113
+ RoleMatch(role, classes[name])
114
+ for name, roles in sorted(self.by_class.items())
115
+ for role in sorted(roles)
116
+ ]
117
+
118
+ def module_roles(self) -> dict[str, frozenset[str]]:
119
+ result: dict[str, set[str]] = defaultdict(set)
120
+ for match in self.matches():
121
+ result[match.cls.module].add(match.role)
122
+ return {module: frozenset(roles) for module, roles in result.items()}