github-license-scanner 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.
@@ -0,0 +1,855 @@
1
+ """
2
+ License classification, registry lookups, compatibility verdicts, and
3
+ end-to-end repository analysis orchestration.
4
+
5
+ IMPORTANT: Results are automated heuristics for developer guidance only.
6
+ They are NOT legal advice. Always consult a qualified attorney for
7
+ compliance decisions involving copyleft and commercial distribution.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import re
14
+ import time
15
+ from datetime import datetime, timezone
16
+ from typing import Any
17
+ from urllib.parse import quote
18
+
19
+ import httpx
20
+
21
+ from .dependency_scanner import parse_many
22
+ from .deploy_advisor import recommend_deploy
23
+ from .github_api import (
24
+ GitHubAPIError,
25
+ create_client,
26
+ download_dependency_files,
27
+ find_dependency_files,
28
+ get_repo_info,
29
+ parse_github_url,
30
+ )
31
+ from .models import (
32
+ Dependency,
33
+ PackageLicense,
34
+ ReplacementSuggestion,
35
+ ScanResult,
36
+ )
37
+
38
+ try:
39
+ from .config import (
40
+ LICENSE_CACHE_MAX_ENTRIES,
41
+ LICENSE_CACHE_TTL_SECONDS,
42
+ MAX_CONCURRENT_LOOKUPS,
43
+ MAX_PACKAGES_LOOKUP,
44
+ USER_AGENT,
45
+ )
46
+ except Exception: # noqa: BLE001
47
+ MAX_CONCURRENT_LOOKUPS = 12
48
+ MAX_PACKAGES_LOOKUP = 80
49
+ USER_AGENT = "github-license-scanner/1.1 (license-lookup; educational)"
50
+ LICENSE_CACHE_TTL_SECONDS = 3600
51
+ LICENSE_CACHE_MAX_ENTRIES = 2000
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # License classification maps (SPDX-oriented, case-insensitive matching)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ PERMISSIVE = {
58
+ "mit",
59
+ "apache-2.0",
60
+ "apache 2.0",
61
+ "apache2",
62
+ "apache-2",
63
+ "bsd-2-clause",
64
+ "bsd-3-clause",
65
+ "bsd",
66
+ "isc",
67
+ "unlicense",
68
+ "0bsd",
69
+ "cc0-1.0",
70
+ "cc0",
71
+ "zlib",
72
+ "boost",
73
+ "bsl-1.0",
74
+ "python-2.0",
75
+ "psf-2.0",
76
+ "blueoak-1.0.0",
77
+ "artistic-2.0",
78
+ "wtfpl",
79
+ "postgresql",
80
+ "openssl",
81
+ }
82
+
83
+ WEAK_COPYLEFT = {
84
+ "lgpl-2.1",
85
+ "lgpl-3.0",
86
+ "lgpl-2.0",
87
+ "lgpl-2.1-only",
88
+ "lgpl-2.1-or-later",
89
+ "lgpl-3.0-only",
90
+ "lgpl-3.0-or-later",
91
+ "lgpl",
92
+ "mpl-2.0",
93
+ "mpl",
94
+ "epl-1.0",
95
+ "epl-2.0",
96
+ "cpl-1.0",
97
+ "cddl-1.0",
98
+ "cddl-1.1",
99
+ "ms-pl",
100
+ "eupl-1.1",
101
+ "eupl-1.2",
102
+ }
103
+
104
+ STRONG_COPYLEFT = {
105
+ "gpl-2.0",
106
+ "gpl-3.0",
107
+ "gpl-2.0-only",
108
+ "gpl-2.0-or-later",
109
+ "gpl-3.0-only",
110
+ "gpl-3.0-or-later",
111
+ "gpl",
112
+ "agpl-3.0",
113
+ "agpl-1.0",
114
+ "agpl-3.0-only",
115
+ "agpl-3.0-or-later",
116
+ "agpl",
117
+ "sspl-1.0",
118
+ "sspl",
119
+ "sleepycat",
120
+ "osl-3.0", # treated as strong for distribution (conservative)
121
+ "cecill-2.1",
122
+ }
123
+
124
+ # Licenses with network-use / SaaS-sensitive obligations (subset of strong)
125
+ NETWORK_COPYLEFT_MARKERS = ("agpl", "sspl")
126
+
127
+ # Heuristic replacements for known strong-copyleft packages (not exhaustive)
128
+ REPLACEMENT_MAP: dict[str, list[str]] = {
129
+ # name lowercased -> permissive alternatives
130
+ "readline": ["prompt_toolkit (BSD)", "pyreadline3 (BSD-ish)"],
131
+ "gnu-readline": ["prompt_toolkit"],
132
+ "ffmpeg-python": ["imageio-ffmpeg (check binary license)", "moviepy with care"],
133
+ "mysql-connector-python": ["PyMySQL (MIT)", "mysqlclient (check license)", "asyncmy (Apache-2.0)"],
134
+ "psycopg2": ["psycopg (LGPL — still weak)", "asyncpg (Apache-2.0)"],
135
+ "qtpy": ["consider pure web UI (NiceGUI/Streamlit) to avoid Qt GPL builds"],
136
+ "pyside2": ["PySide6 is LGPL; or use web UI frameworks"],
137
+ "pyside6": ["dearpygui (MIT)", "NiceGUI (MIT)", "Streamlit (Apache-2.0)"],
138
+ "pyqt5": ["PySide6 (LGPL)", "NiceGUI (MIT)", "tkinter (PSF)"],
139
+ "pyqt6": ["PySide6 (LGPL)", "NiceGUI (MIT)"],
140
+ "ghostscript": ["pypdf (BSD)", "pikepdf (MPL-2.0)"],
141
+ "copyleft": [], # placeholder
142
+ # npm
143
+ "node-gpl-example": ["look for MIT alternatives on npm"],
144
+ }
145
+
146
+ # In-process cache: (ecosystem, name_lower) -> (license_id, url, expires_at)
147
+ _license_cache: dict[tuple[str, str], tuple[str | None, str | None, float]] = {}
148
+
149
+
150
+ def _cache_get(key: tuple[str, str]) -> tuple[str | None, str | None] | None:
151
+ entry = _license_cache.get(key)
152
+ if not entry:
153
+ return None
154
+ lic, url, expires = entry
155
+ if time.monotonic() > expires:
156
+ _license_cache.pop(key, None)
157
+ return None
158
+ return lic, url
159
+
160
+
161
+ def _cache_set(key: tuple[str, str], lic: str | None, url: str | None) -> None:
162
+ # Evict oldest-ish entries if over capacity (simple size guard)
163
+ if len(_license_cache) >= LICENSE_CACHE_MAX_ENTRIES:
164
+ # Drop ~10% of keys
165
+ for drop_key in list(_license_cache.keys())[: max(1, LICENSE_CACHE_MAX_ENTRIES // 10)]:
166
+ _license_cache.pop(drop_key, None)
167
+ _license_cache[key] = (lic, url, time.monotonic() + LICENSE_CACHE_TTL_SECONDS)
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Classification helpers
172
+ # ---------------------------------------------------------------------------
173
+
174
+ def normalize_license_id(raw: str | None) -> str | None:
175
+ """Normalize a license string for comparison and display."""
176
+ if raw is None:
177
+ return None
178
+ text = str(raw).strip()
179
+ if not text or text.upper() in {"NOASSERTION", "NONE", "UNKNOWN", "UNLICENSED", "SEE LICENSE IN LICENSE"}:
180
+ # "UNLICENSED" in npm often means proprietary — keep a marker
181
+ if text.upper() == "UNLICENSED":
182
+ return "UNLICENSED"
183
+ return None
184
+
185
+ # npm sometimes returns {"type": "MIT", "url": "..."}
186
+ # handled before calling this function
187
+
188
+ # Common cleanup
189
+ text = text.replace("License", "").replace("license", "").strip()
190
+ # Take first alternative of dual licenses for classification display
191
+ # e.g. "(MIT OR Apache-2.0)" -> keep original for display but classify carefully
192
+ return text
193
+
194
+
195
+ def classify_license(license_id: str | None) -> str:
196
+ """
197
+ Classify a license string / SPDX expression into a risk bucket.
198
+
199
+ Uses the SPDX expression engine (AND/OR/WITH + parentheses).
200
+ Returns one of: permissive | weak_copyleft | strong_copyleft | unknown
201
+ """
202
+ from .spdx_engine import classify_expression
203
+
204
+ risk, _node, _err = classify_expression(license_id)
205
+ return risk
206
+
207
+
208
+ def _classify_single(token: str) -> str:
209
+ """Classify a single license token (kept for tests / callers)."""
210
+ from .spdx_engine import classify_id
211
+
212
+ return classify_id(token).value
213
+
214
+
215
+ def risk_color(risk: str) -> str:
216
+ """Map risk to a simple color name for the UI."""
217
+ return {
218
+ "permissive": "green",
219
+ "weak_copyleft": "orange",
220
+ "strong_copyleft": "red",
221
+ "unknown": "orange",
222
+ }.get(risk, "grey")
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Registry license lookups
227
+ # ---------------------------------------------------------------------------
228
+
229
+ def _extract_npm_license(data: dict[str, Any]) -> str | None:
230
+ """Extract license field from npm registry JSON."""
231
+ lic = data.get("license")
232
+ if isinstance(lic, str):
233
+ return normalize_license_id(lic)
234
+ if isinstance(lic, dict):
235
+ return normalize_license_id(lic.get("type") or lic.get("name"))
236
+ # Older packages: licenses array
237
+ licenses = data.get("licenses")
238
+ if isinstance(licenses, list) and licenses:
239
+ first = licenses[0]
240
+ if isinstance(first, dict):
241
+ return normalize_license_id(first.get("type"))
242
+ if isinstance(first, str):
243
+ return normalize_license_id(first)
244
+ # Prefer latest version info
245
+ latest = (data.get("dist-tags") or {}).get("latest")
246
+ versions = data.get("versions") or {}
247
+ if latest and latest in versions:
248
+ return _extract_npm_license(versions[latest])
249
+ return None
250
+
251
+
252
+ def _extract_pypi_license(data: dict[str, Any]) -> str | None:
253
+ """Extract license from PyPI JSON API."""
254
+ info = data.get("info") or {}
255
+ lic = info.get("license") or info.get("license_expression")
256
+ if lic and str(lic).strip() and str(lic).strip() not in {"UNKNOWN", "License :: Other/Proprietary License"}:
257
+ # Sometimes license is a long text; try to grab SPDX-like token
258
+ text = str(lic).strip()
259
+ if len(text) < 80:
260
+ return normalize_license_id(text)
261
+
262
+ # Classifiers: License :: OSI Approved :: MIT License
263
+ for classifier in info.get("classifiers") or []:
264
+ if not isinstance(classifier, str):
265
+ continue
266
+ if classifier.startswith("License ::"):
267
+ parts = classifier.split("::")
268
+ label = parts[-1].strip()
269
+ # Map common classifier labels
270
+ mapping = {
271
+ "MIT License": "MIT",
272
+ "Apache Software License": "Apache-2.0",
273
+ "BSD License": "BSD",
274
+ "GNU General Public License v3 (GPLv3)": "GPL-3.0",
275
+ "GNU General Public License v2 (GPLv2)": "GPL-2.0",
276
+ "GNU Affero General Public License v3": "AGPL-3.0",
277
+ "GNU Lesser General Public License v3 (LGPLv3)": "LGPL-3.0",
278
+ "GNU Lesser General Public License v2 (LGPLv2)": "LGPL-2.0",
279
+ "Mozilla Public License 2.0 (MPL 2.0)": "MPL-2.0",
280
+ "ISC License (ISCL)": "ISC",
281
+ "The Unlicense (Unlicense)": "Unlicense",
282
+ "Public Domain": "Unlicense",
283
+ }
284
+ if label in mapping:
285
+ return mapping[label]
286
+ return normalize_license_id(label)
287
+ return normalize_license_id(str(lic) if lic else None)
288
+
289
+
290
+ async def lookup_package_license(
291
+ client: httpx.AsyncClient,
292
+ dep: Dependency,
293
+ ) -> tuple[str | None, str | None]:
294
+ """
295
+ Query the official registry for a package license.
296
+
297
+ Returns (license_id, info_url). Uses an in-process cache.
298
+ """
299
+ cache_key = (dep.ecosystem, dep.name.lower())
300
+ cached = _cache_get(cache_key)
301
+ if cached is not None:
302
+ return cached
303
+
304
+ license_id: str | None = None
305
+ info_url: str | None = None
306
+
307
+ try:
308
+ if dep.ecosystem == "npm":
309
+ # Scoped packages: @scope/name → %40scope%2Fname
310
+ encoded = quote(dep.name, safe="@")
311
+ # httpx quote: better encode slash in scope
312
+ encoded = dep.name.replace("/", "%2F")
313
+ url = f"https://registry.npmjs.org/{encoded}"
314
+ info_url = f"https://www.npmjs.com/package/{dep.name}"
315
+ resp = await client.get(url)
316
+ if resp.status_code == 200:
317
+ license_id = _extract_npm_license(resp.json())
318
+
319
+ elif dep.ecosystem == "pypi":
320
+ name = dep.name
321
+ url = f"https://pypi.org/pypi/{quote(name)}/json"
322
+ info_url = f"https://pypi.org/project/{name}/"
323
+ resp = await client.get(url)
324
+ if resp.status_code == 200:
325
+ license_id = _extract_pypi_license(resp.json())
326
+
327
+ elif dep.ecosystem == "cargo":
328
+ url = f"https://crates.io/api/v1/crates/{quote(dep.name)}"
329
+ info_url = f"https://crates.io/crates/{dep.name}"
330
+ resp = await client.get(url, headers={"User-Agent": USER_AGENT})
331
+ if resp.status_code == 200:
332
+ crate = (resp.json().get("crate") or {})
333
+ # license field on crate
334
+ license_id = normalize_license_id(crate.get("license"))
335
+
336
+ elif dep.ecosystem == "go":
337
+ # Best-effort: pkg.go.dev does not have a stable simple JSON API.
338
+ # Leave unknown; go.mod often has no license metadata remotely.
339
+ info_url = f"https://pkg.go.dev/{dep.name}"
340
+ license_id = None
341
+
342
+ elif dep.ecosystem == "rubygems":
343
+ url = f"https://rubygems.org/api/v1/gems/{quote(dep.name)}.json"
344
+ info_url = f"https://rubygems.org/gems/{dep.name}"
345
+ resp = await client.get(url)
346
+ if resp.status_code == 200:
347
+ data = resp.json()
348
+ licenses = data.get("licenses") or []
349
+ if isinstance(licenses, list) and licenses:
350
+ license_id = normalize_license_id(str(licenses[0]))
351
+ else:
352
+ license_id = normalize_license_id(data.get("license"))
353
+
354
+ elif dep.ecosystem == "composer":
355
+ # Packagist
356
+ if "/" in dep.name:
357
+ url = f"https://repo.packagist.org/p2/{dep.name}.json"
358
+ info_url = f"https://packagist.org/packages/{dep.name}"
359
+ resp = await client.get(url)
360
+ if resp.status_code == 200:
361
+ packages = (resp.json().get("packages") or {}).get(dep.name) or []
362
+ if packages and isinstance(packages, list):
363
+ lic_list = packages[0].get("license") or []
364
+ if isinstance(lic_list, list) and lic_list:
365
+ license_id = normalize_license_id(str(lic_list[0]))
366
+
367
+ # maven / gradle: no free bulk API without coordinates API keys — skip
368
+ else:
369
+ license_id = None
370
+
371
+ except (httpx.HTTPError, ValueError, KeyError, TypeError):
372
+ license_id = None
373
+
374
+ _cache_set(cache_key, license_id, info_url)
375
+ return license_id, info_url
376
+
377
+
378
+ async def resolve_all_licenses(
379
+ dependencies: list[Dependency],
380
+ ) -> list[PackageLicense]:
381
+ """
382
+ Resolve licenses for a list of dependencies with bounded concurrency.
383
+ """
384
+ deps = dependencies[:MAX_PACKAGES_LOOKUP]
385
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_LOOKUPS)
386
+ results: list[PackageLicense] = []
387
+
388
+ async with httpx.AsyncClient(
389
+ timeout=20.0,
390
+ headers={"User-Agent": USER_AGENT},
391
+ follow_redirects=True,
392
+ ) as client:
393
+
394
+ async def one(dep: Dependency) -> PackageLicense:
395
+ async with semaphore:
396
+ lic, url = await lookup_package_license(client, dep)
397
+ risk = classify_license(lic)
398
+ return PackageLicense(
399
+ name=dep.name,
400
+ ecosystem=dep.ecosystem,
401
+ license_id=lic,
402
+ risk=risk,
403
+ source_file=dep.source_file,
404
+ license_url=url,
405
+ version_spec=dep.version_spec,
406
+ is_dev=dep.is_dev,
407
+ )
408
+
409
+ tasks = [one(d) for d in deps]
410
+ results = list(await asyncio.gather(*tasks))
411
+
412
+ return results
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Verdicts, replacements, copyright notice
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def build_copyright_notice(
420
+ owner: str,
421
+ repo: str,
422
+ repo_license: str | None,
423
+ year: int | None = None,
424
+ ) -> str:
425
+ """
426
+ Build a plain-text *template* for notices — NOT a legal copyright claim.
427
+
428
+ Important: the GitHub owner/organization name is often NOT the copyright
429
+ holder (forks, orgs, employers, multi-author projects). Users must verify
430
+ the LICENSE file, NOTICE file, and commit authorship before relying on this.
431
+ """
432
+ y = year or datetime.now(timezone.utc).year
433
+ lic = repo_license or "SEE LICENSE IN REPOSITORY"
434
+ return (
435
+ f"=== NOTICE TEMPLATE (NOT LEGAL ADVICE) ===\n"
436
+ f"WARNING: Do NOT treat the GitHub account name as proof of copyright\n"
437
+ f"ownership. Verify rights holders from LICENSE, NOTICE, and history.\n"
438
+ f"\n"
439
+ f"Repository: https://github.com/{owner}/{repo}\n"
440
+ f"Detected project license (heuristic): {lic}\n"
441
+ f"GitHub owner/org (may differ from copyright holder): {owner}\n"
442
+ f"\n"
443
+ f"--- Suggested skeleton (edit before use) ---\n"
444
+ f"Copyright (c) {y} [COPYRIGHT HOLDER NAME(S) — verify manually]\n"
445
+ f"Licensed under {lic}. See the repository LICENSE file for full terms.\n"
446
+ f"\n"
447
+ f"Third-party software: list each dependency with its license id,\n"
448
+ f"copyright holders, and any required attribution / NOTICE text.\n"
449
+ f"Permissive licenses (MIT/BSD/Apache-2.0) still require attribution\n"
450
+ f"and preservation of license/notice files upon distribution.\n"
451
+ f"\n"
452
+ f"Generated by GitHub License Scanner for convenience only.\n"
453
+ f"Always verify against upstream sources before commercial use."
454
+ )
455
+
456
+
457
+ def suggest_replacements(packages: list[PackageLicense]) -> list[ReplacementSuggestion]:
458
+ """
459
+ Suggest permissive alternatives for strong_copyleft (and notable weak) packages.
460
+ """
461
+ suggestions: list[ReplacementSuggestion] = []
462
+ for pkg in packages:
463
+ if pkg.risk not in {"strong_copyleft", "weak_copyleft"}:
464
+ continue
465
+ key = pkg.name.lower().split("/")[-1] # strip scope / module path tail
466
+ alts = REPLACEMENT_MAP.get(pkg.name.lower()) or REPLACEMENT_MAP.get(key)
467
+ if alts:
468
+ note = "Curated heuristic alternatives — verify licenses yourself."
469
+ alternatives = alts
470
+ else:
471
+ note = (
472
+ "No curated alternative on file. Search the same ecosystem for "
473
+ "packages licensed under MIT, Apache-2.0, or BSD."
474
+ )
475
+ alternatives = [
476
+ f"Search {pkg.ecosystem} for MIT/Apache alternatives to '{pkg.name}'"
477
+ ]
478
+ suggestions.append(
479
+ ReplacementSuggestion(
480
+ package=pkg.name,
481
+ ecosystem=pkg.ecosystem,
482
+ license_id=pkg.license_id,
483
+ alternatives=alternatives,
484
+ note=note,
485
+ )
486
+ )
487
+ return suggestions
488
+
489
+
490
+ def _is_network_copyleft(license_id: str | None) -> bool:
491
+ """True if the license string looks like AGPL/SSPL (SaaS/network sensitive)."""
492
+ from .spdx_engine import is_network_copyleft_expression
493
+
494
+ return is_network_copyleft_expression(license_id)
495
+
496
+
497
+ def compute_verdict(
498
+ repo_license: str | None,
499
+ packages: list[PackageLicense],
500
+ ) -> tuple[bool, bool, bool, bool, str, bool, bool]:
501
+ """
502
+ Heuristic closed-source risk signals (NOT a legal determination).
503
+
504
+ Strong copyleft in **production** deps (or the repo license) sets
505
+ forces_open_source. Strong copyleft only in **dev** deps is reported but
506
+ does not auto-force open.
507
+
508
+ Linking models (static vs dynamic), distribution vs SaaS, dual-licensing,
509
+ additional permissions, and contractual overlays are NOT fully modeled.
510
+
511
+ Returns:
512
+ can_sell_closed, forces_open_source, has_weak_copyleft,
513
+ has_unknown_licenses, verdict_summary, strong_copyleft_dev_only,
514
+ has_network_copyleft
515
+ """
516
+ repo_risk = classify_license(repo_license)
517
+ prod = [p for p in packages if not p.is_dev]
518
+ dev = [p for p in packages if p.is_dev]
519
+
520
+ strong_prod = [p for p in prod if p.risk == "strong_copyleft"]
521
+ strong_dev = [p for p in dev if p.risk == "strong_copyleft"]
522
+ weak_pkgs = [p for p in packages if p.risk == "weak_copyleft"]
523
+ unknown_pkgs = [p for p in packages if p.risk == "unknown"]
524
+ unknown_prod = [p for p in prod if p.risk == "unknown"]
525
+
526
+ network_hits = []
527
+ if _is_network_copyleft(repo_license):
528
+ network_hits.append(f"repo:{repo_license}")
529
+ for p in packages:
530
+ if _is_network_copyleft(p.license_id):
531
+ network_hits.append(p.name)
532
+ has_network = bool(network_hits)
533
+
534
+ forces = False
535
+ reasons: list[str] = []
536
+ strong_dev_only = False
537
+
538
+ if repo_risk == "strong_copyleft":
539
+ forces = True
540
+ reasons.append(
541
+ f"The repository license ({repo_license}) is classified as strong "
542
+ f"copyleft (GPL/AGPL/SSPL-class). Distributing a modified or combined "
543
+ f"work often requires providing corresponding source under compatible "
544
+ f"terms — outcome depends on how you combine and distribute the code."
545
+ )
546
+
547
+ if strong_prod:
548
+ forces = True
549
+ names = ", ".join(sorted({p.name for p in strong_prod})[:12])
550
+ extra = "…" if len(strong_prod) > 12 else ""
551
+ reasons.append(
552
+ f"Strong copyleft in production dependencies ({len(strong_prod)}): "
553
+ f"{names}{extra}. Depending on linking/distribution model, this can "
554
+ f"require opening source of combined works (GPL family). This tool "
555
+ f"does not analyze your binary linking graph."
556
+ )
557
+
558
+ if has_network:
559
+ names = ", ".join(sorted(set(network_hits))[:8])
560
+ reasons.append(
561
+ f"Network-copyleft / SaaS-sensitive license signal (AGPL/SSPL-class): "
562
+ f"{names}. Even without traditional 'distribution', offering the "
563
+ f"software over a network may trigger source-offer obligations. "
564
+ f"Review AGPL §13 / SSPL terms with counsel before SaaS deployment."
565
+ )
566
+
567
+ if strong_dev and not strong_prod:
568
+ strong_dev_only = True
569
+ names = ", ".join(sorted({p.name for p in strong_dev})[:8])
570
+ reasons.append(
571
+ f"Strong copyleft only in dev/test dependencies ({len(strong_dev)}): "
572
+ f"{names}. These often do not ship in production, but confirm your "
573
+ f"build/distribution pipeline does not bundle them."
574
+ )
575
+
576
+ has_weak = bool(weak_pkgs) or repo_risk == "weak_copyleft"
577
+ has_unknown = bool(unknown_pkgs) or repo_risk == "unknown" or not repo_license
578
+
579
+ can_sell = not forces
580
+
581
+ if can_sell and has_weak:
582
+ weak_prod = [p for p in weak_pkgs if not p.is_dev]
583
+ reasons.append(
584
+ f"Weak copyleft packages present ({len(weak_pkgs)} total, "
585
+ f"{len(weak_prod)} production: MPL/LGPL/EPL/EUPL-class). "
586
+ f"You may often keep your own code closed if you comply with "
587
+ f"file-level, library, or reciprocal obligations — review each license."
588
+ )
589
+ if can_sell and has_unknown:
590
+ reasons.append(
591
+ f"Some licenses could not be determined "
592
+ f"({len(unknown_prod)} production / {len(unknown_pkgs)} total packages"
593
+ f"{' and/or missing repo license' if not repo_license else ''}). "
594
+ f"Manual review required before commercial closed-source distribution."
595
+ )
596
+ if can_sell and not reasons:
597
+ reasons.append(
598
+ "No strong copyleft (GPL/AGPL-class) signals found in the repository "
599
+ "license or production dependencies. Closed-source commercial sale may "
600
+ "be feasible, subject to attribution, NOTICE, patent, trademark, and "
601
+ "export terms of each permissive license (MIT/BSD/Apache-2.0, etc.)."
602
+ )
603
+ elif can_sell:
604
+ reasons.append(
605
+ "Even without strong copyleft, permissive licenses still require "
606
+ "preserving copyright/license notices upon redistribution."
607
+ )
608
+
609
+ summary = " ".join(reasons)
610
+ summary += (
611
+ " DISCLAIMER: Automated heuristic only — NOT legal advice, NOT a license "
612
+ "compatibility opinion, and NOT a warranty of non-infringement. Consult a "
613
+ "qualified attorney for compliance decisions."
614
+ )
615
+ return can_sell, forces, has_weak, has_unknown, summary, strong_dev_only, has_network
616
+
617
+
618
+ def compute_risk_score(
619
+ repo_license: str | None,
620
+ packages: list[PackageLicense],
621
+ ) -> tuple[int, str]:
622
+ """
623
+ Compute a 0–100 risk score (higher = more concern for closed-source sale).
624
+
625
+ Weight production deps more heavily than dev deps.
626
+ """
627
+ score = 0
628
+ repo_risk = classify_license(repo_license)
629
+ if repo_risk == "strong_copyleft":
630
+ score += 55
631
+ elif repo_risk == "weak_copyleft":
632
+ score += 15
633
+ elif repo_risk == "unknown" or not repo_license:
634
+ score += 10
635
+
636
+ for pkg in packages:
637
+ w = 0.35 if pkg.is_dev else 1.0
638
+ if pkg.risk == "strong_copyleft":
639
+ score += 18 * w
640
+ elif pkg.risk == "weak_copyleft":
641
+ score += 6 * w
642
+ elif pkg.risk == "unknown":
643
+ score += 4 * w
644
+
645
+ score_i = int(min(100, round(score)))
646
+ if score_i >= 70:
647
+ label = "high"
648
+ elif score_i >= 40:
649
+ label = "medium"
650
+ elif score_i >= 15:
651
+ label = "low"
652
+ else:
653
+ label = "minimal"
654
+ return score_i, label
655
+
656
+
657
+ def group_by_license(packages: list[PackageLicense]) -> dict[str, list[PackageLicense]]:
658
+ """Group packages by license id (display key)."""
659
+ grouped: dict[str, list[PackageLicense]] = {}
660
+ for pkg in packages:
661
+ key = pkg.license_id or "Unknown"
662
+ grouped.setdefault(key, []).append(pkg)
663
+ # Sort groups: strong first (red), then weak, unknown, permissive
664
+ risk_order = {"strong_copyleft": 0, "weak_copyleft": 1, "unknown": 2, "permissive": 3}
665
+
666
+ def group_sort_key(item: tuple[str, list[PackageLicense]]) -> tuple[int, str]:
667
+ lic, pkgs = item
668
+ worst = min((risk_order.get(p.risk, 9) for p in pkgs), default=9)
669
+ return (worst, lic.lower())
670
+
671
+ return dict(sorted(grouped.items(), key=group_sort_key))
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Orchestration
676
+ # ---------------------------------------------------------------------------
677
+
678
+ async def analyze_repository(url: str) -> ScanResult:
679
+ """
680
+ Full pipeline: parse URL → GitHub metadata → dependency files →
681
+ registry licenses → verdict → replacements → deploy advice.
682
+ """
683
+ scanned_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
684
+ errors: list[str] = []
685
+
686
+ try:
687
+ owner, repo = parse_github_url(url)
688
+ except ValueError as exc:
689
+ return ScanResult(
690
+ owner="",
691
+ repo="",
692
+ url=url,
693
+ repo_license=None,
694
+ scanned_at=scanned_at,
695
+ can_sell_closed=False,
696
+ forces_open_source=False,
697
+ scan_complete=False,
698
+ verdict_summary=str(exc),
699
+ errors=[str(exc)],
700
+ copyright_notice="",
701
+ )
702
+
703
+ repo_license: str | None = None
704
+ primary_language: str | None = None
705
+ description: str | None = None
706
+ topics: list[str] = []
707
+ dep_paths: list[str] = []
708
+ has_dockerfile = False
709
+ packages: list[PackageLicense] = []
710
+ dependencies: list[Dependency] = []
711
+ html_url = f"https://github.com/{owner}/{repo}"
712
+
713
+ async with create_client() as gh:
714
+ try:
715
+ info = await get_repo_info(gh, owner, repo)
716
+ repo_license = info.get("license_spdx") or info.get("license_name")
717
+ primary_language = info.get("language")
718
+ description = info.get("description")
719
+ topics = list(info.get("topics") or [])
720
+ html_url = info.get("html_url") or html_url
721
+ branch = info["default_branch"]
722
+
723
+ dep_paths, has_dockerfile = await find_dependency_files(
724
+ gh, owner, repo, branch
725
+ )
726
+ if dep_paths:
727
+ contents = await download_dependency_files(
728
+ gh, owner, repo, dep_paths, ref=branch
729
+ )
730
+ dependencies = parse_many(contents)
731
+ else:
732
+ errors.append("No dependency manifest files found in the repository.")
733
+
734
+ except GitHubAPIError as exc:
735
+ errors.append(str(exc))
736
+ # Incomplete scan — do not imply a closed-sale decision either way
737
+ return ScanResult(
738
+ owner=owner,
739
+ repo=repo,
740
+ url=html_url,
741
+ repo_license=None,
742
+ scanned_at=scanned_at,
743
+ can_sell_closed=False,
744
+ forces_open_source=False,
745
+ scan_complete=False,
746
+ has_weak_copyleft=False,
747
+ has_unknown_licenses=True,
748
+ verdict_summary=(
749
+ f"Scan incomplete: {exc} "
750
+ "No closed-source sellability decision can be made until "
751
+ "GitHub data is available. Set GITHUB_TOKEN if you hit rate limits. "
752
+ "DISCLAIMER: Automated heuristic only — NOT legal advice."
753
+ ),
754
+ errors=errors,
755
+ copyright_notice=build_copyright_notice(owner, repo, None),
756
+ )
757
+ except httpx.HTTPError as exc:
758
+ errors.append(f"Network error talking to GitHub: {exc}")
759
+ return ScanResult(
760
+ owner=owner,
761
+ repo=repo,
762
+ url=html_url,
763
+ repo_license=None,
764
+ scanned_at=scanned_at,
765
+ can_sell_closed=False,
766
+ forces_open_source=False,
767
+ scan_complete=False,
768
+ has_weak_copyleft=False,
769
+ has_unknown_licenses=True,
770
+ verdict_summary=(
771
+ f"Scan incomplete (network error): {exc} "
772
+ "DISCLAIMER: Automated heuristic only — NOT legal advice."
773
+ ),
774
+ errors=errors,
775
+ copyright_notice=build_copyright_notice(owner, repo, None),
776
+ )
777
+
778
+ if len(dependencies) > MAX_PACKAGES_LOOKUP:
779
+ errors.append(
780
+ f"Package list truncated to {MAX_PACKAGES_LOOKUP} of {len(dependencies)} "
781
+ f"for registry lookup performance."
782
+ )
783
+
784
+ if dependencies:
785
+ try:
786
+ packages = await resolve_all_licenses(dependencies)
787
+ except httpx.HTTPError as exc:
788
+ errors.append(f"Registry lookup network error: {exc}")
789
+ packages = [
790
+ PackageLicense(
791
+ name=d.name,
792
+ ecosystem=d.ecosystem,
793
+ license_id=None,
794
+ risk="unknown",
795
+ source_file=d.source_file,
796
+ version_spec=d.version_spec,
797
+ is_dev=d.is_dev,
798
+ )
799
+ for d in dependencies[:MAX_PACKAGES_LOOKUP]
800
+ ]
801
+
802
+ (
803
+ can_sell,
804
+ forces,
805
+ has_weak,
806
+ has_unknown,
807
+ summary,
808
+ strong_dev_only,
809
+ has_network,
810
+ ) = compute_verdict(repo_license, packages)
811
+ risk_score, risk_label = compute_risk_score(repo_license, packages)
812
+ grouped = group_by_license(packages)
813
+ replacements = suggest_replacements(packages)
814
+ deploy = recommend_deploy(
815
+ primary_language=primary_language,
816
+ topics=topics,
817
+ dependencies=dependencies,
818
+ dependency_files=dep_paths,
819
+ has_dockerfile=has_dockerfile,
820
+ description=description,
821
+ )
822
+ notice = build_copyright_notice(owner, repo, repo_license)
823
+ prod_n = sum(1 for p in packages if not p.is_dev)
824
+ dev_n = sum(1 for p in packages if p.is_dev)
825
+
826
+ return ScanResult(
827
+ owner=owner,
828
+ repo=repo,
829
+ url=html_url,
830
+ repo_license=repo_license,
831
+ packages=packages,
832
+ grouped=grouped,
833
+ can_sell_closed=can_sell,
834
+ forces_open_source=forces,
835
+ has_weak_copyleft=has_weak,
836
+ has_unknown_licenses=has_unknown,
837
+ verdict_summary=summary,
838
+ copyright_notice=notice,
839
+ replacements=replacements,
840
+ deploy_advice=deploy,
841
+ scanned_at=scanned_at,
842
+ errors=errors,
843
+ scan_complete=True,
844
+ primary_language=primary_language,
845
+ description=description,
846
+ topics=topics,
847
+ dependency_files=dep_paths,
848
+ risk_score=risk_score,
849
+ risk_score_label=risk_label,
850
+ prod_package_count=prod_n,
851
+ dev_package_count=dev_n,
852
+ strong_copyleft_dev_only=strong_dev_only,
853
+ has_network_copyleft=has_network,
854
+ has_dockerfile=has_dockerfile,
855
+ )