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.
Files changed (104) hide show
  1. package/INSTALL_TOOLKIT.md +163 -0
  2. package/README.md +269 -2
  3. package/adl_drift/__init__.py +6 -0
  4. package/adl_drift/cli/__init__.py +1 -0
  5. package/adl_drift/cli/commands.py +993 -0
  6. package/adl_drift/cli/main.py +744 -0
  7. package/adl_drift/cortex/__init__.py +1 -0
  8. package/adl_drift/cortex/embeddings.py +96 -0
  9. package/adl_drift/cortex/memory.py +1749 -0
  10. package/adl_drift/cortex/patterns.py +89 -0
  11. package/adl_drift/cortex/telemetry.py +192 -0
  12. package/adl_drift/detectors/__init__.py +1 -0
  13. package/adl_drift/detectors/async_patterns.py +115 -0
  14. package/adl_drift/detectors/base.py +121 -0
  15. package/adl_drift/detectors/config.py +122 -0
  16. package/adl_drift/detectors/data_access.py +128 -0
  17. package/adl_drift/detectors/decorators.py +122 -0
  18. package/adl_drift/detectors/dynamic_patterns.py +260 -0
  19. package/adl_drift/detectors/errors.py +134 -0
  20. package/adl_drift/detectors/imports.py +132 -0
  21. package/adl_drift/detectors/logging_patterns.py +108 -0
  22. package/adl_drift/detectors/routes.py +257 -0
  23. package/adl_drift/detectors/security.py +236 -0
  24. package/adl_drift/detectors/taint.py +406 -0
  25. package/adl_drift/detectors/tests.py +95 -0
  26. package/adl_drift/detectors/type_conventions.py +81 -0
  27. package/adl_drift/graph/__init__.py +1 -0
  28. package/adl_drift/graph/builder.py +2670 -0
  29. package/adl_drift/graph/cfg.py +461 -0
  30. package/adl_drift/graph/cluster.py +40 -0
  31. package/adl_drift/graph/edges.py +86 -0
  32. package/adl_drift/graph/node.py +90 -0
  33. package/adl_drift/harness/__init__.py +10 -0
  34. package/adl_drift/harness/setup.py +832 -0
  35. package/adl_drift/harness/skills/drift-antigravity.md +19 -0
  36. package/adl_drift/harness/skills/drift-generic.md +19 -0
  37. package/adl_drift/harness/skills/drift.md +25 -0
  38. package/adl_drift/harness/watch.py +84 -0
  39. package/adl_drift/lang/__init__.py +8 -0
  40. package/adl_drift/lang/base.py +464 -0
  41. package/adl_drift/lang/java/__init__.py +6 -0
  42. package/adl_drift/lang/java/backend.py +97 -0
  43. package/adl_drift/lang/java/cfg.py +1424 -0
  44. package/adl_drift/lang/java/config.py +707 -0
  45. package/adl_drift/lang/java/detectors/__init__.py +29 -0
  46. package/adl_drift/lang/java/detectors/additional.py +445 -0
  47. package/adl_drift/lang/java/detectors/base.py +154 -0
  48. package/adl_drift/lang/java/detectors/data_access.py +148 -0
  49. package/adl_drift/lang/java/detectors/imports.py +53 -0
  50. package/adl_drift/lang/java/detectors/routes.py +159 -0
  51. package/adl_drift/lang/java/detectors/security.py +386 -0
  52. package/adl_drift/lang/java/detectors/tests.py +74 -0
  53. package/adl_drift/lang/java/extractor.py +582 -0
  54. package/adl_drift/lang/java/ids.py +99 -0
  55. package/adl_drift/lang/java/parser.py +49 -0
  56. package/adl_drift/lang/java/resolution/__init__.py +1 -0
  57. package/adl_drift/lang/java/resolution/constants.py +1619 -0
  58. package/adl_drift/lang/java/resolution/constructors.py +278 -0
  59. package/adl_drift/lang/java/resolution/context.py +305 -0
  60. package/adl_drift/lang/java/resolution/dispatch.py +540 -0
  61. package/adl_drift/lang/java/resolution/functional.py +1318 -0
  62. package/adl_drift/lang/java/resolution/generated.py +95 -0
  63. package/adl_drift/lang/java/resolution/model.py +153 -0
  64. package/adl_drift/lang/java/resolution/orchestration.py +866 -0
  65. package/adl_drift/lang/java/resolution/overloads.py +1275 -0
  66. package/adl_drift/lang/java/resolution/scopes.py +1111 -0
  67. package/adl_drift/lang/java/resolution/types.py +2947 -0
  68. package/adl_drift/lang/java/resolver.py +1401 -0
  69. package/adl_drift/lang/java/scopes.py +69 -0
  70. package/adl_drift/lang/java/taint.py +500 -0
  71. package/adl_drift/lang/java/type_provenance.py +342 -0
  72. package/adl_drift/lang/python/__init__.py +8 -0
  73. package/adl_drift/lang/python/backend.py +94 -0
  74. package/adl_drift/lang/python/cfg.py +537 -0
  75. package/adl_drift/lang/python/extractor.py +454 -0
  76. package/adl_drift/lang/python/parser.py +41 -0
  77. package/adl_drift/lang/python/resolver.py +1789 -0
  78. package/adl_drift/lang/python/taint.py +365 -0
  79. package/adl_drift/lang/registry.py +117 -0
  80. package/adl_drift/parser/__init__.py +1 -0
  81. package/adl_drift/parser/cache.py +38 -0
  82. package/adl_drift/parser/core.py +33 -0
  83. package/adl_drift/parser/extractor.py +17 -0
  84. package/adl_drift/parser/literal_eval.py +326 -0
  85. package/adl_drift/scanner/__init__.py +1 -0
  86. package/adl_drift/scanner/discovery.py +338 -0
  87. package/adl_drift/scanner/incremental.py +261 -0
  88. package/adl_drift/scanner/parallel.py +224 -0
  89. package/adl_drift/server/__init__.py +1 -0
  90. package/adl_drift/server/handlers.py +2849 -0
  91. package/adl_drift/server/mcp_server.py +127 -0
  92. package/adl_drift/server/mcp_stdio.py +335 -0
  93. package/adl_drift/server/protocol.py +71 -0
  94. package/adl_drift/server/static/index.html +696 -0
  95. package/adl_drift/server/web.py +350 -0
  96. package/adl_drift/utils/__init__.py +1 -0
  97. package/adl_drift/utils/config.py +77 -0
  98. package/adl_drift/utils/fswalk.py +52 -0
  99. package/adl_drift/utils/types.py +345 -0
  100. package/bin/adl-drift.mjs +35 -0
  101. package/bin/adl-toolkit.mjs +488 -0
  102. package/package.json +29 -4
  103. package/pyproject.toml +49 -0
  104. 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)