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.
- cgis/__init__.py +10 -0
- cgis/__main__.py +16 -0
- cgis/api/.gitkeep +0 -0
- cgis/api/__init__.py +1 -0
- cgis/api/mcp_server.py +550 -0
- cgis/cli.py +1516 -0
- cgis/core/.gitkeep +0 -0
- cgis/core/models.py +140 -0
- cgis/extractors/.gitkeep +0 -0
- cgis/extractors/_python_ast.py +194 -0
- cgis/extractors/_python_classes.py +126 -0
- cgis/extractors/_python_functions.py +312 -0
- cgis/extractors/_python_imports.py +188 -0
- cgis/extractors/_python_types.py +84 -0
- cgis/extractors/base.py +42 -0
- cgis/extractors/python_extractor.py +235 -0
- cgis/extractors/typescript_extractor.py +310 -0
- cgis/guardian/__init__.py +1 -0
- cgis/guardian/bench.py +199 -0
- cgis/guardian/chunked.py +240 -0
- cgis/guardian/chunker.py +148 -0
- cgis/guardian/collector.py +309 -0
- cgis/guardian/core.py +120 -0
- cgis/guardian/diff_index.py +151 -0
- cgis/guardian/findings.py +70 -0
- cgis/guardian/github_poster.py +133 -0
- cgis/guardian/metrics.py +108 -0
- cgis/guardian/prompts.py +217 -0
- cgis/guardian/providers/__init__.py +1 -0
- cgis/guardian/providers/base.py +112 -0
- cgis/guardian/providers/gemini.py +83 -0
- cgis/guardian/providers/mistral.py +83 -0
- cgis/guardian/providers/ollama.py +99 -0
- cgis/guardian/recording.py +82 -0
- cgis/guardian/render.py +107 -0
- cgis/guardian/runner.py +295 -0
- cgis/guardian/skeptic.py +208 -0
- cgis/pipeline.py +252 -0
- cgis/py.typed +0 -0
- cgis/query/analysis/__init__.py +0 -0
- cgis/query/analysis/analyzer.py +241 -0
- cgis/query/analysis/anomaly.py +34 -0
- cgis/query/analysis/cohesion.py +277 -0
- cgis/query/analysis/health.py +128 -0
- cgis/query/analysis/suggest_service.py +221 -0
- cgis/query/context/__init__.py +0 -0
- cgis/query/context/audit.py +134 -0
- cgis/query/context/context_service.py +129 -0
- cgis/query/context/prompt.py +184 -0
- cgis/query/context/snippet.py +96 -0
- cgis/query/drift/__init__.py +0 -0
- cgis/query/drift/_scc.py +90 -0
- cgis/query/drift/drift.py +867 -0
- cgis/query/drift/drift_service.py +217 -0
- cgis/query/drift/fingerprint.py +255 -0
- cgis/query/drift/fractal.py +292 -0
- cgis/query/drift/ontology_init.py +470 -0
- cgis/query/drift/quotient.py +75 -0
- cgis/query/drift/triads.py +234 -0
- cgis/query/engine.py +170 -0
- cgis/query/fqn.py +65 -0
- cgis/query/render/__init__.py +0 -0
- cgis/query/render/graph_json.py +42 -0
- cgis/query/render/mermaid.py +235 -0
- cgis/query/render/metrics.py +346 -0
- cgis/resolver/.gitkeep +0 -0
- cgis/resolver/__init__.py +1 -0
- cgis/resolver/engine.py +145 -0
- cgis/resolver/indices.py +184 -0
- cgis/resolver/symbols.py +179 -0
- cgis/resolver/uplift.py +263 -0
- cgis/storage/.gitkeep +0 -0
- cgis/storage/sqlite_store.py +589 -0
- codegraph_brain-0.6.0.dist-info/METADATA +240 -0
- codegraph_brain-0.6.0.dist-info/RECORD +78 -0
- codegraph_brain-0.6.0.dist-info/WHEEL +4 -0
- codegraph_brain-0.6.0.dist-info/entry_points.txt +3 -0
- 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()
|