polycodegraph 0.1.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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Aggregate graph metrics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from codegraph.analysis._common import _kind_str
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class GraphMetrics:
|
|
14
|
+
total_nodes: int = 0
|
|
15
|
+
total_edges: int = 0
|
|
16
|
+
nodes_by_kind: dict[str, int] = field(default_factory=dict)
|
|
17
|
+
edges_by_kind: dict[str, int] = field(default_factory=dict)
|
|
18
|
+
languages: dict[str, int] = field(default_factory=dict)
|
|
19
|
+
top_files_by_nodes: list[tuple[str, int]] = field(default_factory=list)
|
|
20
|
+
unresolved_edges: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_metrics(graph: nx.MultiDiGraph, *, top_files: int = 10) -> GraphMetrics:
|
|
24
|
+
metrics = GraphMetrics(total_nodes=graph.number_of_nodes())
|
|
25
|
+
kind_counter: Counter[str] = Counter()
|
|
26
|
+
lang_counter: Counter[str] = Counter()
|
|
27
|
+
file_counter: Counter[str] = Counter()
|
|
28
|
+
for _nid, attrs in graph.nodes(data=True):
|
|
29
|
+
kind = _kind_str(attrs.get("kind")) or "UNKNOWN"
|
|
30
|
+
kind_counter[kind] += 1
|
|
31
|
+
lang = str(attrs.get("language") or "unknown")
|
|
32
|
+
lang_counter[lang] += 1
|
|
33
|
+
file_path = attrs.get("file")
|
|
34
|
+
if isinstance(file_path, str) and file_path:
|
|
35
|
+
file_counter[file_path] += 1
|
|
36
|
+
metrics.nodes_by_kind = dict(sorted(kind_counter.items()))
|
|
37
|
+
metrics.languages = dict(sorted(lang_counter.items()))
|
|
38
|
+
metrics.top_files_by_nodes = file_counter.most_common(top_files)
|
|
39
|
+
|
|
40
|
+
edge_counter: Counter[str] = Counter()
|
|
41
|
+
unresolved = 0
|
|
42
|
+
total = 0
|
|
43
|
+
for _src, dst, _key, data in graph.edges(keys=True, data=True):
|
|
44
|
+
total += 1
|
|
45
|
+
ek = _kind_str(data.get("kind")) or "UNKNOWN"
|
|
46
|
+
edge_counter[ek] += 1
|
|
47
|
+
if isinstance(dst, str) and dst.startswith("unresolved::"):
|
|
48
|
+
unresolved += 1
|
|
49
|
+
metrics.total_edges = total
|
|
50
|
+
metrics.edges_by_kind = dict(sorted(edge_counter.items()))
|
|
51
|
+
metrics.unresolved_edges = unresolved
|
|
52
|
+
return metrics
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Aggregate analyze + symbol-resolution helpers used by the CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from dataclasses import asdict, dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from codegraph.analysis import (
|
|
11
|
+
BlastRadiusResult,
|
|
12
|
+
CycleReport,
|
|
13
|
+
DeadNode,
|
|
14
|
+
GraphMetrics,
|
|
15
|
+
Hotspot,
|
|
16
|
+
UntestedNode,
|
|
17
|
+
blast_radius,
|
|
18
|
+
compute_metrics,
|
|
19
|
+
find_cycles,
|
|
20
|
+
find_dead_code,
|
|
21
|
+
find_hotspots,
|
|
22
|
+
find_untested,
|
|
23
|
+
)
|
|
24
|
+
from codegraph.graph.schema import NodeKind
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AnalyzeReport:
|
|
29
|
+
metrics: GraphMetrics
|
|
30
|
+
cycles: CycleReport
|
|
31
|
+
dead_code: list[DeadNode]
|
|
32
|
+
untested: list[UntestedNode]
|
|
33
|
+
hotspots: list[Hotspot]
|
|
34
|
+
warnings: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
# pragma: codegraph-public-api
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"metrics": _metrics_to_dict(self.metrics),
|
|
40
|
+
"cycles": {
|
|
41
|
+
"import_cycles": [
|
|
42
|
+
{"node_ids": c.node_ids, "qualnames": c.qualnames}
|
|
43
|
+
for c in self.cycles.import_cycles
|
|
44
|
+
],
|
|
45
|
+
"call_cycles": [
|
|
46
|
+
{"node_ids": c.node_ids, "qualnames": c.qualnames}
|
|
47
|
+
for c in self.cycles.call_cycles
|
|
48
|
+
],
|
|
49
|
+
"total": self.cycles.total,
|
|
50
|
+
},
|
|
51
|
+
"dead_code": [asdict(d) for d in self.dead_code],
|
|
52
|
+
"untested": [asdict(u) for u in self.untested],
|
|
53
|
+
"hotspots": [asdict(h) for h in self.hotspots],
|
|
54
|
+
"warnings": list(self.warnings),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _metrics_to_dict(m: GraphMetrics) -> dict[str, Any]:
|
|
59
|
+
return {
|
|
60
|
+
"total_nodes": m.total_nodes,
|
|
61
|
+
"total_edges": m.total_edges,
|
|
62
|
+
"nodes_by_kind": dict(m.nodes_by_kind),
|
|
63
|
+
"edges_by_kind": dict(m.edges_by_kind),
|
|
64
|
+
"languages": dict(m.languages),
|
|
65
|
+
"top_files_by_nodes": [list(t) for t in m.top_files_by_nodes],
|
|
66
|
+
"unresolved_edges": m.unresolved_edges,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_full_analyze(
|
|
71
|
+
graph: nx.MultiDiGraph, *, hotspot_limit: int = 20
|
|
72
|
+
) -> AnalyzeReport:
|
|
73
|
+
return AnalyzeReport(
|
|
74
|
+
metrics=compute_metrics(graph),
|
|
75
|
+
cycles=find_cycles(graph),
|
|
76
|
+
dead_code=find_dead_code(graph),
|
|
77
|
+
untested=find_untested(graph),
|
|
78
|
+
hotspots=find_hotspots(graph, limit=hotspot_limit),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def report_to_json(report: AnalyzeReport) -> str:
|
|
83
|
+
return json.dumps(report.to_dict(), indent=2, sort_keys=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def report_to_markdown(report: AnalyzeReport) -> str:
|
|
87
|
+
m = report.metrics
|
|
88
|
+
lines: list[str] = ["# codegraph analysis", ""]
|
|
89
|
+
lines.append("## Metrics")
|
|
90
|
+
lines.append("")
|
|
91
|
+
lines.append(f"- Nodes: **{m.total_nodes}**")
|
|
92
|
+
lines.append(f"- Edges: **{m.total_edges}**")
|
|
93
|
+
lines.append(f"- Unresolved edges: **{m.unresolved_edges}**")
|
|
94
|
+
if m.nodes_by_kind:
|
|
95
|
+
lines.append("- Nodes by kind: " + ", ".join(
|
|
96
|
+
f"{k}={v}" for k, v in m.nodes_by_kind.items()
|
|
97
|
+
))
|
|
98
|
+
if m.edges_by_kind:
|
|
99
|
+
lines.append("- Edges by kind: " + ", ".join(
|
|
100
|
+
f"{k}={v}" for k, v in m.edges_by_kind.items()
|
|
101
|
+
))
|
|
102
|
+
if m.languages:
|
|
103
|
+
lines.append("- Languages: " + ", ".join(
|
|
104
|
+
f"{k}={v}" for k, v in m.languages.items()
|
|
105
|
+
))
|
|
106
|
+
if m.top_files_by_nodes:
|
|
107
|
+
lines.append("")
|
|
108
|
+
lines.append("### Top files by node count")
|
|
109
|
+
lines.append("")
|
|
110
|
+
for path, count in m.top_files_by_nodes:
|
|
111
|
+
lines.append(f"- `{path}` — {count} nodes")
|
|
112
|
+
|
|
113
|
+
lines.append("")
|
|
114
|
+
lines.append("## Cycles")
|
|
115
|
+
lines.append("")
|
|
116
|
+
if not report.cycles.total:
|
|
117
|
+
lines.append("_None._")
|
|
118
|
+
else:
|
|
119
|
+
if report.cycles.import_cycles:
|
|
120
|
+
lines.append(f"### Import cycles ({len(report.cycles.import_cycles)})")
|
|
121
|
+
for cyc in report.cycles.import_cycles[:10]:
|
|
122
|
+
lines.append("- " + " → ".join(cyc.qualnames))
|
|
123
|
+
if report.cycles.call_cycles:
|
|
124
|
+
lines.append(f"### Call cycles ({len(report.cycles.call_cycles)})")
|
|
125
|
+
for cyc in report.cycles.call_cycles[:10]:
|
|
126
|
+
lines.append("- " + " → ".join(cyc.qualnames))
|
|
127
|
+
|
|
128
|
+
lines.append("")
|
|
129
|
+
lines.append(f"## Dead code ({len(report.dead_code)})")
|
|
130
|
+
lines.append("")
|
|
131
|
+
if not report.dead_code:
|
|
132
|
+
lines.append("_None._")
|
|
133
|
+
else:
|
|
134
|
+
for node in report.dead_code[:50]:
|
|
135
|
+
lines.append(
|
|
136
|
+
f"- `{node.qualname}` ({node.kind.lower()}) — "
|
|
137
|
+
f"{node.file}:{node.line_start}"
|
|
138
|
+
)
|
|
139
|
+
if len(report.dead_code) > 50:
|
|
140
|
+
lines.append(f"- … and {len(report.dead_code) - 50} more")
|
|
141
|
+
|
|
142
|
+
lines.append("")
|
|
143
|
+
lines.append(f"## Untested functions ({len(report.untested)})")
|
|
144
|
+
lines.append("")
|
|
145
|
+
if not report.untested:
|
|
146
|
+
lines.append("_None — every function has at least one test caller._")
|
|
147
|
+
else:
|
|
148
|
+
for u in report.untested[:50]:
|
|
149
|
+
lines.append(
|
|
150
|
+
f"- `{u.qualname}` ({u.kind.lower()}) — "
|
|
151
|
+
f"{u.file}:{u.line_start} (callers: {u.incoming_calls})"
|
|
152
|
+
)
|
|
153
|
+
if len(report.untested) > 50:
|
|
154
|
+
lines.append(f"- … and {len(report.untested) - 50} more")
|
|
155
|
+
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append(f"## Hotspots (top {len(report.hotspots)})")
|
|
158
|
+
lines.append("")
|
|
159
|
+
if not report.hotspots:
|
|
160
|
+
lines.append("_None._")
|
|
161
|
+
else:
|
|
162
|
+
lines.append("| Symbol | File | Fan-in | Fan-out | LOC |")
|
|
163
|
+
lines.append("|---|---|---:|---:|---:|")
|
|
164
|
+
for h in report.hotspots:
|
|
165
|
+
lines.append(
|
|
166
|
+
f"| `{h.qualname}` | {h.file} | {h.fan_in} | "
|
|
167
|
+
f"{h.fan_out} | {h.loc} |"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def find_symbol(graph: nx.MultiDiGraph, symbol: str) -> str | None:
|
|
174
|
+
"""Resolve a CLI symbol string to a node id. Tries qualname, then name,
|
|
175
|
+
then file path, then unique substring match. Prefers callable kinds."""
|
|
176
|
+
callable_kinds = {NodeKind.FUNCTION.value, NodeKind.METHOD.value}
|
|
177
|
+
by_qualname: list[tuple[str, str]] = []
|
|
178
|
+
by_name: list[tuple[str, str]] = []
|
|
179
|
+
by_file: list[tuple[str, str]] = []
|
|
180
|
+
for nid, attrs in graph.nodes(data=True):
|
|
181
|
+
kind = str(attrs.get("kind") or "")
|
|
182
|
+
qn = str(attrs.get("qualname") or "")
|
|
183
|
+
name = str(attrs.get("name") or "")
|
|
184
|
+
file_path = str(attrs.get("file") or "")
|
|
185
|
+
if qn == symbol:
|
|
186
|
+
by_qualname.append((nid, kind))
|
|
187
|
+
if name == symbol:
|
|
188
|
+
by_name.append((nid, kind))
|
|
189
|
+
if file_path == symbol:
|
|
190
|
+
by_file.append((nid, kind))
|
|
191
|
+
|
|
192
|
+
for bucket in (by_qualname, by_name, by_file):
|
|
193
|
+
if not bucket:
|
|
194
|
+
continue
|
|
195
|
+
prefer = [nid for nid, kind in bucket if kind in callable_kinds]
|
|
196
|
+
if prefer:
|
|
197
|
+
return prefer[0]
|
|
198
|
+
return bucket[0][0]
|
|
199
|
+
|
|
200
|
+
# substring match across qualnames
|
|
201
|
+
candidates: list[tuple[str, str]] = []
|
|
202
|
+
for nid, attrs in graph.nodes(data=True):
|
|
203
|
+
qn = str(attrs.get("qualname") or "")
|
|
204
|
+
if symbol in qn:
|
|
205
|
+
candidates.append((nid, str(attrs.get("kind") or "")))
|
|
206
|
+
if len(candidates) == 1:
|
|
207
|
+
return candidates[0][0]
|
|
208
|
+
prefer = [nid for nid, kind in candidates if kind in callable_kinds]
|
|
209
|
+
if len(prefer) == 1:
|
|
210
|
+
return prefer[0]
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"AnalyzeReport",
|
|
216
|
+
"BlastRadiusResult",
|
|
217
|
+
"blast_radius",
|
|
218
|
+
"find_symbol",
|
|
219
|
+
"report_to_json",
|
|
220
|
+
"report_to_markdown",
|
|
221
|
+
"run_full_analyze",
|
|
222
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Architectural role classification for FUNCTION/METHOD/CLASS nodes.
|
|
2
|
+
|
|
3
|
+
Stamps ``metadata["role"]`` with one of:
|
|
4
|
+
|
|
5
|
+
* ``HANDLER`` - HTTP/route/endpoint entry points (FastAPI, Flask, NestJS,
|
|
6
|
+
Next.js route files, Express-style decorators).
|
|
7
|
+
* ``SERVICE`` - business-logic classes (``*Service`` suffix, ``@Injectable``)
|
|
8
|
+
and their methods.
|
|
9
|
+
* ``COMPONENT`` - React components (TS/JS only).
|
|
10
|
+
* ``REPO`` - data-access classes (``*Repository`` suffix or SQLAlchemy
|
|
11
|
+
Session-using classes).
|
|
12
|
+
|
|
13
|
+
Conflict priority: HANDLER > COMPONENT > SERVICE > REPO. Whichever fires
|
|
14
|
+
first wins; subsequent rules do not overwrite.
|
|
15
|
+
|
|
16
|
+
This pass is purely additive: it never mutates parser-emitted attributes
|
|
17
|
+
besides the ``role`` key inside a node's ``metadata`` dict.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
import networkx as nx
|
|
25
|
+
|
|
26
|
+
from codegraph.analysis._common import _kind_str
|
|
27
|
+
from codegraph.graph.schema import EdgeKind, NodeKind
|
|
28
|
+
|
|
29
|
+
Role = str # "HANDLER" | "SERVICE" | "COMPONENT" | "REPO"
|
|
30
|
+
|
|
31
|
+
HANDLER: Final[Role] = "HANDLER"
|
|
32
|
+
SERVICE: Final[Role] = "SERVICE"
|
|
33
|
+
COMPONENT: Final[Role] = "COMPONENT"
|
|
34
|
+
REPO: Final[Role] = "REPO"
|
|
35
|
+
|
|
36
|
+
_ROLE_PRIORITY: Final[dict[Role, int]] = {
|
|
37
|
+
HANDLER: 4,
|
|
38
|
+
COMPONENT: 3,
|
|
39
|
+
SERVICE: 2,
|
|
40
|
+
REPO: 1,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# HTTP verb decorator pattern: matches @anything.get / @anything.post / etc.,
|
|
44
|
+
# also @anything.route / @anything.websocket / @anything.endpoint.
|
|
45
|
+
_HTTP_VERBS: Final[tuple[str, ...]] = (
|
|
46
|
+
"get", "post", "put", "delete", "patch", "head", "options",
|
|
47
|
+
"route", "websocket", "endpoint",
|
|
48
|
+
)
|
|
49
|
+
_HTTP_DECORATOR_RE: Final[re.Pattern[str]] = re.compile(
|
|
50
|
+
r"@[\w\.]+\.(?:" + "|".join(_HTTP_VERBS) + r")\b",
|
|
51
|
+
)
|
|
52
|
+
# Substring fallback: catches things like @route(...) or @endpoint(...).
|
|
53
|
+
_ROUTE_SUBSTRINGS: Final[tuple[str, ...]] = ("route", "endpoint")
|
|
54
|
+
|
|
55
|
+
_TS_LIKE_LANGS: Final[frozenset[str]] = frozenset(
|
|
56
|
+
{"typescript", "javascript", "tsx", "jsx"}
|
|
57
|
+
)
|
|
58
|
+
_TSX_EXTS: Final[tuple[str, ...]] = (".tsx", ".jsx")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decorators(attrs: dict[str, object]) -> list[str]:
|
|
62
|
+
metadata = attrs.get("metadata") or {}
|
|
63
|
+
if not isinstance(metadata, dict):
|
|
64
|
+
return []
|
|
65
|
+
raw = metadata.get("decorators") or []
|
|
66
|
+
if not isinstance(raw, list):
|
|
67
|
+
return []
|
|
68
|
+
return [str(x) for x in raw]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_http_decorator(text: str) -> bool:
|
|
72
|
+
if _HTTP_DECORATOR_RE.search(text):
|
|
73
|
+
return True
|
|
74
|
+
# Substring match (case-insensitive) on the decorator name itself.
|
|
75
|
+
lowered = text.lower()
|
|
76
|
+
return any(sub in lowered for sub in _ROUTE_SUBSTRINGS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _has_handler_decorator(decorators: list[str]) -> bool:
|
|
80
|
+
return any(_is_http_decorator(d) for d in decorators)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_injectable_decorator(decorators: list[str]) -> bool:
|
|
84
|
+
return any(d.lstrip("@").startswith("Injectable") for d in decorators)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _has_controller_decorator(decorators: list[str]) -> bool:
|
|
88
|
+
return any(d.lstrip("@").startswith("Controller") for d in decorators)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_next_route_file(file_path: str) -> bool:
|
|
92
|
+
"""Match Next.js app router or pages/api conventions."""
|
|
93
|
+
norm = file_path.replace("\\", "/")
|
|
94
|
+
if re.search(r"(?:^|/)app/.*?/route\.(?:ts|js|tsx|jsx)$", norm):
|
|
95
|
+
return True
|
|
96
|
+
return bool(re.search(r"(?:^|/)pages/api/.*\.(?:ts|js|tsx|jsx)$", norm))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_tsx_file(file_path: str) -> bool:
|
|
100
|
+
norm = file_path.lower()
|
|
101
|
+
return norm.endswith(_TSX_EXTS)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_pascal_case(name: str) -> bool:
|
|
105
|
+
return bool(name) and name[0].isupper() and not name.isupper()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _has_params(attrs: dict[str, object]) -> bool:
|
|
109
|
+
sig = attrs.get("signature")
|
|
110
|
+
if not isinstance(sig, str):
|
|
111
|
+
return False
|
|
112
|
+
# Heuristic: a parameter list with at least one non-empty token between
|
|
113
|
+
# parentheses. ``foo()`` has no params; ``foo(props)`` does.
|
|
114
|
+
m = re.search(r"\(([^)]*)\)", sig)
|
|
115
|
+
if not m:
|
|
116
|
+
return False
|
|
117
|
+
return bool(m.group(1).strip())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _class_inherits_react_component(
|
|
121
|
+
graph: nx.MultiDiGraph, class_id: str
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""True if class has an INHERITS edge to React.Component / Component."""
|
|
124
|
+
for _src, _dst, _key, data in graph.out_edges(class_id, keys=True, data=True):
|
|
125
|
+
if data.get("kind") != EdgeKind.INHERITS.value:
|
|
126
|
+
continue
|
|
127
|
+
target = ""
|
|
128
|
+
meta = data.get("metadata") or {}
|
|
129
|
+
if isinstance(meta, dict):
|
|
130
|
+
target = str(meta.get("target_name") or "")
|
|
131
|
+
if not target:
|
|
132
|
+
continue
|
|
133
|
+
if target in {"React.Component", "Component", "React.PureComponent",
|
|
134
|
+
"PureComponent"}:
|
|
135
|
+
return True
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _members_of_class(
|
|
140
|
+
graph: nx.MultiDiGraph, class_id: str
|
|
141
|
+
) -> list[str]:
|
|
142
|
+
"""Return method/function nodes whose DEFINED_IN edge points to class."""
|
|
143
|
+
members: list[str] = []
|
|
144
|
+
for src, _dst, _key, data in graph.in_edges(class_id, keys=True, data=True):
|
|
145
|
+
if data.get("kind") != EdgeKind.DEFINED_IN.value:
|
|
146
|
+
continue
|
|
147
|
+
attrs = graph.nodes.get(src) or {}
|
|
148
|
+
kind = _kind_str(attrs.get("kind"))
|
|
149
|
+
if kind in (NodeKind.METHOD.value, NodeKind.FUNCTION.value):
|
|
150
|
+
members.append(src)
|
|
151
|
+
return members
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _enclosing_class(
|
|
155
|
+
graph: nx.MultiDiGraph, node_id: str
|
|
156
|
+
) -> str | None:
|
|
157
|
+
"""Return the CLASS node id this method is DEFINED_IN, if any."""
|
|
158
|
+
for _src, dst, _key, data in graph.out_edges(node_id, keys=True, data=True):
|
|
159
|
+
if data.get("kind") != EdgeKind.DEFINED_IN.value:
|
|
160
|
+
continue
|
|
161
|
+
attrs = graph.nodes.get(dst) or {}
|
|
162
|
+
if _kind_str(attrs.get("kind")) == NodeKind.CLASS.value:
|
|
163
|
+
return str(dst)
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _set_role(
|
|
168
|
+
graph: nx.MultiDiGraph, node_id: str, role: Role
|
|
169
|
+
) -> bool:
|
|
170
|
+
"""Set role on the node, respecting priority. Returns True if changed."""
|
|
171
|
+
attrs = graph.nodes.get(node_id)
|
|
172
|
+
if attrs is None:
|
|
173
|
+
return False
|
|
174
|
+
metadata = attrs.get("metadata")
|
|
175
|
+
if not isinstance(metadata, dict):
|
|
176
|
+
metadata = {}
|
|
177
|
+
attrs["metadata"] = metadata
|
|
178
|
+
current = metadata.get("role")
|
|
179
|
+
if (
|
|
180
|
+
isinstance(current, str)
|
|
181
|
+
and current in _ROLE_PRIORITY
|
|
182
|
+
and _ROLE_PRIORITY[role] <= _ROLE_PRIORITY[current]
|
|
183
|
+
):
|
|
184
|
+
return False
|
|
185
|
+
metadata["role"] = role
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _classify_handler(
|
|
190
|
+
graph: nx.MultiDiGraph, node_id: str, attrs: dict[str, object]
|
|
191
|
+
) -> bool:
|
|
192
|
+
kind = _kind_str(attrs.get("kind"))
|
|
193
|
+
if kind not in (NodeKind.FUNCTION.value, NodeKind.METHOD.value):
|
|
194
|
+
return False
|
|
195
|
+
decorators = _decorators(attrs)
|
|
196
|
+
if _has_handler_decorator(decorators):
|
|
197
|
+
return _set_role(graph, node_id, HANDLER)
|
|
198
|
+
# NestJS: methods on a class decorated with @Controller(...) are handlers.
|
|
199
|
+
if kind == NodeKind.METHOD.value:
|
|
200
|
+
cls_id = _enclosing_class(graph, node_id)
|
|
201
|
+
if cls_id is not None:
|
|
202
|
+
cls_attrs = graph.nodes.get(cls_id) or {}
|
|
203
|
+
if _has_controller_decorator(_decorators(cls_attrs)):
|
|
204
|
+
return _set_role(graph, node_id, HANDLER)
|
|
205
|
+
# Next.js app/**/route.{ts,js} or pages/api/**.{ts,js} files.
|
|
206
|
+
if kind == NodeKind.FUNCTION.value:
|
|
207
|
+
file_path = str(attrs.get("file") or "")
|
|
208
|
+
language = str(attrs.get("language") or "").lower()
|
|
209
|
+
if language in _TS_LIKE_LANGS and _is_next_route_file(file_path):
|
|
210
|
+
return _set_role(graph, node_id, HANDLER)
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _classify_service_class(
|
|
215
|
+
graph: nx.MultiDiGraph, node_id: str, attrs: dict[str, object]
|
|
216
|
+
) -> bool:
|
|
217
|
+
if _kind_str(attrs.get("kind")) != NodeKind.CLASS.value:
|
|
218
|
+
return False
|
|
219
|
+
name = str(attrs.get("name") or "")
|
|
220
|
+
if name.endswith("Service"):
|
|
221
|
+
return _set_role(graph, node_id, SERVICE)
|
|
222
|
+
if _has_injectable_decorator(_decorators(attrs)):
|
|
223
|
+
return _set_role(graph, node_id, SERVICE)
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _classify_repo_class(
|
|
228
|
+
graph: nx.MultiDiGraph, node_id: str, attrs: dict[str, object]
|
|
229
|
+
) -> bool:
|
|
230
|
+
if _kind_str(attrs.get("kind")) != NodeKind.CLASS.value:
|
|
231
|
+
return False
|
|
232
|
+
name = str(attrs.get("name") or "")
|
|
233
|
+
if name.endswith("Repository"):
|
|
234
|
+
return _set_role(graph, node_id, REPO)
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _classify_component(
|
|
239
|
+
graph: nx.MultiDiGraph, node_id: str, attrs: dict[str, object]
|
|
240
|
+
) -> bool:
|
|
241
|
+
"""COMPONENT detection (TS/JS only).
|
|
242
|
+
|
|
243
|
+
Rules:
|
|
244
|
+
* CLASS extending ``React.Component`` / ``Component`` / ``PureComponent``.
|
|
245
|
+
* FUNCTION whose file extension is ``.tsx``/``.jsx`` AND name is
|
|
246
|
+
PascalCase AND it accepts at least one parameter.
|
|
247
|
+
"""
|
|
248
|
+
language = str(attrs.get("language") or "").lower()
|
|
249
|
+
if language not in _TS_LIKE_LANGS:
|
|
250
|
+
return False
|
|
251
|
+
kind = _kind_str(attrs.get("kind"))
|
|
252
|
+
file_path = str(attrs.get("file") or "")
|
|
253
|
+
if kind == NodeKind.CLASS.value:
|
|
254
|
+
if _class_inherits_react_component(graph, node_id):
|
|
255
|
+
return _set_role(graph, node_id, COMPONENT)
|
|
256
|
+
return False
|
|
257
|
+
if kind == NodeKind.FUNCTION.value:
|
|
258
|
+
if not _is_tsx_file(file_path):
|
|
259
|
+
return False
|
|
260
|
+
name = str(attrs.get("name") or "")
|
|
261
|
+
if not _is_pascal_case(name):
|
|
262
|
+
return False
|
|
263
|
+
if not _has_params(attrs):
|
|
264
|
+
return False
|
|
265
|
+
return _set_role(graph, node_id, COMPONENT)
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def classify_roles(graph: nx.MultiDiGraph) -> int:
|
|
270
|
+
"""Walk the graph and stamp metadata['role'] on FUNCTION/METHOD/CLASS.
|
|
271
|
+
|
|
272
|
+
Returns the number of nodes that received a non-None role assignment
|
|
273
|
+
(counts each node at most once, even if multiple rules matched).
|
|
274
|
+
"""
|
|
275
|
+
annotated: set[str] = set()
|
|
276
|
+
|
|
277
|
+
# Pass 1: HANDLERs first (highest priority on functions/methods).
|
|
278
|
+
for nid, attrs in graph.nodes(data=True):
|
|
279
|
+
if _classify_handler(graph, nid, attrs):
|
|
280
|
+
annotated.add(nid)
|
|
281
|
+
|
|
282
|
+
# Pass 2: classes — REPO, SERVICE, COMPONENT (CLASS branch).
|
|
283
|
+
service_classes: list[str] = []
|
|
284
|
+
repo_classes: list[str] = []
|
|
285
|
+
for nid, attrs in graph.nodes(data=True):
|
|
286
|
+
kind = _kind_str(attrs.get("kind"))
|
|
287
|
+
if kind != NodeKind.CLASS.value:
|
|
288
|
+
continue
|
|
289
|
+
# COMPONENT class extending React.Component takes priority over
|
|
290
|
+
# SERVICE/REPO suffix coincidences.
|
|
291
|
+
if _classify_component(graph, nid, attrs):
|
|
292
|
+
annotated.add(nid)
|
|
293
|
+
continue
|
|
294
|
+
if _classify_service_class(graph, nid, attrs):
|
|
295
|
+
annotated.add(nid)
|
|
296
|
+
service_classes.append(nid)
|
|
297
|
+
continue
|
|
298
|
+
if _classify_repo_class(graph, nid, attrs):
|
|
299
|
+
annotated.add(nid)
|
|
300
|
+
repo_classes.append(nid)
|
|
301
|
+
|
|
302
|
+
# Pass 3: COMPONENT functions.
|
|
303
|
+
for nid, attrs in graph.nodes(data=True):
|
|
304
|
+
if _kind_str(attrs.get("kind")) != NodeKind.FUNCTION.value:
|
|
305
|
+
continue
|
|
306
|
+
if _classify_component(graph, nid, attrs):
|
|
307
|
+
annotated.add(nid)
|
|
308
|
+
|
|
309
|
+
# Pass 4: propagate SERVICE/REPO to class members. _set_role respects
|
|
310
|
+
# priority, so methods already tagged HANDLER stay HANDLER.
|
|
311
|
+
for cls_id in service_classes:
|
|
312
|
+
for member_id in _members_of_class(graph, cls_id):
|
|
313
|
+
if _set_role(graph, member_id, SERVICE):
|
|
314
|
+
annotated.add(member_id)
|
|
315
|
+
for cls_id in repo_classes:
|
|
316
|
+
for member_id in _members_of_class(graph, cls_id):
|
|
317
|
+
if _set_role(graph, member_id, REPO):
|
|
318
|
+
annotated.add(member_id)
|
|
319
|
+
|
|
320
|
+
return len(annotated)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
__all__ = ["COMPONENT", "HANDLER", "REPO", "SERVICE", "classify_roles"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Untested-symbol detection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import networkx as nx
|
|
7
|
+
|
|
8
|
+
from codegraph.analysis._common import (
|
|
9
|
+
_kind_str,
|
|
10
|
+
in_protocol_class,
|
|
11
|
+
in_test_module,
|
|
12
|
+
is_excluded_path,
|
|
13
|
+
)
|
|
14
|
+
from codegraph.graph.schema import EdgeKind, NodeKind
|
|
15
|
+
|
|
16
|
+
_CANDIDATE_KINDS: frozenset[str] = frozenset(
|
|
17
|
+
{NodeKind.FUNCTION.value, NodeKind.METHOD.value}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class UntestedNode:
|
|
23
|
+
id: str
|
|
24
|
+
name: str
|
|
25
|
+
qualname: str
|
|
26
|
+
kind: str
|
|
27
|
+
file: str
|
|
28
|
+
line_start: int
|
|
29
|
+
incoming_calls: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_untested(graph: nx.MultiDiGraph) -> list[UntestedNode]:
|
|
33
|
+
"""Functions/methods with no incoming CALLS edge from a test module.
|
|
34
|
+
|
|
35
|
+
Skips functions that themselves live in a test module and skips dunder
|
|
36
|
+
helpers (``__init__``, etc.) since users rarely test them directly.
|
|
37
|
+
"""
|
|
38
|
+
out: list[UntestedNode] = []
|
|
39
|
+
for nid, attrs in graph.nodes(data=True):
|
|
40
|
+
kind = _kind_str(attrs.get("kind"))
|
|
41
|
+
if kind not in _CANDIDATE_KINDS:
|
|
42
|
+
continue
|
|
43
|
+
name = str(attrs.get("name") or "")
|
|
44
|
+
if name.startswith("__") and name.endswith("__"):
|
|
45
|
+
continue
|
|
46
|
+
if in_test_module(graph, nid):
|
|
47
|
+
continue
|
|
48
|
+
# Skip test fixtures and static frontend assets — same exclusion as
|
|
49
|
+
# the dead-code analyzer.
|
|
50
|
+
if is_excluded_path(str(attrs.get("file") or "")):
|
|
51
|
+
continue
|
|
52
|
+
# Skip methods defined inside a ``typing.Protocol`` class: Protocol
|
|
53
|
+
# methods are structural type definitions, not runtime code, so
|
|
54
|
+
# "untested" is meaningless for them.
|
|
55
|
+
if kind == NodeKind.METHOD.value and in_protocol_class(graph, nid):
|
|
56
|
+
continue
|
|
57
|
+
incoming = 0
|
|
58
|
+
from_test = 0
|
|
59
|
+
for src, _dst, key in graph.in_edges(nid, keys=True):
|
|
60
|
+
if key != EdgeKind.CALLS.value:
|
|
61
|
+
continue
|
|
62
|
+
incoming += 1
|
|
63
|
+
if in_test_module(graph, src):
|
|
64
|
+
from_test += 1
|
|
65
|
+
if from_test > 0:
|
|
66
|
+
continue
|
|
67
|
+
out.append(
|
|
68
|
+
UntestedNode(
|
|
69
|
+
id=nid,
|
|
70
|
+
name=name,
|
|
71
|
+
qualname=str(attrs.get("qualname") or name),
|
|
72
|
+
kind=kind,
|
|
73
|
+
file=str(attrs.get("file") or ""),
|
|
74
|
+
line_start=int(attrs.get("line_start") or 0),
|
|
75
|
+
incoming_calls=incoming,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
out.sort(key=lambda u: (-u.incoming_calls, u.file, u.line_start))
|
|
79
|
+
return out
|