codegraph-brain 0.6.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.
Files changed (78) hide show
  1. cgis/__init__.py +10 -0
  2. cgis/__main__.py +16 -0
  3. cgis/api/.gitkeep +0 -0
  4. cgis/api/__init__.py +1 -0
  5. cgis/api/mcp_server.py +550 -0
  6. cgis/cli.py +1516 -0
  7. cgis/core/.gitkeep +0 -0
  8. cgis/core/models.py +140 -0
  9. cgis/extractors/.gitkeep +0 -0
  10. cgis/extractors/_python_ast.py +194 -0
  11. cgis/extractors/_python_classes.py +126 -0
  12. cgis/extractors/_python_functions.py +312 -0
  13. cgis/extractors/_python_imports.py +188 -0
  14. cgis/extractors/_python_types.py +84 -0
  15. cgis/extractors/base.py +42 -0
  16. cgis/extractors/python_extractor.py +235 -0
  17. cgis/extractors/typescript_extractor.py +310 -0
  18. cgis/guardian/__init__.py +1 -0
  19. cgis/guardian/bench.py +199 -0
  20. cgis/guardian/chunked.py +240 -0
  21. cgis/guardian/chunker.py +148 -0
  22. cgis/guardian/collector.py +309 -0
  23. cgis/guardian/core.py +120 -0
  24. cgis/guardian/diff_index.py +151 -0
  25. cgis/guardian/findings.py +70 -0
  26. cgis/guardian/github_poster.py +133 -0
  27. cgis/guardian/metrics.py +108 -0
  28. cgis/guardian/prompts.py +217 -0
  29. cgis/guardian/providers/__init__.py +1 -0
  30. cgis/guardian/providers/base.py +112 -0
  31. cgis/guardian/providers/gemini.py +83 -0
  32. cgis/guardian/providers/mistral.py +83 -0
  33. cgis/guardian/providers/ollama.py +99 -0
  34. cgis/guardian/recording.py +82 -0
  35. cgis/guardian/render.py +107 -0
  36. cgis/guardian/runner.py +295 -0
  37. cgis/guardian/skeptic.py +208 -0
  38. cgis/pipeline.py +252 -0
  39. cgis/py.typed +0 -0
  40. cgis/query/analysis/__init__.py +0 -0
  41. cgis/query/analysis/analyzer.py +241 -0
  42. cgis/query/analysis/anomaly.py +34 -0
  43. cgis/query/analysis/cohesion.py +277 -0
  44. cgis/query/analysis/health.py +128 -0
  45. cgis/query/analysis/suggest_service.py +221 -0
  46. cgis/query/context/__init__.py +0 -0
  47. cgis/query/context/audit.py +134 -0
  48. cgis/query/context/context_service.py +129 -0
  49. cgis/query/context/prompt.py +184 -0
  50. cgis/query/context/snippet.py +96 -0
  51. cgis/query/drift/__init__.py +0 -0
  52. cgis/query/drift/_scc.py +90 -0
  53. cgis/query/drift/drift.py +867 -0
  54. cgis/query/drift/drift_service.py +217 -0
  55. cgis/query/drift/fingerprint.py +255 -0
  56. cgis/query/drift/fractal.py +292 -0
  57. cgis/query/drift/ontology_init.py +470 -0
  58. cgis/query/drift/quotient.py +75 -0
  59. cgis/query/drift/triads.py +234 -0
  60. cgis/query/engine.py +170 -0
  61. cgis/query/fqn.py +65 -0
  62. cgis/query/render/__init__.py +0 -0
  63. cgis/query/render/graph_json.py +42 -0
  64. cgis/query/render/mermaid.py +235 -0
  65. cgis/query/render/metrics.py +346 -0
  66. cgis/resolver/.gitkeep +0 -0
  67. cgis/resolver/__init__.py +1 -0
  68. cgis/resolver/engine.py +145 -0
  69. cgis/resolver/indices.py +184 -0
  70. cgis/resolver/symbols.py +179 -0
  71. cgis/resolver/uplift.py +263 -0
  72. cgis/storage/.gitkeep +0 -0
  73. cgis/storage/sqlite_store.py +589 -0
  74. codegraph_brain-0.6.0.dist-info/METADATA +240 -0
  75. codegraph_brain-0.6.0.dist-info/RECORD +78 -0
  76. codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
  77. codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
  78. codegraph_brain-0.6.0.dist-info/licenses/LICENSE +21 -0
