code-review-graph-codeblackwell 2.3.6.post1__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.
- code_review_graph/__init__.py +20 -0
- code_review_graph/__main__.py +4 -0
- code_review_graph/analysis.py +410 -0
- code_review_graph/changes.py +409 -0
- code_review_graph/cli.py +1255 -0
- code_review_graph/communities.py +874 -0
- code_review_graph/constants.py +23 -0
- code_review_graph/context_savings.py +317 -0
- code_review_graph/custom_languages.py +322 -0
- code_review_graph/daemon.py +1009 -0
- code_review_graph/daemon_cli.py +320 -0
- code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md +71 -0
- code_review_graph/embeddings.py +1006 -0
- code_review_graph/enrich.py +303 -0
- code_review_graph/eval/__init__.py +33 -0
- code_review_graph/eval/benchmarks/__init__.py +1 -0
- code_review_graph/eval/benchmarks/agent_baseline.py +193 -0
- code_review_graph/eval/benchmarks/build_performance.py +60 -0
- code_review_graph/eval/benchmarks/flow_completeness.py +36 -0
- code_review_graph/eval/benchmarks/impact_accuracy.py +220 -0
- code_review_graph/eval/benchmarks/multi_hop_retrieval.py +125 -0
- code_review_graph/eval/benchmarks/search_quality.py +59 -0
- code_review_graph/eval/benchmarks/token_efficiency.py +143 -0
- code_review_graph/eval/configs/code-review-graph.yaml +50 -0
- code_review_graph/eval/configs/express.yaml +45 -0
- code_review_graph/eval/configs/fastapi.yaml +48 -0
- code_review_graph/eval/configs/flask.yaml +50 -0
- code_review_graph/eval/configs/gin.yaml +51 -0
- code_review_graph/eval/configs/httpx.yaml +48 -0
- code_review_graph/eval/reporter.py +301 -0
- code_review_graph/eval/runner.py +211 -0
- code_review_graph/eval/scorer.py +85 -0
- code_review_graph/eval/token_benchmark.py +182 -0
- code_review_graph/exports.py +409 -0
- code_review_graph/flows.py +698 -0
- code_review_graph/graph.py +1427 -0
- code_review_graph/graph_diff.py +122 -0
- code_review_graph/hints.py +384 -0
- code_review_graph/incremental.py +1245 -0
- code_review_graph/jedi_resolver.py +303 -0
- code_review_graph/main.py +1079 -0
- code_review_graph/memory.py +142 -0
- code_review_graph/migrations.py +284 -0
- code_review_graph/parser.py +6957 -0
- code_review_graph/postprocessing.py +134 -0
- code_review_graph/prompts.py +159 -0
- code_review_graph/refactor.py +852 -0
- code_review_graph/registry.py +319 -0
- code_review_graph/rescript_resolver.py +206 -0
- code_review_graph/search.py +447 -0
- code_review_graph/skills.py +1481 -0
- code_review_graph/spring_resolver.py +200 -0
- code_review_graph/temporal_resolver.py +199 -0
- code_review_graph/token_benchmark.py +125 -0
- code_review_graph/tools/__init__.py +156 -0
- code_review_graph/tools/_common.py +176 -0
- code_review_graph/tools/analysis_tools.py +184 -0
- code_review_graph/tools/build.py +541 -0
- code_review_graph/tools/community_tools.py +246 -0
- code_review_graph/tools/context.py +152 -0
- code_review_graph/tools/docs.py +274 -0
- code_review_graph/tools/flows_tools.py +176 -0
- code_review_graph/tools/query.py +692 -0
- code_review_graph/tools/refactor_tools.py +168 -0
- code_review_graph/tools/registry_tools.py +125 -0
- code_review_graph/tools/review.py +477 -0
- code_review_graph/tsconfig_resolver.py +257 -0
- code_review_graph/visualization.py +2184 -0
- code_review_graph/wiki.py +305 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/METADATA +718 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/RECORD +74 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/WHEEL +4 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/entry_points.txt +3 -0
- code_review_graph_codeblackwell-2.3.6.post1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Graph snapshot diffing -- compare graph state over time."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .graph import GraphStore
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def take_snapshot(store: GraphStore) -> dict[str, Any]:
|
|
16
|
+
"""Take a snapshot of the current graph state.
|
|
17
|
+
|
|
18
|
+
Returns a dict with node and edge counts, qualified names,
|
|
19
|
+
and community assignments for later diffing.
|
|
20
|
+
"""
|
|
21
|
+
stats = store.get_stats()
|
|
22
|
+
nodes = store.get_all_nodes(exclude_files=False)
|
|
23
|
+
community_map = store.get_all_community_ids()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
"node_count": stats.total_nodes,
|
|
27
|
+
"edge_count": stats.total_edges,
|
|
28
|
+
"nodes": {
|
|
29
|
+
n.qualified_name: {
|
|
30
|
+
"kind": n.kind,
|
|
31
|
+
"file": n.file_path,
|
|
32
|
+
"community_id": community_map.get(
|
|
33
|
+
n.qualified_name
|
|
34
|
+
),
|
|
35
|
+
}
|
|
36
|
+
for n in nodes
|
|
37
|
+
},
|
|
38
|
+
"edges": {
|
|
39
|
+
f"{e.source_qualified}->"
|
|
40
|
+
f"{e.target_qualified}:{e.kind}"
|
|
41
|
+
for e in store.get_all_edges()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def save_snapshot(snapshot: dict, path: Path) -> None:
|
|
47
|
+
"""Save a snapshot to a JSON file."""
|
|
48
|
+
data = dict(snapshot)
|
|
49
|
+
if isinstance(data.get("edges"), set):
|
|
50
|
+
data["edges"] = sorted(data["edges"])
|
|
51
|
+
path.write_text(
|
|
52
|
+
json.dumps(data, indent=2), encoding="utf-8"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_snapshot(path: Path) -> dict:
|
|
57
|
+
"""Load a snapshot from a JSON file."""
|
|
58
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
if isinstance(data.get("edges"), list):
|
|
60
|
+
data["edges"] = set(data["edges"])
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def diff_snapshots(
|
|
65
|
+
before: dict, after: dict,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
"""Compare two graph snapshots.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dict with new_nodes, removed_nodes, new_edges,
|
|
71
|
+
removed_edges, community_changes, and summary
|
|
72
|
+
statistics.
|
|
73
|
+
"""
|
|
74
|
+
before_nodes = set(before.get("nodes", {}).keys())
|
|
75
|
+
after_nodes = set(after.get("nodes", {}).keys())
|
|
76
|
+
before_edges = before.get("edges", set())
|
|
77
|
+
after_edges = after.get("edges", set())
|
|
78
|
+
|
|
79
|
+
new_nodes = after_nodes - before_nodes
|
|
80
|
+
removed_nodes = before_nodes - after_nodes
|
|
81
|
+
new_edges = after_edges - before_edges
|
|
82
|
+
removed_edges = before_edges - after_edges
|
|
83
|
+
|
|
84
|
+
# Community changes for nodes that exist in both
|
|
85
|
+
community_changes = []
|
|
86
|
+
for qn in before_nodes & after_nodes:
|
|
87
|
+
before_cid = before["nodes"][qn].get(
|
|
88
|
+
"community_id"
|
|
89
|
+
)
|
|
90
|
+
after_cid = after["nodes"][qn].get(
|
|
91
|
+
"community_id"
|
|
92
|
+
)
|
|
93
|
+
if before_cid != after_cid:
|
|
94
|
+
community_changes.append({
|
|
95
|
+
"node": qn,
|
|
96
|
+
"before_community": before_cid,
|
|
97
|
+
"after_community": after_cid,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"new_nodes": [
|
|
102
|
+
{"qualified_name": qn, **after["nodes"][qn]}
|
|
103
|
+
for qn in sorted(new_nodes)
|
|
104
|
+
][:100],
|
|
105
|
+
"removed_nodes": sorted(removed_nodes)[:100],
|
|
106
|
+
"new_edges": sorted(new_edges)[:100],
|
|
107
|
+
"removed_edges": sorted(removed_edges)[:100],
|
|
108
|
+
"community_changes": community_changes[:50],
|
|
109
|
+
"summary": {
|
|
110
|
+
"nodes_added": len(new_nodes),
|
|
111
|
+
"nodes_removed": len(removed_nodes),
|
|
112
|
+
"edges_added": len(new_edges),
|
|
113
|
+
"edges_removed": len(removed_edges),
|
|
114
|
+
"community_moves": len(community_changes),
|
|
115
|
+
"before_total": before.get(
|
|
116
|
+
"node_count", 0
|
|
117
|
+
),
|
|
118
|
+
"after_total": after.get(
|
|
119
|
+
"node_count", 0
|
|
120
|
+
),
|
|
121
|
+
},
|
|
122
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Context-aware hints system for MCP tool responses.
|
|
2
|
+
|
|
3
|
+
Tracks session state (in-memory only) and generates intelligent
|
|
4
|
+
next-step suggestions after each tool call. Hints are appended as
|
|
5
|
+
``_hints`` to new tool responses so that Claude Code can propose
|
|
6
|
+
follow-up actions without the user having to discover them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# ---- intent categories and their characteristic tool names ----
|
|
16
|
+
|
|
17
|
+
_INTENT_TOOLS: dict[str, set[str]] = {
|
|
18
|
+
"reviewing": {
|
|
19
|
+
"detect_changes", "get_review_context", "get_affected_flows", "get_impact_radius",
|
|
20
|
+
},
|
|
21
|
+
"debugging": {
|
|
22
|
+
"query_graph", "get_flow", "semantic_search_nodes",
|
|
23
|
+
},
|
|
24
|
+
"refactoring": {
|
|
25
|
+
"refactor", "find_dead_code", "suggest_refactorings",
|
|
26
|
+
},
|
|
27
|
+
"exploring": {
|
|
28
|
+
"list_communities", "get_architecture_overview", "list_flows", "list_graph_stats",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# ---- workflow adjacency: for each tool, which tools are useful next ----
|
|
33
|
+
|
|
34
|
+
_WORKFLOW: dict[str, list[dict[str, str]]] = {
|
|
35
|
+
"list_flows": [
|
|
36
|
+
{
|
|
37
|
+
"tool": "get_flow",
|
|
38
|
+
"suggestion": "Drill into a specific flow for step-by-step details",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"tool": "get_affected_flows",
|
|
42
|
+
"suggestion": "Check which flows are affected by recent changes",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"tool": "get_architecture_overview",
|
|
46
|
+
"suggestion": "See the high-level architecture",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
"get_flow": [
|
|
50
|
+
{
|
|
51
|
+
"tool": "query_graph",
|
|
52
|
+
"suggestion": "Inspect callers/callees of a step in this flow",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"tool": "get_affected_flows",
|
|
56
|
+
"suggestion": "Check if changes affect this flow",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"tool": "list_flows",
|
|
60
|
+
"suggestion": "Browse other execution flows",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
"get_affected_flows": [
|
|
64
|
+
{
|
|
65
|
+
"tool": "detect_changes",
|
|
66
|
+
"suggestion": "Get risk-scored change analysis",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"tool": "get_flow",
|
|
70
|
+
"suggestion": "Inspect a specific affected flow",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"tool": "get_review_context",
|
|
74
|
+
"suggestion": "Build a full review context for the changes",
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
"list_communities": [
|
|
78
|
+
{
|
|
79
|
+
"tool": "get_community",
|
|
80
|
+
"suggestion": "Inspect a specific community's members",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"tool": "get_architecture_overview",
|
|
84
|
+
"suggestion": "See cross-community coupling and warnings",
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"tool": "list_flows",
|
|
88
|
+
"suggestion": "See execution flows across communities",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
"get_community": [
|
|
92
|
+
{
|
|
93
|
+
"tool": "query_graph",
|
|
94
|
+
"suggestion": "Explore callers/callees of community members",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"tool": "list_communities",
|
|
98
|
+
"suggestion": "Browse other communities",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"tool": "get_architecture_overview",
|
|
102
|
+
"suggestion": "See how this community fits the architecture",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
"get_architecture_overview": [
|
|
106
|
+
{
|
|
107
|
+
"tool": "list_communities",
|
|
108
|
+
"suggestion": "Drill into individual communities",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"tool": "detect_changes",
|
|
112
|
+
"suggestion": "See how recent changes affect the architecture",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"tool": "list_flows",
|
|
116
|
+
"suggestion": "Explore execution flows",
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
"detect_changes": [
|
|
120
|
+
{
|
|
121
|
+
"tool": "get_review_context",
|
|
122
|
+
"suggestion": "Build a full review context with source snippets",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"tool": "get_affected_flows",
|
|
126
|
+
"suggestion": "See which execution flows are affected",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"tool": "get_impact_radius",
|
|
130
|
+
"suggestion": "Expand the blast radius analysis",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"tool": "refactor",
|
|
134
|
+
"suggestion": "Look for refactoring opportunities in changed code",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
"refactor": [
|
|
138
|
+
{
|
|
139
|
+
"tool": "query_graph",
|
|
140
|
+
"suggestion": "Verify call sites before applying a rename",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"tool": "detect_changes",
|
|
144
|
+
"suggestion": "Check risk of the refactored code",
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"tool": "semantic_search_nodes",
|
|
148
|
+
"suggestion": "Find related symbols to also rename",
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
"semantic_search_nodes": [
|
|
152
|
+
{
|
|
153
|
+
"tool": "query_graph",
|
|
154
|
+
"suggestion": "Inspect callers/callees of a search result",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"tool": "get_flow",
|
|
158
|
+
"suggestion": "See the execution flow through a matched node",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"tool": "get_impact_radius",
|
|
162
|
+
"suggestion": "Check the blast radius from matched nodes",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Maximum items per hints category returned to the caller.
|
|
168
|
+
_MAX_PER_CATEGORY = 3
|
|
169
|
+
|
|
170
|
+
# Session history caps.
|
|
171
|
+
_MAX_TOOLS_HISTORY = 100
|
|
172
|
+
_MAX_NODES_TRACKED = 1000
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# SessionState
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SessionState:
|
|
181
|
+
"""In-memory session state for a single MCP connection."""
|
|
182
|
+
|
|
183
|
+
def __init__(self) -> None:
|
|
184
|
+
self.tools_called: deque[str] = deque(maxlen=_MAX_TOOLS_HISTORY)
|
|
185
|
+
self.nodes_queried: set[str] = set()
|
|
186
|
+
self.files_touched: set[str] = set()
|
|
187
|
+
self.inferred_intent: str | None = None
|
|
188
|
+
self.last_tool_time: float = 0.0
|
|
189
|
+
|
|
190
|
+
def record_tool_call(self, tool_name: str) -> None:
|
|
191
|
+
"""Record a tool invocation (FIFO, capped at 100)."""
|
|
192
|
+
self.tools_called.append(tool_name)
|
|
193
|
+
self.last_tool_time = time.time()
|
|
194
|
+
|
|
195
|
+
def record_nodes(self, node_ids: list[str]) -> None:
|
|
196
|
+
"""Record queried node identifiers (capped at 1000)."""
|
|
197
|
+
for nid in node_ids:
|
|
198
|
+
if len(self.nodes_queried) >= _MAX_NODES_TRACKED:
|
|
199
|
+
break
|
|
200
|
+
self.nodes_queried.add(nid)
|
|
201
|
+
|
|
202
|
+
def record_files(self, files: list[str]) -> None:
|
|
203
|
+
"""Record touched file paths."""
|
|
204
|
+
self.files_touched.update(files)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ---------------------------------------------------------------------------
|
|
208
|
+
# Intent inference
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def infer_intent(session: SessionState) -> str:
|
|
213
|
+
"""Classify the user's likely intent from their tool-call history.
|
|
214
|
+
|
|
215
|
+
Returns one of: ``"reviewing"``, ``"debugging"``, ``"refactoring"``,
|
|
216
|
+
``"exploring"`` (default).
|
|
217
|
+
"""
|
|
218
|
+
if not session.tools_called:
|
|
219
|
+
return "exploring"
|
|
220
|
+
|
|
221
|
+
# Score each intent by how many of the last N calls match its tools.
|
|
222
|
+
recent = list(session.tools_called)[-10:]
|
|
223
|
+
scores: dict[str, int] = {intent: 0 for intent in _INTENT_TOOLS}
|
|
224
|
+
for tool in recent:
|
|
225
|
+
for intent, tools in _INTENT_TOOLS.items():
|
|
226
|
+
if tool in tools:
|
|
227
|
+
scores[intent] += 1
|
|
228
|
+
|
|
229
|
+
best = max(scores, key=lambda k: scores[k])
|
|
230
|
+
if scores[best] == 0:
|
|
231
|
+
return "exploring"
|
|
232
|
+
return best
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# Hints generation
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def generate_hints(
|
|
241
|
+
tool_name: str,
|
|
242
|
+
result: dict[str, Any],
|
|
243
|
+
session: SessionState,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Build context-aware hints for a tool response.
|
|
246
|
+
|
|
247
|
+
Returns::
|
|
248
|
+
|
|
249
|
+
{
|
|
250
|
+
"next_steps": [{"tool": ..., "suggestion": ...}, ...],
|
|
251
|
+
"related": [...],
|
|
252
|
+
"warnings": [...],
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
At most ``_MAX_PER_CATEGORY`` items per list. Tools already called
|
|
256
|
+
in this session are suppressed from ``next_steps``.
|
|
257
|
+
"""
|
|
258
|
+
# Update session state.
|
|
259
|
+
session.record_tool_call(tool_name)
|
|
260
|
+
session.inferred_intent = infer_intent(session)
|
|
261
|
+
|
|
262
|
+
next_steps = _build_next_steps(tool_name, session)
|
|
263
|
+
warnings = _extract_warnings(result)
|
|
264
|
+
# Build related BEFORE tracking, so that the current result's files
|
|
265
|
+
# are not yet in files_touched and can appear as suggestions.
|
|
266
|
+
related = _build_related(tool_name, result, session)
|
|
267
|
+
|
|
268
|
+
# Collect files/nodes from result for session tracking.
|
|
269
|
+
_track_result(result, session)
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"next_steps": next_steps[:_MAX_PER_CATEGORY],
|
|
273
|
+
"related": related[:_MAX_PER_CATEGORY],
|
|
274
|
+
"warnings": warnings[:_MAX_PER_CATEGORY],
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Internal helpers
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _track_result(result: dict[str, Any], session: SessionState) -> None:
|
|
284
|
+
"""Extract node IDs and file paths from a tool result and record them."""
|
|
285
|
+
# Files
|
|
286
|
+
for key in ("changed_files", "impacted_files"):
|
|
287
|
+
files = result.get(key)
|
|
288
|
+
if isinstance(files, list):
|
|
289
|
+
session.record_files([f for f in files if isinstance(f, str)])
|
|
290
|
+
|
|
291
|
+
# Nodes — look in common result shapes
|
|
292
|
+
node_ids: list[str] = []
|
|
293
|
+
for key in ("results", "changed_nodes", "impacted_nodes"):
|
|
294
|
+
items = result.get(key)
|
|
295
|
+
if isinstance(items, list):
|
|
296
|
+
for item in items:
|
|
297
|
+
if isinstance(item, dict):
|
|
298
|
+
qn = item.get("qualified_name")
|
|
299
|
+
if qn:
|
|
300
|
+
node_ids.append(qn)
|
|
301
|
+
if node_ids:
|
|
302
|
+
session.record_nodes(node_ids)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _build_next_steps(
|
|
306
|
+
tool_name: str, session: SessionState
|
|
307
|
+
) -> list[dict[str, str]]:
|
|
308
|
+
"""Return next-step suggestions, filtering already-called tools."""
|
|
309
|
+
called = set(session.tools_called)
|
|
310
|
+
candidates = _WORKFLOW.get(tool_name, [])
|
|
311
|
+
out: list[dict[str, str]] = []
|
|
312
|
+
for c in candidates:
|
|
313
|
+
if c["tool"] not in called:
|
|
314
|
+
out.append(c)
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _extract_warnings(result: dict[str, Any]) -> list[str]:
|
|
319
|
+
"""Pull warning signals from a tool result."""
|
|
320
|
+
warnings: list[str] = []
|
|
321
|
+
|
|
322
|
+
# Test gaps
|
|
323
|
+
test_gaps = result.get("test_gaps")
|
|
324
|
+
if isinstance(test_gaps, list) and test_gaps:
|
|
325
|
+
names = [g.get("name", g) if isinstance(g, dict) else str(g) for g in test_gaps[:5]]
|
|
326
|
+
warnings.append(
|
|
327
|
+
f"Test coverage gaps: {', '.join(names)}"
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# High risk score
|
|
331
|
+
risk = result.get("risk_score")
|
|
332
|
+
if isinstance(risk, (int, float)) and risk > 0.7:
|
|
333
|
+
warnings.append(f"High risk score ({risk:.2f}) — review carefully")
|
|
334
|
+
|
|
335
|
+
# Coupling warnings from architecture overview
|
|
336
|
+
arch_warnings = result.get("warnings")
|
|
337
|
+
if isinstance(arch_warnings, list):
|
|
338
|
+
for w in arch_warnings[:3]:
|
|
339
|
+
if isinstance(w, str):
|
|
340
|
+
warnings.append(w)
|
|
341
|
+
elif isinstance(w, dict) and "message" in w:
|
|
342
|
+
warnings.append(w["message"])
|
|
343
|
+
|
|
344
|
+
return warnings
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _build_related(
|
|
348
|
+
tool_name: str,
|
|
349
|
+
result: dict[str, Any],
|
|
350
|
+
session: SessionState,
|
|
351
|
+
) -> list[str]:
|
|
352
|
+
"""Suggest related node/file identifiers from the result."""
|
|
353
|
+
related: list[str] = []
|
|
354
|
+
seen: set[str] = set()
|
|
355
|
+
|
|
356
|
+
# Suggest impacted files the user hasn't touched yet
|
|
357
|
+
impacted = result.get("impacted_files")
|
|
358
|
+
if isinstance(impacted, list):
|
|
359
|
+
for f in impacted:
|
|
360
|
+
if isinstance(f, str) and f not in session.files_touched and f not in seen:
|
|
361
|
+
related.append(f)
|
|
362
|
+
seen.add(f)
|
|
363
|
+
if len(related) >= _MAX_PER_CATEGORY:
|
|
364
|
+
break
|
|
365
|
+
|
|
366
|
+
return related
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
# Module-level session singleton
|
|
371
|
+
# ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
_session = SessionState()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_session() -> SessionState:
|
|
377
|
+
"""Return the global in-memory session state."""
|
|
378
|
+
return _session
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def reset_session() -> None:
|
|
382
|
+
"""Reset the global session (useful for testing)."""
|
|
383
|
+
global _session
|
|
384
|
+
_session = SessionState()
|