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/bridge.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module Connectivity Centrality for TypeScriptKG.
|
|
3
|
+
Measures module interaction complexity: how many unique modules each module calls/imports.
|
|
4
|
+
For well-modularized codebases, identifies orchestrator and hub modules.
|
|
5
|
+
|
|
6
|
+
Ported from PyCodeKG's ``analysis/bridge.py`` and adapted to the TS/JS
|
|
7
|
+
module vocabulary (path-based module names).
|
|
8
|
+
|
|
9
|
+
Author: Eric G. Suchanek, PhD
|
|
10
|
+
License: Elastic 2.0
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
|
|
16
|
+
from tscode_kg.centrality import CentralityRecord, StructuralImportanceRanker
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compute_bridge_centrality(
|
|
20
|
+
kind: str = "module",
|
|
21
|
+
include_imports: bool = True,
|
|
22
|
+
top: int = 25,
|
|
23
|
+
db_path: str = "tscodekg.sqlite",
|
|
24
|
+
) -> list[tuple[str, float]]:
|
|
25
|
+
"""
|
|
26
|
+
Compute module connectivity: unique module interactions per module.
|
|
27
|
+
|
|
28
|
+
For well-modularized codebases with strong module boundaries, connectivity
|
|
29
|
+
identifies which modules are hubs (calling many others) or widely depended upon
|
|
30
|
+
(called by many modules).
|
|
31
|
+
|
|
32
|
+
Replaces betweenness centrality which is meaningless when inter-module edges are zero.
|
|
33
|
+
|
|
34
|
+
:param kind: Node kind (default 'module', currently unused but kept for compatibility)
|
|
35
|
+
:param include_imports: Whether to include IMPORTS in connectivity (default True)
|
|
36
|
+
:param top: Number of top modules to return (default 25)
|
|
37
|
+
:param db_path: Path to SQLite database
|
|
38
|
+
:return: List of (module_path, connectivity_score) tuples
|
|
39
|
+
"""
|
|
40
|
+
with sqlite3.connect(db_path) as con:
|
|
41
|
+
rows = con.execute(
|
|
42
|
+
"""
|
|
43
|
+
SELECT src.module_path, dst.module_path, rel
|
|
44
|
+
FROM edges
|
|
45
|
+
JOIN nodes AS src ON edges.src = src.id
|
|
46
|
+
JOIN nodes AS dst ON edges.dst = dst.id
|
|
47
|
+
WHERE rel IN ('CALLS', 'IMPORTS')
|
|
48
|
+
AND src.module_path IS NOT NULL
|
|
49
|
+
AND dst.module_path IS NOT NULL
|
|
50
|
+
"""
|
|
51
|
+
).fetchall()
|
|
52
|
+
|
|
53
|
+
# Compute unique modules called + unique modules calling this module
|
|
54
|
+
outbound: dict[str, set[str]] = defaultdict(set) # modules this module calls
|
|
55
|
+
inbound: dict[str, set[str]] = defaultdict(set) # modules that call this module
|
|
56
|
+
call_counts: dict[str, int] = defaultdict(int) # total call frequency
|
|
57
|
+
|
|
58
|
+
for src_mod, dst_mod, rel in rows:
|
|
59
|
+
if not src_mod or not dst_mod:
|
|
60
|
+
continue
|
|
61
|
+
if rel == "IMPORTS" and not include_imports:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
# Record outbound: src_mod calls/imports dst_mod
|
|
65
|
+
outbound[src_mod].add(dst_mod)
|
|
66
|
+
# Record inbound: dst_mod is called/imported by src_mod
|
|
67
|
+
inbound[dst_mod].add(src_mod)
|
|
68
|
+
call_counts[src_mod] += 1
|
|
69
|
+
|
|
70
|
+
# Collect all modules
|
|
71
|
+
all_modules = set(outbound.keys()) | set(inbound.keys())
|
|
72
|
+
|
|
73
|
+
# Compute connectivity score: unique modules touched (fan-out + fan-in)
|
|
74
|
+
# Higher score = more coupled with other modules
|
|
75
|
+
scores: dict[str, float] = {}
|
|
76
|
+
for mod in all_modules:
|
|
77
|
+
unique_outbound = len(outbound[mod])
|
|
78
|
+
unique_inbound = len(inbound[mod])
|
|
79
|
+
total_calls = call_counts[mod]
|
|
80
|
+
|
|
81
|
+
# Normalize: average of outbound and inbound diversity + call frequency
|
|
82
|
+
# Scale to [0, 1]: assume typical module touches ~15 others
|
|
83
|
+
connectivity_score = (
|
|
84
|
+
(unique_outbound + unique_inbound) / 30.0 # diversity (60%)
|
|
85
|
+
+ min(total_calls / 50.0, 1.0) * 0.4 # frequency (40%)
|
|
86
|
+
) / 1.4 # normalize to roughly [0, 1]
|
|
87
|
+
scores[mod] = min(connectivity_score, 1.0)
|
|
88
|
+
|
|
89
|
+
# Persist scores
|
|
90
|
+
records = [
|
|
91
|
+
CentralityRecord(
|
|
92
|
+
node_id=mod,
|
|
93
|
+
kind="module",
|
|
94
|
+
name=mod.split("/")[-1],
|
|
95
|
+
module_path=mod,
|
|
96
|
+
score=score,
|
|
97
|
+
rank=idx + 1,
|
|
98
|
+
inbound_count=len(inbound.get(mod, set())),
|
|
99
|
+
cross_module_inbound=len(inbound.get(mod, set())), # all are cross-module
|
|
100
|
+
rel_breakdown={
|
|
101
|
+
"calls_to_modules": len(outbound.get(mod, set())),
|
|
102
|
+
"called_by_modules": len(inbound.get(mod, set())),
|
|
103
|
+
},
|
|
104
|
+
top_contributors=[],
|
|
105
|
+
)
|
|
106
|
+
for idx, (mod, score) in enumerate(sorted(scores.items(), key=lambda x: x[1], reverse=True))
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
if records:
|
|
110
|
+
StructuralImportanceRanker(db_path).write_scores(records, metric="module_connectivity")
|
|
111
|
+
|
|
112
|
+
# Return top modules by connectivity
|
|
113
|
+
ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
|
114
|
+
return ranked[:top]
|
tscode_kg/centrality.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Structural centrality analysis for TypeScriptKG.
|
|
3
|
+
|
|
4
|
+
Implements Structural Importance Ranking (SIR): a deterministic weighted
|
|
5
|
+
PageRank over the TypeScriptKG graph. Edge weights are tuned per relation
|
|
6
|
+
type (CALLS > INHERITS/IMPLEMENTS > IMPORTS > CONTAINS) and amplified for
|
|
7
|
+
cross-module links, giving a stable, interpretable importance score for every
|
|
8
|
+
module, class, interface, function, and method in the indexed codebase.
|
|
9
|
+
|
|
10
|
+
Public API:
|
|
11
|
+
- :class:`StructuralImportanceRanker` — compute and persist SIR scores.
|
|
12
|
+
- :func:`aggregate_module_scores` — roll node scores up to module level.
|
|
13
|
+
|
|
14
|
+
Author: Eric G. Suchanek, PhD
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import sqlite3
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
_ALLOWED_KINDS = {"module", "class", "interface", "function", "method"}
|
|
28
|
+
_STRUCTURAL_RELS = ("CALLS", "INHERITS", "IMPLEMENTS", "EXTENDS", "IMPORTS", "CONTAINS")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class CentralityConfig:
|
|
33
|
+
"""Configuration for Structural Importance Ranking.
|
|
34
|
+
|
|
35
|
+
:param damping: PageRank damping factor.
|
|
36
|
+
:param max_iter: Maximum PageRank iterations.
|
|
37
|
+
:param tol: Convergence tolerance.
|
|
38
|
+
:param rel_weights: Per-relation weights.
|
|
39
|
+
:param cross_module_boost: Weight multiplier for cross-module edges.
|
|
40
|
+
:param private_penalty: Multiplier for private symbols.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
damping: float = 0.85
|
|
44
|
+
max_iter: int = 100
|
|
45
|
+
tol: float = 1e-10
|
|
46
|
+
rel_weights: dict[str, float] = field(
|
|
47
|
+
default_factory=lambda: {
|
|
48
|
+
"CALLS": 1.0,
|
|
49
|
+
"INHERITS": 0.8,
|
|
50
|
+
"IMPLEMENTS": 0.8,
|
|
51
|
+
"EXTENDS": 0.8,
|
|
52
|
+
"IMPORTS": 0.45,
|
|
53
|
+
"CONTAINS": 0.15,
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
cross_module_boost: float = 1.5
|
|
57
|
+
private_penalty: float = 0.85
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class CentralityRecord:
|
|
62
|
+
"""Centrality result for a single node.
|
|
63
|
+
|
|
64
|
+
:param node_id: Stable node identifier.
|
|
65
|
+
:param kind: TypeScriptKG node kind.
|
|
66
|
+
:param name: Node name.
|
|
67
|
+
:param module_path: Module path from the store.
|
|
68
|
+
:param score: Final importance score.
|
|
69
|
+
:param rank: Rank among all returned nodes.
|
|
70
|
+
:param inbound_count: Number of inbound effective edges.
|
|
71
|
+
:param cross_module_inbound: Number of inbound cross-module edges.
|
|
72
|
+
:param rel_breakdown: Inbound counts by relation type.
|
|
73
|
+
:param top_contributors: Top inbound contributor summaries.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
node_id: str
|
|
77
|
+
kind: str
|
|
78
|
+
name: str
|
|
79
|
+
module_path: str | None
|
|
80
|
+
score: float
|
|
81
|
+
rank: int
|
|
82
|
+
inbound_count: int
|
|
83
|
+
cross_module_inbound: int
|
|
84
|
+
rel_breakdown: dict[str, int]
|
|
85
|
+
top_contributors: list[dict[str, Any]]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class _NodeInfo:
|
|
90
|
+
node_id: str
|
|
91
|
+
kind: str
|
|
92
|
+
name: str
|
|
93
|
+
module_path: str | None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(slots=True)
|
|
97
|
+
class _EffectiveEdge:
|
|
98
|
+
src: str
|
|
99
|
+
dst: str
|
|
100
|
+
rel: str
|
|
101
|
+
weight: float
|
|
102
|
+
same_module: bool
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class StructuralImportanceRanker:
|
|
106
|
+
"""Compute Structural Importance Ranking (SIR) for a TypeScriptKG SQLite database.
|
|
107
|
+
|
|
108
|
+
Loads all real nodes (excluding sym-stub intermediates) and structural
|
|
109
|
+
edges, resolves cross-module symbol stubs, then runs a weighted PageRank
|
|
110
|
+
where each relation type contributes a distinct edge weight and
|
|
111
|
+
cross-module edges receive an additional boost. Private symbols are
|
|
112
|
+
penalized post-convergence. Scores are normalized to sum to 1.0.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, db_path: str | Path, config: CentralityConfig | None = None) -> None:
|
|
116
|
+
"""Bind the ranker to a SQLite graph and (optional) tuning config.
|
|
117
|
+
|
|
118
|
+
:param db_path: Path to the TypeScriptKG SQLite knowledge graph.
|
|
119
|
+
:param config: Override default relation weights, damping, iteration
|
|
120
|
+
cap, tolerance, cross-module boost, or private-symbol penalty.
|
|
121
|
+
Pass ``None`` for the package defaults (see ``CentralityConfig``).
|
|
122
|
+
"""
|
|
123
|
+
self.db_path = Path(db_path)
|
|
124
|
+
self.config = config or CentralityConfig()
|
|
125
|
+
|
|
126
|
+
def compute(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
kinds: set[str] | None = None,
|
|
130
|
+
top: int | None = None,
|
|
131
|
+
) -> list[CentralityRecord]:
|
|
132
|
+
"""Compute SIR scores for all nodes in the graph.
|
|
133
|
+
|
|
134
|
+
:param kinds: Restrict results to a subset of node kinds
|
|
135
|
+
(``'module'``, ``'class'``, ``'interface'``, ``'function'``,
|
|
136
|
+
``'method'``). When ``None``, all kinds are returned.
|
|
137
|
+
:param top: Cap the number of returned records after filtering.
|
|
138
|
+
:return: Records sorted descending by normalized importance score,
|
|
139
|
+
each annotated with rank, inbound-edge counts, and top
|
|
140
|
+
contributing callers.
|
|
141
|
+
"""
|
|
142
|
+
node_map = self._load_nodes()
|
|
143
|
+
effective_edges = self._load_effective_edges(node_map)
|
|
144
|
+
scores = self._pagerank(node_map, effective_edges)
|
|
145
|
+
records = self._assemble_records(node_map, effective_edges, scores)
|
|
146
|
+
|
|
147
|
+
if kinds:
|
|
148
|
+
records = [r for r in records if r.kind in kinds]
|
|
149
|
+
if top is not None:
|
|
150
|
+
records = records[:top]
|
|
151
|
+
return records
|
|
152
|
+
|
|
153
|
+
def write_scores(
|
|
154
|
+
self,
|
|
155
|
+
records: list[CentralityRecord],
|
|
156
|
+
*,
|
|
157
|
+
metric: str = "sir_pagerank",
|
|
158
|
+
) -> int:
|
|
159
|
+
"""Persist SIR scores into the ``centrality_scores`` table.
|
|
160
|
+
|
|
161
|
+
Upserts on ``(node_id, metric)`` so repeated runs overwrite stale
|
|
162
|
+
scores without accumulating duplicate rows.
|
|
163
|
+
|
|
164
|
+
:param records: Ranked records from :meth:`compute`.
|
|
165
|
+
:param metric: Label for the metric column (default ``'sir_pagerank'``).
|
|
166
|
+
:return: Number of rows written.
|
|
167
|
+
"""
|
|
168
|
+
rows = []
|
|
169
|
+
computed_at = datetime.now(UTC).isoformat()
|
|
170
|
+
params = json.dumps(
|
|
171
|
+
{
|
|
172
|
+
"damping": self.config.damping,
|
|
173
|
+
"max_iter": self.config.max_iter,
|
|
174
|
+
"tol": self.config.tol,
|
|
175
|
+
"rel_weights": self.config.rel_weights,
|
|
176
|
+
"cross_module_boost": self.config.cross_module_boost,
|
|
177
|
+
"private_penalty": self.config.private_penalty,
|
|
178
|
+
},
|
|
179
|
+
sort_keys=True,
|
|
180
|
+
)
|
|
181
|
+
for rec in records:
|
|
182
|
+
rows.append((rec.node_id, metric, rec.score, rec.rank, computed_at, params))
|
|
183
|
+
|
|
184
|
+
with sqlite3.connect(self.db_path) as con:
|
|
185
|
+
con.execute(
|
|
186
|
+
"""
|
|
187
|
+
CREATE TABLE IF NOT EXISTS centrality_scores (
|
|
188
|
+
node_id TEXT NOT NULL,
|
|
189
|
+
metric TEXT NOT NULL,
|
|
190
|
+
score REAL NOT NULL,
|
|
191
|
+
rank INTEGER,
|
|
192
|
+
computed_at TEXT NOT NULL,
|
|
193
|
+
params_json TEXT NOT NULL,
|
|
194
|
+
PRIMARY KEY (node_id, metric)
|
|
195
|
+
)
|
|
196
|
+
"""
|
|
197
|
+
)
|
|
198
|
+
con.executemany(
|
|
199
|
+
"""
|
|
200
|
+
INSERT INTO centrality_scores
|
|
201
|
+
(node_id, metric, score, rank, computed_at, params_json)
|
|
202
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
203
|
+
ON CONFLICT(node_id, metric) DO UPDATE SET
|
|
204
|
+
score = excluded.score,
|
|
205
|
+
rank = excluded.rank,
|
|
206
|
+
computed_at = excluded.computed_at,
|
|
207
|
+
params_json = excluded.params_json
|
|
208
|
+
""",
|
|
209
|
+
rows,
|
|
210
|
+
)
|
|
211
|
+
con.commit()
|
|
212
|
+
return len(rows)
|
|
213
|
+
|
|
214
|
+
def _load_nodes(self) -> dict[str, _NodeInfo]:
|
|
215
|
+
"""Load real graph nodes (modules, classes, interfaces, functions, methods) from SQLite, keyed by node id. ``sym:`` stubs are excluded."""
|
|
216
|
+
with sqlite3.connect(self.db_path) as con:
|
|
217
|
+
rows = con.execute(
|
|
218
|
+
"""
|
|
219
|
+
SELECT id, kind, name, module_path
|
|
220
|
+
FROM nodes
|
|
221
|
+
WHERE kind IN ('module', 'class', 'interface', 'function', 'method')
|
|
222
|
+
"""
|
|
223
|
+
).fetchall()
|
|
224
|
+
return {
|
|
225
|
+
row[0]: _NodeInfo(node_id=row[0], kind=row[1], name=row[2], module_path=row[3])
|
|
226
|
+
for row in rows
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
def _load_effective_edges(
|
|
230
|
+
self,
|
|
231
|
+
node_map: dict[str, _NodeInfo],
|
|
232
|
+
) -> list[_EffectiveEdge]:
|
|
233
|
+
"""Load structural edges (CALLS / INHERITS / IMPLEMENTS / EXTENDS / IMPORTS / CONTAINS) and rewrite ``sym:`` targets through ``RESOLVES_TO``.
|
|
234
|
+
|
|
235
|
+
Returns deduplicated edges weighted by relation type, with cross-module
|
|
236
|
+
edges receiving an additional boost from ``config.cross_module_boost``.
|
|
237
|
+
"""
|
|
238
|
+
with sqlite3.connect(self.db_path) as con:
|
|
239
|
+
structural = con.execute(
|
|
240
|
+
"""
|
|
241
|
+
SELECT src, rel, dst
|
|
242
|
+
FROM edges
|
|
243
|
+
WHERE rel IN ('CALLS', 'INHERITS', 'IMPLEMENTS', 'EXTENDS', 'IMPORTS', 'CONTAINS')
|
|
244
|
+
"""
|
|
245
|
+
).fetchall()
|
|
246
|
+
resolves = con.execute(
|
|
247
|
+
"""
|
|
248
|
+
SELECT src, dst
|
|
249
|
+
FROM edges
|
|
250
|
+
WHERE rel = 'RESOLVES_TO'
|
|
251
|
+
"""
|
|
252
|
+
).fetchall()
|
|
253
|
+
|
|
254
|
+
resolve_map: dict[str, list[str]] = defaultdict(list)
|
|
255
|
+
for sym_id, dst in resolves:
|
|
256
|
+
if dst in node_map:
|
|
257
|
+
resolve_map[sym_id].append(dst)
|
|
258
|
+
|
|
259
|
+
dedup: set[tuple[str, str, str]] = set()
|
|
260
|
+
effective: list[_EffectiveEdge] = []
|
|
261
|
+
|
|
262
|
+
for src, rel, dst in structural:
|
|
263
|
+
if src not in node_map:
|
|
264
|
+
continue
|
|
265
|
+
targets: list[str]
|
|
266
|
+
if dst in node_map:
|
|
267
|
+
targets = [dst]
|
|
268
|
+
else:
|
|
269
|
+
targets = resolve_map.get(dst, [])
|
|
270
|
+
for target in targets:
|
|
271
|
+
if target not in node_map:
|
|
272
|
+
continue
|
|
273
|
+
key = (src, rel, target)
|
|
274
|
+
if key in dedup:
|
|
275
|
+
continue
|
|
276
|
+
dedup.add(key)
|
|
277
|
+
weight = float(self.config.rel_weights[rel])
|
|
278
|
+
same_module = node_map[src].module_path == node_map[target].module_path
|
|
279
|
+
if not same_module:
|
|
280
|
+
weight *= self.config.cross_module_boost
|
|
281
|
+
effective.append(
|
|
282
|
+
_EffectiveEdge(
|
|
283
|
+
src=src,
|
|
284
|
+
dst=target,
|
|
285
|
+
rel=rel,
|
|
286
|
+
weight=weight,
|
|
287
|
+
same_module=same_module,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
return effective
|
|
291
|
+
|
|
292
|
+
def _pagerank(
|
|
293
|
+
self,
|
|
294
|
+
node_map: dict[str, _NodeInfo],
|
|
295
|
+
effective_edges: list[_EffectiveEdge],
|
|
296
|
+
) -> dict[str, float]:
|
|
297
|
+
"""Run weighted PageRank on the resolved edge set until convergence.
|
|
298
|
+
|
|
299
|
+
Iterates up to ``config.max_iter`` with damping ``config.damping``,
|
|
300
|
+
redistributing dangling-node mass uniformly. After convergence, names
|
|
301
|
+
starting with ``_`` are scaled by ``config.private_penalty`` and the
|
|
302
|
+
result is normalized so all scores sum to ``1.0``.
|
|
303
|
+
"""
|
|
304
|
+
nodes = list(node_map)
|
|
305
|
+
n = len(nodes)
|
|
306
|
+
if n == 0:
|
|
307
|
+
return {}
|
|
308
|
+
|
|
309
|
+
out_weight: dict[str, float] = defaultdict(float)
|
|
310
|
+
incoming: dict[str, list[tuple[str, float]]] = defaultdict(list)
|
|
311
|
+
for edge in effective_edges:
|
|
312
|
+
out_weight[edge.src] += edge.weight
|
|
313
|
+
incoming[edge.dst].append((edge.src, edge.weight))
|
|
314
|
+
|
|
315
|
+
pr = {node_id: 1.0 / n for node_id in nodes}
|
|
316
|
+
damping = self.config.damping
|
|
317
|
+
|
|
318
|
+
for _ in range(self.config.max_iter):
|
|
319
|
+
base = (1.0 - damping) / n
|
|
320
|
+
dangling_mass = sum(pr[node_id] for node_id in nodes if out_weight[node_id] == 0.0)
|
|
321
|
+
dangling_share = damping * dangling_mass / n
|
|
322
|
+
new_pr: dict[str, float] = {}
|
|
323
|
+
delta = 0.0
|
|
324
|
+
|
|
325
|
+
for node_id in nodes:
|
|
326
|
+
score = base + dangling_share
|
|
327
|
+
for src, weight in incoming.get(node_id, []):
|
|
328
|
+
denom = out_weight[src]
|
|
329
|
+
if denom > 0.0:
|
|
330
|
+
score += damping * pr[src] * (weight / denom)
|
|
331
|
+
new_pr[node_id] = score
|
|
332
|
+
delta += abs(score - pr[node_id])
|
|
333
|
+
|
|
334
|
+
pr = new_pr
|
|
335
|
+
if delta < self.config.tol:
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
for node_id, info in node_map.items():
|
|
339
|
+
if info.name.startswith("_"):
|
|
340
|
+
pr[node_id] *= self.config.private_penalty
|
|
341
|
+
|
|
342
|
+
total = sum(pr.values())
|
|
343
|
+
if total > 0.0:
|
|
344
|
+
pr = {node_id: score / total for node_id, score in pr.items()}
|
|
345
|
+
return pr
|
|
346
|
+
|
|
347
|
+
def _assemble_records(
|
|
348
|
+
self,
|
|
349
|
+
node_map: dict[str, _NodeInfo],
|
|
350
|
+
effective_edges: list[_EffectiveEdge],
|
|
351
|
+
scores: dict[str, float],
|
|
352
|
+
) -> list[CentralityRecord]:
|
|
353
|
+
"""Combine PageRank scores with inbound counts, per-relation breakdowns, and top contributing callers into rank-ordered ``CentralityRecord`` outputs."""
|
|
354
|
+
inbound_counts: dict[str, int] = defaultdict(int)
|
|
355
|
+
cross_counts: dict[str, int] = defaultdict(int)
|
|
356
|
+
rel_breakdown: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
357
|
+
out_weight: dict[str, float] = defaultdict(float)
|
|
358
|
+
incoming_edges: dict[str, list[_EffectiveEdge]] = defaultdict(list)
|
|
359
|
+
|
|
360
|
+
for edge in effective_edges:
|
|
361
|
+
out_weight[edge.src] += edge.weight
|
|
362
|
+
inbound_counts[edge.dst] += 1
|
|
363
|
+
rel_breakdown[edge.dst][edge.rel] += 1
|
|
364
|
+
if not edge.same_module:
|
|
365
|
+
cross_counts[edge.dst] += 1
|
|
366
|
+
incoming_edges[edge.dst].append(edge)
|
|
367
|
+
|
|
368
|
+
ranked_ids = sorted(scores, key=lambda node_id: scores[node_id], reverse=True)
|
|
369
|
+
records: list[CentralityRecord] = []
|
|
370
|
+
for rank, node_id in enumerate(ranked_ids, start=1):
|
|
371
|
+
info = node_map[node_id]
|
|
372
|
+
contributors: list[dict[str, Any]] = []
|
|
373
|
+
for edge in incoming_edges.get(node_id, []):
|
|
374
|
+
denom = out_weight[edge.src]
|
|
375
|
+
contrib = 0.0 if denom == 0.0 else scores[edge.src] * (edge.weight / denom)
|
|
376
|
+
src_info = node_map.get(edge.src)
|
|
377
|
+
contributors.append(
|
|
378
|
+
{
|
|
379
|
+
"src": edge.src,
|
|
380
|
+
"src_name": src_info.name if src_info else edge.src,
|
|
381
|
+
"rel": edge.rel,
|
|
382
|
+
"same_module": edge.same_module,
|
|
383
|
+
"contribution": contrib,
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
contributors.sort(key=lambda item: item["contribution"], reverse=True)
|
|
387
|
+
records.append(
|
|
388
|
+
CentralityRecord(
|
|
389
|
+
node_id=node_id,
|
|
390
|
+
kind=info.kind,
|
|
391
|
+
name=info.name,
|
|
392
|
+
module_path=info.module_path,
|
|
393
|
+
score=scores[node_id],
|
|
394
|
+
rank=rank,
|
|
395
|
+
inbound_count=inbound_counts[node_id],
|
|
396
|
+
cross_module_inbound=cross_counts[node_id],
|
|
397
|
+
rel_breakdown=dict(sorted(rel_breakdown[node_id].items())),
|
|
398
|
+
top_contributors=contributors[:5],
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
return records
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def aggregate_module_scores(records: list[CentralityRecord]) -> list[dict[str, Any]]:
|
|
405
|
+
"""Roll up node-level SIR scores into per-module totals.
|
|
406
|
+
|
|
407
|
+
Each node's score is weighted by kind (class/interface × 1.2, all others
|
|
408
|
+
× 1.0) before accumulation, so modules that export important types rank
|
|
409
|
+
higher than those containing only utility functions of similar raw score.
|
|
410
|
+
|
|
411
|
+
:param records: Node-level centrality records from
|
|
412
|
+
:meth:`StructuralImportanceRanker.compute`.
|
|
413
|
+
:return: List of ``{module_path, score, rank, member_count}`` dicts,
|
|
414
|
+
sorted descending by aggregated score.
|
|
415
|
+
"""
|
|
416
|
+
kind_weight = {"function": 1.0, "method": 1.0, "class": 1.2, "interface": 1.2, "module": 1.0}
|
|
417
|
+
totals: dict[str, float] = defaultdict(float)
|
|
418
|
+
counts: dict[str, int] = defaultdict(int)
|
|
419
|
+
|
|
420
|
+
for rec in records:
|
|
421
|
+
module_key = rec.module_path or "<unknown>"
|
|
422
|
+
totals[module_key] += rec.score * kind_weight.get(rec.kind, 1.0)
|
|
423
|
+
counts[module_key] += 1
|
|
424
|
+
|
|
425
|
+
ranked = sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
|
426
|
+
return [
|
|
427
|
+
{
|
|
428
|
+
"module_path": module_path,
|
|
429
|
+
"score": score,
|
|
430
|
+
"rank": rank,
|
|
431
|
+
"member_count": counts[module_path],
|
|
432
|
+
}
|
|
433
|
+
for rank, (module_path, score) in enumerate(ranked, start=1)
|
|
434
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""TypeScriptKG CLI package."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_analyze.py — tscodekg analyze command: thorough repository analysis.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command("analyze")
|
|
16
|
+
@click.argument("repo_root", default=".", required=False)
|
|
17
|
+
@click.option(
|
|
18
|
+
"--db",
|
|
19
|
+
default=None,
|
|
20
|
+
type=click.Path(),
|
|
21
|
+
help="SQLite knowledge graph path (default: <repo>/.tscodekg/graph.sqlite).",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--vectors",
|
|
25
|
+
default=None,
|
|
26
|
+
type=click.Path(),
|
|
27
|
+
help="sqlite-vec vector store path (default: <repo>/.tscodekg/vectors.sqlite).",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--report",
|
|
31
|
+
"-o",
|
|
32
|
+
"report_path",
|
|
33
|
+
default=None,
|
|
34
|
+
type=click.Path(),
|
|
35
|
+
help="Markdown report output path (omit to print to stdout).",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--write-centrality",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help="Persist SIR centrality scores to the centrality_scores table in the SQLite graph.",
|
|
41
|
+
)
|
|
42
|
+
def analyze(
|
|
43
|
+
repo_root: str,
|
|
44
|
+
db: str | None,
|
|
45
|
+
vectors: str | None,
|
|
46
|
+
report_path: str | None,
|
|
47
|
+
write_centrality: bool,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Run a thorough analysis of a TypeScript/JavaScript repository.
|
|
50
|
+
|
|
51
|
+
Analyzes fan-in/fan-out, module coupling, CodeRank, SIR centrality,
|
|
52
|
+
JSDoc coverage, class/interface hierarchy, and other health signals.
|
|
53
|
+
Outputs a Markdown report.
|
|
54
|
+
"""
|
|
55
|
+
from tscode_kg.analysis import TSCodeKGAnalyzer # pylint: disable=import-outside-toplevel
|
|
56
|
+
from tscode_kg.kg import TypeScriptKG # pylint: disable=import-outside-toplevel
|
|
57
|
+
|
|
58
|
+
kg = TypeScriptKG(
|
|
59
|
+
repo_root=Path(repo_root).resolve(),
|
|
60
|
+
db_path=db,
|
|
61
|
+
vectors_path=vectors,
|
|
62
|
+
)
|
|
63
|
+
analyzer = TSCodeKGAnalyzer(kg, console=console)
|
|
64
|
+
analyzer.run_analysis(report_path=report_path, persist_centrality=write_centrality)
|
|
65
|
+
|
|
66
|
+
if report_path:
|
|
67
|
+
console.print(f"[green]Report written to {report_path}[/green]")
|
|
68
|
+
else:
|
|
69
|
+
console.print(analyzer.to_markdown())
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli/cmd_bridges.py — tscodekg bridges command.
|
|
3
|
+
|
|
4
|
+
Module connectivity (bridge centrality) over the TypeScriptKG graph.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command("bridges")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--db",
|
|
17
|
+
type=click.Path(path_type=Path, dir_okay=False),
|
|
18
|
+
default=Path(".tscodekg/graph.sqlite"),
|
|
19
|
+
show_default=True,
|
|
20
|
+
help="Path to the TypeScriptKG SQLite graph.",
|
|
21
|
+
)
|
|
22
|
+
@click.option("--top", type=int, default=25, show_default=True, help="Number of top modules.")
|
|
23
|
+
@click.option("--no-imports", is_flag=True, help="Ignore IMPORTS edges.")
|
|
24
|
+
def bridges(db: Path, top: int, no_imports: bool) -> None:
|
|
25
|
+
"""Show top bridge modules by connectivity score."""
|
|
26
|
+
from tscode_kg.bridge import ( # pylint: disable=import-outside-toplevel
|
|
27
|
+
compute_bridge_centrality,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
ranked = compute_bridge_centrality(
|
|
31
|
+
kind="module",
|
|
32
|
+
include_imports=not no_imports,
|
|
33
|
+
top=top,
|
|
34
|
+
db_path=str(db),
|
|
35
|
+
)
|
|
36
|
+
click.echo(f"Top {top} bridge modules:")
|
|
37
|
+
for mod, score in ranked:
|
|
38
|
+
click.echo(f"{mod:50s} {score:.5f}")
|