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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.0"
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.2.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=kSF53Kglqc3H1-xnzSg40MVrMtrK-Fq-mxBVtQDGJig,99
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=sf1nf-BmPWzscFvoScNkKIlfU10wepOgTfFE-lf4NO4,6962
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=E2_EnuYWHWZOLarxENvL5S66QBkZIpyV1BzKetCJWIY,2487
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.2.0.dist-info/METADATA,sha256=kfVit3IrZnWnPAtbKcLjMK3SXCfHbNlSP8JrxB6zI0o,4086
30
- sourcecode-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
31
- sourcecode-0.2.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
32
- sourcecode-0.2.0.dist-info/RECORD,,
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,,