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.
- dotscope/.scope +63 -0
- dotscope/__init__.py +3 -0
- dotscope/absorber.py +390 -0
- dotscope/assertions.py +128 -0
- dotscope/ast_analyzer.py +2 -0
- dotscope/backtest.py +2 -0
- dotscope/bench.py +141 -0
- dotscope/budget.py +3 -0
- dotscope/cache.py +2 -0
- dotscope/check/__init__.py +1 -0
- dotscope/check/acknowledge.py +2 -0
- dotscope/check/checker.py +3 -0
- dotscope/check/checks/__init__.py +1 -0
- dotscope/check/checks/antipattern.py +2 -0
- dotscope/check/checks/boundary.py +2 -0
- dotscope/check/checks/contracts.py +3 -0
- dotscope/check/checks/direction.py +2 -0
- dotscope/check/checks/intent.py +2 -0
- dotscope/check/checks/stability.py +2 -0
- dotscope/check/constraints.py +2 -0
- dotscope/check/models.py +15 -0
- dotscope/cli.py +1447 -0
- dotscope/composer.py +147 -0
- dotscope/constants.py +45 -0
- dotscope/context.py +60 -0
- dotscope/counterfactual.py +180 -0
- dotscope/debug.py +220 -0
- dotscope/discovery.py +104 -0
- dotscope/formatter.py +157 -0
- dotscope/graph.py +3 -0
- dotscope/health.py +212 -0
- dotscope/help.py +204 -0
- dotscope/history.py +6 -0
- dotscope/hooks.py +2 -0
- dotscope/ingest.py +858 -0
- dotscope/intent.py +618 -0
- dotscope/lessons.py +223 -0
- dotscope/matcher.py +104 -0
- dotscope/mcp_server.py +1081 -0
- dotscope/models/.scope +45 -0
- dotscope/models/__init__.py +7 -0
- dotscope/models/core.py +288 -0
- dotscope/models/history.py +73 -0
- dotscope/models/intent.py +213 -0
- dotscope/models/passes.py +58 -0
- dotscope/models/state.py +250 -0
- dotscope/models.py +9 -0
- dotscope/near_miss.py +3 -0
- dotscope/onboarding.py +2 -0
- dotscope/parser.py +387 -0
- dotscope/passes/.scope +105 -0
- dotscope/passes/__init__.py +1 -0
- dotscope/passes/ast_analyzer.py +508 -0
- dotscope/passes/backtest.py +198 -0
- dotscope/passes/budget_allocator.py +164 -0
- dotscope/passes/convention_compliance.py +40 -0
- dotscope/passes/convention_discovery.py +247 -0
- dotscope/passes/convention_parser.py +223 -0
- dotscope/passes/graph_builder.py +299 -0
- dotscope/passes/history_miner.py +336 -0
- dotscope/passes/incremental.py +149 -0
- dotscope/passes/lang/__init__.py +38 -0
- dotscope/passes/lang/_base.py +20 -0
- dotscope/passes/lang/_treesitter.py +93 -0
- dotscope/passes/lang/go.py +333 -0
- dotscope/passes/lang/javascript.py +348 -0
- dotscope/passes/lazy.py +152 -0
- dotscope/passes/semantic_diff.py +160 -0
- dotscope/passes/sentinel/__init__.py +1 -0
- dotscope/passes/sentinel/acknowledge.py +222 -0
- dotscope/passes/sentinel/checker.py +383 -0
- dotscope/passes/sentinel/checks/__init__.py +1 -0
- dotscope/passes/sentinel/checks/antipattern.py +84 -0
- dotscope/passes/sentinel/checks/boundary.py +46 -0
- dotscope/passes/sentinel/checks/contracts.py +148 -0
- dotscope/passes/sentinel/checks/convention.py +54 -0
- dotscope/passes/sentinel/checks/direction.py +71 -0
- dotscope/passes/sentinel/checks/intent.py +207 -0
- dotscope/passes/sentinel/checks/stability.py +66 -0
- dotscope/passes/sentinel/checks/voice.py +108 -0
- dotscope/passes/sentinel/constraints.py +472 -0
- dotscope/passes/sentinel/line_filter.py +88 -0
- dotscope/passes/sentinel/models.py +15 -0
- dotscope/passes/virtual.py +239 -0
- dotscope/passes/voice.py +162 -0
- dotscope/passes/voice_defaults.py +28 -0
- dotscope/passes/voice_discovery.py +245 -0
- dotscope/paths.py +32 -0
- dotscope/progress.py +44 -0
- dotscope/regression.py +147 -0
- dotscope/resolver.py +203 -0
- dotscope/scanner.py +246 -0
- dotscope/sessions.py +2 -0
- dotscope/storage/.scope +64 -0
- dotscope/storage/__init__.py +1 -0
- dotscope/storage/cache.py +114 -0
- dotscope/storage/claude_hooks.py +119 -0
- dotscope/storage/git_hooks.py +277 -0
- dotscope/storage/incremental_state.py +61 -0
- dotscope/storage/mcp_config.py +98 -0
- dotscope/storage/near_miss.py +183 -0
- dotscope/storage/onboarding.py +150 -0
- dotscope/storage/session_manager.py +195 -0
- dotscope/storage/timing.py +84 -0
- dotscope/timing.py +2 -0
- dotscope/tokens.py +53 -0
- dotscope/utility.py +123 -0
- dotscope/virtual.py +3 -0
- dotscope/visibility.py +664 -0
- dotscope-0.1.0.dist-info/METADATA +50 -0
- dotscope-0.1.0.dist-info/RECORD +114 -0
- dotscope-0.1.0.dist-info/WHEEL +4 -0
- dotscope-0.1.0.dist-info/entry_points.txt +3 -0
- 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)
|