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,261 @@
|
|
|
1
|
+
"""Incremental scanning support.
|
|
2
|
+
|
|
3
|
+
Tracks file content hashes and caches AnalysisResults so that only changed
|
|
4
|
+
files need to be re-parsed on subsequent scans.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from adl_drift.cortex.memory import CortexMemory
|
|
15
|
+
from adl_drift.detectors.base import DetectorRegistry
|
|
16
|
+
from adl_drift.utils.types import AnalysisResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def java_declaration_fingerprint(result: Any) -> Tuple[Any, ...]:
|
|
20
|
+
"""Stable Java API surface used by incremental invalidation.
|
|
21
|
+
|
|
22
|
+
A method-body-only edit cannot change inbound resolution, while a
|
|
23
|
+
changed declaration, hierarchy, annotation, or modifier can. The
|
|
24
|
+
latter therefore falls back to a full project rebuild until a richer
|
|
25
|
+
dependency index can prove a smaller closure safe.
|
|
26
|
+
|
|
27
|
+
Shared by ``DriftCommands.build_graph`` and ``MCPHandlers._build_full_graph``
|
|
28
|
+
(MCP_INCREMENTAL_REBUILD_PLAN.md Lớp 1) so the two entry points cannot
|
|
29
|
+
drift apart on what counts as a structural change.
|
|
30
|
+
"""
|
|
31
|
+
symbols = []
|
|
32
|
+
for symbol in result.symbols:
|
|
33
|
+
if symbol.kind not in {"class", "function", "attribute"}:
|
|
34
|
+
continue
|
|
35
|
+
symbols.append((
|
|
36
|
+
symbol.kind, symbol.name, symbol.parent,
|
|
37
|
+
tuple(symbol.scope_chain), symbol.signature,
|
|
38
|
+
tuple(sorted(symbol.param_types.items())), symbol.return_type,
|
|
39
|
+
tuple(symbol.superclasses), tuple(symbol.implements),
|
|
40
|
+
tuple(symbol.decorators), tuple(symbol.modifiers),
|
|
41
|
+
tuple(symbol.throws),
|
|
42
|
+
))
|
|
43
|
+
# ``parent`` is optional, so compare a stable representation rather
|
|
44
|
+
# than relying on Python to order ``None`` and ``str`` in rare nested
|
|
45
|
+
# declaration layouts.
|
|
46
|
+
return result.module_name, tuple(sorted(symbols, key=repr))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def java_incremental_scope(
|
|
50
|
+
root: Path,
|
|
51
|
+
scanner: "IncrementalScanner",
|
|
52
|
+
changed: List[Path],
|
|
53
|
+
deleted: List[Path],
|
|
54
|
+
) -> Tuple[Optional[Set[Path]], str]:
|
|
55
|
+
"""Return the safely invalidated Java-file closure, or ``None``.
|
|
56
|
+
|
|
57
|
+
The persisted graph already supplies stable nodes and inbound edges.
|
|
58
|
+
For body-only edits their declaration fingerprints are identical, so
|
|
59
|
+
only the changed compilation units need new outgoing resolution. A
|
|
60
|
+
structural edit could make previously unresolved callers resolve (or
|
|
61
|
+
change overload/DI/hierarchy selection), hence its safe closure is the
|
|
62
|
+
whole Java project for now — signalled by ``None`` plus the reason.
|
|
63
|
+
"""
|
|
64
|
+
from adl_drift.lang.registry import get_backend # local: avoid import cycles
|
|
65
|
+
|
|
66
|
+
java_changed = [path for path in changed if path.suffix == ".java"]
|
|
67
|
+
if any(path.suffix == ".java" for path in deleted):
|
|
68
|
+
return None, "java file deleted"
|
|
69
|
+
backend = get_backend("java")
|
|
70
|
+
parser, extractor = backend.parser(), backend.extractor()
|
|
71
|
+
for path in java_changed:
|
|
72
|
+
previous = scanner.load_cached_result(root, path)
|
|
73
|
+
if previous is None:
|
|
74
|
+
return None, f"no cached declaration surface for {path.name}"
|
|
75
|
+
try:
|
|
76
|
+
current = extractor._build(parser.parse_file(path), path)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
return None, f"cannot parse {path.name}: {type(exc).__name__}"
|
|
79
|
+
if java_declaration_fingerprint(previous) != java_declaration_fingerprint(current):
|
|
80
|
+
return None, f"declaration surface changed in {path.name}"
|
|
81
|
+
return set(java_changed), "method-body-only Java edit"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class IncrementalScanner:
|
|
85
|
+
"""Manages incremental scanning for a project.
|
|
86
|
+
|
|
87
|
+
Works with ``CortexMemory.file_metadata`` to avoid re-parsing
|
|
88
|
+
unchanged files.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
memory: CortexMemory,
|
|
94
|
+
parser: object,
|
|
95
|
+
extractor: object,
|
|
96
|
+
registry: DetectorRegistry,
|
|
97
|
+
) -> None:
|
|
98
|
+
self.memory = memory
|
|
99
|
+
self.parser = parser
|
|
100
|
+
self.extractor = extractor
|
|
101
|
+
self.registry = registry
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _file_hash(path: Path) -> str:
|
|
105
|
+
"""SHA256 of file contents."""
|
|
106
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _resolve_paths(files: List[Path]) -> List[Tuple[Path, str]]:
|
|
110
|
+
"""``(path, str(path.resolve()))`` for each file, one lstat per distinct
|
|
111
|
+
path component instead of one per component per file.
|
|
112
|
+
|
|
113
|
+
``Path.resolve()`` lstat()s every component of every path, so a corpus
|
|
114
|
+
of a few hundred deep paths costs tens of thousands of syscalls on each
|
|
115
|
+
no-change query (MCP_GRAPH_CACHE_PLAN.md Lớp 2). Directories are
|
|
116
|
+
resolved once and shared by every file beneath them; only a component
|
|
117
|
+
that is itself a symlink falls back to a real ``resolve()``. Relative
|
|
118
|
+
paths and ``..`` components keep the plain ``resolve()`` so the result
|
|
119
|
+
is identical to it in every case.
|
|
120
|
+
"""
|
|
121
|
+
cache: Dict[Path, Path] = {}
|
|
122
|
+
|
|
123
|
+
def real(path: Path) -> Path:
|
|
124
|
+
hit = cache.get(path)
|
|
125
|
+
if hit is None:
|
|
126
|
+
if path.is_symlink():
|
|
127
|
+
hit = path.resolve()
|
|
128
|
+
else:
|
|
129
|
+
parent = path.parent
|
|
130
|
+
hit = path if parent == path else real(parent) / path.name
|
|
131
|
+
cache[path] = hit
|
|
132
|
+
return hit
|
|
133
|
+
|
|
134
|
+
return [
|
|
135
|
+
(f, str(f.resolve() if not f.is_absolute() or ".." in f.parts else real(f)))
|
|
136
|
+
for f in files
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def classify_files(
|
|
140
|
+
self,
|
|
141
|
+
project_path: Path,
|
|
142
|
+
files: List[Path],
|
|
143
|
+
extensions: Optional[Set[str]] = None,
|
|
144
|
+
) -> Tuple[List[Path], List[Path], List[Path]]:
|
|
145
|
+
"""Classify files as changed, unchanged, or deleted.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
(changed_files, unchanged_files, deleted_files)
|
|
149
|
+
"""
|
|
150
|
+
stored = self.memory.get_all_file_metadata(project_path)
|
|
151
|
+
stored_by_path: Dict[str, Dict] = {row["file_path"]: row for row in stored}
|
|
152
|
+
resolved_paths = self._resolve_paths(files)
|
|
153
|
+
current_paths: Set[str] = {fpath for _, fpath in resolved_paths}
|
|
154
|
+
|
|
155
|
+
changed: List[Path] = []
|
|
156
|
+
unchanged: List[Path] = []
|
|
157
|
+
|
|
158
|
+
for f, fpath in resolved_paths:
|
|
159
|
+
row = stored_by_path.get(fpath)
|
|
160
|
+
if row is None:
|
|
161
|
+
changed.append(f)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
current_hash = self._file_hash(f)
|
|
165
|
+
if current_hash != row["content_hash"]:
|
|
166
|
+
changed.append(f)
|
|
167
|
+
else:
|
|
168
|
+
unchanged.append(f)
|
|
169
|
+
|
|
170
|
+
deleted: List[Path] = [
|
|
171
|
+
Path(row["file_path"])
|
|
172
|
+
for fpath, row in stored_by_path.items()
|
|
173
|
+
if fpath not in current_paths
|
|
174
|
+
and (extensions is None or Path(fpath).suffix in extensions)
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
return changed, unchanged, deleted
|
|
178
|
+
|
|
179
|
+
def load_cached_result(
|
|
180
|
+
self,
|
|
181
|
+
project_path: Path,
|
|
182
|
+
file_path: Path,
|
|
183
|
+
) -> Optional[AnalysisResult]:
|
|
184
|
+
"""Load a cached ``AnalysisResult`` from memory."""
|
|
185
|
+
row = self.memory.get_file_metadata(project_path, file_path)
|
|
186
|
+
if row and row.get("analysis_json"):
|
|
187
|
+
return AnalysisResult.from_dict(json.loads(row["analysis_json"]))
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def load_cached_results(
|
|
191
|
+
self,
|
|
192
|
+
project_path: Path,
|
|
193
|
+
file_paths: List[Path],
|
|
194
|
+
) -> Dict[Path, AnalysisResult]:
|
|
195
|
+
"""Load cached ``AnalysisResult`` objects for many files at once.
|
|
196
|
+
|
|
197
|
+
One query replaces a connection-per-file round trip; a large corpus
|
|
198
|
+
reloads every unchanged file on each incremental update.
|
|
199
|
+
"""
|
|
200
|
+
if not file_paths:
|
|
201
|
+
return {}
|
|
202
|
+
rows = {
|
|
203
|
+
row["file_path"]: row
|
|
204
|
+
for row in self.memory.get_all_file_metadata(project_path)
|
|
205
|
+
}
|
|
206
|
+
loaded: Dict[Path, AnalysisResult] = {}
|
|
207
|
+
for file_path in file_paths:
|
|
208
|
+
row = rows.get(str(file_path.resolve()))
|
|
209
|
+
if row and row.get("analysis_json"):
|
|
210
|
+
loaded[file_path] = AnalysisResult.from_dict(json.loads(row["analysis_json"]))
|
|
211
|
+
return loaded
|
|
212
|
+
|
|
213
|
+
def store_result(
|
|
214
|
+
self,
|
|
215
|
+
project_path: Path,
|
|
216
|
+
file_path: Path,
|
|
217
|
+
result: AnalysisResult,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Store an ``AnalysisResult`` and its content hash in memory."""
|
|
220
|
+
self.store_results(project_path, [(file_path, result)])
|
|
221
|
+
|
|
222
|
+
def store_results(
|
|
223
|
+
self,
|
|
224
|
+
project_path: Path,
|
|
225
|
+
entries: List[Tuple[Path, AnalysisResult]],
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Store multiple ``AnalysisResult``s and their content hashes in one
|
|
228
|
+
connection/transaction (see ``CortexMemory.store_files_metadata``).
|
|
229
|
+
|
|
230
|
+
Callers must only flush this after every file in ``entries`` has
|
|
231
|
+
already had its patterns persisted (``_append_patterns``/
|
|
232
|
+
``store_patterns``), same ordering requirement as the previous
|
|
233
|
+
one-call-per-file ``store_result`` — batching here just means the
|
|
234
|
+
flush point moves later, never earlier.
|
|
235
|
+
"""
|
|
236
|
+
if not entries:
|
|
237
|
+
return
|
|
238
|
+
self.memory.store_files_metadata(project_path, [
|
|
239
|
+
(
|
|
240
|
+
file_path,
|
|
241
|
+
self._file_hash(file_path),
|
|
242
|
+
file_path.stat().st_mtime,
|
|
243
|
+
json.dumps(result.to_dict()),
|
|
244
|
+
)
|
|
245
|
+
for file_path, result in entries
|
|
246
|
+
])
|
|
247
|
+
|
|
248
|
+
def prune_deleted_files(
|
|
249
|
+
self,
|
|
250
|
+
project_path: Path,
|
|
251
|
+
current_files: Set[str],
|
|
252
|
+
extensions: Optional[Set[str]] = None,
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Remove metadata for files that no longer exist."""
|
|
255
|
+
if extensions is not None:
|
|
256
|
+
current_files |= {
|
|
257
|
+
row["file_path"]
|
|
258
|
+
for row in self.memory.get_all_file_metadata(project_path)
|
|
259
|
+
if Path(row["file_path"]).suffix not in extensions
|
|
260
|
+
}
|
|
261
|
+
self.memory.prune_file_metadata(project_path, current_files)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Parallel file analysis using ProcessPoolExecutor.
|
|
2
|
+
|
|
3
|
+
Worker processes initialize their own parser + extractor + detector registry
|
|
4
|
+
once (via ``initializer``), then process file paths from a queue. Tree-sitter
|
|
5
|
+
``Tree`` objects are not picklable, so workers return ``AnalysisResult`` and
|
|
6
|
+
``List[Pattern]`` only — callers that need the CST (e.g. ``resolve_calls``)
|
|
7
|
+
must re-parse in the main process.
|
|
8
|
+
|
|
9
|
+
Degradation ladder (P2.9): processes → threads → sequential.
|
|
10
|
+
- Spawned child processes (detected via multiprocessing) never attempt a pool
|
|
11
|
+
at all — prevents recursive spawn storms from unguarded host ``__main__``
|
|
12
|
+
modules and from Drift being embedded inside someone else's worker pool.
|
|
13
|
+
- If the process pool cannot start or dies mid-run, the remaining files are
|
|
14
|
+
analyzed on a thread pool (tree-sitter parsing releases the GIL, so threads
|
|
15
|
+
still buy real parallelism for the parse-heavy part).
|
|
16
|
+
- If even threads are unavailable, plain sequential in-process analysis.
|
|
17
|
+
Every tier yields the identical (file_path, result, patterns, error) stream.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import multiprocessing
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import threading
|
|
27
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
|
28
|
+
from concurrent.futures.process import BrokenProcessPool
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from pickle import PicklingError
|
|
31
|
+
from typing import Iterator, List, Optional, Tuple
|
|
32
|
+
|
|
33
|
+
from adl_drift.utils.types import AnalysisResult, Pattern
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# Pool failures that mean "this parallelism tier is unavailable here", not
|
|
38
|
+
# "the scan is broken" (P2.9). RuntimeError: spawn bootstrap guard (unguarded
|
|
39
|
+
# host __main__ on Windows) or thread-creation failure. BrokenProcessPool:
|
|
40
|
+
# worker died. OSError: process/handle limits. PicklingError: unpicklable arg
|
|
41
|
+
# in a hostile embedding context.
|
|
42
|
+
_POOL_FAILURES = (RuntimeError, BrokenProcessPool, OSError, PicklingError)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Worker-process globals (one set per worker).
|
|
46
|
+
_PARSER = None
|
|
47
|
+
_EXTRACTOR = None
|
|
48
|
+
_REGISTRY = None
|
|
49
|
+
_BACKEND_NAME = None
|
|
50
|
+
|
|
51
|
+
# Thread-fallback components (one set per thread — tree-sitter Parser is not
|
|
52
|
+
# thread-safe, so threads must not share a parser instance).
|
|
53
|
+
_TLS = threading.local()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _in_spawned_child() -> bool:
|
|
57
|
+
"""True when running inside a multiprocessing child process.
|
|
58
|
+
|
|
59
|
+
Covers two cases (P2.9):
|
|
60
|
+
- ``_inheriting``: this process is mid-bootstrap, re-importing the host's
|
|
61
|
+
``__main__`` (the exact context Python's spawn guard protects).
|
|
62
|
+
- ``parent_process() is not None``: we are a worker — either Drift's own
|
|
63
|
+
or one belonging to whatever app embedded us. Nested pools multiply
|
|
64
|
+
processes; never start one from in here.
|
|
65
|
+
"""
|
|
66
|
+
if getattr(multiprocessing.current_process(), "_inheriting", False):
|
|
67
|
+
return True
|
|
68
|
+
return multiprocessing.parent_process() is not None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _make_components(backend_name: str = "python"):
|
|
72
|
+
"""Build a fresh (parser, extractor, registry) set."""
|
|
73
|
+
from adl_drift.detectors.base import DetectorRegistry
|
|
74
|
+
from adl_drift.lang.registry import get_backend
|
|
75
|
+
|
|
76
|
+
backend = get_backend(backend_name)
|
|
77
|
+
return backend.parser(), backend.extractor(), DetectorRegistry(backend.detectors())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _init_components(backend_name: str = "python") -> None:
|
|
81
|
+
global _PARSER, _EXTRACTOR, _REGISTRY, _BACKEND_NAME
|
|
82
|
+
_PARSER, _EXTRACTOR, _REGISTRY = _make_components(backend_name)
|
|
83
|
+
_BACKEND_NAME = backend_name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _init_worker(backend_name: str = "python") -> None:
|
|
87
|
+
# Workers must never write to the real stdout: under the MCP stdio
|
|
88
|
+
# transport it is the JSON-RPC channel, and one stray print() from a
|
|
89
|
+
# detector or import side-effect corrupts the protocol framing (P2.9).
|
|
90
|
+
sys.stdout = sys.stderr
|
|
91
|
+
_init_components(backend_name)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _analyze_one(
|
|
95
|
+
parser, extractor, registry, args: Tuple[str, Optional[str]]
|
|
96
|
+
) -> Tuple[str, Optional[AnalysisResult], List[Pattern], Optional[str]]:
|
|
97
|
+
file_path_str, category_filter = args
|
|
98
|
+
file_path = Path(file_path_str)
|
|
99
|
+
try:
|
|
100
|
+
pr = parser.parse_file(file_path)
|
|
101
|
+
patterns = registry.run_all(pr.tree, pr.source_bytes, file_path)
|
|
102
|
+
ar = extractor._build(pr, file_path)
|
|
103
|
+
if category_filter:
|
|
104
|
+
patterns = [p for p in patterns if p.category.name.lower() == category_filter.lower()]
|
|
105
|
+
return (file_path_str, ar, patterns, None)
|
|
106
|
+
except Exception as exc: # noqa: BLE001 — surfaced to caller
|
|
107
|
+
return (file_path_str, None, [], f"{type(exc).__name__}: {exc}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _scan_one(args: Tuple[str, Optional[str]]) -> Tuple[str, Optional[AnalysisResult], List[Pattern], Optional[str]]:
|
|
111
|
+
"""Process-worker entry: uses the per-process globals set by _init_worker."""
|
|
112
|
+
return _analyze_one(_PARSER, _EXTRACTOR, _REGISTRY, args)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scan_one_threaded(
|
|
116
|
+
args: Tuple[str, Optional[str], str]
|
|
117
|
+
) -> Tuple[str, Optional[AnalysisResult], List[Pattern], Optional[str]]:
|
|
118
|
+
"""Thread-fallback entry: per-thread components (parser is not thread-safe)."""
|
|
119
|
+
file_path, category_filter, backend_name = args
|
|
120
|
+
if getattr(_TLS, "backend_name", None) != backend_name:
|
|
121
|
+
_TLS.parser, _TLS.extractor, _TLS.registry = _make_components(backend_name)
|
|
122
|
+
_TLS.backend_name = backend_name
|
|
123
|
+
return _analyze_one(
|
|
124
|
+
_TLS.parser, _TLS.extractor, _TLS.registry, (file_path, category_filter)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _default_workers() -> int:
|
|
129
|
+
env = os.environ.get("DRIFT_PARALLEL_WORKERS")
|
|
130
|
+
if env:
|
|
131
|
+
try:
|
|
132
|
+
n = int(env)
|
|
133
|
+
if n >= 1:
|
|
134
|
+
return n
|
|
135
|
+
except ValueError:
|
|
136
|
+
pass
|
|
137
|
+
cpu = os.cpu_count() or 1
|
|
138
|
+
return min(cpu, 8)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Below this file count, parallel overhead (spawn + import) loses to sequential.
|
|
142
|
+
PARALLEL_THRESHOLD = 12
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def should_parallelize(file_count: int, max_workers: Optional[int] = None) -> bool:
|
|
146
|
+
if max_workers == 1:
|
|
147
|
+
return False
|
|
148
|
+
if os.environ.get("DRIFT_PARALLEL") == "0":
|
|
149
|
+
return False
|
|
150
|
+
if _in_spawned_child():
|
|
151
|
+
return False
|
|
152
|
+
return file_count >= PARALLEL_THRESHOLD
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def parallel_analyze(
|
|
156
|
+
files: List[Path],
|
|
157
|
+
backend_name: str = "python",
|
|
158
|
+
category_filter: Optional[str] = None,
|
|
159
|
+
max_workers: Optional[int] = None,
|
|
160
|
+
) -> Iterator[Tuple[Path, Optional[AnalysisResult], List[Pattern], Optional[str]]]:
|
|
161
|
+
"""Analyze files with worker-local components for ``backend_name``.
|
|
162
|
+
|
|
163
|
+
Yields (file_path, AnalysisResult|None, patterns, error).
|
|
164
|
+
|
|
165
|
+
P2.9 degradation ladder — every tier produces the identical stream:
|
|
166
|
+
1. Process pool (default). Skipped entirely inside spawned children.
|
|
167
|
+
2. Thread pool, if the process pool cannot start or dies mid-run. A
|
|
168
|
+
``done`` set ensures files already yielded are not re-analyzed.
|
|
169
|
+
3. Sequential, if even the thread pool is unavailable.
|
|
170
|
+
"""
|
|
171
|
+
if not files:
|
|
172
|
+
return
|
|
173
|
+
workers = max_workers or _default_workers()
|
|
174
|
+
args = [(str(f), category_filter) for f in files]
|
|
175
|
+
done: set = set()
|
|
176
|
+
|
|
177
|
+
# Tier 1: processes. Never from inside a spawned child — creating a pool
|
|
178
|
+
# there either trips Python's spawn-bootstrap guard or nests pools.
|
|
179
|
+
if not _in_spawned_child():
|
|
180
|
+
try:
|
|
181
|
+
with ProcessPoolExecutor(
|
|
182
|
+
max_workers=workers,
|
|
183
|
+
initializer=_init_worker,
|
|
184
|
+
initargs=(backend_name,),
|
|
185
|
+
) as pool:
|
|
186
|
+
# chunksize tuned so each worker gets a healthy batch but no straggler dominates.
|
|
187
|
+
chunksize = max(1, len(args) // (workers * 4) or 1)
|
|
188
|
+
for file_path_str, ar, patterns, err in pool.map(_scan_one, args, chunksize=chunksize):
|
|
189
|
+
done.add(file_path_str)
|
|
190
|
+
yield (Path(file_path_str), ar, patterns, err)
|
|
191
|
+
return
|
|
192
|
+
except _POOL_FAILURES as exc:
|
|
193
|
+
logger.warning(
|
|
194
|
+
"Process pool unavailable (%s: %s) — falling back to threads "
|
|
195
|
+
"for %d remaining file(s).",
|
|
196
|
+
type(exc).__name__, exc, len(args) - len(done),
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
logger.debug("In a spawned child process — skipping process pool.")
|
|
200
|
+
|
|
201
|
+
# Tier 2: threads. Parsing releases the GIL, so this still parallelizes
|
|
202
|
+
# the heavy part. Per-thread components via _TLS.
|
|
203
|
+
pending = [(a[0], a[1], backend_name) for a in args if a[0] not in done]
|
|
204
|
+
try:
|
|
205
|
+
with ThreadPoolExecutor(max_workers=min(workers, 4)) as tpool:
|
|
206
|
+
for file_path_str, ar, patterns, err in tpool.map(_scan_one_threaded, pending):
|
|
207
|
+
done.add(file_path_str)
|
|
208
|
+
yield (Path(file_path_str), ar, patterns, err)
|
|
209
|
+
return
|
|
210
|
+
except _POOL_FAILURES as exc:
|
|
211
|
+
logger.warning(
|
|
212
|
+
"Thread pool unavailable (%s: %s) — finishing sequentially "
|
|
213
|
+
"for %d remaining file(s).",
|
|
214
|
+
type(exc).__name__, exc, len(args) - len(done),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Tier 3: sequential in this process (no stdout swap — we may be main).
|
|
218
|
+
if _PARSER is None or _BACKEND_NAME != backend_name:
|
|
219
|
+
_init_components(backend_name)
|
|
220
|
+
for arg in args:
|
|
221
|
+
if arg[0] in done:
|
|
222
|
+
continue
|
|
223
|
+
file_path_str, ar, patterns, err = _analyze_one(_PARSER, _EXTRACTOR, _REGISTRY, arg)
|
|
224
|
+
yield (Path(file_path_str), ar, patterns, err)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP server implementation."""
|