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