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/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)