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.
Files changed (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. 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