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,505 @@
1
+ """Open-source license map — what a project is licensed under, and what that lets you do.
2
+
3
+ The other maps answer questions about the *code*. This one answers the question a
4
+ non-programmer asks before they ever read a line of it: **am I actually allowed to
5
+ use this — in a side project, in something I sell, after I change it?** A repository's
6
+ license is the legal answer, and it is usually buried in a ``LICENSE`` file written in
7
+ dense legalese, or hidden as one line in ``pyproject.toml`` / ``package.json``. Worse,
8
+ a project with *no* license is not "free to use" — by default it is *all rights
9
+ reserved*, which most people get exactly backwards.
10
+
11
+ This module reads what CodeABC already loaded — no LLM, nothing to run — and reports:
12
+
13
+ * the project's own license, recognised from the ``LICENSE`` file's wording or the
14
+ ``license`` field / SPDX tag / trove classifier in its manifest;
15
+ * which plain-language family it belongs to (宽松型 / 弱著佐权 / 强著佐权 /
16
+ 网络著佐权 / 公有领域 / 源码可见但非自由), and what that family means for the
17
+ four questions people actually have — 能不能商用、能不能闭源用、改了要不要公开、
18
+ 会不会"传染"你自己的项目;
19
+ * a blunt warning when no license is found at all.
20
+
21
+ :func:`find_licenses` is pure over the file contents, so it is unit-testable with
22
+ plain strings and needs no repository.
23
+
24
+ Limitations (kept honest on purpose):
25
+
26
+ * Recognition is signature based, not a legal parse. A heavily edited license, a
27
+ dual-license expression, or an obscure license this module does not know will come
28
+ back as "未识别" rather than guessed at. The first recognised license in priority
29
+ order is reported as the primary one.
30
+ * Only the *project's own* license is detected. The licenses of its dependencies are
31
+ not resolved — a manifest lists dependency *names*, not their licenses — so this is
32
+ not a compliance audit of the whole dependency tree.
33
+ * Everything here is a plain-language summary to orient a newcomer, **not legal
34
+ advice**. For anything that matters, read the license text or ask a lawyer.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+
41
+ # Plain-language obligations per license family. Each value answers the same four
42
+ # questions in the same order so the render stays a stable, scannable table.
43
+ _CATEGORY = {
44
+ "permissive": {
45
+ "label": "宽松型",
46
+ "one_line": "几乎想怎么用都行:商用、改造、闭源都可以,只要保留原作者的版权和许可声明。",
47
+ "commercial": "可以",
48
+ "private_use": "可以(能用在闭源 / 商业产品里)",
49
+ "share_changes": "不必(你的改动和新增代码可以不公开)",
50
+ "copyleft_scope": "不会,不影响你自己的代码",
51
+ "keep_notice": "保留原作者的版权声明和许可证全文",
52
+ },
53
+ "weak-copyleft": {
54
+ "label": "弱著佐权(文件 / 库级)",
55
+ "one_line": (
56
+ "可以商用,但你改动它原有文件的部分要按同样许可公开;你自己新写的文件可以闭源。"
57
+ ),
58
+ "commercial": "可以",
59
+ "private_use": "可以",
60
+ "share_changes": "改动了它本身的源文件要公开,你新增的独立文件不用",
61
+ "copyleft_scope": "只波及这个库 / 这些文件,不波及你的整个项目",
62
+ "keep_notice": "保留版权与许可声明,并标明你的改动",
63
+ },
64
+ "strong-copyleft": {
65
+ "label": "强著佐权",
66
+ "one_line": (
67
+ "可以商用、可以改,但只要把成品分发出去,整个衍生项目都得按同样的许可一起开源。"
68
+ ),
69
+ "commercial": "可以,但要连同开源义务一起",
70
+ "private_use": "自己内部用、不对外分发,可以不开源",
71
+ "share_changes": "一旦分发,必须公开包含改动在内的全部源码",
72
+ "copyleft_scope": "会传染——和它合并 / 链接的代码通常都要一起开源",
73
+ "keep_notice": "保留版权与许可声明,并标明你的改动",
74
+ },
75
+ "network-copyleft": {
76
+ "label": "网络著佐权",
77
+ "one_line": (
78
+ "和强著佐权一样,而且更进一步:哪怕只是放在服务器上联网提供服务,也要把源码给用户。"
79
+ ),
80
+ "commercial": "可以,但要连同开源义务一起",
81
+ "private_use": "纯自己用、不提供给任何外部用户才不触发",
82
+ "share_changes": "分发或联网提供服务都要公开全部源码",
83
+ "copyleft_scope": "最强——连 SaaS / 网络服务都会触发开源义务",
84
+ "keep_notice": "保留版权与许可声明,并标明你的改动",
85
+ },
86
+ "public-domain": {
87
+ "label": "公有领域 / 近似放弃版权",
88
+ "one_line": "作者基本放弃了权利,你几乎可以无条件使用,通常连署名都不强制。",
89
+ "commercial": "可以",
90
+ "private_use": "可以",
91
+ "share_changes": "不必",
92
+ "copyleft_scope": "不会",
93
+ "keep_notice": "通常不强制(保留出处仍是好习惯)",
94
+ },
95
+ "source-available": {
96
+ "label": "源码可见但非自由许可",
97
+ "one_line": (
98
+ "源码能看,但不等于能随便用。这类许可常限制商用,"
99
+ "或禁止拿去做与原产品竞争的服务,务必先读清楚条款。"
100
+ ),
101
+ "commercial": "受限——按条款,多数禁止与原产品竞争或限定用途",
102
+ "private_use": "通常可以,但有时附带时间或用途限制",
103
+ "share_changes": "看具体条款",
104
+ "copyleft_scope": "看具体条款",
105
+ "keep_notice": "保留声明,并遵守额外的使用限制",
106
+ },
107
+ "unknown": {
108
+ "label": "未识别",
109
+ "one_line": "扫描到了许可证,但没认出是哪一种——请人工读一下原文,或联系作者确认。",
110
+ "commercial": "需人工确认",
111
+ "private_use": "需人工确认",
112
+ "share_changes": "需人工确认",
113
+ "copyleft_scope": "需人工确认",
114
+ "keep_notice": "需人工确认",
115
+ },
116
+ }
117
+ _CATEGORY_ORDER = [
118
+ "permissive",
119
+ "weak-copyleft",
120
+ "strong-copyleft",
121
+ "network-copyleft",
122
+ "public-domain",
123
+ "source-available",
124
+ "unknown",
125
+ ]
126
+
127
+ # SPDX id -> (English name, plain-Chinese name, family). The id is the key the
128
+ # manifest / tag / classifier paths normalise to, so the three sources agree.
129
+ _LICENSES = {
130
+ "MIT": ("MIT License", "MIT 许可证", "permissive"),
131
+ "Apache-2.0": ("Apache License 2.0", "Apache 2.0 许可证", "permissive"),
132
+ "BSD-2-Clause": ("BSD 2-Clause", "BSD 两条款许可证", "permissive"),
133
+ "BSD-3-Clause": ("BSD 3-Clause", "BSD 三条款许可证", "permissive"),
134
+ "ISC": ("ISC License", "ISC 许可证", "permissive"),
135
+ "BSL-1.0": ("Boost Software License 1.0", "Boost 软件许可证", "permissive"),
136
+ "Zlib": ("zlib License", "zlib 许可证", "permissive"),
137
+ "Python-2.0": ("Python Software Foundation License", "Python PSF 许可证", "permissive"),
138
+ "MPL-2.0": ("Mozilla Public License 2.0", "Mozilla 公共许可证 2.0", "weak-copyleft"),
139
+ "EPL-2.0": ("Eclipse Public License 2.0", "Eclipse 公共许可证 2.0", "weak-copyleft"),
140
+ "LGPL-2.1": ("GNU LGPL v2.1", "GNU 宽通用公共许可证 2.1", "weak-copyleft"),
141
+ "LGPL-3.0": ("GNU LGPL v3.0", "GNU 宽通用公共许可证 3.0", "weak-copyleft"),
142
+ "GPL-2.0": ("GNU GPL v2.0", "GNU 通用公共许可证 2.0", "strong-copyleft"),
143
+ "GPL-3.0": ("GNU GPL v3.0", "GNU 通用公共许可证 3.0", "strong-copyleft"),
144
+ "AGPL-3.0": ("GNU AGPL v3.0", "GNU Affero 通用公共许可证 3.0", "network-copyleft"),
145
+ "Unlicense": ("The Unlicense", "Unlicense(公有领域)", "public-domain"),
146
+ "CC0-1.0": ("Creative Commons Zero 1.0", "CC0 公有领域奉献", "public-domain"),
147
+ "0BSD": ("BSD Zero Clause License", "BSD 零条款许可证", "public-domain"),
148
+ "WTFPL": ("Do What The F*ck You Want To Public License", "WTFPL(随便用)", "public-domain"),
149
+ "BUSL-1.1": ("Business Source License 1.1", "商业源码许可证 1.1", "source-available"),
150
+ "SSPL-1.0": ("Server Side Public License 1.0", "服务器端公共许可证 1.0", "source-available"),
151
+ "Elastic-2.0": ("Elastic License 2.0", "Elastic 许可证 2.0", "source-available"),
152
+ }
153
+
154
+ # Plain-language gloss for where a finding came from.
155
+ _SOURCE_LABEL = {
156
+ "license-file": "许可证文件",
157
+ "manifest": "项目清单的 license 字段",
158
+ "classifier": "项目清单的分类标签",
159
+ "spdx-tag": "源码里的 SPDX 标记",
160
+ }
161
+
162
+ # License-file body signatures, tried in order; the first to match a file wins. The
163
+ # AFFERO / LESSER variants are listed before the plain GPL because their text also
164
+ # contains the words "GNU GENERAL PUBLIC LICENSE", and the more specific ISC grant is
165
+ # listed before the bare 0BSD grant it would otherwise swallow.
166
+ _TEXT_SIGNATURES: list[tuple[str, str]] = [
167
+ ("AGPL-3.0", r"GNU AFFERO GENERAL PUBLIC LICENSE"),
168
+ ("LGPL-3.0", r"GNU LESSER GENERAL PUBLIC LICENSE.{0,400}Version 3"),
169
+ ("LGPL-2.1", r"GNU LESSER GENERAL PUBLIC LICENSE"),
170
+ ("GPL-3.0", r"GNU GENERAL PUBLIC LICENSE.{0,400}Version 3"),
171
+ ("GPL-2.0", r"GNU GENERAL PUBLIC LICENSE.{0,400}Version 2"),
172
+ ("Apache-2.0", r"Apache License.{0,40}Version 2\.0"),
173
+ ("MPL-2.0", r"Mozilla Public License.{0,40}Version 2\.0"),
174
+ ("EPL-2.0", r"Eclipse Public License.{0,40}v(?:ersion )?2\.0"),
175
+ ("BSL-1.0", r"Boost Software License"),
176
+ ("BUSL-1.1", r"Business Source License"),
177
+ ("SSPL-1.0", r"Server Side Public License"),
178
+ ("Elastic-2.0", r"Elastic License 2\.0"),
179
+ ("BSD-3-Clause", r"Redistribution and use in source and binary forms.{0,1500}Neither the name"),
180
+ ("BSD-2-Clause", r"Redistribution and use in source and binary forms"),
181
+ ("MIT", r"Permission is hereby granted, free of charge, to any person obtaining a copy"),
182
+ (
183
+ "ISC",
184
+ r"Permission to use, copy, modify, and(?:/or)? distribute this software for any "
185
+ r"purpose with or without fee is hereby granted, provided that",
186
+ ),
187
+ ("0BSD", r"Permission to use, copy, modify, and/or distribute this software for any purpose"),
188
+ ("Unlicense", r"This is free and unencumbered software released into the public domain"),
189
+ ("CC0-1.0", r"CC0 1\.0 Universal|Creative Commons.{0,80}CC0"),
190
+ ("WTFPL", r"DO WHAT THE F[^\n]{0,4}CK YOU WANT TO PUBLIC LICENSE"),
191
+ ]
192
+ _COMPILED_TEXT = [
193
+ (spdx, re.compile(pat, re.IGNORECASE | re.DOTALL)) for spdx, pat in _TEXT_SIGNATURES
194
+ ]
195
+ _WHITESPACE = re.compile(r"\s+")
196
+
197
+ # Free-text aliases -> canonical SPDX id, for the manifest / tag / classifier paths.
198
+ _SPDX_ALIASES = {
199
+ "mit": "MIT",
200
+ "mit license": "MIT",
201
+ "expat": "MIT",
202
+ "apache-2.0": "Apache-2.0",
203
+ "apache 2.0": "Apache-2.0",
204
+ "apache2": "Apache-2.0",
205
+ "apache license 2.0": "Apache-2.0",
206
+ "asl 2.0": "Apache-2.0",
207
+ "bsd": "BSD-3-Clause",
208
+ "bsd-3-clause": "BSD-3-Clause",
209
+ "bsd 3-clause": "BSD-3-Clause",
210
+ "new bsd license": "BSD-3-Clause",
211
+ "bsd-2-clause": "BSD-2-Clause",
212
+ "bsd 2-clause": "BSD-2-Clause",
213
+ "isc": "ISC",
214
+ "mpl-2.0": "MPL-2.0",
215
+ "mpl 2.0": "MPL-2.0",
216
+ "epl-2.0": "EPL-2.0",
217
+ "lgpl-3.0": "LGPL-3.0",
218
+ "lgplv3": "LGPL-3.0",
219
+ "lgpl-2.1": "LGPL-2.1",
220
+ "lgplv2.1": "LGPL-2.1",
221
+ "gpl-3.0": "GPL-3.0",
222
+ "gplv3": "GPL-3.0",
223
+ "gpl-3.0-or-later": "GPL-3.0",
224
+ "gpl-3.0-only": "GPL-3.0",
225
+ "gpl-2.0": "GPL-2.0",
226
+ "gplv2": "GPL-2.0",
227
+ "gpl-2.0-or-later": "GPL-2.0",
228
+ "agpl-3.0": "AGPL-3.0",
229
+ "agplv3": "AGPL-3.0",
230
+ "agpl-3.0-or-later": "AGPL-3.0",
231
+ "agpl-3.0-only": "AGPL-3.0",
232
+ "unlicense": "Unlicense",
233
+ "the unlicense": "Unlicense",
234
+ "cc0-1.0": "CC0-1.0",
235
+ "cc0": "CC0-1.0",
236
+ "0bsd": "0BSD",
237
+ "wtfpl": "WTFPL",
238
+ "bsl-1.0": "BSL-1.0",
239
+ "boost": "BSL-1.0",
240
+ "zlib": "Zlib",
241
+ "python-2.0": "Python-2.0",
242
+ "psf": "Python-2.0",
243
+ "busl-1.1": "BUSL-1.1",
244
+ "bsl-1.1": "BUSL-1.1",
245
+ "sspl-1.0": "SSPL-1.0",
246
+ "sspl": "SSPL-1.0",
247
+ "elastic-2.0": "Elastic-2.0",
248
+ }
249
+
250
+ # Trove classifier tail (after "License :: OSI Approved :: ") -> canonical SPDX id.
251
+ _CLASSIFIER_SPDX = {
252
+ "mit license": "MIT",
253
+ "apache software license": "Apache-2.0",
254
+ "bsd license": "BSD-3-Clause",
255
+ "isc license (iscl)": "ISC",
256
+ "gnu general public license v3 (gplv3)": "GPL-3.0",
257
+ "gnu general public license v2 (gplv2)": "GPL-2.0",
258
+ "gnu lesser general public license v3 (lgplv3)": "LGPL-3.0",
259
+ "gnu lesser general public license v2 or later (lgplv2+)": "LGPL-2.1",
260
+ "gnu affero general public license v3": "AGPL-3.0",
261
+ "gnu affero general public license v3 or later (agpl v3+)": "AGPL-3.0",
262
+ "mozilla public license 2.0 (mpl 2.0)": "MPL-2.0",
263
+ "the unlicense (unlicense)": "Unlicense",
264
+ }
265
+
266
+ _LICENSE_DOC_EXT = (".md", ".txt", ".rst", ".markdown")
267
+ _MANIFEST_NAMES = {"pyproject.toml", "cargo.toml", "setup.cfg", "setup.py"}
268
+ _JSON_MANIFEST_NAMES = {"package.json", "composer.json"}
269
+ _CLASSIFIER_BEARING = {"pyproject.toml", "setup.cfg", "setup.py"}
270
+
271
+ _SPDX_TAG = re.compile(r"SPDX-License-Identifier:\s*([^\s\n*/]+(?:[ +][^\s\n*/]+)*)")
272
+ _JSON_LICENSE = re.compile(r'"license"\s*:\s*"([^"]+)"')
273
+ _JSON_LICENSE_OBJ = re.compile(r'"license"\s*:\s*\{[^}]*?"type"\s*:\s*"([^"]+)"', re.DOTALL)
274
+ _TOML_LICENSE = re.compile(r'(?m)^\s*license\s*=\s*"([^"]+)"')
275
+ _TOML_LICENSE_OBJ = re.compile(r'(?m)^\s*license\s*=\s*\{[^}]*?text\s*=\s*"([^"]+)"')
276
+ _CFG_LICENSE = re.compile(r"(?im)^\s*license\s*=\s*([^\n]+)$")
277
+ _CLASSIFIER = re.compile(r"License :: (?:OSI Approved :: )?([^\n\"']+?)(?:[\"',]|$)", re.MULTILINE)
278
+
279
+
280
+ def _line_of(content: str, pos: int) -> int:
281
+ return content.count("\n", 0, pos) + 1
282
+
283
+
284
+ def _basename(path: str) -> str:
285
+ return path.replace("\\", "/").rsplit("/", 1)[-1]
286
+
287
+
288
+ def _is_license_file(path: str) -> bool:
289
+ """True if a path is a license file by its name (``LICENSE``, ``COPYING``, ...)."""
290
+ low = _basename(path).lower()
291
+ for ext in _LICENSE_DOC_EXT:
292
+ if low.endswith(ext):
293
+ low = low[: -len(ext)]
294
+ break
295
+ return low in {
296
+ "license",
297
+ "licence",
298
+ "copying",
299
+ "copying.lesser",
300
+ "unlicense",
301
+ } or low.startswith(("license-", "licence-", "license.", "licence."))
302
+
303
+
304
+ def _normalize_spdx(token: str) -> str | None:
305
+ """Map a free-text license token (``"Apache 2.0"``, ``"GPLv3"``) to a known SPDX id."""
306
+ text = token.split("#", 1)[0].strip().strip("\"'").strip()
307
+ # keep only the first term of an "A OR B" / "A WITH B" / "A/B" expression
308
+ text = re.split(r"\s+(?:OR|AND|WITH)\s+|[/|]", text, maxsplit=1, flags=re.IGNORECASE)[0].strip()
309
+ low = text.lower().rstrip("+")
310
+ if low in _SPDX_ALIASES:
311
+ return _SPDX_ALIASES[low]
312
+ for spdx in _LICENSES:
313
+ if spdx.lower() == low:
314
+ return spdx
315
+ return None
316
+
317
+
318
+ def _manifest_findings(path: str, content: str) -> list[tuple[str, int, str]]:
319
+ """Pull (spdx, line, source_kind) license declarations out of one manifest file."""
320
+ name = _basename(path).lower()
321
+ out: list[tuple[str, int, str]] = []
322
+
323
+ if name in _JSON_MANIFEST_NAMES:
324
+ for rx in (_JSON_LICENSE, _JSON_LICENSE_OBJ):
325
+ m = rx.search(content)
326
+ if m and (spdx := _normalize_spdx(m.group(1))):
327
+ out.append((spdx, _line_of(content, m.start()), "manifest"))
328
+ elif name in {"pyproject.toml", "cargo.toml"}:
329
+ for rx in (_TOML_LICENSE, _TOML_LICENSE_OBJ):
330
+ m = rx.search(content)
331
+ if m and (spdx := _normalize_spdx(m.group(1))):
332
+ out.append((spdx, _line_of(content, m.start()), "manifest"))
333
+ elif name == "setup.cfg":
334
+ m = _CFG_LICENSE.search(content)
335
+ if m and (spdx := _normalize_spdx(m.group(1))):
336
+ out.append((spdx, _line_of(content, m.start()), "manifest"))
337
+
338
+ if name in _CLASSIFIER_BEARING:
339
+ for m in _CLASSIFIER.finditer(content):
340
+ tail = m.group(1).strip().lower()
341
+ spdx = _CLASSIFIER_SPDX.get(tail) or _normalize_spdx(tail)
342
+ if spdx:
343
+ out.append((spdx, _line_of(content, m.start()), "classifier"))
344
+ return out
345
+
346
+
347
+ def find_licenses(file_contents: dict[str, str], *, limit: int = 30) -> dict:
348
+ """Identify the project's own license(s) from its files.
349
+
350
+ Args:
351
+ file_contents: mapping of path to file text (as CodeABC already read it).
352
+ limit: how many findings to return in the list.
353
+
354
+ Returns ``{"total", "primary", "primary_category", "found", "categories", "notes"}``
355
+ where each finding is ``{"spdx", "name", "name_zh", "category", "source_path",
356
+ "source_kind", "line"}``. ``primary`` is the best single guess of the project's
357
+ license (``""`` when none was found), and ``primary_category`` its family.
358
+ """
359
+ # (spdx-or-"unknown", source_path, source_kind, line), deduped by (spdx, path).
360
+ raw: list[tuple[str, str, str, int]] = []
361
+ seen: set[tuple[str, str]] = set()
362
+ saw_license_file = False
363
+
364
+ def add(spdx: str, path: str, kind: str, line: int) -> None:
365
+ key = (spdx, path)
366
+ if key in seen:
367
+ return
368
+ seen.add(key)
369
+ raw.append((spdx, path, kind, line))
370
+
371
+ for path, content in file_contents.items():
372
+ if not content:
373
+ continue
374
+ name = _basename(path).lower()
375
+
376
+ if _is_license_file(path):
377
+ saw_license_file = True
378
+ # License prose is wrapped at whatever column width its author chose,
379
+ # so collapse runs of whitespace before matching the grant-clause
380
+ # signatures — otherwise a line break in the middle of "any purpose"
381
+ # would hide an otherwise obvious match.
382
+ flat = _WHITESPACE.sub(" ", content)
383
+ matched = next((spdx for spdx, rx in _COMPILED_TEXT if rx.search(flat)), None)
384
+ add(matched or "unknown", path, "license-file", 1)
385
+
386
+ if name in _MANIFEST_NAMES or name in _JSON_MANIFEST_NAMES:
387
+ for spdx, line, kind in _manifest_findings(path, content):
388
+ add(spdx, path, kind, line)
389
+
390
+ tag = _SPDX_TAG.search(content)
391
+ if tag and (spdx := _normalize_spdx(tag.group(1))):
392
+ add(spdx, path, "spdx-tag", _line_of(content, tag.start()))
393
+
394
+ findings = [
395
+ {
396
+ "spdx": spdx,
397
+ "name": _LICENSES.get(spdx, ("未识别的许可证", "", "unknown"))[0],
398
+ "name_zh": _LICENSES.get(spdx, ("", "未识别的许可证", "unknown"))[1],
399
+ "category": _LICENSES.get(spdx, ("", "", "unknown"))[2],
400
+ "source_path": path,
401
+ "source_kind": kind,
402
+ "line": line,
403
+ }
404
+ for spdx, path, kind, line in raw
405
+ ]
406
+
407
+ # Primary = the highest-priority source, shallowest path as the tie-break.
408
+ priority = {"license-file": 0, "manifest": 1, "classifier": 2, "spdx-tag": 3}
409
+ recognised = [f for f in findings if f["spdx"] != "unknown"]
410
+ primary, primary_category = "", ""
411
+ if recognised:
412
+ best = min(
413
+ recognised,
414
+ key=lambda f: (priority.get(f["source_kind"], 9), f["source_path"].count("/")),
415
+ )
416
+ primary, primary_category = best["spdx"], best["category"]
417
+ elif saw_license_file:
418
+ primary, primary_category = "unknown", "unknown"
419
+
420
+ findings.sort(key=lambda f: (priority.get(f["source_kind"], 9), f["source_path"]))
421
+ categories = [c for c in _CATEGORY_ORDER if any(f["category"] == c for f in findings)]
422
+
423
+ notes: list[str] = []
424
+ if not findings:
425
+ notes.append(
426
+ "在扫描到的文件里没找到许可证。没有许可证 ≠ 免费随便用——默认是“保留所有权利”,"
427
+ "别人原则上不能合法使用或分发。如果这是你的项目,建议补一个 LICENSE;"
428
+ "如果你只上传了部分目录,许可证可能在仓库根目录。"
429
+ )
430
+ elif primary == "unknown":
431
+ notes.append("找到了许可证文件但没认出是哪一种,请人工读一下原文,或联系作者确认。")
432
+ notes.append("以上是大白话概括,不是法律意见;正式用途请读许可证原文或咨询法务。")
433
+
434
+ return {
435
+ "total": len({f["spdx"] for f in recognised}),
436
+ "primary": primary,
437
+ "primary_category": primary_category,
438
+ "found": findings[:limit],
439
+ "categories": categories,
440
+ "notes": notes,
441
+ }
442
+
443
+
444
+ def render_licenses_markdown(project_name: str, data: dict | None) -> str:
445
+ """Render the license map as Markdown, or ``""`` if there is nothing to show."""
446
+ if not isinstance(data, dict):
447
+ return ""
448
+ findings = data.get("found") or []
449
+ primary = data.get("primary") or ""
450
+ notes = data.get("notes") or []
451
+ if not findings and not notes:
452
+ return ""
453
+
454
+ lines = [
455
+ f"# {project_name} — 开源许可证:你能不能用、能不能商用",
456
+ "",
457
+ "> 许可证决定了你能拿这份代码做什么。下面把它翻成大白话——"
458
+ "能不能商用、能不能闭源用、改了要不要公开、会不会“传染”你自己的项目。",
459
+ "",
460
+ ]
461
+
462
+ if primary and primary != "unknown":
463
+ name_zh = _LICENSES[primary][1]
464
+ family = _CATEGORY[data.get("primary_category") or _LICENSES[primary][2]]
465
+ lines.append(f"## 主许可证:{name_zh}({family['label']})")
466
+ lines.append("")
467
+ lines.append(f"> {family['one_line']}")
468
+ lines.append("")
469
+ lines.append(f"- 能不能商用:{family['commercial']}")
470
+ lines.append(f"- 能不能用在闭源 / 私有项目里:{family['private_use']}")
471
+ lines.append(f"- 改了代码必须公开吗:{family['share_changes']}")
472
+ lines.append(f"- 会不会“传染”你自己的整个项目:{family['copyleft_scope']}")
473
+ lines.append(f"- 必须保留 / 遵守:{family['keep_notice']}")
474
+ lines.append("")
475
+ elif primary == "unknown":
476
+ lines.append("## 主许可证:未识别")
477
+ lines.append("")
478
+ lines.append(f"> {_CATEGORY['unknown']['one_line']}")
479
+ lines.append("")
480
+ else:
481
+ lines.append("## ⚠ 没找到许可证")
482
+ lines.append("")
483
+ lines.append(
484
+ "> 没有许可证不等于可以随便用——默认是“保留所有权利”,"
485
+ "别人原则上不能合法使用或分发这份代码。"
486
+ )
487
+ lines.append("")
488
+
489
+ if findings:
490
+ lines.append("## 全部发现")
491
+ for f in findings:
492
+ label = f["name_zh"] or f["name"] or "未识别的许可证"
493
+ where = _SOURCE_LABEL.get(f["source_kind"], f["source_kind"])
494
+ loc = f"`{f['source_path']}`"
495
+ if f["source_kind"] != "license-file":
496
+ loc += f" 第 {f['line']} 行"
497
+ lines.append(f"- {loc} — {label}(来自{where})")
498
+ lines.append("")
499
+
500
+ if notes:
501
+ lines.append("## 提醒")
502
+ for note in notes:
503
+ lines.append(f"- {note}")
504
+
505
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,117 @@
1
+ """LLM integration via litellm — supports any model provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from collections.abc import AsyncGenerator
8
+
9
+ import litellm
10
+
11
+ # Some models reject a custom temperature: gpt-5 / gpt-5-mini / gpt-5-codex and
12
+ # the o1 / o3 reasoning families only accept the default (temperature=1) and
13
+ # error on `temperature=0.3`. Since gpt-5-mini is our default OpenAI model, let
14
+ # litellm silently drop any param a given model doesn't support instead of
15
+ # crashing the request — models that do support temperature still honor it.
16
+ litellm.drop_params = True
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Default model for a direct-provider key (OpenAI etc.). gpt-5-mini is the
21
+ # current cheap OpenAI default; override with CODEABC_MODEL for anything else.
22
+ _DEFAULT_MODEL = "gpt-5-mini"
23
+ # Default when the user pastes an OpenRouter key (sk-or-...). DeepSeek V4 Flash
24
+ # suits a beginner code-reader well: very cheap (~$0.09/$0.18 per Mtok), a 1M
25
+ # context to ingest whole files, reliable JSON output, and strong Chinese
26
+ # explanations — all checked live on OpenRouter (2026-06). Other verified picks,
27
+ # via CODEABC_MODEL: openrouter/anthropic/claude-haiku-4.5 (best explanations,
28
+ # ~10x pricier) or openrouter/google/gemini-3-flash-preview (1M context, strong
29
+ # at code). Note: openai/gpt-5-mini is not reliably reachable through OpenRouter.
30
+ _DEFAULT_OPENROUTER_MODEL = "openrouter/deepseek/deepseek-v4-flash"
31
+
32
+
33
+ def _resolve_model(api_key: str | None = None, model: str | None = None) -> str:
34
+ """Pick the litellm model string, making a pasted OpenRouter key just work.
35
+
36
+ An explicit model (the ``model`` argument or the ``CODEABC_MODEL`` env var)
37
+ always wins. Otherwise the key's shape decides the provider: an OpenRouter
38
+ key (``sk-or-...``) routes through OpenRouter, so a non-technical user only
39
+ has to paste their key — there is no provider or ``openrouter/`` prefix to
40
+ remember. Anything else falls back to the OpenAI default.
41
+ """
42
+ override = model or os.getenv("CODEABC_MODEL")
43
+ is_openrouter_key = bool(api_key) and api_key.startswith("sk-or-")
44
+ if override:
45
+ # a bare model name alongside an OpenRouter key -> route it accordingly
46
+ if is_openrouter_key and "/" not in override:
47
+ return f"openrouter/{override}"
48
+ return override
49
+ return _DEFAULT_OPENROUTER_MODEL if is_openrouter_key else _DEFAULT_MODEL
50
+
51
+
52
+ # On failure the call helpers below surface this sentinel instead of raising, so
53
+ # the streaming generator can finish cleanly. Routers detect it (is_error_text)
54
+ # and turn it into a proper error the UI can show in plain language, rather than
55
+ # letting a raw "[LLM Error: ...]" string reach the reader.
56
+ LLM_ERROR_PREFIX = "[LLM Error:"
57
+
58
+
59
+ def is_error_text(text: str) -> bool:
60
+ """True if ``text`` is an LLM failure sentinel rather than real output."""
61
+ return text.lstrip().startswith(LLM_ERROR_PREFIX)
62
+
63
+
64
+ async def stream_llm(
65
+ prompt: str,
66
+ *,
67
+ api_key: str | None = None,
68
+ model: str | None = None,
69
+ ) -> AsyncGenerator[str, None]:
70
+ """Stream LLM response chunks."""
71
+ model = _resolve_model(api_key, model)
72
+
73
+ kwargs: dict = {
74
+ "model": model,
75
+ "messages": [{"role": "user", "content": prompt}],
76
+ "stream": True,
77
+ "temperature": 0.3,
78
+ "max_tokens": 4096,
79
+ }
80
+ if api_key:
81
+ kwargs["api_key"] = api_key
82
+
83
+ try:
84
+ response = await litellm.acompletion(**kwargs)
85
+ async for chunk in response:
86
+ delta = chunk.choices[0].delta
87
+ if delta.content:
88
+ yield delta.content
89
+ except Exception as e:
90
+ logger.error(f"LLM call failed: {e}")
91
+ yield f"{LLM_ERROR_PREFIX} {e}]"
92
+
93
+
94
+ async def call_llm(
95
+ prompt: str,
96
+ *,
97
+ api_key: str | None = None,
98
+ model: str | None = None,
99
+ ) -> str:
100
+ """Non-streaming LLM call. Returns the full response text."""
101
+ model = _resolve_model(api_key, model)
102
+
103
+ kwargs: dict = {
104
+ "model": model,
105
+ "messages": [{"role": "user", "content": prompt}],
106
+ "temperature": 0.3,
107
+ "max_tokens": 4096,
108
+ }
109
+ if api_key:
110
+ kwargs["api_key"] = api_key
111
+
112
+ try:
113
+ response = await litellm.acompletion(**kwargs)
114
+ return response.choices[0].message.content or ""
115
+ except Exception as e:
116
+ logger.error(f"LLM call failed: {e}")
117
+ return f"{LLM_ERROR_PREFIX} {e}]"