codeabc 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.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
"""Build a lightweight import graph and rank the most depended-on files.
|
|
2
|
+
|
|
3
|
+
Fan-in (how many other files import a given file) is a cheap, deterministic
|
|
4
|
+
proxy for where a project's load-bearing logic lives: utilities, models and core
|
|
5
|
+
services tend to be imported everywhere, while glue and leaf scripts are not. We
|
|
6
|
+
surface the top files next to the reading map so a newcomer knows where to start,
|
|
7
|
+
without spending an LLM call.
|
|
8
|
+
|
|
9
|
+
Resolution is best-effort and stays inside the scanned file set. Imports that
|
|
10
|
+
point at third-party packages or the standard library never match a local file,
|
|
11
|
+
so they simply drop out instead of inflating the ranking.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import posixpath
|
|
17
|
+
import re
|
|
18
|
+
from collections import defaultdict, deque
|
|
19
|
+
from pathlib import PurePosixPath
|
|
20
|
+
|
|
21
|
+
_PY_IMPORT = re.compile(r"^\s*import\s+(.+)$")
|
|
22
|
+
_PY_FROM = re.compile(r"^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+)$")
|
|
23
|
+
|
|
24
|
+
_JS_FROM = re.compile(r"""\bfrom\s*['"]([^'"]+)['"]""")
|
|
25
|
+
_JS_BARE = re.compile(r"""\bimport\s*['"]([^'"]+)['"]""")
|
|
26
|
+
_JS_REQUIRE = re.compile(r"""\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
27
|
+
_JS_DYNAMIC = re.compile(r"""\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)""")
|
|
28
|
+
|
|
29
|
+
_PY_LANGS = {"python"}
|
|
30
|
+
_JS_LANGS = {"javascript", "typescript", "jsx", "tsx", "vue", "svelte"}
|
|
31
|
+
_JS_EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte")
|
|
32
|
+
|
|
33
|
+
# keep the dependents list small enough to ship in the API response
|
|
34
|
+
_MAX_DEPENDENTS = 12
|
|
35
|
+
|
|
36
|
+
# conventional entry-point file names, checked without extension/case
|
|
37
|
+
_ENTRY_HINTS = {
|
|
38
|
+
"main",
|
|
39
|
+
"app",
|
|
40
|
+
"cli",
|
|
41
|
+
"__main__",
|
|
42
|
+
"index",
|
|
43
|
+
"server",
|
|
44
|
+
"run",
|
|
45
|
+
"manage",
|
|
46
|
+
"wsgi",
|
|
47
|
+
"asgi",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _posix(path: str) -> str:
|
|
52
|
+
return path.replace("\\", "/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _py_module_name(path: str) -> str | None:
|
|
56
|
+
"""Dotted module name for a python file, or None if it isn't one."""
|
|
57
|
+
if path == "__init__.py":
|
|
58
|
+
return ""
|
|
59
|
+
if path.endswith("/__init__.py"):
|
|
60
|
+
return path[: -len("/__init__.py")].replace("/", ".")
|
|
61
|
+
if path.endswith(".py"):
|
|
62
|
+
return path[: -len(".py")].replace("/", ".")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _py_package(path: str) -> str:
|
|
67
|
+
"""Package a python file lives in, used to resolve relative imports."""
|
|
68
|
+
mod = _py_module_name(path)
|
|
69
|
+
if mod is None:
|
|
70
|
+
return ""
|
|
71
|
+
if path.endswith("__init__.py"):
|
|
72
|
+
return mod
|
|
73
|
+
return mod.rsplit(".", 1)[0] if "." in mod else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _match_module(dotted: str, py_modules: dict[str, str]) -> str | None:
|
|
77
|
+
"""Resolve a dotted name to a scanned file, falling back to its prefixes.
|
|
78
|
+
|
|
79
|
+
``import a.b.c`` where ``c`` is an attribute still resolves to ``a/b.py``.
|
|
80
|
+
"""
|
|
81
|
+
parts = dotted.split(".")
|
|
82
|
+
while parts:
|
|
83
|
+
cand = ".".join(parts)
|
|
84
|
+
if cand in py_modules:
|
|
85
|
+
return py_modules[cand]
|
|
86
|
+
parts.pop()
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_relative(pkg: str, levels: int, module: str) -> str | None:
|
|
91
|
+
"""Turn a relative import (``from ..x import y``) into an absolute base."""
|
|
92
|
+
if levels == 0:
|
|
93
|
+
return module
|
|
94
|
+
base_parts = pkg.split(".") if pkg else []
|
|
95
|
+
drop = levels - 1
|
|
96
|
+
if drop:
|
|
97
|
+
if drop > len(base_parts):
|
|
98
|
+
return None
|
|
99
|
+
base_parts = base_parts[:-drop]
|
|
100
|
+
base = ".".join(base_parts)
|
|
101
|
+
if module:
|
|
102
|
+
return f"{base}.{module}" if base else module
|
|
103
|
+
return base
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _py_edges(content: str, current: str, py_modules: dict[str, str]) -> set[str]:
|
|
107
|
+
pkg = _py_package(current)
|
|
108
|
+
resolved: set[str] = set()
|
|
109
|
+
|
|
110
|
+
for line in content.splitlines():
|
|
111
|
+
m = _PY_IMPORT.match(line)
|
|
112
|
+
if m:
|
|
113
|
+
for part in m.group(1).split("#")[0].split(","):
|
|
114
|
+
name = part.strip().split(" as ")[0].strip()
|
|
115
|
+
hit = _match_module(name, py_modules) if name else None
|
|
116
|
+
if hit:
|
|
117
|
+
resolved.add(hit)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
m = _PY_FROM.match(line)
|
|
121
|
+
if not m:
|
|
122
|
+
continue
|
|
123
|
+
dots, module, raw_names = m.group(1), m.group(2), m.group(3)
|
|
124
|
+
base = _resolve_relative(pkg, len(dots), module)
|
|
125
|
+
if base is None:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
matched_name = False
|
|
129
|
+
names = raw_names.split("#")[0].replace("(", " ").replace(")", " ")
|
|
130
|
+
for raw in names.split(","):
|
|
131
|
+
name = raw.strip().split(" as ")[0].strip()
|
|
132
|
+
if not name or name == "*":
|
|
133
|
+
continue
|
|
134
|
+
cand = f"{base}.{name}" if base else name
|
|
135
|
+
hit = _match_module(cand, py_modules)
|
|
136
|
+
if hit:
|
|
137
|
+
resolved.add(hit)
|
|
138
|
+
matched_name = True
|
|
139
|
+
# the names were plain attributes, so the dependency is the module itself
|
|
140
|
+
if not matched_name and base:
|
|
141
|
+
hit = _match_module(base, py_modules)
|
|
142
|
+
if hit:
|
|
143
|
+
resolved.add(hit)
|
|
144
|
+
|
|
145
|
+
resolved.discard(current)
|
|
146
|
+
return resolved
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _js_specifiers(content: str) -> list[str]:
|
|
150
|
+
specs = _JS_FROM.findall(content)
|
|
151
|
+
specs += _JS_BARE.findall(content)
|
|
152
|
+
specs += _JS_REQUIRE.findall(content)
|
|
153
|
+
specs += _JS_DYNAMIC.findall(content)
|
|
154
|
+
return specs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _resolve_js_file(base: str, file_set: set[str]) -> str | None:
|
|
158
|
+
if base in file_set:
|
|
159
|
+
return base
|
|
160
|
+
for ext in _JS_EXTS:
|
|
161
|
+
if base + ext in file_set:
|
|
162
|
+
return base + ext
|
|
163
|
+
for ext in _JS_EXTS:
|
|
164
|
+
cand = f"{base}/index{ext}"
|
|
165
|
+
if cand in file_set:
|
|
166
|
+
return cand
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _js_edges(content: str, current: str, file_set: set[str]) -> set[str]:
|
|
171
|
+
cur_dir = str(PurePosixPath(current).parent)
|
|
172
|
+
resolved: set[str] = set()
|
|
173
|
+
|
|
174
|
+
for spec in _js_specifiers(content):
|
|
175
|
+
spec = spec.split("?")[0].split("#")[0]
|
|
176
|
+
if not spec.startswith("."):
|
|
177
|
+
continue # bare specifier -> third-party, not a local file
|
|
178
|
+
base = posixpath.normpath(posixpath.join(cur_dir, spec))
|
|
179
|
+
hit = _resolve_js_file(base, file_set)
|
|
180
|
+
if hit and hit != current:
|
|
181
|
+
resolved.add(hit)
|
|
182
|
+
|
|
183
|
+
return resolved
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _reason(fan_in: int) -> str:
|
|
187
|
+
if fan_in >= 5:
|
|
188
|
+
return f"项目里有 {fan_in} 个文件用到它,几乎绕不开,是最该先读的核心模块。"
|
|
189
|
+
return f"有 {fan_in} 个文件用到它,是比较核心的模块。"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def rank_hotspots(files: list[dict], *, limit: int = 8) -> list[dict]:
|
|
193
|
+
"""Rank scanned files by fan-in (how many other files import them).
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
197
|
+
limit: how many top files to return.
|
|
198
|
+
|
|
199
|
+
Returns a list of ``{"path", "language", "fan_in", "dependents", "reason"}``
|
|
200
|
+
sorted by fan-in descending, then path. Files nothing imports are omitted.
|
|
201
|
+
"""
|
|
202
|
+
by_path: dict[str, dict] = {}
|
|
203
|
+
file_set: set[str] = set()
|
|
204
|
+
py_modules: dict[str, str] = {}
|
|
205
|
+
|
|
206
|
+
for f in files:
|
|
207
|
+
path = _posix(f["path"])
|
|
208
|
+
by_path[path] = f
|
|
209
|
+
file_set.add(path)
|
|
210
|
+
mod = _py_module_name(path)
|
|
211
|
+
if mod is not None:
|
|
212
|
+
py_modules.setdefault(mod, path)
|
|
213
|
+
|
|
214
|
+
fan_in: dict[str, set[str]] = defaultdict(set)
|
|
215
|
+
for f in files:
|
|
216
|
+
path = _posix(f["path"])
|
|
217
|
+
lang = f.get("language", "unknown")
|
|
218
|
+
content = f.get("preview") or ""
|
|
219
|
+
if lang in _PY_LANGS:
|
|
220
|
+
deps = _py_edges(content, path, py_modules)
|
|
221
|
+
elif lang in _JS_LANGS:
|
|
222
|
+
deps = _js_edges(content, path, file_set)
|
|
223
|
+
else:
|
|
224
|
+
continue
|
|
225
|
+
for dep in deps:
|
|
226
|
+
fan_in[dep].add(path)
|
|
227
|
+
|
|
228
|
+
ranked = [
|
|
229
|
+
{
|
|
230
|
+
"path": path,
|
|
231
|
+
"language": by_path[path].get("language", "unknown"),
|
|
232
|
+
"fan_in": len(importers),
|
|
233
|
+
"dependents": sorted(importers)[:_MAX_DEPENDENTS],
|
|
234
|
+
"reason": _reason(len(importers)),
|
|
235
|
+
}
|
|
236
|
+
for path, importers in fan_in.items()
|
|
237
|
+
if importers
|
|
238
|
+
]
|
|
239
|
+
ranked.sort(key=lambda h: (-h["fan_in"], h["path"]))
|
|
240
|
+
return ranked[:limit]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _coupling_reason(fan_out: int) -> str:
|
|
244
|
+
if fan_out >= 5:
|
|
245
|
+
return (
|
|
246
|
+
f"它自己要用到项目里另外 {fan_out} 个文件才能干活,牵扯太多——"
|
|
247
|
+
"改它容易顾此失彼,是最该考虑拆小、理顺的文件。"
|
|
248
|
+
)
|
|
249
|
+
return f"它要用到项目里另外 {fan_out} 个文件,牵扯一般。"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def rank_coupling(files: list[dict], *, limit: int = 8) -> list[dict]:
|
|
253
|
+
"""Rank scanned files by fan-out (how many local modules each one imports).
|
|
254
|
+
|
|
255
|
+
The inverse lens to :func:`rank_hotspots`: hotspots are the most depended-on
|
|
256
|
+
(core) files, whereas high fan-out files depend on the most others, so they
|
|
257
|
+
carry the most coupling and are the prime refactoring/splitting candidates.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
261
|
+
limit: how many top files to return.
|
|
262
|
+
|
|
263
|
+
Returns a list of ``{"path", "language", "fan_out", "dependencies", "reason"}``
|
|
264
|
+
sorted by fan-out descending, then path. Files that import no local module
|
|
265
|
+
are omitted.
|
|
266
|
+
"""
|
|
267
|
+
file_set: set[str] = set()
|
|
268
|
+
py_modules: dict[str, str] = {}
|
|
269
|
+
|
|
270
|
+
for f in files:
|
|
271
|
+
path = _posix(f["path"])
|
|
272
|
+
file_set.add(path)
|
|
273
|
+
mod = _py_module_name(path)
|
|
274
|
+
if mod is not None:
|
|
275
|
+
py_modules.setdefault(mod, path)
|
|
276
|
+
|
|
277
|
+
ranked: list[dict] = []
|
|
278
|
+
for f in files:
|
|
279
|
+
path = _posix(f["path"])
|
|
280
|
+
lang = f.get("language", "unknown")
|
|
281
|
+
content = f.get("preview") or ""
|
|
282
|
+
if lang in _PY_LANGS:
|
|
283
|
+
deps = _py_edges(content, path, py_modules)
|
|
284
|
+
elif lang in _JS_LANGS:
|
|
285
|
+
deps = _js_edges(content, path, file_set)
|
|
286
|
+
else:
|
|
287
|
+
continue
|
|
288
|
+
deps.discard(path) # a self-import is not coupling to another module
|
|
289
|
+
if not deps:
|
|
290
|
+
continue
|
|
291
|
+
ranked.append(
|
|
292
|
+
{
|
|
293
|
+
"path": path,
|
|
294
|
+
"language": lang,
|
|
295
|
+
"fan_out": len(deps),
|
|
296
|
+
"dependencies": sorted(deps)[:_MAX_DEPENDENTS],
|
|
297
|
+
"reason": _coupling_reason(len(deps)),
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
ranked.sort(key=lambda h: (-h["fan_out"], h["path"]))
|
|
301
|
+
return ranked[:limit]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _order_reason(role: str) -> str:
|
|
305
|
+
if role == "entry":
|
|
306
|
+
return "Entry point -- where the program starts; read this first."
|
|
307
|
+
if role == "leaf":
|
|
308
|
+
return "A building block the code above relies on; no further local imports."
|
|
309
|
+
return "Pulled in by what you just read; read it next to follow the flow."
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def suggest_reading_order(files: list[dict], *, limit: int = 12) -> list[dict]:
|
|
313
|
+
"""Suggest an order to read a project's files for someone new to it.
|
|
314
|
+
|
|
315
|
+
Starts from entry points (files nothing else imports, preferring conventional
|
|
316
|
+
names like ``main``/``app``/``cli``) and walks the import graph breadth-first,
|
|
317
|
+
so a reader meets the "front door" first and then the modules it pulls in.
|
|
318
|
+
Like :func:`rank_hotspots` this is deterministic and spends no LLM call.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
322
|
+
limit: how many steps to return.
|
|
323
|
+
|
|
324
|
+
Returns a list of ``{"path", "language", "step", "role", "reason"}`` in the
|
|
325
|
+
order they should be read; ``step`` is 1-based and ``role`` is one of
|
|
326
|
+
``"entry"``, ``"core"`` or ``"leaf"``.
|
|
327
|
+
"""
|
|
328
|
+
by_path: dict[str, dict] = {}
|
|
329
|
+
file_set: set[str] = set()
|
|
330
|
+
py_modules: dict[str, str] = {}
|
|
331
|
+
|
|
332
|
+
for f in files:
|
|
333
|
+
path = _posix(f["path"])
|
|
334
|
+
by_path[path] = f
|
|
335
|
+
file_set.add(path)
|
|
336
|
+
mod = _py_module_name(path)
|
|
337
|
+
if mod is not None:
|
|
338
|
+
py_modules.setdefault(mod, path)
|
|
339
|
+
|
|
340
|
+
imports: dict[str, set[str]] = {}
|
|
341
|
+
fan_in: dict[str, set[str]] = defaultdict(set)
|
|
342
|
+
for f in files:
|
|
343
|
+
path = _posix(f["path"])
|
|
344
|
+
lang = f.get("language", "unknown")
|
|
345
|
+
content = f.get("preview") or ""
|
|
346
|
+
if lang in _PY_LANGS:
|
|
347
|
+
deps = _py_edges(content, path, py_modules)
|
|
348
|
+
elif lang in _JS_LANGS:
|
|
349
|
+
deps = _js_edges(content, path, file_set)
|
|
350
|
+
else:
|
|
351
|
+
deps = set()
|
|
352
|
+
imports[path] = deps
|
|
353
|
+
for dep in deps:
|
|
354
|
+
fan_in[dep].add(path)
|
|
355
|
+
|
|
356
|
+
def _entry_key(path: str) -> tuple[int, int, str]:
|
|
357
|
+
name = path.rsplit("/", 1)[-1]
|
|
358
|
+
stem = name[: -len(".py")] if name.endswith(".py") else name.rsplit(".", 1)[0]
|
|
359
|
+
is_hint = 0 if stem.lower() in _ENTRY_HINTS else 1
|
|
360
|
+
return (is_hint, path.count("/"), path)
|
|
361
|
+
|
|
362
|
+
# entry points: in-project files that nothing else imports
|
|
363
|
+
entries = sorted((p for p in by_path if not fan_in.get(p)), key=_entry_key)
|
|
364
|
+
|
|
365
|
+
order: list[str] = []
|
|
366
|
+
seen: set[str] = set()
|
|
367
|
+
queue: deque[str] = deque(entries)
|
|
368
|
+
while queue:
|
|
369
|
+
path = queue.popleft()
|
|
370
|
+
if path in seen or path not in by_path:
|
|
371
|
+
continue
|
|
372
|
+
seen.add(path)
|
|
373
|
+
order.append(path)
|
|
374
|
+
for dep in sorted(imports.get(path, ())):
|
|
375
|
+
if dep not in seen:
|
|
376
|
+
queue.append(dep)
|
|
377
|
+
|
|
378
|
+
# files unreachable from any entry (isolated or only in a cycle) go last
|
|
379
|
+
for path in sorted(by_path):
|
|
380
|
+
if path not in seen:
|
|
381
|
+
seen.add(path)
|
|
382
|
+
order.append(path)
|
|
383
|
+
|
|
384
|
+
result: list[dict] = []
|
|
385
|
+
for step, path in enumerate(order[:limit], start=1):
|
|
386
|
+
if path in entries:
|
|
387
|
+
role = "entry"
|
|
388
|
+
elif imports.get(path):
|
|
389
|
+
role = "core"
|
|
390
|
+
else:
|
|
391
|
+
role = "leaf"
|
|
392
|
+
result.append(
|
|
393
|
+
{
|
|
394
|
+
"path": path,
|
|
395
|
+
"language": by_path[path].get("language", "unknown"),
|
|
396
|
+
"step": step,
|
|
397
|
+
"role": role,
|
|
398
|
+
"reason": _order_reason(role),
|
|
399
|
+
}
|
|
400
|
+
)
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _build_import_graph(
|
|
405
|
+
files: list[dict],
|
|
406
|
+
) -> tuple[dict[str, dict], dict[str, set[str]], dict[str, set[str]]]:
|
|
407
|
+
"""Build the in-project import graph shared by the analysis helpers.
|
|
408
|
+
|
|
409
|
+
Returns ``(by_path, imports, fan_in)`` where ``imports[p]`` is the set of
|
|
410
|
+
scanned files ``p`` imports and ``fan_in[p]`` is the set that import ``p``.
|
|
411
|
+
"""
|
|
412
|
+
by_path: dict[str, dict] = {}
|
|
413
|
+
file_set: set[str] = set()
|
|
414
|
+
py_modules: dict[str, str] = {}
|
|
415
|
+
for f in files:
|
|
416
|
+
path = _posix(f["path"])
|
|
417
|
+
by_path[path] = f
|
|
418
|
+
file_set.add(path)
|
|
419
|
+
mod = _py_module_name(path)
|
|
420
|
+
if mod is not None:
|
|
421
|
+
py_modules.setdefault(mod, path)
|
|
422
|
+
|
|
423
|
+
imports: dict[str, set[str]] = {}
|
|
424
|
+
fan_in: dict[str, set[str]] = defaultdict(set)
|
|
425
|
+
for f in files:
|
|
426
|
+
path = _posix(f["path"])
|
|
427
|
+
lang = f.get("language", "unknown")
|
|
428
|
+
content = f.get("preview") or ""
|
|
429
|
+
if lang in _PY_LANGS:
|
|
430
|
+
deps = _py_edges(content, path, py_modules)
|
|
431
|
+
elif lang in _JS_LANGS:
|
|
432
|
+
deps = _js_edges(content, path, file_set)
|
|
433
|
+
else:
|
|
434
|
+
deps = set()
|
|
435
|
+
imports[path] = deps
|
|
436
|
+
for dep in deps:
|
|
437
|
+
fan_in[dep].add(path)
|
|
438
|
+
return by_path, imports, fan_in
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _file_change_impact(blast: int, direct: int) -> str:
|
|
442
|
+
if blast == 0:
|
|
443
|
+
return "没有别的文件用到它,改它不会牵连项目里其他文件,可以放心改。"
|
|
444
|
+
if blast == direct:
|
|
445
|
+
return f"有 {direct} 个文件直接用到它,改它前最好看一眼这几个文件会不会受影响。"
|
|
446
|
+
return (
|
|
447
|
+
f"有 {direct} 个文件直接用到它,顺着依赖链一共会牵连约 {blast} 个文件——"
|
|
448
|
+
"改它要格外小心,最好连带检查这些受影响的文件。"
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def file_dependencies(files: list[dict], target: str) -> dict:
|
|
453
|
+
"""What a single file connects to, plus how far a change to it would reach.
|
|
454
|
+
|
|
455
|
+
Returns ``{"imports", "imported_by", "blast_radius", "blast_files",
|
|
456
|
+
"change_impact"}`` — the in-project files ``target`` imports, the files that
|
|
457
|
+
import it directly, and the *transitive* set that would be in scope if it
|
|
458
|
+
changed (its importers, their importers, and so on). ``change_impact`` puts
|
|
459
|
+
that in plain language so a reader of one file can tell "is this safe to
|
|
460
|
+
change, and what should I check?" without reading the whole graph. Paths
|
|
461
|
+
outside the scanned set (third-party / stdlib) are excluded by the graph
|
|
462
|
+
builder; lists are empty and counts zero when the file stands alone or isn't
|
|
463
|
+
in the project.
|
|
464
|
+
"""
|
|
465
|
+
posix_target = _posix(target)
|
|
466
|
+
_by_path, imports, fan_in = _build_import_graph(files)
|
|
467
|
+
|
|
468
|
+
# reverse BFS along fan-in edges: every file that (transitively) imports the
|
|
469
|
+
# target and would therefore be touched by a change to it.
|
|
470
|
+
blast: set[str] = set()
|
|
471
|
+
queue: deque[str] = deque(fan_in.get(posix_target, ()))
|
|
472
|
+
while queue:
|
|
473
|
+
dep = queue.popleft()
|
|
474
|
+
if dep in blast or dep == posix_target:
|
|
475
|
+
continue
|
|
476
|
+
blast.add(dep)
|
|
477
|
+
for upstream in fan_in.get(dep, ()):
|
|
478
|
+
if upstream not in blast and upstream != posix_target:
|
|
479
|
+
queue.append(upstream)
|
|
480
|
+
|
|
481
|
+
direct = len(fan_in.get(posix_target, set()))
|
|
482
|
+
return {
|
|
483
|
+
"imports": sorted(imports.get(posix_target, set())),
|
|
484
|
+
"imported_by": sorted(fan_in.get(posix_target, set())),
|
|
485
|
+
"blast_radius": len(blast),
|
|
486
|
+
"blast_files": sorted(blast)[:_MAX_DEPENDENTS],
|
|
487
|
+
"change_impact": _file_change_impact(len(blast), direct),
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _strongly_connected_components(imports: dict[str, set[str]]) -> list[list[str]]:
|
|
492
|
+
"""Tarjan's SCC algorithm (iterative, so deep graphs don't blow the stack).
|
|
493
|
+
|
|
494
|
+
Node and neighbour iteration is sorted, making the output deterministic.
|
|
495
|
+
"""
|
|
496
|
+
index_of: dict[str, int] = {}
|
|
497
|
+
low: dict[str, int] = {}
|
|
498
|
+
on_stack: set[str] = set()
|
|
499
|
+
stack: list[str] = []
|
|
500
|
+
components: list[list[str]] = []
|
|
501
|
+
counter = 0
|
|
502
|
+
|
|
503
|
+
for root in sorted(imports):
|
|
504
|
+
if root in index_of:
|
|
505
|
+
continue
|
|
506
|
+
# work stack of (node, iterator over its neighbours)
|
|
507
|
+
work: list[tuple[str, list[str]]] = [(root, sorted(imports.get(root, ())))]
|
|
508
|
+
index_of[root] = low[root] = counter
|
|
509
|
+
counter += 1
|
|
510
|
+
stack.append(root)
|
|
511
|
+
on_stack.add(root)
|
|
512
|
+
while work:
|
|
513
|
+
node, neighbours = work[-1]
|
|
514
|
+
advanced = False
|
|
515
|
+
while neighbours:
|
|
516
|
+
nxt = neighbours.pop(0)
|
|
517
|
+
if nxt not in imports: # import points outside the scanned set
|
|
518
|
+
continue
|
|
519
|
+
if nxt not in index_of:
|
|
520
|
+
index_of[nxt] = low[nxt] = counter
|
|
521
|
+
counter += 1
|
|
522
|
+
stack.append(nxt)
|
|
523
|
+
on_stack.add(nxt)
|
|
524
|
+
work.append((nxt, sorted(imports.get(nxt, ()))))
|
|
525
|
+
advanced = True
|
|
526
|
+
break
|
|
527
|
+
if nxt in on_stack:
|
|
528
|
+
low[node] = min(low[node], index_of[nxt])
|
|
529
|
+
if advanced:
|
|
530
|
+
continue
|
|
531
|
+
work.pop()
|
|
532
|
+
if work:
|
|
533
|
+
parent = work[-1][0]
|
|
534
|
+
low[parent] = min(low[parent], low[node])
|
|
535
|
+
if low[node] == index_of[node]:
|
|
536
|
+
component: list[str] = []
|
|
537
|
+
while True:
|
|
538
|
+
w = stack.pop()
|
|
539
|
+
on_stack.discard(w)
|
|
540
|
+
component.append(w)
|
|
541
|
+
if w == node:
|
|
542
|
+
break
|
|
543
|
+
components.append(sorted(component))
|
|
544
|
+
return components
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def find_import_cycles(files: list[dict], *, limit: int = 8) -> list[dict]:
|
|
548
|
+
"""Find groups of files that import each other in a cycle.
|
|
549
|
+
|
|
550
|
+
Circular imports make a codebase harder to read and refactor — you can't
|
|
551
|
+
understand one file without the others, and they invite import-time errors.
|
|
552
|
+
Returns each strongly-connected component of more than one file in the
|
|
553
|
+
import graph, largest first. Deterministic and spends no LLM call.
|
|
554
|
+
|
|
555
|
+
Returns a list of ``{"files", "size", "reason"}``.
|
|
556
|
+
"""
|
|
557
|
+
_, imports, _ = _build_import_graph(files)
|
|
558
|
+
cycles = [comp for comp in _strongly_connected_components(imports) if len(comp) > 1]
|
|
559
|
+
cycles.sort(key=lambda c: (-len(c), c[0]))
|
|
560
|
+
return [
|
|
561
|
+
{
|
|
562
|
+
"files": comp,
|
|
563
|
+
"size": len(comp),
|
|
564
|
+
"reason": (
|
|
565
|
+
f"这 {len(comp)} 个文件互相缠在一起、谁也离不开谁(A 要用 B、B 又回头要用 A),"
|
|
566
|
+
"最好放在一起读;想改干净,得先把这种相互依赖解开。"
|
|
567
|
+
),
|
|
568
|
+
}
|
|
569
|
+
for comp in cycles[:limit]
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def find_orphan_modules(files: list[dict], *, limit: int = 8) -> list[dict]:
|
|
574
|
+
"""Find code files that are disconnected from the import graph.
|
|
575
|
+
|
|
576
|
+
A file that nothing imports and that imports nothing else in the project is
|
|
577
|
+
an island: dead code, a stray script, or a module that should be wired in
|
|
578
|
+
but isn't. This is different from an entry point — an entry point is also
|
|
579
|
+
imported by nothing, but it *does* pull in other modules, while an orphan
|
|
580
|
+
has no edges at all. Only files in languages the graph understands (Python /
|
|
581
|
+
JS-TS) are considered, so docs and config never show up. Deterministic and
|
|
582
|
+
spends no LLM call.
|
|
583
|
+
|
|
584
|
+
Returns a list of ``{"path", "language", "reason"}`` sorted by path.
|
|
585
|
+
"""
|
|
586
|
+
by_path, imports, fan_in = _build_import_graph(files)
|
|
587
|
+
orphans: list[dict] = []
|
|
588
|
+
for path in sorted(by_path):
|
|
589
|
+
f = by_path[path]
|
|
590
|
+
lang = f.get("language", "unknown")
|
|
591
|
+
if lang not in _PY_LANGS and lang not in _JS_LANGS:
|
|
592
|
+
continue
|
|
593
|
+
if imports.get(path) or fan_in.get(path):
|
|
594
|
+
continue
|
|
595
|
+
orphans.append(
|
|
596
|
+
{
|
|
597
|
+
"path": path,
|
|
598
|
+
"language": lang,
|
|
599
|
+
"reason": (
|
|
600
|
+
"项目里没有任何文件用到它,它也不用别的文件,像座孤岛——"
|
|
601
|
+
"可能是没人再用的废弃代码、一个独立的小脚本,或者本该接上却漏接了的部分。"
|
|
602
|
+
),
|
|
603
|
+
}
|
|
604
|
+
)
|
|
605
|
+
return orphans[:limit]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _blast_reason(blast: int, direct: int) -> str:
|
|
609
|
+
if blast >= 5:
|
|
610
|
+
return (
|
|
611
|
+
f"改动它会连带影响 {blast} 个文件(其中 {direct} 个直接用到它),"
|
|
612
|
+
"动手前最该先掂量牵连有多大。"
|
|
613
|
+
)
|
|
614
|
+
return f"改动它大约连带影响 {blast} 个文件,牵连一般。"
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def rank_blast_radius(files: list[dict], *, limit: int = 8) -> list[dict]:
|
|
618
|
+
"""Rank files by how many other files *transitively* depend on them.
|
|
619
|
+
|
|
620
|
+
Where :func:`rank_hotspots` counts direct importers, this follows the import
|
|
621
|
+
edges in reverse to its closure: if changing a file could ripple to N other
|
|
622
|
+
files (its importers, their importers, and so on), that N is its blast
|
|
623
|
+
radius. It answers the newcomer's "if I change this, what might break?" much
|
|
624
|
+
more honestly than direct fan-in, since impact propagates through the graph.
|
|
625
|
+
Cycles are handled by the visited set. Deterministic and spends no LLM call.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
629
|
+
limit: how many top files to return.
|
|
630
|
+
|
|
631
|
+
Returns a list of
|
|
632
|
+
``{"path", "language", "blast_radius", "direct_dependents", "reason"}``
|
|
633
|
+
sorted by blast radius descending, then path. Files nothing depends on are
|
|
634
|
+
omitted.
|
|
635
|
+
"""
|
|
636
|
+
by_path, _imports, fan_in = _build_import_graph(files)
|
|
637
|
+
|
|
638
|
+
ranked: list[dict] = []
|
|
639
|
+
for path in by_path:
|
|
640
|
+
# reverse BFS along fan_in edges: every file that (transitively) imports
|
|
641
|
+
# ``path`` and would therefore be in the blast radius of changing it.
|
|
642
|
+
seen: set[str] = set()
|
|
643
|
+
queue: deque[str] = deque(fan_in.get(path, ()))
|
|
644
|
+
while queue:
|
|
645
|
+
dep = queue.popleft()
|
|
646
|
+
if dep in seen or dep == path:
|
|
647
|
+
continue
|
|
648
|
+
seen.add(dep)
|
|
649
|
+
for upstream in fan_in.get(dep, ()):
|
|
650
|
+
if upstream not in seen and upstream != path:
|
|
651
|
+
queue.append(upstream)
|
|
652
|
+
if not seen:
|
|
653
|
+
continue
|
|
654
|
+
ranked.append(
|
|
655
|
+
{
|
|
656
|
+
"path": path,
|
|
657
|
+
"language": by_path[path].get("language", "unknown"),
|
|
658
|
+
"blast_radius": len(seen),
|
|
659
|
+
"direct_dependents": sorted(fan_in.get(path, ()))[:_MAX_DEPENDENTS],
|
|
660
|
+
"reason": _blast_reason(len(seen), len(fan_in.get(path, set()))),
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
ranked.sort(key=lambda h: (-h["blast_radius"], h["path"]))
|
|
664
|
+
return ranked[:limit]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _layer_reason(layer: int) -> str:
|
|
668
|
+
if layer == 0:
|
|
669
|
+
return "地基层:不依赖项目里的其他文件,被上层模块复用,最稳定,最该先读懂。"
|
|
670
|
+
return f"第 {layer} 层:叠在 {layer} 层本地依赖之上,越往上越接近应用入口和编排逻辑。"
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def assign_architecture_layers(files: list[dict], *, limit: int = 12) -> list[dict]:
|
|
674
|
+
"""Stratify the project's code files into dependency layers.
|
|
675
|
+
|
|
676
|
+
Cycles are condensed into single nodes (via SCCs) so the layering graph is
|
|
677
|
+
acyclic, then each file gets a layer equal to the longest chain of local
|
|
678
|
+
imports beneath it: files that import nothing local sit at layer 0 (the
|
|
679
|
+
foundation everything builds on), and each layer above imports the one
|
|
680
|
+
below, up to the entry/orchestration files. Unlike a reading order (a flat
|
|
681
|
+
sequence) this shows the *shape* of a codebase as tiers. Deterministic and
|
|
682
|
+
spends no LLM call.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
686
|
+
limit: how many files to return (highest layers first).
|
|
687
|
+
|
|
688
|
+
Returns a list of ``{"path", "language", "layer", "reason"}`` sorted by layer
|
|
689
|
+
descending, then path. Only Python / JS-TS files are layered.
|
|
690
|
+
"""
|
|
691
|
+
by_path, imports, _fan_in = _build_import_graph(files)
|
|
692
|
+
|
|
693
|
+
# condense cycles so the layering graph is acyclic
|
|
694
|
+
scc_of: dict[str, int] = {}
|
|
695
|
+
for i, comp in enumerate(_strongly_connected_components(imports)):
|
|
696
|
+
for node in comp:
|
|
697
|
+
scc_of[node] = i
|
|
698
|
+
|
|
699
|
+
scc_imports: dict[int, set[int]] = defaultdict(set)
|
|
700
|
+
for path, deps in imports.items():
|
|
701
|
+
a = scc_of.get(path)
|
|
702
|
+
if a is None:
|
|
703
|
+
continue
|
|
704
|
+
for dep in deps:
|
|
705
|
+
b = scc_of.get(dep)
|
|
706
|
+
if b is not None and b != a:
|
|
707
|
+
scc_imports[a].add(b)
|
|
708
|
+
|
|
709
|
+
# longest path to a leaf on the condensed DAG; memoized, acyclic so it ends
|
|
710
|
+
layer_cache: dict[int, int] = {}
|
|
711
|
+
|
|
712
|
+
def _scc_layer(scc: int) -> int:
|
|
713
|
+
if scc not in layer_cache:
|
|
714
|
+
deps = scc_imports.get(scc, ())
|
|
715
|
+
layer_cache[scc] = 1 + max((_scc_layer(d) for d in deps), default=-1)
|
|
716
|
+
return layer_cache[scc]
|
|
717
|
+
|
|
718
|
+
result: list[dict] = []
|
|
719
|
+
for path in by_path:
|
|
720
|
+
lang = by_path[path].get("language", "unknown")
|
|
721
|
+
if lang not in _PY_LANGS and lang not in _JS_LANGS:
|
|
722
|
+
continue
|
|
723
|
+
scc = scc_of.get(path)
|
|
724
|
+
layer = _scc_layer(scc) if scc is not None else 0
|
|
725
|
+
result.append(
|
|
726
|
+
{
|
|
727
|
+
"path": path,
|
|
728
|
+
"language": lang,
|
|
729
|
+
"layer": layer,
|
|
730
|
+
"reason": _layer_reason(layer),
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
result.sort(key=lambda h: (-h["layer"], h["path"]))
|
|
734
|
+
return result[:limit]
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _package_of(path: str) -> str:
|
|
738
|
+
"""The directory a file lives in, used as its package label."""
|
|
739
|
+
posix = _posix(path)
|
|
740
|
+
return posix.rsplit("/", 1)[0] if "/" in posix else "(root)"
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _package_reason(fan_out: int, fan_in: int) -> str:
|
|
744
|
+
if fan_in and not fan_out:
|
|
745
|
+
return (
|
|
746
|
+
f"被 {fan_in} 个其他目录依赖、自己不依赖别的目录:"
|
|
747
|
+
"偏底层的公共能力,改动影响面最大,值得先读懂。"
|
|
748
|
+
)
|
|
749
|
+
if fan_out and not fan_in:
|
|
750
|
+
return (
|
|
751
|
+
f"依赖 {fan_out} 个其他目录、没有目录反过来依赖它:"
|
|
752
|
+
"偏上层的入口 / 编排,适合从这里顺着依赖往下读。"
|
|
753
|
+
)
|
|
754
|
+
return f"依赖 {fan_out} 个目录、又被 {fan_in} 个目录依赖:处在架构中段,是连接上下层的枢纽。"
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def summarize_package_dependencies(files: list[dict], *, limit: int = 12) -> list[dict]:
|
|
758
|
+
"""Condense the file-level import graph into a directory-level one.
|
|
759
|
+
|
|
760
|
+
Each file is labelled by the directory it lives in, and same-directory
|
|
761
|
+
imports are dropped, so the result is a high-level map of which folders lean
|
|
762
|
+
on which. It answers "how do the big pieces fit together" without making a
|
|
763
|
+
reader open a single file. Deterministic and spends no LLM call.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
files: scanner output dicts with ``path``, ``language`` and ``preview``.
|
|
767
|
+
limit: how many packages to return (most cross-package coupling first).
|
|
768
|
+
|
|
769
|
+
Returns a list of ``{"package", "depends_on", "depended_on_by", "fan_out",
|
|
770
|
+
"fan_in", "reason"}`` sorted by total cross-package coupling descending, then
|
|
771
|
+
package. Directories with no cross-directory imports are omitted.
|
|
772
|
+
"""
|
|
773
|
+
_by_path, imports, _fan_in = _build_import_graph(files)
|
|
774
|
+
pkg_of = {path: _package_of(path) for path in imports}
|
|
775
|
+
|
|
776
|
+
depends_on: dict[str, set[str]] = defaultdict(set)
|
|
777
|
+
depended_on_by: dict[str, set[str]] = defaultdict(set)
|
|
778
|
+
for path, deps in imports.items():
|
|
779
|
+
a = pkg_of[path]
|
|
780
|
+
for dep in deps:
|
|
781
|
+
b = pkg_of.get(dep)
|
|
782
|
+
if b is None or b == a:
|
|
783
|
+
continue
|
|
784
|
+
depends_on[a].add(b)
|
|
785
|
+
depended_on_by[b].add(a)
|
|
786
|
+
|
|
787
|
+
result: list[dict] = []
|
|
788
|
+
for pkg in set(depends_on) | set(depended_on_by):
|
|
789
|
+
outs = sorted(depends_on.get(pkg, ()))
|
|
790
|
+
ins = sorted(depended_on_by.get(pkg, ()))
|
|
791
|
+
result.append(
|
|
792
|
+
{
|
|
793
|
+
"package": pkg,
|
|
794
|
+
"depends_on": outs,
|
|
795
|
+
"depended_on_by": ins,
|
|
796
|
+
"fan_out": len(outs),
|
|
797
|
+
"fan_in": len(ins),
|
|
798
|
+
"reason": _package_reason(len(outs), len(ins)),
|
|
799
|
+
}
|
|
800
|
+
)
|
|
801
|
+
result.sort(key=lambda d: (-(d["fan_in"] + d["fan_out"]), d["package"]))
|
|
802
|
+
return result[:limit]
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def summarize_project_health(files: list[dict]) -> dict:
|
|
806
|
+
"""A one-glance, plain-language health read of the project's structure.
|
|
807
|
+
|
|
808
|
+
Rolls the per-file / per-directory analyses up to project level so a
|
|
809
|
+
non-programmer can size up a codebase at a glance: how big it is, what its
|
|
810
|
+
load-bearing file is, and where the structural risks (circular imports,
|
|
811
|
+
orphan files, wide change blast radius) are. Deterministic, no LLM call.
|
|
812
|
+
|
|
813
|
+
Returns a dict of totals, risk counts, the single most-depended-on file, the
|
|
814
|
+
widest change blast radius, and a list of plain-language ``notes``.
|
|
815
|
+
"""
|
|
816
|
+
by_path, imports, fan_in = _build_import_graph(files)
|
|
817
|
+
code = [
|
|
818
|
+
path
|
|
819
|
+
for path in by_path
|
|
820
|
+
if by_path[path].get("language") in _PY_LANGS or by_path[path].get("language") in _JS_LANGS
|
|
821
|
+
]
|
|
822
|
+
total_code_files = len(code)
|
|
823
|
+
total_directories = len({_package_of(path) for path in code})
|
|
824
|
+
cycle_groups = sum(1 for comp in _strongly_connected_components(imports) if len(comp) > 1)
|
|
825
|
+
orphan_files = sum(1 for path in code if not imports.get(path) and not fan_in.get(path))
|
|
826
|
+
|
|
827
|
+
core = rank_hotspots(files, limit=1)
|
|
828
|
+
blast = rank_blast_radius(files, limit=1)
|
|
829
|
+
most_depended_on = core[0]["path"] if core else ""
|
|
830
|
+
most_depended_on_fan_in = core[0]["fan_in"] if core else 0
|
|
831
|
+
widest_blast_radius_file = blast[0]["path"] if blast else ""
|
|
832
|
+
widest_blast_radius = blast[0]["blast_radius"] if blast else 0
|
|
833
|
+
|
|
834
|
+
notes: list[str] = [
|
|
835
|
+
f"项目里有 {total_code_files} 个代码文件,分布在 {total_directories} 个目录。"
|
|
836
|
+
]
|
|
837
|
+
if most_depended_on:
|
|
838
|
+
notes.append(
|
|
839
|
+
f"最核心的文件是 {most_depended_on}"
|
|
840
|
+
f"(被 {most_depended_on_fan_in} 个文件依赖),建议最先读懂。"
|
|
841
|
+
)
|
|
842
|
+
if widest_blast_radius > 0:
|
|
843
|
+
notes.append(
|
|
844
|
+
f"改动影响面最大的是 {widest_blast_radius_file}"
|
|
845
|
+
f"(牵连约 {widest_blast_radius} 个文件),改它要格外小心。"
|
|
846
|
+
)
|
|
847
|
+
if cycle_groups:
|
|
848
|
+
notes.append(f"发现 {cycle_groups} 组循环依赖,建议理清以降低耦合。")
|
|
849
|
+
else:
|
|
850
|
+
notes.append("没有循环依赖,依赖关系是干净的有向结构。")
|
|
851
|
+
if orphan_files:
|
|
852
|
+
notes.append(
|
|
853
|
+
f"有 {orphan_files} 个文件和其他文件没有任何 import 关系,可能是死代码或漏接的模块。"
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
"total_code_files": total_code_files,
|
|
858
|
+
"total_directories": total_directories,
|
|
859
|
+
"circular_dependency_groups": cycle_groups,
|
|
860
|
+
"orphan_files": orphan_files,
|
|
861
|
+
"most_depended_on": most_depended_on,
|
|
862
|
+
"most_depended_on_fan_in": most_depended_on_fan_in,
|
|
863
|
+
"widest_blast_radius_file": widest_blast_radius_file,
|
|
864
|
+
"widest_blast_radius": widest_blast_radius,
|
|
865
|
+
"notes": notes,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _mermaid_node_id(name: str) -> str:
|
|
870
|
+
"""A Mermaid-safe node id for a package label (kept stable and unique)."""
|
|
871
|
+
return "n_" + re.sub(r"[^A-Za-z0-9_]", "_", name)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _codemap_mermaid(packages: list[dict]) -> str:
|
|
875
|
+
"""Build a Mermaid flowchart of the directory-dependency edges.
|
|
876
|
+
|
|
877
|
+
Rendered inline by GitHub and other Markdown viewers, so the exported map
|
|
878
|
+
carries a visual architecture diagram, not just lists. Returns "" when there
|
|
879
|
+
are no cross-directory edges to draw.
|
|
880
|
+
"""
|
|
881
|
+
nodes: dict[str, str] = {}
|
|
882
|
+
edges: set[tuple[str, str]] = set()
|
|
883
|
+
for pkg in packages:
|
|
884
|
+
a = _mermaid_node_id(pkg["package"])
|
|
885
|
+
nodes[a] = pkg["package"]
|
|
886
|
+
for dep in pkg["depends_on"]:
|
|
887
|
+
b = _mermaid_node_id(dep)
|
|
888
|
+
nodes[b] = dep
|
|
889
|
+
edges.add((a, b))
|
|
890
|
+
if not edges:
|
|
891
|
+
return ""
|
|
892
|
+
lines = ["flowchart TD"]
|
|
893
|
+
lines.extend(f' {nid}["{label}/"]' for nid, label in sorted(nodes.items()))
|
|
894
|
+
lines.extend(f" {a} --> {b}" for a, b in sorted(edges))
|
|
895
|
+
return "\n".join(lines)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def render_codemap_markdown(project_name: str, files: list[dict]) -> str:
|
|
899
|
+
"""Render the full deterministic code map as a shareable Markdown document.
|
|
900
|
+
|
|
901
|
+
Bundles every import-graph analysis (health, core files, layers, directory
|
|
902
|
+
dependencies, cycles, blast radius, coupling, orphans) into one document a
|
|
903
|
+
reader can save, share, or paste into a PR. Sections with nothing to show
|
|
904
|
+
are omitted so small projects stay short. Deterministic, no LLM call.
|
|
905
|
+
"""
|
|
906
|
+
lines = [
|
|
907
|
+
f"# {project_name} — 代码地图",
|
|
908
|
+
"",
|
|
909
|
+
"> 这是一份自动生成的代码地图,不用会编程也能看:它顺着文件之间「谁用到谁」"
|
|
910
|
+
"的关系算出来(不依赖 AI),帮你快速看清这个项目有哪些核心文件、从哪看起、"
|
|
911
|
+
"哪里最该小心。下面每一节开头都有一句大白话说明它在讲什么——刚接手项目的话,"
|
|
912
|
+
"建议先看「项目体检」和「该先读的文件」。",
|
|
913
|
+
"",
|
|
914
|
+
]
|
|
915
|
+
|
|
916
|
+
health = summarize_project_health(files)
|
|
917
|
+
if health["notes"]:
|
|
918
|
+
lines.append("## 项目体检")
|
|
919
|
+
lines.append("")
|
|
920
|
+
lines.extend(f"- {note}" for note in health["notes"])
|
|
921
|
+
lines.append("")
|
|
922
|
+
|
|
923
|
+
hotspots = rank_hotspots(files)
|
|
924
|
+
if hotspots:
|
|
925
|
+
lines.append("## 核心文件(被依赖最多)")
|
|
926
|
+
lines.append("")
|
|
927
|
+
lines.extend(f"- `{h['path']}` — {h['reason']}" for h in hotspots)
|
|
928
|
+
lines.append("")
|
|
929
|
+
|
|
930
|
+
layers = assign_architecture_layers(files)
|
|
931
|
+
if layers:
|
|
932
|
+
lines.append("## 架构分层(第 0 层是地基,越往上越接近入口)")
|
|
933
|
+
lines.append("")
|
|
934
|
+
lines.extend(f"- L{a['layer']} `{a['path']}`" for a in layers)
|
|
935
|
+
lines.append("")
|
|
936
|
+
|
|
937
|
+
packages = summarize_package_dependencies(files)
|
|
938
|
+
if packages:
|
|
939
|
+
lines.append("## 目录之间怎么依赖")
|
|
940
|
+
lines.append("")
|
|
941
|
+
for p in packages:
|
|
942
|
+
deps = "、".join(p["depends_on"])
|
|
943
|
+
arrow = f" → 依赖 {deps}" if deps else ""
|
|
944
|
+
lines.append(f"- `{p['package']}/`{arrow}")
|
|
945
|
+
lines.append(f" - {p['reason']}")
|
|
946
|
+
lines.append("")
|
|
947
|
+
mermaid = _codemap_mermaid(packages)
|
|
948
|
+
if mermaid:
|
|
949
|
+
lines.append("### 目录依赖图")
|
|
950
|
+
lines.append("")
|
|
951
|
+
lines.append("```mermaid")
|
|
952
|
+
lines.append(mermaid)
|
|
953
|
+
lines.append("```")
|
|
954
|
+
lines.append("")
|
|
955
|
+
|
|
956
|
+
cycles = find_import_cycles(files)
|
|
957
|
+
if cycles:
|
|
958
|
+
lines.append("## 循环依赖(几个文件互相用到对方,绕成一圈,建议理清)")
|
|
959
|
+
lines.append("")
|
|
960
|
+
for c in cycles:
|
|
961
|
+
chain = " → ".join(f"`{p}`" for p in c["files"])
|
|
962
|
+
lines.append(f"- {chain}")
|
|
963
|
+
lines.append("")
|
|
964
|
+
|
|
965
|
+
blast = rank_blast_radius(files)
|
|
966
|
+
if blast:
|
|
967
|
+
lines.append("## 改动影响面(改这些文件波及最广)")
|
|
968
|
+
lines.append("")
|
|
969
|
+
lines.extend(f"- `{b['path']}` — {b['reason']}" for b in blast)
|
|
970
|
+
lines.append("")
|
|
971
|
+
|
|
972
|
+
coupling = rank_coupling(files)
|
|
973
|
+
if coupling:
|
|
974
|
+
lines.append("## 依赖最多的文件(牵连其他文件最多)")
|
|
975
|
+
lines.append("")
|
|
976
|
+
lines.extend(f"- `{c['path']}` — {c['reason']}" for c in coupling)
|
|
977
|
+
lines.append("")
|
|
978
|
+
|
|
979
|
+
orphans = find_orphan_modules(files)
|
|
980
|
+
if orphans:
|
|
981
|
+
lines.append("## 可能没人用的文件(项目里没有别的文件用到它)")
|
|
982
|
+
lines.append("")
|
|
983
|
+
lines.extend(f"- `{o['path']}` — {o['reason']}" for o in orphans)
|
|
984
|
+
lines.append("")
|
|
985
|
+
|
|
986
|
+
return "\n".join(lines).rstrip() + "\n"
|