sourcecode 0.2.0__py3-none-any.whl → 0.3.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +28 -0
- sourcecode/dependency_analyzer.py +932 -0
- sourcecode/schema.py +30 -0
- {sourcecode-0.2.0.dist-info → sourcecode-0.3.0.dist-info}/METADATA +52 -1
- {sourcecode-0.2.0.dist-info → sourcecode-0.3.0.dist-info}/RECORD +8 -7
- {sourcecode-0.2.0.dist-info → sourcecode-0.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.2.0.dist-info → sourcecode-0.3.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -46,6 +46,11 @@ def main(
|
|
|
46
46
|
"--compact",
|
|
47
47
|
help="Output reducido (~500 tokens): tipo de proyecto, stacks, entradas y arbol nivel 1",
|
|
48
48
|
),
|
|
49
|
+
dependencies: bool = typer.Option(
|
|
50
|
+
False,
|
|
51
|
+
"--dependencies",
|
|
52
|
+
help="Incluir dependencias directas, versiones exactas y transitivas cuando haya lockfiles compatibles",
|
|
53
|
+
),
|
|
49
54
|
no_redact: bool = typer.Option(
|
|
50
55
|
False,
|
|
51
56
|
"--no-redact",
|
|
@@ -88,6 +93,7 @@ def main(
|
|
|
88
93
|
# --- Importar modulos de logica ---
|
|
89
94
|
from dataclasses import asdict, replace
|
|
90
95
|
|
|
96
|
+
from sourcecode.dependency_analyzer import DependencyAnalyzer
|
|
91
97
|
from sourcecode.detectors import ProjectDetector, build_default_detectors
|
|
92
98
|
from sourcecode.redactor import SecretRedactor, redact_dict
|
|
93
99
|
from sourcecode.scanner import FileScanner
|
|
@@ -116,6 +122,7 @@ def main(
|
|
|
116
122
|
manifests = scanner.find_manifests()
|
|
117
123
|
detector = ProjectDetector(build_default_detectors())
|
|
118
124
|
workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
|
|
125
|
+
dependency_analyzer = DependencyAnalyzer() if dependencies else None
|
|
119
126
|
|
|
120
127
|
root_manifests = [
|
|
121
128
|
manifest
|
|
@@ -129,6 +136,13 @@ def main(
|
|
|
129
136
|
else:
|
|
130
137
|
stacks, entry_points, _project_type = detector.detect(target, file_tree, detection_manifests)
|
|
131
138
|
|
|
139
|
+
dependency_records = []
|
|
140
|
+
dependency_summaries = []
|
|
141
|
+
if dependency_analyzer is not None:
|
|
142
|
+
root_dependencies, root_summary = dependency_analyzer.analyze(target)
|
|
143
|
+
dependency_records.extend(root_dependencies)
|
|
144
|
+
dependency_summaries.append(root_summary)
|
|
145
|
+
|
|
132
146
|
for workspace in workspace_analysis.workspaces:
|
|
133
147
|
workspace_root = target / workspace.path
|
|
134
148
|
if not workspace_root.exists() or not workspace_root.is_dir():
|
|
@@ -153,6 +167,13 @@ def main(
|
|
|
153
167
|
)
|
|
154
168
|
for entry_point in workspace_entry_points
|
|
155
169
|
)
|
|
170
|
+
if dependency_analyzer is not None:
|
|
171
|
+
workspace_dependencies, workspace_summary = dependency_analyzer.analyze(
|
|
172
|
+
workspace_root,
|
|
173
|
+
workspace=workspace.path,
|
|
174
|
+
)
|
|
175
|
+
dependency_records.extend(workspace_dependencies)
|
|
176
|
+
dependency_summaries.append(workspace_summary)
|
|
156
177
|
|
|
157
178
|
stacks, project_type = detector.classify_results(
|
|
158
179
|
file_tree,
|
|
@@ -160,6 +181,11 @@ def main(
|
|
|
160
181
|
entry_points,
|
|
161
182
|
project_type_override="monorepo" if workspace_analysis.is_monorepo else None,
|
|
162
183
|
)
|
|
184
|
+
dependency_summary = (
|
|
185
|
+
dependency_analyzer.merge_summaries(dependency_summaries)
|
|
186
|
+
if dependency_analyzer is not None
|
|
187
|
+
else None
|
|
188
|
+
)
|
|
163
189
|
|
|
164
190
|
# 3. Construir el schema
|
|
165
191
|
metadata = AnalysisMetadata(analyzed_path=str(target))
|
|
@@ -169,6 +195,8 @@ def main(
|
|
|
169
195
|
stacks=stacks,
|
|
170
196
|
project_type=project_type,
|
|
171
197
|
entry_points=entry_points,
|
|
198
|
+
dependencies=dependency_records,
|
|
199
|
+
dependency_summary=dependency_summary,
|
|
172
200
|
)
|
|
173
201
|
|
|
174
202
|
# 4. Serializar (con o sin modo compact)
|
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"""Analisis offline de dependencias directas y transitivas."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from ruamel.yaml import YAML
|
|
12
|
+
|
|
13
|
+
from sourcecode.detectors.parsers import load_json_file, load_toml_file, read_text_lines
|
|
14
|
+
from sourcecode.schema import DependencyRecord, DependencySummary
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DependencyAnalyzer:
|
|
18
|
+
"""Resuelve dependencias desde manifests y lockfiles sin ejecutar toolchains."""
|
|
19
|
+
|
|
20
|
+
def analyze(
|
|
21
|
+
self,
|
|
22
|
+
root: Path,
|
|
23
|
+
*,
|
|
24
|
+
workspace: str | None = None,
|
|
25
|
+
) -> tuple[list[DependencyRecord], DependencySummary]:
|
|
26
|
+
records: list[DependencyRecord] = []
|
|
27
|
+
limitations: list[str] = []
|
|
28
|
+
|
|
29
|
+
for handler in (
|
|
30
|
+
self._analyze_node,
|
|
31
|
+
self._analyze_python,
|
|
32
|
+
self._analyze_php,
|
|
33
|
+
self._analyze_ruby,
|
|
34
|
+
self._analyze_rust,
|
|
35
|
+
self._analyze_go,
|
|
36
|
+
self._analyze_dotnet,
|
|
37
|
+
):
|
|
38
|
+
handler_records, handler_limitations = handler(root)
|
|
39
|
+
records.extend(replace(record, workspace=workspace) for record in handler_records)
|
|
40
|
+
limitations.extend(handler_limitations)
|
|
41
|
+
|
|
42
|
+
deduped = self._dedupe(records)
|
|
43
|
+
return deduped, self._build_summary(deduped, limitations)
|
|
44
|
+
|
|
45
|
+
def merge_summaries(self, summaries: Iterable[DependencySummary]) -> DependencySummary:
|
|
46
|
+
result = DependencySummary(requested=True)
|
|
47
|
+
ecosystems: set[str] = set()
|
|
48
|
+
sources: set[str] = set()
|
|
49
|
+
limitations: list[str] = []
|
|
50
|
+
for summary in summaries:
|
|
51
|
+
result.total_count += summary.total_count
|
|
52
|
+
result.direct_count += summary.direct_count
|
|
53
|
+
result.transitive_count += summary.transitive_count
|
|
54
|
+
ecosystems.update(summary.ecosystems)
|
|
55
|
+
sources.update(summary.sources)
|
|
56
|
+
for limitation in summary.limitations:
|
|
57
|
+
if limitation not in limitations:
|
|
58
|
+
limitations.append(limitation)
|
|
59
|
+
result.ecosystems = sorted(ecosystems)
|
|
60
|
+
result.sources = sorted(sources)
|
|
61
|
+
result.limitations = limitations
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def _build_summary(
|
|
65
|
+
self, records: list[DependencyRecord], limitations: list[str]
|
|
66
|
+
) -> DependencySummary:
|
|
67
|
+
sources = sorted({record.source for record in records})
|
|
68
|
+
ecosystems = sorted({record.ecosystem for record in records})
|
|
69
|
+
direct_count = sum(1 for record in records if record.scope != "transitive")
|
|
70
|
+
transitive_count = sum(1 for record in records if record.scope == "transitive")
|
|
71
|
+
unique_limitations: list[str] = []
|
|
72
|
+
for limitation in limitations:
|
|
73
|
+
if limitation not in unique_limitations:
|
|
74
|
+
unique_limitations.append(limitation)
|
|
75
|
+
return DependencySummary(
|
|
76
|
+
requested=True,
|
|
77
|
+
total_count=len(records),
|
|
78
|
+
direct_count=direct_count,
|
|
79
|
+
transitive_count=transitive_count,
|
|
80
|
+
ecosystems=ecosystems,
|
|
81
|
+
sources=sources,
|
|
82
|
+
limitations=unique_limitations,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _dedupe(self, records: Iterable[DependencyRecord]) -> list[DependencyRecord]:
|
|
86
|
+
seen: set[tuple[Any, ...]] = set()
|
|
87
|
+
deduped: list[DependencyRecord] = []
|
|
88
|
+
for record in records:
|
|
89
|
+
key = (
|
|
90
|
+
record.workspace,
|
|
91
|
+
record.ecosystem,
|
|
92
|
+
record.name,
|
|
93
|
+
record.scope,
|
|
94
|
+
record.declared_version,
|
|
95
|
+
record.resolved_version,
|
|
96
|
+
record.source,
|
|
97
|
+
record.parent,
|
|
98
|
+
record.manifest_path,
|
|
99
|
+
)
|
|
100
|
+
if key not in seen:
|
|
101
|
+
seen.add(key)
|
|
102
|
+
deduped.append(record)
|
|
103
|
+
return deduped
|
|
104
|
+
|
|
105
|
+
def _analyze_node(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
106
|
+
package_json = load_json_file(root / "package.json")
|
|
107
|
+
if package_json is None:
|
|
108
|
+
return [], []
|
|
109
|
+
|
|
110
|
+
direct = self._parse_node_direct_dependencies(package_json)
|
|
111
|
+
direct_names = {record.name for record in direct}
|
|
112
|
+
records = list(direct)
|
|
113
|
+
|
|
114
|
+
package_lock = load_json_file(root / "package-lock.json")
|
|
115
|
+
if package_lock is not None:
|
|
116
|
+
records = self._merge_resolved(records, self._parse_package_lock(package_lock, direct_names))
|
|
117
|
+
return records, []
|
|
118
|
+
|
|
119
|
+
pnpm_lock = self._load_yaml_file(root / "pnpm-lock.yaml")
|
|
120
|
+
if isinstance(pnpm_lock, dict):
|
|
121
|
+
records = self._merge_resolved(records, self._parse_pnpm_lock(pnpm_lock, direct))
|
|
122
|
+
return records, []
|
|
123
|
+
|
|
124
|
+
yarn_lock = root / "yarn.lock"
|
|
125
|
+
if yarn_lock.exists():
|
|
126
|
+
return records, ["nodejs: yarn.lock detectado; resolucion transitiva no implementada"]
|
|
127
|
+
|
|
128
|
+
return records, []
|
|
129
|
+
|
|
130
|
+
def _parse_node_direct_dependencies(self, package_json: dict[str, Any]) -> list[DependencyRecord]:
|
|
131
|
+
records: list[DependencyRecord] = []
|
|
132
|
+
field_scope = {
|
|
133
|
+
"dependencies": "direct",
|
|
134
|
+
"devDependencies": "dev",
|
|
135
|
+
"peerDependencies": "peer",
|
|
136
|
+
"optionalDependencies": "optional",
|
|
137
|
+
}
|
|
138
|
+
for field, scope in field_scope.items():
|
|
139
|
+
raw = package_json.get(field, {})
|
|
140
|
+
if not isinstance(raw, dict):
|
|
141
|
+
continue
|
|
142
|
+
for name, version in raw.items():
|
|
143
|
+
records.append(
|
|
144
|
+
DependencyRecord(
|
|
145
|
+
name=str(name),
|
|
146
|
+
ecosystem="nodejs",
|
|
147
|
+
scope=scope,
|
|
148
|
+
declared_version=str(version),
|
|
149
|
+
source="manifest",
|
|
150
|
+
manifest_path="package.json",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return records
|
|
154
|
+
|
|
155
|
+
def _parse_package_lock(
|
|
156
|
+
self, package_lock: dict[str, Any], direct_names: set[str]
|
|
157
|
+
) -> list[DependencyRecord]:
|
|
158
|
+
packages = package_lock.get("packages")
|
|
159
|
+
if isinstance(packages, dict) and packages:
|
|
160
|
+
parent_map = self._node_parent_map_from_packages(packages)
|
|
161
|
+
records: list[DependencyRecord] = []
|
|
162
|
+
for package_path, info in packages.items():
|
|
163
|
+
if not package_path:
|
|
164
|
+
continue
|
|
165
|
+
if not isinstance(info, dict):
|
|
166
|
+
continue
|
|
167
|
+
name = self._node_name_from_package_path(package_path)
|
|
168
|
+
if not name:
|
|
169
|
+
continue
|
|
170
|
+
version = info.get("version")
|
|
171
|
+
scope = "direct" if name in direct_names else "transitive"
|
|
172
|
+
records.append(
|
|
173
|
+
DependencyRecord(
|
|
174
|
+
name=name,
|
|
175
|
+
ecosystem="nodejs",
|
|
176
|
+
scope=scope,
|
|
177
|
+
resolved_version=str(version) if version is not None else None,
|
|
178
|
+
source="lockfile",
|
|
179
|
+
parent=parent_map.get(name),
|
|
180
|
+
manifest_path="package-lock.json",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return records
|
|
184
|
+
|
|
185
|
+
dependencies = package_lock.get("dependencies")
|
|
186
|
+
if isinstance(dependencies, dict):
|
|
187
|
+
return self._walk_npm_dependency_tree(dependencies, direct_names)
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
def _walk_npm_dependency_tree(
|
|
191
|
+
self,
|
|
192
|
+
dependencies: dict[str, Any],
|
|
193
|
+
direct_names: set[str],
|
|
194
|
+
*,
|
|
195
|
+
parent: str | None = None,
|
|
196
|
+
) -> list[DependencyRecord]:
|
|
197
|
+
records: list[DependencyRecord] = []
|
|
198
|
+
for name, info in dependencies.items():
|
|
199
|
+
if not isinstance(info, dict):
|
|
200
|
+
continue
|
|
201
|
+
scope = "direct" if parent is None and name in direct_names else "transitive"
|
|
202
|
+
records.append(
|
|
203
|
+
DependencyRecord(
|
|
204
|
+
name=str(name),
|
|
205
|
+
ecosystem="nodejs",
|
|
206
|
+
scope=scope,
|
|
207
|
+
resolved_version=str(info.get("version")) if info.get("version") is not None else None,
|
|
208
|
+
source="lockfile",
|
|
209
|
+
parent=parent,
|
|
210
|
+
manifest_path="package-lock.json",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
child_dependencies = info.get("dependencies")
|
|
214
|
+
if isinstance(child_dependencies, dict):
|
|
215
|
+
records.extend(
|
|
216
|
+
self._walk_npm_dependency_tree(child_dependencies, direct_names, parent=str(name))
|
|
217
|
+
)
|
|
218
|
+
return records
|
|
219
|
+
|
|
220
|
+
def _node_parent_map_from_packages(self, packages: dict[str, Any]) -> dict[str, str]:
|
|
221
|
+
parent_map: dict[str, str] = {}
|
|
222
|
+
for package_path, info in packages.items():
|
|
223
|
+
parent_name = self._node_name_from_package_path(package_path)
|
|
224
|
+
if not parent_name or not isinstance(info, dict):
|
|
225
|
+
continue
|
|
226
|
+
dependencies = info.get("dependencies")
|
|
227
|
+
if not isinstance(dependencies, dict):
|
|
228
|
+
continue
|
|
229
|
+
for dependency_name in dependencies:
|
|
230
|
+
parent_map.setdefault(str(dependency_name), parent_name)
|
|
231
|
+
return parent_map
|
|
232
|
+
|
|
233
|
+
def _node_name_from_package_path(self, package_path: str) -> str:
|
|
234
|
+
marker = "node_modules/"
|
|
235
|
+
if marker not in package_path:
|
|
236
|
+
return package_path.strip("/")
|
|
237
|
+
return package_path.rsplit(marker, 1)[-1]
|
|
238
|
+
|
|
239
|
+
def _parse_pnpm_lock(
|
|
240
|
+
self,
|
|
241
|
+
lock_data: dict[str, Any],
|
|
242
|
+
direct_records: list[DependencyRecord],
|
|
243
|
+
) -> list[DependencyRecord]:
|
|
244
|
+
records: list[DependencyRecord] = []
|
|
245
|
+
direct_by_name = {record.name: record for record in direct_records}
|
|
246
|
+
|
|
247
|
+
importers = lock_data.get("importers", {})
|
|
248
|
+
importer_data = importers.get(".") if isinstance(importers, dict) else None
|
|
249
|
+
if isinstance(importer_data, dict):
|
|
250
|
+
for field, scope in (
|
|
251
|
+
("dependencies", "direct"),
|
|
252
|
+
("devDependencies", "dev"),
|
|
253
|
+
("peerDependencies", "peer"),
|
|
254
|
+
("optionalDependencies", "optional"),
|
|
255
|
+
):
|
|
256
|
+
raw = importer_data.get(field, {})
|
|
257
|
+
if not isinstance(raw, dict):
|
|
258
|
+
continue
|
|
259
|
+
for name, details in raw.items():
|
|
260
|
+
resolved_version = self._extract_pnpm_version(details)
|
|
261
|
+
record = direct_by_name.get(str(name))
|
|
262
|
+
records.append(
|
|
263
|
+
DependencyRecord(
|
|
264
|
+
name=str(name),
|
|
265
|
+
ecosystem="nodejs",
|
|
266
|
+
scope=scope,
|
|
267
|
+
declared_version=record.declared_version if record else None,
|
|
268
|
+
resolved_version=resolved_version,
|
|
269
|
+
source="lockfile",
|
|
270
|
+
manifest_path="pnpm-lock.yaml",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
packages = lock_data.get("packages", {})
|
|
275
|
+
if isinstance(packages, dict):
|
|
276
|
+
parent_map = self._pnpm_parent_map(packages)
|
|
277
|
+
for key, info in packages.items():
|
|
278
|
+
name, version = self._pnpm_package_key(key)
|
|
279
|
+
if not name or name in direct_by_name:
|
|
280
|
+
continue
|
|
281
|
+
if not isinstance(info, dict):
|
|
282
|
+
info = {}
|
|
283
|
+
records.append(
|
|
284
|
+
DependencyRecord(
|
|
285
|
+
name=name,
|
|
286
|
+
ecosystem="nodejs",
|
|
287
|
+
scope="transitive",
|
|
288
|
+
resolved_version=version,
|
|
289
|
+
source="lockfile",
|
|
290
|
+
parent=parent_map.get(name),
|
|
291
|
+
manifest_path="pnpm-lock.yaml",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
return records
|
|
295
|
+
|
|
296
|
+
def _pnpm_parent_map(self, packages: dict[str, Any]) -> dict[str, str]:
|
|
297
|
+
parent_map: dict[str, str] = {}
|
|
298
|
+
for key, info in packages.items():
|
|
299
|
+
parent_name, _version = self._pnpm_package_key(key)
|
|
300
|
+
if not parent_name or not isinstance(info, dict):
|
|
301
|
+
continue
|
|
302
|
+
dependencies = info.get("dependencies", {})
|
|
303
|
+
if not isinstance(dependencies, dict):
|
|
304
|
+
continue
|
|
305
|
+
for dependency_name in dependencies:
|
|
306
|
+
parent_map.setdefault(str(dependency_name), parent_name)
|
|
307
|
+
return parent_map
|
|
308
|
+
|
|
309
|
+
def _pnpm_package_key(self, key: str) -> tuple[str, Optional[str]]:
|
|
310
|
+
cleaned = key.lstrip("/")
|
|
311
|
+
if "@" not in cleaned:
|
|
312
|
+
return cleaned, None
|
|
313
|
+
if cleaned.startswith("@"):
|
|
314
|
+
parts = cleaned.rsplit("@", 1)
|
|
315
|
+
if len(parts) == 2:
|
|
316
|
+
return parts[0], parts[1]
|
|
317
|
+
name, version = cleaned.split("@", 1)
|
|
318
|
+
return name, version
|
|
319
|
+
|
|
320
|
+
def _extract_pnpm_version(self, details: Any) -> Optional[str]:
|
|
321
|
+
if isinstance(details, str):
|
|
322
|
+
return details
|
|
323
|
+
if isinstance(details, dict):
|
|
324
|
+
version = details.get("version")
|
|
325
|
+
return str(version) if version is not None else None
|
|
326
|
+
return None
|
|
327
|
+
|
|
328
|
+
def _analyze_python(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
329
|
+
direct = self._parse_python_direct_dependencies(root)
|
|
330
|
+
if not direct:
|
|
331
|
+
return [], []
|
|
332
|
+
|
|
333
|
+
direct_names = {record.name for record in direct}
|
|
334
|
+
records = list(direct)
|
|
335
|
+
limitations: list[str] = []
|
|
336
|
+
|
|
337
|
+
poetry_lock = load_toml_file(root / "poetry.lock")
|
|
338
|
+
if poetry_lock is not None:
|
|
339
|
+
records = self._merge_resolved(records, self._parse_python_lock_packages(poetry_lock, direct_names, "poetry.lock"))
|
|
340
|
+
|
|
341
|
+
uv_lock = load_toml_file(root / "uv.lock")
|
|
342
|
+
if uv_lock is not None:
|
|
343
|
+
records = self._merge_resolved(records, self._parse_python_lock_packages(uv_lock, direct_names, "uv.lock"))
|
|
344
|
+
|
|
345
|
+
pipfile_lock = load_json_file(root / "Pipfile.lock")
|
|
346
|
+
if pipfile_lock is not None:
|
|
347
|
+
records = self._merge_resolved(records, self._parse_pipfile_lock(pipfile_lock, direct_names))
|
|
348
|
+
|
|
349
|
+
if not any(record.source == "lockfile" for record in records):
|
|
350
|
+
requirements_lock = root / "requirements.lock"
|
|
351
|
+
if requirements_lock.exists():
|
|
352
|
+
limitations.append("python: requirements.lock detectado pero no parseado en esta fase")
|
|
353
|
+
|
|
354
|
+
return records, limitations
|
|
355
|
+
|
|
356
|
+
def _parse_python_direct_dependencies(self, root: Path) -> list[DependencyRecord]:
|
|
357
|
+
records: list[DependencyRecord] = []
|
|
358
|
+
pyproject = load_toml_file(root / "pyproject.toml")
|
|
359
|
+
if pyproject:
|
|
360
|
+
project = pyproject.get("project", {})
|
|
361
|
+
if isinstance(project, dict):
|
|
362
|
+
for dependency in project.get("dependencies", []):
|
|
363
|
+
parsed = self._split_python_requirement(dependency)
|
|
364
|
+
if parsed is not None:
|
|
365
|
+
name, version = parsed
|
|
366
|
+
records.append(
|
|
367
|
+
DependencyRecord(
|
|
368
|
+
name=name,
|
|
369
|
+
ecosystem="python",
|
|
370
|
+
declared_version=version,
|
|
371
|
+
source="manifest",
|
|
372
|
+
manifest_path="pyproject.toml",
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
optional = project.get("optional-dependencies", {})
|
|
376
|
+
if isinstance(optional, dict):
|
|
377
|
+
for group_name, group in optional.items():
|
|
378
|
+
if not isinstance(group, list):
|
|
379
|
+
continue
|
|
380
|
+
for dependency in group:
|
|
381
|
+
parsed = self._split_python_requirement(dependency)
|
|
382
|
+
if parsed is not None:
|
|
383
|
+
name, version = parsed
|
|
384
|
+
records.append(
|
|
385
|
+
DependencyRecord(
|
|
386
|
+
name=name,
|
|
387
|
+
ecosystem="python",
|
|
388
|
+
scope=f"optional:{group_name}",
|
|
389
|
+
declared_version=version,
|
|
390
|
+
source="manifest",
|
|
391
|
+
manifest_path="pyproject.toml",
|
|
392
|
+
)
|
|
393
|
+
)
|
|
394
|
+
tool = pyproject.get("tool", {})
|
|
395
|
+
if isinstance(tool, dict):
|
|
396
|
+
poetry = tool.get("poetry", {})
|
|
397
|
+
if isinstance(poetry, dict):
|
|
398
|
+
for name, raw in poetry.get("dependencies", {}).items():
|
|
399
|
+
if str(name).lower() == "python":
|
|
400
|
+
continue
|
|
401
|
+
records.append(
|
|
402
|
+
DependencyRecord(
|
|
403
|
+
name=str(name).lower(),
|
|
404
|
+
ecosystem="python",
|
|
405
|
+
declared_version=self._normalize_declared_version(raw),
|
|
406
|
+
source="manifest",
|
|
407
|
+
manifest_path="pyproject.toml",
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
groups = poetry.get("group", {})
|
|
411
|
+
if isinstance(groups, dict):
|
|
412
|
+
for group_name, group in groups.items():
|
|
413
|
+
if not isinstance(group, dict):
|
|
414
|
+
continue
|
|
415
|
+
dependencies = group.get("dependencies", {})
|
|
416
|
+
if not isinstance(dependencies, dict):
|
|
417
|
+
continue
|
|
418
|
+
for name, raw in dependencies.items():
|
|
419
|
+
records.append(
|
|
420
|
+
DependencyRecord(
|
|
421
|
+
name=str(name).lower(),
|
|
422
|
+
ecosystem="python",
|
|
423
|
+
scope=f"group:{group_name}",
|
|
424
|
+
declared_version=self._normalize_declared_version(raw),
|
|
425
|
+
source="manifest",
|
|
426
|
+
manifest_path="pyproject.toml",
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
for path in ("requirements.txt", "requirements-dev.txt"):
|
|
431
|
+
file_path = root / path
|
|
432
|
+
if not file_path.exists():
|
|
433
|
+
continue
|
|
434
|
+
scope = "dev" if "dev" in path else "direct"
|
|
435
|
+
for line in read_text_lines(file_path):
|
|
436
|
+
parsed = self._split_python_requirement(line)
|
|
437
|
+
if parsed is None:
|
|
438
|
+
continue
|
|
439
|
+
name, version = parsed
|
|
440
|
+
records.append(
|
|
441
|
+
DependencyRecord(
|
|
442
|
+
name=name,
|
|
443
|
+
ecosystem="python",
|
|
444
|
+
scope=scope,
|
|
445
|
+
declared_version=version,
|
|
446
|
+
source="manifest",
|
|
447
|
+
manifest_path=path,
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
return self._dedupe(records)
|
|
452
|
+
|
|
453
|
+
def _split_python_requirement(self, raw: Any) -> Optional[tuple[str, Optional[str]]]:
|
|
454
|
+
if not isinstance(raw, str):
|
|
455
|
+
return None
|
|
456
|
+
stripped = raw.strip()
|
|
457
|
+
if not stripped or stripped.startswith(("#", "[")):
|
|
458
|
+
return None
|
|
459
|
+
cleaned = stripped.split(";", 1)[0].strip()
|
|
460
|
+
cleaned = cleaned.split("#", 1)[0].strip()
|
|
461
|
+
match = re.match(r"^([A-Za-z0-9_.-]+)\s*(.*)$", cleaned)
|
|
462
|
+
if match is None:
|
|
463
|
+
return None
|
|
464
|
+
name = match.group(1).lower().replace("_", "-")
|
|
465
|
+
version = match.group(2).strip() or None
|
|
466
|
+
return name, version
|
|
467
|
+
|
|
468
|
+
def _normalize_declared_version(self, raw: Any) -> Optional[str]:
|
|
469
|
+
if isinstance(raw, str):
|
|
470
|
+
return raw
|
|
471
|
+
if isinstance(raw, dict):
|
|
472
|
+
version = raw.get("version")
|
|
473
|
+
return str(version) if version is not None else None
|
|
474
|
+
return None
|
|
475
|
+
|
|
476
|
+
def _parse_python_lock_packages(
|
|
477
|
+
self,
|
|
478
|
+
lock_data: dict[str, Any],
|
|
479
|
+
direct_names: set[str],
|
|
480
|
+
manifest_path: str,
|
|
481
|
+
) -> list[DependencyRecord]:
|
|
482
|
+
raw_packages = lock_data.get("package", [])
|
|
483
|
+
if not isinstance(raw_packages, list):
|
|
484
|
+
return []
|
|
485
|
+
parent_map: dict[str, str] = {}
|
|
486
|
+
records: list[DependencyRecord] = []
|
|
487
|
+
for package in raw_packages:
|
|
488
|
+
if not isinstance(package, dict):
|
|
489
|
+
continue
|
|
490
|
+
name = str(package.get("name", "")).lower()
|
|
491
|
+
if not name:
|
|
492
|
+
continue
|
|
493
|
+
dependencies = package.get("dependencies", {})
|
|
494
|
+
for dependency_name in self._iter_dependency_names(dependencies):
|
|
495
|
+
parent_map.setdefault(dependency_name, name)
|
|
496
|
+
records.append(
|
|
497
|
+
DependencyRecord(
|
|
498
|
+
name=name,
|
|
499
|
+
ecosystem="python",
|
|
500
|
+
scope="direct" if name in direct_names else "transitive",
|
|
501
|
+
resolved_version=str(package.get("version")) if package.get("version") is not None else None,
|
|
502
|
+
source="lockfile",
|
|
503
|
+
parent=parent_map.get(name),
|
|
504
|
+
manifest_path=manifest_path,
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
return records
|
|
508
|
+
|
|
509
|
+
def _parse_pipfile_lock(
|
|
510
|
+
self, lock_data: dict[str, Any], direct_names: set[str]
|
|
511
|
+
) -> list[DependencyRecord]:
|
|
512
|
+
records: list[DependencyRecord] = []
|
|
513
|
+
for field, scope in (("default", "direct"), ("develop", "dev")):
|
|
514
|
+
raw = lock_data.get(field, {})
|
|
515
|
+
if not isinstance(raw, dict):
|
|
516
|
+
continue
|
|
517
|
+
for name, info in raw.items():
|
|
518
|
+
resolved = None
|
|
519
|
+
dependencies = {}
|
|
520
|
+
if isinstance(info, dict):
|
|
521
|
+
version = info.get("version")
|
|
522
|
+
resolved = str(version).lstrip("=") if version is not None else None
|
|
523
|
+
dependencies = info.get("dependencies", {})
|
|
524
|
+
records.append(
|
|
525
|
+
DependencyRecord(
|
|
526
|
+
name=str(name).lower(),
|
|
527
|
+
ecosystem="python",
|
|
528
|
+
scope=scope,
|
|
529
|
+
declared_version=None,
|
|
530
|
+
resolved_version=resolved,
|
|
531
|
+
source="lockfile",
|
|
532
|
+
manifest_path="Pipfile.lock",
|
|
533
|
+
)
|
|
534
|
+
)
|
|
535
|
+
for dependency_name in self._iter_dependency_names(dependencies):
|
|
536
|
+
if dependency_name in direct_names:
|
|
537
|
+
continue
|
|
538
|
+
records.append(
|
|
539
|
+
DependencyRecord(
|
|
540
|
+
name=dependency_name,
|
|
541
|
+
ecosystem="python",
|
|
542
|
+
scope="transitive",
|
|
543
|
+
source="lockfile",
|
|
544
|
+
parent=str(name).lower(),
|
|
545
|
+
manifest_path="Pipfile.lock",
|
|
546
|
+
)
|
|
547
|
+
)
|
|
548
|
+
return records
|
|
549
|
+
|
|
550
|
+
def _iter_dependency_names(self, raw: Any) -> list[str]:
|
|
551
|
+
if isinstance(raw, dict):
|
|
552
|
+
return [str(name).lower() for name in raw]
|
|
553
|
+
if isinstance(raw, list):
|
|
554
|
+
result: list[str] = []
|
|
555
|
+
for item in raw:
|
|
556
|
+
if isinstance(item, str):
|
|
557
|
+
result.append(item.lower())
|
|
558
|
+
elif isinstance(item, dict):
|
|
559
|
+
name = item.get("name")
|
|
560
|
+
if isinstance(name, str):
|
|
561
|
+
result.append(name.lower())
|
|
562
|
+
return result
|
|
563
|
+
return []
|
|
564
|
+
|
|
565
|
+
def _analyze_php(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
566
|
+
composer_json = load_json_file(root / "composer.json")
|
|
567
|
+
if composer_json is None:
|
|
568
|
+
return [], []
|
|
569
|
+
records: list[DependencyRecord] = []
|
|
570
|
+
for field, scope in (("require", "direct"), ("require-dev", "dev")):
|
|
571
|
+
raw = composer_json.get(field, {})
|
|
572
|
+
if not isinstance(raw, dict):
|
|
573
|
+
continue
|
|
574
|
+
for name, version in raw.items():
|
|
575
|
+
records.append(
|
|
576
|
+
DependencyRecord(
|
|
577
|
+
name=str(name),
|
|
578
|
+
ecosystem="php",
|
|
579
|
+
scope=scope,
|
|
580
|
+
declared_version=str(version),
|
|
581
|
+
source="manifest",
|
|
582
|
+
manifest_path="composer.json",
|
|
583
|
+
)
|
|
584
|
+
)
|
|
585
|
+
composer_lock = load_json_file(root / "composer.lock")
|
|
586
|
+
if composer_lock is not None:
|
|
587
|
+
records = self._merge_resolved(records, self._parse_composer_lock(composer_lock, records))
|
|
588
|
+
return records, []
|
|
589
|
+
|
|
590
|
+
def _parse_composer_lock(
|
|
591
|
+
self, lock_data: dict[str, Any], direct_records: list[DependencyRecord]
|
|
592
|
+
) -> list[DependencyRecord]:
|
|
593
|
+
records: list[DependencyRecord] = []
|
|
594
|
+
packages = []
|
|
595
|
+
for field in ("packages", "packages-dev"):
|
|
596
|
+
raw = lock_data.get(field, [])
|
|
597
|
+
if isinstance(raw, list):
|
|
598
|
+
packages.extend(raw)
|
|
599
|
+
parent_map: dict[str, str] = {}
|
|
600
|
+
direct_names = {record.name for record in direct_records}
|
|
601
|
+
for package in packages:
|
|
602
|
+
if not isinstance(package, dict):
|
|
603
|
+
continue
|
|
604
|
+
name = str(package.get("name", ""))
|
|
605
|
+
if not name:
|
|
606
|
+
continue
|
|
607
|
+
requires = package.get("require", {})
|
|
608
|
+
if isinstance(requires, dict):
|
|
609
|
+
for dependency_name in requires:
|
|
610
|
+
parent_map.setdefault(str(dependency_name), name)
|
|
611
|
+
records.append(
|
|
612
|
+
DependencyRecord(
|
|
613
|
+
name=name,
|
|
614
|
+
ecosystem="php",
|
|
615
|
+
scope="direct" if name in direct_names else "transitive",
|
|
616
|
+
resolved_version=str(package.get("version")) if package.get("version") is not None else None,
|
|
617
|
+
source="lockfile",
|
|
618
|
+
parent=parent_map.get(name),
|
|
619
|
+
manifest_path="composer.lock",
|
|
620
|
+
)
|
|
621
|
+
)
|
|
622
|
+
return records
|
|
623
|
+
|
|
624
|
+
def _analyze_ruby(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
625
|
+
gemfile = root / "Gemfile"
|
|
626
|
+
if not gemfile.exists():
|
|
627
|
+
return [], []
|
|
628
|
+
records = self._parse_gemfile(gemfile)
|
|
629
|
+
gemfile_lock = root / "Gemfile.lock"
|
|
630
|
+
if gemfile_lock.exists():
|
|
631
|
+
records = self._merge_resolved(records, self._parse_gemfile_lock(gemfile_lock, records))
|
|
632
|
+
return records, []
|
|
633
|
+
return records, ["ruby: Gemfile sin Gemfile.lock; transitivas no disponibles"]
|
|
634
|
+
|
|
635
|
+
def _parse_gemfile(self, gemfile: Path) -> list[DependencyRecord]:
|
|
636
|
+
records: list[DependencyRecord] = []
|
|
637
|
+
for line in read_text_lines(gemfile):
|
|
638
|
+
match = re.search(r"""gem\s+["']([^"']+)["'](?:\s*,\s*["']([^"']+)["'])?""", line)
|
|
639
|
+
if match is None:
|
|
640
|
+
continue
|
|
641
|
+
records.append(
|
|
642
|
+
DependencyRecord(
|
|
643
|
+
name=match.group(1),
|
|
644
|
+
ecosystem="ruby",
|
|
645
|
+
declared_version=match.group(2),
|
|
646
|
+
source="manifest",
|
|
647
|
+
manifest_path="Gemfile",
|
|
648
|
+
)
|
|
649
|
+
)
|
|
650
|
+
return records
|
|
651
|
+
|
|
652
|
+
def _parse_gemfile_lock(
|
|
653
|
+
self, gemfile_lock: Path, direct_records: list[DependencyRecord]
|
|
654
|
+
) -> list[DependencyRecord]:
|
|
655
|
+
direct_names = {record.name for record in direct_records}
|
|
656
|
+
records: list[DependencyRecord] = []
|
|
657
|
+
current_parent: str | None = None
|
|
658
|
+
in_specs = False
|
|
659
|
+
for line in read_text_lines(gemfile_lock):
|
|
660
|
+
if line.strip() == "specs:":
|
|
661
|
+
in_specs = True
|
|
662
|
+
continue
|
|
663
|
+
if not in_specs:
|
|
664
|
+
continue
|
|
665
|
+
if line.strip() == "DEPENDENCIES":
|
|
666
|
+
break
|
|
667
|
+
if line.startswith(" ") and not line.startswith(" "):
|
|
668
|
+
match = re.match(r"\s{4}([^\s(]+)\s+\(([^)]+)\)", line)
|
|
669
|
+
if match is None:
|
|
670
|
+
continue
|
|
671
|
+
current_parent = match.group(1)
|
|
672
|
+
records.append(
|
|
673
|
+
DependencyRecord(
|
|
674
|
+
name=current_parent,
|
|
675
|
+
ecosystem="ruby",
|
|
676
|
+
scope="direct" if current_parent in direct_names else "transitive",
|
|
677
|
+
resolved_version=match.group(2),
|
|
678
|
+
source="lockfile",
|
|
679
|
+
manifest_path="Gemfile.lock",
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
elif line.startswith(" ") and current_parent is not None:
|
|
683
|
+
dep_match = re.match(r"\s{6}([^\s(]+)", line)
|
|
684
|
+
if dep_match is None:
|
|
685
|
+
continue
|
|
686
|
+
dependency_name = dep_match.group(1)
|
|
687
|
+
if dependency_name in direct_names:
|
|
688
|
+
continue
|
|
689
|
+
records.append(
|
|
690
|
+
DependencyRecord(
|
|
691
|
+
name=dependency_name,
|
|
692
|
+
ecosystem="ruby",
|
|
693
|
+
scope="transitive",
|
|
694
|
+
source="lockfile",
|
|
695
|
+
parent=current_parent,
|
|
696
|
+
manifest_path="Gemfile.lock",
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
return records
|
|
700
|
+
|
|
701
|
+
def _analyze_rust(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
702
|
+
cargo_toml = load_toml_file(root / "Cargo.toml")
|
|
703
|
+
if cargo_toml is None:
|
|
704
|
+
return [], []
|
|
705
|
+
direct = self._parse_cargo_direct(cargo_toml)
|
|
706
|
+
cargo_lock = load_toml_file(root / "Cargo.lock")
|
|
707
|
+
limitations: list[str] = []
|
|
708
|
+
if cargo_lock is not None:
|
|
709
|
+
direct = self._merge_resolved(direct, self._parse_cargo_lock(cargo_lock, direct))
|
|
710
|
+
else:
|
|
711
|
+
limitations.append("rust: Cargo.lock ausente; transitivas no disponibles")
|
|
712
|
+
return direct, limitations
|
|
713
|
+
|
|
714
|
+
def _parse_cargo_direct(self, cargo_toml: dict[str, Any]) -> list[DependencyRecord]:
|
|
715
|
+
records: list[DependencyRecord] = []
|
|
716
|
+
dependencies = cargo_toml.get("dependencies", {})
|
|
717
|
+
if not isinstance(dependencies, dict):
|
|
718
|
+
return []
|
|
719
|
+
for name, raw in dependencies.items():
|
|
720
|
+
declared_version = self._normalize_declared_version(raw)
|
|
721
|
+
records.append(
|
|
722
|
+
DependencyRecord(
|
|
723
|
+
name=str(name),
|
|
724
|
+
ecosystem="rust",
|
|
725
|
+
declared_version=declared_version,
|
|
726
|
+
source="manifest",
|
|
727
|
+
manifest_path="Cargo.toml",
|
|
728
|
+
)
|
|
729
|
+
)
|
|
730
|
+
return records
|
|
731
|
+
|
|
732
|
+
def _parse_cargo_lock(
|
|
733
|
+
self, cargo_lock: dict[str, Any], direct_records: list[DependencyRecord]
|
|
734
|
+
) -> list[DependencyRecord]:
|
|
735
|
+
direct_names = {record.name for record in direct_records}
|
|
736
|
+
raw_packages = cargo_lock.get("package", [])
|
|
737
|
+
if not isinstance(raw_packages, list):
|
|
738
|
+
return []
|
|
739
|
+
parent_map: dict[str, str] = {}
|
|
740
|
+
records: list[DependencyRecord] = []
|
|
741
|
+
for package in raw_packages:
|
|
742
|
+
if not isinstance(package, dict):
|
|
743
|
+
continue
|
|
744
|
+
name = str(package.get("name", ""))
|
|
745
|
+
if not name:
|
|
746
|
+
continue
|
|
747
|
+
dependencies = package.get("dependencies", [])
|
|
748
|
+
if isinstance(dependencies, list):
|
|
749
|
+
for dependency in dependencies:
|
|
750
|
+
if isinstance(dependency, str):
|
|
751
|
+
dep_name = dependency.split(" ", 1)[0]
|
|
752
|
+
parent_map.setdefault(dep_name, name)
|
|
753
|
+
records.append(
|
|
754
|
+
DependencyRecord(
|
|
755
|
+
name=name,
|
|
756
|
+
ecosystem="rust",
|
|
757
|
+
scope="direct" if name in direct_names else "transitive",
|
|
758
|
+
resolved_version=str(package.get("version")) if package.get("version") is not None else None,
|
|
759
|
+
source="lockfile",
|
|
760
|
+
parent=parent_map.get(name),
|
|
761
|
+
manifest_path="Cargo.lock",
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
return records
|
|
765
|
+
|
|
766
|
+
def _analyze_go(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
767
|
+
go_mod = root / "go.mod"
|
|
768
|
+
if not go_mod.exists():
|
|
769
|
+
return [], []
|
|
770
|
+
records = self._parse_go_mod(go_mod)
|
|
771
|
+
return records, ["go: go.sum no expone arbol transitivo fiable offline en esta fase"]
|
|
772
|
+
|
|
773
|
+
def _parse_go_mod(self, go_mod: Path) -> list[DependencyRecord]:
|
|
774
|
+
records: list[DependencyRecord] = []
|
|
775
|
+
in_require_block = False
|
|
776
|
+
for line in read_text_lines(go_mod):
|
|
777
|
+
stripped = line.strip()
|
|
778
|
+
if stripped.startswith("require ("):
|
|
779
|
+
in_require_block = True
|
|
780
|
+
continue
|
|
781
|
+
if in_require_block and stripped == ")":
|
|
782
|
+
in_require_block = False
|
|
783
|
+
continue
|
|
784
|
+
if stripped.startswith("require "):
|
|
785
|
+
stripped = stripped.removeprefix("require ").strip()
|
|
786
|
+
elif not in_require_block:
|
|
787
|
+
continue
|
|
788
|
+
|
|
789
|
+
parts = stripped.split()
|
|
790
|
+
if len(parts) < 2:
|
|
791
|
+
continue
|
|
792
|
+
name, version = parts[0], parts[1]
|
|
793
|
+
scope = "indirect" if "// indirect" in stripped else "direct"
|
|
794
|
+
records.append(
|
|
795
|
+
DependencyRecord(
|
|
796
|
+
name=name,
|
|
797
|
+
ecosystem="go",
|
|
798
|
+
scope=scope,
|
|
799
|
+
declared_version=version,
|
|
800
|
+
resolved_version=version,
|
|
801
|
+
source="manifest",
|
|
802
|
+
manifest_path="go.mod",
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
return records
|
|
806
|
+
|
|
807
|
+
def _analyze_dotnet(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
808
|
+
csproj_files = sorted(root.glob("*.csproj"))
|
|
809
|
+
if not csproj_files:
|
|
810
|
+
return [], []
|
|
811
|
+
records: list[DependencyRecord] = []
|
|
812
|
+
for csproj in csproj_files:
|
|
813
|
+
records.extend(self._parse_csproj_dependencies(csproj))
|
|
814
|
+
|
|
815
|
+
packages_lock = load_json_file(root / "packages.lock.json")
|
|
816
|
+
if packages_lock is not None:
|
|
817
|
+
records = self._merge_resolved(records, self._parse_packages_lock(packages_lock, records))
|
|
818
|
+
return records, []
|
|
819
|
+
return records, ["dotnet: packages.lock.json ausente; transitivas no disponibles"]
|
|
820
|
+
|
|
821
|
+
def _parse_csproj_dependencies(self, csproj: Path) -> list[DependencyRecord]:
|
|
822
|
+
try:
|
|
823
|
+
tree = ET.parse(csproj)
|
|
824
|
+
except (ET.ParseError, OSError):
|
|
825
|
+
return []
|
|
826
|
+
records: list[DependencyRecord] = []
|
|
827
|
+
for elem in tree.findall(".//PackageReference"):
|
|
828
|
+
include = elem.attrib.get("Include") or elem.attrib.get("Update")
|
|
829
|
+
if not include:
|
|
830
|
+
continue
|
|
831
|
+
version = elem.attrib.get("Version")
|
|
832
|
+
if version is None:
|
|
833
|
+
version_elem = elem.find("Version")
|
|
834
|
+
version = version_elem.text if version_elem is not None else None
|
|
835
|
+
records.append(
|
|
836
|
+
DependencyRecord(
|
|
837
|
+
name=include,
|
|
838
|
+
ecosystem="dotnet",
|
|
839
|
+
declared_version=version,
|
|
840
|
+
source="manifest",
|
|
841
|
+
manifest_path=csproj.name,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
return records
|
|
845
|
+
|
|
846
|
+
def _parse_packages_lock(
|
|
847
|
+
self, lock_data: dict[str, Any], direct_records: list[DependencyRecord]
|
|
848
|
+
) -> list[DependencyRecord]:
|
|
849
|
+
direct_names = {record.name for record in direct_records}
|
|
850
|
+
records: list[DependencyRecord] = []
|
|
851
|
+
dependencies = lock_data.get("dependencies", {})
|
|
852
|
+
if not isinstance(dependencies, dict):
|
|
853
|
+
return []
|
|
854
|
+
for target_data in dependencies.values():
|
|
855
|
+
if not isinstance(target_data, dict):
|
|
856
|
+
continue
|
|
857
|
+
parent_map: dict[str, str] = {}
|
|
858
|
+
for package_name, info in target_data.items():
|
|
859
|
+
if not isinstance(info, dict):
|
|
860
|
+
continue
|
|
861
|
+
nested = info.get("dependencies", {})
|
|
862
|
+
if isinstance(nested, dict):
|
|
863
|
+
for dependency_name in nested:
|
|
864
|
+
parent_map.setdefault(str(dependency_name), str(package_name))
|
|
865
|
+
for package_name, info in target_data.items():
|
|
866
|
+
if not isinstance(info, dict):
|
|
867
|
+
continue
|
|
868
|
+
records.append(
|
|
869
|
+
DependencyRecord(
|
|
870
|
+
name=str(package_name),
|
|
871
|
+
ecosystem="dotnet",
|
|
872
|
+
scope="direct" if package_name in direct_names else "transitive",
|
|
873
|
+
resolved_version=str(info.get("resolved")) if info.get("resolved") is not None else None,
|
|
874
|
+
source="lockfile",
|
|
875
|
+
parent=parent_map.get(str(package_name)),
|
|
876
|
+
manifest_path="packages.lock.json",
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
return records
|
|
880
|
+
|
|
881
|
+
def _merge_resolved(
|
|
882
|
+
self,
|
|
883
|
+
direct_records: list[DependencyRecord],
|
|
884
|
+
resolved_records: list[DependencyRecord],
|
|
885
|
+
) -> list[DependencyRecord]:
|
|
886
|
+
if not resolved_records:
|
|
887
|
+
return direct_records
|
|
888
|
+
result: list[DependencyRecord] = []
|
|
889
|
+
matched: set[tuple[str, str, str]] = set()
|
|
890
|
+
for direct in direct_records:
|
|
891
|
+
replacement = next(
|
|
892
|
+
(
|
|
893
|
+
resolved
|
|
894
|
+
for resolved in resolved_records
|
|
895
|
+
if resolved.ecosystem == direct.ecosystem
|
|
896
|
+
and resolved.name == direct.name
|
|
897
|
+
and resolved.scope == direct.scope
|
|
898
|
+
),
|
|
899
|
+
None,
|
|
900
|
+
)
|
|
901
|
+
if replacement is None:
|
|
902
|
+
result.append(direct)
|
|
903
|
+
continue
|
|
904
|
+
matched.add((replacement.ecosystem, replacement.name, replacement.scope))
|
|
905
|
+
result.append(
|
|
906
|
+
DependencyRecord(
|
|
907
|
+
name=direct.name,
|
|
908
|
+
ecosystem=direct.ecosystem,
|
|
909
|
+
scope=direct.scope,
|
|
910
|
+
declared_version=direct.declared_version,
|
|
911
|
+
resolved_version=replacement.resolved_version,
|
|
912
|
+
source=replacement.source,
|
|
913
|
+
parent=replacement.parent,
|
|
914
|
+
manifest_path=replacement.manifest_path or direct.manifest_path,
|
|
915
|
+
workspace=direct.workspace,
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
for resolved in resolved_records:
|
|
919
|
+
key = (resolved.ecosystem, resolved.name, resolved.scope)
|
|
920
|
+
if key not in matched:
|
|
921
|
+
result.append(resolved)
|
|
922
|
+
return self._dedupe(result)
|
|
923
|
+
|
|
924
|
+
def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
|
|
925
|
+
if not path.exists():
|
|
926
|
+
return None
|
|
927
|
+
yaml = YAML(typ="safe")
|
|
928
|
+
try:
|
|
929
|
+
data = yaml.load(path.read_text(encoding="utf-8"))
|
|
930
|
+
except Exception:
|
|
931
|
+
return None
|
|
932
|
+
return data if isinstance(data, dict) else None
|
sourcecode/schema.py
CHANGED
|
@@ -68,6 +68,34 @@ class EntryPoint:
|
|
|
68
68
|
source: str = "manifest"
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
@dataclass
|
|
72
|
+
class DependencyRecord:
|
|
73
|
+
"""Dependencia detectada del proyecto."""
|
|
74
|
+
|
|
75
|
+
name: str
|
|
76
|
+
ecosystem: str
|
|
77
|
+
scope: str = "direct"
|
|
78
|
+
declared_version: Optional[str] = None
|
|
79
|
+
resolved_version: Optional[str] = None
|
|
80
|
+
source: str = "manifest"
|
|
81
|
+
parent: Optional[str] = None
|
|
82
|
+
manifest_path: Optional[str] = None
|
|
83
|
+
workspace: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class DependencySummary:
|
|
88
|
+
"""Resumen del analisis de dependencias."""
|
|
89
|
+
|
|
90
|
+
requested: bool = False
|
|
91
|
+
total_count: int = 0
|
|
92
|
+
direct_count: int = 0
|
|
93
|
+
transitive_count: int = 0
|
|
94
|
+
ecosystems: list[str] = field(default_factory=list)
|
|
95
|
+
sources: list[str] = field(default_factory=list)
|
|
96
|
+
limitations: list[str] = field(default_factory=list)
|
|
97
|
+
|
|
98
|
+
|
|
71
99
|
@dataclass
|
|
72
100
|
class SourceMap:
|
|
73
101
|
"""Schema completo del output v1.0.
|
|
@@ -85,3 +113,5 @@ class SourceMap:
|
|
|
85
113
|
stacks: list[StackDetection] = field(default_factory=list)
|
|
86
114
|
project_type: Optional[str] = None # relleno en Fase 3
|
|
87
115
|
entry_points: list[EntryPoint] = field(default_factory=list)
|
|
116
|
+
dependencies: list[DependencyRecord] = field(default_factory=list)
|
|
117
|
+
dependency_summary: Optional[DependencySummary] = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Genera un mapa de contexto estructurado de proyectos de software para agentes IA
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.9
|
|
@@ -46,6 +46,12 @@ Analyze another directory and write YAML to a file:
|
|
|
46
46
|
sourcecode --format yaml --output sourcecode.yaml /path/to/project
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
+
Include direct dependencies, exact versions, and transitive dependencies when compatible lockfiles are available:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
sourcecode . --dependencies
|
|
53
|
+
```
|
|
54
|
+
|
|
49
55
|
Show the version:
|
|
50
56
|
|
|
51
57
|
```bash
|
|
@@ -57,6 +63,7 @@ Main options:
|
|
|
57
63
|
- `--format json|yaml`: output format.
|
|
58
64
|
- `--output PATH`: write to a file instead of `stdout`.
|
|
59
65
|
- `--compact`: return a reduced view with `schema_version`, `project_type`, `stacks`, `entry_points`, and `file_tree_depth1`.
|
|
66
|
+
- `--dependencies`: include direct dependencies, resolved versions, and transitive relationships when lockfiles make that possible.
|
|
60
67
|
- `--depth INTEGER`: maximum file tree depth.
|
|
61
68
|
- `--no-redact`: disable secret redaction.
|
|
62
69
|
|
|
@@ -152,6 +159,50 @@ The full schema includes:
|
|
|
152
159
|
- `stacks`: stack detections with confidence, frameworks, manifests, `primary`, `root`, `workspace`, and `signals`.
|
|
153
160
|
- `project_type`: overall project classification.
|
|
154
161
|
- `entry_points`: detected entry points by stack.
|
|
162
|
+
- `dependencies`: optional dependency records with declared and resolved versions.
|
|
163
|
+
- `dependency_summary`: optional summary with ecosystem coverage, counts, and known limitations.
|
|
164
|
+
|
|
165
|
+
Example dependency block:
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"dependencies": [
|
|
170
|
+
{
|
|
171
|
+
"name": "fastapi",
|
|
172
|
+
"ecosystem": "python",
|
|
173
|
+
"scope": "direct",
|
|
174
|
+
"declared_version": ">=0.115",
|
|
175
|
+
"resolved_version": "0.115.2",
|
|
176
|
+
"source": "lockfile",
|
|
177
|
+
"parent": null,
|
|
178
|
+
"manifest_path": "poetry.lock",
|
|
179
|
+
"workspace": "packages/api"
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"name": "starlette",
|
|
183
|
+
"ecosystem": "python",
|
|
184
|
+
"scope": "transitive",
|
|
185
|
+
"declared_version": null,
|
|
186
|
+
"resolved_version": "0.38.6",
|
|
187
|
+
"source": "lockfile",
|
|
188
|
+
"parent": "fastapi",
|
|
189
|
+
"manifest_path": "poetry.lock",
|
|
190
|
+
"workspace": "packages/api"
|
|
191
|
+
}
|
|
192
|
+
],
|
|
193
|
+
"dependency_summary": {
|
|
194
|
+
"requested": true,
|
|
195
|
+
"total_count": 2,
|
|
196
|
+
"direct_count": 1,
|
|
197
|
+
"transitive_count": 1,
|
|
198
|
+
"ecosystems": ["python"],
|
|
199
|
+
"sources": ["lockfile"],
|
|
200
|
+
"limitations": []
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Dependency analysis is still offline and conservative: if a lockfile does not expose a reliable transitive graph, `sourcecode` reports direct dependencies and records the limitation instead of guessing.
|
|
155
206
|
|
|
156
207
|
Detailed reference: [docs/schema.md](/Users/user/Documents/workspace/atlas/atlas-cli/docs/schema.md).
|
|
157
208
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=sw7UZiMGE-MNL0gak-WMvhCd_M-yBDowwBXfPLOuDDI,99
|
|
2
2
|
sourcecode/classifier.py,sha256=7ubGTn5vfw9z-9eosLIYDtQUu7d8dvB4XKKDxysUVSM,6910
|
|
3
|
-
sourcecode/cli.py,sha256=
|
|
3
|
+
sourcecode/cli.py,sha256=vNf8DvdKmOyB8Sp3goK4lBDv3qQFzb6tKcULVziGxhA,8167
|
|
4
|
+
sourcecode/dependency_analyzer.py,sha256=c8tSbUpug-2avKNWCP4-_e2_uhBwWl96LwL1Be8O90M,39067
|
|
4
5
|
sourcecode/redactor.py,sha256=abSf5jH_IzYzpjF3lEN6hCwdXxvmyHs-3DHz9fd8Mic,2883
|
|
5
6
|
sourcecode/scanner.py,sha256=TvcuD77m_NF8OlTj3XH1b_jO0qrto8c90fND96ecqb0,6182
|
|
6
|
-
sourcecode/schema.py,sha256=
|
|
7
|
+
sourcecode/schema.py,sha256=Z1yg9FzwBsLEA-uIEofVqzXOEhFZNdP3e7LJvyFRcxk,3346
|
|
7
8
|
sourcecode/serializer.py,sha256=hezTYOAAvZ2yPn7Te7Eu42ajbMhVCsUkuUZtqx4wjAs,3085
|
|
8
9
|
sourcecode/tree_utils.py,sha256=5WHQc2L-AGqxKFfYYcQftE6hETxgUxP9MWDuaThcAUw,991
|
|
9
10
|
sourcecode/workspace.py,sha256=1L4xH38gU89fLeMYSuF3ft7lSdCyTfMfJ9NQt5q4Ric,6904
|
|
@@ -26,7 +27,7 @@ sourcecode/detectors/rust.py,sha256=iUz9lE4HfBy8CBaDwGhfh6kLsNIovrH2ay2v3y6qNDc,
|
|
|
26
27
|
sourcecode/detectors/systems.py,sha256=qf5M9tio1H6XDorSstTWIxKe0xVLR_QGTJpFGyhDz4A,1690
|
|
27
28
|
sourcecode/detectors/terraform.py,sha256=5EOKQtTViWo7u3tFK0h45YD8GQPwhrww0cOicxGPPn4,1732
|
|
28
29
|
sourcecode/detectors/tooling.py,sha256=wPvudLCJZ8_cr9GIx44C8jkLAlEN9uTUZcJsP7eVYnQ,1937
|
|
29
|
-
sourcecode-0.
|
|
30
|
-
sourcecode-0.
|
|
31
|
-
sourcecode-0.
|
|
32
|
-
sourcecode-0.
|
|
30
|
+
sourcecode-0.3.0.dist-info/METADATA,sha256=kLVxfZvLBfFwiXaIvhRWXCfwi3H-gJasHWAs0-wfiEc,5613
|
|
31
|
+
sourcecode-0.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
32
|
+
sourcecode-0.3.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
|
|
33
|
+
sourcecode-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|