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,239 @@
|
|
|
1
|
+
"""Silent failures: where the code catches an error and then does nothing.
|
|
2
|
+
|
|
3
|
+
Tests answer "is this code verified?"; docs answer "is this code explained?".
|
|
4
|
+
This answers a third question that scares anyone inheriting a codebase: "where
|
|
5
|
+
does this code *hide its own problems*?" A caught error that is thrown away --
|
|
6
|
+
a bare ``except:``, an ``except ...: pass``, an empty ``catch {}`` -- means a
|
|
7
|
+
real failure can happen in production and leave no exception, no log, no trace.
|
|
8
|
+
Those are the spots where a bug can live for months without anyone noticing,
|
|
9
|
+
and they don't show up in a test-coverage or complexity report.
|
|
10
|
+
|
|
11
|
+
:func:`find_swallowed_errors` walks the already-read file contents and flags
|
|
12
|
+
three high-confidence, syntactic patterns (no full parser, just an honest
|
|
13
|
+
line/indentation heuristic):
|
|
14
|
+
|
|
15
|
+
* Python bare ``except:`` -- catches everything, including ``KeyboardInterrupt``
|
|
16
|
+
and ``SystemExit``, and can't tell one error from another.
|
|
17
|
+
* Python ``except ...:`` whose whole body is just ``pass`` or ``...``.
|
|
18
|
+
* JS/TS empty ``catch (e) {}`` / ``catch {}``.
|
|
19
|
+
|
|
20
|
+
It deliberately does NOT flag a ``catch`` whose body is a comment (that is a
|
|
21
|
+
*documented* decision to ignore) or an ``except`` that actually does something,
|
|
22
|
+
to keep the signal high and the noise low.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
_PY = {"py", "pyi"}
|
|
30
|
+
_JSTS = {"js", "jsx", "ts", "tsx", "mjs", "cjs", "mts", "cts"}
|
|
31
|
+
|
|
32
|
+
_TEST_SEGMENTS = {"test", "tests", "__tests__", "spec", "specs"}
|
|
33
|
+
|
|
34
|
+
# A Python ``except`` clause: capture the exception type (empty for a bare
|
|
35
|
+
# ``except:``) and anything written inline after the colon.
|
|
36
|
+
_EXCEPT_RE = re.compile(r"except\b\s*([^:]*):(.*)$")
|
|
37
|
+
# An empty JS/TS catch on one line: ``catch {}`` / ``catch (e) {}``.
|
|
38
|
+
_INLINE_EMPTY_CATCH_RE = re.compile(r"catch\s*(\([^)]*\))?\s*\{\s*\}")
|
|
39
|
+
# A catch that opens a block at end of line: ``catch (e) {``.
|
|
40
|
+
_OPEN_CATCH_RE = re.compile(r"catch\s*(\([^)]*\))?\s*\{\s*$")
|
|
41
|
+
|
|
42
|
+
_NOOP_BODIES = {"pass", "...", "pass # noqa", "pass # noqa"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ext(path: str) -> str:
|
|
46
|
+
name = path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
47
|
+
return name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_test(path: str) -> bool:
|
|
51
|
+
norm = path.replace("\\", "/")
|
|
52
|
+
segments = [s for s in norm.split("/") if s]
|
|
53
|
+
if any(s in _TEST_SEGMENTS for s in segments):
|
|
54
|
+
return True
|
|
55
|
+
name = segments[-1] if segments else ""
|
|
56
|
+
stem = name.rsplit(".", 1)[0] if "." in name else name
|
|
57
|
+
return stem.startswith("test_") or stem.endswith("_test") or ".test" in name or ".spec" in name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _indent(line: str) -> int:
|
|
61
|
+
return len(line) - len(line.lstrip())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _except_body_is_noop(lines: list[str], idx: int) -> bool:
|
|
65
|
+
"""True when the block opened by the ``except`` at ``idx`` is only pass/...."""
|
|
66
|
+
base_indent = _indent(lines[idx])
|
|
67
|
+
body: list[str] = []
|
|
68
|
+
for j in range(idx + 1, len(lines)):
|
|
69
|
+
line = lines[j]
|
|
70
|
+
if not line.strip():
|
|
71
|
+
continue
|
|
72
|
+
if _indent(line) <= base_indent:
|
|
73
|
+
break # dedented: the except block is over
|
|
74
|
+
stripped = line.strip()
|
|
75
|
+
if stripped.startswith("#"):
|
|
76
|
+
continue # a comment is a documented choice; don't count it as the body
|
|
77
|
+
body.append(stripped)
|
|
78
|
+
return len(body) == 1 and body[0] in _NOOP_BODIES
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _scan_python(path: str, content: str) -> list[dict]:
|
|
82
|
+
out: list[dict] = []
|
|
83
|
+
lines = content.splitlines()
|
|
84
|
+
for i, raw in enumerate(lines):
|
|
85
|
+
stripped = raw.strip()
|
|
86
|
+
m = _EXCEPT_RE.match(stripped)
|
|
87
|
+
if not m:
|
|
88
|
+
continue
|
|
89
|
+
exc_type = m.group(1).strip()
|
|
90
|
+
inline_body = m.group(2).strip()
|
|
91
|
+
line_no = i + 1
|
|
92
|
+
|
|
93
|
+
if exc_type == "":
|
|
94
|
+
out.append(
|
|
95
|
+
{
|
|
96
|
+
"path": path,
|
|
97
|
+
"line": line_no,
|
|
98
|
+
"category": "bare_except",
|
|
99
|
+
"snippet": stripped[:120],
|
|
100
|
+
"reason": "`except:` 捕获所有异常(连 Ctrl+C、系统退出都接住),又不区分错误"
|
|
101
|
+
"类型,真出问题时会被无差别吞掉、很难定位。",
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# `except X: pass` written on one line, or a block whose body is only pass/...
|
|
107
|
+
swallowed = inline_body in _NOOP_BODIES or (
|
|
108
|
+
(inline_body == "" or inline_body.startswith("#")) and _except_body_is_noop(lines, i)
|
|
109
|
+
)
|
|
110
|
+
if swallowed:
|
|
111
|
+
out.append(
|
|
112
|
+
{
|
|
113
|
+
"path": path,
|
|
114
|
+
"line": line_no,
|
|
115
|
+
"category": "swallowed",
|
|
116
|
+
"snippet": stripped[:120],
|
|
117
|
+
"reason": "捕获到错误后只写了 `pass`,等于把问题悄悄丢掉:程序不报错、日志"
|
|
118
|
+
"不留痕,bug 发生了也没人会知道。",
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _scan_jsts(path: str, content: str) -> list[dict]:
|
|
125
|
+
out: list[dict] = []
|
|
126
|
+
lines = content.splitlines()
|
|
127
|
+
for i, raw in enumerate(lines):
|
|
128
|
+
stripped = raw.strip()
|
|
129
|
+
if _INLINE_EMPTY_CATCH_RE.search(stripped):
|
|
130
|
+
hit = True
|
|
131
|
+
elif _OPEN_CATCH_RE.search(stripped):
|
|
132
|
+
# multi-line: the catch opens a block; flag only if the next
|
|
133
|
+
# non-blank line closes it immediately (a truly empty body).
|
|
134
|
+
hit = False
|
|
135
|
+
for j in range(i + 1, len(lines)):
|
|
136
|
+
nxt = lines[j].strip()
|
|
137
|
+
if not nxt:
|
|
138
|
+
continue
|
|
139
|
+
hit = nxt.startswith("}")
|
|
140
|
+
break
|
|
141
|
+
else:
|
|
142
|
+
continue
|
|
143
|
+
if hit:
|
|
144
|
+
out.append(
|
|
145
|
+
{
|
|
146
|
+
"path": path,
|
|
147
|
+
"line": i + 1,
|
|
148
|
+
"category": "empty_catch",
|
|
149
|
+
"snippet": stripped[:120],
|
|
150
|
+
"reason": "`catch` 块是空的,错误被接住后什么都不做,出问题时不会报错也"
|
|
151
|
+
"不留痕迹。",
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Worst-first: a bare except hides the most, an empty catch the least.
|
|
158
|
+
_SEVERITY = {"bare_except": 0, "swallowed": 1, "empty_catch": 2}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def find_swallowed_errors(file_contents: dict[str, str], *, limit: int = 15) -> dict:
|
|
162
|
+
"""Find places that catch an error and silently drop it.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
file_contents: mapping of path to file text (as CodeABC already read it).
|
|
166
|
+
limit: how many findings to return in the ranked list.
|
|
167
|
+
|
|
168
|
+
Returns::
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
"total": int, # all findings across the project
|
|
172
|
+
"files_affected": int, # distinct files with at least one
|
|
173
|
+
"findings": [ # ranked worst-first, capped at limit
|
|
174
|
+
{"path", "line", "category", "snippet", "reason"}, ...
|
|
175
|
+
],
|
|
176
|
+
"notes": [str, ...],
|
|
177
|
+
}
|
|
178
|
+
"""
|
|
179
|
+
findings: list[dict] = []
|
|
180
|
+
for path, content in file_contents.items():
|
|
181
|
+
if not content or _is_test(path):
|
|
182
|
+
continue
|
|
183
|
+
ext = _ext(path)
|
|
184
|
+
if ext in _PY:
|
|
185
|
+
findings.extend(_scan_python(path, content))
|
|
186
|
+
elif ext in _JSTS:
|
|
187
|
+
findings.extend(_scan_jsts(path, content))
|
|
188
|
+
|
|
189
|
+
findings.sort(key=lambda f: (_SEVERITY.get(f["category"], 9), f["path"], f["line"]))
|
|
190
|
+
files_affected = len({f["path"] for f in findings})
|
|
191
|
+
|
|
192
|
+
notes: list[str] = []
|
|
193
|
+
if not findings:
|
|
194
|
+
notes.append("没有发现被静默吞掉的错误,错误处理这块比较稳。")
|
|
195
|
+
elif len(findings) > limit:
|
|
196
|
+
notes.append(f"共发现 {len(findings)} 处,下面按严重程度只列前 {limit} 处。")
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
"total": len(findings),
|
|
200
|
+
"files_affected": files_affected,
|
|
201
|
+
"findings": findings[:limit],
|
|
202
|
+
"notes": notes,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_CATEGORY_LABEL = {
|
|
207
|
+
"bare_except": "裸 except(吞掉一切)",
|
|
208
|
+
"swallowed": "捕获后只 pass",
|
|
209
|
+
"empty_catch": "空 catch 块",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def render_error_handling_markdown(project_name: str, data: dict | None) -> str:
|
|
214
|
+
"""Render the silent-failures map as a Markdown section, or ``""``."""
|
|
215
|
+
d = data or {}
|
|
216
|
+
total = d.get("total", 0)
|
|
217
|
+
if not total:
|
|
218
|
+
return ""
|
|
219
|
+
|
|
220
|
+
lines = [
|
|
221
|
+
f"# {project_name} — 静默失败的地方",
|
|
222
|
+
"",
|
|
223
|
+
f"> 发现 {total} 处「错误被接住后悄悄丢掉」的代码,分布在 {d.get('files_affected', 0)} "
|
|
224
|
+
"个文件里。这些地方一旦出问题,既不会报错也不会留日志,是 bug 最容易长期藏身的角落。",
|
|
225
|
+
"",
|
|
226
|
+
"## 最该补错误处理的地方",
|
|
227
|
+
"",
|
|
228
|
+
]
|
|
229
|
+
for f in d.get("findings") or []:
|
|
230
|
+
label = _CATEGORY_LABEL.get(f["category"], f["category"])
|
|
231
|
+
lines.append(f"- `{f['path']}:{f['line']}`({label})— {f['reason']}")
|
|
232
|
+
|
|
233
|
+
notes = d.get("notes") or []
|
|
234
|
+
if notes:
|
|
235
|
+
lines += ["", "## 注意事项", ""]
|
|
236
|
+
for n in notes:
|
|
237
|
+
lines.append(f"- {n}")
|
|
238
|
+
|
|
239
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Filename meanings: explain what a file *is* from its name alone.
|
|
2
|
+
|
|
3
|
+
A non-programmer dropped into a repository trips over filenames as much as over
|
|
4
|
+
code. ``conftest.py``, ``serializers.py``, ``__init__.py``, ``Dockerfile`` — each
|
|
5
|
+
is a wall of jargon before a single line is read. Developers recognise these by
|
|
6
|
+
convention; everyone else has to guess.
|
|
7
|
+
|
|
8
|
+
This is a curated dictionary of those conventions (same plain-language voice as
|
|
9
|
+
:mod:`backend.services.glossary`). :func:`explain_path` takes a file path and
|
|
10
|
+
returns a short "what this file usually is" note, matching from most specific to
|
|
11
|
+
least: an exact well-known filename, then a directory convention, then a
|
|
12
|
+
stem/suffix pattern, and finally a bare extension. It depends only on the path —
|
|
13
|
+
no file contents, no LLM — so it is instant, free, and unit-testable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
# --- Exact filenames (highest priority) ----------------------------------
|
|
21
|
+
# Keyed by lowercase basename. Each value is (kind, explanation).
|
|
22
|
+
_EXACT: dict[str, tuple[str, str]] = {
|
|
23
|
+
"__init__.py": (
|
|
24
|
+
"包标记",
|
|
25
|
+
"告诉 Python「这个文件夹是一个包」,可以被别处 import。常常是空的,空着也完全正常。",
|
|
26
|
+
),
|
|
27
|
+
"__main__.py": (
|
|
28
|
+
"运行入口",
|
|
29
|
+
"用 `python -m 包名` 跑这个包时,实际执行的就是这里,相当于这个包的「开始按钮」。",
|
|
30
|
+
),
|
|
31
|
+
"conftest.py": (
|
|
32
|
+
"测试配置",
|
|
33
|
+
"pytest 的共享测试配置:里面的夹具(fixture)会在跑测试时自动加载,不用手动 import。它不是项目功能代码。",
|
|
34
|
+
),
|
|
35
|
+
"setup.py": (
|
|
36
|
+
"打包脚本",
|
|
37
|
+
"老式的「怎么把这个项目打包安装」说明,定义项目名、版本、依赖。多数已被 pyproject.toml 取代。",
|
|
38
|
+
),
|
|
39
|
+
"setup.cfg": (
|
|
40
|
+
"打包/工具配置",
|
|
41
|
+
"项目的打包信息和一些工具(如 flake8)的配置,纯文本键值对,不是运行代码。",
|
|
42
|
+
),
|
|
43
|
+
"pyproject.toml": (
|
|
44
|
+
"项目说明书",
|
|
45
|
+
"现代 Python 项目的「身份证+配置中心」:项目名、版本、依赖、打包方式、各种工具设置都写在这。",
|
|
46
|
+
),
|
|
47
|
+
"requirements.txt": (
|
|
48
|
+
"依赖清单",
|
|
49
|
+
"这个项目运行所需要的第三方库列表,`pip install -r` 会照着它一次装齐。",
|
|
50
|
+
),
|
|
51
|
+
"package.json": (
|
|
52
|
+
"项目说明书",
|
|
53
|
+
"JavaScript/Node 项目的「身份证+配置中心」:项目名、版本、依赖库、可运行的脚本命令都在这里。",
|
|
54
|
+
),
|
|
55
|
+
"package-lock.json": (
|
|
56
|
+
"依赖锁定",
|
|
57
|
+
"把每个依赖的精确版本钉死,保证别人装出来和你一模一样。机器生成的,一般不用手改。",
|
|
58
|
+
),
|
|
59
|
+
"yarn.lock": (
|
|
60
|
+
"依赖锁定",
|
|
61
|
+
"和 package-lock.json 作用一样,是 yarn 这个工具生成的依赖版本锁定文件,一般不用手改。",
|
|
62
|
+
),
|
|
63
|
+
"tsconfig.json": (
|
|
64
|
+
"TS 配置",
|
|
65
|
+
"TypeScript 的编译配置:告诉编译器用哪个语法版本、哪些目录算源码、严格到什么程度。",
|
|
66
|
+
),
|
|
67
|
+
"dockerfile": (
|
|
68
|
+
"打包成镜像",
|
|
69
|
+
"一份「怎么把这个项目装进一个标准化集装箱(容器)」的步骤说明,照着它能在任何机器上一键跑起来。",
|
|
70
|
+
),
|
|
71
|
+
"docker-compose.yml": (
|
|
72
|
+
"多容器编排",
|
|
73
|
+
"一次性启动并连接好多个容器(比如「后端+数据库+缓存」)的配置,省得一个个手动启动。",
|
|
74
|
+
),
|
|
75
|
+
"makefile": (
|
|
76
|
+
"命令快捷表",
|
|
77
|
+
"把一串常用命令起个短名字(如 `make test`),敲一下就跑一整套,省得记长命令。",
|
|
78
|
+
),
|
|
79
|
+
".gitignore": (
|
|
80
|
+
"忽略清单",
|
|
81
|
+
"告诉 Git「这些文件不要纳入版本管理」(如临时文件、密钥、依赖目录),避免误传上去。",
|
|
82
|
+
),
|
|
83
|
+
".env": (
|
|
84
|
+
"环境变量/密钥",
|
|
85
|
+
"存放配置和敏感信息(数据库地址、API 密钥等)的本地文件。通常不该提交到仓库里。",
|
|
86
|
+
),
|
|
87
|
+
".env.example": (
|
|
88
|
+
"配置模板",
|
|
89
|
+
"`.env` 的样板:列出需要填哪些配置项但留空,方便别人照着复制一份自己的。",
|
|
90
|
+
),
|
|
91
|
+
"readme.md": (
|
|
92
|
+
"项目简介",
|
|
93
|
+
"项目的「门面」:这是什么、能干嘛、怎么安装运行。打开一个陌生项目,先读它。",
|
|
94
|
+
),
|
|
95
|
+
"license": (
|
|
96
|
+
"授权协议",
|
|
97
|
+
"规定别人可以怎么使用、修改、分发这份代码的法律条款。",
|
|
98
|
+
),
|
|
99
|
+
"changelog.md": (
|
|
100
|
+
"更新日志",
|
|
101
|
+
"按版本记录「每次更新改了什么」的流水账,想知道某个功能哪个版本加的就看它。",
|
|
102
|
+
),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# --- Directory conventions: a path segment that gives the file its role ---
|
|
106
|
+
# Keyed by lowercase directory name appearing anywhere in the path.
|
|
107
|
+
_DIR_HINTS: dict[str, tuple[str, str]] = {
|
|
108
|
+
"migrations": (
|
|
109
|
+
"数据库变更",
|
|
110
|
+
"数据库结构的「变更记录」:每个文件是一次「给表加一列 / 改个字段」的步骤,按顺序累积。一般是工具生成的,不用手读。",
|
|
111
|
+
),
|
|
112
|
+
"tests": (
|
|
113
|
+
"测试代码",
|
|
114
|
+
"用来自动检查功能对不对的代码,不影响项目正常运行;它是项目的「质检员」。",
|
|
115
|
+
),
|
|
116
|
+
"test": (
|
|
117
|
+
"测试代码",
|
|
118
|
+
"用来自动检查功能对不对的代码,不影响项目正常运行;它是项目的「质检员」。",
|
|
119
|
+
),
|
|
120
|
+
"__tests__": (
|
|
121
|
+
"测试代码",
|
|
122
|
+
"用来自动检查功能对不对的代码(JS 项目常用这个文件夹名),不影响项目正常运行。",
|
|
123
|
+
),
|
|
124
|
+
"node_modules": (
|
|
125
|
+
"第三方库",
|
|
126
|
+
"自动下载的所有依赖库存放处,体积大、机器生成,几乎从不需要手动看或改。",
|
|
127
|
+
),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Stem / suffix conventions (matched on the basename) ------------------
|
|
132
|
+
# Order matters: first match wins. Each entry is (predicate, kind, explanation).
|
|
133
|
+
def _stem_rules() -> list[tuple[re.Pattern[str], str, str]]:
|
|
134
|
+
return [
|
|
135
|
+
(
|
|
136
|
+
re.compile(r"^test_.*\.py$|.*_test\.py$"),
|
|
137
|
+
"测试代码",
|
|
138
|
+
"一个测试文件:自动检查对应功能是否正确,不是项目本身运行的代码。",
|
|
139
|
+
),
|
|
140
|
+
(
|
|
141
|
+
re.compile(r".*\.(test|spec)\.(t|j)sx?$"),
|
|
142
|
+
"测试代码",
|
|
143
|
+
"一个测试文件:自动检查对应功能是否正确,不是项目本身运行的代码。",
|
|
144
|
+
),
|
|
145
|
+
(
|
|
146
|
+
re.compile(r"^models?\.py$|^models?\.(t|j)sx?$"),
|
|
147
|
+
"数据结构",
|
|
148
|
+
"定义「数据长什么样」的地方:常对应数据库里的表或核心业务对象的字段。",
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
re.compile(r"^views?\.py$"),
|
|
152
|
+
"页面/接口逻辑",
|
|
153
|
+
"处理「收到一个请求后做什么、返回什么」的逻辑,是前端和数据之间的中间层。",
|
|
154
|
+
),
|
|
155
|
+
(
|
|
156
|
+
re.compile(r"^(urls?|routes?|router)\.(py|ts|js)$"),
|
|
157
|
+
"地址路由表",
|
|
158
|
+
"网址(路由)对照表:规定「访问哪个地址,就交给哪段代码处理」。",
|
|
159
|
+
),
|
|
160
|
+
(
|
|
161
|
+
re.compile(r"^serializers?\.py$"),
|
|
162
|
+
"数据转换",
|
|
163
|
+
"负责把程序内部的对象和对外的传输格式(如 JSON)来回转换,进出口的「翻译官」。",
|
|
164
|
+
),
|
|
165
|
+
(
|
|
166
|
+
re.compile(r"^schemas?\.(py|ts|js)$"),
|
|
167
|
+
"数据格式定义",
|
|
168
|
+
"定义数据应有的字段和类型,用来校验「传进来的数据合不合规矩」。",
|
|
169
|
+
),
|
|
170
|
+
(
|
|
171
|
+
re.compile(r"^(settings?|config|configuration)\.(py|ts|js)$"),
|
|
172
|
+
"配置",
|
|
173
|
+
"项目的各种设置项集中地(数据库地址、开关、密钥等),改行为多半从这里改。",
|
|
174
|
+
),
|
|
175
|
+
(
|
|
176
|
+
re.compile(r"^constants?\.(py|ts|js)$"),
|
|
177
|
+
"常量",
|
|
178
|
+
"把固定不变的值(如默认数字、固定字符串)集中起来取好名字,避免到处硬写。",
|
|
179
|
+
),
|
|
180
|
+
(
|
|
181
|
+
re.compile(r"^(utils?|helpers?)\.(py|ts|js)$"),
|
|
182
|
+
"工具函数",
|
|
183
|
+
"一堆零散的小工具函数集合,被项目各处反复借用,没有单一主线。",
|
|
184
|
+
),
|
|
185
|
+
(
|
|
186
|
+
re.compile(r"^index\.(t|j)sx?$"),
|
|
187
|
+
"目录入口",
|
|
188
|
+
"这个文件夹的「总入口/总开关」:别处 import 这个文件夹时,默认进来的就是它。",
|
|
189
|
+
),
|
|
190
|
+
(
|
|
191
|
+
re.compile(r".*\.d\.ts$"),
|
|
192
|
+
"类型声明",
|
|
193
|
+
"只描述「类型长什么样」、不含实际逻辑的 TypeScript 声明文件,给编辑器和编译器看的。",
|
|
194
|
+
),
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_STEM_RULES = _stem_rules()
|
|
199
|
+
|
|
200
|
+
# --- Bare extension fallback ----------------------------------------------
|
|
201
|
+
_EXT: dict[str, tuple[str, str]] = {
|
|
202
|
+
".py": ("Python 源码", "一段 Python 程序代码。"),
|
|
203
|
+
".ts": ("TypeScript 源码", "一段 TypeScript 程序代码(带类型的 JavaScript)。"),
|
|
204
|
+
".tsx": ("React 组件", "一个用 TypeScript 写的 React 界面组件,对应页面上的一块 UI。"),
|
|
205
|
+
".js": ("JavaScript 源码", "一段 JavaScript 程序代码。"),
|
|
206
|
+
".jsx": ("React 组件", "一个用 JavaScript 写的 React 界面组件,对应页面上的一块 UI。"),
|
|
207
|
+
".md": ("说明文档", "用 Markdown 写的文字说明文档,不是运行代码。"),
|
|
208
|
+
".json": ("数据/配置", "用 JSON 格式存的数据或配置,纯文本的键值结构。"),
|
|
209
|
+
".yml": ("配置", "用 YAML 格式写的配置文件,常见于 CI、部署、各类工具设置。"),
|
|
210
|
+
".yaml": ("配置", "用 YAML 格式写的配置文件,常见于 CI、部署、各类工具设置。"),
|
|
211
|
+
".toml": ("配置", "用 TOML 格式写的配置文件,常见于 Python 项目设置。"),
|
|
212
|
+
".sh": ("脚本", "一段在终端里跑的 Shell 脚本,通常用来安装、构建或部署。"),
|
|
213
|
+
".sql": ("数据库语句", "操作数据库的 SQL 语句(查询、建表、改数据等)。"),
|
|
214
|
+
".css": ("样式", "控制页面长相(颜色、间距、字体等)的样式文件。"),
|
|
215
|
+
".html": ("网页结构", "网页的骨架结构文件,决定页面上有哪些元素。"),
|
|
216
|
+
".txt": ("纯文本", "一个纯文本文件。"),
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def explain_path(path: str) -> dict | None:
|
|
221
|
+
"""Explain what a file *is* from its name, or ``None`` if nothing fits.
|
|
222
|
+
|
|
223
|
+
Returns ``{"name", "kind", "explanation"}`` where ``name`` is the basename,
|
|
224
|
+
``kind`` is a short category label, and ``explanation`` is a plain-language
|
|
225
|
+
sentence. Matching runs most-specific first: exact filename, then directory
|
|
226
|
+
convention, then stem/suffix pattern, then bare extension.
|
|
227
|
+
"""
|
|
228
|
+
if not path:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
posix = path.replace("\\", "/")
|
|
232
|
+
name = posix.rsplit("/", 1)[-1]
|
|
233
|
+
lower = name.lower()
|
|
234
|
+
|
|
235
|
+
# 1. Exact well-known filename.
|
|
236
|
+
if lower in _EXACT:
|
|
237
|
+
kind, explanation = _EXACT[lower]
|
|
238
|
+
return {"name": name, "kind": kind, "explanation": explanation}
|
|
239
|
+
|
|
240
|
+
# 2. Stem/suffix pattern (before directory, so test_x.py reads as a test
|
|
241
|
+
# file rather than just "something in a tests/ folder").
|
|
242
|
+
for pattern, kind, explanation in _STEM_RULES:
|
|
243
|
+
if pattern.match(lower):
|
|
244
|
+
return {"name": name, "kind": kind, "explanation": explanation}
|
|
245
|
+
|
|
246
|
+
# 3. Directory convention anywhere in the path.
|
|
247
|
+
segments = {seg.lower() for seg in posix.split("/")[:-1]}
|
|
248
|
+
for seg in segments:
|
|
249
|
+
if seg in _DIR_HINTS:
|
|
250
|
+
kind, explanation = _DIR_HINTS[seg]
|
|
251
|
+
return {"name": name, "kind": kind, "explanation": explanation}
|
|
252
|
+
|
|
253
|
+
# 4. Bare extension fallback.
|
|
254
|
+
dot = lower.rfind(".")
|
|
255
|
+
if dot > 0:
|
|
256
|
+
ext = lower[dot:]
|
|
257
|
+
if ext in _EXT:
|
|
258
|
+
kind, explanation = _EXT[ext]
|
|
259
|
+
return {"name": name, "kind": kind, "explanation": explanation}
|
|
260
|
+
|
|
261
|
+
return None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Clone GitHub repositories with safety limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
# 500 MB max repo size after clone
|
|
14
|
+
_MAX_REPO_SIZE_MB = 500
|
|
15
|
+
_CLONE_TIMEOUT = 120 # seconds
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _repo_dir_size(path: Path) -> int:
|
|
19
|
+
"""Total size in bytes of all files under path."""
|
|
20
|
+
total = 0
|
|
21
|
+
for f in path.rglob("*"):
|
|
22
|
+
if f.is_file():
|
|
23
|
+
total += f.stat().st_size
|
|
24
|
+
return total
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# A bare "owner/repo" shorthand: a GitHub login (alphanumerics + hyphens) and a
|
|
28
|
+
# repo name (which may contain dots, underscores, hyphens), nothing else.
|
|
29
|
+
_SHORTHAND = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]*/[A-Za-z0-9._-]+$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_github_url(url: str) -> tuple[str, str]:
|
|
33
|
+
"""Extract (owner, repo) from the many shapes people paste.
|
|
34
|
+
|
|
35
|
+
Handles the plain repo URL, a link pointing somewhere *inside* the repo
|
|
36
|
+
(``/tree/main``, ``/blob/...``, ``?tab=stars``, ``#readme`` — what you get by
|
|
37
|
+
copying the browser address bar), the ``.git`` suffix, the ``git@`` SSH form,
|
|
38
|
+
and the bare ``owner/repo`` shorthand. Rejects non-GitHub hosts.
|
|
39
|
+
"""
|
|
40
|
+
text = url.strip()
|
|
41
|
+
|
|
42
|
+
ssh = re.match(r"^git@github\.com:(.+)$", text, re.IGNORECASE)
|
|
43
|
+
if ssh:
|
|
44
|
+
path = ssh.group(1)
|
|
45
|
+
elif "://" not in text and "github.com" not in text.lower() and _SHORTHAND.match(text):
|
|
46
|
+
path = text
|
|
47
|
+
else:
|
|
48
|
+
parsed = urlparse(text if "://" in text else "https://" + text)
|
|
49
|
+
host = (parsed.hostname or "").lower()
|
|
50
|
+
if host not in ("github.com", "www.github.com"):
|
|
51
|
+
raise ValueError(f"Only GitHub repositories are supported: {url}")
|
|
52
|
+
path = parsed.path
|
|
53
|
+
|
|
54
|
+
# A query string or fragment may still ride along in the SSH/shorthand paths.
|
|
55
|
+
path = path.split("?")[0].split("#")[0].strip("/")
|
|
56
|
+
if path.lower().endswith(".git"):
|
|
57
|
+
path = path[:-4]
|
|
58
|
+
|
|
59
|
+
parts = [p for p in path.split("/") if p]
|
|
60
|
+
if len(parts) < 2:
|
|
61
|
+
raise ValueError(f"Cannot find owner/repo in: {url}")
|
|
62
|
+
return parts[0], parts[1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def clone_repo(url: str) -> Path:
|
|
66
|
+
"""Shallow-clone a GitHub repo and return the local path.
|
|
67
|
+
|
|
68
|
+
Raises ValueError if the repo is too large or clone fails.
|
|
69
|
+
The caller is responsible for cleaning up the directory when done.
|
|
70
|
+
"""
|
|
71
|
+
owner, repo = _parse_github_url(url)
|
|
72
|
+
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
|
|
73
|
+
dest = Path(tempfile.gettempdir()) / "codeabc_repos" / f"{owner}_{repo}_{url_hash}"
|
|
74
|
+
|
|
75
|
+
# reuse if already cloned
|
|
76
|
+
if dest.exists() and (dest / ".git").exists():
|
|
77
|
+
return dest
|
|
78
|
+
|
|
79
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# normalize URL to https
|
|
82
|
+
clone_url = f"https://github.com/{owner}/{repo}.git"
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
proc = await asyncio.create_subprocess_exec(
|
|
86
|
+
"git",
|
|
87
|
+
"clone",
|
|
88
|
+
"--depth",
|
|
89
|
+
"1",
|
|
90
|
+
clone_url,
|
|
91
|
+
str(dest),
|
|
92
|
+
stdout=asyncio.subprocess.PIPE,
|
|
93
|
+
stderr=asyncio.subprocess.PIPE,
|
|
94
|
+
)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
# git isn't installed — the clone path can't work. Say so plainly
|
|
97
|
+
# instead of letting a raw OSError become a 500. (Uploading a folder
|
|
98
|
+
# still works without git.)
|
|
99
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"Git isn't installed, so cloning from a URL won't work. "
|
|
102
|
+
"Install Git from https://git-scm.com, or upload the project folder instead."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=_CLONE_TIMEOUT)
|
|
107
|
+
except asyncio.TimeoutError:
|
|
108
|
+
proc.kill()
|
|
109
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
110
|
+
raise ValueError(f"Clone timed out after {_CLONE_TIMEOUT}s: {url}")
|
|
111
|
+
|
|
112
|
+
if proc.returncode != 0:
|
|
113
|
+
err_msg = stderr.decode(errors="replace").strip()
|
|
114
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
115
|
+
raise ValueError(f"Git clone failed: {err_msg}")
|
|
116
|
+
|
|
117
|
+
# check size
|
|
118
|
+
size_mb = _repo_dir_size(dest) / (1024 * 1024)
|
|
119
|
+
if size_mb > _MAX_REPO_SIZE_MB:
|
|
120
|
+
shutil.rmtree(dest, ignore_errors=True)
|
|
121
|
+
raise ValueError(f"Repo too large ({size_mb:.0f} MB > {_MAX_REPO_SIZE_MB} MB limit)")
|
|
122
|
+
|
|
123
|
+
return dest
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def cleanup_repo(path: Path) -> None:
|
|
127
|
+
"""Remove a cloned repo directory."""
|
|
128
|
+
if path.exists():
|
|
129
|
+
shutil.rmtree(path, ignore_errors=True)
|