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,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)
|