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.
Files changed (62) hide show
  1. backend/__init__.py +0 -0
  2. backend/app.py +95 -0
  3. backend/desktop_server.py +46 -0
  4. backend/models.py +629 -0
  5. backend/prompts/__init__.py +0 -0
  6. backend/prompts/annotate.py +44 -0
  7. backend/prompts/edit.py +62 -0
  8. backend/prompts/overview.py +57 -0
  9. backend/prompts/qa.py +45 -0
  10. backend/routers/__init__.py +0 -0
  11. backend/routers/analyze.py +271 -0
  12. backend/routers/project.py +872 -0
  13. backend/services/__init__.py +0 -0
  14. backend/services/action_plan.py +366 -0
  15. backend/services/activity.py +313 -0
  16. backend/services/apimap.py +391 -0
  17. backend/services/async_surface.py +123 -0
  18. backend/services/cache.py +183 -0
  19. backend/services/churn.py +244 -0
  20. backend/services/ci_checks.py +317 -0
  21. backend/services/codemap_export.py +136 -0
  22. backend/services/commands.py +276 -0
  23. backend/services/complexity.py +111 -0
  24. backend/services/config_files.py +190 -0
  25. backend/services/contributing.py +308 -0
  26. backend/services/coverage.py +283 -0
  27. backend/services/datamodels.py +190 -0
  28. backend/services/deep_nesting.py +110 -0
  29. backend/services/dependencies.py +270 -0
  30. backend/services/docs.py +255 -0
  31. backend/services/docstrings.py +163 -0
  32. backend/services/duplicate_code.py +165 -0
  33. backend/services/entrypoints.py +152 -0
  34. backend/services/envscan.py +167 -0
  35. backend/services/error_handling.py +239 -0
  36. backend/services/filenames.py +261 -0
  37. backend/services/github_clone.py +129 -0
  38. backend/services/glossary.py +96 -0
  39. backend/services/health_score.py +381 -0
  40. backend/services/importgraph.py +986 -0
  41. backend/services/integrations.py +263 -0
  42. backend/services/licenses.py +505 -0
  43. backend/services/llm.py +117 -0
  44. backend/services/long_functions.py +101 -0
  45. backend/services/ownership.py +194 -0
  46. backend/services/package_purposes.py +112 -0
  47. backend/services/pr_reading.py +181 -0
  48. backend/services/release_map.py +402 -0
  49. backend/services/report_export.py +232 -0
  50. backend/services/risk.py +88 -0
  51. backend/services/scanner.py +546 -0
  52. backend/services/schedules.py +369 -0
  53. backend/services/security.py +445 -0
  54. backend/services/settings_map.py +234 -0
  55. backend/services/symbols.py +605 -0
  56. backend/services/techdebt.py +124 -0
  57. backend/services/too_many_params.py +102 -0
  58. backend/services/typing_coverage.py +184 -0
  59. codeabc-0.1.0.dist-info/METADATA +283 -0
  60. codeabc-0.1.0.dist-info/RECORD +62 -0
  61. codeabc-0.1.0.dist-info/WHEEL +4 -0
  62. 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"