sourcecode 0.8.0__py3-none-any.whl → 0.10.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/architecture_summary.py +248 -0
- sourcecode/cli.py +56 -5
- sourcecode/detectors/go.py +7 -1
- sourcecode/detectors/nodejs.py +30 -11
- sourcecode/detectors/python.py +28 -6
- sourcecode/schema.py +67 -0
- sourcecode/semantic_analyzer.py +1887 -0
- sourcecode/serializer.py +2 -2
- sourcecode/summarizer.py +114 -5
- {sourcecode-0.8.0.dist-info → sourcecode-0.10.0.dist-info}/METADATA +7 -6
- {sourcecode-0.8.0.dist-info → sourcecode-0.10.0.dist-info}/RECORD +14 -12
- {sourcecode-0.8.0.dist-info → sourcecode-0.10.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.8.0.dist-info → sourcecode-0.10.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Resumen arquitectonico estatico y compacto para consumo LLM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sourcecode.schema import EntryPoint, SourceMap, StackDetection
|
|
10
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
11
|
+
|
|
12
|
+
_TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
|
|
13
|
+
_PYTHON_EXTENSIONS = {".py"}
|
|
14
|
+
_NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
|
15
|
+
_GO_EXTENSIONS = {".go"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArchitectureSummarizer:
|
|
19
|
+
"""Construye un resumen arquitectonico estatico de 3-5 lineas."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, root: Path) -> None:
|
|
22
|
+
self.root = root
|
|
23
|
+
|
|
24
|
+
def generate(self, sm: SourceMap) -> str | None:
|
|
25
|
+
try:
|
|
26
|
+
return self._build_summary(sm)
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def _build_summary(self, sm: SourceMap) -> str | None:
|
|
31
|
+
file_paths = [
|
|
32
|
+
path for path in flatten_file_tree(sm.file_tree)
|
|
33
|
+
if not self._is_tooling_path(path)
|
|
34
|
+
]
|
|
35
|
+
if not file_paths:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
entry_points = [
|
|
39
|
+
entry for entry in sm.entry_points
|
|
40
|
+
if not self._is_tooling_path(entry.path)
|
|
41
|
+
]
|
|
42
|
+
if not entry_points:
|
|
43
|
+
fallback = self._infer_fallback_entry_points(file_paths, sm.stacks)
|
|
44
|
+
entry_points = fallback[:1]
|
|
45
|
+
|
|
46
|
+
if not entry_points:
|
|
47
|
+
return "Arquitectura no inferida con suficiente evidencia estatica."
|
|
48
|
+
|
|
49
|
+
entry_point = entry_points[0]
|
|
50
|
+
content = self._read_file(entry_point.path)
|
|
51
|
+
if content is None:
|
|
52
|
+
return f"Entry point principal: {entry_point.path}. Arquitectura no inferida con suficiente evidencia estatica."
|
|
53
|
+
|
|
54
|
+
lines = [self._describe_entry_point(entry_point, sm.project_type)]
|
|
55
|
+
suffix = Path(entry_point.path).suffix
|
|
56
|
+
if suffix in _PYTHON_EXTENSIONS:
|
|
57
|
+
lines.extend(self._summarize_python_entry(entry_point.path, content))
|
|
58
|
+
elif suffix in _NODE_EXTENSIONS:
|
|
59
|
+
lines.extend(self._summarize_node_entry(entry_point.path, content))
|
|
60
|
+
elif suffix in _GO_EXTENSIONS:
|
|
61
|
+
lines.extend(self._summarize_go_entry(entry_point.path, content))
|
|
62
|
+
else:
|
|
63
|
+
lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
|
|
64
|
+
|
|
65
|
+
unique_lines: list[str] = []
|
|
66
|
+
seen: set[str] = set()
|
|
67
|
+
for line in lines:
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line or line in seen:
|
|
70
|
+
continue
|
|
71
|
+
seen.add(line)
|
|
72
|
+
unique_lines.append(line)
|
|
73
|
+
return "\n".join(unique_lines[:5]) if unique_lines else None
|
|
74
|
+
|
|
75
|
+
def _summarize_python_entry(self, path: str, content: str) -> list[str]:
|
|
76
|
+
try:
|
|
77
|
+
tree = ast.parse(content)
|
|
78
|
+
except SyntaxError:
|
|
79
|
+
return ["Orquesta modulos Python, pero el entry point no pudo parsearse por completo."]
|
|
80
|
+
|
|
81
|
+
package_prefix = self._python_package_prefix(path)
|
|
82
|
+
imported_modules: list[str] = []
|
|
83
|
+
optional_analyzers: list[str] = []
|
|
84
|
+
uses_serializer = False
|
|
85
|
+
uses_redactor = False
|
|
86
|
+
|
|
87
|
+
for node in ast.walk(tree):
|
|
88
|
+
if isinstance(node, ast.ImportFrom) and isinstance(node.module, str):
|
|
89
|
+
if package_prefix and node.module.startswith(package_prefix):
|
|
90
|
+
imported_modules.append(node.module.rsplit(".", 1)[-1])
|
|
91
|
+
if node.module == "sourcecode.serializer":
|
|
92
|
+
uses_serializer = True
|
|
93
|
+
if node.module == "sourcecode.redactor":
|
|
94
|
+
uses_redactor = True
|
|
95
|
+
elif isinstance(node, ast.Assign):
|
|
96
|
+
optional = self._extract_optional_analyzer(node)
|
|
97
|
+
if optional:
|
|
98
|
+
optional_analyzers.append(optional)
|
|
99
|
+
|
|
100
|
+
lines: list[str] = []
|
|
101
|
+
core_modules = self._format_module_list(imported_modules)
|
|
102
|
+
if core_modules:
|
|
103
|
+
lines.append(f"Orquesta modulos internos: {core_modules}.")
|
|
104
|
+
if optional_analyzers:
|
|
105
|
+
lines.append(f"Activa analisis opcionales: {', '.join(optional_analyzers)}.")
|
|
106
|
+
if uses_serializer:
|
|
107
|
+
output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta."
|
|
108
|
+
if uses_redactor:
|
|
109
|
+
output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta con redaccion de secretos."
|
|
110
|
+
lines.append(output_line)
|
|
111
|
+
elif not lines:
|
|
112
|
+
lines.append("Orquesta modulos Python internos sin un patron de salida claramente inferible.")
|
|
113
|
+
return lines
|
|
114
|
+
|
|
115
|
+
def _summarize_node_entry(self, path: str, content: str) -> list[str]:
|
|
116
|
+
imports = re.findall(r"""from\s+['"](\.?\.?/[^'"]+)['"]|require\(['"](\.?\.?/[^'"]+)['"]\)""", content)
|
|
117
|
+
modules = [item for pair in imports for item in pair if item]
|
|
118
|
+
lines: list[str] = []
|
|
119
|
+
if modules:
|
|
120
|
+
formatted = self._format_module_list([self._module_label(module) for module in modules])
|
|
121
|
+
if formatted:
|
|
122
|
+
lines.append(f"Orquesta modulos internos: {formatted}.")
|
|
123
|
+
lines.append("Produce la salida principal del entry point JavaScript/TypeScript detectado.")
|
|
124
|
+
return lines
|
|
125
|
+
|
|
126
|
+
def _summarize_go_entry(self, path: str, content: str) -> list[str]:
|
|
127
|
+
imports = re.findall(r'"([^"]+)"', content)
|
|
128
|
+
internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
|
|
129
|
+
lines: list[str] = []
|
|
130
|
+
if internal:
|
|
131
|
+
formatted = self._format_module_list([self._module_label(module) for module in internal])
|
|
132
|
+
if formatted:
|
|
133
|
+
lines.append(f"Orquesta paquetes internos: {formatted}.")
|
|
134
|
+
lines.append("Produce la salida principal del binario Go detectado.")
|
|
135
|
+
return lines
|
|
136
|
+
|
|
137
|
+
def _describe_entry_point(self, entry_point: EntryPoint, project_type: str | None) -> str:
|
|
138
|
+
if entry_point.kind == "cli" or entry_point.path.endswith("cli.py"):
|
|
139
|
+
return f"Entry point principal: {entry_point.path} expone la CLI del proyecto."
|
|
140
|
+
if entry_point.kind == "web":
|
|
141
|
+
return f"Entry point principal: {entry_point.path} arranca la interfaz web."
|
|
142
|
+
if project_type == "api" or entry_point.kind == "server":
|
|
143
|
+
return f"Entry point principal: {entry_point.path} arranca el servicio principal."
|
|
144
|
+
if entry_point.kind == "binary":
|
|
145
|
+
return f"Entry point principal: {entry_point.path} arranca el binario principal."
|
|
146
|
+
return f"Entry point principal: {entry_point.path} coordina el flujo principal del proyecto."
|
|
147
|
+
|
|
148
|
+
def _extract_optional_analyzer(self, node: ast.Assign) -> str | None:
|
|
149
|
+
value = node.value
|
|
150
|
+
if not isinstance(value, ast.IfExp):
|
|
151
|
+
return None
|
|
152
|
+
if not isinstance(value.test, ast.Name):
|
|
153
|
+
return None
|
|
154
|
+
if not isinstance(value.body, ast.Call):
|
|
155
|
+
return None
|
|
156
|
+
if not isinstance(value.body.func, ast.Name):
|
|
157
|
+
return None
|
|
158
|
+
analyzer_name = value.body.func.id
|
|
159
|
+
if not analyzer_name.endswith("Analyzer"):
|
|
160
|
+
return None
|
|
161
|
+
return f"{analyzer_name} (--{value.test.id.replace('_', '-')})"
|
|
162
|
+
|
|
163
|
+
def _infer_fallback_entry_points(
|
|
164
|
+
self, file_paths: list[str], stacks: list[StackDetection]
|
|
165
|
+
) -> list[EntryPoint]:
|
|
166
|
+
candidates: list[EntryPoint] = []
|
|
167
|
+
stack_name = stacks[0].stack if stacks else "unknown"
|
|
168
|
+
ordered_paths = sorted(file_paths, key=self._fallback_priority)
|
|
169
|
+
for path in ordered_paths:
|
|
170
|
+
if path.endswith(("cli.py", "__main__.py", "main.py")):
|
|
171
|
+
candidates.append(
|
|
172
|
+
EntryPoint(
|
|
173
|
+
path=path,
|
|
174
|
+
stack=stack_name,
|
|
175
|
+
kind="cli",
|
|
176
|
+
source="convention",
|
|
177
|
+
confidence="medium",
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
elif path.endswith(("app/page.tsx", "pages/index.js", "server.js")):
|
|
181
|
+
candidates.append(
|
|
182
|
+
EntryPoint(
|
|
183
|
+
path=path,
|
|
184
|
+
stack=stack_name,
|
|
185
|
+
kind="web" if "page" in path or "pages/" in path else "server",
|
|
186
|
+
source="convention",
|
|
187
|
+
confidence="medium",
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
elif path.endswith("main.go"):
|
|
191
|
+
candidates.append(
|
|
192
|
+
EntryPoint(
|
|
193
|
+
path=path,
|
|
194
|
+
stack=stack_name,
|
|
195
|
+
kind="binary",
|
|
196
|
+
source="convention",
|
|
197
|
+
confidence="medium",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return candidates
|
|
201
|
+
|
|
202
|
+
def _fallback_priority(self, path: str) -> tuple[int, int, str]:
|
|
203
|
+
return (
|
|
204
|
+
0 if "/cli.py" in path or path.endswith("cli.py") else 1,
|
|
205
|
+
0 if path.startswith("src/") else 1,
|
|
206
|
+
path,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _format_module_list(self, modules: list[str]) -> str:
|
|
210
|
+
normalized = [self._module_label(module) for module in modules]
|
|
211
|
+
filtered = [module for module in normalized if module and not self._is_tooling_path(module)]
|
|
212
|
+
if not filtered:
|
|
213
|
+
return ""
|
|
214
|
+
ordered: list[str] = []
|
|
215
|
+
seen: set[str] = set()
|
|
216
|
+
for module in filtered:
|
|
217
|
+
if module not in seen:
|
|
218
|
+
seen.add(module)
|
|
219
|
+
ordered.append(module)
|
|
220
|
+
return ", ".join(ordered[:8])
|
|
221
|
+
|
|
222
|
+
def _module_label(self, module: str) -> str:
|
|
223
|
+
cleaned = module.strip().strip("./")
|
|
224
|
+
if "/" in cleaned:
|
|
225
|
+
return cleaned.split("/")[-1]
|
|
226
|
+
if "." in cleaned:
|
|
227
|
+
return cleaned.rsplit(".", 1)[-1]
|
|
228
|
+
return cleaned
|
|
229
|
+
|
|
230
|
+
def _python_package_prefix(self, path: str) -> str:
|
|
231
|
+
parts = Path(path).parts
|
|
232
|
+
if len(parts) >= 3 and parts[0] == "src":
|
|
233
|
+
return f"{parts[1]}."
|
|
234
|
+
if len(parts) >= 2:
|
|
235
|
+
return f"{parts[0]}."
|
|
236
|
+
return ""
|
|
237
|
+
|
|
238
|
+
def _read_file(self, relative_path: str) -> str | None:
|
|
239
|
+
try:
|
|
240
|
+
return (self.root / relative_path).read_text(encoding="utf-8", errors="replace")
|
|
241
|
+
except OSError:
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
def _is_tooling_path(self, path: str | None) -> bool:
|
|
245
|
+
if not path:
|
|
246
|
+
return False
|
|
247
|
+
normalized = path.strip().lstrip("/")
|
|
248
|
+
return normalized.startswith(_TOOLING_PREFIXES)
|
sourcecode/cli.py
CHANGED
|
@@ -112,6 +112,11 @@ def main(
|
|
|
112
112
|
"--full-metrics",
|
|
113
113
|
help="Incluir metricas de calidad: LOC, simbolos, complejidad, tests y cobertura por fichero",
|
|
114
114
|
),
|
|
115
|
+
semantics: bool = typer.Option(
|
|
116
|
+
False,
|
|
117
|
+
"--semantics",
|
|
118
|
+
help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
|
|
119
|
+
),
|
|
115
120
|
) -> None:
|
|
116
121
|
"""Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
|
|
117
122
|
# Validar formato
|
|
@@ -228,6 +233,9 @@ def main(
|
|
|
228
233
|
doc_analyzer = DocAnalyzer() if docs else None
|
|
229
234
|
metrics_analyzer = MetricsAnalyzer() if full_metrics else None
|
|
230
235
|
|
|
236
|
+
from sourcecode.semantic_analyzer import SemanticAnalyzer
|
|
237
|
+
semantic_analyzer = SemanticAnalyzer() if semantics else None
|
|
238
|
+
|
|
231
239
|
root_manifests = [
|
|
232
240
|
manifest
|
|
233
241
|
for manifest in manifests
|
|
@@ -424,9 +432,51 @@ def main(
|
|
|
424
432
|
metrics_summary=metrics_summary,
|
|
425
433
|
)
|
|
426
434
|
|
|
427
|
-
#
|
|
428
|
-
|
|
429
|
-
|
|
435
|
+
# Semantic analysis (--semantics flag)
|
|
436
|
+
if semantic_analyzer is not None:
|
|
437
|
+
if workspace_analysis.workspaces:
|
|
438
|
+
all_sem_calls: list[Any] = []
|
|
439
|
+
all_sem_symbols: list[Any] = []
|
|
440
|
+
all_sem_links: list[Any] = []
|
|
441
|
+
all_sem_summaries: list[Any] = []
|
|
442
|
+
for ws in workspace_analysis.workspaces:
|
|
443
|
+
ws_calls, ws_syms, ws_links, ws_sum = semantic_analyzer.analyze(
|
|
444
|
+
target / ws.path,
|
|
445
|
+
(
|
|
446
|
+
filter_sensitive_files(
|
|
447
|
+
FileScanner(target / ws.path, max_depth=depth).scan_tree()
|
|
448
|
+
)
|
|
449
|
+
),
|
|
450
|
+
workspace=ws.path,
|
|
451
|
+
)
|
|
452
|
+
all_sem_calls.extend(ws_calls)
|
|
453
|
+
all_sem_symbols.extend(ws_syms)
|
|
454
|
+
all_sem_links.extend(ws_links)
|
|
455
|
+
all_sem_summaries.append(ws_sum)
|
|
456
|
+
merged_sem = semantic_analyzer.merge_summaries(all_sem_summaries)
|
|
457
|
+
sm = replace(
|
|
458
|
+
sm,
|
|
459
|
+
semantic_calls=all_sem_calls,
|
|
460
|
+
semantic_symbols=all_sem_symbols,
|
|
461
|
+
semantic_links=all_sem_links,
|
|
462
|
+
semantic_summary=merged_sem,
|
|
463
|
+
)
|
|
464
|
+
else:
|
|
465
|
+
sem_calls, sem_syms, sem_links, sem_sum = semantic_analyzer.analyze(
|
|
466
|
+
target, file_tree
|
|
467
|
+
)
|
|
468
|
+
sm = replace(
|
|
469
|
+
sm,
|
|
470
|
+
semantic_calls=sem_calls,
|
|
471
|
+
semantic_symbols=sem_syms,
|
|
472
|
+
semantic_links=sem_links,
|
|
473
|
+
semantic_summary=sem_sum,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Phase 9: LLM Output Quality — poblar campos derivados
|
|
477
|
+
from sourcecode.architecture_summary import ArchitectureSummarizer
|
|
478
|
+
from sourcecode.summarizer import ProjectSummarizer
|
|
479
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
430
480
|
|
|
431
481
|
# LQN-01: lista plana de paths del file_tree con separador forward-slash
|
|
432
482
|
sm.file_paths = [
|
|
@@ -446,8 +496,9 @@ def main(
|
|
|
446
496
|
|
|
447
497
|
sm.key_dependencies = sorted(direct_deps, key=_dep_sort_key)[:15]
|
|
448
498
|
|
|
449
|
-
# LQN-02: resumen NL deterministico
|
|
450
|
-
sm.project_summary = ProjectSummarizer().generate(sm)
|
|
499
|
+
# LQN-02: resumen NL deterministico
|
|
500
|
+
sm.project_summary = ProjectSummarizer(target).generate(sm)
|
|
501
|
+
sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
|
|
451
502
|
|
|
452
503
|
# 4. Serializar (con o sin modo compact)
|
|
453
504
|
if compact:
|
sourcecode/detectors/go.py
CHANGED
|
@@ -39,7 +39,13 @@ class GoDetector(AbstractDetector):
|
|
|
39
39
|
]
|
|
40
40
|
preferred = [path for path in entry_candidates if path.startswith("cmd/")] or entry_candidates
|
|
41
41
|
entry_points = [
|
|
42
|
-
EntryPoint(
|
|
42
|
+
EntryPoint(
|
|
43
|
+
path=path,
|
|
44
|
+
stack="go",
|
|
45
|
+
kind="binary",
|
|
46
|
+
source="convention",
|
|
47
|
+
confidence="medium",
|
|
48
|
+
)
|
|
43
49
|
for path in unique_strings(preferred)
|
|
44
50
|
]
|
|
45
51
|
stack = StackDetection(
|
sourcecode/detectors/nodejs.py
CHANGED
|
@@ -77,20 +77,33 @@ class NodejsDetector(AbstractDetector):
|
|
|
77
77
|
def _collect_entry_points(
|
|
78
78
|
self, context: DetectionContext, package_json: dict[str, Any]
|
|
79
79
|
) -> list[EntryPoint]:
|
|
80
|
-
|
|
80
|
+
entry_points: list[EntryPoint] = []
|
|
81
|
+
seen: set[str] = set()
|
|
81
82
|
main = package_json.get("main")
|
|
82
83
|
if isinstance(main, str) and main.strip():
|
|
83
|
-
|
|
84
|
+
path = main.strip()
|
|
85
|
+
if path_exists_in_tree(context.file_tree, path):
|
|
86
|
+
seen.add(path)
|
|
87
|
+
entry_points.append(
|
|
88
|
+
EntryPoint(
|
|
89
|
+
path=path,
|
|
90
|
+
stack="nodejs",
|
|
91
|
+
kind="server",
|
|
92
|
+
source="package.json",
|
|
93
|
+
confidence="high",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
84
96
|
|
|
97
|
+
convention_candidates: list[str] = []
|
|
85
98
|
bin_field = package_json.get("bin")
|
|
86
99
|
if isinstance(bin_field, str) and bin_field.strip():
|
|
87
|
-
|
|
100
|
+
convention_candidates.append(bin_field.strip())
|
|
88
101
|
elif isinstance(bin_field, dict):
|
|
89
|
-
|
|
102
|
+
convention_candidates.extend(
|
|
90
103
|
str(value).strip() for value in bin_field.values() if isinstance(value, str) and value.strip()
|
|
91
104
|
)
|
|
92
105
|
|
|
93
|
-
|
|
106
|
+
convention_candidates.extend(
|
|
94
107
|
[
|
|
95
108
|
"server.js",
|
|
96
109
|
"src/index.js",
|
|
@@ -103,11 +116,17 @@ class NodejsDetector(AbstractDetector):
|
|
|
103
116
|
]
|
|
104
117
|
)
|
|
105
118
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
for path in unique_strings(convention_candidates):
|
|
120
|
+
if path in seen or not path_exists_in_tree(context.file_tree, path):
|
|
121
|
+
continue
|
|
122
|
+
kind = "web" if path.startswith(("app/", "pages/")) else "server"
|
|
123
|
+
entry_points.append(
|
|
124
|
+
EntryPoint(
|
|
125
|
+
path=path,
|
|
126
|
+
stack="nodejs",
|
|
127
|
+
kind=kind,
|
|
128
|
+
source="convention",
|
|
129
|
+
confidence="medium",
|
|
112
130
|
)
|
|
131
|
+
)
|
|
113
132
|
return entry_points
|
sourcecode/detectors/python.py
CHANGED
|
@@ -132,7 +132,7 @@ class PythonDetector(AbstractDetector):
|
|
|
132
132
|
return {match.lower() for match in matches}
|
|
133
133
|
|
|
134
134
|
def _collect_entry_points(self, context: DetectionContext) -> list[EntryPoint]:
|
|
135
|
-
|
|
135
|
+
declared_candidates: list[str] = []
|
|
136
136
|
pyproject = load_toml_file(context.root / "pyproject.toml")
|
|
137
137
|
if pyproject:
|
|
138
138
|
project = pyproject.get("project", {})
|
|
@@ -142,16 +142,38 @@ class PythonDetector(AbstractDetector):
|
|
|
142
142
|
for value in scripts.values():
|
|
143
143
|
if isinstance(value, str) and ":" in value:
|
|
144
144
|
module, _callable = value.split(":", 1)
|
|
145
|
-
|
|
145
|
+
declared_candidates.append(module.replace(".", "/") + ".py")
|
|
146
146
|
|
|
147
|
-
candidates.extend(["__main__.py", "main.py", "app.py", "manage.py", "src/main.py"])
|
|
148
147
|
entry_points: list[EntryPoint] = []
|
|
149
|
-
|
|
148
|
+
declared = set()
|
|
149
|
+
for path in unique_strings(declared_candidates):
|
|
150
150
|
if path_exists_in_tree(context.file_tree, path):
|
|
151
|
-
|
|
151
|
+
declared.add(path)
|
|
152
|
+
kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
|
|
152
153
|
entry_points.append(
|
|
153
|
-
EntryPoint(
|
|
154
|
+
EntryPoint(
|
|
155
|
+
path=path,
|
|
156
|
+
stack="python",
|
|
157
|
+
kind=kind,
|
|
158
|
+
source="pyproject.toml",
|
|
159
|
+
confidence="high",
|
|
160
|
+
)
|
|
154
161
|
)
|
|
162
|
+
|
|
163
|
+
convention_candidates = ["cli.py", "__main__.py", "main.py", "app.py", "manage.py", "src/main.py"]
|
|
164
|
+
for path in unique_strings(convention_candidates):
|
|
165
|
+
if path in declared or not path_exists_in_tree(context.file_tree, path):
|
|
166
|
+
continue
|
|
167
|
+
kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
|
|
168
|
+
entry_points.append(
|
|
169
|
+
EntryPoint(
|
|
170
|
+
path=path,
|
|
171
|
+
stack="python",
|
|
172
|
+
kind=kind,
|
|
173
|
+
source="convention",
|
|
174
|
+
confidence="medium",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
155
177
|
return entry_points
|
|
156
178
|
|
|
157
179
|
def _detect_package_manager(
|
sourcecode/schema.py
CHANGED
|
@@ -66,6 +66,7 @@ class EntryPoint:
|
|
|
66
66
|
stack: str
|
|
67
67
|
kind: str = "entry"
|
|
68
68
|
source: str = "manifest"
|
|
69
|
+
confidence: Literal["high", "medium", "low"] = "high"
|
|
69
70
|
|
|
70
71
|
|
|
71
72
|
@dataclass
|
|
@@ -236,6 +237,66 @@ class DocSummary:
|
|
|
236
237
|
limitations: list[str] = field(default_factory=list)
|
|
237
238
|
|
|
238
239
|
|
|
240
|
+
@dataclass
|
|
241
|
+
class SymbolRecord:
|
|
242
|
+
"""Symbol definition found in a source file."""
|
|
243
|
+
|
|
244
|
+
symbol: str # local name: "MyClass" or "my_func"
|
|
245
|
+
kind: str # "function" | "class" | "constant" | "method"
|
|
246
|
+
language: str
|
|
247
|
+
path: str # relative path to defining file
|
|
248
|
+
line: Optional[int] = None
|
|
249
|
+
qualified_name: Optional[str] = None # "pkg.module.MyClass"
|
|
250
|
+
exported: bool = True # False if name starts with _ and no __all__ override
|
|
251
|
+
workspace: Optional[str] = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclass
|
|
255
|
+
class CallRecord:
|
|
256
|
+
"""A resolved call from one symbol to another, possibly across files."""
|
|
257
|
+
|
|
258
|
+
caller_path: str
|
|
259
|
+
caller_symbol: str
|
|
260
|
+
callee_path: str
|
|
261
|
+
callee_symbol: str
|
|
262
|
+
call_line: Optional[int] = None
|
|
263
|
+
confidence: Literal["high", "medium", "low"] = "medium"
|
|
264
|
+
method: Literal["ast", "heuristic", "unresolved"] = "heuristic"
|
|
265
|
+
args: list[str] = field(default_factory=list)
|
|
266
|
+
kwargs: dict[str, str] = field(default_factory=dict)
|
|
267
|
+
workspace: Optional[str] = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass
|
|
271
|
+
class SymbolLink:
|
|
272
|
+
"""A symbol imported in one file, resolved to its definition in another."""
|
|
273
|
+
|
|
274
|
+
importer_path: str
|
|
275
|
+
symbol: str
|
|
276
|
+
source_path: Optional[str] = None
|
|
277
|
+
source_line: Optional[int] = None
|
|
278
|
+
is_external: bool = False
|
|
279
|
+
confidence: Literal["high", "medium", "low"] = "high"
|
|
280
|
+
method: Literal["ast", "heuristic", "unresolved"] = "ast"
|
|
281
|
+
workspace: Optional[str] = None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@dataclass
|
|
285
|
+
class SemanticSummary:
|
|
286
|
+
"""Summary of the --semantics analysis."""
|
|
287
|
+
|
|
288
|
+
requested: bool = False
|
|
289
|
+
call_count: int = 0
|
|
290
|
+
symbol_count: int = 0
|
|
291
|
+
link_count: int = 0
|
|
292
|
+
languages: list[str] = field(default_factory=list)
|
|
293
|
+
language_coverage: dict[str, str] = field(default_factory=dict)
|
|
294
|
+
files_analyzed: int = 0
|
|
295
|
+
files_skipped: int = 0
|
|
296
|
+
truncated: bool = False
|
|
297
|
+
limitations: list[str] = field(default_factory=list)
|
|
298
|
+
|
|
299
|
+
|
|
239
300
|
@dataclass
|
|
240
301
|
class SourceMap:
|
|
241
302
|
"""Schema completo del output v1.0.
|
|
@@ -262,7 +323,13 @@ class SourceMap:
|
|
|
262
323
|
# Phase 9: LLM Output Quality
|
|
263
324
|
file_paths: list[str] = field(default_factory=list)
|
|
264
325
|
project_summary: Optional[str] = None
|
|
326
|
+
architecture_summary: Optional[str] = None
|
|
265
327
|
key_dependencies: list[DependencyRecord] = field(default_factory=list)
|
|
266
328
|
# Phase 10: Code Quality Metrics
|
|
267
329
|
file_metrics: list[FileMetrics] = field(default_factory=list)
|
|
268
330
|
metrics_summary: Optional[MetricsSummary] = None
|
|
331
|
+
# Phase 12: Static Semantics
|
|
332
|
+
semantic_calls: list[CallRecord] = field(default_factory=list)
|
|
333
|
+
semantic_symbols: list[SymbolRecord] = field(default_factory=list)
|
|
334
|
+
semantic_links: list[SymbolLink] = field(default_factory=list)
|
|
335
|
+
semantic_summary: Optional[SemanticSummary] = None
|