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,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."""