adl-toolkit 0.0.0-stage → 0.1.1
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.
- package/INSTALL_TOOLKIT.md +163 -0
- package/README.md +269 -2
- package/adl_drift/__init__.py +6 -0
- package/adl_drift/cli/__init__.py +1 -0
- package/adl_drift/cli/commands.py +993 -0
- package/adl_drift/cli/main.py +744 -0
- package/adl_drift/cortex/__init__.py +1 -0
- package/adl_drift/cortex/embeddings.py +96 -0
- package/adl_drift/cortex/memory.py +1749 -0
- package/adl_drift/cortex/patterns.py +89 -0
- package/adl_drift/cortex/telemetry.py +192 -0
- package/adl_drift/detectors/__init__.py +1 -0
- package/adl_drift/detectors/async_patterns.py +115 -0
- package/adl_drift/detectors/base.py +121 -0
- package/adl_drift/detectors/config.py +122 -0
- package/adl_drift/detectors/data_access.py +128 -0
- package/adl_drift/detectors/decorators.py +122 -0
- package/adl_drift/detectors/dynamic_patterns.py +260 -0
- package/adl_drift/detectors/errors.py +134 -0
- package/adl_drift/detectors/imports.py +132 -0
- package/adl_drift/detectors/logging_patterns.py +108 -0
- package/adl_drift/detectors/routes.py +257 -0
- package/adl_drift/detectors/security.py +236 -0
- package/adl_drift/detectors/taint.py +406 -0
- package/adl_drift/detectors/tests.py +95 -0
- package/adl_drift/detectors/type_conventions.py +81 -0
- package/adl_drift/graph/__init__.py +1 -0
- package/adl_drift/graph/builder.py +2670 -0
- package/adl_drift/graph/cfg.py +461 -0
- package/adl_drift/graph/cluster.py +40 -0
- package/adl_drift/graph/edges.py +86 -0
- package/adl_drift/graph/node.py +90 -0
- package/adl_drift/harness/__init__.py +10 -0
- package/adl_drift/harness/setup.py +832 -0
- package/adl_drift/harness/skills/drift-antigravity.md +19 -0
- package/adl_drift/harness/skills/drift-generic.md +19 -0
- package/adl_drift/harness/skills/drift.md +25 -0
- package/adl_drift/harness/watch.py +84 -0
- package/adl_drift/lang/__init__.py +8 -0
- package/adl_drift/lang/base.py +464 -0
- package/adl_drift/lang/java/__init__.py +6 -0
- package/adl_drift/lang/java/backend.py +97 -0
- package/adl_drift/lang/java/cfg.py +1424 -0
- package/adl_drift/lang/java/config.py +707 -0
- package/adl_drift/lang/java/detectors/__init__.py +29 -0
- package/adl_drift/lang/java/detectors/additional.py +445 -0
- package/adl_drift/lang/java/detectors/base.py +154 -0
- package/adl_drift/lang/java/detectors/data_access.py +148 -0
- package/adl_drift/lang/java/detectors/imports.py +53 -0
- package/adl_drift/lang/java/detectors/routes.py +159 -0
- package/adl_drift/lang/java/detectors/security.py +386 -0
- package/adl_drift/lang/java/detectors/tests.py +74 -0
- package/adl_drift/lang/java/extractor.py +582 -0
- package/adl_drift/lang/java/ids.py +99 -0
- package/adl_drift/lang/java/parser.py +49 -0
- package/adl_drift/lang/java/resolution/__init__.py +1 -0
- package/adl_drift/lang/java/resolution/constants.py +1619 -0
- package/adl_drift/lang/java/resolution/constructors.py +278 -0
- package/adl_drift/lang/java/resolution/context.py +305 -0
- package/adl_drift/lang/java/resolution/dispatch.py +540 -0
- package/adl_drift/lang/java/resolution/functional.py +1318 -0
- package/adl_drift/lang/java/resolution/generated.py +95 -0
- package/adl_drift/lang/java/resolution/model.py +153 -0
- package/adl_drift/lang/java/resolution/orchestration.py +866 -0
- package/adl_drift/lang/java/resolution/overloads.py +1275 -0
- package/adl_drift/lang/java/resolution/scopes.py +1111 -0
- package/adl_drift/lang/java/resolution/types.py +2947 -0
- package/adl_drift/lang/java/resolver.py +1401 -0
- package/adl_drift/lang/java/scopes.py +69 -0
- package/adl_drift/lang/java/taint.py +500 -0
- package/adl_drift/lang/java/type_provenance.py +342 -0
- package/adl_drift/lang/python/__init__.py +8 -0
- package/adl_drift/lang/python/backend.py +94 -0
- package/adl_drift/lang/python/cfg.py +537 -0
- package/adl_drift/lang/python/extractor.py +454 -0
- package/adl_drift/lang/python/parser.py +41 -0
- package/adl_drift/lang/python/resolver.py +1789 -0
- package/adl_drift/lang/python/taint.py +365 -0
- package/adl_drift/lang/registry.py +117 -0
- package/adl_drift/parser/__init__.py +1 -0
- package/adl_drift/parser/cache.py +38 -0
- package/adl_drift/parser/core.py +33 -0
- package/adl_drift/parser/extractor.py +17 -0
- package/adl_drift/parser/literal_eval.py +326 -0
- package/adl_drift/scanner/__init__.py +1 -0
- package/adl_drift/scanner/discovery.py +338 -0
- package/adl_drift/scanner/incremental.py +261 -0
- package/adl_drift/scanner/parallel.py +224 -0
- package/adl_drift/server/__init__.py +1 -0
- package/adl_drift/server/handlers.py +2849 -0
- package/adl_drift/server/mcp_server.py +127 -0
- package/adl_drift/server/mcp_stdio.py +335 -0
- package/adl_drift/server/protocol.py +71 -0
- package/adl_drift/server/static/index.html +696 -0
- package/adl_drift/server/web.py +350 -0
- package/adl_drift/utils/__init__.py +1 -0
- package/adl_drift/utils/config.py +77 -0
- package/adl_drift/utils/fswalk.py +52 -0
- package/adl_drift/utils/types.py +345 -0
- package/bin/adl-drift.mjs +35 -0
- package/bin/adl-toolkit.mjs +488 -0
- package/package.json +29 -4
- package/pyproject.toml +49 -0
- package/scripts/install_toolkit.py +3584 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Interactive graph visualization server.
|
|
2
|
+
|
|
3
|
+
Stdlib HTTP server. Serves a Cytoscape.js UI plus JSON endpoints backed by the
|
|
4
|
+
persisted call graph in `cortex.db`. Lazy expansion: the client requests node
|
|
5
|
+
children / neighbors on demand instead of loading the whole graph.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from http import HTTPStatus
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
17
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
18
|
+
|
|
19
|
+
from adl_drift.cli.commands import DriftCommands
|
|
20
|
+
from adl_drift.graph.builder import CallGraphBuilder
|
|
21
|
+
from adl_drift.graph.cluster import detect_communities
|
|
22
|
+
from adl_drift.graph.edges import EdgeType
|
|
23
|
+
from adl_drift.graph.node import NodeType
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GraphService:
|
|
30
|
+
"""Wraps DriftCommands + version polling for the HTTP layer."""
|
|
31
|
+
|
|
32
|
+
VERSION_CACHE_SECONDS = 1.0
|
|
33
|
+
|
|
34
|
+
def __init__(self, project_path: Path):
|
|
35
|
+
self.project_path = project_path
|
|
36
|
+
self.commands = DriftCommands(project_path)
|
|
37
|
+
self._lock = threading.Lock()
|
|
38
|
+
self._builder: Optional[CallGraphBuilder] = None
|
|
39
|
+
self._version: Tuple[float, int] = (0.0, 0)
|
|
40
|
+
self._last_version_check = 0.0
|
|
41
|
+
self._cached_version: Tuple[float, int] = (0.0, 0)
|
|
42
|
+
self._communities: Dict[str, int] = {}
|
|
43
|
+
|
|
44
|
+
# -- public --
|
|
45
|
+
|
|
46
|
+
def ensure_graph(self) -> CallGraphBuilder:
|
|
47
|
+
"""Return current graph. Rebuild incrementally if files changed."""
|
|
48
|
+
with self._lock:
|
|
49
|
+
current = self._scan_version()
|
|
50
|
+
if self._builder is None or current != self._version:
|
|
51
|
+
self._builder = self.commands.build_graph(self.project_path, incremental=True)
|
|
52
|
+
self._communities = detect_communities(self._builder.graph)
|
|
53
|
+
self._version = current
|
|
54
|
+
return self._builder
|
|
55
|
+
|
|
56
|
+
def community_of(self, node_id: str) -> int:
|
|
57
|
+
return self._communities.get(node_id, -1)
|
|
58
|
+
|
|
59
|
+
def version(self) -> Dict[str, Any]:
|
|
60
|
+
with self._lock:
|
|
61
|
+
v = self._scan_version()
|
|
62
|
+
return {"mtime": v[0], "files": v[1]}
|
|
63
|
+
|
|
64
|
+
# -- internals --
|
|
65
|
+
|
|
66
|
+
def _scan_version(self) -> Tuple[float, int]:
|
|
67
|
+
now = time.time()
|
|
68
|
+
if now - self._last_version_check < self.VERSION_CACHE_SECONDS:
|
|
69
|
+
return self._cached_version
|
|
70
|
+
files = self.commands._find_files(self.project_path)
|
|
71
|
+
max_mtime = 0.0
|
|
72
|
+
for f in files:
|
|
73
|
+
try:
|
|
74
|
+
m = f.stat().st_mtime
|
|
75
|
+
if m > max_mtime:
|
|
76
|
+
max_mtime = m
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
self._cached_version = (max_mtime, len(files))
|
|
80
|
+
self._last_version_check = now
|
|
81
|
+
return self._cached_version
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- payload builders ----
|
|
85
|
+
|
|
86
|
+
def _node_payload(
|
|
87
|
+
builder: CallGraphBuilder,
|
|
88
|
+
node_id: str,
|
|
89
|
+
service: Optional["GraphService"] = None,
|
|
90
|
+
) -> Optional[Dict[str, Any]]:
|
|
91
|
+
node = builder._nodes.get(node_id)
|
|
92
|
+
if node is None:
|
|
93
|
+
return None
|
|
94
|
+
degree = (
|
|
95
|
+
builder.graph.in_degree(node_id) + builder.graph.out_degree(node_id)
|
|
96
|
+
if node_id in builder.graph else 0
|
|
97
|
+
)
|
|
98
|
+
return {
|
|
99
|
+
"id": node.id,
|
|
100
|
+
"qualified_name": node.qualified_name,
|
|
101
|
+
"name": node.name,
|
|
102
|
+
"type": node.node_type.name,
|
|
103
|
+
"module": node.module,
|
|
104
|
+
"file": node.file_path,
|
|
105
|
+
"line": node.line,
|
|
106
|
+
"column": node.column,
|
|
107
|
+
"is_async": node.is_async,
|
|
108
|
+
"is_entry_point": node.is_entry_point,
|
|
109
|
+
"decorators": list(node.decorators),
|
|
110
|
+
"signature": node.signature,
|
|
111
|
+
"docstring": node.docstring,
|
|
112
|
+
"contained_in": node.contained_in,
|
|
113
|
+
"calls_count": len(node.calls),
|
|
114
|
+
"called_by_count": len(node.called_by),
|
|
115
|
+
"degree": degree,
|
|
116
|
+
"community": service.community_of(node_id) if service else -1,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _edge_payload(
|
|
121
|
+
src: str,
|
|
122
|
+
dst: str,
|
|
123
|
+
etype: str,
|
|
124
|
+
confidence: float = 1.0,
|
|
125
|
+
) -> Dict[str, Any]:
|
|
126
|
+
return {"source": src, "target": dst, "type": etype, "confidence": float(confidence)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_roots(builder: CallGraphBuilder, service: Optional["GraphService"] = None) -> Dict[str, Any]:
|
|
130
|
+
"""Top-level: every MODULE node, with child counts."""
|
|
131
|
+
nodes: List[Dict[str, Any]] = []
|
|
132
|
+
for nid, n in builder._nodes.items():
|
|
133
|
+
if n.node_type != NodeType.MODULE:
|
|
134
|
+
continue
|
|
135
|
+
payload = _node_payload(builder, nid, service)
|
|
136
|
+
if payload is None:
|
|
137
|
+
continue
|
|
138
|
+
contains_count = sum(
|
|
139
|
+
1 for _, _, d in builder.graph.out_edges(nid, data=True)
|
|
140
|
+
if d.get("type") == EdgeType.CONTAINS.name
|
|
141
|
+
)
|
|
142
|
+
payload["children_count"] = contains_count
|
|
143
|
+
nodes.append(payload)
|
|
144
|
+
nodes.sort(key=lambda x: x["id"])
|
|
145
|
+
return {"nodes": nodes, "edges": []}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_children(
|
|
149
|
+
builder: CallGraphBuilder,
|
|
150
|
+
node_id: str,
|
|
151
|
+
service: Optional["GraphService"] = None,
|
|
152
|
+
) -> Dict[str, Any]:
|
|
153
|
+
"""CONTAINS children of node_id."""
|
|
154
|
+
if node_id not in builder._nodes:
|
|
155
|
+
return {"nodes": [], "edges": []}
|
|
156
|
+
nodes: List[Dict[str, Any]] = []
|
|
157
|
+
edges: List[Dict[str, Any]] = []
|
|
158
|
+
for _, tgt, d in builder.graph.out_edges(node_id, data=True):
|
|
159
|
+
if d.get("type") != EdgeType.CONTAINS.name:
|
|
160
|
+
continue
|
|
161
|
+
payload = _node_payload(builder, tgt, service)
|
|
162
|
+
if payload is None:
|
|
163
|
+
continue
|
|
164
|
+
# children count for further expansion
|
|
165
|
+
child_count = sum(
|
|
166
|
+
1 for _, _, dd in builder.graph.out_edges(tgt, data=True)
|
|
167
|
+
if dd.get("type") == EdgeType.CONTAINS.name
|
|
168
|
+
)
|
|
169
|
+
payload["children_count"] = child_count
|
|
170
|
+
nodes.append(payload)
|
|
171
|
+
edges.append(_edge_payload(
|
|
172
|
+
node_id, tgt, EdgeType.CONTAINS.name, d.get("confidence", 1.0)
|
|
173
|
+
))
|
|
174
|
+
nodes.sort(key=lambda x: (x["type"], x["name"]))
|
|
175
|
+
return {"nodes": nodes, "edges": edges}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_neighbors(
|
|
179
|
+
builder: CallGraphBuilder,
|
|
180
|
+
node_id: str,
|
|
181
|
+
direction: str = "both",
|
|
182
|
+
service: Optional["GraphService"] = None,
|
|
183
|
+
) -> Dict[str, Any]:
|
|
184
|
+
"""Dependency edges incident to node_id (CALLS / REFERENCES / TYPE_OF /
|
|
185
|
+
RETURNS) — "callers" includes variable readers and type consumers."""
|
|
186
|
+
if node_id not in builder._nodes:
|
|
187
|
+
return {"nodes": [], "edges": []}
|
|
188
|
+
nodes: Dict[str, Dict[str, Any]] = {}
|
|
189
|
+
edges: List[Dict[str, Any]] = []
|
|
190
|
+
wanted = builder.IMPACT_EDGE_TYPES
|
|
191
|
+
|
|
192
|
+
if direction in ("callees", "both"):
|
|
193
|
+
for _, tgt, d in builder.graph.out_edges(node_id, data=True):
|
|
194
|
+
etype = d.get("type")
|
|
195
|
+
if etype not in wanted:
|
|
196
|
+
continue
|
|
197
|
+
payload = _node_payload(builder, tgt, service)
|
|
198
|
+
if payload is None:
|
|
199
|
+
continue
|
|
200
|
+
nodes[tgt] = payload
|
|
201
|
+
edges.append(_edge_payload(
|
|
202
|
+
node_id, tgt, etype, d.get("confidence", 1.0)
|
|
203
|
+
))
|
|
204
|
+
|
|
205
|
+
if direction in ("callers", "both"):
|
|
206
|
+
for src, _, d in builder.graph.in_edges(node_id, data=True):
|
|
207
|
+
etype = d.get("type")
|
|
208
|
+
if etype not in wanted:
|
|
209
|
+
continue
|
|
210
|
+
payload = _node_payload(builder, src, service)
|
|
211
|
+
if payload is None:
|
|
212
|
+
continue
|
|
213
|
+
nodes[src] = payload
|
|
214
|
+
edges.append(_edge_payload(
|
|
215
|
+
src, node_id, etype, d.get("confidence", 1.0)
|
|
216
|
+
))
|
|
217
|
+
|
|
218
|
+
return {"nodes": list(nodes.values()), "edges": edges}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def search_nodes(builder: CallGraphBuilder, query: str, limit: int = 50) -> Dict[str, Any]:
|
|
222
|
+
if not query:
|
|
223
|
+
return {"matches": []}
|
|
224
|
+
q = query.lower()
|
|
225
|
+
matches: List[Dict[str, Any]] = []
|
|
226
|
+
for nid, n in builder._nodes.items():
|
|
227
|
+
if q in nid.lower() or q in n.name.lower():
|
|
228
|
+
matches.append({
|
|
229
|
+
"id": nid,
|
|
230
|
+
"name": n.name,
|
|
231
|
+
"type": n.node_type.name,
|
|
232
|
+
"module": n.module,
|
|
233
|
+
"file": n.file_path,
|
|
234
|
+
"line": n.line,
|
|
235
|
+
})
|
|
236
|
+
if len(matches) >= limit:
|
|
237
|
+
break
|
|
238
|
+
return {"matches": matches}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ---- HTTP handler ----
|
|
242
|
+
|
|
243
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
244
|
+
service: GraphService = None # type: ignore[assignment]
|
|
245
|
+
|
|
246
|
+
def log_message(self, fmt: str, *args: Any) -> None: # silence default logging
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
def _send_json(self, status: int, body: Any) -> None:
|
|
250
|
+
data = json.dumps(body, default=str).encode("utf-8")
|
|
251
|
+
self.send_response(status)
|
|
252
|
+
self.send_header("Content-Type", "application/json")
|
|
253
|
+
self.send_header("Content-Length", str(len(data)))
|
|
254
|
+
self.send_header("Cache-Control", "no-store")
|
|
255
|
+
self.end_headers()
|
|
256
|
+
self.wfile.write(data)
|
|
257
|
+
|
|
258
|
+
def _send_file(self, path: Path, content_type: str) -> None:
|
|
259
|
+
try:
|
|
260
|
+
data = path.read_bytes()
|
|
261
|
+
except OSError:
|
|
262
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
263
|
+
return
|
|
264
|
+
self.send_response(HTTPStatus.OK)
|
|
265
|
+
self.send_header("Content-Type", content_type)
|
|
266
|
+
self.send_header("Content-Length", str(len(data)))
|
|
267
|
+
self.end_headers()
|
|
268
|
+
self.wfile.write(data)
|
|
269
|
+
|
|
270
|
+
def do_GET(self) -> None: # noqa: N802
|
|
271
|
+
parsed = urlparse(self.path)
|
|
272
|
+
# Node ids contain characters (":", "(", ")", ",") that the client
|
|
273
|
+
# percent-encodes via encodeURIComponent — decode before matching
|
|
274
|
+
# against builder._nodes, or every id with such a character 404s.
|
|
275
|
+
path = unquote(parsed.path)
|
|
276
|
+
params = parse_qs(parsed.query)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
if path == "/" or path == "/index.html":
|
|
280
|
+
self._send_file(STATIC_DIR / "index.html", "text/html; charset=utf-8")
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
if path == "/api/version":
|
|
284
|
+
self._send_json(HTTPStatus.OK, self.service.version())
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
if path == "/api/roots":
|
|
288
|
+
builder = self.service.ensure_graph()
|
|
289
|
+
self._send_json(HTTPStatus.OK, get_roots(builder, self.service))
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
if path == "/api/stats":
|
|
293
|
+
builder = self.service.ensure_graph()
|
|
294
|
+
self._send_json(HTTPStatus.OK, builder.stats())
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if path == "/api/search":
|
|
298
|
+
q = params.get("q", [""])[0]
|
|
299
|
+
builder = self.service.ensure_graph()
|
|
300
|
+
self._send_json(HTTPStatus.OK, search_nodes(builder, q))
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if path.startswith("/api/node/"):
|
|
304
|
+
rest = path[len("/api/node/"):]
|
|
305
|
+
# /api/node/<id> details
|
|
306
|
+
# /api/node/<id>/children CONTAINS children
|
|
307
|
+
# /api/node/<id>/neighbors CALLS neighbors
|
|
308
|
+
if rest.endswith("/children"):
|
|
309
|
+
nid = rest[:-len("/children")]
|
|
310
|
+
builder = self.service.ensure_graph()
|
|
311
|
+
self._send_json(HTTPStatus.OK, get_children(builder, nid, self.service))
|
|
312
|
+
return
|
|
313
|
+
if rest.endswith("/neighbors"):
|
|
314
|
+
nid = rest[:-len("/neighbors")]
|
|
315
|
+
direction = params.get("direction", ["both"])[0]
|
|
316
|
+
builder = self.service.ensure_graph()
|
|
317
|
+
self._send_json(HTTPStatus.OK, get_neighbors(builder, nid, direction, self.service))
|
|
318
|
+
return
|
|
319
|
+
# plain details
|
|
320
|
+
builder = self.service.ensure_graph()
|
|
321
|
+
payload = _node_payload(builder, rest, self.service)
|
|
322
|
+
if payload is None:
|
|
323
|
+
self._send_json(HTTPStatus.NOT_FOUND, {"error": "not found", "id": rest})
|
|
324
|
+
return
|
|
325
|
+
self._send_json(HTTPStatus.OK, payload)
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
self.send_error(HTTPStatus.NOT_FOUND)
|
|
329
|
+
except Exception as e: # noqa: BLE001
|
|
330
|
+
self._send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(e)})
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def serve(project_path: Path, host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
334
|
+
"""Block and serve. Triggers initial graph build."""
|
|
335
|
+
service = GraphService(project_path)
|
|
336
|
+
# Warm the graph so the first browser request is fast
|
|
337
|
+
service.ensure_graph()
|
|
338
|
+
|
|
339
|
+
handler_cls = type("Handler", (_Handler,), {"service": service})
|
|
340
|
+
httpd = ThreadingHTTPServer((host, port), handler_cls)
|
|
341
|
+
url = f"http://{host}:{port}/"
|
|
342
|
+
print(f"ADL Drift graph viz listening on {url}")
|
|
343
|
+
print(f"Project: {project_path}")
|
|
344
|
+
print("Ctrl-C to stop.")
|
|
345
|
+
try:
|
|
346
|
+
httpd.serve_forever()
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
print("\nStopping...")
|
|
349
|
+
finally:
|
|
350
|
+
httpd.server_close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared utilities."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Project configuration & framework auto-detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PROJECT_ROOT_MARKERS = (
|
|
10
|
+
"pyproject.toml",
|
|
11
|
+
"pom.xml",
|
|
12
|
+
"build.gradle",
|
|
13
|
+
"build.gradle.kts",
|
|
14
|
+
"settings.gradle",
|
|
15
|
+
"settings.gradle.kts",
|
|
16
|
+
".git",
|
|
17
|
+
"setup.py",
|
|
18
|
+
"setup.cfg",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_project_root(start: Path) -> Optional[Path]:
|
|
23
|
+
"""Return the nearest directory containing a supported project marker."""
|
|
24
|
+
start = Path(start)
|
|
25
|
+
if start.is_file():
|
|
26
|
+
start = start.parent
|
|
27
|
+
for path in (start, *start.parents):
|
|
28
|
+
if any((path / marker).exists() for marker in PROJECT_ROOT_MARKERS):
|
|
29
|
+
return path
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
FRAMEWORK_MARKERS = {
|
|
34
|
+
"flask": ["flask"],
|
|
35
|
+
"fastapi": ["fastapi"],
|
|
36
|
+
"django": ["django"],
|
|
37
|
+
"sqlalchemy": ["sqlalchemy"],
|
|
38
|
+
"pydantic": ["pydantic"],
|
|
39
|
+
"pytest": ["pytest"],
|
|
40
|
+
"celery": ["celery"],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ProjectConfig:
|
|
45
|
+
"""Auto-detects project frameworks from pyproject.toml / requirements.txt."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, project_path: Path):
|
|
48
|
+
self.project_path = Path(project_path)
|
|
49
|
+
self.frameworks: List[str] = []
|
|
50
|
+
# Dependency files read, with (st_mtime_ns, st_size) at read time —
|
|
51
|
+
# see JavaProjectConfig.build_file_stats.
|
|
52
|
+
self.build_file_stats: Dict[str, Tuple[int, int]] = {}
|
|
53
|
+
self._detect()
|
|
54
|
+
|
|
55
|
+
def _detect(self) -> None:
|
|
56
|
+
text = self._read_dep_text().lower()
|
|
57
|
+
found: Set[str] = set()
|
|
58
|
+
for fw, markers in FRAMEWORK_MARKERS.items():
|
|
59
|
+
if any(m in text for m in markers):
|
|
60
|
+
found.add(fw)
|
|
61
|
+
self.frameworks = sorted(found)
|
|
62
|
+
|
|
63
|
+
def _read_dep_text(self) -> str:
|
|
64
|
+
chunks: List[str] = []
|
|
65
|
+
for fname in ("pyproject.toml", "requirements.txt", "setup.py", "Pipfile"):
|
|
66
|
+
fpath = self.project_path / fname
|
|
67
|
+
if fpath.is_file():
|
|
68
|
+
try:
|
|
69
|
+
stat = fpath.stat()
|
|
70
|
+
chunks.append(fpath.read_text(encoding="utf-8", errors="ignore"))
|
|
71
|
+
self.build_file_stats[str(fpath)] = (stat.st_mtime_ns, stat.st_size)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
return "\n".join(chunks)
|
|
75
|
+
|
|
76
|
+
def has_framework(self, name: str) -> bool:
|
|
77
|
+
return name.lower() in self.frameworks
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Directory traversal shared by source discovery and backend detection.
|
|
2
|
+
|
|
3
|
+
MCP_GRAPH_CACHE_PLAN.md Lớp 2. ``Path.rglob`` and ``os.walk`` both cost one
|
|
4
|
+
extra ``lstat`` per directory on Python 3.10 (``os.walk`` re-checks
|
|
5
|
+
``islink(new_path)`` before descending — see CPython issue 23605 — and
|
|
6
|
+
``rglob`` resolves nothing but visits the tree once per pattern). On a slow
|
|
7
|
+
filesystem that syscall dominates a no-op MCP query, so this walker takes
|
|
8
|
+
every type/symlink answer from ``os.DirEntry`` (``d_type``) and touches the
|
|
9
|
+
kernel exactly once per directory.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Iterator, List, Tuple
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def iter_tree(top: str) -> Iterator[Tuple[str, List[os.DirEntry], List[os.DirEntry]]]:
|
|
19
|
+
"""Depth-first walk of ``top`` that never follows directory symlinks —
|
|
20
|
+
the traversal ``Path.rglob`` and ``os.walk(followlinks=False)`` perform.
|
|
21
|
+
|
|
22
|
+
Yields ``(dirpath, dirs, others)``:
|
|
23
|
+
|
|
24
|
+
* ``dirs`` — real subdirectories. The consumer may prune this list in
|
|
25
|
+
place before the next iteration to skip subtrees.
|
|
26
|
+
* ``others`` — every other entry ``rglob`` would have yielded as a
|
|
27
|
+
match candidate: regular files, symlinks to files, dangling symlinks
|
|
28
|
+
**and** symlinks to directories (which are listed but never entered).
|
|
29
|
+
|
|
30
|
+
Unreadable or vanished directories are skipped silently, as ``rglob``
|
|
31
|
+
and ``os.walk`` skip them.
|
|
32
|
+
"""
|
|
33
|
+
stack = [top]
|
|
34
|
+
while stack:
|
|
35
|
+
dirpath = stack.pop()
|
|
36
|
+
try:
|
|
37
|
+
with os.scandir(dirpath) as scandir_it:
|
|
38
|
+
entries = list(scandir_it)
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
dirs: List[os.DirEntry] = []
|
|
42
|
+
others: List[os.DirEntry] = []
|
|
43
|
+
for entry in entries:
|
|
44
|
+
try:
|
|
45
|
+
if entry.is_dir(follow_symlinks=False):
|
|
46
|
+
dirs.append(entry)
|
|
47
|
+
else:
|
|
48
|
+
others.append(entry)
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
yield dirpath, dirs, others
|
|
52
|
+
stack.extend(entry.path for entry in dirs)
|