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
backend/services/risk.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Risk hotspots: fuse static centrality with change frequency.
|
|
2
|
+
|
|
3
|
+
The import graph says which files are *central* (lots of things depend on
|
|
4
|
+
them); the git-history churn says which files *change a lot*. Neither alone
|
|
5
|
+
tells you where defects concentrate — but a file that is BOTH heavily
|
|
6
|
+
depended on AND frequently changed is exactly where a regression hurts most
|
|
7
|
+
and where review/tests pay off first. This module joins the two existing
|
|
8
|
+
analyses (no recomputation) and ranks that intersection.
|
|
9
|
+
|
|
10
|
+
Pure functions over the outputs of ``importgraph.rank_hotspots`` and
|
|
11
|
+
``churn.analyze_churn`` — unit-testable with plain dicts, no repo needed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _risk_reason(fan_in: int, commits: int) -> str:
|
|
18
|
+
if fan_in >= 5 and commits >= 10:
|
|
19
|
+
return (
|
|
20
|
+
f"{fan_in} 个文件依赖它、又改了 {commits} 次——既是核心又高频变动,"
|
|
21
|
+
"是 bug 最容易出的地方,审查和回归测试优先级最高。"
|
|
22
|
+
)
|
|
23
|
+
return f"{fan_in} 个文件依赖它、改了 {commits} 次:核心且在动,改它要格外小心、最好先补测试。"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def rank_risk(
|
|
27
|
+
import_hotspots: list[dict],
|
|
28
|
+
churn_hotspots: list[dict],
|
|
29
|
+
*,
|
|
30
|
+
limit: int = 8,
|
|
31
|
+
) -> list[dict]:
|
|
32
|
+
"""Rank files that are both depended-on (central) and frequently changed.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
import_hotspots: ``importgraph.rank_hotspots`` output — dicts with
|
|
36
|
+
``path`` and ``fan_in``.
|
|
37
|
+
churn_hotspots: ``churn.analyze_churn`` ``hotspots`` — dicts with
|
|
38
|
+
``path`` and ``commits``.
|
|
39
|
+
limit: how many to return.
|
|
40
|
+
|
|
41
|
+
Returns ``{"path", "fan_in", "commits", "score", "reason"}`` for files
|
|
42
|
+
present in BOTH inputs, sorted by a 0-100 risk score (high fan-in AND high
|
|
43
|
+
churn score highest). Files in only one input are not risk hotspots.
|
|
44
|
+
"""
|
|
45
|
+
fan_in = {h["path"]: h.get("fan_in", 0) for h in import_hotspots}
|
|
46
|
+
commits = {h["path"]: h.get("commits", 0) for h in churn_hotspots}
|
|
47
|
+
|
|
48
|
+
shared = [p for p in commits if p in fan_in]
|
|
49
|
+
if not shared:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
max_fan = max((fan_in[p] for p in shared), default=0) or 1
|
|
53
|
+
max_commits = max((commits[p] for p in shared), default=0) or 1
|
|
54
|
+
|
|
55
|
+
ranked = []
|
|
56
|
+
for path in shared:
|
|
57
|
+
f, c = fan_in[path], commits[path]
|
|
58
|
+
# normalised product: rewards being high on BOTH axes, not just one
|
|
59
|
+
score = round((f / max_fan) * (c / max_commits) * 100)
|
|
60
|
+
ranked.append(
|
|
61
|
+
{
|
|
62
|
+
"path": path,
|
|
63
|
+
"fan_in": f,
|
|
64
|
+
"commits": c,
|
|
65
|
+
"score": score,
|
|
66
|
+
"reason": _risk_reason(f, c),
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
ranked.sort(key=lambda r: (-r["score"], -r["commits"], r["path"]))
|
|
70
|
+
return ranked[:limit]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_risk_markdown(risk_hotspots: list[dict]) -> str:
|
|
74
|
+
"""Render the risk hotspots as a Markdown section, or ``""`` if empty."""
|
|
75
|
+
if not risk_hotspots:
|
|
76
|
+
return ""
|
|
77
|
+
lines = [
|
|
78
|
+
"# 风险清单(核心 × 高频变动)",
|
|
79
|
+
"",
|
|
80
|
+
"> 既被很多文件依赖、又改得频繁的文件——bug 最容易出在这里,优先审查和补测试。",
|
|
81
|
+
"",
|
|
82
|
+
]
|
|
83
|
+
lines.extend(
|
|
84
|
+
f"- `{r['path']}` — 风险 {r['score']}"
|
|
85
|
+
f"({r['fan_in']} 处依赖 · 改 {r['commits']} 次):{r['reason']}"
|
|
86
|
+
for r in risk_hotspots
|
|
87
|
+
)
|
|
88
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Scan a project directory and collect file info for LLM analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from fnmatch import fnmatch
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# dirs we never want to look at
|
|
13
|
+
_SKIP_DIRS = {
|
|
14
|
+
".git",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
".venv",
|
|
18
|
+
"venv",
|
|
19
|
+
"env",
|
|
20
|
+
".idea",
|
|
21
|
+
".vscode",
|
|
22
|
+
".next",
|
|
23
|
+
"dist",
|
|
24
|
+
"build",
|
|
25
|
+
".tox",
|
|
26
|
+
".mypy_cache",
|
|
27
|
+
".pytest_cache",
|
|
28
|
+
".ruff_cache",
|
|
29
|
+
"egg-info",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# binary / non-text extensions
|
|
33
|
+
_SKIP_EXTS = {
|
|
34
|
+
".png",
|
|
35
|
+
".jpg",
|
|
36
|
+
".jpeg",
|
|
37
|
+
".gif",
|
|
38
|
+
".bmp",
|
|
39
|
+
".ico",
|
|
40
|
+
".svg",
|
|
41
|
+
".webp",
|
|
42
|
+
".mp3",
|
|
43
|
+
".mp4",
|
|
44
|
+
".wav",
|
|
45
|
+
".avi",
|
|
46
|
+
".mov",
|
|
47
|
+
".mkv",
|
|
48
|
+
".zip",
|
|
49
|
+
".tar",
|
|
50
|
+
".gz",
|
|
51
|
+
".bz2",
|
|
52
|
+
".7z",
|
|
53
|
+
".rar",
|
|
54
|
+
".exe",
|
|
55
|
+
".dll",
|
|
56
|
+
".so",
|
|
57
|
+
".dylib",
|
|
58
|
+
".bin",
|
|
59
|
+
".dat",
|
|
60
|
+
".woff",
|
|
61
|
+
".woff2",
|
|
62
|
+
".ttf",
|
|
63
|
+
".eot",
|
|
64
|
+
".otf",
|
|
65
|
+
".pyc",
|
|
66
|
+
".pyo",
|
|
67
|
+
".class",
|
|
68
|
+
".o",
|
|
69
|
+
".obj",
|
|
70
|
+
".pdf",
|
|
71
|
+
".doc",
|
|
72
|
+
".docx",
|
|
73
|
+
".xls",
|
|
74
|
+
".xlsx",
|
|
75
|
+
".db",
|
|
76
|
+
".sqlite",
|
|
77
|
+
".sqlite3",
|
|
78
|
+
".lock", # package-lock.json etc are huge and useless
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
_GENERATED_NAME_SUFFIXES = (
|
|
82
|
+
".min.js",
|
|
83
|
+
".min.css",
|
|
84
|
+
".bundle.js",
|
|
85
|
+
".bundle.css",
|
|
86
|
+
".chunk.js",
|
|
87
|
+
".chunk.css",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
_ENV_EXAMPLE_FILES = {
|
|
91
|
+
".env.example",
|
|
92
|
+
".env.sample",
|
|
93
|
+
".env.template",
|
|
94
|
+
".env.defaults",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_SENSITIVE_FILENAMES = {
|
|
98
|
+
".npmrc",
|
|
99
|
+
".pypirc",
|
|
100
|
+
".netrc",
|
|
101
|
+
"credentials.json",
|
|
102
|
+
"secrets.json",
|
|
103
|
+
"service-account.json",
|
|
104
|
+
"id_rsa",
|
|
105
|
+
"id_dsa",
|
|
106
|
+
"id_ecdsa",
|
|
107
|
+
"id_ed25519",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_SENSITIVE_DIRS = {
|
|
111
|
+
".aws",
|
|
112
|
+
".azure",
|
|
113
|
+
".gcloud",
|
|
114
|
+
".gnupg",
|
|
115
|
+
".ssh",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_SENSITIVE_NAME_PATTERNS = (
|
|
119
|
+
"*api_key*",
|
|
120
|
+
"*api-key*",
|
|
121
|
+
"*apikey*",
|
|
122
|
+
"*credential*",
|
|
123
|
+
"*secret*",
|
|
124
|
+
"*access_token*",
|
|
125
|
+
"*auth_token*",
|
|
126
|
+
"*refresh_token*",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
_SENSITIVE_EXTS = {
|
|
130
|
+
".key",
|
|
131
|
+
".pem",
|
|
132
|
+
".p12",
|
|
133
|
+
".pfx",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# max file size to read (100 KB)
|
|
137
|
+
_MAX_FILE_SIZE = 100 * 1024
|
|
138
|
+
|
|
139
|
+
# how many lines to keep per file for overview context
|
|
140
|
+
_PREVIEW_LINES = 80
|
|
141
|
+
|
|
142
|
+
# hard cap on number of files we'll process
|
|
143
|
+
_MAX_FILES = 500
|
|
144
|
+
|
|
145
|
+
# map extensions -> human readable language name
|
|
146
|
+
_LANG_MAP = {
|
|
147
|
+
".py": "python",
|
|
148
|
+
".js": "javascript",
|
|
149
|
+
".ts": "typescript",
|
|
150
|
+
".jsx": "jsx",
|
|
151
|
+
".tsx": "tsx",
|
|
152
|
+
".html": "html",
|
|
153
|
+
".css": "css",
|
|
154
|
+
".json": "json",
|
|
155
|
+
".yaml": "yaml",
|
|
156
|
+
".yml": "yaml",
|
|
157
|
+
".md": "markdown",
|
|
158
|
+
".txt": "text",
|
|
159
|
+
".sh": "shell",
|
|
160
|
+
".go": "go",
|
|
161
|
+
".rs": "rust",
|
|
162
|
+
".java": "java",
|
|
163
|
+
".c": "c",
|
|
164
|
+
".cpp": "cpp",
|
|
165
|
+
".h": "c",
|
|
166
|
+
".hpp": "cpp",
|
|
167
|
+
".rb": "ruby",
|
|
168
|
+
".php": "php",
|
|
169
|
+
".r": "r",
|
|
170
|
+
".R": "r",
|
|
171
|
+
".do": "stata",
|
|
172
|
+
".sql": "sql",
|
|
173
|
+
".toml": "toml",
|
|
174
|
+
".cfg": "ini",
|
|
175
|
+
".ini": "ini",
|
|
176
|
+
".env": "text",
|
|
177
|
+
".swift": "swift",
|
|
178
|
+
".kt": "kotlin",
|
|
179
|
+
".scala": "scala",
|
|
180
|
+
".lua": "lua",
|
|
181
|
+
".dart": "dart",
|
|
182
|
+
".vue": "vue",
|
|
183
|
+
".svelte": "svelte",
|
|
184
|
+
".zig": "zig",
|
|
185
|
+
".nim": "nim",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# config files that should be read in full (they help LLM understand the project)
|
|
189
|
+
_CONFIG_FILES = {
|
|
190
|
+
"requirements.txt",
|
|
191
|
+
"setup.py",
|
|
192
|
+
"setup.cfg",
|
|
193
|
+
"pyproject.toml",
|
|
194
|
+
"package.json",
|
|
195
|
+
"Cargo.toml",
|
|
196
|
+
"go.mod",
|
|
197
|
+
"Makefile",
|
|
198
|
+
"Dockerfile",
|
|
199
|
+
"docker-compose.yml",
|
|
200
|
+
"docker-compose.yaml",
|
|
201
|
+
".env.example",
|
|
202
|
+
"config.py",
|
|
203
|
+
"config.yaml",
|
|
204
|
+
"config.json",
|
|
205
|
+
"README.md",
|
|
206
|
+
"README.rst",
|
|
207
|
+
"README.txt",
|
|
208
|
+
"README",
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
_ENTRYPOINT_NAMES = {
|
|
212
|
+
"main.py",
|
|
213
|
+
"app.py",
|
|
214
|
+
"__main__.py",
|
|
215
|
+
"index.py",
|
|
216
|
+
"main.ts",
|
|
217
|
+
"main.tsx",
|
|
218
|
+
"index.ts",
|
|
219
|
+
"index.tsx",
|
|
220
|
+
"main.js",
|
|
221
|
+
"index.js",
|
|
222
|
+
"main.go",
|
|
223
|
+
"main.rs",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_MANIFEST_NAMES = {
|
|
227
|
+
"pyproject.toml",
|
|
228
|
+
"package.json",
|
|
229
|
+
"requirements.txt",
|
|
230
|
+
"go.mod",
|
|
231
|
+
"cargo.toml",
|
|
232
|
+
"dockerfile",
|
|
233
|
+
"makefile",
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _has_generated_name(path: str | Path) -> bool:
|
|
238
|
+
name = Path(path).name.lower()
|
|
239
|
+
return name.endswith(_GENERATED_NAME_SUFFIXES)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _in_skipped_dir(path: str | Path) -> bool:
|
|
243
|
+
"""True if the file sits inside a directory we never scan.
|
|
244
|
+
|
|
245
|
+
scan_directory prunes these while walking; uploaded files arrive as a flat
|
|
246
|
+
list of paths (e.g. "node_modules/x/y.js"), so they need the same check or a
|
|
247
|
+
dropped-in folder full of dependencies would drown out the real source.
|
|
248
|
+
"""
|
|
249
|
+
parts = Path(str(path).replace("\\", "/")).parts
|
|
250
|
+
return any(part in _SKIP_DIRS or part.endswith(".egg-info") for part in parts[:-1])
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _looks_sensitive_path(path: str | Path) -> bool:
|
|
254
|
+
p = Path(path)
|
|
255
|
+
parts = [part.lower() for part in p.parts]
|
|
256
|
+
name = p.name.lower()
|
|
257
|
+
|
|
258
|
+
if any(part in _SENSITIVE_DIRS for part in parts):
|
|
259
|
+
return True
|
|
260
|
+
if name.startswith(".env") and name not in _ENV_EXAMPLE_FILES:
|
|
261
|
+
return True
|
|
262
|
+
if name in _SENSITIVE_FILENAMES:
|
|
263
|
+
return True
|
|
264
|
+
if p.suffix.lower() in _SENSITIVE_EXTS:
|
|
265
|
+
return True
|
|
266
|
+
return any(fnmatch(name, pattern) for pattern in _SENSITIVE_NAME_PATTERNS)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _looks_like_generated_bundle(text: str, language: str) -> bool:
|
|
270
|
+
if language not in {"javascript", "typescript", "jsx", "tsx", "css"}:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
lines = text.splitlines()
|
|
274
|
+
if not lines:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
longest = max(len(line.strip()) for line in lines)
|
|
278
|
+
avg = sum(len(line.strip()) for line in lines) / len(lines)
|
|
279
|
+
return longest > 2000 or (len(lines) <= 5 and avg > 500)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_binary(data: bytes) -> bool:
|
|
283
|
+
"""Quick check: if there's a null byte in the first 1024 bytes, it's binary."""
|
|
284
|
+
return b"\x00" in data[:1024]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def build_reading_map(files: list[dict], limit: int = 8) -> list[dict]:
|
|
288
|
+
"""Recommend a deterministic first reading path without an LLM call."""
|
|
289
|
+
|
|
290
|
+
def classify(file: dict) -> tuple[int, str]:
|
|
291
|
+
path = file["path"].replace("\\", "/")
|
|
292
|
+
lower = path.lower()
|
|
293
|
+
name = Path(lower).name
|
|
294
|
+
parts = lower.split("/")
|
|
295
|
+
|
|
296
|
+
if name in {"readme", "readme.md", "readme.rst", "readme.txt"}:
|
|
297
|
+
return 0, "先看项目作者写的介绍和使用说明。"
|
|
298
|
+
if name in _ENTRYPOINT_NAMES:
|
|
299
|
+
return 1, "这很可能是程序开始运行的入口。"
|
|
300
|
+
if name in _MANIFEST_NAMES:
|
|
301
|
+
return 2, "这里记录了依赖、常用命令或项目打包方式。"
|
|
302
|
+
if "test" in parts or "tests" in parts or name.startswith("test_"):
|
|
303
|
+
return 5, "测试展示了项目承诺保持的具体行为。"
|
|
304
|
+
if parts[0] in {"src", "app", "backend", "frontend", "lib", "pkg", "cmd"}:
|
|
305
|
+
return 3, "这是项目主要功能的实现代码。"
|
|
306
|
+
if file.get("language") in {"markdown", "yaml", "json", "toml", "ini"}:
|
|
307
|
+
return 4, "这是辅助说明或配置文件。"
|
|
308
|
+
return 4, "这是项目的辅助文件。"
|
|
309
|
+
|
|
310
|
+
ranked = []
|
|
311
|
+
for file in files:
|
|
312
|
+
category, reason = classify(file)
|
|
313
|
+
depth = file["path"].count("/") + file["path"].count("\\")
|
|
314
|
+
ranked.append((category, depth, file["path"], reason))
|
|
315
|
+
|
|
316
|
+
return [
|
|
317
|
+
{"order": order, "path": path, "reason": reason}
|
|
318
|
+
for order, (_, _, path, reason) in enumerate(sorted(ranked)[:limit], start=1)
|
|
319
|
+
]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _detect_language(path: str) -> str:
|
|
323
|
+
ext = Path(path).suffix.lower()
|
|
324
|
+
return _LANG_MAP.get(ext, "unknown")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _is_safe_path(rel_path: str) -> bool:
|
|
328
|
+
"""Reject paths that try to escape the project root."""
|
|
329
|
+
normalized = os.path.normpath(rel_path)
|
|
330
|
+
return not normalized.startswith("..") and not os.path.isabs(normalized)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _read_gitignore_patterns(root: Path) -> list[str]:
|
|
334
|
+
gitignore = root / ".gitignore"
|
|
335
|
+
if not gitignore.exists():
|
|
336
|
+
return []
|
|
337
|
+
try:
|
|
338
|
+
lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
339
|
+
except OSError:
|
|
340
|
+
return []
|
|
341
|
+
return [line.strip() for line in lines if line.strip() and not line.lstrip().startswith("#")]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _matches_gitignore(rel_path: str, is_dir: bool, raw_pattern: str) -> bool:
|
|
345
|
+
pattern = raw_pattern.strip().replace("\\", "/")
|
|
346
|
+
if not pattern or pattern.startswith("!"):
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
directory_only = pattern.endswith("/")
|
|
350
|
+
anchored = pattern.startswith("/")
|
|
351
|
+
pattern = pattern.strip("/")
|
|
352
|
+
if not pattern:
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
if directory_only:
|
|
356
|
+
return (
|
|
357
|
+
rel_path == pattern
|
|
358
|
+
or rel_path.startswith(pattern + "/")
|
|
359
|
+
or (not anchored and f"/{pattern}/" in f"/{rel_path}/")
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
if anchored or "/" in pattern:
|
|
363
|
+
return fnmatch(rel_path, pattern) or (not anchored and fnmatch(rel_path, f"*/{pattern}"))
|
|
364
|
+
|
|
365
|
+
parts = rel_path.split("/")
|
|
366
|
+
if is_dir:
|
|
367
|
+
return any(fnmatch(part, pattern) for part in parts)
|
|
368
|
+
return fnmatch(parts[-1], pattern) or any(fnmatch(part, pattern) for part in parts[:-1])
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _is_gitignored(root: Path, path: Path, is_dir: bool, patterns: list[str]) -> bool:
|
|
372
|
+
try:
|
|
373
|
+
rel_path = path.relative_to(root).as_posix()
|
|
374
|
+
except ValueError:
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
ignored = False
|
|
378
|
+
for raw in patterns:
|
|
379
|
+
negated = raw.startswith("!")
|
|
380
|
+
pattern = raw[1:] if negated else raw
|
|
381
|
+
if _matches_gitignore(rel_path, is_dir, pattern):
|
|
382
|
+
ignored = not negated
|
|
383
|
+
return ignored
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def scan_directory(root: str | Path) -> list[dict]:
|
|
387
|
+
"""Walk a project directory and return file metadata + previews.
|
|
388
|
+
|
|
389
|
+
Returns list of dicts:
|
|
390
|
+
{"path": relative_path, "size": bytes, "language": str, "preview": str}
|
|
391
|
+
"""
|
|
392
|
+
root = Path(root).resolve()
|
|
393
|
+
results = []
|
|
394
|
+
gitignore_patterns = _read_gitignore_patterns(root)
|
|
395
|
+
|
|
396
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
397
|
+
current = Path(dirpath)
|
|
398
|
+
# prune skipped dirs in-place so os.walk won't descend into them
|
|
399
|
+
dirnames[:] = [
|
|
400
|
+
d
|
|
401
|
+
for d in dirnames
|
|
402
|
+
if d not in _SKIP_DIRS
|
|
403
|
+
and not d.endswith(".egg-info")
|
|
404
|
+
and not _is_gitignored(root, current / d, is_dir=True, patterns=gitignore_patterns)
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
for fname in filenames:
|
|
408
|
+
if len(results) >= _MAX_FILES:
|
|
409
|
+
logger.info("Hit file limit (%d), stopping scan", _MAX_FILES)
|
|
410
|
+
break
|
|
411
|
+
|
|
412
|
+
full = Path(dirpath) / fname
|
|
413
|
+
rel = str(full.relative_to(root))
|
|
414
|
+
|
|
415
|
+
# skip symlinks to prevent escaping the project root
|
|
416
|
+
if full.is_symlink():
|
|
417
|
+
continue
|
|
418
|
+
if _looks_sensitive_path(rel):
|
|
419
|
+
continue
|
|
420
|
+
if _is_gitignored(root, full, is_dir=False, patterns=gitignore_patterns):
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
# skip by extension
|
|
424
|
+
if full.suffix.lower() in _SKIP_EXTS or _has_generated_name(full):
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
# skip oversized files
|
|
428
|
+
try:
|
|
429
|
+
size = full.stat().st_size
|
|
430
|
+
except OSError:
|
|
431
|
+
continue
|
|
432
|
+
if size > _MAX_FILE_SIZE:
|
|
433
|
+
continue
|
|
434
|
+
if size == 0:
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
# try reading
|
|
438
|
+
try:
|
|
439
|
+
raw = full.read_bytes()
|
|
440
|
+
except OSError:
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
if _is_binary(raw):
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
text = raw.decode("utf-8", errors="replace")
|
|
448
|
+
except Exception:
|
|
449
|
+
continue
|
|
450
|
+
|
|
451
|
+
lang = _detect_language(rel)
|
|
452
|
+
if _looks_like_generated_bundle(text, lang):
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# config files get full content, others get preview
|
|
456
|
+
is_config = fname in _CONFIG_FILES
|
|
457
|
+
if is_config:
|
|
458
|
+
preview = text
|
|
459
|
+
else:
|
|
460
|
+
lines = text.splitlines()
|
|
461
|
+
preview = "\n".join(lines[:_PREVIEW_LINES])
|
|
462
|
+
|
|
463
|
+
results.append(
|
|
464
|
+
{
|
|
465
|
+
"path": rel,
|
|
466
|
+
"size": size,
|
|
467
|
+
"language": lang,
|
|
468
|
+
"preview": preview,
|
|
469
|
+
}
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
if len(results) >= _MAX_FILES:
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
# sort: config/readme first, then by path
|
|
476
|
+
def sort_key(f):
|
|
477
|
+
name = Path(f["path"]).name
|
|
478
|
+
if name in _CONFIG_FILES:
|
|
479
|
+
return (0, f["path"])
|
|
480
|
+
return (1, f["path"])
|
|
481
|
+
|
|
482
|
+
results.sort(key=sort_key)
|
|
483
|
+
return results
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def scan_uploaded_files(files: list[dict]) -> list[dict]:
|
|
487
|
+
"""Process files uploaded from frontend (already have path + content).
|
|
488
|
+
|
|
489
|
+
Input: [{"path": str, "content": str}]
|
|
490
|
+
Output: same format as scan_directory
|
|
491
|
+
"""
|
|
492
|
+
results = []
|
|
493
|
+
for f in files:
|
|
494
|
+
if len(results) >= _MAX_FILES:
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
path = f["path"]
|
|
498
|
+
content = f["content"]
|
|
499
|
+
|
|
500
|
+
# reject suspicious paths
|
|
501
|
+
if not _is_safe_path(path):
|
|
502
|
+
logger.warning("Skipping unsafe path: %s", path)
|
|
503
|
+
continue
|
|
504
|
+
if _looks_sensitive_path(path):
|
|
505
|
+
continue
|
|
506
|
+
if _in_skipped_dir(path):
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
ext = Path(path).suffix.lower()
|
|
510
|
+
if ext in _SKIP_EXTS or _has_generated_name(path):
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# skip oversized content
|
|
514
|
+
if len(content) > _MAX_FILE_SIZE:
|
|
515
|
+
continue
|
|
516
|
+
|
|
517
|
+
lang = _detect_language(path)
|
|
518
|
+
if _looks_like_generated_bundle(content, lang):
|
|
519
|
+
continue
|
|
520
|
+
|
|
521
|
+
fname = Path(path).name
|
|
522
|
+
is_config = fname in _CONFIG_FILES
|
|
523
|
+
|
|
524
|
+
if is_config:
|
|
525
|
+
preview = content
|
|
526
|
+
else:
|
|
527
|
+
lines = content.splitlines()
|
|
528
|
+
preview = "\n".join(lines[:_PREVIEW_LINES])
|
|
529
|
+
|
|
530
|
+
results.append(
|
|
531
|
+
{
|
|
532
|
+
"path": path,
|
|
533
|
+
"size": len(content.encode()),
|
|
534
|
+
"language": lang,
|
|
535
|
+
"preview": preview,
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
def sort_key(f):
|
|
540
|
+
name = Path(f["path"]).name
|
|
541
|
+
if name in _CONFIG_FILES:
|
|
542
|
+
return (0, f["path"])
|
|
543
|
+
return (1, f["path"])
|
|
544
|
+
|
|
545
|
+
results.sort(key=sort_key)
|
|
546
|
+
return results
|