sourcecode 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.
- sourcecode/__init__.py +3 -0
- sourcecode/classifier.py +177 -0
- sourcecode/cli.py +208 -0
- sourcecode/detectors/__init__.py +54 -0
- sourcecode/detectors/base.py +42 -0
- sourcecode/detectors/dart.py +42 -0
- sourcecode/detectors/go.py +52 -0
- sourcecode/detectors/heuristic.py +70 -0
- sourcecode/detectors/java.py +88 -0
- sourcecode/detectors/nodejs.py +112 -0
- sourcecode/detectors/parsers.py +73 -0
- sourcecode/detectors/php.py +58 -0
- sourcecode/detectors/project.py +129 -0
- sourcecode/detectors/python.py +120 -0
- sourcecode/detectors/ruby.py +44 -0
- sourcecode/detectors/rust.py +69 -0
- sourcecode/redactor.py +81 -0
- sourcecode/scanner.py +180 -0
- sourcecode/schema.py +87 -0
- sourcecode/serializer.py +90 -0
- sourcecode/tree_utils.py +28 -0
- sourcecode/workspace.py +166 -0
- sourcecode-0.1.0.dist-info/METADATA +172 -0
- sourcecode-0.1.0.dist-info/RECORD +26 -0
- sourcecode-0.1.0.dist-info/WHEEL +4 -0
- sourcecode-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Detector de proyectos Rust."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sourcecode.detectors.base import (
|
|
7
|
+
AbstractDetector,
|
|
8
|
+
DetectionContext,
|
|
9
|
+
EntryPoint,
|
|
10
|
+
StackDetection,
|
|
11
|
+
)
|
|
12
|
+
from sourcecode.detectors.parsers import load_toml_file, unique_strings
|
|
13
|
+
from sourcecode.schema import FrameworkDetection
|
|
14
|
+
from sourcecode.tree_utils import path_exists_in_tree
|
|
15
|
+
|
|
16
|
+
_FRAMEWORK_MAP = {
|
|
17
|
+
"axum": "Axum",
|
|
18
|
+
"actix-web": "Actix Web",
|
|
19
|
+
"rocket": "Rocket",
|
|
20
|
+
"clap": "Clap",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RustDetector(AbstractDetector):
|
|
25
|
+
name = "rust"
|
|
26
|
+
priority = 50
|
|
27
|
+
|
|
28
|
+
def can_detect(self, context: DetectionContext) -> bool:
|
|
29
|
+
return "Cargo.toml" in context.manifests
|
|
30
|
+
|
|
31
|
+
def detect(self, context: DetectionContext) -> tuple[list[StackDetection], list[EntryPoint]]:
|
|
32
|
+
cargo = load_toml_file(context.root / "Cargo.toml")
|
|
33
|
+
if cargo is None:
|
|
34
|
+
return [], []
|
|
35
|
+
|
|
36
|
+
dependencies = cargo.get("dependencies", {})
|
|
37
|
+
frameworks = [
|
|
38
|
+
FrameworkDetection(name=label, source="Cargo.toml")
|
|
39
|
+
for package_name, label in _FRAMEWORK_MAP.items()
|
|
40
|
+
if isinstance(dependencies, dict) and package_name in dependencies
|
|
41
|
+
]
|
|
42
|
+
entry_points = self._collect_entry_points(context, cargo)
|
|
43
|
+
stack = StackDetection(
|
|
44
|
+
stack="rust",
|
|
45
|
+
detection_method="manifest",
|
|
46
|
+
confidence="high",
|
|
47
|
+
frameworks=frameworks,
|
|
48
|
+
manifests=["Cargo.toml"],
|
|
49
|
+
)
|
|
50
|
+
return [stack], entry_points
|
|
51
|
+
|
|
52
|
+
def _collect_entry_points(self, context: DetectionContext, cargo: dict[str, Any]) -> list[EntryPoint]:
|
|
53
|
+
candidates: list[str] = []
|
|
54
|
+
if path_exists_in_tree(context.file_tree, "src/main.rs"):
|
|
55
|
+
candidates.append("src/main.rs")
|
|
56
|
+
|
|
57
|
+
bins = cargo.get("bin", [])
|
|
58
|
+
if isinstance(bins, list):
|
|
59
|
+
for item in bins:
|
|
60
|
+
if isinstance(item, dict):
|
|
61
|
+
path = item.get("path")
|
|
62
|
+
if isinstance(path, str) and path.strip():
|
|
63
|
+
candidates.append(path.strip())
|
|
64
|
+
|
|
65
|
+
return [
|
|
66
|
+
EntryPoint(path=path, stack="rust", kind="binary", source="Cargo.toml")
|
|
67
|
+
for path in unique_strings(candidates)
|
|
68
|
+
if path_exists_in_tree(context.file_tree, path)
|
|
69
|
+
]
|
sourcecode/redactor.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Redactor de secretos para sourcecode.
|
|
2
|
+
|
|
3
|
+
Aplica patrones regex sobre los valores de texto del output JSON/YAML.
|
|
4
|
+
Activo por defecto; desactivable con --no-redact.
|
|
5
|
+
|
|
6
|
+
NOTA (Fase 1): La redaccion actua sobre el OUTPUT (nombres de ficheros, paths,
|
|
7
|
+
metadatos) — no sobre el contenido de ficheros. En Fase 1 el output no incluye
|
|
8
|
+
contenido de ficheros, por lo que la redaccion es principalmente una red de
|
|
9
|
+
seguridad. En Fases 2+ cuando se lean manifiestos, la redaccion sera mas critica.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
REDACTED = "[REDACTED]"
|
|
17
|
+
|
|
18
|
+
# Patrones de secretos conocidos (compilados en modulo-load para rendimiento)
|
|
19
|
+
_SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
20
|
+
re.compile(r"ghp_[A-Za-z0-9]{36}"), # GitHub PAT
|
|
21
|
+
re.compile(r"sk-proj-[A-Za-z0-9\-_]{50,}"), # OpenAI project key (mas especifico primero)
|
|
22
|
+
re.compile(r"sk-[A-Za-z0-9]{48}"), # OpenAI legacy key
|
|
23
|
+
re.compile(r"AKIA[0-9A-Z]{16}"), # AWS Access Key ID
|
|
24
|
+
re.compile(r"Bearer\s+[A-Za-z0-9\-._~+/]+=*"), # Bearer tokens
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Patrones de nombres de fichero que deben excluirse (SEC-02)
|
|
28
|
+
_EXCLUDE_FILENAME_PATTERNS: list[re.Pattern[str]] = [
|
|
29
|
+
re.compile(r"^\.env(\..+)?$"), # .env, .env.local, .env.production, etc.
|
|
30
|
+
re.compile(r"^.+\.secret$"), # cualquier fichero *.secret
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def redact_value(value: str) -> str:
|
|
35
|
+
"""Aplica todos los patrones de secreto sobre un string."""
|
|
36
|
+
for pattern in _SECRET_PATTERNS:
|
|
37
|
+
value = pattern.sub(REDACTED, value)
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def redact_dict(data: Any) -> Any:
|
|
42
|
+
"""Redaccion recursiva sobre el dict/list del output.
|
|
43
|
+
|
|
44
|
+
- str: aplica patrones
|
|
45
|
+
- dict: redacta cada valor
|
|
46
|
+
- list: redacta cada elemento
|
|
47
|
+
- None, int, float, bool: retorna sin modificar
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(data, str):
|
|
50
|
+
return redact_value(data)
|
|
51
|
+
elif isinstance(data, dict):
|
|
52
|
+
return {k: redact_dict(v) for k, v in data.items()}
|
|
53
|
+
elif isinstance(data, list):
|
|
54
|
+
return [redact_dict(item) for item in data]
|
|
55
|
+
# None, int, float, bool — sin modificar
|
|
56
|
+
return data
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SecretRedactor:
|
|
60
|
+
"""Redactor configurable de secretos.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
enabled: Si False, redact() retorna los datos sin modificar (--no-redact).
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, enabled: bool = True) -> None:
|
|
67
|
+
self.enabled = enabled
|
|
68
|
+
|
|
69
|
+
def redact(self, data: Any) -> Any:
|
|
70
|
+
"""Redacta secretos del dict si esta habilitado."""
|
|
71
|
+
if not self.enabled:
|
|
72
|
+
return data
|
|
73
|
+
return redact_dict(data)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def should_exclude_file(filename: str) -> bool:
|
|
77
|
+
"""Determina si un fichero debe excluirse del analisis de contenido (SEC-02).
|
|
78
|
+
|
|
79
|
+
Excluye: .env, .env.*, *.secret
|
|
80
|
+
"""
|
|
81
|
+
return any(pattern.match(filename) for pattern in _EXCLUDE_FILENAME_PATTERNS)
|
sourcecode/scanner.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Scanner de ficheros para sourcecode.
|
|
2
|
+
|
|
3
|
+
Construye un arbol JSON anidado del proyecto respetando .gitignore,
|
|
4
|
+
exclusiones por defecto y sin seguir symlinks.
|
|
5
|
+
|
|
6
|
+
Convencion de nodos (D-01, D-02):
|
|
7
|
+
- null (None en Python) = fichero
|
|
8
|
+
- dict = directorio (vacio o con hijos)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional, cast
|
|
15
|
+
|
|
16
|
+
from pathspec import GitIgnoreSpec
|
|
17
|
+
|
|
18
|
+
# Directorios excluidos por defecto (SCAN-02)
|
|
19
|
+
DEFAULT_EXCLUDES: frozenset[str] = frozenset({
|
|
20
|
+
"node_modules",
|
|
21
|
+
"__pycache__",
|
|
22
|
+
".git",
|
|
23
|
+
"vendor",
|
|
24
|
+
"venv",
|
|
25
|
+
".venv",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
"target",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
# Nombres de ficheros de manifiesto conocidos (para find_manifests)
|
|
32
|
+
MANIFEST_NAMES: frozenset[str] = frozenset({
|
|
33
|
+
"pyproject.toml",
|
|
34
|
+
"setup.py",
|
|
35
|
+
"setup.cfg",
|
|
36
|
+
"requirements.txt",
|
|
37
|
+
"Pipfile",
|
|
38
|
+
"uv.lock",
|
|
39
|
+
"package.json",
|
|
40
|
+
"go.mod",
|
|
41
|
+
"Cargo.toml",
|
|
42
|
+
"pom.xml",
|
|
43
|
+
"build.gradle",
|
|
44
|
+
"composer.json",
|
|
45
|
+
"Gemfile",
|
|
46
|
+
"pubspec.yaml",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FileScanner:
|
|
51
|
+
"""Escanea un directorio de proyecto y produce un arbol de ficheros filtrado.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
root: Directorio raiz del proyecto a analizar.
|
|
55
|
+
max_depth: Profundidad maxima del arbol de ficheros (default: 4). (SCAN-05)
|
|
56
|
+
extra_excludes: Conjunto adicional de nombres de directorio a excluir.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
root: Path,
|
|
62
|
+
max_depth: int = 4,
|
|
63
|
+
extra_excludes: Optional[frozenset[str]] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.root = root.resolve()
|
|
66
|
+
self.max_depth = max_depth
|
|
67
|
+
self._excludes = DEFAULT_EXCLUDES | (extra_excludes or frozenset())
|
|
68
|
+
self._gitignore_spec: Optional[GitIgnoreSpec] = None
|
|
69
|
+
|
|
70
|
+
def _load_gitignore_spec(self) -> GitIgnoreSpec:
|
|
71
|
+
"""Carga .gitignore del proyecto como GitIgnoreSpec (SCAN-01)."""
|
|
72
|
+
if self._gitignore_spec is None:
|
|
73
|
+
gitignore = self.root / ".gitignore"
|
|
74
|
+
if gitignore.exists():
|
|
75
|
+
lines = gitignore.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
76
|
+
else:
|
|
77
|
+
lines = []
|
|
78
|
+
self._gitignore_spec = GitIgnoreSpec.from_lines(lines)
|
|
79
|
+
return self._gitignore_spec
|
|
80
|
+
|
|
81
|
+
def _is_excluded_by_gitignore(self, rel_path: str, is_dir: bool) -> bool:
|
|
82
|
+
"""Comprueba si una ruta relativa (a self.root) esta excluida por .gitignore."""
|
|
83
|
+
spec = self._load_gitignore_spec()
|
|
84
|
+
# GitIgnoreSpec espera rutas con / al final para directorios
|
|
85
|
+
path_to_match = rel_path + "/" if is_dir else rel_path
|
|
86
|
+
return spec.match_file(path_to_match)
|
|
87
|
+
|
|
88
|
+
def scan_tree(self) -> dict[str, Any]:
|
|
89
|
+
"""Construye el arbol JSON anidado del proyecto.
|
|
90
|
+
|
|
91
|
+
Retorna:
|
|
92
|
+
dict donde None = fichero (D-02) y dict = directorio (D-01).
|
|
93
|
+
"""
|
|
94
|
+
self._load_gitignore_spec()
|
|
95
|
+
# Arbol raiz que se va rellenando
|
|
96
|
+
root_tree: dict[str, Any] = {}
|
|
97
|
+
|
|
98
|
+
for dirpath, dirnames, filenames in os.walk(
|
|
99
|
+
self.root, followlinks=False # SCAN-03: no seguir symlinks de directorio
|
|
100
|
+
):
|
|
101
|
+
current = Path(dirpath)
|
|
102
|
+
try:
|
|
103
|
+
rel = current.relative_to(self.root)
|
|
104
|
+
except ValueError:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
depth = len(rel.parts)
|
|
108
|
+
|
|
109
|
+
if depth >= self.max_depth:
|
|
110
|
+
# No descender mas alla de max_depth (SCAN-05)
|
|
111
|
+
dirnames.clear()
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Filtrar directorios excluidos in-place (CRITICO: slice assignment) (Trampa 1)
|
|
115
|
+
dirnames[:] = [
|
|
116
|
+
d for d in dirnames
|
|
117
|
+
if d not in self._excludes
|
|
118
|
+
and not (current / d).is_symlink() # SCAN-03: symlinks explicito
|
|
119
|
+
and not self._is_excluded_by_gitignore(
|
|
120
|
+
str(rel / d) if rel.parts else d,
|
|
121
|
+
is_dir=True,
|
|
122
|
+
)
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
# Obtener nodo del arbol correspondiente a este directorio
|
|
126
|
+
node = self._get_or_create_node(root_tree, rel.parts)
|
|
127
|
+
|
|
128
|
+
# Agregar ficheros al nodo (null = fichero segun D-02)
|
|
129
|
+
for fname in filenames:
|
|
130
|
+
fpath = current / fname
|
|
131
|
+
# SCAN-03: no incluir symlinks de fichero
|
|
132
|
+
if fpath.is_symlink():
|
|
133
|
+
continue
|
|
134
|
+
# Calcular ruta relativa para gitignore (Trampa 2: rutas relativas)
|
|
135
|
+
rel_file = str(rel / fname) if rel.parts else fname
|
|
136
|
+
if self._is_excluded_by_gitignore(rel_file, is_dir=False):
|
|
137
|
+
continue
|
|
138
|
+
node[fname] = None # D-02: null = fichero
|
|
139
|
+
|
|
140
|
+
# Asegurar que los subdirectorios aceptados existen como dicts en el nodo
|
|
141
|
+
for d in dirnames:
|
|
142
|
+
if d not in node:
|
|
143
|
+
node[d] = {}
|
|
144
|
+
|
|
145
|
+
return root_tree
|
|
146
|
+
|
|
147
|
+
def _get_or_create_node(
|
|
148
|
+
self, tree: dict[str, Any], parts: tuple[str, ...]
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
"""Navega/crea el nodo del arbol para la ruta indicada."""
|
|
151
|
+
node = tree
|
|
152
|
+
for part in parts:
|
|
153
|
+
if part not in node or node[part] is None:
|
|
154
|
+
node[part] = {}
|
|
155
|
+
node = cast(dict[str, Any], node[part])
|
|
156
|
+
return node
|
|
157
|
+
|
|
158
|
+
def find_manifests(self) -> list[str]:
|
|
159
|
+
"""Encuentra ficheros de manifiesto en profundidad 0-1 (SCAN-04).
|
|
160
|
+
|
|
161
|
+
Retorna:
|
|
162
|
+
Lista de paths absolutos de manifiestos encontrados.
|
|
163
|
+
"""
|
|
164
|
+
manifests: list[str] = []
|
|
165
|
+
# Profundidad 0: raiz
|
|
166
|
+
for name in MANIFEST_NAMES:
|
|
167
|
+
candidate = self.root / name
|
|
168
|
+
if candidate.exists() and not candidate.is_symlink():
|
|
169
|
+
manifests.append(str(candidate))
|
|
170
|
+
# Profundidad 1: primer nivel
|
|
171
|
+
try:
|
|
172
|
+
for child in self.root.iterdir():
|
|
173
|
+
if child.is_dir() and not child.is_symlink() and child.name not in self._excludes:
|
|
174
|
+
for name in MANIFEST_NAMES:
|
|
175
|
+
candidate = child / name
|
|
176
|
+
if candidate.exists() and not candidate.is_symlink():
|
|
177
|
+
manifests.append(str(candidate))
|
|
178
|
+
except PermissionError:
|
|
179
|
+
pass
|
|
180
|
+
return manifests
|
sourcecode/schema.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Schema de output v1.0 para sourcecode.
|
|
2
|
+
|
|
3
|
+
Contrato publico estable — los detectores de Fase 2 escriben en SourceMap.stacks.
|
|
4
|
+
Los campos stacks, project_type y entry_points son vacios/null en Fase 1.
|
|
5
|
+
|
|
6
|
+
Convencion del arbol de ficheros (D-01, D-02):
|
|
7
|
+
- None = fichero
|
|
8
|
+
- dict = directorio (vacio o con hijos)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from typing import Any, Literal, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _now_utc() -> str:
|
|
18
|
+
"""Retorna timestamp ISO 8601 UTC con offset +00:00."""
|
|
19
|
+
return datetime.now(timezone.utc).isoformat()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sourcecode_version() -> str:
|
|
23
|
+
from sourcecode import __version__
|
|
24
|
+
return __version__
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AnalysisMetadata:
|
|
29
|
+
"""Metadatos del analisis — siempre presentes en el output."""
|
|
30
|
+
|
|
31
|
+
schema_version: str = "1.0"
|
|
32
|
+
generated_at: str = field(default_factory=_now_utc)
|
|
33
|
+
sourcecode_version: str = field(default_factory=_sourcecode_version)
|
|
34
|
+
analyzed_path: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class FrameworkDetection:
|
|
39
|
+
"""Framework detectado dentro de un stack."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
source: str = "manifest"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class StackDetection:
|
|
47
|
+
"""Deteccion de un stack tecnologico."""
|
|
48
|
+
|
|
49
|
+
stack: str
|
|
50
|
+
detection_method: Literal["manifest", "lockfile", "heuristic"] = "manifest"
|
|
51
|
+
confidence: Literal["high", "medium", "low"] = "high"
|
|
52
|
+
frameworks: list[FrameworkDetection] = field(default_factory=list)
|
|
53
|
+
package_manager: Optional[str] = None
|
|
54
|
+
manifests: list[str] = field(default_factory=list)
|
|
55
|
+
primary: bool = False
|
|
56
|
+
root: Optional[str] = None
|
|
57
|
+
workspace: Optional[str] = None
|
|
58
|
+
signals: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class EntryPoint:
|
|
63
|
+
"""Punto de entrada detectado del proyecto."""
|
|
64
|
+
|
|
65
|
+
path: str
|
|
66
|
+
stack: str
|
|
67
|
+
kind: str = "entry"
|
|
68
|
+
source: str = "manifest"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SourceMap:
|
|
73
|
+
"""Schema completo del output v1.0.
|
|
74
|
+
|
|
75
|
+
Campos de deteccion (stacks, project_type, entry_points) son vacios/null
|
|
76
|
+
en Fase 1 y se rellenan a partir de Fase 2.
|
|
77
|
+
|
|
78
|
+
file_tree sigue la convencion D-01/D-02:
|
|
79
|
+
- None = fichero
|
|
80
|
+
- dict = directorio
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
metadata: AnalysisMetadata = field(default_factory=AnalysisMetadata)
|
|
84
|
+
file_tree: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
stacks: list[StackDetection] = field(default_factory=list)
|
|
86
|
+
project_type: Optional[str] = None # relleno en Fase 3
|
|
87
|
+
entry_points: list[EntryPoint] = field(default_factory=list)
|
sourcecode/serializer.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Serializer de sourcecode — JSON canonico, YAML y modo compact.
|
|
2
|
+
|
|
3
|
+
Patrones criticos:
|
|
4
|
+
- SIEMPRE pasar por dataclasses.asdict() antes de json.dumps (json no serializa dataclasses)
|
|
5
|
+
- ruamel.yaml con representer para null canonico (no ~)
|
|
6
|
+
- compact_view() proyecta solo los campos necesarios (~500 tokens)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import asdict, is_dataclass
|
|
13
|
+
from io import StringIO
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from sourcecode.schema import SourceMap
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_json(sm: SourceMap | dict[str, Any], indent: int = 2) -> str:
|
|
21
|
+
"""Serializa SourceMap o dict a JSON canonico.
|
|
22
|
+
|
|
23
|
+
Acepta un SourceMap (dataclass) o un dict ya preparado (e.g. compact_view()).
|
|
24
|
+
Usa dataclasses.asdict() para convertir dataclasses a dict antes de json.dumps.
|
|
25
|
+
ensure_ascii=False para preservar UTF-8 en paths.
|
|
26
|
+
"""
|
|
27
|
+
data = asdict(sm) if is_dataclass(sm) and not isinstance(sm, type) else sm
|
|
28
|
+
return json.dumps(data, indent=indent, ensure_ascii=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_yaml(sm: SourceMap) -> str:
|
|
32
|
+
"""Serializa SourceMap a YAML usando ruamel.yaml.
|
|
33
|
+
|
|
34
|
+
ruamel.yaml preserva el orden de claves y serializa None como null
|
|
35
|
+
(no como ~) con la configuracion por defecto del dump de dicts.
|
|
36
|
+
"""
|
|
37
|
+
from ruamel.yaml import YAML
|
|
38
|
+
|
|
39
|
+
yaml = YAML()
|
|
40
|
+
yaml.default_flow_style = False
|
|
41
|
+
# Asegurar que None se serializa como 'null' y no como '~'
|
|
42
|
+
yaml.representer.add_representer(
|
|
43
|
+
type(None),
|
|
44
|
+
lambda dumper, data: dumper.represent_scalar("tag:yaml.org,2002:null", "null"),
|
|
45
|
+
)
|
|
46
|
+
stream = StringIO()
|
|
47
|
+
yaml.dump(asdict(sm), stream)
|
|
48
|
+
return stream.getvalue()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def compact_view(sm: SourceMap) -> dict[str, Any]:
|
|
52
|
+
"""Proyeccion compacta del SourceMap (~500 tokens).
|
|
53
|
+
|
|
54
|
+
Incluye: schema_version, project_type, stacks, entry_points y
|
|
55
|
+
file_tree_depth1 (solo el primer nivel del arbol de ficheros).
|
|
56
|
+
|
|
57
|
+
En Fase 1 stacks/project_type/entry_points estan vacios — el output
|
|
58
|
+
compact es principalmente el arbol de primer nivel.
|
|
59
|
+
"""
|
|
60
|
+
depth1: dict[str, Any] = {}
|
|
61
|
+
for name, value in sm.file_tree.items():
|
|
62
|
+
if isinstance(value, dict):
|
|
63
|
+
# Directorio: mostrar como dict vacio en depth1 (solo la entrada raiz)
|
|
64
|
+
depth1[name] = {}
|
|
65
|
+
else:
|
|
66
|
+
# Fichero: mantener null
|
|
67
|
+
depth1[name] = None
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"schema_version": sm.metadata.schema_version,
|
|
71
|
+
"project_type": sm.project_type,
|
|
72
|
+
"stacks": [asdict(stack) for stack in sm.stacks],
|
|
73
|
+
"entry_points": [asdict(entry_point) for entry_point in sm.entry_points],
|
|
74
|
+
"file_tree_depth1": depth1,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_output(content: str, output: Optional[Path]) -> None:
|
|
79
|
+
"""Escribe el contenido a stdout o a un fichero.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
content: String serializado (JSON o YAML).
|
|
83
|
+
output: Path del fichero destino. None = stdout.
|
|
84
|
+
"""
|
|
85
|
+
if output is None:
|
|
86
|
+
sys.stdout.write(content)
|
|
87
|
+
if not content.endswith("\n"):
|
|
88
|
+
sys.stdout.write("\n")
|
|
89
|
+
else:
|
|
90
|
+
output.write_text(content, encoding="utf-8")
|
sourcecode/tree_utils.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Helpers neutrales para trabajar con el file_tree."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def flatten_file_tree(file_tree: dict[str, Any], prefix: str = "") -> list[str]:
|
|
8
|
+
"""Aplana el file_tree jerarquico a paths relativos."""
|
|
9
|
+
paths: list[str] = []
|
|
10
|
+
for name, value in file_tree.items():
|
|
11
|
+
current = f"{prefix}/{name}" if prefix else name
|
|
12
|
+
paths.append(current)
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
paths.extend(flatten_file_tree(value, current))
|
|
15
|
+
return paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def path_exists_in_tree(file_tree: dict[str, Any], target: str) -> bool:
|
|
19
|
+
"""Comprueba si un path relativo existe en el file_tree."""
|
|
20
|
+
normalized = target.strip("/").split("/")
|
|
21
|
+
node: Any = file_tree
|
|
22
|
+
for index, part in enumerate(normalized):
|
|
23
|
+
if not isinstance(node, dict) or part not in node:
|
|
24
|
+
return False
|
|
25
|
+
node = node[part]
|
|
26
|
+
if index < len(normalized) - 1 and node is None:
|
|
27
|
+
return False
|
|
28
|
+
return True
|