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,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"
|