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,326 @@
1
+ """Literal-template expander for exec/eval calls (P2.16).
2
+
3
+ Strictly literal-only evaluation — any name lookup outside the provided
4
+ environment aborts the expansion. Never executes user code; only folds
5
+ string constants and unrolls literal for-loops.
6
+
7
+ Public API
8
+ ----------
9
+ eval_literal_str(node, source, env=None) -> Optional[str]
10
+ Try to evaluate a CST string/concat node to its Python str value.
11
+
12
+ find_exec_expansions(root_node, source) -> List[str]
13
+ Walk a module root and return all expandable exec/eval source strings.
14
+ Handles two patterns:
15
+ 1. Direct literal: exec("def foo(): pass")
16
+ 2. For-loop unroll: for n in ("a","b"): exec(f"def get_{n}(): ...")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import ast
22
+ import warnings
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ from tree_sitter import Node
26
+
27
+
28
+ def eval_literal_str(
29
+ node: Node,
30
+ source: bytes,
31
+ env: Optional[Dict[str, Any]] = None,
32
+ ) -> Optional[str]:
33
+ """Try to evaluate a CST string/concat/binary-concat node to a str.
34
+
35
+ Returns the string value, or None if any non-literal sub-expression is
36
+ encountered.
37
+ """
38
+ return _eval_str_node(node, source, env or {})
39
+
40
+
41
+ def find_exec_expansions(root_node: Node, source: bytes) -> List[str]:
42
+ """Walk a module root and return all expandable exec/eval source strings.
43
+
44
+ Non-literal inputs are silently skipped — only fully-recoverable
45
+ templates contribute to the result.
46
+ """
47
+ results: List[str] = []
48
+ _walk_for_execs(root_node, source, {}, results)
49
+ return results
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # String evaluation
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _eval_str_node(node: Node, source: bytes, env: Dict[str, Any]) -> Optional[str]:
57
+ t = node.type
58
+
59
+ if t == "string":
60
+ return _eval_single_string(node, source, env)
61
+
62
+ if t == "concatenated_string":
63
+ # Adjacent string literals: "a" "b" -> "ab"
64
+ parts: List[str] = []
65
+ for child in node.children:
66
+ if child.type == "string":
67
+ val = _eval_single_string(child, source, env)
68
+ if val is None:
69
+ return None
70
+ parts.append(val)
71
+ return "".join(parts)
72
+
73
+ if t == "binary_operator":
74
+ # "a" + "b" string concatenation via + operator
75
+ left = node.child_by_field_name("left")
76
+ right = node.child_by_field_name("right")
77
+ if left is None or right is None:
78
+ return None
79
+ # Check operator is +
80
+ has_plus = any(
81
+ child.type == "+"
82
+ or (child.type not in (left.type, right.type, "comment")
83
+ and source[child.start_byte:child.end_byte] == b"+")
84
+ for child in node.children
85
+ if child.id != left.id and child.id != right.id
86
+ )
87
+ # Simpler: just check raw operator text
88
+ op_raw = source[left.end_byte:right.start_byte].decode("utf-8", errors="replace").strip()
89
+ if "+" not in op_raw:
90
+ return None
91
+ lv = _eval_str_node(left, source, env)
92
+ rv = _eval_str_node(right, source, env)
93
+ if lv is None or rv is None:
94
+ return None
95
+ return lv + rv
96
+
97
+ if t == "identifier":
98
+ name = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
99
+ val = env.get(name)
100
+ return str(val) if val is not None else None
101
+
102
+ return None
103
+
104
+
105
+ def _eval_single_string(node: Node, source: bytes, env: Dict[str, Any]) -> Optional[str]:
106
+ """Evaluate one `string` CST node."""
107
+ # Detect f-string by presence of interpolation children
108
+ is_fstring = any(c.type == "interpolation" for c in node.children)
109
+
110
+ if not is_fstring:
111
+ # Plain string — delegate to ast.literal_eval (handles escapes, quotes)
112
+ raw = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
113
+ try:
114
+ with warnings.catch_warnings():
115
+ warnings.simplefilter("ignore", SyntaxWarning)
116
+ val = ast.literal_eval(raw)
117
+ return val if isinstance(val, str) else None
118
+ except (ValueError, SyntaxError):
119
+ return None
120
+
121
+ # F-string — walk CST children, substituting env vars
122
+ return _eval_fstring(node, source, env)
123
+
124
+
125
+ def _eval_fstring(node: Node, source: bytes, env: Dict[str, Any]) -> Optional[str]:
126
+ """Reconstruct an f-string value from its CST children using env."""
127
+ parts: List[str] = []
128
+ for child in node.children:
129
+ ct = child.type
130
+ if ct in ("string_start", "string_end"):
131
+ continue
132
+ if ct == "string_content":
133
+ parts.append(source[child.start_byte:child.end_byte].decode("utf-8", errors="replace"))
134
+ elif ct == "escape_sequence":
135
+ raw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
136
+ try:
137
+ # Decode standard Python escape sequences
138
+ decoded = raw.encode("raw_unicode_escape").decode("unicode_escape")
139
+ parts.append(decoded)
140
+ except Exception:
141
+ parts.append(raw)
142
+ elif ct == "interpolation":
143
+ val = _eval_interpolation(child, source, env)
144
+ if val is None:
145
+ return None
146
+ parts.append(str(val))
147
+ else:
148
+ # Unknown structure (format spec, conversion, nested f-string) → abort
149
+ return None
150
+ return "".join(parts)
151
+
152
+
153
+ def _eval_interpolation(node: Node, source: bytes, env: Dict[str, Any]) -> Optional[Any]:
154
+ """Evaluate a single {expr} interpolation."""
155
+ for child in node.children:
156
+ ct = child.type
157
+ if ct in ("{", "}", "!", "format_spec", "type_conversion"):
158
+ continue
159
+ # The expression inside the braces
160
+ return _eval_expr(child, source, env)
161
+ return None
162
+
163
+
164
+ def _eval_expr(node: Node, source: bytes, env: Dict[str, Any]) -> Optional[Any]:
165
+ """Evaluate a simple expression to a Python value for f-string substitution."""
166
+ t = node.type
167
+ if t == "identifier":
168
+ name = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
169
+ return env.get(name) # None if unknown name → abort
170
+
171
+ if t in ("string", "concatenated_string"):
172
+ return _eval_str_node(node, source, env)
173
+
174
+ if t == "integer":
175
+ raw = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
176
+ try:
177
+ return int(raw, 0)
178
+ except ValueError:
179
+ return None
180
+
181
+ if t == "float":
182
+ raw = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
183
+ try:
184
+ return float(raw)
185
+ except ValueError:
186
+ return None
187
+
188
+ # Any other expression type (call, subscript, attribute, etc.) → abort
189
+ return None
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Iterable evaluation (for for-loop unrolling)
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def _eval_iterable(node: Node, source: bytes) -> Optional[List[Any]]:
197
+ """Evaluate a literal tuple or list to a Python list of values."""
198
+ if node.type not in ("tuple", "list"):
199
+ return None
200
+ items: List[Any] = []
201
+ for child in node.children:
202
+ if child.type in (",", "(", ")", "[", "]"):
203
+ continue
204
+ val = _eval_elem(child, source)
205
+ if val is None:
206
+ return None
207
+ items.append(val)
208
+ return items if items else None
209
+
210
+
211
+ def _eval_elem(node: Node, source: bytes) -> Optional[Any]:
212
+ """Evaluate a single tuple/list element (no env — pure literal)."""
213
+ t = node.type
214
+ if t in ("string", "concatenated_string"):
215
+ return _eval_str_node(node, source, {})
216
+ if t == "integer":
217
+ raw = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
218
+ try:
219
+ return int(raw, 0)
220
+ except ValueError:
221
+ return None
222
+ if t == "float":
223
+ raw = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
224
+ try:
225
+ return float(raw)
226
+ except ValueError:
227
+ return None
228
+ # Nested tuple/list not supported (single-level only per P2.16 spec)
229
+ return None
230
+
231
+
232
+ # ---------------------------------------------------------------------------
233
+ # Tree-walking exec/eval finder
234
+ # ---------------------------------------------------------------------------
235
+
236
+ def _walk_for_execs(
237
+ node: Node,
238
+ source: bytes,
239
+ env: Dict[str, Any],
240
+ results: List[str],
241
+ ) -> None:
242
+ """Recursively walk CST, collecting expandable exec/eval strings."""
243
+ t = node.type
244
+
245
+ if t == "for_statement":
246
+ _handle_for_statement(node, source, env, results)
247
+ return # children already visited inside
248
+
249
+ if t == "call":
250
+ _handle_exec_call(node, source, env, results)
251
+ # Don't return — there may be other calls in siblings
252
+
253
+ for child in node.children:
254
+ _walk_for_execs(child, source, env, results)
255
+
256
+
257
+ def _handle_for_statement(
258
+ node: Node,
259
+ source: bytes,
260
+ env: Dict[str, Any],
261
+ results: List[str],
262
+ ) -> None:
263
+ """Unroll: for var in (lit1, lit2, ...): body_with_exec"""
264
+ left = node.child_by_field_name("left")
265
+ right = node.child_by_field_name("right")
266
+ body = node.child_by_field_name("body")
267
+
268
+ if left is None or right is None or body is None:
269
+ return
270
+
271
+ # Only handle simple identifier loop variables (no tuple unpacking)
272
+ if left.type != "identifier":
273
+ return
274
+
275
+ var_name = source[left.start_byte:left.end_byte].decode("utf-8", errors="replace")
276
+
277
+ iterable = _eval_iterable(right, source)
278
+ if not iterable:
279
+ return
280
+
281
+ for val in iterable:
282
+ new_env = dict(env)
283
+ new_env[var_name] = val
284
+ _walk_for_execs(body, source, new_env, results)
285
+
286
+
287
+ def _handle_exec_call(
288
+ node: Node,
289
+ source: bytes,
290
+ env: Dict[str, Any],
291
+ results: List[str],
292
+ ) -> None:
293
+ """Expand: exec("literal") or eval("literal") → collect source string."""
294
+ func = node.child_by_field_name("function")
295
+ if func is None:
296
+ return
297
+
298
+ func_name = source[func.start_byte:func.end_byte].decode("utf-8", errors="replace").strip()
299
+ if func_name not in ("exec", "eval"):
300
+ return
301
+
302
+ args = node.child_by_field_name("arguments")
303
+ if args is None:
304
+ return
305
+
306
+ code_arg = _nth_positional_arg(args, 0)
307
+ if code_arg is None:
308
+ return
309
+
310
+ code_str = eval_literal_str(code_arg, source, env)
311
+ if code_str and code_str.strip():
312
+ results.append(code_str)
313
+
314
+
315
+ def _nth_positional_arg(args_node: Node, n: int) -> Optional[Node]:
316
+ """Return the n-th positional argument, skipping *args/**kwargs/keywords."""
317
+ pos = 0
318
+ for child in args_node.children:
319
+ if child.type in (",", "(", ")"):
320
+ continue
321
+ if child.type in ("keyword_argument", "dictionary_splat_argument", "list_splat_argument"):
322
+ continue
323
+ if pos == n:
324
+ return child
325
+ pos += 1
326
+ return None
@@ -0,0 +1 @@
1
+ """Scanner modules for incremental and full project scans."""
@@ -0,0 +1,338 @@
1
+ """Backend-owned source discovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Iterable, NamedTuple
9
+
10
+ from adl_drift.lang.base import LanguageBackend
11
+ from adl_drift.lang.registry import backend_for_file, detect_backends, get_backend
12
+ from adl_drift.utils.fswalk import iter_tree
13
+
14
+
15
+ EXCLUDED_DIRS = {
16
+ ".venv", "venv", ".env", "env", "__pycache__", ".git", ".gradle",
17
+ ".idea", ".tox", ".mypy_cache", ".pytest_cache", "node_modules",
18
+ ".eggs", "site-packages",
19
+ }
20
+ BUILD_OUTPUT_DIRS = {"target", "build", "out", "dist"}
21
+ GENERATED_DIRS = {"generated", "generated-sources", "generated-test-sources"}
22
+
23
+
24
+ def select_backend(
25
+ path: Path, candidates: Iterable[LanguageBackend] | None = None
26
+ ) -> LanguageBackend:
27
+ """Compatibility helper for callers that require one backend.
28
+
29
+ ``candidates`` is a ``detect_backends(path)`` result the caller already
30
+ has, so the project tree is not walked again for the same answer.
31
+ """
32
+ path = Path(path)
33
+ if path.is_file():
34
+ return backend_for_file(path) or get_backend("python")
35
+ candidates = detect_backends(path) if candidates is None else list(candidates)
36
+ with_sources = [
37
+ backend for backend in candidates
38
+ if any(any(path.rglob(f"*{ext}")) for ext in backend.file_extensions)
39
+ ]
40
+ return (with_sources or candidates or [get_backend("python")])[0]
41
+
42
+
43
+ def discover_file_groups(
44
+ path: Path,
45
+ *,
46
+ include_generated: bool | None = None,
47
+ backends: Iterable[LanguageBackend] | None = None,
48
+ configs: dict[str, object] | None = None,
49
+ ) -> dict[str, list[Path]]:
50
+ """Discover every supported source and group paths by backend name.
51
+
52
+ ``backends`` (a ``detect_backends(path)`` result) and ``configs``
53
+ (``backend name -> backend.project_config(path)``) let a caller that
54
+ already computed them for this same ``path`` skip the tree walk and the
55
+ build-file parse they cost; both must have been computed for ``path``.
56
+ """
57
+ path = Path(path)
58
+ if path.is_file():
59
+ backend = backend_for_file(path)
60
+ return {backend.name: [path]} if backend else {}
61
+ configs = configs or {}
62
+ return {
63
+ backend.name: files
64
+ for backend in (detect_backends(path) if backends is None else backends)
65
+ if (files := discover_files(
66
+ path, backend, include_generated=include_generated,
67
+ config=configs.get(backend.name),
68
+ ))
69
+ }
70
+
71
+
72
+ class _DiscoveryEntry(NamedTuple):
73
+ taken_at: float
74
+ include_generated: bool
75
+ dir_mtimes: dict[str, int]
76
+ build_file_stats: dict[str, tuple[int, int]]
77
+ groups: dict[str, list[Path]]
78
+
79
+
80
+ class CachedDiscovery:
81
+ """``discover_file_groups(path)`` memo for a long-lived process, validated
82
+ by directory mtimes and build-file stats instead of re-walking the tree.
83
+
84
+ Creating, deleting, renaming or re-linking any file or directory updates
85
+ the mtime of the directory holding it, so re-stat()ing every directory
86
+ the previous discovery could have visited proves the walks would list
87
+ the same entries. Content edits never touch a directory mtime and are
88
+ invisible here by design — the graph's per-file stat fingerprint
89
+ (``CortexMemory._source_state``) catches those. The one place discovery
90
+ reads file *contents* is the build files a backend's ``project_config``
91
+ parses (source roots, modules); those are re-validated by their own
92
+ ``(st_mtime_ns, st_size)``. Same racy-window rule as
93
+ MCP_GRAPH_CACHE_PLAN.md Lớp 1: a directory or build file modified within
94
+ ``racy_window`` seconds of the snapshot is never trusted.
95
+ """
96
+
97
+ def __init__(self, path: Path, *, racy_window: float) -> None:
98
+ self.path = Path(path)
99
+ self.racy_window = racy_window
100
+ self._entry: _DiscoveryEntry | None = None
101
+
102
+ @staticmethod
103
+ def _include_generated() -> bool:
104
+ return os.environ.get("DRIFT_INCLUDE_GENERATED") == "1"
105
+
106
+ def groups(self) -> dict[str, list[Path]]:
107
+ entry = self._entry
108
+ if entry is not None and self._still_valid(entry):
109
+ return {name: list(files) for name, files in entry.groups.items()}
110
+ if self.path.is_file():
111
+ return discover_file_groups(self.path)
112
+ self._entry = entry = self._discover()
113
+ return {name: list(files) for name, files in entry.groups.items()}
114
+
115
+ def _discover(self) -> _DiscoveryEntry:
116
+ taken_at = time.time()
117
+ include_generated = self._include_generated()
118
+ backends = detect_backends(self.path)
119
+ configs = {backend.name: backend.project_config(self.path) for backend in backends}
120
+ groups = discover_file_groups(
121
+ self.path, include_generated=include_generated,
122
+ backends=backends, configs=configs,
123
+ )
124
+ # Every walk starts at ``path`` or at a config's project root (which
125
+ # may lie above ``path``); track the outermost of those trees.
126
+ roots = {self.path.resolve()}
127
+ build_file_stats: dict[str, tuple[int, int]] = {}
128
+ for config in configs.values():
129
+ project_path = getattr(config, "project_path", None)
130
+ if project_path is not None:
131
+ roots.add(Path(project_path).resolve())
132
+ build_file_stats.update(getattr(config, "build_file_stats", {}) or {})
133
+ outermost = [
134
+ root for root in roots
135
+ if not any(other != root and _under(root, {other}) for other in roots)
136
+ ]
137
+ dir_mtimes: dict[str, int] = {}
138
+ for root in outermost:
139
+ try:
140
+ dir_mtimes[str(root)] = os.stat(root).st_mtime_ns
141
+ except OSError:
142
+ continue
143
+ for _dirpath, dirs, _others in iter_tree(str(root)):
144
+ for entry in dirs:
145
+ try:
146
+ dir_mtimes[entry.path] = entry.stat(follow_symlinks=False).st_mtime_ns
147
+ except OSError:
148
+ continue
149
+ return _DiscoveryEntry(taken_at, include_generated, dir_mtimes, build_file_stats, groups)
150
+
151
+ def _still_valid(self, entry: _DiscoveryEntry) -> bool:
152
+ if entry.include_generated != self._include_generated():
153
+ return False
154
+ racy_before = (entry.taken_at - self.racy_window) * 1e9
155
+ try:
156
+ for dirpath, mtime in entry.dir_mtimes.items():
157
+ if mtime >= racy_before or os.stat(dirpath).st_mtime_ns != mtime:
158
+ return False
159
+ for filepath, (mtime, size) in entry.build_file_stats.items():
160
+ stat = os.stat(filepath)
161
+ if mtime >= racy_before or (stat.st_mtime_ns, stat.st_size) != (mtime, size):
162
+ return False
163
+ except OSError:
164
+ return False
165
+ return True
166
+
167
+
168
+ # ``Path.relative_to`` compares case-insensitively on Windows; normalise both
169
+ # sides the same way so the string check keeps that behaviour (identity on
170
+ # POSIX). Module attribute so tests can simulate the Windows rule.
171
+ _normcase = os.path.normcase
172
+
173
+
174
+ def _root_prefixes(roots: Iterable[Path]) -> tuple[tuple[str, str], ...]:
175
+ """Precompute (exact, prefix) string forms for containment checks.
176
+
177
+ ``Path.relative_to`` re-parses both paths on every call; a corpus with
178
+ many build modules multiplies that by every output root per candidate
179
+ file. Comparing resolved path strings is equivalent for the resolved
180
+ inputs used here and avoids the pathlib overhead.
181
+ """
182
+ prefixes = []
183
+ for root in roots:
184
+ text = _normcase(str(root))
185
+ prefixes.append((text, text if text.endswith(os.sep) else text + os.sep))
186
+ return tuple(prefixes)
187
+
188
+
189
+ def _under_prefixes(path_text: str, prefixes: tuple[tuple[str, str], ...]) -> bool:
190
+ path_text = _normcase(path_text)
191
+ for exact, prefix in prefixes:
192
+ if path_text == exact or path_text.startswith(prefix):
193
+ return True
194
+ return False
195
+
196
+
197
+ def _under(path: Path, roots: set[Path]) -> bool:
198
+ return _under_prefixes(str(path), _root_prefixes(roots))
199
+
200
+
201
+ # Kept under the old private name for tests; the walker itself lives in
202
+ # utils so lang/registry.py can share it without importing the scanner.
203
+ _iter_tree = iter_tree
204
+
205
+
206
+ def _project_dirs(project_root: Path, markers: Iterable[str]) -> set[Path]:
207
+ """Directories under ``project_root`` holding a project marker.
208
+
209
+ One walk for every marker at once instead of one full ``rglob`` per
210
+ marker (Java has five). ``rglob(marker)`` matched directory entries
211
+ too, so directory names are checked as well. ``project_root`` is
212
+ already resolved and the walk never descends symlinked directories, so
213
+ ``dirpath`` is the resolved parent the old ``marker_path.parent.resolve()``
214
+ produced.
215
+ """
216
+ marker_names = set(markers)
217
+ # Gradle permits ``project.buildFileName`` to point at an arbitrary
218
+ # ``*.gradle``/``*.gradle.kts`` file. Spring projects commonly discover
219
+ # those files dynamically from ``settings.gradle`` (rather than spelling
220
+ # every project in a static ``include(...)`` call), so exact-name matching
221
+ # alone collapses all such projects into the repository-root namespace.
222
+ # Enable the suffix match only for backends that advertised Gradle's
223
+ # conventional build markers; other language backends keep exact matching.
224
+ accepts_custom_gradle = bool(
225
+ marker_names & {"build.gradle", "build.gradle.kts"}
226
+ )
227
+ dirs = {project_root}
228
+ if not marker_names:
229
+ return dirs
230
+ for dirpath, subdirs, others in iter_tree(str(project_root)):
231
+ if any(entry.name in marker_names for entry in subdirs) or any(
232
+ entry.name in marker_names
233
+ or (
234
+ accepts_custom_gradle
235
+ and entry.name not in {"settings.gradle", "settings.gradle.kts"}
236
+ and entry.name.endswith((".gradle", ".gradle.kts"))
237
+ )
238
+ for entry in others
239
+ ):
240
+ dirs.add(Path(dirpath))
241
+ return dirs
242
+
243
+
244
+ def discover_files(
245
+ path: Path,
246
+ backend: LanguageBackend,
247
+ *,
248
+ include_generated: bool | None = None,
249
+ config: object | None = None,
250
+ ) -> list[Path]:
251
+ """Find backend sources under configured roots, excluding build output.
252
+
253
+ ``config`` is ``backend.project_config(path)`` when the caller already
254
+ holds it (see ``discover_file_groups``); otherwise it is computed here.
255
+
256
+ MCP_GRAPH_CACHE_PLAN.md Lớp 2: one walk per (outermost) scan root with
257
+ ``EXCLUDED_DIRS``/build-output subtrees pruned as they are met, and no
258
+ ``Path.resolve()`` per candidate — the scan roots are resolved once and
259
+ the walk never enters a symlinked directory, so a candidate's resolved
260
+ path differs from the candidate only when the file itself is a symlink.
261
+ The result set is the one the previous per-marker/per-extension
262
+ ``rglob`` implementation produced, except that a *directory* whose name
263
+ ends in a source extension is no longer reported as a source file.
264
+ """
265
+ path = Path(path)
266
+ if path.is_file():
267
+ return [path] if path.suffix in backend.file_extensions else []
268
+
269
+ if include_generated is None:
270
+ include_generated = os.environ.get("DRIFT_INCLUDE_GENERATED") == "1"
271
+
272
+ if config is None:
273
+ config = backend.project_config(path)
274
+ roots: Iterable[Path] = getattr(config, "source_roots", ()) or (path,)
275
+ found: set[Path] = set()
276
+ root = path.resolve()
277
+ project_root = Path(getattr(config, "project_path", root)).resolve()
278
+ output_roots = {
279
+ project_dir / name
280
+ for project_dir in _project_dirs(project_root, backend.project_markers)
281
+ for name in BUILD_OUTPUT_DIRS
282
+ }
283
+ output_prefixes = _root_prefixes(output_roots)
284
+
285
+ scan_roots: set[Path] = set()
286
+ for source_root in roots:
287
+ source_root = Path(source_root).resolve()
288
+ if not source_root.is_dir() or not _under(source_root, {project_root}):
289
+ continue
290
+ if _under(source_root, {root}):
291
+ scan_root = source_root
292
+ elif _under(root, {source_root}):
293
+ scan_root = root
294
+ else:
295
+ continue
296
+ if not include_generated and any(
297
+ part.lower() in GENERATED_DIRS
298
+ for part in source_root.relative_to(project_root).parts
299
+ ):
300
+ continue
301
+ scan_roots.add(scan_root)
302
+ # A scan root nested inside another is reached by the outer walk; the
303
+ # per-file checks below are identical either way, so walk it once.
304
+ outermost = [
305
+ scan_root for scan_root in scan_roots
306
+ if not any(other != scan_root and _under(scan_root, {other}) for other in scan_roots)
307
+ ]
308
+
309
+ extensions = tuple(backend.file_extensions)
310
+ for scan_root in outermost:
311
+ for _dirpath, dirs, others in _iter_tree(str(scan_root)):
312
+ # Prune what the per-file checks would reject anyway: every path
313
+ # below carries the excluded part / lies under the output root.
314
+ dirs[:] = [
315
+ entry for entry in dirs
316
+ if entry.name not in EXCLUDED_DIRS
317
+ and not _under_prefixes(entry.path, output_prefixes)
318
+ ]
319
+ for entry in others:
320
+ if not entry.name.endswith(extensions):
321
+ continue
322
+ candidate = Path(entry.path)
323
+ try:
324
+ if entry.is_symlink():
325
+ if entry.is_dir():
326
+ continue # symlink to a directory is not a source file
327
+ resolved = candidate.resolve()
328
+ else:
329
+ resolved = candidate
330
+ parts = resolved.relative_to(root).parts[:-1]
331
+ except (OSError, ValueError):
332
+ continue
333
+ if any(part in EXCLUDED_DIRS for part in parts):
334
+ continue
335
+ if _under_prefixes(str(resolved), output_prefixes):
336
+ continue
337
+ found.add(candidate)
338
+ return sorted(found, key=str)