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.
- github_license_scanner-0.1.0.dist-info/METADATA +307 -0
- github_license_scanner-0.1.0.dist-info/RECORD +24 -0
- github_license_scanner-0.1.0.dist-info/WHEEL +4 -0
- github_license_scanner-0.1.0.dist-info/entry_points.txt +3 -0
- github_license_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- gls/__init__.py +1 -0
- gls/auth.py +229 -0
- gls/cli.py +358 -0
- gls/config.py +161 -0
- gls/dependency_scanner.py +621 -0
- gls/deploy_advisor.py +286 -0
- gls/docs/LEGAL_DISCLAIMER.md +57 -0
- gls/docs/PRIVACY.md +69 -0
- gls/docs/TERMS.md +52 -0
- gls/github_api.py +514 -0
- gls/history_store.py +177 -0
- gls/i18n.py +530 -0
- gls/license_analyzer.py +855 -0
- gls/models.py +148 -0
- gls/rate_limit.py +64 -0
- gls/report.py +150 -0
- gls/sbom_export.py +312 -0
- gls/spdx_engine.py +441 -0
- gls/webui.py +1828 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dependency manifest parsers for multiple language ecosystems.
|
|
3
|
+
|
|
4
|
+
Each parser receives file path + text content and returns a list of Dependency
|
|
5
|
+
objects. The public entry point is parse_dependencies(path, content).
|
|
6
|
+
|
|
7
|
+
Uses the packaging library for robust Python requirement parsing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import tomllib
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
18
|
+
|
|
19
|
+
from .models import Dependency
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Public API
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def parse_dependencies(path: str, content: str) -> list[Dependency]:
|
|
26
|
+
"""
|
|
27
|
+
Parse a dependency file and return declared packages.
|
|
28
|
+
|
|
29
|
+
The parser is chosen from the file basename. Unknown files yield [].
|
|
30
|
+
"""
|
|
31
|
+
basename = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
32
|
+
lower = basename.lower()
|
|
33
|
+
|
|
34
|
+
if lower == "package.json":
|
|
35
|
+
return _parse_package_json(path, content)
|
|
36
|
+
if lower == "pyproject.toml":
|
|
37
|
+
return _parse_pyproject_toml(path, content)
|
|
38
|
+
if lower == "cargo.toml":
|
|
39
|
+
return _parse_cargo_toml(path, content)
|
|
40
|
+
if lower == "go.mod":
|
|
41
|
+
return _parse_go_mod(path, content)
|
|
42
|
+
if lower == "gemfile":
|
|
43
|
+
return _parse_gemfile(path, content)
|
|
44
|
+
if lower == "composer.json":
|
|
45
|
+
return _parse_composer_json(path, content)
|
|
46
|
+
if lower == "pom.xml":
|
|
47
|
+
return _parse_pom_xml(path, content)
|
|
48
|
+
if lower in {"build.gradle", "build.gradle.kts"}:
|
|
49
|
+
return _parse_gradle(path, content)
|
|
50
|
+
if lower == "pipfile":
|
|
51
|
+
return _parse_pipfile(path, content)
|
|
52
|
+
if lower == "setup.py":
|
|
53
|
+
return _parse_setup_py(path, content)
|
|
54
|
+
if re.match(r"^requirements(-[\w.-]+)?\.txt$", lower):
|
|
55
|
+
return _parse_requirements_txt(path, content)
|
|
56
|
+
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_many(files: dict[str, str]) -> list[Dependency]:
|
|
61
|
+
"""
|
|
62
|
+
Parse multiple path -> content mappings and deduplicate by (ecosystem, name).
|
|
63
|
+
|
|
64
|
+
Prefer production declarations over dev-only: if a package appears as both,
|
|
65
|
+
keep the non-dev entry.
|
|
66
|
+
"""
|
|
67
|
+
by_key: dict[tuple[str, str], Dependency] = {}
|
|
68
|
+
for path, content in files.items():
|
|
69
|
+
for dep in parse_dependencies(path, content):
|
|
70
|
+
key = (dep.ecosystem, dep.name.lower())
|
|
71
|
+
existing = by_key.get(key)
|
|
72
|
+
if existing is None:
|
|
73
|
+
by_key[key] = dep
|
|
74
|
+
continue
|
|
75
|
+
# Upgrade dev → prod if we later see a runtime declaration
|
|
76
|
+
if existing.is_dev and not dep.is_dev:
|
|
77
|
+
by_key[key] = dep
|
|
78
|
+
return list(by_key.values())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# npm / package.json
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def _parse_package_json(path: str, content: str) -> list[Dependency]:
|
|
86
|
+
"""Extract dependencies and devDependencies from package.json."""
|
|
87
|
+
try:
|
|
88
|
+
data = json.loads(content)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
deps: list[Dependency] = []
|
|
93
|
+
for section in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
|
|
94
|
+
block = data.get(section) or {}
|
|
95
|
+
if not isinstance(block, dict):
|
|
96
|
+
continue
|
|
97
|
+
is_dev = section == "devDependencies"
|
|
98
|
+
for name, version in block.items():
|
|
99
|
+
if not isinstance(name, str):
|
|
100
|
+
continue
|
|
101
|
+
version_spec = str(version) if version is not None else None
|
|
102
|
+
# Skip workspace protocol / file: local refs for registry lookup
|
|
103
|
+
if version_spec and (
|
|
104
|
+
version_spec.startswith("workspace:")
|
|
105
|
+
or version_spec.startswith("file:")
|
|
106
|
+
or version_spec.startswith("link:")
|
|
107
|
+
):
|
|
108
|
+
continue
|
|
109
|
+
deps.append(
|
|
110
|
+
Dependency(
|
|
111
|
+
name=name,
|
|
112
|
+
version_spec=version_spec,
|
|
113
|
+
ecosystem="npm",
|
|
114
|
+
source_file=path,
|
|
115
|
+
is_dev=is_dev,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return deps
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Python — requirements.txt / pyproject.toml / Pipfile / setup.py
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def _parse_requirement_line(line: str) -> tuple[str, str | None] | None:
|
|
126
|
+
"""
|
|
127
|
+
Parse a single requirements.txt-style line into (name, version_spec).
|
|
128
|
+
|
|
129
|
+
Returns None for comments, blank lines, flags, and editable/VCS installs.
|
|
130
|
+
"""
|
|
131
|
+
# Strip inline comments
|
|
132
|
+
if "#" in line:
|
|
133
|
+
line = line[: line.index("#")]
|
|
134
|
+
line = line.strip()
|
|
135
|
+
if not line:
|
|
136
|
+
return None
|
|
137
|
+
# pip options: -r, -e, --index-url, etc.
|
|
138
|
+
if line.startswith("-"):
|
|
139
|
+
return None
|
|
140
|
+
# VCS / URL installs
|
|
141
|
+
if re.match(r"^(git\+|hg\+|svn\+|bzr\+|https?://|svn\+)", line, re.I):
|
|
142
|
+
return None
|
|
143
|
+
if line.startswith("."):
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# Environment markers: package>=1; python_version>="3.10"
|
|
147
|
+
req_part = line.split(";", 1)[0].strip()
|
|
148
|
+
# Extras: package[extra]>=1.0
|
|
149
|
+
try:
|
|
150
|
+
req = Requirement(req_part)
|
|
151
|
+
name = req.name
|
|
152
|
+
version_spec = str(req.specifier) if req.specifier else None
|
|
153
|
+
return name, version_spec or None
|
|
154
|
+
except InvalidRequirement:
|
|
155
|
+
# Fallback: crude name extraction
|
|
156
|
+
m = re.match(r"^([A-Za-z0-9_.-]+)", req_part)
|
|
157
|
+
if m:
|
|
158
|
+
return m.group(1), None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _is_dev_requirements_path(path: str) -> bool:
|
|
163
|
+
"""Heuristic: requirements-dev.txt, requirements/test.txt, etc."""
|
|
164
|
+
lower = path.replace("\\", "/").lower()
|
|
165
|
+
base = lower.rsplit("/", 1)[-1]
|
|
166
|
+
return bool(
|
|
167
|
+
re.search(r"(^|[_\-/])(dev|test|tests|lint|docs|doc|ci|typing)([_\-./]|$)", lower)
|
|
168
|
+
or re.search(r"requirements[-_.]?(dev|test|docs)", base)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_requirements_txt(path: str, content: str) -> list[Dependency]:
|
|
173
|
+
"""Parse classic pip requirements files."""
|
|
174
|
+
is_dev = _is_dev_requirements_path(path)
|
|
175
|
+
deps: list[Dependency] = []
|
|
176
|
+
for raw_line in content.splitlines():
|
|
177
|
+
parsed = _parse_requirement_line(raw_line)
|
|
178
|
+
if not parsed:
|
|
179
|
+
continue
|
|
180
|
+
name, version_spec = parsed
|
|
181
|
+
deps.append(
|
|
182
|
+
Dependency(
|
|
183
|
+
name=name,
|
|
184
|
+
version_spec=version_spec,
|
|
185
|
+
ecosystem="pypi",
|
|
186
|
+
source_file=path,
|
|
187
|
+
is_dev=is_dev,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
return deps
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _parse_pyproject_toml(path: str, content: str) -> list[Dependency]:
|
|
194
|
+
"""
|
|
195
|
+
Parse PEP 621, Poetry, and PDM dependency sections from pyproject.toml.
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
data = tomllib.loads(content)
|
|
199
|
+
except Exception: # noqa: BLE001 — invalid TOML
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
deps: list[Dependency] = []
|
|
203
|
+
seen: dict[str, bool] = {} # name -> is_dev (prefer prod)
|
|
204
|
+
|
|
205
|
+
def add_req_string(req_str: str, *, is_dev: bool = False) -> None:
|
|
206
|
+
parsed = _parse_requirement_line(req_str)
|
|
207
|
+
if not parsed:
|
|
208
|
+
return
|
|
209
|
+
name, version_spec = parsed
|
|
210
|
+
key = name.lower()
|
|
211
|
+
if key in seen:
|
|
212
|
+
if seen[key] and not is_dev:
|
|
213
|
+
# upgrade later by removing old and re-adding
|
|
214
|
+
deps[:] = [d for d in deps if d.name.lower() != key]
|
|
215
|
+
else:
|
|
216
|
+
return
|
|
217
|
+
seen[key] = is_dev
|
|
218
|
+
deps.append(
|
|
219
|
+
Dependency(
|
|
220
|
+
name=name,
|
|
221
|
+
version_spec=version_spec,
|
|
222
|
+
ecosystem="pypi",
|
|
223
|
+
source_file=path,
|
|
224
|
+
is_dev=is_dev,
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# PEP 621: [project] dependencies
|
|
229
|
+
project = data.get("project") or {}
|
|
230
|
+
for item in project.get("dependencies") or []:
|
|
231
|
+
if isinstance(item, str):
|
|
232
|
+
add_req_string(item, is_dev=False)
|
|
233
|
+
|
|
234
|
+
# optional-dependencies: treat as dev/extra (not default runtime ship)
|
|
235
|
+
optional = project.get("optional-dependencies") or {}
|
|
236
|
+
if isinstance(optional, dict):
|
|
237
|
+
for group_name, group_reqs in optional.items():
|
|
238
|
+
g_dev = str(group_name).lower() in {
|
|
239
|
+
"dev", "test", "tests", "lint", "docs", "doc", "typing", "ci"
|
|
240
|
+
} or True # extras are optional → mark as dev for closed-sale scoring
|
|
241
|
+
if isinstance(group_reqs, list):
|
|
242
|
+
for item in group_reqs:
|
|
243
|
+
if isinstance(item, str):
|
|
244
|
+
add_req_string(item, is_dev=g_dev)
|
|
245
|
+
|
|
246
|
+
# Poetry: [tool.poetry.dependencies] / group.dev
|
|
247
|
+
poetry = (data.get("tool") or {}).get("poetry") or {}
|
|
248
|
+
for section_name in ("dependencies", "dev-dependencies"):
|
|
249
|
+
block = poetry.get(section_name) or {}
|
|
250
|
+
if not isinstance(block, dict):
|
|
251
|
+
continue
|
|
252
|
+
is_dev = section_name == "dev-dependencies"
|
|
253
|
+
for name, spec in block.items():
|
|
254
|
+
if name.lower() == "python":
|
|
255
|
+
continue
|
|
256
|
+
key = name.lower()
|
|
257
|
+
if key in seen and not (seen[key] and not is_dev):
|
|
258
|
+
if seen[key] and not is_dev:
|
|
259
|
+
deps[:] = [d for d in deps if d.name.lower() != key]
|
|
260
|
+
else:
|
|
261
|
+
continue
|
|
262
|
+
seen[key] = is_dev
|
|
263
|
+
version_spec = None
|
|
264
|
+
if isinstance(spec, str):
|
|
265
|
+
version_spec = spec
|
|
266
|
+
elif isinstance(spec, dict):
|
|
267
|
+
version_spec = str(spec.get("version") or "")
|
|
268
|
+
deps.append(
|
|
269
|
+
Dependency(
|
|
270
|
+
name=name,
|
|
271
|
+
version_spec=version_spec,
|
|
272
|
+
ecosystem="pypi",
|
|
273
|
+
source_file=path,
|
|
274
|
+
is_dev=is_dev,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Poetry 1.2+ groups (usually dev/test)
|
|
279
|
+
groups = poetry.get("group") or {}
|
|
280
|
+
if isinstance(groups, dict):
|
|
281
|
+
for group_name, group in groups.items():
|
|
282
|
+
if not isinstance(group, dict):
|
|
283
|
+
continue
|
|
284
|
+
gdeps = group.get("dependencies") or {}
|
|
285
|
+
if not isinstance(gdeps, dict):
|
|
286
|
+
continue
|
|
287
|
+
is_dev = str(group_name).lower() != "main"
|
|
288
|
+
for name, spec in gdeps.items():
|
|
289
|
+
key = name.lower()
|
|
290
|
+
if key in seen:
|
|
291
|
+
if seen[key] and not is_dev:
|
|
292
|
+
deps[:] = [d for d in deps if d.name.lower() != key]
|
|
293
|
+
else:
|
|
294
|
+
continue
|
|
295
|
+
seen[key] = is_dev
|
|
296
|
+
version_spec = spec if isinstance(spec, str) else None
|
|
297
|
+
deps.append(
|
|
298
|
+
Dependency(
|
|
299
|
+
name=name,
|
|
300
|
+
version_spec=version_spec if isinstance(version_spec, str) else None,
|
|
301
|
+
ecosystem="pypi",
|
|
302
|
+
source_file=path,
|
|
303
|
+
is_dev=is_dev,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return deps
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_pipfile(path: str, content: str) -> list[Dependency]:
|
|
311
|
+
"""Parse Pipenv Pipfile [packages] and [dev-packages]."""
|
|
312
|
+
try:
|
|
313
|
+
data = tomllib.loads(content)
|
|
314
|
+
except Exception: # noqa: BLE001
|
|
315
|
+
return []
|
|
316
|
+
|
|
317
|
+
deps: list[Dependency] = []
|
|
318
|
+
for section in ("packages", "dev-packages"):
|
|
319
|
+
block = data.get(section) or {}
|
|
320
|
+
if not isinstance(block, dict):
|
|
321
|
+
continue
|
|
322
|
+
is_dev = section == "dev-packages"
|
|
323
|
+
for name, spec in block.items():
|
|
324
|
+
version_spec = spec if isinstance(spec, str) else None
|
|
325
|
+
deps.append(
|
|
326
|
+
Dependency(
|
|
327
|
+
name=name,
|
|
328
|
+
version_spec=version_spec,
|
|
329
|
+
ecosystem="pypi",
|
|
330
|
+
source_file=path,
|
|
331
|
+
is_dev=is_dev,
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
return deps
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _parse_setup_py(path: str, content: str) -> list[Dependency]:
|
|
338
|
+
"""
|
|
339
|
+
Best-effort extraction of install_requires from setup.py.
|
|
340
|
+
|
|
341
|
+
Only handles simple list literals; dynamic setup() calls are skipped.
|
|
342
|
+
"""
|
|
343
|
+
deps: list[Dependency] = []
|
|
344
|
+
# install_requires=["a", "b>=1"]
|
|
345
|
+
match = re.search(
|
|
346
|
+
r"install_requires\s*=\s*\[(.*?)\]",
|
|
347
|
+
content,
|
|
348
|
+
re.DOTALL | re.IGNORECASE,
|
|
349
|
+
)
|
|
350
|
+
if not match:
|
|
351
|
+
return deps
|
|
352
|
+
body = match.group(1)
|
|
353
|
+
for item in re.findall(r"['\"]([^'\"]+)['\"]", body):
|
|
354
|
+
parsed = _parse_requirement_line(item)
|
|
355
|
+
if parsed:
|
|
356
|
+
name, version_spec = parsed
|
|
357
|
+
deps.append(
|
|
358
|
+
Dependency(
|
|
359
|
+
name=name,
|
|
360
|
+
version_spec=version_spec,
|
|
361
|
+
ecosystem="pypi",
|
|
362
|
+
source_file=path,
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
return deps
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Rust — Cargo.toml
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
def _parse_cargo_toml(path: str, content: str) -> list[Dependency]:
|
|
373
|
+
"""Parse [dependencies], [dev-dependencies], [build-dependencies]."""
|
|
374
|
+
try:
|
|
375
|
+
data = tomllib.loads(content)
|
|
376
|
+
except Exception: # noqa: BLE001
|
|
377
|
+
return []
|
|
378
|
+
|
|
379
|
+
deps: list[Dependency] = []
|
|
380
|
+
seen: dict[str, bool] = {}
|
|
381
|
+
for section in ("dependencies", "dev-dependencies", "build-dependencies"):
|
|
382
|
+
block = data.get(section) or {}
|
|
383
|
+
if not isinstance(block, dict):
|
|
384
|
+
continue
|
|
385
|
+
is_dev = section in {"dev-dependencies", "build-dependencies"}
|
|
386
|
+
for name, spec in block.items():
|
|
387
|
+
key = name.lower()
|
|
388
|
+
if key in seen:
|
|
389
|
+
if seen[key] and not is_dev:
|
|
390
|
+
deps[:] = [d for d in deps if d.name.lower() != key]
|
|
391
|
+
else:
|
|
392
|
+
continue
|
|
393
|
+
seen[key] = is_dev
|
|
394
|
+
version_spec = None
|
|
395
|
+
if isinstance(spec, str):
|
|
396
|
+
version_spec = spec
|
|
397
|
+
elif isinstance(spec, dict):
|
|
398
|
+
# Skip path/git only crates (not on crates.io)
|
|
399
|
+
if spec.get("path") or spec.get("git"):
|
|
400
|
+
if not spec.get("version"):
|
|
401
|
+
continue
|
|
402
|
+
version_spec = str(spec.get("version") or "") or None
|
|
403
|
+
deps.append(
|
|
404
|
+
Dependency(
|
|
405
|
+
name=name,
|
|
406
|
+
version_spec=version_spec,
|
|
407
|
+
ecosystem="cargo",
|
|
408
|
+
source_file=path,
|
|
409
|
+
is_dev=is_dev,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
return deps
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# ---------------------------------------------------------------------------
|
|
416
|
+
# Go — go.mod
|
|
417
|
+
# ---------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
def _parse_go_mod(path: str, content: str) -> list[Dependency]:
|
|
420
|
+
"""
|
|
421
|
+
Parse require directives from go.mod.
|
|
422
|
+
|
|
423
|
+
Example lines:
|
|
424
|
+
require github.com/foo/bar v1.2.3
|
|
425
|
+
require (
|
|
426
|
+
github.com/a/b v0.1.0
|
|
427
|
+
github.com/c/d v2.0.0 // indirect
|
|
428
|
+
)
|
|
429
|
+
"""
|
|
430
|
+
deps: list[Dependency] = []
|
|
431
|
+
in_block = False
|
|
432
|
+
for raw in content.splitlines():
|
|
433
|
+
line = raw.split("//", 1)[0].strip()
|
|
434
|
+
if not line:
|
|
435
|
+
continue
|
|
436
|
+
if line.startswith("require ("):
|
|
437
|
+
in_block = True
|
|
438
|
+
continue
|
|
439
|
+
if in_block:
|
|
440
|
+
if line == ")":
|
|
441
|
+
in_block = False
|
|
442
|
+
continue
|
|
443
|
+
parts = line.split()
|
|
444
|
+
if len(parts) >= 1:
|
|
445
|
+
name = parts[0]
|
|
446
|
+
version = parts[1] if len(parts) > 1 else None
|
|
447
|
+
deps.append(
|
|
448
|
+
Dependency(
|
|
449
|
+
name=name,
|
|
450
|
+
version_spec=version,
|
|
451
|
+
ecosystem="go",
|
|
452
|
+
source_file=path,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
continue
|
|
456
|
+
if line.startswith("require "):
|
|
457
|
+
rest = line[len("require ") :].strip()
|
|
458
|
+
if rest.startswith("("):
|
|
459
|
+
in_block = True
|
|
460
|
+
continue
|
|
461
|
+
parts = rest.split()
|
|
462
|
+
if parts:
|
|
463
|
+
deps.append(
|
|
464
|
+
Dependency(
|
|
465
|
+
name=parts[0],
|
|
466
|
+
version_spec=parts[1] if len(parts) > 1 else None,
|
|
467
|
+
ecosystem="go",
|
|
468
|
+
source_file=path,
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
return deps
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
# Ruby — Gemfile
|
|
476
|
+
# ---------------------------------------------------------------------------
|
|
477
|
+
|
|
478
|
+
def _parse_gemfile(path: str, content: str) -> list[Dependency]:
|
|
479
|
+
"""Best-effort: gem 'name', gem \"name\", version."""
|
|
480
|
+
deps: list[Dependency] = []
|
|
481
|
+
pattern = re.compile(
|
|
482
|
+
r"""gem\s+['\"]([^'\"]+)['\"](?:\s*,\s*['\"]([^'\"]+)['\"])?""",
|
|
483
|
+
re.IGNORECASE,
|
|
484
|
+
)
|
|
485
|
+
for match in pattern.finditer(content):
|
|
486
|
+
name = match.group(1)
|
|
487
|
+
version = match.group(2)
|
|
488
|
+
deps.append(
|
|
489
|
+
Dependency(
|
|
490
|
+
name=name,
|
|
491
|
+
version_spec=version,
|
|
492
|
+
ecosystem="rubygems",
|
|
493
|
+
source_file=path,
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
return deps
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ---------------------------------------------------------------------------
|
|
500
|
+
# PHP — composer.json
|
|
501
|
+
# ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def _parse_composer_json(path: str, content: str) -> list[Dependency]:
|
|
504
|
+
"""Parse require and require-dev from composer.json."""
|
|
505
|
+
try:
|
|
506
|
+
data = json.loads(content)
|
|
507
|
+
except json.JSONDecodeError:
|
|
508
|
+
return []
|
|
509
|
+
|
|
510
|
+
deps: list[Dependency] = []
|
|
511
|
+
for section in ("require", "require-dev"):
|
|
512
|
+
block = data.get(section) or {}
|
|
513
|
+
if not isinstance(block, dict):
|
|
514
|
+
continue
|
|
515
|
+
is_dev = section == "require-dev"
|
|
516
|
+
for name, version in block.items():
|
|
517
|
+
# Skip php runtime and extensions
|
|
518
|
+
if name == "php" or name.startswith("ext-"):
|
|
519
|
+
continue
|
|
520
|
+
deps.append(
|
|
521
|
+
Dependency(
|
|
522
|
+
name=name,
|
|
523
|
+
version_spec=str(version) if version is not None else None,
|
|
524
|
+
ecosystem="composer",
|
|
525
|
+
source_file=path,
|
|
526
|
+
is_dev=is_dev,
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
return deps
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# ---------------------------------------------------------------------------
|
|
533
|
+
# Java — pom.xml / Gradle (best-effort)
|
|
534
|
+
# ---------------------------------------------------------------------------
|
|
535
|
+
|
|
536
|
+
def _parse_pom_xml(path: str, content: str) -> list[Dependency]:
|
|
537
|
+
"""Extract groupId:artifactId pairs from a Maven pom.xml (simple regex)."""
|
|
538
|
+
deps: list[Dependency] = []
|
|
539
|
+
# Match dependency blocks with groupId + artifactId
|
|
540
|
+
pattern = re.compile(
|
|
541
|
+
r"<dependency>\s*"
|
|
542
|
+
r"<groupId>([^<]+)</groupId>\s*"
|
|
543
|
+
r"<artifactId>([^<]+)</artifactId>"
|
|
544
|
+
r"(?:\s*<version>([^<]+)</version>)?",
|
|
545
|
+
re.DOTALL | re.IGNORECASE,
|
|
546
|
+
)
|
|
547
|
+
for match in pattern.finditer(content):
|
|
548
|
+
group_id = match.group(1).strip()
|
|
549
|
+
artifact_id = match.group(2).strip()
|
|
550
|
+
version = match.group(3).strip() if match.group(3) else None
|
|
551
|
+
# Skip property placeholders only versions without names
|
|
552
|
+
if "${" in group_id or "${" in artifact_id:
|
|
553
|
+
continue
|
|
554
|
+
name = f"{group_id}:{artifact_id}"
|
|
555
|
+
deps.append(
|
|
556
|
+
Dependency(
|
|
557
|
+
name=name,
|
|
558
|
+
version_spec=version,
|
|
559
|
+
ecosystem="maven",
|
|
560
|
+
source_file=path,
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
return deps
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _parse_gradle(path: str, content: str) -> list[Dependency]:
|
|
567
|
+
"""
|
|
568
|
+
Best-effort Gradle dependency extraction.
|
|
569
|
+
|
|
570
|
+
Matches lines like:
|
|
571
|
+
implementation 'com.google.guava:guava:31.0'
|
|
572
|
+
implementation("org.slf4j:slf4j-api:2.0.0")
|
|
573
|
+
|
|
574
|
+
test*/compileOnly configurations are marked is_dev=True so they do not
|
|
575
|
+
auto-force open-source on the production sellability heuristic.
|
|
576
|
+
"""
|
|
577
|
+
deps: list[Dependency] = []
|
|
578
|
+
pattern = re.compile(
|
|
579
|
+
r"""(?P<config>implementation|api|compileOnly|runtimeOnly|testImplementation|"""
|
|
580
|
+
r"""testCompileOnly|testRuntimeOnly|compile|testCompile|annotationProcessor)"""
|
|
581
|
+
r"""\s*[\(]?\s*['\"]([^:'\"]+):([^:'\"]+)(?::([^'\"]+))?['\"]""",
|
|
582
|
+
re.IGNORECASE,
|
|
583
|
+
)
|
|
584
|
+
dev_configs = {
|
|
585
|
+
"compileonly",
|
|
586
|
+
"testimplementation",
|
|
587
|
+
"testcompileonly",
|
|
588
|
+
"testruntimeonly",
|
|
589
|
+
"testcompile",
|
|
590
|
+
"annotationprocessor",
|
|
591
|
+
}
|
|
592
|
+
for match in pattern.finditer(content):
|
|
593
|
+
config = (match.group("config") or "").lower()
|
|
594
|
+
group_id, artifact_id, version = match.group(2), match.group(3), match.group(4)
|
|
595
|
+
name = f"{group_id}:{artifact_id}"
|
|
596
|
+
deps.append(
|
|
597
|
+
Dependency(
|
|
598
|
+
name=name,
|
|
599
|
+
version_spec=version,
|
|
600
|
+
ecosystem="gradle",
|
|
601
|
+
source_file=path,
|
|
602
|
+
is_dev=config in dev_configs,
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
return deps
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# Map ecosystem -> human label (for UI)
|
|
609
|
+
ECOSYSTEM_LABELS: dict[str, str] = {
|
|
610
|
+
"npm": "npm (Node.js)",
|
|
611
|
+
"pypi": "PyPI (Python)",
|
|
612
|
+
"cargo": "crates.io (Rust)",
|
|
613
|
+
"go": "Go modules",
|
|
614
|
+
"rubygems": "RubyGems",
|
|
615
|
+
"composer": "Packagist (PHP)",
|
|
616
|
+
"maven": "Maven",
|
|
617
|
+
"gradle": "Gradle",
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
# Type alias for custom parsers (extension point)
|
|
621
|
+
ParserFn = Callable[[str, str], list[Dependency]]
|