dotscope 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 (114) hide show
  1. dotscope/.scope +63 -0
  2. dotscope/__init__.py +3 -0
  3. dotscope/absorber.py +390 -0
  4. dotscope/assertions.py +128 -0
  5. dotscope/ast_analyzer.py +2 -0
  6. dotscope/backtest.py +2 -0
  7. dotscope/bench.py +141 -0
  8. dotscope/budget.py +3 -0
  9. dotscope/cache.py +2 -0
  10. dotscope/check/__init__.py +1 -0
  11. dotscope/check/acknowledge.py +2 -0
  12. dotscope/check/checker.py +3 -0
  13. dotscope/check/checks/__init__.py +1 -0
  14. dotscope/check/checks/antipattern.py +2 -0
  15. dotscope/check/checks/boundary.py +2 -0
  16. dotscope/check/checks/contracts.py +3 -0
  17. dotscope/check/checks/direction.py +2 -0
  18. dotscope/check/checks/intent.py +2 -0
  19. dotscope/check/checks/stability.py +2 -0
  20. dotscope/check/constraints.py +2 -0
  21. dotscope/check/models.py +15 -0
  22. dotscope/cli.py +1447 -0
  23. dotscope/composer.py +147 -0
  24. dotscope/constants.py +45 -0
  25. dotscope/context.py +60 -0
  26. dotscope/counterfactual.py +180 -0
  27. dotscope/debug.py +220 -0
  28. dotscope/discovery.py +104 -0
  29. dotscope/formatter.py +157 -0
  30. dotscope/graph.py +3 -0
  31. dotscope/health.py +212 -0
  32. dotscope/help.py +204 -0
  33. dotscope/history.py +6 -0
  34. dotscope/hooks.py +2 -0
  35. dotscope/ingest.py +858 -0
  36. dotscope/intent.py +618 -0
  37. dotscope/lessons.py +223 -0
  38. dotscope/matcher.py +104 -0
  39. dotscope/mcp_server.py +1081 -0
  40. dotscope/models/.scope +45 -0
  41. dotscope/models/__init__.py +7 -0
  42. dotscope/models/core.py +288 -0
  43. dotscope/models/history.py +73 -0
  44. dotscope/models/intent.py +213 -0
  45. dotscope/models/passes.py +58 -0
  46. dotscope/models/state.py +250 -0
  47. dotscope/models.py +9 -0
  48. dotscope/near_miss.py +3 -0
  49. dotscope/onboarding.py +2 -0
  50. dotscope/parser.py +387 -0
  51. dotscope/passes/.scope +105 -0
  52. dotscope/passes/__init__.py +1 -0
  53. dotscope/passes/ast_analyzer.py +508 -0
  54. dotscope/passes/backtest.py +198 -0
  55. dotscope/passes/budget_allocator.py +164 -0
  56. dotscope/passes/convention_compliance.py +40 -0
  57. dotscope/passes/convention_discovery.py +247 -0
  58. dotscope/passes/convention_parser.py +223 -0
  59. dotscope/passes/graph_builder.py +299 -0
  60. dotscope/passes/history_miner.py +336 -0
  61. dotscope/passes/incremental.py +149 -0
  62. dotscope/passes/lang/__init__.py +38 -0
  63. dotscope/passes/lang/_base.py +20 -0
  64. dotscope/passes/lang/_treesitter.py +93 -0
  65. dotscope/passes/lang/go.py +333 -0
  66. dotscope/passes/lang/javascript.py +348 -0
  67. dotscope/passes/lazy.py +152 -0
  68. dotscope/passes/semantic_diff.py +160 -0
  69. dotscope/passes/sentinel/__init__.py +1 -0
  70. dotscope/passes/sentinel/acknowledge.py +222 -0
  71. dotscope/passes/sentinel/checker.py +383 -0
  72. dotscope/passes/sentinel/checks/__init__.py +1 -0
  73. dotscope/passes/sentinel/checks/antipattern.py +84 -0
  74. dotscope/passes/sentinel/checks/boundary.py +46 -0
  75. dotscope/passes/sentinel/checks/contracts.py +148 -0
  76. dotscope/passes/sentinel/checks/convention.py +54 -0
  77. dotscope/passes/sentinel/checks/direction.py +71 -0
  78. dotscope/passes/sentinel/checks/intent.py +207 -0
  79. dotscope/passes/sentinel/checks/stability.py +66 -0
  80. dotscope/passes/sentinel/checks/voice.py +108 -0
  81. dotscope/passes/sentinel/constraints.py +472 -0
  82. dotscope/passes/sentinel/line_filter.py +88 -0
  83. dotscope/passes/sentinel/models.py +15 -0
  84. dotscope/passes/virtual.py +239 -0
  85. dotscope/passes/voice.py +162 -0
  86. dotscope/passes/voice_defaults.py +28 -0
  87. dotscope/passes/voice_discovery.py +245 -0
  88. dotscope/paths.py +32 -0
  89. dotscope/progress.py +44 -0
  90. dotscope/regression.py +147 -0
  91. dotscope/resolver.py +203 -0
  92. dotscope/scanner.py +246 -0
  93. dotscope/sessions.py +2 -0
  94. dotscope/storage/.scope +64 -0
  95. dotscope/storage/__init__.py +1 -0
  96. dotscope/storage/cache.py +114 -0
  97. dotscope/storage/claude_hooks.py +119 -0
  98. dotscope/storage/git_hooks.py +277 -0
  99. dotscope/storage/incremental_state.py +61 -0
  100. dotscope/storage/mcp_config.py +98 -0
  101. dotscope/storage/near_miss.py +183 -0
  102. dotscope/storage/onboarding.py +150 -0
  103. dotscope/storage/session_manager.py +195 -0
  104. dotscope/storage/timing.py +84 -0
  105. dotscope/timing.py +2 -0
  106. dotscope/tokens.py +53 -0
  107. dotscope/utility.py +123 -0
  108. dotscope/virtual.py +3 -0
  109. dotscope/visibility.py +664 -0
  110. dotscope-0.1.0.dist-info/METADATA +50 -0
  111. dotscope-0.1.0.dist-info/RECORD +114 -0
  112. dotscope-0.1.0.dist-info/WHEEL +4 -0
  113. dotscope-0.1.0.dist-info/entry_points.txt +3 -0
  114. dotscope-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,223 @@
