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.
@@ -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)
@@ -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")
@@ -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