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,136 @@
1
+ """Assemble the full deterministic code map as a single Markdown document.
2
+
3
+ The browser UI renders each analysis (import graph, risk, churn, coverage, ...)
4
+ in its own panel. The ``codemap.md`` export stitches the same analyses into one
5
+ file you can download and read offline or hand to someone else. Every section is
6
+ produced by the service that owns that analysis; this module only decides which
7
+ sections run, in what order, and how they are joined.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from backend.services import (
13
+ action_plan as action_plan_svc,
14
+ )
15
+ from backend.services import (
16
+ activity,
17
+ apimap,
18
+ async_surface,
19
+ churn,
20
+ ci_checks,
21
+ commands,
22
+ complexity,
23
+ contributing,
24
+ coverage,
25
+ datamodels,
26
+ deep_nesting,
27
+ dependencies,
28
+ docs,
29
+ docstrings,
30
+ duplicate_code,
31
+ entrypoints,
32
+ envscan,
33
+ error_handling,
34
+ importgraph,
35
+ integrations,
36
+ licenses,
37
+ long_functions,
38
+ ownership,
39
+ release_map,
40
+ risk,
41
+ schedules,
42
+ security,
43
+ settings_map,
44
+ techdebt,
45
+ too_many_params,
46
+ typing_coverage,
47
+ )
48
+ from backend.services import (
49
+ health_score as health_score_svc,
50
+ )
51
+
52
+ _SECTION_SEPARATOR = "\n\n---\n\n"
53
+
54
+
55
+ def _ordered_sections(proj: dict) -> list[str]:
56
+ """Render every optional code-map section in display order.
57
+
58
+ Each service returns an empty string when it has nothing to report (for
59
+ example, churn/ownership need git history, so an uploaded folder skips them).
60
+ The import-graph code map is the always-present base and is handled by the
61
+ caller, so it is not part of this list.
62
+ """
63
+ name = proj["name"]
64
+ files = proj["files"]
65
+ contents = proj.get("file_contents", {})
66
+
67
+ return [
68
+ risk.render_risk_markdown(
69
+ risk.rank_risk(
70
+ importgraph.rank_hotspots(files, limit=500),
71
+ (proj.get("churn") or {}).get("hotspots", []),
72
+ )
73
+ ),
74
+ churn.render_churn_markdown(name, proj.get("churn")),
75
+ ownership.render_ownership_markdown(name, proj.get("ownership")),
76
+ coverage.render_coverage_markdown(name, coverage.assess_test_coverage(files)),
77
+ techdebt.render_techdebt_markdown(name, techdebt.scan_tech_debt(contents)),
78
+ envscan.render_env_markdown(name, envscan.scan_env_vars(contents)),
79
+ entrypoints.render_entrypoints_markdown(name, entrypoints.find_entry_points(contents)),
80
+ commands.render_commands_markdown(name, commands.find_cli_commands(contents)),
81
+ datamodels.render_data_models_markdown(name, datamodels.find_data_models(contents)),
82
+ settings_map.render_settings_markdown(name, settings_map.find_tunable_settings(contents)),
83
+ schedules.render_schedules_markdown(name, schedules.find_scheduled_tasks(contents)),
84
+ ci_checks.render_ci_checks_markdown(name, ci_checks.find_ci_checks(contents)),
85
+ release_map.render_release_markdown(name, release_map.find_release_info(contents)),
86
+ contributing.render_contributing_markdown(
87
+ name, contributing.find_contribution_guide(contents)
88
+ ),
89
+ licenses.render_licenses_markdown(name, licenses.find_licenses(contents)),
90
+ complexity.render_complexity_markdown(name, complexity.scan_complexity(contents)),
91
+ deep_nesting.render_deep_nesting_markdown(name, deep_nesting.scan_deep_nesting(contents)),
92
+ long_functions.render_long_functions_markdown(
93
+ name, long_functions.scan_long_functions(contents)
94
+ ),
95
+ too_many_params.render_too_many_params_markdown(
96
+ name, too_many_params.scan_too_many_params(contents)
97
+ ),
98
+ duplicate_code.render_duplicate_code_markdown(
99
+ name, duplicate_code.scan_duplicate_code(contents)
100
+ ),
101
+ dependencies.render_dependencies_markdown(name, dependencies.scan_dependencies(contents)),
102
+ security.render_security_markdown(name, security.scan_security(contents)),
103
+ apimap.render_apimap_markdown(name, apimap.scan_api_routes(contents)),
104
+ docs.render_doc_coverage_markdown(name, docs.assess_doc_coverage(contents)),
105
+ docstrings.render_docstring_coverage_markdown(
106
+ name, docstrings.scan_docstring_coverage(contents)
107
+ ),
108
+ typing_coverage.render_typing_coverage_markdown(
109
+ name, typing_coverage.scan_typing_coverage(contents)
110
+ ),
111
+ async_surface.render_async_surface_markdown(
112
+ name, async_surface.scan_async_surface(contents)
113
+ ),
114
+ error_handling.render_error_handling_markdown(
115
+ name, error_handling.find_swallowed_errors(contents)
116
+ ),
117
+ integrations.render_integrations_markdown(
118
+ name, integrations.detect_external_services(contents)
119
+ ),
120
+ activity.render_activity_markdown(name, proj.get("activity")),
121
+ health_score_svc.render_health_score_markdown(name, proj.get("health_score")),
122
+ action_plan_svc.render_action_plan_markdown(name, proj.get("action_plan")),
123
+ ]
124
+
125
+
126
+ def build_codemap_markdown(proj: dict) -> str:
127
+ """Build the downloadable ``codemap.md`` for a scanned project.
128
+
129
+ The import-graph code map always leads; every other analysis is appended only
130
+ when it has content, separated by a horizontal rule.
131
+ """
132
+ markdown = importgraph.render_codemap_markdown(proj["name"], proj["files"])
133
+ for section in _ordered_sections(proj):
134
+ if section:
135
+ markdown = f"{markdown.rstrip()}{_SECTION_SEPARATOR}{section}"
136
+ return markdown
@@ -0,0 +1,276 @@
1
+ """CLI command surface — what you can actually type to drive the tool.
2
+
3
+ The entry-points map answers "which file or console command starts the program"
4
+ — that you run ``mytool`` or ``python manage.py``. It stops at the front door.
5
+ This map walks through it: once you have a CLI, what *sub-commands* does it
6
+ accept, and what does each one do? For someone evaluating a tool without reading
7
+ the code, that is the real question — ``mytool build``, ``mytool deploy``, the
8
+ list of verbs and the one-line help next to each.
9
+
10
+ It reads the command surface straight from the source CodeABC already loaded —
11
+ no LLM, and nothing to install or run — by recognising the three CLI frameworks
12
+ that cover the overwhelming majority of Python tools:
13
+
14
+ argparse ``sub = parser.add_subparsers(); sub.add_parser("build", help=...)``
15
+ click ``@cli.command("build")`` / ``@click.command`` + ``@click.option``
16
+ typer ``@app.command()`` on a ``typer.Typer()`` app
17
+
18
+ Each command carries its name, the framework it came from, a short help line (the
19
+ ``help=`` argument, or the function's docstring first line), the option/argument
20
+ flags it declares, and the file and line where it is defined.
21
+
22
+ :func:`find_cli_commands` is pure over the file contents, so it is unit-testable
23
+ with plain strings and needs no repository.
24
+
25
+ Limitations (kept honest on purpose):
26
+
27
+ * Only the three frameworks above, and only statically-declared commands.
28
+ Names or help built at runtime, plugin-loaded sub-commands, and groups
29
+ registered dynamically are out of scope.
30
+ * A file that does not parse as Python is skipped, not guessed at.
31
+ * Group nesting is recorded by each command's own name, not rebuilt into a
32
+ full ``parent child`` path.
33
+ * argparse option flags need dataflow to tie back to their sub-parser, so
34
+ argparse commands list their name and help but not their flags.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import ast
40
+
41
+ # Decorator attribute names that register a CLI command or group.
42
+ _COMMAND_ATTRS = ("command", "group")
43
+ # Decorator attribute names that declare a flag/argument on a click/typer command.
44
+ _OPTION_ATTRS = ("option", "argument")
45
+
46
+
47
+ def _base_name(node: ast.expr) -> str | None:
48
+ """Return the left-most name of ``a`` in ``@a.command`` / ``@a.b.command``."""
49
+ if isinstance(node, ast.Name):
50
+ return node.id
51
+ if isinstance(node, ast.Attribute):
52
+ return _base_name(node.value)
53
+ return None
54
+
55
+
56
+ def _first_string_arg(call: ast.Call) -> str | None:
57
+ for arg in call.args:
58
+ if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
59
+ return arg.value
60
+ return None
61
+
62
+
63
+ def _string_kwarg(call: ast.Call, name: str) -> str | None:
64
+ for kw in call.keywords:
65
+ if (
66
+ kw.arg == name
67
+ and isinstance(kw.value, ast.Constant)
68
+ and isinstance(kw.value.value, str)
69
+ ):
70
+ return kw.value.value
71
+ return None
72
+
73
+
74
+ def _docstring_summary(func: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
75
+ doc = ast.get_docstring(func)
76
+ if not doc:
77
+ return ""
78
+ for line in doc.splitlines():
79
+ stripped = line.strip()
80
+ if stripped:
81
+ return stripped
82
+ return ""
83
+
84
+
85
+ def _command_decorator(dec: ast.expr) -> ast.Attribute | None:
86
+ """Return the decorator's target if it is a ``.command`` / ``.group`` one."""
87
+ target = dec.func if isinstance(dec, ast.Call) else dec
88
+ if isinstance(target, ast.Attribute) and target.attr in _COMMAND_ATTRS:
89
+ return target
90
+ return None
91
+
92
+
93
+ def _option_flag(dec: ast.expr) -> str | None:
94
+ if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
95
+ if dec.func.attr in _OPTION_ATTRS:
96
+ return _first_string_arg(dec)
97
+ return None
98
+
99
+
100
+ def _scan_imports_and_apps(tree: ast.Module) -> tuple[set[str], bool, bool, bool]:
101
+ """Return (typer_app_vars, click_imported, typer_imported, argparse_imported)."""
102
+ typer_apps: set[str] = set()
103
+ click_imported = typer_imported = False
104
+ argparse_imported = False
105
+
106
+ for node in ast.walk(tree):
107
+ if isinstance(node, ast.Import):
108
+ for alias in node.names:
109
+ root = alias.name.split(".")[0]
110
+ click_imported |= root == "click"
111
+ typer_imported |= root == "typer"
112
+ argparse_imported |= root == "argparse"
113
+ elif isinstance(node, ast.ImportFrom):
114
+ root = (node.module or "").split(".")[0]
115
+ click_imported |= root == "click"
116
+ typer_imported |= root == "typer"
117
+ argparse_imported |= root == "argparse"
118
+ elif isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
119
+ func = node.value.func
120
+ # `app = typer.Typer(...)` or `app = Typer(...)`
121
+ is_typer = (isinstance(func, ast.Attribute) and func.attr == "Typer") or (
122
+ isinstance(func, ast.Name) and func.id == "Typer"
123
+ )
124
+ if is_typer:
125
+ for target in node.targets:
126
+ if isinstance(target, ast.Name):
127
+ typer_apps.add(target.id)
128
+
129
+ return typer_apps, click_imported, typer_imported, argparse_imported
130
+
131
+
132
+ def _framework(
133
+ base: str | None, typer_apps: set[str], click_imp: bool, typer_imp: bool
134
+ ) -> str | None:
135
+ """Resolve which CLI framework a ``.command`` decorator belongs to."""
136
+ if base in typer_apps or base == "typer":
137
+ return "typer"
138
+ if base == "click":
139
+ return "click"
140
+ # `@cli.command` on a group variable — fall back to whatever the file imports.
141
+ if typer_imp and not click_imp:
142
+ return "typer"
143
+ if click_imp:
144
+ return "click"
145
+ return None
146
+
147
+
148
+ def _decorated_commands(tree: ast.Module, path: str) -> list[dict]:
149
+ typer_apps, click_imp, typer_imp, _ = _scan_imports_and_apps(tree)
150
+ commands: list[dict] = []
151
+
152
+ for node in ast.walk(tree):
153
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
154
+ continue
155
+
156
+ target = next(
157
+ (t for dec in node.decorator_list if (t := _command_decorator(dec)) is not None),
158
+ None,
159
+ )
160
+ if target is None:
161
+ continue
162
+
163
+ framework = _framework(_base_name(target.value), typer_apps, click_imp, typer_imp)
164
+ if framework is None:
165
+ continue
166
+
167
+ decorator = next(d for d in node.decorator_list if _command_decorator(d) is target)
168
+ name = None
169
+ help_text = ""
170
+ if isinstance(decorator, ast.Call):
171
+ name = _first_string_arg(decorator) or _string_kwarg(decorator, "name")
172
+ help_text = _string_kwarg(decorator, "help") or ""
173
+ name = name or node.name.replace("_", "-")
174
+ if not help_text:
175
+ help_text = _docstring_summary(node)
176
+
177
+ options = [f for dec in node.decorator_list if (f := _option_flag(dec))]
178
+ commands.append(
179
+ {
180
+ "name": name,
181
+ "framework": framework,
182
+ "help": help_text,
183
+ "options": options,
184
+ "path": path,
185
+ "line": node.lineno,
186
+ }
187
+ )
188
+
189
+ return commands
190
+
191
+
192
+ def _argparse_commands(tree: ast.Module, path: str) -> list[dict]:
193
+ commands: list[dict] = []
194
+ for node in ast.walk(tree):
195
+ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
196
+ continue
197
+ if node.func.attr != "add_parser":
198
+ continue
199
+ name = _first_string_arg(node)
200
+ if not name:
201
+ continue
202
+ commands.append(
203
+ {
204
+ "name": name,
205
+ "framework": "argparse",
206
+ "help": _string_kwarg(node, "help") or "",
207
+ "options": [],
208
+ "path": path,
209
+ "line": node.lineno,
210
+ }
211
+ )
212
+ return commands
213
+
214
+
215
+ def find_cli_commands(file_contents: dict[str, str], *, limit: int = 60) -> dict:
216
+ """Collect the CLI sub-commands a project exposes from its source.
217
+
218
+ Args:
219
+ file_contents: mapping of path to file text (as CodeABC already read it).
220
+ limit: how many commands to return in the sorted list.
221
+
222
+ Returns ``{"total", "frameworks", "commands"}`` where each command is
223
+ ``{"name", "framework", "help", "options", "path", "line"}`` and
224
+ ``framework`` is one of ``click`` / ``typer`` / ``argparse``.
225
+ """
226
+ seen: set[tuple[str, str]] = set()
227
+ commands: list[dict] = []
228
+
229
+ for path, content in file_contents.items():
230
+ if not content or not path.endswith(".py"):
231
+ continue
232
+ try:
233
+ tree = ast.parse(content)
234
+ except (SyntaxError, ValueError):
235
+ continue
236
+
237
+ _, _, _, argparse_imp = _scan_imports_and_apps(tree)
238
+ found = _decorated_commands(tree, path)
239
+ if argparse_imp:
240
+ found += _argparse_commands(tree, path)
241
+
242
+ for cmd in found:
243
+ key = (cmd["path"], cmd["name"])
244
+ if key in seen:
245
+ continue
246
+ seen.add(key)
247
+ commands.append(cmd)
248
+
249
+ commands.sort(key=lambda c: (c["path"], c["line"]))
250
+ frameworks = sorted({c["framework"] for c in commands})
251
+ return {"total": len(commands), "frameworks": frameworks, "commands": commands[:limit]}
252
+
253
+
254
+ def render_commands_markdown(project_name: str, data: dict | None) -> str:
255
+ """Render the CLI command surface as Markdown, or ``""`` if none were found."""
256
+ commands = (data or {}).get("commands") or []
257
+ if not commands:
258
+ return ""
259
+
260
+ lines = [
261
+ f"# {project_name} — 能运行哪些命令(CLI 命令)",
262
+ "",
263
+ "> 入口点告诉你“用哪个命令启动”,这里告诉你“启动后能敲哪些子命令”——"
264
+ "作者用 argparse / click / typer 声明的子命令,以及每个命令的一句话说明。",
265
+ "",
266
+ ]
267
+ for cmd in commands:
268
+ head = f"- `{cmd['name']}`"
269
+ if cmd["help"]:
270
+ head += f" — {cmd['help']}"
271
+ head += f" ({cmd['framework']},{cmd['path']})"
272
+ lines.append(head)
273
+ if cmd["options"]:
274
+ lines.append(f" - 选项:{', '.join(f'`{o}`' for o in cmd['options'])}")
275
+
276
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,111 @@
1
+ """Logic complexity: which files are the hardest to follow.
2
+
3
+ The import-graph maps say which files are *central*; churn says which change
4
+ *often*; this says which carry the most tangled *logic* — the files packed with
5
+ branches, loops and nested conditions that a reader has to hold in their head.
6
+ For someone learning a codebase, "where is the gnarliest logic" is a different
7
+ and useful question from "what's central" or "what changes a lot".
8
+
9
+ It approximates cyclomatic complexity from the Python AST: every decision point
10
+ (an ``if``, ``for``, ``while``, ``except``, a boolean ``and``/``or``, a ternary,
11
+ a comprehension filter, a ``match`` case) is one more independent path through
12
+ the code, so more of them means more to reason about. ``scan_complexity`` is
13
+ pure over the file contents and parses with the standard library, so it needs no
14
+ repository; non-Python files and files that don't parse are skipped.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+
21
+ # AST node types that introduce an extra branch (one more independent path).
22
+ _BRANCH_NODES = (
23
+ ast.If,
24
+ ast.For,
25
+ ast.AsyncFor,
26
+ ast.While,
27
+ ast.ExceptHandler,
28
+ ast.With,
29
+ ast.AsyncWith,
30
+ ast.IfExp, # a ... if cond else b
31
+ ast.Assert,
32
+ ast.comprehension, # the `for` clause of a comprehension
33
+ ast.match_case,
34
+ )
35
+
36
+
37
+ def _file_complexity(tree: ast.AST) -> tuple[int, int]:
38
+ """Return (complexity, function_count) for a parsed module.
39
+
40
+ Complexity starts at 1 for the module's straight-line path and gains one for
41
+ each decision point; each boolean operator chain adds one per extra operand
42
+ (``a and b and c`` is two more branches), and each comprehension ``if`` adds
43
+ one too.
44
+ """
45
+ complexity = 1
46
+ functions = 0
47
+ for node in ast.walk(tree):
48
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
49
+ functions += 1
50
+ complexity += 1
51
+ elif isinstance(node, _BRANCH_NODES):
52
+ complexity += 1
53
+ elif isinstance(node, ast.BoolOp):
54
+ # `a and b and c` has two operators after the first => two branches.
55
+ complexity += len(node.values) - 1
56
+ elif isinstance(node, ast.ListComp | ast.SetComp | ast.DictComp | ast.GeneratorExp):
57
+ # Filters inside the comprehension; the `for` clauses are counted as
58
+ # ast.comprehension above.
59
+ complexity += sum(len(gen.ifs) for gen in node.generators)
60
+ return complexity, functions
61
+
62
+
63
+ def scan_complexity(file_contents: dict[str, str], *, limit: int = 15) -> dict:
64
+ """Rank the project's Python files by approximate logic complexity.
65
+
66
+ Args:
67
+ file_contents: mapping of path to file text (as CodeABC already read it).
68
+ limit: how many of the most complex files to return.
69
+
70
+ Returns ``{"total", "files"}`` where ``files`` is the most complex Python
71
+ files (descending), each ``{"path", "complexity", "functions", "reason"}``.
72
+ """
73
+ scored: list[dict] = []
74
+ for path, content in file_contents.items():
75
+ if not content or not path.endswith(".py"):
76
+ continue
77
+ try:
78
+ tree = ast.parse(content)
79
+ except (SyntaxError, ValueError):
80
+ continue
81
+ complexity, functions = _file_complexity(tree)
82
+ if complexity <= 1:
83
+ continue # straight-line file, nothing to flag
84
+ reason = (
85
+ f"约 {complexity} 个判断分支,{functions} 个函数"
86
+ if functions
87
+ else f"约 {complexity} 个判断分支"
88
+ )
89
+ scored.append(
90
+ {"path": path, "complexity": complexity, "functions": functions, "reason": reason}
91
+ )
92
+
93
+ scored.sort(key=lambda f: (-f["complexity"], f["path"]))
94
+ return {"total": len(scored), "files": scored[:limit]}
95
+
96
+
97
+ def render_complexity_markdown(project_name: str, data: dict | None) -> str:
98
+ """Render the complexity ranking as Markdown, or ``""`` if none."""
99
+ files = (data or {}).get("files") or []
100
+ if not files:
101
+ return ""
102
+
103
+ lines = [
104
+ f"# {project_name} — 逻辑复杂度(最难看懂的文件)",
105
+ "",
106
+ "> 按代码里「要分情况讨论」的地方(条件判断、循环、异常处理等)多少排序——"
107
+ "分支越多,逻辑越绕、越要小心读。这和“被很多文件依赖”“改得勤”是不同的角度。",
108
+ "",
109
+ ]
110
+ lines.extend(f"- `{f['path']}` — {f['reason']}(复杂度 {f['complexity']})" for f in files)
111
+ return "\n".join(lines).rstrip() + "\n"