sourcecode 0.13.0__py3-none-any.whl → 0.15.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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

Files changed (44) hide show
  1. sourcecode/__init__.py +1 -1
  2. sourcecode/architecture_analyzer.py +347 -0
  3. sourcecode/architecture_summary.py +0 -1
  4. sourcecode/classifier.py +0 -1
  5. sourcecode/cli.py +87 -29
  6. sourcecode/code_notes_analyzer.py +241 -0
  7. sourcecode/coverage_parser.py +2 -1
  8. sourcecode/dependency_analyzer.py +0 -1
  9. sourcecode/detectors/base.py +0 -1
  10. sourcecode/detectors/dart.py +0 -1
  11. sourcecode/detectors/dotnet.py +0 -1
  12. sourcecode/detectors/elixir.py +0 -1
  13. sourcecode/detectors/go.py +0 -1
  14. sourcecode/detectors/heuristic.py +0 -1
  15. sourcecode/detectors/java.py +0 -1
  16. sourcecode/detectors/jvm_ext.py +0 -1
  17. sourcecode/detectors/nodejs.py +0 -1
  18. sourcecode/detectors/parsers.py +0 -1
  19. sourcecode/detectors/php.py +0 -1
  20. sourcecode/detectors/project.py +0 -1
  21. sourcecode/detectors/python.py +0 -1
  22. sourcecode/detectors/ruby.py +0 -1
  23. sourcecode/detectors/rust.py +0 -1
  24. sourcecode/detectors/systems.py +0 -1
  25. sourcecode/detectors/terraform.py +0 -1
  26. sourcecode/detectors/tooling.py +0 -1
  27. sourcecode/doc_analyzer.py +1 -1
  28. sourcecode/env_analyzer.py +319 -0
  29. sourcecode/git_analyzer.py +240 -0
  30. sourcecode/graph_analyzer.py +0 -1
  31. sourcecode/metrics_analyzer.py +2 -1
  32. sourcecode/redactor.py +2 -1
  33. sourcecode/scanner.py +2 -1
  34. sourcecode/schema.py +501 -335
  35. sourcecode/semantic_analyzer.py +2 -1
  36. sourcecode/serializer.py +107 -96
  37. sourcecode/summarizer.py +139 -27
  38. sourcecode/tree_utils.py +0 -1
  39. sourcecode/workspace.py +0 -1
  40. {sourcecode-0.13.0.dist-info → sourcecode-0.15.0.dist-info}/METADATA +215 -6
  41. sourcecode-0.15.0.dist-info/RECORD +45 -0
  42. sourcecode-0.13.0.dist-info/RECORD +0 -41
  43. {sourcecode-0.13.0.dist-info → sourcecode-0.15.0.dist-info}/WHEEL +0 -0
  44. {sourcecode-0.13.0.dist-info → sourcecode-0.15.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.13.0"
3
+ __version__ = "0.15.0"
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Literal, Optional
5
+
6
+ from sourcecode.schema import (
7
+ ArchitectureAnalysis,
8
+ ArchitectureDomain,
9
+ ArchitectureLayer,
10
+ BoundedContext,
11
+ ModuleGraph,
12
+ SourceMap,
13
+ )
14
+
15
+ _TOOLING_PREFIXES = (
16
+ ".claude/",
17
+ ".vscode/",
18
+ "bin/",
19
+ ".git/",
20
+ "__pycache__/",
21
+ "node_modules/",
22
+ ".venv/",
23
+ "venv/",
24
+ ".mypy_cache/",
25
+ ".pytest_cache/",
26
+ "dist/",
27
+ "build/",
28
+ )
29
+ _SRC_TRANSPARENT = {"src", "lib", "app", "pkg"}
30
+ _CODE_EXTENSIONS = {
31
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs",
32
+ ".go", ".java", ".kt", ".rs", ".rb",
33
+ }
34
+ _GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
35
+
36
+ DOMAIN_ROLES: dict[str, str] = {
37
+ "controllers": "HTTP request handlers",
38
+ "handlers": "HTTP request handlers",
39
+ "routes": "Route definitions",
40
+ "services": "Business logic",
41
+ "usecases": "Business logic",
42
+ "application": "Application layer",
43
+ "repositories": "Data access",
44
+ "repos": "Data access",
45
+ "store": "Data access",
46
+ "models": "Domain models",
47
+ "entities": "Domain entities",
48
+ "domain": "Domain core",
49
+ "infra": "Infrastructure",
50
+ "infrastructure": "Infrastructure",
51
+ "adapters": "Hexagonal adapters",
52
+ "ports": "Hexagonal ports",
53
+ "frontend": "Frontend/UI layer",
54
+ "backend": "Backend/API layer",
55
+ "components": "UI components",
56
+ "pages": "UI pages/views",
57
+ "views": "View layer",
58
+ "templates": "View templates",
59
+ "tests": "Test suite",
60
+ "test": "Test suite",
61
+ }
62
+
63
+ LAYER_PATTERNS: dict[str, dict[str, list[str]]] = {
64
+ "mvc": {
65
+ "controller": ["controller", "controllers", "routes", "views", "handlers"],
66
+ "model": ["model", "models", "entity", "entities", "domain"],
67
+ "view": ["views", "templates", "pages", "components"],
68
+ },
69
+ "layered": {
70
+ "controller": ["controller", "controllers", "api", "routes", "handlers", "endpoints"],
71
+ "service": ["service", "services", "usecase", "usecases", "application"],
72
+ "repository": ["repository", "repositories", "repo", "repos", "store", "storage", "dao"],
73
+ "infrastructure": ["infra", "infrastructure", "persistence", "db", "database"],
74
+ },
75
+ "hexagonal": {
76
+ "port": ["port", "ports", "interface", "interfaces"],
77
+ "adapter": ["adapter", "adapters"],
78
+ "domain": ["domain", "core", "model", "models"],
79
+ },
80
+ "fullstack": {
81
+ "frontend": ["frontend", "client", "web", "ui", "pages", "components", "app"],
82
+ "backend": ["backend", "server", "api", "services"],
83
+ },
84
+ }
85
+
86
+
87
+ class ArchitectureAnalyzer:
88
+ """Analiza la arquitectura de un proyecto a partir de su estructura de ficheros y grafo de modulos."""
89
+
90
+ def analyze(
91
+ self,
92
+ root: Path,
93
+ sm: SourceMap,
94
+ graph: Optional[ModuleGraph] = None,
95
+ ) -> ArchitectureAnalysis:
96
+ limitations: list[str] = []
97
+
98
+ # Step 1: filter paths
99
+ filtered = self._filter_paths(sm.file_paths)
100
+ if len(filtered) < 2:
101
+ return ArchitectureAnalysis(
102
+ requested=True,
103
+ pattern="unknown",
104
+ limitations=["Arquitectura no inferida: proyecto sin archivos de codigo suficientes"],
105
+ )
106
+
107
+ # Step 2: domain clustering
108
+ domains = self._cluster_domains(filtered)
109
+
110
+ # Step 3: layer detection
111
+ pattern, layers = self._detect_layers(filtered)
112
+ if pattern in (None, "flat", "unknown"):
113
+ if pattern == "flat":
114
+ limitations.append("Patron de capas no detectado: proyecto con estructura plana")
115
+ elif pattern == "unknown":
116
+ limitations.append("Patron de capas no reconocido: estructura de directorios sin senales claras")
117
+
118
+ # Step 4: bounded context inference
119
+ bounded_contexts = self._infer_bounded_contexts(domains, graph)
120
+
121
+ # Overall confidence
122
+ confidence: Literal["high", "medium", "low"]
123
+ if len(domains) >= 3 and pattern not in (None, "unknown"):
124
+ confidence = "high"
125
+ elif len(domains) >= 1:
126
+ confidence = "medium"
127
+ else:
128
+ confidence = "low"
129
+
130
+ method = "graph+heuristic" if graph is not None else "heuristic"
131
+
132
+ return ArchitectureAnalysis(
133
+ requested=True,
134
+ pattern=pattern,
135
+ domains=domains,
136
+ layers=layers,
137
+ bounded_contexts=bounded_contexts,
138
+ confidence=confidence,
139
+ method=method,
140
+ limitations=limitations,
141
+ )
142
+
143
+ # ------------------------------------------------------------------
144
+ # Private helpers
145
+ # ------------------------------------------------------------------
146
+
147
+ def _is_tooling(self, path: str) -> bool:
148
+ norm = path.replace("\\", "/")
149
+ return any(norm.startswith(p) for p in _TOOLING_PREFIXES)
150
+
151
+ def _filter_paths(self, paths: list[str]) -> list[str]:
152
+ result = []
153
+ for p in paths:
154
+ norm = p.replace("\\", "/")
155
+ if self._is_tooling(norm):
156
+ continue
157
+ ext = Path(norm).suffix.lower()
158
+ if ext not in _CODE_EXTENSIONS:
159
+ continue
160
+ result.append(norm)
161
+ return result
162
+
163
+ def _extract_domain_segment(self, path: str) -> str:
164
+ parts = path.replace("\\", "/").split("/")
165
+ if len(parts) == 1:
166
+ return "root"
167
+ first = parts[0]
168
+ if first in _SRC_TRANSPARENT and len(parts) >= 3:
169
+ return parts[1]
170
+ return first
171
+
172
+ def _cluster_domains(self, paths: list[str]) -> list[ArchitectureDomain]:
173
+ groups: dict[str, list[str]] = {}
174
+ for p in paths:
175
+ seg = self._extract_domain_segment(p)
176
+ groups.setdefault(seg, []).append(p)
177
+
178
+ domains: list[ArchitectureDomain] = []
179
+ for name, files in groups.items():
180
+ if len(files) < 2:
181
+ continue
182
+ role = DOMAIN_ROLES.get(name, "")
183
+ domain_confidence: Literal["high", "medium", "low"]
184
+ if name in DOMAIN_ROLES:
185
+ domain_confidence = "high"
186
+ elif len(name) >= 5 and name not in _GENERIC_NAMES:
187
+ domain_confidence = "medium"
188
+ else:
189
+ domain_confidence = "low"
190
+ domains.append(ArchitectureDomain(name=name, files=files, role=role, confidence=domain_confidence))
191
+ return domains
192
+
193
+ def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
194
+ dir_names: set[str] = set()
195
+ for p in paths:
196
+ parts = p.replace("\\", "/").split("/")
197
+ for part in parts[:-1]:
198
+ dir_names.add(part.lower())
199
+
200
+ best_pattern = ""
201
+ best_score = 0
202
+ best_matched: dict[str, list[str]] = {}
203
+
204
+ for pattern_name, layer_keys in LAYER_PATTERNS.items():
205
+ matched: dict[str, list[str]] = {}
206
+ for layer_key, keywords in layer_keys.items():
207
+ matched_dirs = [d for d in dir_names if d in keywords]
208
+ if matched_dirs:
209
+ matched[layer_key] = matched_dirs
210
+ score = len(matched)
211
+ if score > best_score:
212
+ best_score = score
213
+ best_pattern = pattern_name
214
+ best_matched = matched
215
+
216
+ if best_score < 2:
217
+ # Check depth for flat vs unknown
218
+ max_depth = max(
219
+ len(p.replace("\\", "/").split("/")) - 1
220
+ for p in paths
221
+ )
222
+ flat_pattern = "flat" if max_depth <= 2 else "unknown"
223
+ return flat_pattern, []
224
+
225
+ # Build ArchitectureLayer list
226
+ layer_confidence: Literal["high", "medium", "low"] = "high" if best_score >= 3 else "medium"
227
+ layers: list[ArchitectureLayer] = []
228
+ for layer_key, matched_dirs in best_matched.items():
229
+ matched_files = [
230
+ p for p in paths
231
+ if any(
232
+ seg.lower() in matched_dirs
233
+ for seg in p.replace("\\", "/").split("/")[:-1]
234
+ )
235
+ ]
236
+ layers.append(ArchitectureLayer(
237
+ name=layer_key,
238
+ pattern=best_pattern,
239
+ files=matched_files,
240
+ confidence=layer_confidence,
241
+ ))
242
+
243
+ return best_pattern or "unknown", layers
244
+
245
+ def _infer_bounded_contexts(
246
+ self,
247
+ domains: list[ArchitectureDomain],
248
+ graph: Optional[ModuleGraph],
249
+ ) -> list[BoundedContext]:
250
+ # Priority 1: use graph SCCs when available
251
+ if graph is not None:
252
+ sccs = self._find_sccs(graph)
253
+ bc_list: list[BoundedContext] = []
254
+ for scc in sccs:
255
+ if len(scc) < 2:
256
+ continue
257
+ # Derive name from most frequent directory segment among nodes
258
+ seg_counts: dict[str, int] = {}
259
+ for node_id in scc:
260
+ parts = node_id.replace("\\", "/").split("/")
261
+ seg = parts[0] if len(parts) == 1 else parts[-2] if len(parts) >= 2 else parts[0]
262
+ seg_counts[seg] = seg_counts.get(seg, 0) + 1
263
+ name = max(seg_counts, key=lambda k: seg_counts[k])
264
+ if name in _GENERIC_NAMES:
265
+ continue
266
+ bc_conf: Literal["high", "medium", "low"] = "high" if name in DOMAIN_ROLES else "medium"
267
+ bc_list.append(BoundedContext(name=name, modules=scc, confidence=bc_conf))
268
+ if bc_list:
269
+ return bc_list
270
+
271
+ # Priority 2: use domains as candidates
272
+ bc_from_domains: list[BoundedContext] = []
273
+ for d in domains:
274
+ if d.name in _GENERIC_NAMES:
275
+ continue
276
+ if d.confidence == "low":
277
+ continue
278
+ bc_from_domains.append(BoundedContext(
279
+ name=d.name,
280
+ modules=d.files,
281
+ confidence="medium",
282
+ ))
283
+ return bc_from_domains
284
+
285
+ def _find_sccs(self, graph: ModuleGraph) -> list[list[str]]:
286
+ """Kosaraju's algorithm for strongly connected components."""
287
+ import_edges = [
288
+ (e.source, e.target)
289
+ for e in graph.edges
290
+ if e.kind == "imports"
291
+ ]
292
+ node_ids = [n.id for n in graph.nodes]
293
+
294
+ # Build adjacency lists
295
+ adj: dict[str, list[str]] = {n: [] for n in node_ids}
296
+ radj: dict[str, list[str]] = {n: [] for n in node_ids}
297
+ for src, tgt in import_edges:
298
+ if src in adj and tgt in adj:
299
+ adj[src].append(tgt)
300
+ radj[tgt].append(src)
301
+
302
+ visited: set[str] = set()
303
+ order: list[str] = []
304
+
305
+ def dfs1(v: str) -> None:
306
+ stack = [(v, False)]
307
+ while stack:
308
+ node, done = stack.pop()
309
+ if done:
310
+ order.append(node)
311
+ continue
312
+ if node in visited:
313
+ continue
314
+ visited.add(node)
315
+ stack.append((node, True))
316
+ for nb in adj[node]:
317
+ if nb not in visited:
318
+ stack.append((nb, False))
319
+
320
+ for n in node_ids:
321
+ if n not in visited:
322
+ dfs1(n)
323
+
324
+ visited2: set[str] = set()
325
+ sccs: list[list[str]] = []
326
+
327
+ def dfs2(v: str) -> list[str]:
328
+ comp: list[str] = []
329
+ stack = [v]
330
+ while stack:
331
+ node = stack.pop()
332
+ if node in visited2:
333
+ continue
334
+ visited2.add(node)
335
+ comp.append(node)
336
+ for nb in radj[node]:
337
+ if nb not in visited2:
338
+ stack.append(nb)
339
+ return comp
340
+
341
+ for n in reversed(order):
342
+ if n not in visited2:
343
+ comp = dfs2(n)
344
+ if len(comp) >= 2:
345
+ sccs.append(comp)
346
+
347
+ return sccs
@@ -1,4 +1,3 @@
1
- """Resumen arquitectonico estatico y compacto para consumo LLM."""
2
1
  from __future__ import annotations
3
2
 
4
3
  import ast
sourcecode/classifier.py CHANGED
@@ -1,4 +1,3 @@
1
- """Clasificacion de tipo de proyecto y scoring de confianza."""
2
1
  from __future__ import annotations
3
2
 
4
3
  from collections.abc import Iterable, Sequence
sourcecode/cli.py CHANGED
@@ -1,4 +1,3 @@
1
- """CLI de sourcecode — interfaz de linea de comandos principal."""
2
1
  from __future__ import annotations
3
2
 
4
3
  import json
@@ -47,7 +46,7 @@ def main(
47
46
  compact: bool = typer.Option(
48
47
  False,
49
48
  "--compact",
50
- help="Output reducido (~500 tokens): tipo de proyecto, stacks, entradas y arbol nivel 1",
49
+ help="Output reducido (~500-700 tokens): tipo, stacks, entradas, arbol nivel 1 y summaries de flags opcionales activos",
51
50
  ),
52
51
  dependencies: bool = typer.Option(
53
52
  False,
@@ -117,6 +116,41 @@ def main(
117
116
  "--semantics",
118
117
  help="Incluir call graph semantico, linking cross-file de simbolos y resolucion avanzada de imports",
119
118
  ),
119
+ architecture: bool = typer.Option(
120
+ False,
121
+ "--architecture",
122
+ help="Inferencia arquitectonica: dominios funcionales, capas (MVC/layered/hexagonal) y bounded contexts aproximados",
123
+ ),
124
+ git_context: bool = typer.Option(
125
+ False,
126
+ "--git-context",
127
+ "-g",
128
+ help="Incluir contexto git: commits recientes, ficheros mas activos y cambios pendientes",
129
+ ),
130
+ git_depth: int = typer.Option(
131
+ 20,
132
+ "--git-depth",
133
+ help="Numero de commits recientes a incluir con --git-context (default: 20)",
134
+ min=1,
135
+ max=100,
136
+ ),
137
+ git_days: int = typer.Option(
138
+ 90,
139
+ "--git-days",
140
+ help="Ventana temporal en dias para detectar ficheros mas activos con --git-context (default: 90)",
141
+ min=1,
142
+ max=3650,
143
+ ),
144
+ env_map: bool = typer.Option(
145
+ False,
146
+ "--env-map",
147
+ help="Incluir mapa de variables de entorno: claves, tipos, categorias y ficheros que las referencian",
148
+ ),
149
+ code_notes: bool = typer.Option(
150
+ False,
151
+ "--code-notes",
152
+ help="Extraer anotaciones TODO/FIXME/HACK/NOTE/DEPRECATED/WARNING/BUG/XXX/OPTIMIZE con ubicacion y simbolo envolvente, y detectar ADRs en docs/decisions/, docs/adr/ y similares",
153
+ ),
120
154
  ) -> None:
121
155
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
122
156
  # Validar formato
@@ -158,6 +192,7 @@ def main(
158
192
  from sourcecode.metrics_analyzer import MetricsAnalyzer
159
193
  from sourcecode.redactor import SecretRedactor, redact_dict
160
194
  from sourcecode.scanner import FileScanner
195
+ from sourcecode.semantic_analyzer import SemanticAnalyzer
161
196
  from sourcecode.schema import (
162
197
  AnalysisMetadata,
163
198
  DocRecord,
@@ -233,7 +268,6 @@ def main(
233
268
  doc_analyzer = DocAnalyzer() if docs else None
234
269
  metrics_analyzer = MetricsAnalyzer() if full_metrics else None
235
270
 
236
- from sourcecode.semantic_analyzer import SemanticAnalyzer
237
271
  semantic_analyzer = SemanticAnalyzer() if semantics else None
238
272
 
239
273
  root_manifests = [
@@ -292,7 +326,7 @@ def main(
292
326
  doc_records.extend(root_doc_records)
293
327
  doc_summaries.append(root_doc_summary)
294
328
 
295
- file_metrics_records: list = []
329
+ file_metrics_records: list[Any] = []
296
330
  metrics_summaries = []
297
331
  if metrics_analyzer is not None:
298
332
  root_metrics_tree = (
@@ -500,6 +534,29 @@ def main(
500
534
  sm.project_summary = ProjectSummarizer(target).generate(sm)
501
535
  sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
502
536
 
537
+ # Phase 13 Plan 04: Architectural Inference (--architecture flag)
538
+ if architecture:
539
+ from sourcecode.architecture_analyzer import ArchitectureAnalyzer
540
+ arch_graph = module_graph # None si --graph-modules no fue pasado
541
+ sm.architecture = ArchitectureAnalyzer().analyze(target, sm, arch_graph)
542
+
543
+ # Git Context (--git-context flag)
544
+ if git_context:
545
+ from sourcecode.git_analyzer import GitAnalyzer
546
+ sm.git_context = GitAnalyzer().analyze(target, depth=git_depth, days=git_days)
547
+
548
+ # Env Map (--env-map flag)
549
+ if env_map:
550
+ from sourcecode.env_analyzer import EnvAnalyzer
551
+ env_records, env_summary = EnvAnalyzer().analyze(target, file_tree)
552
+ sm = replace(sm, env_map=env_records, env_summary=env_summary)
553
+
554
+ # Code Notes (--code-notes flag)
555
+ if code_notes:
556
+ from sourcecode.code_notes_analyzer import CodeNotesAnalyzer
557
+ cn_notes, cn_adrs, cn_summary = CodeNotesAnalyzer().analyze(target)
558
+ sm = replace(sm, code_notes=cn_notes, code_adrs=cn_adrs, code_notes_summary=cn_summary)
559
+
503
560
  # 4. Serializar (con o sin modo compact)
504
561
  if compact:
505
562
  data = compact_view(sm)
@@ -536,33 +593,34 @@ def main(
536
593
  # 5. Escribir output (CLI-04)
537
594
  write_output(content, output=output)
538
595
 
596
+
597
+ @app.command("prepare-context")
598
+ def prepare_context_cmd(
599
+ task: str = typer.Argument(..., help="Descripción de la tarea"),
600
+ path: Path = typer.Option(".", "--path", "-p", help="Directorio del proyecto"),
601
+ ) -> None:
602
+ """Prepara contexto mínimo optimizado para que un LLM modifique el código."""
603
+ from dataclasses import asdict
604
+
539
605
  from sourcecode.prepare_context import ContextBuilder
540
606
 
541
- @app.command("prepare-context")
542
- def prepare_context_cmd(
543
- task: str = typer.Argument(..., help="Descripción de la tarea"),
544
- path: Path = typer.Option(".", help="Directorio del proyecto"),
545
- ):
546
- """
547
- Prepara contexto mínimo optimizado para que un LLM modifique el código.
548
- """
549
- target = path.resolve()
550
-
551
- if not target.exists() or not target.is_dir():
552
- typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
553
- raise typer.Exit(code=1)
607
+ target = path.resolve()
608
+
609
+ if not target.exists() or not target.is_dir():
610
+ typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
611
+ raise typer.Exit(code=1)
554
612
 
555
- builder = ContextBuilder(target)
556
- result = builder.prepare(task)
613
+ builder = ContextBuilder(target)
614
+ result = builder.prepare(task)
557
615
 
558
- output = {
559
- "task": result.task,
560
- "entry_points": result.entry_points,
561
- "relevant_files": [f.__dict__ for f in result.relevant_files],
562
- "call_flow": result.call_flow,
563
- "snippets": [s.__dict__ for s in result.snippets],
564
- "tests": result.tests,
565
- "notes": result.notes,
566
- }
616
+ out = {
617
+ "task": result.task,
618
+ "entry_points": result.entry_points,
619
+ "relevant_files": [asdict(f) for f in result.relevant_files],
620
+ "call_flow": result.call_flow,
621
+ "snippets": [asdict(s) for s in result.snippets],
622
+ "tests": result.tests,
623
+ "notes": result.notes,
624
+ }
567
625
 
568
- typer.echo(json.dumps(output, indent=2, ensure_ascii=False))
626
+ typer.echo(json.dumps(out, indent=2, ensure_ascii=False))