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