1
+ """Convention parser pass: map files to conventions and check rules."""
2
+
3
+ import os
4
+ import re
5
+ from typing import Dict, List, Optional
6
+
7
+ from ..models import ConventionNode, ConventionRule, FileAnalysis
8
+
9
+
10
+ def parse_conventions(
11
+ ast_data: Dict[str, FileAnalysis],
12
+ conventions: List[ConventionRule],
13
+ ) -> List[ConventionNode]:
14
+ """Apply convention rules against AST data.
15
+
16
+ For each file, check if it matches any convention's criteria.
17
+ If matched, check rules and produce a ConventionNode.
18
+ """
19
+ nodes = []
20
+ for file_path, analysis in ast_data.items():
21
+ for rule in conventions:
22
+ if matches_convention(analysis, file_path, rule.match_criteria):
23
+ violations = check_convention_rules(analysis, file_path, rule.rules)
24
+ matched_by = _identify_matching_criteria(
25
+ analysis, file_path, rule.match_criteria
26
+ )
27
+ nodes.append(ConventionNode(
28
+ name=rule.name,
29
+ file_path=file_path,
30
+ target_name=(
31
+ analysis.classes[0].name if analysis.classes
32
+ else os.path.basename(file_path)
33
+ ),
34
+ violations=violations,
35
+ matched_by=matched_by,
36
+ ))
37
+ return nodes
38
+
39
+
40
+ def matches_convention(
41
+ analysis: FileAnalysis,
42
+ file_path: str,
43
+ match_criteria: dict,
44
+ ) -> bool:
45
+ """Flexible matching with any_of / all_of logic.
46
+
47
+ A file matches when:
48
+ - At least one criterion in any_of matches (OR)
49
+ - All criteria in all_of match (AND)
50
+ - If only one block is present, it determines the match alone
51
+ """
52
+ any_of = match_criteria.get("any_of", [])
53
+ all_of = match_criteria.get("all_of", [])
54
+
55
+ # Legacy flat format (no any_of/all_of) treated as all_of
56
+ if not any_of and not all_of:
57
+ all_of = [match_criteria] if match_criteria else []
58
+
59
+ any_passed = not any_of # Vacuously true if empty
60
+ for criterion in any_of:
61
+ if _matches_single(analysis, file_path, criterion):
62
+ any_passed = True
63
+ break
64
+
65
+ all_passed = True
66
+ for criterion in all_of:
67
+ if not _matches_single(analysis, file_path, criterion):
68
+ all_passed = False
69
+ break
70
+
71
+ return any_passed and all_passed
72
+
73
+
74
+ def _matches_single(
75
+ analysis: FileAnalysis,
76
+ file_path: str,
77
+ criterion: dict,
78
+ ) -> bool:
79
+ """Match a single criterion against a file."""
80
+ for key, value in criterion.items():
81
+ if key == "has_decorator":
82
+ decorators = analysis.decorators_used or []
83
+ if not any(re.search(value, d) for d in decorators):
84
+ return False
85
+ elif key == "file_path":
86
+ if not re.match(value, file_path):
87
+ return False
88
+ elif key == "class_ends_with":
89
+ if not any(c.name.endswith(value) for c in (analysis.classes or [])):
90
+ return False
91
+ elif key == "imports":
92
+ import_modules = _import_modules(analysis)
93
+ if not all(imp in import_modules for imp in value):
94
+ return False
95
+ elif key == "not_imports":
96
+ import_modules = _import_modules(analysis)
97
+ if any(imp in import_modules for imp in value):
98
+ return False
99
+ elif key == "base_class":
100
+ if not any(
101
+ value in (c.bases or [])
102
+ for c in (analysis.classes or [])
103
+ ):
104
+ return False
105
+ else:
106
+ return False # Unknown criterion, fail safe
107
+ return True
108
+
109
+
110
+ def check_convention_rules(
111
+ analysis: FileAnalysis,
112
+ file_path: str,
113
+ rules: dict,
114
+ ) -> List[str]:
115
+ """Check a file against its convention's rules."""
116
+ violations = []
117
+
118
+ if "prohibited_imports" in rules:
119
+ import_modules = _import_modules(analysis)
120
+ for imp in rules["prohibited_imports"]:
121
+ if imp in import_modules:
122
+ violations.append(f"Prohibited import: {imp}")
123
+
124
+ if "required_methods" in rules:
125
+ if analysis.classes:
126
+ methods = set(analysis.classes[0].methods)
127
+ for required in rules["required_methods"]:
128
+ if required not in methods:
129
+ violations.append(f"Missing required method: {required}")
130
+
131
+ if "must_have_matching" in rules:
132
+ pattern = rules["must_have_matching"]
133
+ filename = os.path.splitext(os.path.basename(file_path))[0]
134
+ stem = _extract_stem(filename)
135
+
136
+ expected_pattern = (
137
+ pattern
138
+ .replace("{filename}", re.escape(filename))
139
+ .replace("{captured_stem}", re.escape(stem))
140
+ )
141
+
142
+ # Search for a matching file in the repo
143
+ repo_root = _guess_repo_root(file_path)
144
+ if repo_root:
145
+ found = False
146
+ for candidate in _walk_files(repo_root):
147
+ if re.match(expected_pattern, candidate):
148
+ found = True
149
+ break
150
+ if not found:
151
+ violations.append(
152
+ f"Missing matching file: {pattern} (resolved: {expected_pattern})"
153
+ )
154
+
155
+ return violations
156
+
157
+
158
+ def _identify_matching_criteria(
159
+ analysis: FileAnalysis,
160
+ file_path: str,
161
+ match_criteria: dict,
162
+ ) -> List[str]:
163
+ """Identify which criteria matched for diagnostics."""
164
+ matched = []
165
+ for block_name in ("any_of", "all_of"):
166
+ for criterion in match_criteria.get(block_name, []):
167
+ for key in criterion:
168
+ if _matches_single(analysis, file_path, {key: criterion[key]}):
169
+ matched.append(key)
170
+ return matched
171
+
172
+
173
+ def _import_modules(analysis: FileAnalysis) -> set:
174
+ """Extract all import module names from a FileAnalysis."""
175
+ modules = set()
176
+ for imp in (analysis.imports or []):
177
+ if imp.module:
178
+ modules.add(imp.module)
179
+ # Also add individual imported names for granular matching
180
+ for name in (imp.names or []):
181
+ if imp.module:
182
+ modules.add(f"{imp.module}.{name}")
183
+ if imp.raw:
184
+ modules.add(imp.raw)
185
+ return modules
186
+
187
+
188
+ def _extract_stem(filename: str) -> str:
189
+ """Extract stem by stripping common suffixes.
190
+
191
+ "user_controller" -> "user"
192
+ "billing_repo" -> "billing"
193
+ "auth_service" -> "auth"
194
+ """
195
+ for suffix in ("_controller", "_service", "_repo", "_repository",
196
+ "_handler", "_manager", "_factory", "_helper",
197
+ "_view", "_model", "_test"):
198
+ if filename.endswith(suffix):
199
+ return filename[:-len(suffix)]
200
+ return filename
201
+
202
+
203
+ def _guess_repo_root(file_path: str) -> Optional[str]:
204
+ """Walk up from file_path to find repo root (contains .git or .scopes)."""
205
+ current = os.path.dirname(os.path.abspath(file_path))
206
+ for _ in range(10):
207
+ if os.path.exists(os.path.join(current, ".git")) or \
208
+ os.path.exists(os.path.join(current, ".scopes")):
209
+ return current
210
+ parent = os.path.dirname(current)
211
+ if parent == current:
212
+ break
213
+ current = parent
214
+ return None
215
+
216
+
217
+ def _walk_files(root: str):
218
+ """Yield relative file paths under root."""
219
+ skip = {"__pycache__", ".git", "node_modules", ".tox", ".mypy_cache", "venv", ".venv"}
220
+ for dirpath, dirnames, filenames in os.walk(root):
221
+ dirnames[:] = [d for d in dirnames if d not in skip]
222
+ for fn in filenames:
223
+ yield os.path.relpath(os.path.join(dirpath, fn), root)
@@ -0,0 +1,299 @@
1
+ """Dependency graph analysis: AST-powered import parsing, module boundary detection.
2
+
3
+ Builds a file-level dependency graph with transitive closure support.
4
+ """
5
+
6
+ import os
7
+ from collections import defaultdict, deque
8
+ from pathlib import Path
9
+ from typing import Dict, List, Set, Tuple
10
+
11
+ from ..ast_analyzer import (
12
+ analyze_file,
13
+ resolve_js_import,
14
+ resolve_python_import,
15
+ )
16
+ from ..constants import LANG_MAP, SKIP_DIRS
17
+ from ..models.core import (
18
+ DependencyGraph,
19
+ FileNode,
20
+ ModuleBoundary,
21
+ ModuleAPI,
22
+ )
23
+
24
+
25
+ def build_graph(root: str) -> DependencyGraph:
26
+ """Build a dependency graph using AST analysis.
27
+
28
+ 1. Walk all source files
29
+ 2. AST-analyze each file for imports + API surface
30
+ 3. Resolve imports to file paths
31
+ 4. Detect module boundaries using directory cohesion
32
+ """
33
+ root = os.path.abspath(root)
34
+ graph = DependencyGraph(root=root)
35
+
36
+ source_files = _collect_source_files(root)
37
+
38
+ # AST analyze each file
39
+ for rel_path, language in source_files:
40
+ abs_path = os.path.join(root, rel_path)
41
+ api = analyze_file(abs_path, language)
42
+
43
+ resolved_imports = []
44
+ if api:
45
+ graph.apis[rel_path] = api
46
+ for imp in api.imports:
47
+ resolved = _resolve_import(imp, rel_path, root, language)
48
+ if resolved:
49
+ resolved_imports.append(resolved)
50
+ imp.resolved_path = resolved
51
+
52
+ node = FileNode(
53
+ path=rel_path,
54
+ language=language,
55
+ imports=resolved_imports,
56
+ api=api,
57
+ )
58
+ graph.files[rel_path] = node
59
+
60
+ # Build edge list and back-references
61
+ for path, node in graph.files.items():
62
+ for imp in node.imports:
63
+ graph.edges.append((path, imp))
64
+ if imp in graph.files:
65
+ graph.files[imp].imported_by.append(path)
66
+
67
+ graph.modules = _detect_modules(graph)
68
+ return graph
69
+
70
+
71
+ def build_partial_graph(root: str, seed_files: List[str]) -> DependencyGraph:
72
+ """Build a graph containing only seed_files and their direct imports.
73
+
74
+ Used by lazy ingest to scope analysis to a single module.
75
+ Does NOT detect module boundaries (requires the full graph).
76
+
77
+ Args:
78
+ root: Repository root (absolute path)
79
+ seed_files: List of (relative_path, language) tuples
80
+ """
81
+ root = os.path.abspath(root)
82
+ graph = DependencyGraph(root=root)
83
+
84
+ # AST-analyze seed files
85
+ for rel_path, language in seed_files:
86
+ abs_path = os.path.join(root, rel_path)
87
+ api = analyze_file(abs_path, language)
88
+
89
+ resolved_imports = []
90
+ if api:
91
+ graph.apis[rel_path] = api
92
+ for imp in api.imports:
93
+ resolved = _resolve_import(imp, rel_path, root, language)
94
+ if resolved:
95
+ resolved_imports.append(resolved)
96
+ imp.resolved_path = resolved
97
+
98
+ node = FileNode(
99
+ path=rel_path,
100
+ language=language,
101
+ imports=resolved_imports,
102
+ api=api,
103
+ )
104
+ graph.files[rel_path] = node
105
+
106
+ # Follow direct imports one level deep
107
+ imports_to_add = []
108
+ for path, node in graph.files.items():
109
+ for imp in node.imports:
110
+ if imp not in graph.files:
111
+ imp_abs = os.path.join(root, imp)
112
+ if os.path.exists(imp_abs):
113
+ ext = os.path.splitext(imp)[1]
114
+ lang = LANG_MAP.get(ext)
115
+ if lang:
116
+ imports_to_add.append((imp, lang))
117
+
118
+ for rel_path, language in imports_to_add:
119
+ abs_path = os.path.join(root, rel_path)
120
+ api = analyze_file(abs_path, language)
121
+ if api:
122
+ graph.apis[rel_path] = api
123
+ node = FileNode(
124
+ path=rel_path,
125
+ language=language,
126
+ imports=[],
127
+ api=api,
128
+ )
129
+ graph.files[rel_path] = node
130
+
131
+ # Build edges
132
+ for path, node in graph.files.items():
133
+ for imp in node.imports:
134
+ graph.edges.append((path, imp))
135
+ if imp in graph.files:
136
+ graph.files[imp].imported_by.append(path)
137
+
138
+ return graph
139
+
140
+
141
+ def transitive_deps(graph: DependencyGraph, file: str) -> Set[str]:
142
+ """BFS for all transitive dependencies of a file (cycle-safe)."""
143
+ visited = set()
144
+ queue = deque()
145
+
146
+ node = graph.files.get(file)
147
+ if not node:
148
+ return visited
149
+
150
+ for imp in node.imports:
151
+ queue.append(imp)
152
+
153
+ while queue:
154
+ current = queue.popleft()
155
+ if current in visited:
156
+ continue
157
+ visited.add(current)
158
+ dep_node = graph.files.get(current)
159
+ if dep_node:
160
+ for imp in dep_node.imports:
161
+ if imp not in visited:
162
+ queue.append(imp)
163
+
164
+ return visited
165
+
166
+
167
+ def transitive_dependents(graph: DependencyGraph, file: str) -> Set[str]:
168
+ """BFS for all transitive dependents of a file (who ultimately depends on this)."""
169
+ visited = set()
170
+ queue = deque()
171
+
172
+ node = graph.files.get(file)
173
+ if not node:
174
+ return visited
175
+
176
+ for imp_by in node.imported_by:
177
+ queue.append(imp_by)
178
+
179
+ while queue:
180
+ current = queue.popleft()
181
+ if current in visited:
182
+ continue
183
+ visited.add(current)
184
+ dep_node = graph.files.get(current)
185
+ if dep_node:
186
+ for imp_by in dep_node.imported_by:
187
+ if imp_by not in visited:
188
+ queue.append(imp_by)
189
+
190
+ return visited
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Internals
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def _collect_source_files(root: str) -> List[Tuple[str, str]]:
198
+ """Walk the tree and collect (relative_path, language)."""
199
+ lang_map = {k: v.lower() for k, v in LANG_MAP.items()}
200
+ results = []
201
+
202
+ for dirpath, dirnames, filenames in os.walk(root):
203
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
204
+ for fn in filenames:
205
+ ext = os.path.splitext(fn)[1].lower()
206
+ if ext in lang_map:
207
+ rel = os.path.relpath(os.path.join(dirpath, fn), root)
208
+ results.append((rel, lang_map[ext]))
209
+
210
+ return sorted(results)
211
+
212
+
213
+ def _resolve_import(imp, source_file: str, root: str, language: str):
214
+ """Resolve an import to a relative file path."""
215
+ if language == "python":
216
+ return resolve_python_import(imp, os.path.join(root, source_file), root)
217
+ elif language in ("javascript", "typescript"):
218
+ return resolve_js_import(imp, os.path.join(root, source_file), root)
219
+ elif language == "go":
220
+ from .lang.go import resolve_go_import
221
+ return resolve_go_import(imp, os.path.join(root, source_file), root)
222
+ return None
223
+
224
+
225
+ def _detect_modules(graph: DependencyGraph) -> List[ModuleBoundary]:
226
+ """Detect module boundaries using directory structure + import cohesion.
227
+
228
+ Uses transitive coupling for more accurate cohesion scoring.
229
+ """
230
+ dir_files: Dict[str, List[str]] = defaultdict(list)
231
+ for rel_path in graph.files:
232
+ parts = Path(rel_path).parts
233
+ if len(parts) > 1:
234
+ dir_files[parts[0]].append(rel_path)
235
+
236
+ modules = []
237
+ for directory, files in sorted(dir_files.items()):
238
+ file_set = set(files)
239
+ internal = 0
240
+ external = 0
241
+ ext_deps: Set[str] = set()
242
+ dep_by: Set[str] = set()
243
+
244
+ for f in files:
245
+ # Use transitive deps for richer cohesion
246
+ all_deps = transitive_deps(graph, f)
247
+ for dep in all_deps:
248
+ if dep in file_set:
249
+ internal += 1
250
+ else:
251
+ external += 1
252
+ dep_parts = Path(dep).parts
253
+ if len(dep_parts) > 1:
254
+ ext_deps.add(dep_parts[0])
255
+
256
+ all_dependents = transitive_dependents(graph, f)
257
+ for dep_by_file in all_dependents:
258
+ if dep_by_file not in file_set:
259
+ dep_parts = Path(dep_by_file).parts
260
+ if len(dep_parts) > 1:
261
+ dep_by.add(dep_parts[0])
262
+
263
+ total = internal + external
264
+ cohesion = internal / total if total > 0 else 1.0
265
+
266
+ modules.append(ModuleBoundary(
267
+ directory=directory,
268
+ files=files,
269
+ internal_edges=internal,
270
+ external_edges=external,
271
+ external_deps=sorted(ext_deps - {directory}),
272
+ depended_on_by=sorted(dep_by - {directory}),
273
+ cohesion=round(cohesion, 3),
274
+ ))
275
+
276
+ modules.sort(key=lambda m: -len(m.files))
277
+ return modules
278
+
279
+
280
+ def format_graph_summary(graph: DependencyGraph) -> str:
281
+ """Human-readable summary of the dependency graph."""
282
+ lines = [
283
+ f"Dependency Graph: {len(graph.files)} files, {len(graph.edges)} edges",
284
+ f"Detected {len(graph.modules)} module(s):",
285
+ "",
286
+ ]
287
+
288
+ for mod in graph.modules:
289
+ cohesion_bar = "█" * int(mod.cohesion * 10) + "░" * (10 - int(mod.cohesion * 10))
290
+ lines.append(
291
+ f" {mod.directory}/ — {len(mod.files)} files, "
292
+ f"cohesion: {cohesion_bar} {mod.cohesion:.0%}"
293
+ )
294
+ if mod.external_deps:
295
+ lines.append(f" depends on: {', '.join(mod.external_deps)}")
296
+ if mod.depended_on_by:
297
+ lines.append(f" used by: {', '.join(mod.depended_on_by)}")
298
+
299
+ return "\n".join(lines)