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,707 @@
|
|
|
1
|
+
"""Bounded Maven/Gradle project and framework detection (P4.7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from defusedxml import ElementTree as DefusedET
|
|
12
|
+
from defusedxml.common import DefusedXmlException
|
|
13
|
+
|
|
14
|
+
from adl_drift.utils.config import find_project_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
FRAMEWORK_MARKERS = {
|
|
18
|
+
"spring_boot": ("org.springframework.boot", "spring-boot"),
|
|
19
|
+
"spring_mvc": ("spring-webmvc", "spring-boot-starter-web"),
|
|
20
|
+
"spring_data": ("spring-data", "spring-boot-starter-data"),
|
|
21
|
+
"jpa": ("jakarta.persistence", "javax.persistence", "data-jpa"),
|
|
22
|
+
"hibernate": ("org.hibernate", "hibernate-core"),
|
|
23
|
+
"junit": ("junit",),
|
|
24
|
+
"testng": ("testng",),
|
|
25
|
+
"lombok": ("lombok",),
|
|
26
|
+
"jackson": ("com.fasterxml.jackson", "jackson-"),
|
|
27
|
+
"mybatis": ("mybatis",),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_GRADLE_DEPENDENCY = re.compile(
|
|
31
|
+
r"^\s*(?:[\w.]*(?:implementation|api|compileOnly|runtimeOnly|"
|
|
32
|
+
r"annotationProcessor|kapt|classpath))\s*(?:\(\s*)?(?:platform\(\s*)?"
|
|
33
|
+
r"[\"']([^\"']+:[^\"']+(?::[^\"']+)*)[\"']",
|
|
34
|
+
re.MULTILINE | re.IGNORECASE,
|
|
35
|
+
)
|
|
36
|
+
_GRADLE_CONSTRAINTS_BLOCK = re.compile(r"\bconstraints\s*\{")
|
|
37
|
+
# ponytail: common static Gradle forms only; use the Tooling API if dynamic DSL
|
|
38
|
+
# projects become a measured source of misses.
|
|
39
|
+
_GRADLE_PLUGIN = re.compile(r"\bid\s*(?:\(\s*)?[\"']([^\"']+)[\"']")
|
|
40
|
+
_GRADLE_GROUP = re.compile(r"(?m)^\s*group\s*=\s*[\"']([^\"']+)[\"']")
|
|
41
|
+
_GRADLE_ROOT_NAME = re.compile(
|
|
42
|
+
r"(?m)^\s*rootProject\.name\s*=\s*[\"']([^\"']+)[\"']"
|
|
43
|
+
)
|
|
44
|
+
_GRADLE_INCLUDE = re.compile(r"(?m)^\s*include\b\s*(?:\(([^\n]*)\)|([^\n]*))")
|
|
45
|
+
_GRADLE_SOURCE_LINE = re.compile(
|
|
46
|
+
r"(?m)^.*(?:srcDir|srcDirs|setSrcDirs)\s*(?:=|\()?([^\n]*)$"
|
|
47
|
+
)
|
|
48
|
+
_QUOTED = re.compile(r"[\"']([^\"']+)[\"']")
|
|
49
|
+
# A Windows drive-letter (`C:\...`/`C:/...`) or POSIX (`/...`) absolute path,
|
|
50
|
+
# as opposed to Gradle's own `:`-delimited project-path notation
|
|
51
|
+
# (`:app:sub`) — the two must not be conflated (see `_read_gradle` below).
|
|
52
|
+
_ABS_PATH = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/])")
|
|
53
|
+
DEFAULT_MAX_BUILD_FILE_BYTES = 1024 * 1024
|
|
54
|
+
DEFAULT_MAX_BUILD_TOTAL_BYTES = 16 * 1024 * 1024
|
|
55
|
+
_MAX_MAVEN_POMS = 128
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _blank_gradle_token(token: str) -> str:
|
|
59
|
+
return "".join(ch if ch == "\n" else " " for ch in token)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _mask_gradle_tokens(text: str, *, mask_strings: bool) -> str:
|
|
63
|
+
"""Blank Gradle comments, and optionally strings, in one linear pass."""
|
|
64
|
+
out: list[str] = []
|
|
65
|
+
i = 0
|
|
66
|
+
length = len(text)
|
|
67
|
+
while i < length:
|
|
68
|
+
ch = text[i]
|
|
69
|
+
next_ch = text[i + 1] if i + 1 < length else ""
|
|
70
|
+
if ch == "/" and next_ch == "/":
|
|
71
|
+
start = i
|
|
72
|
+
i += 2
|
|
73
|
+
while i < length and text[i] != "\n":
|
|
74
|
+
i += 1
|
|
75
|
+
out.append(_blank_gradle_token(text[start:i]))
|
|
76
|
+
continue
|
|
77
|
+
if ch == "/" and next_ch == "*":
|
|
78
|
+
start = i
|
|
79
|
+
i += 2
|
|
80
|
+
while i + 1 < length and not (text[i] == "*" and text[i + 1] == "/"):
|
|
81
|
+
i += 1
|
|
82
|
+
i = i + 2 if i + 1 < length else length
|
|
83
|
+
out.append(_blank_gradle_token(text[start:i]))
|
|
84
|
+
continue
|
|
85
|
+
if ch in "\"'":
|
|
86
|
+
quote = ch
|
|
87
|
+
triple = text.startswith(quote * 3, i)
|
|
88
|
+
start = i
|
|
89
|
+
i += 3 if triple else 1
|
|
90
|
+
if triple:
|
|
91
|
+
while i + 2 < length and not text.startswith(quote * 3, i):
|
|
92
|
+
i += 1
|
|
93
|
+
i = i + 3 if i + 2 < length else length
|
|
94
|
+
else:
|
|
95
|
+
escaped = False
|
|
96
|
+
while i < length:
|
|
97
|
+
current = text[i]
|
|
98
|
+
i += 1
|
|
99
|
+
if escaped:
|
|
100
|
+
escaped = False
|
|
101
|
+
elif current == "\\":
|
|
102
|
+
escaped = True
|
|
103
|
+
elif current == quote:
|
|
104
|
+
break
|
|
105
|
+
token = text[start:i]
|
|
106
|
+
out.append(_blank_gradle_token(token) if mask_strings else token)
|
|
107
|
+
continue
|
|
108
|
+
out.append(ch)
|
|
109
|
+
i += 1
|
|
110
|
+
return "".join(out)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mask_gradle_noise(text: str) -> str:
|
|
114
|
+
"""Blank out comments and string literals, preserving length and newlines.
|
|
115
|
+
|
|
116
|
+
Brace-depth counting must only see structural Groovy/Kotlin syntax —
|
|
117
|
+
otherwise a stray ``constraints {`` (or an unmatched brace) inside a
|
|
118
|
+
comment or string literal desyncs the depth counter and swallows real
|
|
119
|
+
code that follows it.
|
|
120
|
+
"""
|
|
121
|
+
return _mask_gradle_tokens(text, mask_strings=True)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _mask_gradle_comments(text: str) -> str:
|
|
125
|
+
"""Blank out ``//``/``/* */`` comments only, keeping string content intact.
|
|
126
|
+
|
|
127
|
+
Every non-brace-counting extraction (dependency/plugin/group/source-root
|
|
128
|
+
regexes) must run on this, not raw source: those regexes have no notion
|
|
129
|
+
of "inside a comment", so a commented-out ``implementation '...'`` line
|
|
130
|
+
(very common when a dependency is disabled) or even a same-line ``//
|
|
131
|
+
id '...'`` reads as a real declaration otherwise. Reuses the combined
|
|
132
|
+
comment-or-string tokenizer so a ``//`` embedded in a string (e.g. a URL
|
|
133
|
+
coordinate) is correctly kept as string content, not treated as a
|
|
134
|
+
comment start.
|
|
135
|
+
"""
|
|
136
|
+
return _mask_gradle_tokens(text, mask_strings=False)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _strip_gradle_constraints(text: str) -> tuple[str, bool]:
|
|
140
|
+
"""Drop ``constraints { ... }`` bodies before dependency extraction.
|
|
141
|
+
|
|
142
|
+
A `constraints` block only pins versions for the Gradle version-alignment
|
|
143
|
+
engine (Gradle's equivalent of Maven `dependencyManagement`) — it does not
|
|
144
|
+
declare an actual dependency. Without this, a `dependencies { constraints
|
|
145
|
+
{ implementation(...) } }` block would be indistinguishable from a real
|
|
146
|
+
`dependencies { implementation(...) }` declaration.
|
|
147
|
+
|
|
148
|
+
Returns the cleaned text and whether an unbalanced ``constraints {`` was
|
|
149
|
+
found. On an unbalanced match, text up to the unmatched brace is kept
|
|
150
|
+
(real dependencies declared earlier in the file are not discarded) but
|
|
151
|
+
nothing from the unmatched ``constraints {`` onward is: falling back to
|
|
152
|
+
the raw remainder there would treat pinned-only (managed, not actually
|
|
153
|
+
used) coordinates as real dependencies — a false positive that can also
|
|
154
|
+
leak into framework detection. Losing any real declaration that happens
|
|
155
|
+
to follow a malformed block is the safer trade-off; the caller surfaces
|
|
156
|
+
this as a truncation error so downstream consumers know the metadata is
|
|
157
|
+
partial.
|
|
158
|
+
"""
|
|
159
|
+
masked = _mask_gradle_noise(text)
|
|
160
|
+
out = []
|
|
161
|
+
pos = 0
|
|
162
|
+
unbalanced = False
|
|
163
|
+
for match in _GRADLE_CONSTRAINTS_BLOCK.finditer(masked):
|
|
164
|
+
if match.start() < pos:
|
|
165
|
+
continue
|
|
166
|
+
depth = 1
|
|
167
|
+
i = match.end()
|
|
168
|
+
while i < len(masked) and depth > 0:
|
|
169
|
+
if masked[i] == "{":
|
|
170
|
+
depth += 1
|
|
171
|
+
elif masked[i] == "}":
|
|
172
|
+
depth -= 1
|
|
173
|
+
i += 1
|
|
174
|
+
out.append(text[pos:match.start()])
|
|
175
|
+
if depth > 0:
|
|
176
|
+
unbalanced = True
|
|
177
|
+
pos = len(text)
|
|
178
|
+
break
|
|
179
|
+
pos = i
|
|
180
|
+
if not unbalanced:
|
|
181
|
+
out.append(text[pos:])
|
|
182
|
+
return "".join(out), unbalanced
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _local_name(tag: str) -> str:
|
|
186
|
+
return tag.rsplit("}", 1)[-1]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _child_text(element: ET.Element, name: str) -> Optional[str]:
|
|
190
|
+
for child in element:
|
|
191
|
+
if _local_name(child.tag) == name and child.text:
|
|
192
|
+
return child.text.strip()
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _direct_child(element: ET.Element, name: str) -> Optional[ET.Element]:
|
|
197
|
+
return next((child for child in element if _local_name(child.tag) == name), None)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _coordinate(element: ET.Element) -> Optional[str]:
|
|
201
|
+
group = _child_text(element, "groupId")
|
|
202
|
+
artifact = _child_text(element, "artifactId")
|
|
203
|
+
version = _child_text(element, "version")
|
|
204
|
+
if not group or not artifact:
|
|
205
|
+
return None
|
|
206
|
+
return ":".join(part for part in (group, artifact, version) if part)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _maven_module_parents(project: ET.Element) -> list[ET.Element]:
|
|
210
|
+
"""Return reactor ``<modules>`` blocks, including profile reactors.
|
|
211
|
+
|
|
212
|
+
Static analysis takes the union of Maven profiles: unlike a build invocation,
|
|
213
|
+
it has no activation environment, and excluding inactive-profile modules makes
|
|
214
|
+
source discovery disagree with a repository-wide oracle.
|
|
215
|
+
"""
|
|
216
|
+
parents = []
|
|
217
|
+
direct = _direct_child(project, "modules")
|
|
218
|
+
if direct is not None:
|
|
219
|
+
parents.append(direct)
|
|
220
|
+
profiles = _direct_child(project, "profiles")
|
|
221
|
+
if profiles is not None:
|
|
222
|
+
for profile in profiles:
|
|
223
|
+
if _local_name(profile.tag) != "profile":
|
|
224
|
+
continue
|
|
225
|
+
modules = _direct_child(profile, "modules")
|
|
226
|
+
if modules is not None:
|
|
227
|
+
parents.append(modules)
|
|
228
|
+
return parents
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _resolve_path(base: Path, value: str) -> Optional[Path]:
|
|
232
|
+
value = value.strip().replace("${project.basedir}", str(base)).replace(
|
|
233
|
+
"${basedir}", str(base)
|
|
234
|
+
)
|
|
235
|
+
if not value or "${" in value or "$" in value:
|
|
236
|
+
return None
|
|
237
|
+
path = Path(value)
|
|
238
|
+
return path if path.is_absolute() else base / path
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class JavaProjectConfig:
|
|
243
|
+
"""Shared ProjectConfig shape populated from Maven or bounded Gradle syntax."""
|
|
244
|
+
|
|
245
|
+
project_path: Path
|
|
246
|
+
max_build_file_bytes: int = DEFAULT_MAX_BUILD_FILE_BYTES
|
|
247
|
+
max_build_total_bytes: int = DEFAULT_MAX_BUILD_TOTAL_BYTES
|
|
248
|
+
frameworks: list[str] = field(init=False, default_factory=list)
|
|
249
|
+
source_roots: list[Path] = field(init=False, default_factory=list)
|
|
250
|
+
dependencies: list[str] = field(init=False, default_factory=list)
|
|
251
|
+
modules: list[str] = field(init=False, default_factory=list)
|
|
252
|
+
plugins: list[str] = field(init=False, default_factory=list)
|
|
253
|
+
group_id: Optional[str] = field(init=False, default=None)
|
|
254
|
+
artifact_id: Optional[str] = field(init=False, default=None)
|
|
255
|
+
build_system: Optional[str] = field(init=False, default=None)
|
|
256
|
+
errors: list[str] = field(init=False, default_factory=list)
|
|
257
|
+
error_details: list[dict[str, Any]] = field(init=False, default_factory=list)
|
|
258
|
+
truncated: bool = field(init=False, default=False)
|
|
259
|
+
build_files_processed: int = field(init=False, default=0)
|
|
260
|
+
build_files_remaining: int = field(init=False, default=0)
|
|
261
|
+
build_bytes_read: int = field(init=False, default=0)
|
|
262
|
+
_budget_exhausted: bool = field(init=False, default=False, repr=False)
|
|
263
|
+
_resolved_root: Optional[Path] = field(init=False, default=None, repr=False)
|
|
264
|
+
# Every build file this config read, with its (st_mtime_ns, st_size) at
|
|
265
|
+
# read time: a long-lived process re-validates a cached discovery by
|
|
266
|
+
# re-stat()ing these (scanner.discovery.CachedDiscovery).
|
|
267
|
+
build_file_stats: dict[str, tuple[int, int]] = field(
|
|
268
|
+
init=False, default_factory=dict, repr=False,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def __post_init__(self) -> None:
|
|
272
|
+
if self.max_build_file_bytes < 1 or self.max_build_total_bytes < 1:
|
|
273
|
+
raise ValueError("build-file byte limits must be positive")
|
|
274
|
+
supplied = Path(self.project_path)
|
|
275
|
+
if supplied.is_file():
|
|
276
|
+
supplied = supplied.parent
|
|
277
|
+
self.project_path = find_project_root(supplied) or supplied
|
|
278
|
+
self._detect()
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def metadata(self) -> dict[str, Any]:
|
|
282
|
+
"""JSON-safe project metadata for scanner/MCP consumers in later P4 work."""
|
|
283
|
+
return {
|
|
284
|
+
"project_path": str(self.project_path),
|
|
285
|
+
"build_system": self.build_system,
|
|
286
|
+
"group_id": self.group_id,
|
|
287
|
+
"artifact_id": self.artifact_id,
|
|
288
|
+
"frameworks": self.frameworks,
|
|
289
|
+
"dependencies": self.dependencies,
|
|
290
|
+
"modules": self.modules,
|
|
291
|
+
"plugins": self.plugins,
|
|
292
|
+
"source_roots": [str(path) for path in self.source_roots],
|
|
293
|
+
"truncated": self.truncated,
|
|
294
|
+
"build_files": {
|
|
295
|
+
"processed": self.build_files_processed,
|
|
296
|
+
"remaining": self.build_files_remaining,
|
|
297
|
+
"bytes_read": self.build_bytes_read,
|
|
298
|
+
},
|
|
299
|
+
"limits": {
|
|
300
|
+
"max_file_bytes": self.max_build_file_bytes,
|
|
301
|
+
"max_total_bytes": self.max_build_total_bytes,
|
|
302
|
+
"max_maven_poms": _MAX_MAVEN_POMS,
|
|
303
|
+
},
|
|
304
|
+
"errors": self.errors,
|
|
305
|
+
"error_details": self.error_details,
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
def has_framework(self, name: str) -> bool:
|
|
309
|
+
return name.lower() in self.frameworks
|
|
310
|
+
|
|
311
|
+
def _project_path(self, candidate: Path, label: str) -> Optional[Path]:
|
|
312
|
+
"""Resolve ``candidate`` only when it remains inside the project root."""
|
|
313
|
+
try:
|
|
314
|
+
# Resolve the root once per config: this runs for every module /
|
|
315
|
+
# source directory of a reactor and Path.resolve() lstat()s each
|
|
316
|
+
# path component (MCP_GRAPH_CACHE_PLAN.md Lớp 2).
|
|
317
|
+
if self._resolved_root is None:
|
|
318
|
+
self._resolved_root = self.project_path.resolve()
|
|
319
|
+
root = self._resolved_root
|
|
320
|
+
resolved = candidate.resolve()
|
|
321
|
+
resolved.relative_to(root)
|
|
322
|
+
except (OSError, RuntimeError, ValueError):
|
|
323
|
+
self.errors.append(f"{label} escapes project root: {candidate}")
|
|
324
|
+
return None
|
|
325
|
+
return resolved
|
|
326
|
+
|
|
327
|
+
def _resource_error(self, code: str, message: str, **details: Any) -> None:
|
|
328
|
+
self.truncated = True
|
|
329
|
+
self.errors.append(message)
|
|
330
|
+
self.error_details.append({"code": code, "message": message, **details})
|
|
331
|
+
|
|
332
|
+
def _read_build_file(self, path: Path) -> Optional[bytes]:
|
|
333
|
+
"""Read one build file without exceeding per-file or reactor byte limits."""
|
|
334
|
+
try:
|
|
335
|
+
stat = path.stat()
|
|
336
|
+
except OSError as exc:
|
|
337
|
+
self.errors.append(f"{path}: {exc}")
|
|
338
|
+
return None
|
|
339
|
+
size = stat.st_size
|
|
340
|
+
self.build_file_stats[str(path)] = (stat.st_mtime_ns, size)
|
|
341
|
+
|
|
342
|
+
if size > self.max_build_file_bytes:
|
|
343
|
+
self.build_files_remaining += 1
|
|
344
|
+
self._resource_error(
|
|
345
|
+
"build_file_too_large",
|
|
346
|
+
f"{path}: build file exceeds {self.max_build_file_bytes} byte limit",
|
|
347
|
+
path=str(path),
|
|
348
|
+
actual_bytes=size,
|
|
349
|
+
limit_bytes=self.max_build_file_bytes,
|
|
350
|
+
)
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
remaining = self.max_build_total_bytes - self.build_bytes_read
|
|
354
|
+
if size > remaining:
|
|
355
|
+
self.build_files_remaining += 1
|
|
356
|
+
self._budget_exhausted = True
|
|
357
|
+
self._resource_error(
|
|
358
|
+
"build_total_too_large",
|
|
359
|
+
f"{path}: build files exceed {self.max_build_total_bytes} byte total limit",
|
|
360
|
+
path=str(path),
|
|
361
|
+
actual_bytes=size,
|
|
362
|
+
remaining_bytes=max(remaining, 0),
|
|
363
|
+
limit_bytes=self.max_build_total_bytes,
|
|
364
|
+
)
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
with path.open("rb") as stream:
|
|
369
|
+
data = stream.read(size)
|
|
370
|
+
except OSError as exc:
|
|
371
|
+
self.errors.append(f"{path}: {exc}")
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
self.build_files_processed += 1
|
|
375
|
+
self.build_bytes_read += len(data)
|
|
376
|
+
return data
|
|
377
|
+
|
|
378
|
+
def _parse_pom(self, pom: Path, data: bytes) -> Optional[ET.Element]:
|
|
379
|
+
try:
|
|
380
|
+
return DefusedET.fromstring(
|
|
381
|
+
data, forbid_dtd=True, forbid_entities=True, forbid_external=True
|
|
382
|
+
)
|
|
383
|
+
except DefusedXmlException as exc:
|
|
384
|
+
self._resource_error(
|
|
385
|
+
"unsafe_xml",
|
|
386
|
+
f"{pom}: unsafe XML is not allowed: {exc}",
|
|
387
|
+
path=str(pom),
|
|
388
|
+
)
|
|
389
|
+
return None
|
|
390
|
+
except ET.ParseError as exc:
|
|
391
|
+
self.errors.append(f"{pom}: {exc}")
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
def _detect(self) -> None:
|
|
395
|
+
dependencies: set[str] = set()
|
|
396
|
+
plugins: set[str] = set()
|
|
397
|
+
modules: set[str] = set()
|
|
398
|
+
roots: set[Path] = set()
|
|
399
|
+
hints: set[str] = set()
|
|
400
|
+
|
|
401
|
+
pom = self.project_path / "pom.xml"
|
|
402
|
+
gradle_files = [
|
|
403
|
+
self.project_path / name
|
|
404
|
+
for name in (
|
|
405
|
+
"build.gradle",
|
|
406
|
+
"build.gradle.kts",
|
|
407
|
+
"settings.gradle",
|
|
408
|
+
"settings.gradle.kts",
|
|
409
|
+
)
|
|
410
|
+
if (self.project_path / name).is_file()
|
|
411
|
+
]
|
|
412
|
+
|
|
413
|
+
if pom.is_file():
|
|
414
|
+
self.build_system = "maven"
|
|
415
|
+
self._read_maven(pom, dependencies, plugins, modules, roots, hints)
|
|
416
|
+
elif gradle_files:
|
|
417
|
+
self.build_system = "gradle"
|
|
418
|
+
self._read_gradle(gradle_files, dependencies, plugins, modules, roots, hints)
|
|
419
|
+
|
|
420
|
+
self._add_conventional_roots(self.project_path, roots)
|
|
421
|
+
for module in modules:
|
|
422
|
+
self._add_conventional_roots(self.project_path / module, roots)
|
|
423
|
+
|
|
424
|
+
marker_text = "\n".join((*dependencies, *plugins, *hints)).lower()
|
|
425
|
+
self.frameworks = sorted(
|
|
426
|
+
framework
|
|
427
|
+
for framework, markers in FRAMEWORK_MARKERS.items()
|
|
428
|
+
if any(marker in marker_text for marker in markers)
|
|
429
|
+
)
|
|
430
|
+
self.dependencies = sorted(dependencies)
|
|
431
|
+
self.plugins = sorted(plugins)
|
|
432
|
+
self.modules = sorted(modules)
|
|
433
|
+
self.source_roots = sorted(roots, key=str)
|
|
434
|
+
|
|
435
|
+
def _read_maven(
|
|
436
|
+
self,
|
|
437
|
+
root_pom: Path,
|
|
438
|
+
dependencies: set[str],
|
|
439
|
+
plugins: set[str],
|
|
440
|
+
modules: set[str],
|
|
441
|
+
roots: set[Path],
|
|
442
|
+
hints: set[str],
|
|
443
|
+
) -> None:
|
|
444
|
+
pending = [root_pom]
|
|
445
|
+
seen: set[Path] = set()
|
|
446
|
+
# Same module can legally appear in more than one `<modules>` block
|
|
447
|
+
# (default + a profile, or two different profiles) — union-of-profiles
|
|
448
|
+
# reactor discovery must not enqueue it twice, or a cap/budget stop
|
|
449
|
+
# mid-queue overcounts `build_files_remaining` with a POM that was
|
|
450
|
+
# already fully processed, falsely reporting the reactor as truncated.
|
|
451
|
+
queued: set[Path] = {root_pom}
|
|
452
|
+
resolved_root_pom = root_pom.resolve()
|
|
453
|
+
# ponytail: fixed file/count budgets bound untrusted reactors; make them
|
|
454
|
+
# configurable only if real repositories need different limits.
|
|
455
|
+
while pending and len(seen) < _MAX_MAVEN_POMS and not self._budget_exhausted:
|
|
456
|
+
pom = self._project_path(pending.pop(), "Maven POM")
|
|
457
|
+
if pom is None:
|
|
458
|
+
continue
|
|
459
|
+
if pom in seen or not pom.is_file():
|
|
460
|
+
continue
|
|
461
|
+
seen.add(pom)
|
|
462
|
+
data = self._read_build_file(pom)
|
|
463
|
+
if data is None:
|
|
464
|
+
continue
|
|
465
|
+
project = self._parse_pom(pom, data)
|
|
466
|
+
if project is None:
|
|
467
|
+
continue
|
|
468
|
+
base = pom.parent
|
|
469
|
+
|
|
470
|
+
if pom == resolved_root_pom:
|
|
471
|
+
parent = _direct_child(project, "parent")
|
|
472
|
+
self.group_id = _child_text(project, "groupId") or (
|
|
473
|
+
_child_text(parent, "groupId") if parent is not None else None
|
|
474
|
+
)
|
|
475
|
+
self.artifact_id = _child_text(project, "artifactId")
|
|
476
|
+
|
|
477
|
+
parent = _direct_child(project, "parent")
|
|
478
|
+
if parent is not None and (coordinate := _coordinate(parent)):
|
|
479
|
+
hints.add(coordinate)
|
|
480
|
+
|
|
481
|
+
dependency_parent = _direct_child(project, "dependencies")
|
|
482
|
+
if dependency_parent is not None:
|
|
483
|
+
for dependency in dependency_parent:
|
|
484
|
+
if (
|
|
485
|
+
_local_name(dependency.tag) == "dependency"
|
|
486
|
+
and (coordinate := _coordinate(dependency))
|
|
487
|
+
):
|
|
488
|
+
dependencies.add(coordinate)
|
|
489
|
+
|
|
490
|
+
build = _direct_child(project, "build")
|
|
491
|
+
plugin_parent = _direct_child(build, "plugins") if build is not None else None
|
|
492
|
+
if plugin_parent is not None:
|
|
493
|
+
for plugin in plugin_parent:
|
|
494
|
+
if (
|
|
495
|
+
_local_name(plugin.tag) == "plugin"
|
|
496
|
+
and (coordinate := _coordinate(plugin))
|
|
497
|
+
):
|
|
498
|
+
plugins.add(coordinate)
|
|
499
|
+
|
|
500
|
+
for element in project.iter():
|
|
501
|
+
kind = _local_name(element.tag)
|
|
502
|
+
if kind in {"sourceDirectory", "testSourceDirectory"} and element.text:
|
|
503
|
+
source_root = _resolve_path(base, element.text)
|
|
504
|
+
if source_root is not None:
|
|
505
|
+
source_root = self._project_path(source_root, f"Maven {kind}")
|
|
506
|
+
if source_root is not None:
|
|
507
|
+
roots.add(source_root)
|
|
508
|
+
elif kind == "sources":
|
|
509
|
+
for source in element:
|
|
510
|
+
if _local_name(source.tag) == "source" and source.text:
|
|
511
|
+
source_root = _resolve_path(base, source.text)
|
|
512
|
+
if source_root is not None:
|
|
513
|
+
source_root = self._project_path(
|
|
514
|
+
source_root, "Maven configured source root"
|
|
515
|
+
)
|
|
516
|
+
if source_root is not None:
|
|
517
|
+
roots.add(source_root)
|
|
518
|
+
|
|
519
|
+
for module_parent in _maven_module_parents(project):
|
|
520
|
+
for module in module_parent:
|
|
521
|
+
if _local_name(module.tag) != "module" or not module.text:
|
|
522
|
+
continue
|
|
523
|
+
module_name = module.text.strip().replace("\\", "/")
|
|
524
|
+
if not module_name or "${" in module_name:
|
|
525
|
+
continue
|
|
526
|
+
module_path = self._project_path(
|
|
527
|
+
base / module_name, f"Maven module {module_name!r}"
|
|
528
|
+
)
|
|
529
|
+
if module_path is None:
|
|
530
|
+
continue
|
|
531
|
+
relative = module_path.relative_to(
|
|
532
|
+
self.project_path.resolve()
|
|
533
|
+
).as_posix()
|
|
534
|
+
modules.add(relative)
|
|
535
|
+
child_pom = module_path / "pom.xml"
|
|
536
|
+
if child_pom not in queued:
|
|
537
|
+
queued.add(child_pom)
|
|
538
|
+
pending.append(child_pom)
|
|
539
|
+
|
|
540
|
+
if pending:
|
|
541
|
+
self.build_files_remaining += len(pending)
|
|
542
|
+
if len(seen) >= _MAX_MAVEN_POMS:
|
|
543
|
+
self._resource_error(
|
|
544
|
+
"maven_reactor_limit",
|
|
545
|
+
f"Maven reactor stopped after {_MAX_MAVEN_POMS} POMs; "
|
|
546
|
+
f"{len(pending)} remain",
|
|
547
|
+
processed=len(seen),
|
|
548
|
+
remaining=len(pending),
|
|
549
|
+
limit=_MAX_MAVEN_POMS,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def _read_gradle(
|
|
553
|
+
self,
|
|
554
|
+
root_files: list[Path],
|
|
555
|
+
dependencies: set[str],
|
|
556
|
+
plugins: set[str],
|
|
557
|
+
modules: set[str],
|
|
558
|
+
roots: set[Path],
|
|
559
|
+
hints: set[str],
|
|
560
|
+
) -> None:
|
|
561
|
+
texts: dict[Path, str] = {}
|
|
562
|
+
ordered_root_files = sorted(
|
|
563
|
+
root_files, key=lambda path: not path.name.startswith("settings.gradle")
|
|
564
|
+
)
|
|
565
|
+
for index, candidate in enumerate(ordered_root_files):
|
|
566
|
+
if self._budget_exhausted:
|
|
567
|
+
self.build_files_remaining += len(ordered_root_files) - index
|
|
568
|
+
break
|
|
569
|
+
path = self._project_path(candidate, f"Gradle build file {candidate.name}")
|
|
570
|
+
if path is None:
|
|
571
|
+
continue
|
|
572
|
+
data = self._read_build_file(path)
|
|
573
|
+
if data is not None:
|
|
574
|
+
texts[path] = _mask_gradle_comments(data.decode("utf-8", errors="replace"))
|
|
575
|
+
|
|
576
|
+
settings_text = "\n".join(
|
|
577
|
+
text for path, text in texts.items() if path.name.startswith("settings.gradle")
|
|
578
|
+
)
|
|
579
|
+
if match := _GRADLE_ROOT_NAME.search(settings_text):
|
|
580
|
+
self.artifact_id = match.group(1)
|
|
581
|
+
for match in _GRADLE_INCLUDE.finditer(settings_text):
|
|
582
|
+
for value in _QUOTED.findall(match.group(1) or match.group(2) or ""):
|
|
583
|
+
# Gradle's own module-path notation (`:app:sub`) uses `:` as
|
|
584
|
+
# its separator, so it's normally safe to fold into `/` — but
|
|
585
|
+
# an `include(...)` argument that's actually an absolute
|
|
586
|
+
# filesystem path (this can be produced by dynamic/scripted
|
|
587
|
+
# settings.gradle, and matters most on Windows) carries a
|
|
588
|
+
# drive-letter `:` that is NOT a Gradle separator. Folding it
|
|
589
|
+
# anyway used to strip the drive letter's colon (`C:\x` ->
|
|
590
|
+
# `C\x`), turning an absolute path into what pathlib treats
|
|
591
|
+
# as a *relative* one when joined below — silently pulling a
|
|
592
|
+
# module that escapes the project root back "inside" it and
|
|
593
|
+
# defeating the `_project_path` escape check entirely.
|
|
594
|
+
module = value if _ABS_PATH.match(value) else value.strip(":").replace(":", "/")
|
|
595
|
+
if not module:
|
|
596
|
+
continue
|
|
597
|
+
module_path = self._project_path(
|
|
598
|
+
self.project_path / module, f"Gradle module {value!r}"
|
|
599
|
+
)
|
|
600
|
+
if module_path is not None:
|
|
601
|
+
modules.add(
|
|
602
|
+
module_path.relative_to(self.project_path.resolve()).as_posix()
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
build_files = {
|
|
606
|
+
path for path in texts if path.name in {"build.gradle", "build.gradle.kts"}
|
|
607
|
+
}
|
|
608
|
+
for module in modules:
|
|
609
|
+
for name in ("build.gradle", "build.gradle.kts"):
|
|
610
|
+
candidate = self.project_path / module / name
|
|
611
|
+
path = self._project_path(candidate, f"Gradle build file {candidate}")
|
|
612
|
+
if path is not None and path.is_file():
|
|
613
|
+
build_files.add(path)
|
|
614
|
+
|
|
615
|
+
unread = sorted(build_files - texts.keys(), key=str)
|
|
616
|
+
for index, path in enumerate(unread):
|
|
617
|
+
if self._budget_exhausted:
|
|
618
|
+
self.build_files_remaining += len(unread) - index
|
|
619
|
+
break
|
|
620
|
+
data = self._read_build_file(path)
|
|
621
|
+
if data is not None:
|
|
622
|
+
texts[path] = _mask_gradle_comments(data.decode("utf-8", errors="replace"))
|
|
623
|
+
|
|
624
|
+
for path in sorted(build_files & texts.keys(), key=str):
|
|
625
|
+
text = texts[path]
|
|
626
|
+
cleaned, unbalanced = _strip_gradle_constraints(text)
|
|
627
|
+
if unbalanced:
|
|
628
|
+
self._resource_error(
|
|
629
|
+
"unbalanced_gradle_constraints",
|
|
630
|
+
f"{path}: unbalanced 'constraints {{' block (possibly "
|
|
631
|
+
"inside a comment/string); dependency extraction "
|
|
632
|
+
"stopped before the unmatched block",
|
|
633
|
+
path=str(path),
|
|
634
|
+
)
|
|
635
|
+
dependencies.update(_GRADLE_DEPENDENCY.findall(cleaned))
|
|
636
|
+
plugins.update(_GRADLE_PLUGIN.findall(text))
|
|
637
|
+
if self.group_id is None and (match := _GRADLE_GROUP.search(text)):
|
|
638
|
+
self.group_id = match.group(1)
|
|
639
|
+
for match in _GRADLE_SOURCE_LINE.finditer(text):
|
|
640
|
+
for value in _QUOTED.findall(match.group(1)):
|
|
641
|
+
source_root = _resolve_path(path.parent, value)
|
|
642
|
+
if source_root is not None:
|
|
643
|
+
source_root = self._project_path(
|
|
644
|
+
source_root, "Gradle configured source root"
|
|
645
|
+
)
|
|
646
|
+
if source_root is not None:
|
|
647
|
+
roots.add(source_root)
|
|
648
|
+
|
|
649
|
+
hints.update(plugins)
|
|
650
|
+
|
|
651
|
+
@staticmethod
|
|
652
|
+
def _add_conventional_roots(project: Path, roots: set[Path]) -> None:
|
|
653
|
+
for relative in ("src/main/java", "src/test/java"):
|
|
654
|
+
path = project / relative
|
|
655
|
+
if path.is_dir():
|
|
656
|
+
roots.add(path)
|
|
657
|
+
|
|
658
|
+
def excluded_source_files(self) -> list[dict[str, Any]]:
|
|
659
|
+
"""Diagnostic only — every ``.java`` file physically present under
|
|
660
|
+
the project root that ``source_roots`` does not cover, each with a
|
|
661
|
+
best-effort reason. Not called from ``_detect()``: it walks the
|
|
662
|
+
whole tree once (``rglob``), which is fine for an opt-in diagnostic
|
|
663
|
+
(``scan --format json``) but would be wasted work on every
|
|
664
|
+
``graph``/``scan`` build, where callers only need ``source_roots``
|
|
665
|
+
itself.
|
|
666
|
+
|
|
667
|
+
A benchmark comparing Drift's file counts against a plain recursive
|
|
668
|
+
``.java`` count found Drift "silently" analyzing fewer files than
|
|
669
|
+
exist on disk, with no way to tell a correct exclusion (a Maven
|
|
670
|
+
module commented out of its parent's ``<modules>`` list; a
|
|
671
|
+
``.java``-suffixed file sitting in a ``resources`` directory, never
|
|
672
|
+
a compiled source root) from an actual bug. This surfaces that gap.
|
|
673
|
+
"""
|
|
674
|
+
if self.build_system != "maven":
|
|
675
|
+
return []
|
|
676
|
+
root = self._resolved_root or self.project_path.resolve()
|
|
677
|
+
covered_roots = [r.resolve() for r in self.source_roots]
|
|
678
|
+
|
|
679
|
+
def _is_covered(path: Path) -> bool:
|
|
680
|
+
return any(path == cr or cr in path.parents for cr in covered_roots)
|
|
681
|
+
|
|
682
|
+
resolved_modules = {(root / m).resolve() for m in self.modules}
|
|
683
|
+
module_dirs = {p.parent.resolve() for p in root.rglob("pom.xml")}
|
|
684
|
+
module_dirs.add(root)
|
|
685
|
+
|
|
686
|
+
excluded: list[dict[str, Any]] = []
|
|
687
|
+
for java_file in sorted(root.rglob("*.java")):
|
|
688
|
+
if _is_covered(java_file):
|
|
689
|
+
continue
|
|
690
|
+
rel = java_file.relative_to(root).as_posix()
|
|
691
|
+
reason = "unknown"
|
|
692
|
+
detail: Optional[str] = None
|
|
693
|
+
if f"/{rel}".startswith("/") and (
|
|
694
|
+
"/src/main/resources/" in f"/{rel}" or "/src/test/resources/" in f"/{rel}"
|
|
695
|
+
):
|
|
696
|
+
reason = "not_a_compiled_source_root"
|
|
697
|
+
detail = "under a Maven resources directory, not src/main|test/java"
|
|
698
|
+
else:
|
|
699
|
+
for ancestor in java_file.parents:
|
|
700
|
+
if ancestor == root:
|
|
701
|
+
break
|
|
702
|
+
if ancestor in module_dirs and ancestor not in resolved_modules:
|
|
703
|
+
reason = "module_not_in_active_reactor"
|
|
704
|
+
detail = ancestor.relative_to(root).as_posix()
|
|
705
|
+
break
|
|
706
|
+
excluded.append({"path": rel, "reason": reason, "detail": detail})
|
|
707
|
+
return excluded
|