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