cgis/cli.py ADDED
@@ -0,0 +1,1516 @@
1
+ """ "CLI to run pipeline."""
2
+
3
+ import dataclasses
4
+ import json as _json
5
+ from enum import StrEnum
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from rich.markup import escape
12
+ from rich.table import Table
13
+ from rich.tree import Tree
14
+
15
+ from cgis import __app_name__, __version__
16
+ from cgis.core.models import VIRTUAL_FILE_PATH, Edge, EdgeType, Node, NodeNamespace, NodeType
17
+ from cgis.extractors.python_extractor import PythonExtractor, file_path_to_module_fqn
18
+ from cgis.extractors.typescript_extractor import TypeScriptExtractor
19
+ from cgis.guardian.metrics import load_reviews, rate_review
20
+ from cgis.pipeline import IngestionPipeline
21
+ from cgis.query.analysis.analyzer import AnalyzerEngine
22
+ from cgis.query.analysis.anomaly import AnomalyType, ArchitecturalAnomaly
23
+ from cgis.query.analysis.health import HealthScorer
24
+ from cgis.query.analysis.suggest_service import SuggestReport, report_to_dict, suggest_packages
25
+ from cgis.query.context.audit import ReachabilityAudit, audit_reachability
26
+ from cgis.query.context.context_service import build_context
27
+ from cgis.query.drift.drift import DriftReport, FitQuality
28
+ from cgis.query.drift.drift_service import analyze_drift
29
+ from cgis.query.drift.fractal import FractalReport, analyze_fractal_db
30
+ from cgis.query.drift.ontology_init import propose_ontology
31
+ from cgis.query.engine import BEHAVIORAL_EDGE_TYPES, QueryEngine
32
+ from cgis.query.fqn import resolve_fqn
33
+ from cgis.query.render.graph_json import graph_to_json
34
+ from cgis.query.render.mermaid import MermaidCompiler
35
+ from cgis.query.render.metrics import ArchitectureReport, DuckDBAnalyzer
36
+ from cgis.resolver.uplift import SemanticUpliftEngine
37
+ from cgis.storage.sqlite_store import RAW_CALL_PREFIX, SQLiteStore
38
+
39
+ _DEFAULT_DB = "graph.db"
40
+ _DEFAULT_DB_HELP = "Path to the SQLite database"
41
+ _DEPTH_HELP = "Maximum traversal depth"
42
+ _FORMAT_HELP = "Output format: text, mermaid, or json"
43
+ _TEXT_JSON_FORMAT_HELP = "Output format: text or json"
44
+ _TRUNCATED_HINT = " [dim]โ€ฆ (more matches exist; refine the name)[/dim]"
45
+ _INTERNAL_ONLY_TEXT_ERR = (
46
+ "--internal-only is only supported with '--format mermaid' or '--format json'"
47
+ )
48
+
49
+ _OPT_SHOW_STRUCTURE: bool = typer.Option(
50
+ False,
51
+ "--show-structure/--no-show-structure",
52
+ help="Include structural edges (CONTAINS, DECLARES) in output",
53
+ )
54
+ _OPT_SHOW_EXTERNAL: bool = typer.Option(
55
+ False,
56
+ "--show-external/--no-show-external",
57
+ help="Include stdlib and external nodes in output",
58
+ )
59
+ _OPT_MIN_CONFIDENCE: float | None = typer.Option(
60
+ None,
61
+ "--min-confidence",
62
+ min=0.0,
63
+ max=1.0,
64
+ help="Hide edges below this confidence (e.g. 0.5 drops unresolved raw_call edges)",
65
+ )
66
+
67
+
68
+ class OutputFormat(StrEnum):
69
+ """Supported output formats for trace, impact, and structure commands."""
70
+
71
+ TEXT = "text"
72
+ MERMAID = "mermaid"
73
+ JSON = "json"
74
+
75
+
76
+ class DriftOutputFormat(StrEnum):
77
+ """Supported output formats for the drift command."""
78
+
79
+ TEXT = "text"
80
+ JSON = "json"
81
+
82
+
83
+ class SuggestOutputFormat(StrEnum):
84
+ """Supported output formats for the suggest-packages command."""
85
+
86
+ TEXT = "text"
87
+ JSON = "json"
88
+
89
+
90
+ class FractalOutputFormat(StrEnum):
91
+ """Supported output formats for the fractal command."""
92
+
93
+ TEXT = "text"
94
+ JSON = "json"
95
+
96
+
97
+ console = Console()
98
+ app = typer.Typer(help="CGIS: Code Graph Intelligence System CLI")
99
+
100
+
101
+ def _version_callback(value: bool) -> None:
102
+ """Print version string and exit when --version flag is passed."""
103
+ if value:
104
+ typer.echo(f"{__app_name__} v{__version__}")
105
+ raise typer.Exit
106
+
107
+
108
+ @app.callback()
109
+ def main(
110
+ _version: bool | None = typer.Option(
111
+ None,
112
+ "--version",
113
+ "-v",
114
+ help="Show the application's version and exit.",
115
+ callback=_version_callback,
116
+ is_eager=True,
117
+ ),
118
+ ) -> None:
119
+ """CGIS โ€” Code Graph Intelligence System CLI."""
120
+
121
+
122
+ def _write_graph_output(
123
+ output: str,
124
+ source_path: str,
125
+ nodes: list[Node],
126
+ resolved_edges: list[Edge],
127
+ domains: str | None,
128
+ ) -> None:
129
+ """Persist the ingestion result to the given output path (.db or .json)."""
130
+ if output.endswith(".json"):
131
+ enriched_nodes = HealthScorer(nodes, resolved_edges).enrich()
132
+ graph_data = {
133
+ "metadata": {
134
+ "source_path": source_path,
135
+ "node_count": len(enriched_nodes),
136
+ "edge_count": len(resolved_edges),
137
+ },
138
+ "nodes": [n.model_dump() for n in enriched_nodes],
139
+ "edges": [e.model_dump() for e in resolved_edges],
140
+ }
141
+ output_path = Path(output)
142
+ output_path.parent.mkdir(parents=True, exist_ok=True)
143
+ with output_path.open("w", encoding="utf-8") as f:
144
+ _json.dump(graph_data, f, indent=2)
145
+ else:
146
+ with SQLiteStore(output) as store:
147
+ store.save_graph(nodes, resolved_edges, overwrite=True)
148
+ SemanticUpliftEngine(store, domains).execute_uplift()
149
+
150
+
151
+ @app.command()
152
+ def ingest(
153
+ path: str = typer.Argument(..., help="Path to the repository to ingest"),
154
+ output: str = typer.Option(
155
+ "graph.json", "--output", "-o", help="Path to save the graph (JSON or .db)"
156
+ ),
157
+ incremental: bool = typer.Option(
158
+ False,
159
+ "--incremental",
160
+ "-i",
161
+ help="Only re-ingest files whose content has changed (requires .db output).",
162
+ ),
163
+ domains: str | None = typer.Option(
164
+ None,
165
+ "--domains",
166
+ "-d",
167
+ help="Path to a domains.yaml file for semantic uplift (requires .db output).",
168
+ ),
169
+ source_roots: list[str] = typer.Option(
170
+ [],
171
+ "--source-root",
172
+ "-s",
173
+ help=(
174
+ "Strip this directory prefix when building FQNs "
175
+ "(e.g. --source-root src makes src/cgis/foo.py โ†’ cgis.foo). "
176
+ "May be repeated for multiple roots."
177
+ ),
178
+ ),
179
+ ) -> None:
180
+ """
181
+ Scan a repository, extract code structure, and resolve semantic links.
182
+ """
183
+ roots = source_roots or []
184
+ extractors = {
185
+ ".py": PythonExtractor(source_roots=roots),
186
+ ".ts": TypeScriptExtractor(source_roots=roots),
187
+ ".tsx": TypeScriptExtractor(tsx=True, source_roots=roots),
188
+ }
189
+
190
+ pipeline = IngestionPipeline(extractors, domains_config=domains)
191
+
192
+ if domains and not Path(domains).is_file():
193
+ console.print(f"[bold red]โŒ Domains config file not found:[/bold red] {domains}")
194
+ raise typer.Exit(code=1)
195
+
196
+ console.print(f"[bold blue]๐Ÿš€ Starting ingestion for:[/bold blue] {path}")
197
+
198
+ if incremental and output.endswith(".json"):
199
+ console.print(
200
+ "[bold yellow]โš ๏ธ --incremental requires a .db output file. "
201
+ "Falling back to full ingest.[/bold yellow]"
202
+ )
203
+ incremental = False
204
+
205
+ try:
206
+ if incremental:
207
+ with SQLiteStore(output) as store:
208
+ nodes, raw_edges, resolved_edges = pipeline.run(path, store=store)
209
+ else:
210
+ nodes, raw_edges, resolved_edges = pipeline.run(path)
211
+
212
+ if not nodes:
213
+ console.print(
214
+ "[bold yellow]โš ๏ธ Warning: No nodes were extracted. "
215
+ "Check your path or file extensions.[/bold yellow]"
216
+ )
217
+ return
218
+
219
+ if not incremental:
220
+ _write_graph_output(output, path, nodes, resolved_edges, domains)
221
+
222
+ table = Table(title="Ingestion Summary")
223
+ table.add_column("Metric", style="cyan")
224
+ table.add_column("Value", style="magenta")
225
+
226
+ table.add_row("Nodes extracted", str(len(nodes)))
227
+ table.add_row("Edges extracted (raw)", str(len(raw_edges)))
228
+ table.add_row("Edges resolved (clean)", str(len(resolved_edges)))
229
+ if incremental:
230
+ table.add_row("Mode", "incremental")
231
+
232
+ console.print(table)
233
+ console.print("[bold green]โœ… Success![/bold green] Graph data ready.")
234
+
235
+ except Exception as e:
236
+ console.print(f"[bold red]โŒ Error during ingestion:[/bold red] {e}")
237
+ raise typer.Exit(code=1) from e
238
+
239
+
240
+ def _filter_internal(
241
+ nodes: list[Node],
242
+ edges: list[Edge],
243
+ ) -> tuple[list[Node], list[Edge]]:
244
+ """Keep only INTERNAL nodes backed by a real source file (exclude virtual placeholders)."""
245
+ filtered_nodes = [
246
+ n
247
+ for n in nodes
248
+ if n.namespace == NodeNamespace.INTERNAL and n.file_path != VIRTUAL_FILE_PATH
249
+ ]
250
+ internal_ids = {n.id for n in filtered_nodes}
251
+ filtered_edges = [e for e in edges if e.source in internal_ids and e.target in internal_ids]
252
+ return filtered_nodes, filtered_edges
253
+
254
+
255
+ def _render_graph(
256
+ output_format: OutputFormat,
257
+ root: str,
258
+ nodes: list[Node],
259
+ edges: list[Edge],
260
+ internal_only: bool = False,
261
+ ) -> str:
262
+ """Render a subgraph for a non-text format (Mermaid for eyes, JSON for agents)."""
263
+ if internal_only:
264
+ nodes, edges = _filter_internal(nodes, edges)
265
+ if output_format == OutputFormat.JSON:
266
+ return _json.dumps(graph_to_json(root, nodes, edges), indent=2)
267
+ return MermaidCompiler().compile(nodes, edges)
268
+
269
+
270
+ def _resolve_cli_fqn(store: SQLiteStore, target: str, kind: str) -> str:
271
+ """Resolve a possibly-partial FQN for a CLI command or exit with code 1."""
272
+ resolution = resolve_fqn(store, target)
273
+ if resolution.resolved is None:
274
+ if resolution.candidates:
275
+ console.print(f"[bold red]โŒ Ambiguous FQN:[/bold red] {target}")
276
+ for candidate in resolution.candidates:
277
+ console.print(f" [dim]- {candidate}[/dim]")
278
+ if resolution.truncated:
279
+ console.print(_TRUNCATED_HINT)
280
+ else:
281
+ console.print(f"[bold red]โŒ {kind} not found in graph:[/bold red] {target}")
282
+ raise typer.Exit(code=1)
283
+ if resolution.via_suffix:
284
+ console.print(f"[dim]Resolved '{target}' โ†’ '{resolution.resolved}'[/dim]")
285
+ return resolution.resolved
286
+
287
+
288
+ def build_trace_tree(
289
+ store: SQLiteStore,
290
+ current_id: str,
291
+ current_tree: Tree,
292
+ path_visited: set[str],
293
+ max_depth: int,
294
+ current_depth: int,
295
+ allowed_edge_types: frozenset[EdgeType] | None = None,
296
+ show_external: bool = True,
297
+ min_confidence: float | None = None,
298
+ ) -> None:
299
+ """Cycle-safe recursive tree builder for downstream flow tracing."""
300
+ if current_depth >= max_depth:
301
+ return
302
+
303
+ outgoing = store.get_outgoing_edges(current_id)
304
+ if allowed_edge_types is not None:
305
+ outgoing = [e for e in outgoing if e.type in allowed_edge_types]
306
+ if min_confidence is not None:
307
+ outgoing = [e for e in outgoing if e.confidence >= min_confidence]
308
+ nodes_map = {n.id: n for n in store.get_nodes([e.target for e in outgoing])}
309
+ for edge in outgoing:
310
+ target_id = edge.target
311
+ target_node = nodes_map.get(target_id)
312
+
313
+ if not show_external and (
314
+ target_node is None or target_node.namespace != NodeNamespace.INTERNAL
315
+ ):
316
+ continue
317
+
318
+ if target_node:
319
+ label = (
320
+ f"[bold green]{target_node.type.value}[/bold green] "
321
+ f"[yellow]{target_node.id}[/yellow] "
322
+ f"[dim]({target_node.file_path}:{target_node.start_line})[/dim]"
323
+ )
324
+ else:
325
+ label = f"[bold red]Unresolved[/bold red] [dim]{target_id}[/dim]"
326
+
327
+ branch = current_tree.add(label)
328
+
329
+ if not target_node:
330
+ continue
331
+
332
+ if target_id in path_visited:
333
+ branch.add("[bold red]โ†ป Cycle detected[/bold red]")
334
+ continue
335
+
336
+ path_visited.add(target_id)
337
+ build_trace_tree(
338
+ store,
339
+ target_id,
340
+ branch,
341
+ path_visited,
342
+ max_depth,
343
+ current_depth + 1,
344
+ allowed_edge_types=allowed_edge_types,
345
+ show_external=show_external,
346
+ min_confidence=min_confidence,
347
+ )
348
+ path_visited.remove(target_id)
349
+
350
+
351
+ @app.command()
352
+ def trace(
353
+ start: str = typer.Argument(..., help="FQN of the starting node to trace flow from"),
354
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
355
+ depth: int = typer.Option(5, "--depth", help=_DEPTH_HELP),
356
+ output_format: OutputFormat = typer.Option(
357
+ OutputFormat.TEXT, "--format", "-f", help=_FORMAT_HELP
358
+ ),
359
+ internal_only: bool = typer.Option(
360
+ False, "--internal-only", help="Exclude stdlib and external nodes from output"
361
+ ),
362
+ show_structure: bool = _OPT_SHOW_STRUCTURE,
363
+ show_external: bool = _OPT_SHOW_EXTERNAL,
364
+ min_confidence: float | None = _OPT_MIN_CONFIDENCE,
365
+ ) -> None:
366
+ """
367
+ Trace execution flow starting from a specific code entity downwards.
368
+ """
369
+ path = Path(db)
370
+ if not path.is_file():
371
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
372
+ raise typer.Exit(code=1)
373
+
374
+ allowed: frozenset[EdgeType] | None = None if show_structure else BEHAVIORAL_EDGE_TYPES
375
+
376
+ with SQLiteStore(db) as store:
377
+ start = _resolve_cli_fqn(store, start, "Start entity")
378
+ start_node = store.get_node(start)
379
+ if not start_node: # pragma: no cover โ€” resolved FQNs always exist
380
+ raise typer.Exit(code=1)
381
+
382
+ if output_format != OutputFormat.TEXT:
383
+ nodes, edges = QueryEngine(store).get_flow_graph(
384
+ start,
385
+ max_depth=depth,
386
+ allowed_edge_types=allowed,
387
+ show_external=show_external,
388
+ min_confidence=min_confidence,
389
+ )
390
+ typer.echo(_render_graph(output_format, start, nodes, edges, internal_only))
391
+ else:
392
+ if internal_only:
393
+ raise typer.BadParameter(_INTERNAL_ONLY_TEXT_ERR)
394
+ console.print(
395
+ f"[bold blue]๐Ÿ” Tracing execution flow starting from:[/bold blue] {start}\n"
396
+ )
397
+ root_label = (
398
+ f"[bold cyan]{start_node.type.value}[/bold cyan] "
399
+ f"[yellow]{start_node.id}[/yellow] "
400
+ f"[dim]({start_node.file_path}:{start_node.start_line})[/dim]"
401
+ )
402
+ tree = Tree(root_label)
403
+ build_trace_tree(
404
+ store,
405
+ start,
406
+ tree,
407
+ {start},
408
+ depth,
409
+ 0,
410
+ allowed_edge_types=allowed,
411
+ show_external=show_external,
412
+ min_confidence=min_confidence,
413
+ )
414
+ console.print(tree)
415
+
416
+
417
+ def build_impact_tree(
418
+ store: SQLiteStore,
419
+ current_id: str,
420
+ current_tree: Tree,
421
+ path_visited: set[str],
422
+ max_depth: int,
423
+ current_depth: int,
424
+ allowed_edge_types: frozenset[EdgeType] | None = None,
425
+ show_external: bool = True,
426
+ min_confidence: float | None = None,
427
+ ) -> None:
428
+ """Cycle-safe recursive tree builder for upstream impact analysis."""
429
+ if current_depth >= max_depth:
430
+ return
431
+
432
+ incoming = store.get_incoming_edges(current_id)
433
+ if allowed_edge_types is not None:
434
+ incoming = [e for e in incoming if e.type in allowed_edge_types]
435
+ if min_confidence is not None:
436
+ incoming = [e for e in incoming if e.confidence >= min_confidence]
437
+ nodes_map = {n.id: n for n in store.get_nodes([e.source for e in incoming])}
438
+ for edge in incoming:
439
+ source_id = edge.source
440
+ source_node = nodes_map.get(source_id)
441
+
442
+ if not show_external and (
443
+ source_node is None or source_node.namespace != NodeNamespace.INTERNAL
444
+ ):
445
+ continue
446
+
447
+ if source_node:
448
+ label = (
449
+ f"[bold magenta]{source_node.type.value}[/bold magenta] "
450
+ f"[yellow]{source_node.id}[/yellow] "
451
+ f"[dim]({source_node.file_path}:{source_node.start_line})[/dim]"
452
+ )
453
+ else:
454
+ label = f"[bold red]Unknown Caller[/bold red] [dim]{source_id}[/dim]"
455
+
456
+ branch = current_tree.add(label)
457
+
458
+ if not source_node:
459
+ continue
460
+
461
+ if source_id in path_visited:
462
+ branch.add("[bold red]โ†ป Cycle detected[/bold red]")
463
+ continue
464
+
465
+ path_visited.add(source_id)
466
+ build_impact_tree(
467
+ store,
468
+ source_id,
469
+ branch,
470
+ path_visited,
471
+ max_depth,
472
+ current_depth + 1,
473
+ allowed_edge_types=allowed_edge_types,
474
+ show_external=show_external,
475
+ min_confidence=min_confidence,
476
+ )
477
+ path_visited.remove(source_id)
478
+
479
+
480
+ @app.command()
481
+ def impact(
482
+ target: str = typer.Argument(..., help="FQN of the target entity to analyze"),
483
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
484
+ depth: int = typer.Option(5, "--depth", help=_DEPTH_HELP),
485
+ output_format: OutputFormat = typer.Option(
486
+ OutputFormat.TEXT, "--format", "-f", help=_FORMAT_HELP
487
+ ),
488
+ internal_only: bool = typer.Option(
489
+ False, "--internal-only", help="Exclude stdlib and external nodes from output"
490
+ ),
491
+ show_structure: bool = _OPT_SHOW_STRUCTURE,
492
+ show_external: bool = _OPT_SHOW_EXTERNAL,
493
+ min_confidence: float | None = _OPT_MIN_CONFIDENCE,
494
+ ) -> None:
495
+ """
496
+ Analyze transitive upstream impact (callers) of changing a specific code entity.
497
+ """
498
+ path = Path(db)
499
+ if not path.is_file():
500
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
501
+ raise typer.Exit(code=1)
502
+
503
+ allowed: frozenset[EdgeType] | None = None if show_structure else BEHAVIORAL_EDGE_TYPES
504
+
505
+ with SQLiteStore(db) as store:
506
+ target = _resolve_cli_fqn(store, target, "Target entity")
507
+ target_node = store.get_node(target)
508
+ if not target_node: # pragma: no cover โ€” resolved FQNs always exist
509
+ raise typer.Exit(code=1)
510
+
511
+ if output_format != OutputFormat.TEXT:
512
+ nodes, edges = QueryEngine(store).get_impact_graph(
513
+ target,
514
+ max_depth=depth,
515
+ allowed_edge_types=allowed,
516
+ show_external=show_external,
517
+ min_confidence=min_confidence,
518
+ )
519
+ typer.echo(_render_graph(output_format, target, nodes, edges, internal_only))
520
+ else:
521
+ if internal_only:
522
+ raise typer.BadParameter(_INTERNAL_ONLY_TEXT_ERR)
523
+ console.print(
524
+ f"[bold blue]๐Ÿ” Analyzing transitive upstream callers of:[/bold blue] {target}\n"
525
+ )
526
+ root_label = (
527
+ f"[bold cyan]{target_node.type.value}[/bold cyan] "
528
+ f"[yellow]{target_node.id}[/yellow] "
529
+ f"[dim]({target_node.file_path}:{target_node.start_line})[/dim]"
530
+ )
531
+ tree = Tree(root_label)
532
+ build_impact_tree(
533
+ store,
534
+ target,
535
+ tree,
536
+ {target},
537
+ depth,
538
+ 0,
539
+ allowed_edge_types=allowed,
540
+ show_external=show_external,
541
+ min_confidence=min_confidence,
542
+ )
543
+ console.print(tree)
544
+
545
+
546
+ @app.command()
547
+ def validate(
548
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
549
+ threshold: float = typer.Option(
550
+ 0.30,
551
+ "--threshold",
552
+ "-t",
553
+ min=0.0,
554
+ max=1.0,
555
+ help="Max allowed unresolved ratio (default 0.30 = 30%)",
556
+ ),
557
+ ) -> None:
558
+ """
559
+ Report graph integrity: resolved vs unresolved edge ratio.
560
+
561
+ Exits with code 1 if the unresolved ratio exceeds the threshold.
562
+ """
563
+ path = Path(db)
564
+ if not path.is_file():
565
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
566
+ raise typer.Exit(code=1)
567
+
568
+ try:
569
+ with SQLiteStore(db) as store:
570
+ stats = store.get_edge_stats()
571
+ except Exception as e:
572
+ console.print(f"[bold red]โŒ Error reading database:[/bold red] {e}")
573
+ raise typer.Exit(code=1) from e
574
+
575
+ def _pct(n: int) -> str:
576
+ """Format an edge count as a percentage of total edges."""
577
+ return f"{n / stats.total * 100:.1f}%" if stats.total else "0.0%"
578
+
579
+ unresolved_pct = stats.unresolved_ratio * 100
580
+
581
+ table = Table(title="Graph Integrity Report")
582
+ table.add_column("Metric", style="cyan")
583
+ table.add_column("Value", style="magenta", justify="right")
584
+
585
+ table.add_row("Total edges", str(stats.total))
586
+ table.add_row("Internal (resolved)", f"{stats.resolved} ({_pct(stats.resolved)})")
587
+ table.add_row("Stdlib calls", f"{stats.stdlib} ({_pct(stats.stdlib)})")
588
+ table.add_row("External calls", f"{stats.external} ({_pct(stats.external)})")
589
+ if stats.unresolved:
590
+ table.add_row(
591
+ "Unresolved (raw)",
592
+ f"[bold red]{stats.unresolved} ({_pct(stats.unresolved)})[/bold red]",
593
+ )
594
+ console.print(table)
595
+
596
+ if stats.top_unresolved:
597
+ console.print("\n[bold]Top unresolved targets:[/bold]")
598
+ top_table = Table(show_header=False, box=None, padding=(0, 2))
599
+ top_table.add_column("target", style="dim")
600
+ top_table.add_column("count", justify="right", style="yellow")
601
+ for target, count in stats.top_unresolved:
602
+ name = target.removeprefix(RAW_CALL_PREFIX)
603
+ top_table.add_row(name, str(count))
604
+ console.print(top_table)
605
+
606
+ threshold_pct = threshold * 100
607
+ if stats.unresolved_ratio > threshold:
608
+ console.print(
609
+ f"\n[bold red]โŒ Unresolved ratio {unresolved_pct:.1f}% "
610
+ f"exceeds threshold {threshold_pct:.1f}%[/bold red]"
611
+ )
612
+ raise typer.Exit(code=1)
613
+
614
+ internal_pct = stats.resolved / stats.total * 100 if stats.total else 0.0
615
+ console.print(
616
+ f"\n[bold green]โœ… Internal {internal_pct:.1f}% โ€” "
617
+ f"unresolved {unresolved_pct:.1f}% below threshold {threshold_pct:.1f}%[/bold green]"
618
+ )
619
+
620
+
621
+ @app.command()
622
+ def find(
623
+ query: str = typer.Argument(..., help="Partial symbol name to search for"),
624
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
625
+ kind: str | None = typer.Option(
626
+ None, "--kind", "-k", help="Filter by node type (FUNCTION/METHOD/CLASS/...)"
627
+ ),
628
+ prefix: str | None = typer.Option(
629
+ None, "--prefix", "-p", help="Scope results to FQNs under this prefix"
630
+ ),
631
+ limit: int = typer.Option(20, "--limit", "-n", min=1, help="Max results"),
632
+ ) -> None:
633
+ """
634
+ Find symbols by partial name โ†’ candidate FQNs (ranked exact > prefix > substring).
635
+
636
+ Resolves the "I know the name but not the FQN" gap before trace/impact/structure.
637
+ """
638
+ path = Path(db)
639
+ if not path.is_file():
640
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
641
+ raise typer.Exit(code=1)
642
+
643
+ kinds = (kind.strip().upper(),) if kind and kind.strip() else ()
644
+ with SQLiteStore(db) as store:
645
+ matches = store.search_nodes(query, kinds=kinds, fqn_prefix=prefix, limit=limit)
646
+
647
+ if not matches:
648
+ console.print(f"[yellow]No symbols matching '{escape(query)}'[/yellow]")
649
+ return
650
+
651
+ table = Table(title=f"Symbols matching '{escape(query)}'")
652
+ table.add_column("Name", style="cyan")
653
+ table.add_column("Type", style="dim")
654
+ table.add_column("FQN", style="yellow")
655
+ table.add_column("Location", style="dim")
656
+ for node in matches:
657
+ table.add_row(node.name, node.type.value, node.id, f"{node.file_path}:{node.start_line}")
658
+ console.print(table)
659
+
660
+
661
+ @app.command()
662
+ def structure(
663
+ target: str = typer.Argument(..., help="FQN or file path of the module/class to inspect"),
664
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
665
+ depth: int = typer.Option(3, "--depth", help=_DEPTH_HELP),
666
+ output_format: OutputFormat = typer.Option(
667
+ OutputFormat.TEXT, "--format", "-f", help=_FORMAT_HELP
668
+ ),
669
+ ) -> None:
670
+ """
671
+ Show the structural hierarchy (UML) of a module or class.
672
+
673
+ Traverses only CONTAINS and DECLARES edges โ€” no call-graph noise.
674
+ Accepts an FQN (e.g. src.cgis.pipeline) or a file path (src/cgis/pipeline.py).
675
+ """
676
+ path = Path(db)
677
+ if not path.is_file():
678
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
679
+ raise typer.Exit(code=1)
680
+
681
+ # Normalize file path โ†’ FQN
682
+ if "/" in target or "\\" in target or target.endswith(".py"):
683
+ normalized = target.replace("\\", "/").removeprefix("./")
684
+ target = file_path_to_module_fqn(normalized)
685
+ console.print(f"[dim]โ†’ FQN: {target}[/dim]")
686
+
687
+ with SQLiteStore(db) as store:
688
+ target = _resolve_cli_fqn(store, target, "Node")
689
+ target_node = store.get_node(target)
690
+ if not target_node: # pragma: no cover โ€” resolved FQNs always exist
691
+ raise typer.Exit(code=1)
692
+
693
+ nodes, edges = QueryEngine(store).get_structural_graph(target, max_depth=depth)
694
+
695
+ if output_format != OutputFormat.TEXT:
696
+ typer.echo(_render_graph(output_format, target, nodes, edges))
697
+ return
698
+
699
+ console.print(f"[bold blue]๐Ÿ“ฆ Structure of:[/bold blue] {target}\n")
700
+ root_label = (
701
+ f"[bold cyan]{target_node.type.value}[/bold cyan] [yellow]{target_node.id}[/yellow]"
702
+ )
703
+ tree = Tree(root_label)
704
+ nodes_map = {n.id: n for n in nodes}
705
+ children_map: dict[str, list[str]] = {}
706
+ for edge in edges:
707
+ children_map.setdefault(edge.source, []).append(edge.target)
708
+ _build_structure_tree(target_node.id, tree, nodes_map, children_map, depth, 0)
709
+ console.print(tree)
710
+
711
+
712
+ def _build_structure_tree(
713
+ node_id: str,
714
+ branch: Tree,
715
+ nodes_map: dict[str, Node],
716
+ children_map: dict[str, list[str]],
717
+ max_depth: int,
718
+ current_depth: int,
719
+ ) -> None:
720
+ """Recursively add children to a rich Tree branch using preloaded nodes/edges."""
721
+ if current_depth >= max_depth:
722
+ return
723
+ for child_id in sorted(
724
+ children_map.get(node_id, []),
725
+ key=lambda cid: nodes_map[cid].start_line if cid in nodes_map else 0,
726
+ ):
727
+ child_node = nodes_map.get(child_id)
728
+ if not child_node:
729
+ continue
730
+ label = f"[bold cyan]{child_node.type.value}[/bold cyan] [yellow]{child_node.name}[/yellow]"
731
+ child_branch = branch.add(label)
732
+ _build_structure_tree(
733
+ child_id, child_branch, nodes_map, children_map, max_depth, current_depth + 1
734
+ )
735
+
736
+
737
+ _SEVERITY_COLOUR = {
738
+ AnomalyType.CIRCULAR_DEPENDENCY: "red",
739
+ AnomalyType.ZONE_OF_PAIN: "yellow",
740
+ AnomalyType.GOD_OBJECT: "magenta",
741
+ }
742
+
743
+
744
+ @app.command()
745
+ def analyze(
746
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
747
+ min_severity: float = typer.Option(
748
+ 0.0,
749
+ "--min-severity",
750
+ "-s",
751
+ min=0.0,
752
+ max=1.0,
753
+ help="Only show anomalies at or above this severity score (0.0-1.0)",
754
+ ),
755
+ output_format: OutputFormat = typer.Option(
756
+ OutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
757
+ ),
758
+ ) -> None:
759
+ """
760
+ Detect architectural anti-patterns in the ingested code graph.
761
+
762
+ Runs three detectors: circular dependencies (Tarjan SCC), Zone of Pain
763
+ (Uncle Bob's instability / abstractness metrics), and God Objects.
764
+ Requires a .db graph file produced by the `ingest` command.
765
+ """
766
+ path = Path(db)
767
+ if not path.is_file():
768
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
769
+ raise typer.Exit(code=1)
770
+
771
+ with SQLiteStore(db) as store:
772
+ report = AnalyzerEngine(store).run()
773
+
774
+ visible = [a for a in report.anomalies if a.severity_score >= min_severity]
775
+
776
+ if output_format == OutputFormat.MERMAID:
777
+ console.print("[bold red]โŒ JSON/text only for analyze โ€” mermaid not supported.[/bold red]")
778
+ raise typer.Exit(code=1)
779
+
780
+ if output_format == OutputFormat.JSON:
781
+ typer.echo(_json.dumps([a.model_dump() for a in visible], indent=2))
782
+ return
783
+
784
+ console.print(
785
+ f"\n[bold blue]๐Ÿ” Architectural Health Report[/bold blue] "
786
+ f"[dim]{db}[/dim]\n"
787
+ f" Nodes analysed : [cyan]{report.total_nodes_analyzed}[/cyan]\n"
788
+ f" Anomalies found: [{'red' if report.total_anomalies else 'green'}]"
789
+ f"{report.total_anomalies}[/{'red' if report.total_anomalies else 'green'}]\n"
790
+ )
791
+
792
+ if not visible:
793
+ console.print("[bold green]โœ… No anomalies above the severity threshold.[/bold green]")
794
+ return
795
+
796
+ by_type: dict[AnomalyType, list[ArchitecturalAnomaly]] = {}
797
+ for a in visible:
798
+ by_type.setdefault(a.type, []).append(a)
799
+
800
+ for anomaly_type, items in by_type.items():
801
+ colour = _SEVERITY_COLOUR.get(anomaly_type, "white")
802
+ console.print(f"[bold {colour}]{'โ”' * 60}[/bold {colour}]")
803
+ console.print(
804
+ f"[bold {colour}]{anomaly_type.value}[/bold {colour}] ({len(items)} found)\n"
805
+ )
806
+ for a in sorted(items, key=lambda x: x.severity_score, reverse=True):
807
+ console.print(
808
+ f" [yellow]{a.focal_fqn}[/yellow] "
809
+ f"severity=[bold {colour}]{a.severity_score:.2f}[/bold {colour}]"
810
+ )
811
+ for key, val in a.metrics.items():
812
+ console.print(f" [dim]{key}:[/dim] {val}")
813
+ console.print(f" [italic]๐Ÿ’ก {a.refactoring_hint}[/italic]\n")
814
+
815
+
816
+ _DEFAULT_METRICS = "guardian_metrics.jsonl"
817
+
818
+
819
+ @app.command()
820
+ def guardian_rate(
821
+ pr: int = typer.Argument(..., help="GitHub PR number to rate."),
822
+ applied: int = typer.Argument(..., help="Number of findings actually applied."),
823
+ metrics: str = typer.Option(_DEFAULT_METRICS, "--metrics", "-m", help="Path to metrics file."),
824
+ ) -> None:
825
+ """Record how many Guardian findings were applied for a given PR."""
826
+ updated = rate_review(pr=pr, applied=applied, metrics_path=Path(metrics))
827
+ if updated:
828
+ console.print(f"[green]โœ… PR #{pr}: recorded {applied} applied findings.[/green]")
829
+ else:
830
+ console.print(f"[red]โŒ No unrated entry found for PR #{pr} in {metrics}.[/red]")
831
+ raise typer.Exit(code=1)
832
+
833
+
834
+ @app.command()
835
+ def guardian_stats(
836
+ metrics: str = typer.Option(_DEFAULT_METRICS, "--metrics", "-m", help="Path to metrics file."),
837
+ last: int = typer.Option(20, "--last", "-n", help="Show only the last N reviews."),
838
+ ) -> None:
839
+ """Show Guardian review quality metrics trend."""
840
+ reviews = load_reviews(Path(metrics))
841
+ if not reviews:
842
+ console.print(f"[yellow]No metrics found in {metrics}.[/yellow]")
843
+ raise typer.Exit
844
+
845
+ reviews = reviews[-last:]
846
+
847
+ table = Table(title=f"Guardian Review Metrics (last {len(reviews)})")
848
+ table.add_column("PR", style="cyan", justify="right")
849
+ table.add_column("Model", style="dim")
850
+ table.add_column("Tokens", justify="right")
851
+ table.add_column("Findings", justify="right")
852
+ table.add_column("Applied", justify="right")
853
+ table.add_column("Precision", justify="right")
854
+ table.add_column("LGTM")
855
+
856
+ total_tokens = 0
857
+ rated = [r for r in reviews if r.get("findings_applied") is not None]
858
+
859
+ for r in reviews:
860
+ pr_str = f"#{r['pr']}" if r.get("pr") else "โ€”"
861
+ tokens = int(r.get("total_tokens", 0))
862
+ total_tokens += tokens
863
+ findings = int(r.get("findings_total", 0))
864
+ applied = r.get("findings_applied")
865
+ if applied is not None:
866
+ applied_str = str(applied)
867
+ precision = f"{int(applied) / findings * 100:.0f}%" if findings else "โ€”"
868
+ else:
869
+ applied_str = "[dim]?[/dim]"
870
+ precision = "[dim]?[/dim]"
871
+ lgtm = "โœ…" if r.get("lgtm") else ""
872
+ table.add_row(
873
+ pr_str,
874
+ str(r.get("model", "")),
875
+ f"{tokens:,}",
876
+ str(findings),
877
+ applied_str,
878
+ precision,
879
+ lgtm,
880
+ )
881
+
882
+ console.print(table)
883
+
884
+ if rated:
885
+ total_findings = sum(int(r["findings_total"]) for r in rated)
886
+ total_applied = sum(int(r["findings_applied"]) for r in rated)
887
+ avg_precision = f"{total_applied / total_findings * 100:.0f}%" if total_findings else "โ€”"
888
+ console.print(f"\n Avg tokens/review : [cyan]{total_tokens // len(reviews):,}[/cyan]")
889
+ rated_label = f"rated {len(rated)}/{len(reviews)} reviews"
890
+ console.print(f" Overall precision : [cyan]{avg_precision}[/cyan] ({rated_label})")
891
+
892
+
893
+ def _drift_status_label(status: str = "clean") -> str:
894
+ """Return a Rich-formatted status label for a drift report entry.
895
+
896
+ ``gate_failed`` renders first and distinctly (spec ยง2.4, #170A).
897
+ ``empty`` and ``no_signal`` are status-driven and precede score-driven ones (#178).
898
+ Label derivation is fully status-driven.
899
+ """
900
+ if status == "gate_failed":
901
+ return "[bold red]โ›” gate failed[/bold red]"
902
+ if status == "empty":
903
+ return "[bold red]โ›” EMPTY[/bold red]"
904
+ if status == "no_signal":
905
+ return "[yellow]โ—Œ no signal[/yellow]"
906
+ if status == "critical":
907
+ return "[bold red]โŒ critical[/bold red]"
908
+ if status == "warning":
909
+ return "[yellow]โš ๏ธ warning[/yellow]"
910
+ return "[green]โœ… clean[/green]"
911
+
912
+
913
+ def _fit_cell(fit: FitQuality | None) -> str:
914
+ """Render a report's fit-quality (#177): nearest template + residual, banded."""
915
+ if fit is None:
916
+ return "โ€”"
917
+ label = f"{fit.nearest_template} {fit.nearest_residual:.2f}"
918
+ if fit.band == "good":
919
+ return f"[green]{label}[/green]"
920
+ if fit.band == "weak":
921
+ return f"[yellow]{label}[/yellow]"
922
+ return f"[bold red]โœ— {label}[/bold red]"
923
+
924
+
925
+ def _render_fit_rollups(reports: list[DriftReport], coverage: list[str]) -> None:
926
+ """Print the 'no template fits' and unbound-coverage roll-ups (#177)."""
927
+ for r in reports:
928
+ f = r.fit
929
+ if f is None or f.band != "none":
930
+ continue
931
+ runner = (
932
+ f", runner-up {f.runner_up_template} {f.runner_up_residual:.2f}"
933
+ if f.runner_up_template is not None and f.runner_up_residual is not None
934
+ else ""
935
+ )
936
+ console.print(
937
+ f"[yellow]โš  {escape(r.fqn_prefix)}: no template fits "
938
+ f"(nearest {f.nearest_template} {f.nearest_residual:.2f}{runner})[/yellow]"
939
+ " โ€” split the module or extend the alphabet."
940
+ )
941
+ if coverage:
942
+ shown = ", ".join(escape(c) for c in coverage[:8])
943
+ more = f" (+{len(coverage) - 8} more)" if len(coverage) > 8 else ""
944
+ console.print(
945
+ f"[dim]Unbound code (no project_domain): {shown}{more} โ€” drift skips these.[/dim]"
946
+ )
947
+
948
+
949
+ def _render_drift_table(reports: list[DriftReport]) -> None:
950
+ """Print an Architectural Drift Report table to the console.
951
+
952
+ Each report carries its own effective tolerance (``r.tolerance``), so no
953
+ global ``max_drift`` parameter is needed here (#170B, spec ยง2.4).
954
+ """
955
+ table = Table(title="Architectural Drift Report")
956
+ table.add_column("Domain", style="cyan")
957
+ table.add_column("Expected Pattern", style="dim")
958
+ table.add_column("Drift", justify="right")
959
+ table.add_column("TV imp", justify="right", style="dim")
960
+ table.add_column("TV calls", justify="right", style="dim")
961
+ table.add_column("Fit", justify="left")
962
+ table.add_column("Status", justify="center")
963
+ for r in reports:
964
+ table.add_row(
965
+ r.fqn_prefix,
966
+ r.expected_pattern or "(hygiene)",
967
+ f"{r.drift_score:.2f}",
968
+ f"{r.tv_imports:.2f}" if r.tv_imports is not None else "โ€”",
969
+ f"{r.tv_calls:.2f}" if r.tv_calls is not None else "โ€”",
970
+ _fit_cell(r.fit),
971
+ _drift_status_label(r.status),
972
+ )
973
+ console.print(table)
974
+ # Notes print below the table (not as a cramped sub-row) so the diagnostic
975
+ # text isn't wrapped by the columnar layout.
976
+ for r in reports:
977
+ if r.note:
978
+ console.print(f" [dim]{escape(r.note)}[/dim]")
979
+
980
+
981
+ @app.command()
982
+ def drift(
983
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
984
+ patterns: str = typer.Option(
985
+ "docs/ontology/patterns.yaml",
986
+ "--patterns",
987
+ "-p",
988
+ help="Path to a patterns.yaml file with domain expectations.",
989
+ ),
990
+ output_format: DriftOutputFormat = typer.Option(
991
+ DriftOutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
992
+ ),
993
+ max_drift: float = typer.Option(
994
+ 0.50,
995
+ "--max-drift",
996
+ min=0.0,
997
+ max=1.0,
998
+ help=(
999
+ "Default tolerance for domains that do not declare drift_tolerance "
1000
+ "(no longer caps domains that do โ€” see #170)."
1001
+ ),
1002
+ ),
1003
+ profile: str | None = typer.Option(
1004
+ None,
1005
+ "--profile",
1006
+ "-P",
1007
+ help=(
1008
+ "Score only domains with this profile (plus profile-less ones). "
1009
+ "Without it, zero-match domains of OTHER profiles report EMPTY "
1010
+ "and fail the gate โ€” use --profile when your patterns.yaml mixes "
1011
+ "languages but the db holds one graph."
1012
+ ),
1013
+ ),
1014
+ max_residual: float = typer.Option(
1015
+ 0.45,
1016
+ "--max-residual",
1017
+ min=0.0,
1018
+ max=1.0,
1019
+ help=(
1020
+ "Fit-quality cutoff (#177): a domain whose nearest template is farther "
1021
+ "than this is flagged 'no template fits' โ€” a grab-bag module or an "
1022
+ "alphabet gap, independent of drift tolerance."
1023
+ ),
1024
+ ),
1025
+ ) -> None:
1026
+ """
1027
+ Report per-domain architectural drift against declared ideal patterns.
1028
+
1029
+ Exits with code 1 if any domain drift score meets or exceeds the critical threshold.
1030
+ """
1031
+ if not Path(db).is_file():
1032
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1033
+ raise typer.Exit(code=1)
1034
+
1035
+ if not Path(patterns).is_file():
1036
+ console.print(f"[bold red]โŒ Patterns file not found:[/bold red] {patterns}")
1037
+ raise typer.Exit(code=1)
1038
+
1039
+ try:
1040
+ analysis = analyze_drift(
1041
+ db, patterns, max_drift=max_drift, profile=profile, max_residual=max_residual
1042
+ )
1043
+ except Exception as e:
1044
+ console.print(f"[bold red]โŒ Error during drift analysis:[/bold red] {e}")
1045
+ raise typer.Exit(code=1) from e
1046
+
1047
+ if output_format == DriftOutputFormat.JSON:
1048
+ payload = [dataclasses.asdict(r) for r in analysis.reports]
1049
+ payload += [{**dataclasses.asdict(r), "enforce": b.enforce} for b, r in analysis.quotient]
1050
+ typer.echo(_json.dumps(payload, indent=2))
1051
+ if analysis.any_critical:
1052
+ raise typer.Exit(code=1)
1053
+ return
1054
+
1055
+ _render_drift_table(analysis.reports)
1056
+
1057
+ for b, qr in analysis.quotient:
1058
+ marker = "" if b.enforce else " [dim](observe-only)[/dim]"
1059
+ status_label = _drift_status_label(qr.status)
1060
+ console.print(
1061
+ f"Quotient k=1 \\[{b.name}] vs {qr.expected_pattern}: "
1062
+ f"drift={qr.drift_score:.2f} {status_label}{marker}"
1063
+ )
1064
+ if qr.note:
1065
+ console.print(f" [dim]{escape(qr.note)}[/dim]")
1066
+
1067
+ _render_fit_rollups(analysis.reports, analysis.coverage)
1068
+
1069
+ if analysis.any_critical:
1070
+ console.print("[bold red]โŒ One or more domains exceed the drift threshold.[/bold red]")
1071
+ raise typer.Exit(code=1)
1072
+
1073
+ console.print("[bold green]โœ… All domains within tolerance.[/bold green]")
1074
+
1075
+
1076
+ def _render_init_summary(text: str) -> None:
1077
+ """Print a compact Rich summary table for the proposed ontology.
1078
+
1079
+ Parses ``yaml.safe_load(text)["project_domains"]`` and renders one row per
1080
+ domain with columns: name, fqn_prefix, pattern (or "(hygiene)"), tolerance.
1081
+ Mirrors the style of ``_render_drift_table``.
1082
+ """
1083
+ try:
1084
+ data = yaml.safe_load(text)
1085
+ domains = data.get("project_domains") or []
1086
+ except Exception: # pragma: no cover โ€” malformed yaml is not expected here
1087
+ return
1088
+
1089
+ table = Table(title="Proposed Ontology Summary")
1090
+ table.add_column("Name", style="cyan")
1091
+ table.add_column("FQN Prefix", style="yellow")
1092
+ table.add_column("Pattern", style="dim")
1093
+ table.add_column("Tolerance", justify="right", style="magenta")
1094
+
1095
+ for d in domains:
1096
+ table.add_row(
1097
+ escape(str(d.get("name", ""))),
1098
+ escape(str(d.get("fqn_prefix", ""))),
1099
+ escape(str(d.get("expected_pattern", "(hygiene)"))),
1100
+ f"{d.get('drift_tolerance', ''):.2f}"
1101
+ if isinstance(d.get("drift_tolerance"), (int, float))
1102
+ else escape(str(d.get("drift_tolerance", ""))),
1103
+ )
1104
+ console.print(table)
1105
+
1106
+
1107
+ @app.command(name="init-ontology")
1108
+ def init_ontology(
1109
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1110
+ out: str = typer.Option(
1111
+ "patterns.yaml", "--out", "-o", help="Where to write the proposed ontology."
1112
+ ),
1113
+ margin: float = typer.Option(
1114
+ 0.03, "--margin", min=0.0, max=0.5, help="Tolerance headroom above the measured score."
1115
+ ),
1116
+ min_nodes: int = typer.Option(
1117
+ 10, "--min-nodes", min=1, help="Domains smaller than this stay hygiene-only."
1118
+ ),
1119
+ depth: int | None = typer.Option(
1120
+ None, "--depth", min=1, help="Fixed FQN segment depth for domain discovery (default: auto)."
1121
+ ),
1122
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing --out file."),
1123
+ ) -> None:
1124
+ """Propose a starter patterns.yaml from the measured graph (measure-then-label)."""
1125
+ if Path(out).exists() and not force:
1126
+ console.print(f"[bold red]โŒ {out} already exists[/bold red] โ€” use --force to overwrite.")
1127
+ raise typer.Exit(code=1)
1128
+ try:
1129
+ text = propose_ontology(db, margin=margin, min_nodes=min_nodes, depth=depth)
1130
+ except FileNotFoundError as e:
1131
+ console.print(f"[bold red]โŒ {e}[/bold red]")
1132
+ raise typer.Exit(code=1) from e
1133
+ Path(out).write_text(text)
1134
+ _render_init_summary(text)
1135
+ console.print(f"[bold green]โœ… Proposed ontology written to {out}[/bold green]")
1136
+ console.print(f"Next: [cyan]cgis drift --db {db} --patterns {out}[/cyan]")
1137
+
1138
+
1139
+ @app.command()
1140
+ def context(
1141
+ fqn: str = typer.Argument(..., help="FQN of the focal node to compile context for"),
1142
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1143
+ depth: int = typer.Option(
1144
+ 1, "--depth", min=1, help="CALLS traversal depth (1=direct neighbours)."
1145
+ ),
1146
+ source_root: str = typer.Option(
1147
+ "",
1148
+ "--source-root",
1149
+ "-s",
1150
+ help="Directory used to locate source files for the snippet "
1151
+ "(e.g. 'src' if you ran `cgis ingest ./src`). Stored paths that already "
1152
+ "carry the root segment are handled too.",
1153
+ ),
1154
+ ) -> None:
1155
+ """Compile an agent-facing GraphRAG context package for a focal FQN.
1156
+
1157
+ Emits an XML-tagged prompt (focal source, enclosing class, domain boundary,
1158
+ callers, callees) to stdout โ€” pipe it straight into an LLM:
1159
+
1160
+ cgis context "pkg.mod.func" | llm "refactor this safely"
1161
+
1162
+ Resolution notes go to stderr so stdout stays a clean payload.
1163
+ """
1164
+ path = Path(db)
1165
+ if not path.is_file():
1166
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1167
+ raise typer.Exit(code=1)
1168
+
1169
+ err_console = Console(stderr=True)
1170
+ with SQLiteStore(db) as store:
1171
+ resolution = resolve_fqn(store, fqn)
1172
+ if resolution.resolved is None:
1173
+ if resolution.candidates:
1174
+ err_console.print(f"[bold red]โŒ Ambiguous FQN:[/bold red] {fqn}")
1175
+ for candidate in resolution.candidates:
1176
+ err_console.print(f" [dim]- {candidate}[/dim]")
1177
+ if resolution.truncated:
1178
+ err_console.print(_TRUNCATED_HINT)
1179
+ else:
1180
+ err_console.print(f"[bold red]โŒ Node not found in graph:[/bold red] {fqn}")
1181
+ raise typer.Exit(code=1)
1182
+ if resolution.via_suffix:
1183
+ err_console.print(f"[dim]Resolved '{fqn}' โ†’ '{resolution.resolved}'[/dim]")
1184
+ payload = build_context(store, resolution.resolved, depth=depth, source_root=source_root)
1185
+ typer.echo(payload)
1186
+
1187
+
1188
+ def _render_metrics(report: ArchitectureReport) -> None:
1189
+ """Print the architecture report as two Rich tables (bottlenecks + God classes)."""
1190
+ bottlenecks = Table(title="๐Ÿ”Œ Coupling bottlenecks (top by fan-in + fan-out)")
1191
+ bottlenecks.add_column("Node", style="cyan")
1192
+ bottlenecks.add_column("Type", style="magenta")
1193
+ bottlenecks.add_column("In", justify="right", style="green")
1194
+ bottlenecks.add_column("Out", justify="right", style="yellow")
1195
+ for m in report.bottlenecks:
1196
+ bottlenecks.add_row(m.node_id, m.node_type, str(m.in_degree), str(m.out_degree))
1197
+ console.print(bottlenecks)
1198
+
1199
+ gods = Table(title="๐Ÿ›๏ธ God classes (top by declared members)")
1200
+ gods.add_column("Class", style="cyan")
1201
+ gods.add_column("Members", justify="right", style="red")
1202
+ for m in report.god_classes:
1203
+ gods.add_row(m.node_id, str(m.out_degree))
1204
+ console.print(gods)
1205
+
1206
+ critical = Table(title="โญ Critical nodes (top by PageRank โ€” transitive importance)")
1207
+ critical.add_column("Node", style="cyan")
1208
+ critical.add_column("Type", style="magenta")
1209
+ critical.add_column("PageRank", justify="right", style="green")
1210
+ critical.add_column("In", justify="right", style="dim")
1211
+ critical.add_column("Out", justify="right", style="dim")
1212
+ for m in report.critical:
1213
+ # In/Out are over the same internal graph PageRank ran on; a high rank with
1214
+ # In=0 is a dangling-mass leaf artifact, not a real hub (#237).
1215
+ critical.add_row(
1216
+ m.node_id, m.node_type, f"{m.page_rank:.4f}", str(m.in_degree), str(m.out_degree)
1217
+ )
1218
+ console.print(critical)
1219
+
1220
+
1221
+ @app.command()
1222
+ def metrics(
1223
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1224
+ limit: int = typer.Option(10, "--limit", min=1, help="Top-N rows per section."),
1225
+ exclude: list[str] = typer.Option(
1226
+ [],
1227
+ "--exclude",
1228
+ "-x",
1229
+ help=(
1230
+ "Drop nodes whose FQN contains this dot-segment anywhere, e.g. "
1231
+ "'-x tests' removes both tests.* and domains.*.tests.* (repeatable)."
1232
+ ),
1233
+ ),
1234
+ output_format: OutputFormat = typer.Option(
1235
+ OutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
1236
+ ),
1237
+ ) -> None:
1238
+ """Whole-graph architectural metrics โ€” bottlenecks, God classes, PageRank (DuckDB).
1239
+
1240
+ Runs vectorized aggregations over the graph via an optional DuckDB layer.
1241
+ Install it with `pip install 'codegraph-brain[analytics]'` if missing.
1242
+ Use `--exclude tests` to keep test scaffolding out of the rankings.
1243
+ """
1244
+ if output_format == OutputFormat.MERMAID:
1245
+ console.print("[bold red]โŒ metrics supports --format text or json only.[/bold red]")
1246
+ raise typer.Exit(code=2)
1247
+ if not Path(db).is_file():
1248
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1249
+ raise typer.Exit(code=1)
1250
+ try:
1251
+ with DuckDBAnalyzer(db) as analyzer:
1252
+ report = analyzer.architecture_report(
1253
+ bottleneck_limit=limit, god_limit=limit, critical_limit=limit, exclude=exclude
1254
+ )
1255
+ except Exception as e: # duckdb missing, extension fetch, or a non-SQLite file
1256
+ console.print(f"[bold red]โŒ {e}[/bold red]")
1257
+ raise typer.Exit(code=1) from e
1258
+
1259
+ if output_format == OutputFormat.JSON:
1260
+ typer.echo(_json.dumps(report.model_dump(), indent=2))
1261
+ return
1262
+ _render_metrics(report)
1263
+
1264
+
1265
+ def _resolve_checkpoint(store: SQLiteStore, target: str) -> str:
1266
+ """Resolve a checkpoint FQN (suffix-aware), diagnosing to stderr or exiting 1.
1267
+
1268
+ Notes go to stderr so `cgis audit --format json` keeps a clean stdout payload.
1269
+ """
1270
+ err_console = Console(stderr=True)
1271
+ resolution = resolve_fqn(store, target)
1272
+ if resolution.resolved is None:
1273
+ if resolution.candidates:
1274
+ err_console.print(f"[bold red]โŒ Ambiguous checkpoint FQN:[/bold red] {target}")
1275
+ for candidate in resolution.candidates:
1276
+ err_console.print(f" [dim]- {candidate}[/dim]")
1277
+ if resolution.truncated:
1278
+ err_console.print(_TRUNCATED_HINT)
1279
+ else:
1280
+ err_console.print(f"[bold red]โŒ Checkpoint not found in graph:[/bold red] {target}")
1281
+ raise typer.Exit(code=1)
1282
+ if resolution.via_suffix:
1283
+ err_console.print(f"[dim]Resolved '{target}' โ†’ '{resolution.resolved}'[/dim]")
1284
+ return resolution.resolved
1285
+
1286
+
1287
+ def _render_audit(result: ReachabilityAudit) -> None:
1288
+ """Print a reachability audit โ€” a covered/gap summary then the gap list (#172)."""
1289
+ total = len(result.covered) + len(result.gaps)
1290
+ console.print(
1291
+ f"[bold blue]๐Ÿ”’ Reachability audit:[/bold blue] {total} sources โ†’ "
1292
+ f"[cyan]{escape(result.target)}[/cyan]"
1293
+ )
1294
+ console.print(
1295
+ f" [green]โœ… covered: {len(result.covered)}[/green] "
1296
+ f"[bold red]โŒ gaps: {len(result.gaps)}[/bold red]"
1297
+ )
1298
+ for gap in result.gaps:
1299
+ console.print(
1300
+ f" [bold red]โœ— {escape(gap.fqn)}[/bold red] "
1301
+ f"[dim]({escape(gap.file)}:{gap.line})[/dim] โ€” never reaches the checkpoint"
1302
+ )
1303
+
1304
+
1305
+ @app.command()
1306
+ def audit(
1307
+ target: str = typer.Argument(
1308
+ ..., help="FQN of the checkpoint every source must reach (e.g. verify_resource_ownership)"
1309
+ ),
1310
+ from_type: NodeType | None = typer.Option(
1311
+ None,
1312
+ "--from-type",
1313
+ help="Only audit nodes of this type (e.g. ROUTE_HANDLER, API_ENDPOINT).",
1314
+ ),
1315
+ from_prefix: str | None = typer.Option(
1316
+ None, "--from-prefix", help="Only audit nodes whose FQN starts with this prefix."
1317
+ ),
1318
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1319
+ depth: int = typer.Option(
1320
+ 5,
1321
+ "--depth",
1322
+ min=1,
1323
+ help="Max reachability depth โ€” bounds the proof; a deeper-than-this path reads as a gap.",
1324
+ ),
1325
+ output_format: OutputFormat = typer.Option(
1326
+ OutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
1327
+ ),
1328
+ ) -> None:
1329
+ """Reachability audit โ€” which sources never reach a required checkpoint (#172).
1330
+
1331
+ The headline use is authorization coverage: list every route handler that does
1332
+ NOT transitively reach the ownership check (IDOR-class gaps), following CALLS
1333
+ *and* FastAPI DEPENDS_ON edges:
1334
+
1335
+ cgis audit verify_resource_ownership --from-type ROUTE_HANDLER
1336
+
1337
+ Generalizes to any "must pass a checkpoint" rule (validators, event tracking,
1338
+ service-layer boundaries).
1339
+ """
1340
+ if output_format == OutputFormat.MERMAID:
1341
+ console.print("[bold red]โŒ audit supports --format text or json only.[/bold red]")
1342
+ raise typer.Exit(code=2)
1343
+ from_prefix = from_prefix.strip() if from_prefix else None
1344
+ if from_type is None and not from_prefix:
1345
+ console.print(
1346
+ "[bold red]โŒ Provide --from-type or --from-prefix to select sources.[/bold red]"
1347
+ )
1348
+ raise typer.Exit(code=2)
1349
+ if not Path(db).is_file():
1350
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1351
+ raise typer.Exit(code=1)
1352
+
1353
+ with SQLiteStore(db) as store:
1354
+ resolved = _resolve_checkpoint(store, target)
1355
+ result = audit_reachability(
1356
+ store,
1357
+ target_fqn=resolved,
1358
+ from_type=from_type,
1359
+ from_prefix=from_prefix,
1360
+ max_depth=depth,
1361
+ )
1362
+
1363
+ if output_format == OutputFormat.JSON:
1364
+ typer.echo(_json.dumps(dataclasses.asdict(result), indent=2))
1365
+ else:
1366
+ _render_audit(result)
1367
+ # Exit non-zero when gaps exist so `cgis audit` can gate CI like a linter.
1368
+ if result.gaps:
1369
+ raise typer.Exit(code=1)
1370
+
1371
+
1372
+ _VERDICT_LABEL = {
1373
+ "split": "โœ‚๏ธ SPLIT",
1374
+ "consolidate": "๐Ÿ”— CONSOLIDATE",
1375
+ "aligned": "โœ… ALIGNED",
1376
+ "leave": "ยท LEAVE",
1377
+ "borderline": "๐ŸŸก BORDERLINE",
1378
+ "no_signal": "โ—Œ no signal",
1379
+ }
1380
+
1381
+
1382
+ def _render_suggest(report: SuggestReport) -> None:
1383
+ """Render a SuggestReport: verdict, metrics, communities, and bridge edges."""
1384
+ console.print(
1385
+ f"[bold]{_VERDICT_LABEL.get(report.verdict, report.verdict)}[/bold] "
1386
+ f"{escape(report.package)} "
1387
+ f"[dim]Q={report.modularity_q:.3f} divergence={report.divergence:.3f} "
1388
+ f"direction={report.direction} ({report.layer})[/dim]"
1389
+ )
1390
+ if report.note:
1391
+ console.print(f" [dim]{escape(report.note)}[/dim]")
1392
+ return
1393
+ comm_table = Table(title="Communities")
1394
+ comm_table.add_column("#", justify="right", style="cyan")
1395
+ comm_table.add_column("Files", style="white")
1396
+ for c in report.communities:
1397
+ comm_table.add_row(str(c.id), escape(", ".join(c.files)))
1398
+ console.print(comm_table)
1399
+ if report.bridges:
1400
+ bridge_table = Table(title="Bridge edges (cost of splitting)")
1401
+ bridge_table.add_column("Source", style="yellow")
1402
+ bridge_table.add_column("Target", style="yellow")
1403
+ bridge_table.add_column("Weight", justify="right", style="magenta")
1404
+ for b in report.bridges:
1405
+ bridge_table.add_row(escape(b.source), escape(b.target), f"{b.weight:.0f}")
1406
+ console.print(bridge_table)
1407
+
1408
+
1409
+ @app.command(name="suggest-packages")
1410
+ def suggest_packages_cmd(
1411
+ prefix: str = typer.Argument(
1412
+ ..., help="FQN prefix of the package to analyze (e.g. cgis.query)."
1413
+ ),
1414
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1415
+ with_calls: bool = typer.Option(
1416
+ False, "--with-calls", help="Include CALLS edges (combined graph), not just IMPORTS."
1417
+ ),
1418
+ output_format: SuggestOutputFormat = typer.Option(
1419
+ SuggestOutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
1420
+ ),
1421
+ min_q: float = typer.Option(
1422
+ 0.35,
1423
+ "--min-q",
1424
+ min=0.0,
1425
+ max=1.0,
1426
+ help="Modularity threshold above which a divergent package is flagged 'split'.",
1427
+ ),
1428
+ ) -> None:
1429
+ """Suggest sub-package boundaries from a package's dependency communities.
1430
+
1431
+ Detects communities (greedy modularity Q) over the intra-package import
1432
+ graph, measures how far the directory layout diverges (1-NMI), and reports a
1433
+ verdict (split / consolidate / aligned / leave / borderline). Advisory โ€”
1434
+ always exits 0 on success. Run `ingest` first.
1435
+ """
1436
+ if not Path(db).is_file():
1437
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1438
+ raise typer.Exit(code=1)
1439
+ try:
1440
+ report = suggest_packages(db, prefix, with_calls=with_calls, min_q=min_q)
1441
+ except Exception as e:
1442
+ console.print(f"[bold red]โŒ Error during suggest-packages:[/bold red] {e}")
1443
+ raise typer.Exit(code=1) from e
1444
+
1445
+ if output_format == SuggestOutputFormat.JSON:
1446
+ typer.echo(_json.dumps(report_to_dict(report), indent=2))
1447
+ return
1448
+ _render_suggest(report)
1449
+
1450
+
1451
+ def _render_fractal(reports: list[FractalReport]) -> None:
1452
+ """Render one table per layer: the ladder, then the fit and verdict."""
1453
+ for report in reports:
1454
+ fit = report.fit
1455
+ summary = (
1456
+ f"slope={fit.slope:+.3f} Rยฒ={fit.r_squared:.2f} "
1457
+ f"band=ยฑ{2 * fit.std_error:.3f} live={fit.live_rungs}"
1458
+ if fit is not None
1459
+ else "no fit"
1460
+ )
1461
+ console.print(
1462
+ f"\n[bold]{report.layer}[/bold] [cyan]{report.verdict}[/cyan] [dim]{summary}[/dim]"
1463
+ )
1464
+ table = Table()
1465
+ table.add_column("Rung", style="white")
1466
+ table.add_column("Groups", justify="right")
1467
+ table.add_column("Triads", justify="right")
1468
+ table.add_column("H (bits)", justify="right")
1469
+ table.add_column("Dominant", style="cyan")
1470
+ table.add_column("Tangle", justify="right")
1471
+ for rung in report.rungs:
1472
+ entropy = "โ€”" if rung.entropy is None else f"{rung.entropy:.2f}"
1473
+ name = rung.name if rung.live else f"{rung.name} [dim](no_signal)[/dim]"
1474
+ table.add_row(
1475
+ name,
1476
+ str(rung.groups),
1477
+ str(rung.triads),
1478
+ entropy,
1479
+ f"{rung.dominant}:{rung.dominant_share:.2f}",
1480
+ f"{rung.tangle_ratio:.3f}",
1481
+ )
1482
+ console.print(table)
1483
+
1484
+
1485
+ @app.command()
1486
+ def fractal(
1487
+ db: str = typer.Option(_DEFAULT_DB, "--db", "-d", help=_DEFAULT_DB_HELP),
1488
+ output_format: FractalOutputFormat = typer.Option(
1489
+ FractalOutputFormat.TEXT, "--format", "-f", help=_TEXT_JSON_FORMAT_HELP
1490
+ ),
1491
+ ) -> None:
1492
+ """Report the motif census across the repository's structural tiers.
1493
+
1494
+ Coarsens the graph along its own structure โ€” symbol, class, module, then
1495
+ directory levels โ€” and fits Shannon entropy against log group count. A
1496
+ positive slope means coarsening adds motif diversity (`hierarchical`); a
1497
+ negative one means it destroys it (`flat`). Observe-only: always exits 0 on
1498
+ success. Run `ingest` first.
1499
+ """
1500
+ try:
1501
+ reports = analyze_fractal_db(db)
1502
+ except FileNotFoundError as e:
1503
+ console.print(f"[bold red]โŒ Database not found:[/bold red] {db}. Run `ingest` first.")
1504
+ raise typer.Exit(code=1) from e
1505
+ except Exception as e:
1506
+ console.print(f"[bold red]โŒ Error during fractal analysis:[/bold red] {e}")
1507
+ raise typer.Exit(code=1) from e
1508
+
1509
+ if output_format == FractalOutputFormat.JSON:
1510
+ typer.echo(_json.dumps({"layers": [dataclasses.asdict(r) for r in reports]}, indent=2))
1511
+ return
1512
+ _render_fractal(reports)
1513
+
1514
+
1515
+ if __name__ == "__main__": # pragma: no cover
1516
+ app()