tscode-kg 0.2.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.
- tscode_kg/__init__.py +42 -0
- tscode_kg/__main__.py +6 -0
- tscode_kg/analysis.py +1829 -0
- tscode_kg/app.py +1355 -0
- tscode_kg/bridge.py +114 -0
- tscode_kg/centrality.py +434 -0
- tscode_kg/cli/__init__.py +1 -0
- tscode_kg/cli/cmd_analyze.py +69 -0
- tscode_kg/cli/cmd_bridges.py +38 -0
- tscode_kg/cli/cmd_build.py +86 -0
- tscode_kg/cli/cmd_centrality.py +124 -0
- tscode_kg/cli/cmd_explain.py +58 -0
- tscode_kg/cli/cmd_framework_nodes.py +43 -0
- tscode_kg/cli/cmd_hooks.py +125 -0
- tscode_kg/cli/cmd_init.py +234 -0
- tscode_kg/cli/cmd_mcp.py +35 -0
- tscode_kg/cli/cmd_model.py +52 -0
- tscode_kg/cli/cmd_query.py +75 -0
- tscode_kg/cli/cmd_snapshot.py +431 -0
- tscode_kg/cli/cmd_viz.py +175 -0
- tscode_kg/cli/main.py +56 -0
- tscode_kg/coderank.py +564 -0
- tscode_kg/config.py +36 -0
- tscode_kg/explain.py +270 -0
- tscode_kg/extractor.py +827 -0
- tscode_kg/framework_detector.py +106 -0
- tscode_kg/kg.py +193 -0
- tscode_kg/layout3d.py +492 -0
- tscode_kg/mcp_server.py +1412 -0
- tscode_kg/snapshots.py +64 -0
- tscode_kg/viz3d.py +1457 -0
- tscode_kg/viz3d_timeline.py +369 -0
- tscode_kg-0.2.0.dist-info/METADATA +196 -0
- tscode_kg-0.2.0.dist-info/RECORD +37 -0
- tscode_kg-0.2.0.dist-info/WHEEL +4 -0
- tscode_kg-0.2.0.dist-info/entry_points.txt +15 -0
- tscode_kg-0.2.0.dist-info/licenses/LICENSE +24 -0
tscode_kg/analysis.py
ADDED
|
@@ -0,0 +1,1829 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
TSCodeKG Thorough Repository Analysis Tool
|
|
4
|
+
|
|
5
|
+
Performs comprehensive architectural analysis of TypeScript/JavaScript repositories
|
|
6
|
+
using TypeScriptKG's graph traversal capabilities. Analyzes:
|
|
7
|
+
- Complexity hotspots (highest fan-in/fan-out functions and methods)
|
|
8
|
+
- Architectural patterns (core modules, integration points)
|
|
9
|
+
- Dependency analysis (orphaned declarations, tight coupling)
|
|
10
|
+
- JSDoc coverage (determines semantic retrieval quality)
|
|
11
|
+
- Inheritance, implements, and interface-extends hierarchies
|
|
12
|
+
- Exported public API surface
|
|
13
|
+
|
|
14
|
+
Operational behaviour:
|
|
15
|
+
- Entry point defaults: resolves ``repo_root`` and defaults ``db_path``/``vectors_path``
|
|
16
|
+
to ``.tscodekg/graph.sqlite`` and ``.tscodekg/vectors.sqlite``.
|
|
17
|
+
- Logging: Rich console for user-facing status; ``logging`` for diagnostics.
|
|
18
|
+
- Error handling: degrades gracefully when optional data is missing.
|
|
19
|
+
|
|
20
|
+
Usage (Python API):
|
|
21
|
+
from tscode_kg import TypeScriptKG
|
|
22
|
+
from tscode_kg.analysis import TSCodeKGAnalyzer
|
|
23
|
+
|
|
24
|
+
kg = TypeScriptKG("/path/to/ts-repo")
|
|
25
|
+
kg.build()
|
|
26
|
+
analyzer = TSCodeKGAnalyzer(kg)
|
|
27
|
+
results = analyzer.run_analysis(report_path="analysis.md")
|
|
28
|
+
|
|
29
|
+
Usage (CLI):
|
|
30
|
+
tscodekg analyze /path/to/ts-repo [--report analysis.md]
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import datetime
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import platform
|
|
39
|
+
import subprocess
|
|
40
|
+
import time
|
|
41
|
+
from collections import defaultdict
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
from dataclasses import asdict, dataclass
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from rich.console import Console
|
|
47
|
+
|
|
48
|
+
logging.basicConfig(level=logging.INFO)
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Data classes
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class FunctionMetrics:
|
|
59
|
+
"""Metrics for a single function, method, or class.
|
|
60
|
+
|
|
61
|
+
:param node_id: Stable node identifier
|
|
62
|
+
:param name: Function/class name
|
|
63
|
+
:param module: Module path containing this definition
|
|
64
|
+
:param kind: Node kind (function, method, class, interface)
|
|
65
|
+
:param fan_in: Count of callers (how many call this)
|
|
66
|
+
:param fan_out: Count of callees (how many this calls)
|
|
67
|
+
:param lines: Approximate line count
|
|
68
|
+
:param docstring: JSDoc text if available
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
node_id: str
|
|
72
|
+
name: str
|
|
73
|
+
module: str
|
|
74
|
+
kind: str
|
|
75
|
+
fan_in: int
|
|
76
|
+
fan_out: int
|
|
77
|
+
lines: int
|
|
78
|
+
docstring: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class ModuleMetrics:
|
|
83
|
+
"""Metrics for a module (single TypeScript/JavaScript file).
|
|
84
|
+
|
|
85
|
+
:param path: Module file path (relative to repo root)
|
|
86
|
+
:param functions: Count of top-level functions defined
|
|
87
|
+
:param classes: Count of classes defined
|
|
88
|
+
:param methods: Count of methods defined
|
|
89
|
+
:param incoming_deps: Modules whose code calls into this one
|
|
90
|
+
:param outgoing_deps: Modules this one imports from
|
|
91
|
+
:param total_fan_in: Sum of cross-module callers for all nodes in module
|
|
92
|
+
:param cohesion_score: Internal coupling strength (0–1)
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
path: str
|
|
96
|
+
functions: int
|
|
97
|
+
classes: int
|
|
98
|
+
methods: int
|
|
99
|
+
incoming_deps: list[str]
|
|
100
|
+
outgoing_deps: list[str]
|
|
101
|
+
total_fan_in: int
|
|
102
|
+
cohesion_score: float
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class CallChain:
|
|
107
|
+
"""Represents a notable call chain.
|
|
108
|
+
|
|
109
|
+
:param chain: List of function/method names in call order
|
|
110
|
+
:param depth: Length of the chain
|
|
111
|
+
:param total_callers: Sum of all callers in chain
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
chain: list[str]
|
|
115
|
+
depth: int
|
|
116
|
+
total_callers: int
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Analyzer
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TSCodeKGAnalyzer:
|
|
125
|
+
"""Thorough TypeScript/JavaScript repository analyzer using TypeScriptKG.
|
|
126
|
+
|
|
127
|
+
:param kg: TypeScriptKG instance (built KG required for useful results)
|
|
128
|
+
:param console: Rich console for terminal output (creates new if None)
|
|
129
|
+
:param snapshot_mgr: Optional SnapshotManager for temporal history
|
|
130
|
+
:param include_dirs: Directories included in the indexed build
|
|
131
|
+
:param exclude_dirs: Directories excluded from the indexed build
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
_TOTAL_PHASES = 14
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
kg,
|
|
139
|
+
console: Console | None = None,
|
|
140
|
+
snapshot_mgr=None,
|
|
141
|
+
include_dirs: set[str] | None = None,
|
|
142
|
+
exclude_dirs: set[str] | None = None,
|
|
143
|
+
) -> None:
|
|
144
|
+
self.kg = kg
|
|
145
|
+
self.console = console or Console()
|
|
146
|
+
self.snapshot_mgr = snapshot_mgr
|
|
147
|
+
self.include_dirs: set[str] = include_dirs or set()
|
|
148
|
+
self.exclude_dirs: set[str] = exclude_dirs or set()
|
|
149
|
+
|
|
150
|
+
# Phase results
|
|
151
|
+
self.stats: dict = {}
|
|
152
|
+
self.function_metrics: dict[str, FunctionMetrics] = {}
|
|
153
|
+
self.module_metrics: dict[str, ModuleMetrics] = {}
|
|
154
|
+
self.orphaned_functions: list[FunctionMetrics] = []
|
|
155
|
+
self.high_fanout_functions: list[FunctionMetrics] = []
|
|
156
|
+
self.critical_paths: list[CallChain] = []
|
|
157
|
+
self.public_apis: list[FunctionMetrics] = []
|
|
158
|
+
self.issues: list[str] = []
|
|
159
|
+
self.strengths: list[str] = []
|
|
160
|
+
self.jsdoc_coverage: dict = {}
|
|
161
|
+
self.inheritance_analysis: dict = {}
|
|
162
|
+
self.snapshot_history: list[dict] = []
|
|
163
|
+
self.centrality_records: list = []
|
|
164
|
+
self.centrality_modules: list[dict] = []
|
|
165
|
+
self.coderank_scores: dict[str, float] = {}
|
|
166
|
+
self.coderank_top_nodes: list[dict] = []
|
|
167
|
+
self.concern_analysis: list[dict] = []
|
|
168
|
+
self._phase_result: str = ""
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Phase runner
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def _run_phase(self, num: int, name: str, fn: Callable[[], None]) -> None:
|
|
175
|
+
self._phase_result = ""
|
|
176
|
+
t0 = time.monotonic()
|
|
177
|
+
fn()
|
|
178
|
+
elapsed = time.monotonic() - t0
|
|
179
|
+
result = f" {self._phase_result}" if self._phase_result else ""
|
|
180
|
+
self.console.print(
|
|
181
|
+
f" [cyan]▶ Phase {num:2d}/{self._TOTAL_PHASES}:[/cyan]"
|
|
182
|
+
f" {name}{result} [green]({elapsed:.1f}s)[/green]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# Public entrypoint
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def run_analysis(
|
|
190
|
+
self,
|
|
191
|
+
report_path: str | None = None,
|
|
192
|
+
*,
|
|
193
|
+
persist_centrality: bool = False,
|
|
194
|
+
) -> dict:
|
|
195
|
+
"""Run complete multi-phase analysis.
|
|
196
|
+
|
|
197
|
+
Phase order:
|
|
198
|
+
1. Baseline metrics
|
|
199
|
+
2. CodeRank (global PageRank over CALLS + IMPORTS + INHERITS)
|
|
200
|
+
3. Fan-in analysis (most-called functions/methods)
|
|
201
|
+
4. Fan-out analysis (orchestrators)
|
|
202
|
+
5. Orphan detection (zero-callers excluding framework entry points)
|
|
203
|
+
6. Pattern detection
|
|
204
|
+
7. Module coupling (IMPORTS + cross-module CALLS)
|
|
205
|
+
8. Critical call chains
|
|
206
|
+
9. Public API surface (exported declarations)
|
|
207
|
+
10. JSDoc coverage
|
|
208
|
+
11. Class/interface hierarchy (INHERITS + IMPLEMENTS + EXTENDS)
|
|
209
|
+
12. Generate insights and recommendations
|
|
210
|
+
13. Snapshot history
|
|
211
|
+
14. Structural centrality (SIR PageRank)
|
|
212
|
+
|
|
213
|
+
:param report_path: Optional file path to write the Markdown report
|
|
214
|
+
:param persist_centrality: When True, write centrality scores to SQLite
|
|
215
|
+
:return: Dictionary of all analysis results
|
|
216
|
+
"""
|
|
217
|
+
_start = datetime.datetime.now(datetime.UTC)
|
|
218
|
+
try:
|
|
219
|
+
self._run_phase(1, "Baseline metrics", self._analyze_baseline)
|
|
220
|
+
self._run_phase(2, "CodeRank (global PageRank)", self._compute_coderank)
|
|
221
|
+
self._run_phase(3, "Fan-in analysis", self._analyze_fan_in)
|
|
222
|
+
self._run_phase(4, "Fan-out analysis", self._analyze_fan_out)
|
|
223
|
+
self._run_phase(5, "Orphan detection", self._analyze_orphans)
|
|
224
|
+
self._run_phase(6, "Pattern detection", self._detect_patterns)
|
|
225
|
+
self._run_phase(7, "Module coupling", self._analyze_module_coupling)
|
|
226
|
+
self._run_phase(8, "Critical call chains", self._analyze_critical_paths)
|
|
227
|
+
self._run_phase(9, "Public API surface", self._identify_public_apis)
|
|
228
|
+
self._run_phase(10, "JSDoc coverage", self._analyze_jsdoc_coverage)
|
|
229
|
+
self._run_phase(11, "Class/interface hierarchy", self._analyze_inheritance)
|
|
230
|
+
self._run_phase(12, "Generate insights", self._generate_insights)
|
|
231
|
+
self._run_phase(13, "Snapshot history", self._analyze_snapshots)
|
|
232
|
+
self._run_phase(14, "Structural centrality (SIR)", self._analyze_centrality)
|
|
233
|
+
|
|
234
|
+
if persist_centrality and self.centrality_records:
|
|
235
|
+
try:
|
|
236
|
+
from tscode_kg.centrality import (
|
|
237
|
+
StructuralImportanceRanker, # noqa: PLC0415
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
StructuralImportanceRanker(self.kg.db_path).write_scores(
|
|
241
|
+
self.centrality_records
|
|
242
|
+
)
|
|
243
|
+
except (ImportError, AttributeError, ValueError, RuntimeError):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
if report_path:
|
|
247
|
+
elapsed = (datetime.datetime.now(datetime.UTC) - _start).total_seconds()
|
|
248
|
+
self._write_report(report_path, elapsed_seconds=elapsed)
|
|
249
|
+
|
|
250
|
+
return self._compile_results()
|
|
251
|
+
|
|
252
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
253
|
+
self.console.print(f"[red]Analysis failed: {exc}[/red]")
|
|
254
|
+
logger.exception("Analysis failed")
|
|
255
|
+
raise
|
|
256
|
+
|
|
257
|
+
# ------------------------------------------------------------------
|
|
258
|
+
# Phase implementations
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def _analyze_baseline(self) -> None:
|
|
262
|
+
"""Phase 1: Establish baseline node and edge counts."""
|
|
263
|
+
try:
|
|
264
|
+
self.stats = self.kg.stats()
|
|
265
|
+
n = self.stats.get("total_nodes", "?")
|
|
266
|
+
e = self.stats.get("total_edges", "?")
|
|
267
|
+
self._phase_result = f"nodes={n} edges={e}"
|
|
268
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
269
|
+
logger.warning("Could not get baseline stats: %s", exc)
|
|
270
|
+
|
|
271
|
+
def _compute_coderank(self) -> None:
|
|
272
|
+
"""Phase 2: Compute global CodeRank (weighted PageRank) over the graph."""
|
|
273
|
+
try:
|
|
274
|
+
from tscode_kg.coderank import ( # noqa: PLC0415
|
|
275
|
+
build_code_graph,
|
|
276
|
+
compute_coderank,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
graph = build_code_graph(
|
|
280
|
+
str(self.kg.db_path),
|
|
281
|
+
include_relations=("CALLS", "IMPORTS", "INHERITS"),
|
|
282
|
+
exclude_test_paths=True,
|
|
283
|
+
)
|
|
284
|
+
self.coderank_scores = compute_coderank(graph)
|
|
285
|
+
|
|
286
|
+
sorted_nodes = sorted(self.coderank_scores.items(), key=lambda kv: kv[1], reverse=True)
|
|
287
|
+
top_nodes: list[dict] = []
|
|
288
|
+
for node_id, score in sorted_nodes:
|
|
289
|
+
if node_id.startswith("sym:"):
|
|
290
|
+
continue
|
|
291
|
+
attrs = graph.nodes.get(node_id, {})
|
|
292
|
+
kind = attrs.get("kind", "")
|
|
293
|
+
if kind not in ("function", "method", "class", "module"):
|
|
294
|
+
continue
|
|
295
|
+
top_nodes.append(
|
|
296
|
+
{
|
|
297
|
+
"node_id": node_id,
|
|
298
|
+
"score": score,
|
|
299
|
+
"kind": kind,
|
|
300
|
+
"name": attrs.get("name", node_id.split(":")[-1]),
|
|
301
|
+
"qualname": attrs.get("qualname", ""),
|
|
302
|
+
"module_path": attrs.get("module_path", ""),
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
if len(top_nodes) >= 25:
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
self.coderank_top_nodes = top_nodes
|
|
309
|
+
if top_nodes:
|
|
310
|
+
self._phase_result = (
|
|
311
|
+
f"{len(self.coderank_scores)} nodes top=`{top_nodes[0]['name']}`"
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
self._phase_result = f"{len(self.coderank_scores)} nodes"
|
|
315
|
+
|
|
316
|
+
except (AttributeError, ValueError, RuntimeError, ImportError) as exc:
|
|
317
|
+
logger.warning("CodeRank incomplete: %s", exc)
|
|
318
|
+
self.console.print(f"[yellow]WARN[/yellow] CodeRank incomplete: {exc}")
|
|
319
|
+
|
|
320
|
+
def _analyze_fan_in(self) -> None:
|
|
321
|
+
"""Phase 3: Find most-called functions and methods (fan-in).
|
|
322
|
+
|
|
323
|
+
Seeds from CodeRank top nodes when available; falls back to a direct
|
|
324
|
+
SQL scan when CodeRank is not installed.
|
|
325
|
+
"""
|
|
326
|
+
try:
|
|
327
|
+
con = self.kg.store.con
|
|
328
|
+
if self.coderank_scores:
|
|
329
|
+
rows = con.execute(
|
|
330
|
+
"""
|
|
331
|
+
SELECT id, name, kind, module_path, docstring, lineno, end_lineno
|
|
332
|
+
FROM nodes
|
|
333
|
+
WHERE kind IN ('function', 'method', 'class')
|
|
334
|
+
AND id NOT LIKE 'sym:%'
|
|
335
|
+
ORDER BY module_path, name
|
|
336
|
+
"""
|
|
337
|
+
).fetchall()
|
|
338
|
+
scored: list[tuple[float, tuple]] = []
|
|
339
|
+
for row in rows:
|
|
340
|
+
node_id = row[0]
|
|
341
|
+
score = self.coderank_scores.get(node_id, 0.0)
|
|
342
|
+
scored.append((score, row))
|
|
343
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
344
|
+
candidates = [row for _, row in scored[:100]]
|
|
345
|
+
seed_label = "CodeRank-seeded"
|
|
346
|
+
else:
|
|
347
|
+
candidates = con.execute(
|
|
348
|
+
"""
|
|
349
|
+
SELECT id, name, kind, module_path, docstring, lineno, end_lineno
|
|
350
|
+
FROM nodes
|
|
351
|
+
WHERE kind IN ('function', 'method', 'class')
|
|
352
|
+
AND id NOT LIKE 'sym:%'
|
|
353
|
+
ORDER BY module_path, name
|
|
354
|
+
"""
|
|
355
|
+
).fetchall()
|
|
356
|
+
seed_label = "SQL fallback"
|
|
357
|
+
|
|
358
|
+
fan_in_data: list[tuple] = []
|
|
359
|
+
for row in candidates:
|
|
360
|
+
node_id, name, kind, module_path, docstring, lineno, end_lineno = row
|
|
361
|
+
try:
|
|
362
|
+
caller_list = self.kg.callers(node_id, rel="CALLS")
|
|
363
|
+
fan_in_data.append(
|
|
364
|
+
(
|
|
365
|
+
node_id,
|
|
366
|
+
FunctionMetrics(
|
|
367
|
+
node_id=node_id,
|
|
368
|
+
name=name or "unknown",
|
|
369
|
+
module=module_path or "unknown",
|
|
370
|
+
kind=kind or "unknown",
|
|
371
|
+
fan_in=len(caller_list),
|
|
372
|
+
fan_out=0,
|
|
373
|
+
lines=max(0, (end_lineno or 0) - (lineno or 0)),
|
|
374
|
+
docstring=docstring,
|
|
375
|
+
),
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
except (AttributeError, ValueError, RuntimeError, TypeError):
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
fan_in_data.sort(key=lambda x: x[1].fan_in, reverse=True)
|
|
382
|
+
for node_id, metrics in fan_in_data[:15]:
|
|
383
|
+
self.function_metrics[node_id] = metrics
|
|
384
|
+
|
|
385
|
+
self._phase_result = f"top {len(self.function_metrics)} by fan-in ({seed_label})"
|
|
386
|
+
|
|
387
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
388
|
+
logger.warning("Fan-in analysis incomplete: %s", exc)
|
|
389
|
+
self.console.print(f"[yellow]WARN[/yellow] Fan-in analysis incomplete: {exc}")
|
|
390
|
+
|
|
391
|
+
def _analyze_fan_out(self) -> None:
|
|
392
|
+
"""Phase 4: Compute fan-out for identified nodes and find orchestrators."""
|
|
393
|
+
try:
|
|
394
|
+
for node_id, metrics in self.function_metrics.items():
|
|
395
|
+
try:
|
|
396
|
+
edges = self.kg.store.edges_from(node_id, rel="CALLS", limit=200)
|
|
397
|
+
metrics.fan_out = len(edges) if edges else 0
|
|
398
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
399
|
+
pass
|
|
400
|
+
|
|
401
|
+
# Find additional orchestrators not already in function_metrics
|
|
402
|
+
try:
|
|
403
|
+
result = self.kg.query(
|
|
404
|
+
"coordinator orchestrator manager setup initializer",
|
|
405
|
+
k=20,
|
|
406
|
+
hop=0,
|
|
407
|
+
rels=("CONTAINS",),
|
|
408
|
+
)
|
|
409
|
+
for node in result.nodes:
|
|
410
|
+
node_id = node.get("id")
|
|
411
|
+
if not node_id or node_id in self.function_metrics:
|
|
412
|
+
continue
|
|
413
|
+
if node.get("kind") not in ("function", "method"):
|
|
414
|
+
continue
|
|
415
|
+
try:
|
|
416
|
+
edges = self.kg.store.edges_from(node_id, rel="CALLS", limit=200)
|
|
417
|
+
fanout_count = len(edges) if edges else 0
|
|
418
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
419
|
+
fanout_count = 0
|
|
420
|
+
if fanout_count > 20:
|
|
421
|
+
self.high_fanout_functions.append(
|
|
422
|
+
FunctionMetrics(
|
|
423
|
+
node_id=node_id,
|
|
424
|
+
name=node.get("name", "unknown"),
|
|
425
|
+
module=node.get("module_path", "unknown"),
|
|
426
|
+
kind=node.get("kind", "unknown"),
|
|
427
|
+
fan_in=0,
|
|
428
|
+
fan_out=fanout_count,
|
|
429
|
+
lines=max(
|
|
430
|
+
0,
|
|
431
|
+
(node.get("end_lineno") or 0) - (node.get("lineno") or 0),
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
self._phase_result = f"{len(self.high_fanout_functions)} high-fanout functions"
|
|
439
|
+
|
|
440
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
441
|
+
logger.warning("Fan-out analysis incomplete: %s", exc)
|
|
442
|
+
|
|
443
|
+
def _is_ts_entry_point(self, node: dict) -> bool:
|
|
444
|
+
"""Return True if this node is a framework-driven entry point.
|
|
445
|
+
|
|
446
|
+
TypeScript entry points that have zero internal callers by design:
|
|
447
|
+
- MCP tool functions (in mcp_server files)
|
|
448
|
+
- CLI command handlers (in cli/ directories or *cli.ts files)
|
|
449
|
+
- Next.js page/API route exports (files under pages/ or app/)
|
|
450
|
+
- Express/Fastify route handlers registered directly on app
|
|
451
|
+
- Event listener callbacks (named handle*, on*, listen*)
|
|
452
|
+
- Constructor functions (named with leading capital, kind=class)
|
|
453
|
+
"""
|
|
454
|
+
name = node.get("name", "")
|
|
455
|
+
module = node.get("module_path", "") or ""
|
|
456
|
+
kind = node.get("kind", "")
|
|
457
|
+
|
|
458
|
+
if "mcp_server" in module or "mcp-server" in module:
|
|
459
|
+
return True
|
|
460
|
+
if "/cli/" in module or module.endswith("cli.ts") or module.endswith("cli.js"):
|
|
461
|
+
return True
|
|
462
|
+
if "/pages/" in module or "/app/" in module or "/routes/" in module:
|
|
463
|
+
return True
|
|
464
|
+
if name.startswith(("on", "handle", "listen")) and kind in ("function", "method"):
|
|
465
|
+
return True
|
|
466
|
+
if kind == "class":
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
def _analyze_orphans(self) -> None:
|
|
472
|
+
"""Phase 5: Detect orphaned declarations (zero callers, not entry points)."""
|
|
473
|
+
try:
|
|
474
|
+
con = self.kg.store.con
|
|
475
|
+
rows = con.execute(
|
|
476
|
+
"""
|
|
477
|
+
SELECT id, name, kind, module_path, docstring, lineno, end_lineno
|
|
478
|
+
FROM nodes
|
|
479
|
+
WHERE kind IN ('function', 'method')
|
|
480
|
+
AND id NOT LIKE 'sym:%'
|
|
481
|
+
ORDER BY module_path, name
|
|
482
|
+
"""
|
|
483
|
+
).fetchall()
|
|
484
|
+
|
|
485
|
+
for node_id, name, kind, module_path, docstring, lineno, end_lineno in rows:
|
|
486
|
+
try:
|
|
487
|
+
callers = self.kg.callers(node_id, rel="CALLS")
|
|
488
|
+
if callers:
|
|
489
|
+
continue
|
|
490
|
+
node = {
|
|
491
|
+
"id": node_id,
|
|
492
|
+
"name": name,
|
|
493
|
+
"kind": kind,
|
|
494
|
+
"module_path": module_path,
|
|
495
|
+
}
|
|
496
|
+
if self._is_ts_entry_point(node):
|
|
497
|
+
continue
|
|
498
|
+
self.orphaned_functions.append(
|
|
499
|
+
FunctionMetrics(
|
|
500
|
+
node_id=node_id,
|
|
501
|
+
name=name or "unknown",
|
|
502
|
+
module=module_path or "unknown",
|
|
503
|
+
kind=kind or "unknown",
|
|
504
|
+
fan_in=0,
|
|
505
|
+
fan_out=0,
|
|
506
|
+
lines=max(0, (end_lineno or 0) - (lineno or 0)),
|
|
507
|
+
docstring=docstring,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
except (AttributeError, ValueError, RuntimeError, TypeError):
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
self._phase_result = f"{len(self.orphaned_functions)} orphaned declarations"
|
|
514
|
+
|
|
515
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
516
|
+
logger.warning("Orphan detection incomplete: %s", exc)
|
|
517
|
+
|
|
518
|
+
def _detect_patterns(self) -> None:
|
|
519
|
+
"""Phase 6: Detect core modules and architectural coupling patterns."""
|
|
520
|
+
try:
|
|
521
|
+
module_call_counts: dict[str, int] = defaultdict(int)
|
|
522
|
+
for metrics in self.function_metrics.values():
|
|
523
|
+
mod = metrics.module.split("/")[0] if "/" in metrics.module else metrics.module
|
|
524
|
+
module_call_counts[mod] += metrics.fan_in
|
|
525
|
+
|
|
526
|
+
core_modules = sorted(module_call_counts.items(), key=lambda x: x[1], reverse=True)[:5]
|
|
527
|
+
if core_modules:
|
|
528
|
+
self._phase_result = f"{len(core_modules)} core modules"
|
|
529
|
+
|
|
530
|
+
high_fanout = sorted(
|
|
531
|
+
list(self.function_metrics.values()) + self.high_fanout_functions,
|
|
532
|
+
key=lambda m: m.fan_out,
|
|
533
|
+
reverse=True,
|
|
534
|
+
)[:10]
|
|
535
|
+
for func in high_fanout:
|
|
536
|
+
if func.fan_out > 40:
|
|
537
|
+
self.issues.append(
|
|
538
|
+
f"[HIGH] `{func.name}` has high fan-out ({func.fan_out} callees) "
|
|
539
|
+
"— consider decomposing into smaller, focused functions"
|
|
540
|
+
)
|
|
541
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
542
|
+
logger.warning("Pattern detection incomplete: %s", exc)
|
|
543
|
+
|
|
544
|
+
def _analyze_module_coupling(self) -> None:
|
|
545
|
+
"""Phase 7: Compute module-level coupling using IMPORTS and cross-module CALLS.
|
|
546
|
+
|
|
547
|
+
Outgoing deps = modules/packages this module imports.
|
|
548
|
+
Incoming deps = modules whose functions call into this one.
|
|
549
|
+
Cohesion = incoming / (incoming + outgoing + 1).
|
|
550
|
+
"""
|
|
551
|
+
try:
|
|
552
|
+
con = self.kg.store.con
|
|
553
|
+
|
|
554
|
+
module_rows = con.execute(
|
|
555
|
+
"SELECT id, module_path FROM nodes WHERE kind = 'module' ORDER BY module_path"
|
|
556
|
+
).fetchall()
|
|
557
|
+
|
|
558
|
+
# Cross-module CALLS: src.module_path → dst.module_path
|
|
559
|
+
cross_call_pairs = con.execute(
|
|
560
|
+
"""
|
|
561
|
+
SELECT DISTINCT src.module_path AS caller_mod, dst.module_path AS callee_mod
|
|
562
|
+
FROM edges e
|
|
563
|
+
JOIN nodes src ON e.src = src.id
|
|
564
|
+
JOIN nodes dst ON e.dst = dst.id
|
|
565
|
+
WHERE e.rel = 'CALLS'
|
|
566
|
+
AND src.module_path IS NOT NULL
|
|
567
|
+
AND dst.module_path IS NOT NULL
|
|
568
|
+
AND src.module_path != dst.module_path
|
|
569
|
+
"""
|
|
570
|
+
).fetchall()
|
|
571
|
+
|
|
572
|
+
# IMPORTS: count outgoing imports per module
|
|
573
|
+
import_rows = con.execute(
|
|
574
|
+
"""
|
|
575
|
+
SELECT src.module_path, COUNT(*) AS import_count
|
|
576
|
+
FROM edges e
|
|
577
|
+
JOIN nodes src ON e.src = src.id
|
|
578
|
+
WHERE e.rel = 'IMPORTS'
|
|
579
|
+
AND src.kind = 'module'
|
|
580
|
+
AND src.module_path IS NOT NULL
|
|
581
|
+
GROUP BY src.module_path
|
|
582
|
+
"""
|
|
583
|
+
).fetchall()
|
|
584
|
+
import_count_by_mod: dict[str, int] = {r[0]: r[1] for r in import_rows}
|
|
585
|
+
|
|
586
|
+
# Build cross-call maps
|
|
587
|
+
cross_incoming: dict[str, set[str]] = defaultdict(set) # callee_mod → callers
|
|
588
|
+
cross_outgoing: dict[str, set[str]] = defaultdict(set) # caller_mod → callees
|
|
589
|
+
for caller_mod, callee_mod in cross_call_pairs:
|
|
590
|
+
if caller_mod and callee_mod:
|
|
591
|
+
cross_incoming[callee_mod].add(caller_mod)
|
|
592
|
+
cross_outgoing[caller_mod].add(callee_mod)
|
|
593
|
+
|
|
594
|
+
# Node counts per module
|
|
595
|
+
count_rows = con.execute(
|
|
596
|
+
"SELECT module_path, kind, COUNT(*) FROM nodes"
|
|
597
|
+
" WHERE kind IN ('function', 'class', 'method')"
|
|
598
|
+
" GROUP BY module_path, kind"
|
|
599
|
+
).fetchall()
|
|
600
|
+
kind_counts: dict[str, dict[str, int]] = defaultdict(dict)
|
|
601
|
+
for mod_path, kind, cnt in count_rows:
|
|
602
|
+
if mod_path:
|
|
603
|
+
kind_counts[mod_path][kind] = cnt
|
|
604
|
+
|
|
605
|
+
for _, module_path in module_rows:
|
|
606
|
+
module_path = module_path or "unknown"
|
|
607
|
+
incoming = list(cross_incoming.get(module_path, set()))
|
|
608
|
+
outgoing = list(cross_outgoing.get(module_path, set()))
|
|
609
|
+
# Supplement outgoing with import count if no cross-call data
|
|
610
|
+
n_imports = import_count_by_mod.get(module_path, 0)
|
|
611
|
+
effective_outgoing = max(len(outgoing), min(n_imports, 10))
|
|
612
|
+
cohesion = min(1.0, len(incoming) / (len(incoming) + effective_outgoing + 1))
|
|
613
|
+
counts = kind_counts.get(module_path, {})
|
|
614
|
+
self.module_metrics[module_path] = ModuleMetrics(
|
|
615
|
+
path=module_path,
|
|
616
|
+
functions=counts.get("function", 0),
|
|
617
|
+
classes=counts.get("class", 0),
|
|
618
|
+
methods=counts.get("method", 0),
|
|
619
|
+
incoming_deps=incoming,
|
|
620
|
+
outgoing_deps=outgoing,
|
|
621
|
+
total_fan_in=len(incoming),
|
|
622
|
+
cohesion_score=cohesion,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
self._phase_result = f"{len(self.module_metrics)} modules"
|
|
626
|
+
|
|
627
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
628
|
+
logger.warning("Module coupling analysis incomplete: %s", exc)
|
|
629
|
+
|
|
630
|
+
def _analyze_critical_paths(self) -> None:
|
|
631
|
+
"""Phase 8: Trace key call chains starting from high fan-in nodes."""
|
|
632
|
+
try:
|
|
633
|
+
top_functions = [
|
|
634
|
+
m
|
|
635
|
+
for m in sorted(
|
|
636
|
+
self.function_metrics.values(), key=lambda m: m.fan_in, reverse=True
|
|
637
|
+
)
|
|
638
|
+
][:5]
|
|
639
|
+
|
|
640
|
+
for func in top_functions:
|
|
641
|
+
try:
|
|
642
|
+
callers = self.kg.callers(func.node_id, rel="CALLS")
|
|
643
|
+
chain_names = [func.name]
|
|
644
|
+
chain_modules = [func.module]
|
|
645
|
+
seen_ids: set[str] = {func.node_id}
|
|
646
|
+
current_id = func.node_id
|
|
647
|
+
|
|
648
|
+
for _ in range(6):
|
|
649
|
+
edges = self.kg.store.edges_from(current_id, rel="CALLS", limit=5)
|
|
650
|
+
callee = None
|
|
651
|
+
for edge in edges:
|
|
652
|
+
dst_id = edge["dst"]
|
|
653
|
+
if dst_id in seen_ids or dst_id.startswith("sym:"):
|
|
654
|
+
continue
|
|
655
|
+
node = self.kg.store.node(dst_id)
|
|
656
|
+
if node and node.get("module_path"):
|
|
657
|
+
callee = node
|
|
658
|
+
seen_ids.add(dst_id)
|
|
659
|
+
current_id = dst_id
|
|
660
|
+
break
|
|
661
|
+
if callee:
|
|
662
|
+
chain_names.append(callee.get("name", "?"))
|
|
663
|
+
chain_modules.append(callee.get("module_path", ""))
|
|
664
|
+
else:
|
|
665
|
+
break
|
|
666
|
+
|
|
667
|
+
if callers:
|
|
668
|
+
chain_names = [callers[0].get("name", "?"), *chain_names]
|
|
669
|
+
chain_modules = [callers[0].get("module_path", ""), *chain_modules]
|
|
670
|
+
|
|
671
|
+
crosses_module = len(set(chain_modules)) > 1
|
|
672
|
+
if len(chain_names) >= 4 or crosses_module:
|
|
673
|
+
self.critical_paths.append(
|
|
674
|
+
CallChain(
|
|
675
|
+
chain=chain_names,
|
|
676
|
+
depth=len(chain_names),
|
|
677
|
+
total_callers=len(callers),
|
|
678
|
+
)
|
|
679
|
+
)
|
|
680
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
681
|
+
pass
|
|
682
|
+
|
|
683
|
+
self._phase_result = f"{len(self.critical_paths)} key call chains"
|
|
684
|
+
|
|
685
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
686
|
+
logger.warning("Call chain analysis incomplete: %s", exc)
|
|
687
|
+
|
|
688
|
+
def _identify_public_apis(self) -> None:
|
|
689
|
+
"""Phase 9: Identify exported public API declarations.
|
|
690
|
+
|
|
691
|
+
Strategy (priority order):
|
|
692
|
+
1. Scan source files for ``export`` keyword before function/class/interface/
|
|
693
|
+
type/const declarations and look those names up in the graph.
|
|
694
|
+
2. Supplement with non-private functions in function_metrics that have
|
|
695
|
+
at least one cross-module caller.
|
|
696
|
+
"""
|
|
697
|
+
try:
|
|
698
|
+
already_ids: set[str] = set()
|
|
699
|
+
repo_root = Path(self.kg.repo_root)
|
|
700
|
+
con = self.kg.store.con
|
|
701
|
+
|
|
702
|
+
# Step 1: grep source files for top-level exports
|
|
703
|
+
_TS_EXTS = {".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"}
|
|
704
|
+
_SKIP = {"node_modules", ".git", "dist", "build", ".next", ".tscodekg"}
|
|
705
|
+
|
|
706
|
+
export_names: set[str] = set()
|
|
707
|
+
for source_file in repo_root.rglob("*"):
|
|
708
|
+
if not source_file.is_file():
|
|
709
|
+
continue
|
|
710
|
+
if source_file.suffix not in _TS_EXTS:
|
|
711
|
+
continue
|
|
712
|
+
if any(part in _SKIP for part in source_file.parts):
|
|
713
|
+
continue
|
|
714
|
+
try:
|
|
715
|
+
text = source_file.read_text(encoding="utf-8", errors="replace")
|
|
716
|
+
for line in text.splitlines():
|
|
717
|
+
stripped = line.strip()
|
|
718
|
+
if not stripped.startswith("export"):
|
|
719
|
+
continue
|
|
720
|
+
# export function foo / export class Foo / export const foo
|
|
721
|
+
# export interface Foo / export type Foo / export enum Foo
|
|
722
|
+
# export default function foo / export default class Foo
|
|
723
|
+
tokens = stripped.split()
|
|
724
|
+
for i, tok in enumerate(tokens):
|
|
725
|
+
if tok in (
|
|
726
|
+
"function",
|
|
727
|
+
"class",
|
|
728
|
+
"interface",
|
|
729
|
+
"type",
|
|
730
|
+
"enum",
|
|
731
|
+
"const",
|
|
732
|
+
"let",
|
|
733
|
+
"var",
|
|
734
|
+
):
|
|
735
|
+
if i + 1 < len(tokens):
|
|
736
|
+
candidate = tokens[i + 1].rstrip("(<{:=")
|
|
737
|
+
if (
|
|
738
|
+
candidate
|
|
739
|
+
and candidate[0].isalpha()
|
|
740
|
+
or candidate.startswith("_")
|
|
741
|
+
):
|
|
742
|
+
export_names.add(candidate)
|
|
743
|
+
break
|
|
744
|
+
except OSError:
|
|
745
|
+
pass
|
|
746
|
+
|
|
747
|
+
for name in export_names:
|
|
748
|
+
rows = con.execute(
|
|
749
|
+
"""
|
|
750
|
+
SELECT id, name, kind, module_path, docstring
|
|
751
|
+
FROM nodes
|
|
752
|
+
WHERE name = ?
|
|
753
|
+
AND kind IN ('function', 'method', 'class', 'interface', 'type_alias', 'enum')
|
|
754
|
+
AND id NOT LIKE 'sym:%'
|
|
755
|
+
""",
|
|
756
|
+
(name,),
|
|
757
|
+
).fetchall()
|
|
758
|
+
for node_id, nm, kind, module_path, docstring in rows:
|
|
759
|
+
if node_id in already_ids:
|
|
760
|
+
continue
|
|
761
|
+
try:
|
|
762
|
+
fan_in = len(self.kg.callers(node_id, rel="CALLS"))
|
|
763
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
764
|
+
fan_in = 0
|
|
765
|
+
self.public_apis.append(
|
|
766
|
+
FunctionMetrics(
|
|
767
|
+
node_id=node_id,
|
|
768
|
+
name=nm or name,
|
|
769
|
+
module=module_path or "",
|
|
770
|
+
kind=kind,
|
|
771
|
+
fan_in=fan_in,
|
|
772
|
+
fan_out=0,
|
|
773
|
+
lines=0,
|
|
774
|
+
docstring=docstring,
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
already_ids.add(node_id)
|
|
778
|
+
|
|
779
|
+
# Step 2: supplement from high fan-in function_metrics
|
|
780
|
+
for func in sorted(
|
|
781
|
+
self.function_metrics.values(), key=lambda m: m.fan_in, reverse=True
|
|
782
|
+
):
|
|
783
|
+
if (
|
|
784
|
+
func.kind in ("function", "class")
|
|
785
|
+
and func.fan_in >= 1
|
|
786
|
+
and not func.name.startswith("_")
|
|
787
|
+
and func.node_id not in already_ids
|
|
788
|
+
):
|
|
789
|
+
self.public_apis.append(func)
|
|
790
|
+
already_ids.add(func.node_id)
|
|
791
|
+
|
|
792
|
+
self.public_apis.sort(key=lambda m: m.fan_in, reverse=True)
|
|
793
|
+
self._phase_result = f"{len(self.public_apis)} exported declarations"
|
|
794
|
+
|
|
795
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
796
|
+
logger.warning("Public API identification incomplete: %s", exc)
|
|
797
|
+
|
|
798
|
+
def _analyze_jsdoc_coverage(self) -> None:
|
|
799
|
+
"""Phase 10: Measure JSDoc coverage across all node kinds."""
|
|
800
|
+
try:
|
|
801
|
+
con = self.kg.store.con
|
|
802
|
+
rows = con.execute(
|
|
803
|
+
"""
|
|
804
|
+
SELECT
|
|
805
|
+
kind,
|
|
806
|
+
COUNT(*) AS total,
|
|
807
|
+
SUM(
|
|
808
|
+
CASE WHEN docstring IS NOT NULL AND TRIM(docstring) != ''
|
|
809
|
+
THEN 1 ELSE 0 END
|
|
810
|
+
) AS with_doc
|
|
811
|
+
FROM nodes
|
|
812
|
+
WHERE kind IN ('function', 'method', 'class', 'interface', 'module')
|
|
813
|
+
GROUP BY kind
|
|
814
|
+
ORDER BY kind
|
|
815
|
+
"""
|
|
816
|
+
).fetchall()
|
|
817
|
+
|
|
818
|
+
by_kind: dict[str, dict[str, int]] = {}
|
|
819
|
+
overall_total = 0
|
|
820
|
+
overall_with_doc = 0
|
|
821
|
+
for kind, total, with_doc in rows:
|
|
822
|
+
by_kind[kind] = {"total": total, "with_doc": with_doc}
|
|
823
|
+
overall_total += total
|
|
824
|
+
overall_with_doc += with_doc
|
|
825
|
+
|
|
826
|
+
overall_pct = (overall_with_doc / overall_total * 100) if overall_total else 0.0
|
|
827
|
+
self.jsdoc_coverage = {
|
|
828
|
+
"by_kind": by_kind,
|
|
829
|
+
"total": overall_total,
|
|
830
|
+
"with_doc": overall_with_doc,
|
|
831
|
+
"coverage_pct": round(overall_pct, 1),
|
|
832
|
+
}
|
|
833
|
+
self._phase_result = f"{overall_with_doc}/{overall_total} nodes ({overall_pct:.1f}%)"
|
|
834
|
+
|
|
835
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
836
|
+
logger.warning("JSDoc coverage analysis incomplete: %s", exc)
|
|
837
|
+
|
|
838
|
+
def _analyze_inheritance(self) -> None:
|
|
839
|
+
"""Phase 11: Analyze class and interface hierarchies.
|
|
840
|
+
|
|
841
|
+
Processes three edge types:
|
|
842
|
+
- INHERITS: class extends class
|
|
843
|
+
- IMPLEMENTS: class implements interface
|
|
844
|
+
- EXTENDS: interface extends interface
|
|
845
|
+
"""
|
|
846
|
+
try:
|
|
847
|
+
con = self.kg.store.con
|
|
848
|
+
|
|
849
|
+
inherits_rows = con.execute(
|
|
850
|
+
"SELECT src, dst FROM edges WHERE rel = 'INHERITS'"
|
|
851
|
+
).fetchall()
|
|
852
|
+
implements_rows = con.execute(
|
|
853
|
+
"SELECT src, dst FROM edges WHERE rel = 'IMPLEMENTS'"
|
|
854
|
+
).fetchall()
|
|
855
|
+
extends_rows = con.execute(
|
|
856
|
+
"SELECT src, dst FROM edges WHERE rel = 'EXTENDS'"
|
|
857
|
+
).fetchall()
|
|
858
|
+
|
|
859
|
+
total_edges = len(inherits_rows) + len(implements_rows) + len(extends_rows)
|
|
860
|
+
|
|
861
|
+
if total_edges == 0:
|
|
862
|
+
self.inheritance_analysis = {
|
|
863
|
+
"total_inherits_edges": 0,
|
|
864
|
+
"total_implements_edges": 0,
|
|
865
|
+
"total_extends_edges": 0,
|
|
866
|
+
"classes": [],
|
|
867
|
+
"max_depth": 0,
|
|
868
|
+
"multiple_inheritance": [],
|
|
869
|
+
"implements": [],
|
|
870
|
+
}
|
|
871
|
+
self._phase_result = "no hierarchy edges"
|
|
872
|
+
return
|
|
873
|
+
|
|
874
|
+
parents: dict[str, set[str]] = {}
|
|
875
|
+
children: dict[str, set[str]] = {}
|
|
876
|
+
all_classes: set[str] = set()
|
|
877
|
+
|
|
878
|
+
for src, dst in inherits_rows:
|
|
879
|
+
if dst.startswith("sym:"):
|
|
880
|
+
continue
|
|
881
|
+
parents.setdefault(src, set()).add(dst)
|
|
882
|
+
children.setdefault(dst, set()).add(src)
|
|
883
|
+
all_classes.add(src)
|
|
884
|
+
all_classes.add(dst)
|
|
885
|
+
|
|
886
|
+
def _compute_depth(cls_id: str, memo: dict[str, int]) -> int:
|
|
887
|
+
if cls_id in memo:
|
|
888
|
+
return max(memo[cls_id], 0)
|
|
889
|
+
ps = parents.get(cls_id, set())
|
|
890
|
+
if not ps:
|
|
891
|
+
memo[cls_id] = 0
|
|
892
|
+
return 0
|
|
893
|
+
memo[cls_id] = -1
|
|
894
|
+
depth = 1 + max(_compute_depth(p, memo) for p in ps)
|
|
895
|
+
memo[cls_id] = depth
|
|
896
|
+
return depth
|
|
897
|
+
|
|
898
|
+
depth_memo: dict[str, int] = {}
|
|
899
|
+
class_data: list[dict] = []
|
|
900
|
+
multiple_inheritance: list[dict] = []
|
|
901
|
+
|
|
902
|
+
for cls_id in sorted(all_classes):
|
|
903
|
+
node = self.kg.store.node(cls_id)
|
|
904
|
+
name = node.get("name", cls_id.split(":")[-1]) if node else cls_id.split(":")[-1]
|
|
905
|
+
module = node.get("module_path", "") if node else ""
|
|
906
|
+
cls_parents = parents.get(cls_id, set())
|
|
907
|
+
depth = _compute_depth(cls_id, depth_memo)
|
|
908
|
+
class_data.append(
|
|
909
|
+
{
|
|
910
|
+
"node_id": cls_id,
|
|
911
|
+
"name": name,
|
|
912
|
+
"module": module,
|
|
913
|
+
"depth": depth,
|
|
914
|
+
"parent_count": len(cls_parents),
|
|
915
|
+
"child_count": len(children.get(cls_id, set())),
|
|
916
|
+
}
|
|
917
|
+
)
|
|
918
|
+
if len(cls_parents) > 1:
|
|
919
|
+
parent_names = []
|
|
920
|
+
for p in sorted(cls_parents):
|
|
921
|
+
pn = self.kg.store.node(p)
|
|
922
|
+
parent_names.append(pn.get("name", p) if pn else p.split(":")[-1])
|
|
923
|
+
multiple_inheritance.append(
|
|
924
|
+
{"class": name, "module": module, "bases": sorted(parent_names)}
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
max_depth = max((e["depth"] for e in class_data), default=0)
|
|
928
|
+
|
|
929
|
+
# Implements table
|
|
930
|
+
implements_list: list[dict] = []
|
|
931
|
+
for src, dst in implements_rows:
|
|
932
|
+
src_node = self.kg.store.node(src)
|
|
933
|
+
dst_node = self.kg.store.node(dst) if not dst.startswith("sym:") else None
|
|
934
|
+
cls_name = src_node.get("name", src) if src_node else src.split(":")[-1]
|
|
935
|
+
iface_name = dst_node.get("name", dst) if dst_node else dst.split(":")[-1]
|
|
936
|
+
cls_mod = src_node.get("module_path", "") if src_node else ""
|
|
937
|
+
implements_list.append(
|
|
938
|
+
{"class": cls_name, "interface": iface_name, "module": cls_mod}
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
self.inheritance_analysis = {
|
|
942
|
+
"total_inherits_edges": len(inherits_rows),
|
|
943
|
+
"total_implements_edges": len(implements_rows),
|
|
944
|
+
"total_extends_edges": len(extends_rows),
|
|
945
|
+
"classes": sorted(class_data, key=lambda x: x["depth"], reverse=True),
|
|
946
|
+
"max_depth": max_depth,
|
|
947
|
+
"multiple_inheritance": multiple_inheritance,
|
|
948
|
+
"implements": implements_list,
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
self._phase_result = (
|
|
952
|
+
f"{len(all_classes)} classes max-depth={max_depth} "
|
|
953
|
+
f"{len(implements_rows)} implements {len(extends_rows)} iface-extends"
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
except (AttributeError, ValueError, RuntimeError) as exc:
|
|
957
|
+
logger.warning("Inheritance analysis incomplete: %s", exc)
|
|
958
|
+
self.console.print(f"[yellow]WARN[/yellow] Inheritance analysis incomplete: {exc}")
|
|
959
|
+
|
|
960
|
+
def _generate_insights(self) -> None:
|
|
961
|
+
"""Phase 12: Compile actionable insights from earlier phases."""
|
|
962
|
+
if len(self.function_metrics) > 0:
|
|
963
|
+
self.strengths.append(
|
|
964
|
+
f"Well-structured codebase — {len(self.function_metrics)} core functions identified"
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
if len(self.orphaned_functions) == 0:
|
|
968
|
+
self.strengths.append("No obvious dead code detected")
|
|
969
|
+
else:
|
|
970
|
+
names = ", ".join(f"`{f.name}`" for f in self.orphaned_functions[:5])
|
|
971
|
+
suffix = (
|
|
972
|
+
f" (and {len(self.orphaned_functions) - 5} more)"
|
|
973
|
+
if len(self.orphaned_functions) > 5
|
|
974
|
+
else ""
|
|
975
|
+
)
|
|
976
|
+
self.issues.append(
|
|
977
|
+
f"[WARN] {len(self.orphaned_functions)} orphaned declarations: {names}{suffix} — "
|
|
978
|
+
"zero callers detected; verify these aren't dead code"
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
if len(self.high_fanout_functions) == 0:
|
|
982
|
+
self.strengths.append("No god functions detected — healthy fan-out distribution")
|
|
983
|
+
else:
|
|
984
|
+
self.issues.append(
|
|
985
|
+
f"[WARN] {len(self.high_fanout_functions)} high fan-out functions — "
|
|
986
|
+
"potential orchestrators or god objects"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# JSDoc coverage signals
|
|
990
|
+
cov = self.jsdoc_coverage
|
|
991
|
+
if cov:
|
|
992
|
+
pct = cov["coverage_pct"]
|
|
993
|
+
if pct >= 80:
|
|
994
|
+
self.strengths.append(
|
|
995
|
+
f"Good JSDoc coverage: {pct}% of declarations documented — "
|
|
996
|
+
"semantic retrieval will be effective"
|
|
997
|
+
)
|
|
998
|
+
elif pct >= 50:
|
|
999
|
+
self.issues.append(
|
|
1000
|
+
f"[WARN] Moderate JSDoc coverage ({pct}%) — semantic retrieval is degraded "
|
|
1001
|
+
"for undocumented nodes; prioritize high-fan-in functions first"
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
self.issues.append(
|
|
1005
|
+
f"[LOW] Low JSDoc coverage ({pct}%) — semantic query quality will be poor; "
|
|
1006
|
+
"undocumented nodes embed only identifiers, not natural language"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
# Module size checks
|
|
1010
|
+
try:
|
|
1011
|
+
large_modules = self.kg.store.con.execute(
|
|
1012
|
+
"""
|
|
1013
|
+
SELECT module_path, COUNT(*) AS cnt
|
|
1014
|
+
FROM nodes
|
|
1015
|
+
WHERE kind IN ('function', 'method', 'class')
|
|
1016
|
+
AND module_path IS NOT NULL
|
|
1017
|
+
GROUP BY module_path
|
|
1018
|
+
HAVING cnt > 30
|
|
1019
|
+
ORDER BY cnt DESC
|
|
1020
|
+
LIMIT 5
|
|
1021
|
+
"""
|
|
1022
|
+
).fetchall()
|
|
1023
|
+
for mod_path, cnt in large_modules:
|
|
1024
|
+
mod_name = mod_path.split("/")[-1] if mod_path else "?"
|
|
1025
|
+
self.issues.append(
|
|
1026
|
+
f"[WARN] `{mod_name}` has {cnt} declarations — "
|
|
1027
|
+
"consider splitting into focused modules"
|
|
1028
|
+
)
|
|
1029
|
+
except (AttributeError, ValueError, RuntimeError):
|
|
1030
|
+
pass
|
|
1031
|
+
|
|
1032
|
+
# Inheritance insights
|
|
1033
|
+
inh = self.inheritance_analysis
|
|
1034
|
+
if inh:
|
|
1035
|
+
if inh.get("max_depth", 0) > 4:
|
|
1036
|
+
self.issues.append(
|
|
1037
|
+
f"[WARN] Deep inheritance hierarchy (max depth {inh['max_depth']}) — "
|
|
1038
|
+
"prefer composition over deep inheritance in TypeScript"
|
|
1039
|
+
)
|
|
1040
|
+
elif inh.get("max_depth", 0) > 0:
|
|
1041
|
+
self.strengths.append(
|
|
1042
|
+
f"Shallow inheritance hierarchy (max depth {inh['max_depth']}) — "
|
|
1043
|
+
"composition-friendly design"
|
|
1044
|
+
)
|
|
1045
|
+
if inh.get("implements"):
|
|
1046
|
+
self.strengths.append(
|
|
1047
|
+
f"{len(inh['implements'])} class/interface contracts via `implements` — "
|
|
1048
|
+
"type-safe polymorphism in use"
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# Centrality cross-reference
|
|
1052
|
+
if self.centrality_modules and self.module_metrics:
|
|
1053
|
+
sir_by_path = {m["module_path"]: m for m in self.centrality_modules[:10]}
|
|
1054
|
+
risky = [
|
|
1055
|
+
m
|
|
1056
|
+
for path, m in sir_by_path.items()
|
|
1057
|
+
if path in self.module_metrics
|
|
1058
|
+
and (
|
|
1059
|
+
len(self.module_metrics[path].incoming_deps)
|
|
1060
|
+
+ len(self.module_metrics[path].outgoing_deps)
|
|
1061
|
+
)
|
|
1062
|
+
> 4
|
|
1063
|
+
]
|
|
1064
|
+
if risky:
|
|
1065
|
+
names = ", ".join(f"`{m['module_path'].split('/')[-1]}`" for m in risky[:3])
|
|
1066
|
+
self.issues.append(
|
|
1067
|
+
f"[WARN] High-SIR modules with tight coupling: {names} — "
|
|
1068
|
+
"structurally central AND heavily connected; changes here ripple broadly"
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
self._phase_result = f"{len(self.issues)} issues {len(self.strengths)} strengths"
|
|
1072
|
+
|
|
1073
|
+
def _analyze_snapshots(self) -> None:
|
|
1074
|
+
"""Phase 13: Load snapshot history for temporal comparison."""
|
|
1075
|
+
if self.snapshot_mgr is None:
|
|
1076
|
+
self._phase_result = "skipped (no snapshot manager)"
|
|
1077
|
+
return
|
|
1078
|
+
try:
|
|
1079
|
+
self.snapshot_history = self.snapshot_mgr.list_snapshots(limit=10)
|
|
1080
|
+
self._phase_result = f"{len(self.snapshot_history)} snapshot(s)"
|
|
1081
|
+
except (AttributeError, ValueError, RuntimeError, OSError) as exc:
|
|
1082
|
+
logger.warning("Snapshot history unavailable: %s", exc)
|
|
1083
|
+
|
|
1084
|
+
def _analyze_centrality(self) -> None:
|
|
1085
|
+
"""Phase 14: Compute Structural Importance Ranking (SIR) via PageRank."""
|
|
1086
|
+
try:
|
|
1087
|
+
from tscode_kg.centrality import ( # noqa: PLC0415
|
|
1088
|
+
StructuralImportanceRanker,
|
|
1089
|
+
aggregate_module_scores,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
ranker = StructuralImportanceRanker(self.kg.db_path)
|
|
1093
|
+
all_records = ranker.compute()
|
|
1094
|
+
self.centrality_records = all_records[:25]
|
|
1095
|
+
self.centrality_modules = aggregate_module_scores(all_records)
|
|
1096
|
+
self._phase_result = f"{len(all_records)} nodes {len(self.centrality_modules)} modules"
|
|
1097
|
+
except (AttributeError, ValueError, RuntimeError, ImportError) as exc:
|
|
1098
|
+
logger.warning("Centrality analysis incomplete: %s", exc)
|
|
1099
|
+
self.console.print(f"[yellow]WARN[/yellow] Centrality incomplete: {exc}")
|
|
1100
|
+
|
|
1101
|
+
# ------------------------------------------------------------------
|
|
1102
|
+
# Report generation helpers
|
|
1103
|
+
# ------------------------------------------------------------------
|
|
1104
|
+
|
|
1105
|
+
def _compute_quality_grade(self) -> tuple[float, str, str]:
|
|
1106
|
+
"""Compute an overall quality score, letter grade, and label.
|
|
1107
|
+
|
|
1108
|
+
Scoring (100 points):
|
|
1109
|
+
- JSDoc coverage (0–40 pts): ≥80% → 40, ≥50% → 20, else 0
|
|
1110
|
+
- Orphaned declarations (0–25 pts): 0 → 25, 1–2 → 15, 3–5 → 5, else 0
|
|
1111
|
+
- High fan-out functions (0–20 pts): 0 → 20, 1–2 → 12, else 4
|
|
1112
|
+
- Type safety signals (0–15 pts): implements edges present → 15, else 0
|
|
1113
|
+
"""
|
|
1114
|
+
score = 0.0
|
|
1115
|
+
|
|
1116
|
+
cov = self.jsdoc_coverage
|
|
1117
|
+
if cov:
|
|
1118
|
+
pct = cov.get("coverage_pct", 0)
|
|
1119
|
+
if pct >= 80:
|
|
1120
|
+
score += 40
|
|
1121
|
+
elif pct >= 50:
|
|
1122
|
+
score += 20
|
|
1123
|
+
|
|
1124
|
+
n_orphaned = len(self.orphaned_functions)
|
|
1125
|
+
if n_orphaned == 0:
|
|
1126
|
+
score += 25
|
|
1127
|
+
elif n_orphaned <= 2:
|
|
1128
|
+
score += 15
|
|
1129
|
+
elif n_orphaned <= 5:
|
|
1130
|
+
score += 5
|
|
1131
|
+
|
|
1132
|
+
n_fanout = len(self.high_fanout_functions)
|
|
1133
|
+
if n_fanout == 0:
|
|
1134
|
+
score += 20
|
|
1135
|
+
elif n_fanout <= 2:
|
|
1136
|
+
score += 12
|
|
1137
|
+
else:
|
|
1138
|
+
score += 4
|
|
1139
|
+
|
|
1140
|
+
inh = self.inheritance_analysis
|
|
1141
|
+
if inh and inh.get("total_implements_edges", 0) > 0:
|
|
1142
|
+
score += 15
|
|
1143
|
+
|
|
1144
|
+
if score >= 90:
|
|
1145
|
+
grade, label = "A", "Excellent"
|
|
1146
|
+
elif score >= 75:
|
|
1147
|
+
grade, label = "B", "Good"
|
|
1148
|
+
elif score >= 60:
|
|
1149
|
+
grade, label = "C", "Fair"
|
|
1150
|
+
elif score >= 45:
|
|
1151
|
+
grade, label = "D", "Needs Work"
|
|
1152
|
+
else:
|
|
1153
|
+
grade, label = "F", "Critical"
|
|
1154
|
+
|
|
1155
|
+
return score, grade, label
|
|
1156
|
+
|
|
1157
|
+
def _build_recommendations(self) -> str:
|
|
1158
|
+
"""Build prioritized recommendations from analysis results."""
|
|
1159
|
+
immediate: list[str] = []
|
|
1160
|
+
medium: list[str] = []
|
|
1161
|
+
long_term: list[str] = []
|
|
1162
|
+
|
|
1163
|
+
cov = self.jsdoc_coverage
|
|
1164
|
+
if cov and cov.get("coverage_pct", 100) < 80:
|
|
1165
|
+
undocumented = cov.get("total", 0) - cov.get("with_doc", 0)
|
|
1166
|
+
immediate.append(
|
|
1167
|
+
f"**Improve JSDoc coverage** — {undocumented} declarations lack JSDoc; "
|
|
1168
|
+
"prioritize high fan-in functions and exported API surface first"
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
if self.orphaned_functions:
|
|
1172
|
+
names = ", ".join(f"`{f.name}`" for f in self.orphaned_functions[:5])
|
|
1173
|
+
suffix = (
|
|
1174
|
+
f" (and {len(self.orphaned_functions) - 5} more)"
|
|
1175
|
+
if len(self.orphaned_functions) > 5
|
|
1176
|
+
else ""
|
|
1177
|
+
)
|
|
1178
|
+
immediate.append(
|
|
1179
|
+
f"**Audit orphaned declarations** — {names}{suffix} have zero callers; "
|
|
1180
|
+
"remove dead code or add tests/usage"
|
|
1181
|
+
)
|
|
1182
|
+
|
|
1183
|
+
if self.high_fanout_functions:
|
|
1184
|
+
top = self.high_fanout_functions[0]
|
|
1185
|
+
immediate.append(
|
|
1186
|
+
f"**Refactor high fan-out orchestrators** — `{top.name}` calls {top.fan_out} others; "
|
|
1187
|
+
"split into smaller, focused coordinators"
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
top_fanin = sorted(self.function_metrics.values(), key=lambda m: m.fan_in, reverse=True)[:3]
|
|
1191
|
+
if top_fanin and top_fanin[0].fan_in > 1:
|
|
1192
|
+
names = ", ".join(f"`{m.name}`" for m in top_fanin)
|
|
1193
|
+
medium.append(
|
|
1194
|
+
f"**Harden high fan-in functions** — {names} are widely depended upon; "
|
|
1195
|
+
"review contracts, add type guards, and document edge cases"
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
if self.module_metrics:
|
|
1199
|
+
tightly_coupled = [
|
|
1200
|
+
m
|
|
1201
|
+
for m in self.module_metrics.values()
|
|
1202
|
+
if len(m.incoming_deps) + len(m.outgoing_deps) > 5
|
|
1203
|
+
]
|
|
1204
|
+
if tightly_coupled:
|
|
1205
|
+
medium.append(
|
|
1206
|
+
"**Reduce module coupling** — introduce interface boundaries or barrel "
|
|
1207
|
+
"exports to decouple tightly coupled modules"
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
if self.critical_paths:
|
|
1211
|
+
medium.append(
|
|
1212
|
+
"**Add integration tests for key call chains** — the identified chains are "
|
|
1213
|
+
"well-traveled paths that benefit most from regression coverage"
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
inh = self.inheritance_analysis
|
|
1217
|
+
if inh and inh.get("max_depth", 0) > 3:
|
|
1218
|
+
long_term.append(
|
|
1219
|
+
"**Flatten deep inheritance** — prefer composition (mixins, generics) "
|
|
1220
|
+
"over deep class hierarchies in TypeScript"
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
if self.public_apis:
|
|
1224
|
+
long_term.append(
|
|
1225
|
+
"**Stabilize the exported API** — document breaking-change policies "
|
|
1226
|
+
f"for exported symbols: {', '.join(f'`{a.name}`' for a in self.public_apis[:3])}"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
long_term.append(
|
|
1230
|
+
"**Enforce module boundaries in CI** — add import-lint rules to prevent "
|
|
1231
|
+
"accidental cross-layer coupling as the codebase grows"
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
if not immediate and not medium:
|
|
1235
|
+
immediate.append(
|
|
1236
|
+
"**Maintain current quality** — no critical issues detected; keep JSDoc "
|
|
1237
|
+
"coverage and module cohesion healthy"
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
lines = []
|
|
1241
|
+
if immediate:
|
|
1242
|
+
lines.append("### Immediate Actions")
|
|
1243
|
+
for i, rec in enumerate(immediate, 1):
|
|
1244
|
+
lines.append(f"{i}. {rec}")
|
|
1245
|
+
lines.append("")
|
|
1246
|
+
if medium:
|
|
1247
|
+
lines.append("### Medium-term Refactoring")
|
|
1248
|
+
for i, rec in enumerate(medium, 1):
|
|
1249
|
+
lines.append(f"{i}. {rec}")
|
|
1250
|
+
lines.append("")
|
|
1251
|
+
if long_term:
|
|
1252
|
+
lines.append("### Long-term Architecture")
|
|
1253
|
+
for i, rec in enumerate(long_term, 1):
|
|
1254
|
+
lines.append(f"{i}. {rec}")
|
|
1255
|
+
return "\n".join(lines)
|
|
1256
|
+
|
|
1257
|
+
def _get_report_metadata(self, elapsed_seconds: float = 0.0) -> str:
|
|
1258
|
+
"""Build a Markdown metadata block for the top of the report."""
|
|
1259
|
+
now = datetime.datetime.now(datetime.UTC)
|
|
1260
|
+
generated = now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
1261
|
+
|
|
1262
|
+
version = "unknown"
|
|
1263
|
+
try:
|
|
1264
|
+
from importlib.metadata import version as _pkg_version # noqa: PLC0415
|
|
1265
|
+
|
|
1266
|
+
version = f"tscode-kg {_pkg_version('tscode-kg')}"
|
|
1267
|
+
except Exception: # noqa: BLE001
|
|
1268
|
+
version = "tscode-kg (dev)"
|
|
1269
|
+
|
|
1270
|
+
commit = os.environ.get("GITHUB_SHA", "")
|
|
1271
|
+
if commit:
|
|
1272
|
+
commit = commit[:7]
|
|
1273
|
+
else:
|
|
1274
|
+
try:
|
|
1275
|
+
result = subprocess.run(
|
|
1276
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
1277
|
+
capture_output=True,
|
|
1278
|
+
text=True,
|
|
1279
|
+
timeout=5,
|
|
1280
|
+
)
|
|
1281
|
+
if result.returncode == 0:
|
|
1282
|
+
commit = result.stdout.strip()
|
|
1283
|
+
except (OSError, FileNotFoundError):
|
|
1284
|
+
pass
|
|
1285
|
+
commit = commit or "unknown"
|
|
1286
|
+
|
|
1287
|
+
branch = ""
|
|
1288
|
+
github_ref = os.environ.get("GITHUB_REF", "")
|
|
1289
|
+
if github_ref.startswith("refs/heads/"):
|
|
1290
|
+
branch = github_ref[len("refs/heads/") :]
|
|
1291
|
+
if not branch:
|
|
1292
|
+
try:
|
|
1293
|
+
result = subprocess.run(
|
|
1294
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
1295
|
+
capture_output=True,
|
|
1296
|
+
text=True,
|
|
1297
|
+
timeout=5,
|
|
1298
|
+
)
|
|
1299
|
+
if result.returncode == 0:
|
|
1300
|
+
branch = result.stdout.strip()
|
|
1301
|
+
except (OSError, FileNotFoundError):
|
|
1302
|
+
pass
|
|
1303
|
+
branch = branch or "unknown"
|
|
1304
|
+
|
|
1305
|
+
try:
|
|
1306
|
+
_sys = platform.system()
|
|
1307
|
+
_mac = platform.mac_ver()[0]
|
|
1308
|
+
_os = f"macOS {_mac}" if _mac else f"{_sys} {platform.release()}"
|
|
1309
|
+
plat = f"{_os} | {platform.machine()} | Python {platform.python_version()}"
|
|
1310
|
+
except Exception: # noqa: BLE001
|
|
1311
|
+
plat = "unknown"
|
|
1312
|
+
|
|
1313
|
+
stats = self.stats or {}
|
|
1314
|
+
total_nodes = stats.get("total_nodes", "?")
|
|
1315
|
+
total_edges = stats.get("total_edges", "?")
|
|
1316
|
+
meaningful = stats.get("meaningful_nodes")
|
|
1317
|
+
graph_line = f"{total_nodes} nodes · {total_edges} edges"
|
|
1318
|
+
if meaningful is not None:
|
|
1319
|
+
graph_line += f" ({meaningful} meaningful)"
|
|
1320
|
+
|
|
1321
|
+
dirs_line = ", ".join(sorted(self.include_dirs)) if self.include_dirs else "all"
|
|
1322
|
+
exclude_line = ", ".join(sorted(self.exclude_dirs)) if self.exclude_dirs else "none"
|
|
1323
|
+
|
|
1324
|
+
elapsed_str = ""
|
|
1325
|
+
if elapsed_seconds > 0:
|
|
1326
|
+
mins, secs = divmod(int(elapsed_seconds), 60)
|
|
1327
|
+
elapsed_str = f"{mins}m {secs}s" if mins else f"{secs}s"
|
|
1328
|
+
|
|
1329
|
+
return (
|
|
1330
|
+
"> **Analysis Report Metadata** \n"
|
|
1331
|
+
f"> - **Generated:** {generated} \n"
|
|
1332
|
+
f"> - **Version:** {version} \n"
|
|
1333
|
+
f"> - **Commit:** {commit} ({branch}) \n"
|
|
1334
|
+
f"> - **Platform:** {plat} \n"
|
|
1335
|
+
f"> - **Graph:** {graph_line} \n"
|
|
1336
|
+
f"> - **Included directories:** {dirs_line} \n"
|
|
1337
|
+
f"> - **Excluded directories:** {exclude_line} \n"
|
|
1338
|
+
+ (f"> - **Elapsed time:** {elapsed_str} \n" if elapsed_str else "")
|
|
1339
|
+
+ "\n"
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
def _write_report(self, report_path: str, elapsed_seconds: float = 0.0) -> None:
|
|
1343
|
+
"""Write full Markdown analysis report to *report_path*."""
|
|
1344
|
+
report_date = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
1345
|
+
stats = self.stats
|
|
1346
|
+
repo_name = Path(self.kg.repo_root).name
|
|
1347
|
+
quality_score, quality_grade, quality_label = self._compute_quality_grade()
|
|
1348
|
+
grade_tag = f"[{quality_grade}]"
|
|
1349
|
+
|
|
1350
|
+
metadata = self._get_report_metadata(elapsed_seconds=elapsed_seconds)
|
|
1351
|
+
|
|
1352
|
+
report = (
|
|
1353
|
+
metadata
|
|
1354
|
+
+ f"""# {repo_name} — TypeScript/JavaScript Analysis
|
|
1355
|
+
|
|
1356
|
+
**Generated:** {report_date}
|
|
1357
|
+
|
|
1358
|
+
---
|
|
1359
|
+
|
|
1360
|
+
## Executive Summary
|
|
1361
|
+
|
|
1362
|
+
Comprehensive architectural analysis of **{repo_name}** using TypeScriptKG's knowledge graph.
|
|
1363
|
+
Covers complexity hotspots, module coupling, call chains, JSDoc coverage, and type hierarchy.
|
|
1364
|
+
|
|
1365
|
+
| Overall Quality | Grade | Score |
|
|
1366
|
+
|----------------|-------|-------|
|
|
1367
|
+
| {grade_tag} **{quality_label}** | **{quality_grade}** | {quality_score:.0f} / 100 |
|
|
1368
|
+
|
|
1369
|
+
---
|
|
1370
|
+
|
|
1371
|
+
## Baseline Metrics
|
|
1372
|
+
|
|
1373
|
+
| Metric | Value |
|
|
1374
|
+
|--------|-------|
|
|
1375
|
+
| **Total Nodes** | {stats.get("total_nodes", "N/A")} |
|
|
1376
|
+
| **Total Edges** | {stats.get("total_edges", "N/A")} |
|
|
1377
|
+
| **Modules** | {stats.get("node_counts", {}).get("module", "N/A")} |
|
|
1378
|
+
| **Functions** | {stats.get("node_counts", {}).get("function", "N/A")} |
|
|
1379
|
+
| **Classes** | {stats.get("node_counts", {}).get("class", "N/A")} |
|
|
1380
|
+
| **Methods** | {stats.get("node_counts", {}).get("method", "N/A")} |
|
|
1381
|
+
| **Interfaces** | {stats.get("node_counts", {}).get("interface", "N/A")} |
|
|
1382
|
+
| **Type Aliases** | {stats.get("node_counts", {}).get("type_alias", "N/A")} |
|
|
1383
|
+
| **Enums** | {stats.get("node_counts", {}).get("enum", "N/A")} |
|
|
1384
|
+
|
|
1385
|
+
### Edge Distribution
|
|
1386
|
+
|
|
1387
|
+
| Relationship | Count |
|
|
1388
|
+
|---|---|
|
|
1389
|
+
| CALLS | {stats.get("edge_counts", {}).get("CALLS", 0)} |
|
|
1390
|
+
| CONTAINS | {stats.get("edge_counts", {}).get("CONTAINS", 0)} |
|
|
1391
|
+
| IMPORTS | {stats.get("edge_counts", {}).get("IMPORTS", 0)} |
|
|
1392
|
+
| INHERITS | {stats.get("edge_counts", {}).get("INHERITS", 0)} |
|
|
1393
|
+
| IMPLEMENTS | {stats.get("edge_counts", {}).get("IMPLEMENTS", 0)} |
|
|
1394
|
+
| EXTENDS | {stats.get("edge_counts", {}).get("EXTENDS", 0)} |
|
|
1395
|
+
|
|
1396
|
+
---
|
|
1397
|
+
|
|
1398
|
+
## Fan-In Ranking
|
|
1399
|
+
|
|
1400
|
+
Most-called functions and methods — potential bottlenecks or core APIs.
|
|
1401
|
+
|
|
1402
|
+
| # | Kind | Name | Module | Callers |
|
|
1403
|
+
|---|---|---|---|---|
|
|
1404
|
+
"""
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
for i, metrics in enumerate(
|
|
1408
|
+
sorted(self.function_metrics.values(), key=lambda m: m.fan_in, reverse=True)[:15], 1
|
|
1409
|
+
):
|
|
1410
|
+
report += f"| {i} | {metrics.kind} | `{metrics.name}` | {metrics.module} | **{metrics.fan_in}** |\n"
|
|
1411
|
+
|
|
1412
|
+
report += """
|
|
1413
|
+
**Insight:** High fan-in functions are core APIs or bottlenecks — review for type safety,
|
|
1414
|
+
clear JSDoc contracts, and stable interfaces.
|
|
1415
|
+
|
|
1416
|
+
---
|
|
1417
|
+
|
|
1418
|
+
## High Fan-Out Functions (Orchestrators)
|
|
1419
|
+
|
|
1420
|
+
Functions that call many others may indicate complex orchestration or poor separation of concerns.
|
|
1421
|
+
|
|
1422
|
+
"""
|
|
1423
|
+
if self.high_fanout_functions:
|
|
1424
|
+
report += "| # | Name | Module | Calls | Type |\n|---|---|---|---|---|\n"
|
|
1425
|
+
for i, func in enumerate(
|
|
1426
|
+
sorted(self.high_fanout_functions, key=lambda f: f.fan_out, reverse=True)[:10], 1
|
|
1427
|
+
):
|
|
1428
|
+
func_type = "Orchestrator" if func.fan_out > 40 else "Coordinator"
|
|
1429
|
+
report += (
|
|
1430
|
+
f"| {i} | `{func.name}` | {func.module} | **{func.fan_out}** | {func_type} |\n"
|
|
1431
|
+
)
|
|
1432
|
+
report += "\n"
|
|
1433
|
+
else:
|
|
1434
|
+
report += "No extreme high fan-out functions detected. Well-balanced architecture.\n\n"
|
|
1435
|
+
|
|
1436
|
+
report += """---
|
|
1437
|
+
|
|
1438
|
+
## Module Architecture
|
|
1439
|
+
|
|
1440
|
+
Cohesion = incoming-callers / (incoming + outgoing + 1). Higher = more internally focused.
|
|
1441
|
+
|
|
1442
|
+
"""
|
|
1443
|
+
if self.module_metrics:
|
|
1444
|
+
report += "| Module | Functions | Classes | Incoming | Outgoing | Cohesion |\n"
|
|
1445
|
+
report += "|---|---|---|---|---|---|\n"
|
|
1446
|
+
for module, m in sorted(
|
|
1447
|
+
self.module_metrics.items(),
|
|
1448
|
+
key=lambda x: x[1].functions + x[1].classes + x[1].methods,
|
|
1449
|
+
reverse=True,
|
|
1450
|
+
)[:12]:
|
|
1451
|
+
report += (
|
|
1452
|
+
f"| `{module}` | {m.functions} | {m.classes} | "
|
|
1453
|
+
f"{len(m.incoming_deps)} | {len(m.outgoing_deps)} | "
|
|
1454
|
+
f"{m.cohesion_score:.2f} |\n"
|
|
1455
|
+
)
|
|
1456
|
+
report += "\n"
|
|
1457
|
+
|
|
1458
|
+
report += "---\n\n## Key Call Chains\n\n"
|
|
1459
|
+
if self.critical_paths:
|
|
1460
|
+
for i, chain in enumerate(self.critical_paths[:5], 1):
|
|
1461
|
+
chain_str = " → ".join(chain.chain)
|
|
1462
|
+
report += f"**Chain {i}** (depth: {chain.depth})\n\n```\n{chain_str}\n```\n\n"
|
|
1463
|
+
else:
|
|
1464
|
+
report += "No deep call chains detected.\n\n"
|
|
1465
|
+
|
|
1466
|
+
report += "---\n\n## Public API Surface\n\nExported declarations (top-level `export` keyword).\n\n"
|
|
1467
|
+
if self.public_apis:
|
|
1468
|
+
report += "| Name | Kind | Module | Callers |\n|---|---|---|---|\n"
|
|
1469
|
+
for api in sorted(self.public_apis, key=lambda a: a.fan_in, reverse=True)[:12]:
|
|
1470
|
+
report += f"| `{api.name}` | {api.kind} | {api.module} | {api.fan_in} |\n"
|
|
1471
|
+
report += "\n"
|
|
1472
|
+
else:
|
|
1473
|
+
report += "No exported declarations identified.\n\n"
|
|
1474
|
+
|
|
1475
|
+
# JSDoc Coverage
|
|
1476
|
+
cov = self.jsdoc_coverage
|
|
1477
|
+
if cov:
|
|
1478
|
+
overall_pct = cov["coverage_pct"]
|
|
1479
|
+
pct_bar = "[OK]" if overall_pct >= 80 else "[WARN]" if overall_pct >= 50 else "[LOW]"
|
|
1480
|
+
report += "---\n\n## JSDoc Coverage\n\n"
|
|
1481
|
+
report += (
|
|
1482
|
+
"JSDoc coverage determines semantic retrieval quality. Nodes without JSDoc "
|
|
1483
|
+
"embed only structured identifiers — keyword search is as effective as vector "
|
|
1484
|
+
"embeddings. The semantic model earns its value only when JSDoc is present.\n\n"
|
|
1485
|
+
)
|
|
1486
|
+
report += "| Kind | Documented | Total | Coverage |\n|---|---|---|---|\n"
|
|
1487
|
+
for kind in ("function", "method", "class", "interface", "module"):
|
|
1488
|
+
if kind in cov["by_kind"]:
|
|
1489
|
+
k = cov["by_kind"][kind]
|
|
1490
|
+
kind_pct = (k["with_doc"] / k["total"] * 100) if k["total"] else 0.0
|
|
1491
|
+
kind_bar = "[OK]" if kind_pct >= 80 else "[WARN]" if kind_pct >= 50 else "[LOW]"
|
|
1492
|
+
report += (
|
|
1493
|
+
f"| `{kind}` | {k['with_doc']} | {k['total']} | "
|
|
1494
|
+
f"{kind_bar} {kind_pct:.1f}% |\n"
|
|
1495
|
+
)
|
|
1496
|
+
report += (
|
|
1497
|
+
f"| **total** | **{cov['with_doc']}** | **{cov['total']}** | "
|
|
1498
|
+
f"**{pct_bar} {overall_pct:.1f}%** |\n\n"
|
|
1499
|
+
)
|
|
1500
|
+
if overall_pct < 80:
|
|
1501
|
+
undocumented = cov["total"] - cov["with_doc"]
|
|
1502
|
+
report += (
|
|
1503
|
+
f"> **Recommendation:** {undocumented} declarations lack JSDoc. "
|
|
1504
|
+
"Prioritize exported functions and high fan-in methods first.\n\n"
|
|
1505
|
+
)
|
|
1506
|
+
else:
|
|
1507
|
+
report += "---\n\n## JSDoc Coverage\n\nCoverage data not available.\n\n"
|
|
1508
|
+
|
|
1509
|
+
# Structural Importance Ranking
|
|
1510
|
+
report += "---\n\n## Structural Importance Ranking (SIR)\n\n"
|
|
1511
|
+
if self.centrality_modules:
|
|
1512
|
+
report += (
|
|
1513
|
+
"Weighted PageRank aggregated by module. "
|
|
1514
|
+
"Cross-module edges boosted 1.5×; private symbols penalized 0.85×.\n\n"
|
|
1515
|
+
)
|
|
1516
|
+
report += "| Rank | Score | Members | Module |\n|---|---|---|---|\n"
|
|
1517
|
+
for mod in self.centrality_modules[:12]:
|
|
1518
|
+
report += (
|
|
1519
|
+
f"| {mod['rank']} | {mod['score']:.6f} | {mod['member_count']} "
|
|
1520
|
+
f"| `{mod['module_path']}` |\n"
|
|
1521
|
+
)
|
|
1522
|
+
report += "\n"
|
|
1523
|
+
else:
|
|
1524
|
+
report += "Centrality data not available.\n\n"
|
|
1525
|
+
|
|
1526
|
+
# CodeRank top nodes
|
|
1527
|
+
report += "---\n\n## CodeRank — Global Structural Importance\n\n"
|
|
1528
|
+
if self.coderank_top_nodes:
|
|
1529
|
+
report += (
|
|
1530
|
+
"Weighted PageRank over CALLS + IMPORTS + INHERITS edges (test paths excluded). "
|
|
1531
|
+
"Scores normalized to sum to 1.0.\n\n"
|
|
1532
|
+
)
|
|
1533
|
+
report += "| Rank | Score | Kind | Name | Module |\n|---|---|---|---|---|\n"
|
|
1534
|
+
for i, n in enumerate(self.coderank_top_nodes[:20], 1):
|
|
1535
|
+
report += (
|
|
1536
|
+
f"| {i} | {n['score']:.6f} | {n['kind']} "
|
|
1537
|
+
f"| `{n['qualname'] or n['name']}` | {n['module_path']} |\n"
|
|
1538
|
+
)
|
|
1539
|
+
report += "\n"
|
|
1540
|
+
else:
|
|
1541
|
+
report += "CodeRank data not available.\n\n"
|
|
1542
|
+
|
|
1543
|
+
# Issues / Strengths
|
|
1544
|
+
issues_text = (
|
|
1545
|
+
"\n".join(f"- {issue}" for issue in self.issues)
|
|
1546
|
+
if self.issues
|
|
1547
|
+
else "- No major issues detected"
|
|
1548
|
+
)
|
|
1549
|
+
strengths_text = (
|
|
1550
|
+
"\n".join(f"- {s}" for s in self.strengths)
|
|
1551
|
+
if self.strengths
|
|
1552
|
+
else "- Continue monitoring code quality"
|
|
1553
|
+
)
|
|
1554
|
+
|
|
1555
|
+
report += f"""---
|
|
1556
|
+
|
|
1557
|
+
## Code Quality Issues
|
|
1558
|
+
|
|
1559
|
+
{issues_text}
|
|
1560
|
+
|
|
1561
|
+
---
|
|
1562
|
+
|
|
1563
|
+
## Architectural Strengths
|
|
1564
|
+
|
|
1565
|
+
{strengths_text}
|
|
1566
|
+
|
|
1567
|
+
---
|
|
1568
|
+
|
|
1569
|
+
## Recommendations
|
|
1570
|
+
|
|
1571
|
+
{self._build_recommendations()}
|
|
1572
|
+
|
|
1573
|
+
---
|
|
1574
|
+
|
|
1575
|
+
## Class and Interface Hierarchy
|
|
1576
|
+
|
|
1577
|
+
"""
|
|
1578
|
+
inh = self.inheritance_analysis
|
|
1579
|
+
if inh and (inh.get("total_inherits_edges", 0) + inh.get("total_implements_edges", 0)) > 0:
|
|
1580
|
+
report += (
|
|
1581
|
+
f"**{inh.get('total_inherits_edges', 0)}** INHERITS · "
|
|
1582
|
+
f"**{inh.get('total_implements_edges', 0)}** IMPLEMENTS · "
|
|
1583
|
+
f"**{inh.get('total_extends_edges', 0)}** interface EXTENDS\n\n"
|
|
1584
|
+
)
|
|
1585
|
+
if inh.get("classes"):
|
|
1586
|
+
report += "### Class Hierarchy (INHERITS)\n\n"
|
|
1587
|
+
report += "| Class | Module | Depth | Parents | Children |\n|---|---|---|---|---|\n"
|
|
1588
|
+
for cls in inh["classes"][:20]:
|
|
1589
|
+
report += (
|
|
1590
|
+
f"| `{cls['name']}` | {cls['module']} "
|
|
1591
|
+
f"| {cls['depth']} | {cls['parent_count']} | {cls['child_count']} |\n"
|
|
1592
|
+
)
|
|
1593
|
+
report += "\n"
|
|
1594
|
+
if inh.get("implements"):
|
|
1595
|
+
report += "### Class Implements Interface\n\n"
|
|
1596
|
+
report += "| Class | Interface | Module |\n|---|---|---|\n"
|
|
1597
|
+
for impl in inh["implements"][:15]:
|
|
1598
|
+
report += f"| `{impl['class']}` | `{impl['interface']}` | {impl['module']} |\n"
|
|
1599
|
+
report += "\n"
|
|
1600
|
+
if inh.get("multiple_inheritance"):
|
|
1601
|
+
report += (
|
|
1602
|
+
f"### Multiple Inheritance ({len(inh['multiple_inheritance'])} classes)\n\n"
|
|
1603
|
+
)
|
|
1604
|
+
for mi in inh["multiple_inheritance"]:
|
|
1605
|
+
bases = ", ".join(f"`{b}`" for b in mi["bases"])
|
|
1606
|
+
report += f"- `{mi['class']}` ({mi['module']}) extends {bases}\n"
|
|
1607
|
+
report += "\n"
|
|
1608
|
+
else:
|
|
1609
|
+
report += "No class hierarchy detected.\n"
|
|
1610
|
+
|
|
1611
|
+
# Snapshot history
|
|
1612
|
+
report += "\n---\n\n## Snapshot History\n\n"
|
|
1613
|
+
if self.snapshot_history:
|
|
1614
|
+
report += "| # | Timestamp | Branch | Nodes | Edges |\n|---|---|---|---|---|\n"
|
|
1615
|
+
for i, snap in enumerate(self.snapshot_history, 1):
|
|
1616
|
+
ts = snap.get("timestamp", "")[:19].replace("T", " ")
|
|
1617
|
+
branch = snap.get("branch", "?")
|
|
1618
|
+
m = snap.get("metrics", {})
|
|
1619
|
+
report += f"| {i} | {ts} | {branch} | {m.get('total_nodes', '?')} | {m.get('total_edges', '?')} |\n"
|
|
1620
|
+
else:
|
|
1621
|
+
report += "No snapshots. Run `tscodekg snapshot save <version>` to capture one.\n"
|
|
1622
|
+
|
|
1623
|
+
# Orphaned code appendix
|
|
1624
|
+
report += (
|
|
1625
|
+
"\n---\n\n## Appendix: Orphaned Declarations\n\nDeclarations with zero callers:\n\n"
|
|
1626
|
+
)
|
|
1627
|
+
if self.orphaned_functions:
|
|
1628
|
+
report += "| Name | Kind | Module | Lines |\n|---|---|---|---|\n"
|
|
1629
|
+
for func in sorted(self.orphaned_functions, key=lambda f: f.lines, reverse=True)[:15]:
|
|
1630
|
+
report += f"| `{func.name}` | {func.kind} | {func.module} | {func.lines} |\n"
|
|
1631
|
+
else:
|
|
1632
|
+
report += "No orphaned declarations detected.\n"
|
|
1633
|
+
|
|
1634
|
+
elapsed_str = (
|
|
1635
|
+
f"{elapsed_seconds:.1f}s" if elapsed_seconds < 60 else f"{elapsed_seconds / 60:.1f}m"
|
|
1636
|
+
)
|
|
1637
|
+
report += (
|
|
1638
|
+
f"\n\n---\n\n*Report generated by TypeScriptKG analysis — completed in {elapsed_str}*\n"
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
Path(report_path).write_text(report, encoding="utf-8")
|
|
1642
|
+
self.console.print(f"[green]✓[/green] Report written to {report_path}")
|
|
1643
|
+
|
|
1644
|
+
def _compile_results(self) -> dict:
|
|
1645
|
+
"""Compile all phase results into a serialisable dictionary."""
|
|
1646
|
+
sorted_fn = sorted(self.function_metrics.items(), key=lambda kv: kv[1].fan_in, reverse=True)
|
|
1647
|
+
active_modules = {
|
|
1648
|
+
k: v
|
|
1649
|
+
for k, v in self.module_metrics.items()
|
|
1650
|
+
if v.total_fan_in > 0 or len(v.outgoing_deps) > 0
|
|
1651
|
+
}
|
|
1652
|
+
return {
|
|
1653
|
+
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
|
|
1654
|
+
"statistics": self.stats,
|
|
1655
|
+
"jsdoc_coverage": self.jsdoc_coverage,
|
|
1656
|
+
"function_metrics": {k: asdict(v) for k, v in sorted_fn},
|
|
1657
|
+
"module_metrics": {k: asdict(v) for k, v in active_modules.items()},
|
|
1658
|
+
"orphaned_functions": [asdict(f) for f in self.orphaned_functions],
|
|
1659
|
+
"high_fanout_functions": [asdict(f) for f in self.high_fanout_functions],
|
|
1660
|
+
"critical_paths": [asdict(c) for c in self.critical_paths],
|
|
1661
|
+
"public_apis": [asdict(a) for a in self.public_apis],
|
|
1662
|
+
"issues": self.issues,
|
|
1663
|
+
"strengths": self.strengths,
|
|
1664
|
+
"inheritance": self.inheritance_analysis,
|
|
1665
|
+
"snapshot_history": self.snapshot_history,
|
|
1666
|
+
"centrality": [
|
|
1667
|
+
{
|
|
1668
|
+
"rank": r.rank,
|
|
1669
|
+
"node_id": r.node_id,
|
|
1670
|
+
"kind": r.kind,
|
|
1671
|
+
"name": r.name,
|
|
1672
|
+
"module_path": r.module_path,
|
|
1673
|
+
"score": r.score,
|
|
1674
|
+
}
|
|
1675
|
+
for r in self.centrality_records
|
|
1676
|
+
],
|
|
1677
|
+
"centrality_modules": self.centrality_modules,
|
|
1678
|
+
"coderank_top_nodes": self.coderank_top_nodes,
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
def to_markdown(self) -> str:
|
|
1682
|
+
"""Render analysis as a compact Markdown context document for LLM ingestion.
|
|
1683
|
+
|
|
1684
|
+
Similar in spirit to ``SnippetPack.to_markdown()`` — structured,
|
|
1685
|
+
header-navigable output optimized for inclusion in AI prompts.
|
|
1686
|
+
|
|
1687
|
+
:return: Markdown string covering all analysis phases.
|
|
1688
|
+
"""
|
|
1689
|
+
out: list[str] = []
|
|
1690
|
+
stats = self.stats
|
|
1691
|
+
|
|
1692
|
+
out.append("# TypeScriptKG Repository Analysis\n")
|
|
1693
|
+
out.append(f"**Generated:** {datetime.datetime.now(datetime.UTC).isoformat()} \n")
|
|
1694
|
+
out.append("\n---\n")
|
|
1695
|
+
|
|
1696
|
+
out.append("## Baseline Metrics\n")
|
|
1697
|
+
out.append("| Metric | Value |")
|
|
1698
|
+
out.append("|---|---|")
|
|
1699
|
+
out.append(f"| Total Nodes | {stats.get('total_nodes', 'N/A')} |")
|
|
1700
|
+
out.append(f"| Total Edges | {stats.get('total_edges', 'N/A')} |")
|
|
1701
|
+
out.append(f"| Modules | {stats.get('node_counts', {}).get('module', 'N/A')} |")
|
|
1702
|
+
out.append(f"| Functions | {stats.get('node_counts', {}).get('function', 'N/A')} |")
|
|
1703
|
+
out.append(f"| Classes | {stats.get('node_counts', {}).get('class', 'N/A')} |")
|
|
1704
|
+
out.append(f"| Methods | {stats.get('node_counts', {}).get('method', 'N/A')} |")
|
|
1705
|
+
out.append(f"| Interfaces | {stats.get('node_counts', {}).get('interface', 'N/A')} |")
|
|
1706
|
+
out.append("")
|
|
1707
|
+
|
|
1708
|
+
out.append("### Edge Distribution")
|
|
1709
|
+
out.append("| Relationship | Count |")
|
|
1710
|
+
out.append("|---|---|")
|
|
1711
|
+
for rel in ("CALLS", "CONTAINS", "IMPORTS", "INHERITS", "IMPLEMENTS", "EXTENDS"):
|
|
1712
|
+
out.append(f"| {rel} | {stats.get('edge_counts', {}).get(rel, 0)} |")
|
|
1713
|
+
out.append("")
|
|
1714
|
+
|
|
1715
|
+
out.append("## Fan-In Ranking\n")
|
|
1716
|
+
if self.function_metrics:
|
|
1717
|
+
out.append("| # | Kind | Name | Module | Callers |")
|
|
1718
|
+
out.append("|---|---|---|---|---|")
|
|
1719
|
+
for i, metrics in enumerate(
|
|
1720
|
+
sorted(self.function_metrics.values(), key=lambda m: m.fan_in, reverse=True)[:15], 1
|
|
1721
|
+
):
|
|
1722
|
+
out.append(
|
|
1723
|
+
f"| {i} | {metrics.kind} | `{metrics.name}` | {metrics.module} | {metrics.fan_in} |"
|
|
1724
|
+
)
|
|
1725
|
+
else:
|
|
1726
|
+
out.append("No high fan-in functions identified.\n")
|
|
1727
|
+
out.append("")
|
|
1728
|
+
|
|
1729
|
+
out.append("## High Fan-Out Functions\n")
|
|
1730
|
+
if self.high_fanout_functions:
|
|
1731
|
+
out.append("| # | Name | Module | Calls |")
|
|
1732
|
+
out.append("|---|---|---|---|")
|
|
1733
|
+
for i, func in enumerate(
|
|
1734
|
+
sorted(self.high_fanout_functions, key=lambda f: f.fan_out, reverse=True)[:10], 1
|
|
1735
|
+
):
|
|
1736
|
+
out.append(f"| {i} | `{func.name}` | {func.module} | {func.fan_out} |")
|
|
1737
|
+
else:
|
|
1738
|
+
out.append("No extreme high fan-out functions detected.\n")
|
|
1739
|
+
out.append("")
|
|
1740
|
+
|
|
1741
|
+
out.append("## Module Architecture\n")
|
|
1742
|
+
if self.module_metrics:
|
|
1743
|
+
cap = min(10, len(self.module_metrics))
|
|
1744
|
+
out.append("| Module | Functions | Classes | Incoming | Outgoing | Cohesion |")
|
|
1745
|
+
out.append("|---|---|---|---|---|---|")
|
|
1746
|
+
for module, m in sorted(
|
|
1747
|
+
self.module_metrics.items(),
|
|
1748
|
+
key=lambda x: x[1].functions + x[1].classes + x[1].methods,
|
|
1749
|
+
reverse=True,
|
|
1750
|
+
)[:cap]:
|
|
1751
|
+
out.append(
|
|
1752
|
+
f"| `{module}` | {m.functions} | {m.classes} | "
|
|
1753
|
+
f"{len(m.incoming_deps)} | {len(m.outgoing_deps)} | "
|
|
1754
|
+
f"{m.cohesion_score:.2f} |"
|
|
1755
|
+
)
|
|
1756
|
+
else:
|
|
1757
|
+
out.append("No module metrics available.\n")
|
|
1758
|
+
out.append("")
|
|
1759
|
+
|
|
1760
|
+
out.append("## Key Call Chains\n")
|
|
1761
|
+
if self.critical_paths:
|
|
1762
|
+
for i, chain in enumerate(self.critical_paths[:5], 1):
|
|
1763
|
+
out.append(f"**Chain {i}** (depth: {chain.depth})\n")
|
|
1764
|
+
out.append(f"```\n{' → '.join(chain.chain)}\n```\n")
|
|
1765
|
+
else:
|
|
1766
|
+
out.append("No deep call chains detected.\n")
|
|
1767
|
+
out.append("")
|
|
1768
|
+
|
|
1769
|
+
out.append("## Public API Surface\n")
|
|
1770
|
+
if self.public_apis:
|
|
1771
|
+
out.append("| Name | Kind | Module | Callers |")
|
|
1772
|
+
out.append("|---|---|---|---|")
|
|
1773
|
+
for api in sorted(self.public_apis, key=lambda a: a.fan_in, reverse=True)[:12]:
|
|
1774
|
+
out.append(f"| `{api.name}` | {api.kind} | {api.module} | {api.fan_in} |")
|
|
1775
|
+
else:
|
|
1776
|
+
out.append("No exported declarations identified.\n")
|
|
1777
|
+
out.append("")
|
|
1778
|
+
|
|
1779
|
+
out.append("## JSDoc Coverage\n")
|
|
1780
|
+
cov = self.jsdoc_coverage
|
|
1781
|
+
if cov:
|
|
1782
|
+
out.append("| Kind | Documented | Total | Coverage |")
|
|
1783
|
+
out.append("|---|---|---|---|")
|
|
1784
|
+
for kind in ("function", "method", "class", "interface", "module"):
|
|
1785
|
+
if kind in cov["by_kind"]:
|
|
1786
|
+
k = cov["by_kind"][kind]
|
|
1787
|
+
kind_pct = (k["with_doc"] / k["total"] * 100) if k["total"] else 0.0
|
|
1788
|
+
out.append(f"| `{kind}` | {k['with_doc']} | {k['total']} | {kind_pct:.1f}% |")
|
|
1789
|
+
overall_pct = cov["coverage_pct"]
|
|
1790
|
+
out.append(
|
|
1791
|
+
f"| **total** | **{cov['with_doc']}** | **{cov['total']}** | **{overall_pct:.1f}%** |"
|
|
1792
|
+
)
|
|
1793
|
+
else:
|
|
1794
|
+
out.append("Coverage data not available.\n")
|
|
1795
|
+
out.append("")
|
|
1796
|
+
|
|
1797
|
+
out.append("## Class and Interface Hierarchy\n")
|
|
1798
|
+
inh = self.inheritance_analysis
|
|
1799
|
+
if inh and inh.get("total_inherits_edges", 0) > 0:
|
|
1800
|
+
out.append(
|
|
1801
|
+
f"{inh['total_inherits_edges']} INHERITS · "
|
|
1802
|
+
f"{inh.get('total_implements_edges', 0)} IMPLEMENTS · "
|
|
1803
|
+
f"{inh.get('total_extends_edges', 0)} iface-EXTENDS "
|
|
1804
|
+
f"Max depth: {inh['max_depth']}\n"
|
|
1805
|
+
)
|
|
1806
|
+
out.append("| Class | Module | Depth | Parents | Children |")
|
|
1807
|
+
out.append("|---|---|---|---|---|")
|
|
1808
|
+
for cls in inh["classes"][:15]:
|
|
1809
|
+
out.append(
|
|
1810
|
+
f"| `{cls['name']}` | {cls['module']} "
|
|
1811
|
+
f"| {cls['depth']} | {cls['parent_count']} | {cls['child_count']} |"
|
|
1812
|
+
)
|
|
1813
|
+
else:
|
|
1814
|
+
out.append("No class hierarchy.\n")
|
|
1815
|
+
out.append("")
|
|
1816
|
+
|
|
1817
|
+
quality_score, quality_grade, quality_label = self._compute_quality_grade()
|
|
1818
|
+
out.append("## Code Quality\n")
|
|
1819
|
+
out.append(f"**Grade: {quality_grade} ({quality_label}) — {quality_score:.0f}/100**\n")
|
|
1820
|
+
|
|
1821
|
+
out.append("### Issues")
|
|
1822
|
+
for issue in self.issues:
|
|
1823
|
+
out.append(f"- {issue}")
|
|
1824
|
+
out.append("\n### Strengths")
|
|
1825
|
+
for s in self.strengths:
|
|
1826
|
+
out.append(f"- {s}")
|
|
1827
|
+
out.append("")
|
|
1828
|
+
|
|
1829
|
+
return "\n".join(out)
|