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,89 @@
|
|
|
1
|
+
"""Pattern management and approval workflow.
|
|
2
|
+
|
|
3
|
+
Handles the lifecycle of detected patterns:
|
|
4
|
+
1. Detection (auto)
|
|
5
|
+
2. Review (human)
|
|
6
|
+
3. Approval (human marks as intentional)
|
|
7
|
+
4. Enforcement (subsequent scans check against approved patterns)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import List, Dict, Any, Optional
|
|
13
|
+
from adl_drift.utils.types import Pattern, PatternCategory
|
|
14
|
+
from adl_drift.cortex.memory import CortexMemory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PatternManager:
|
|
18
|
+
"""Manages the pattern approval workflow.
|
|
19
|
+
|
|
20
|
+
Workflow:
|
|
21
|
+
1. Detectors find patterns
|
|
22
|
+
2. User reviews via `adl-drift patterns`
|
|
23
|
+
3. User approves via `adl-drift approve <id>`
|
|
24
|
+
4. Future scans flag deviations from approved patterns
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, memory: CortexMemory):
|
|
28
|
+
self.memory = memory
|
|
29
|
+
|
|
30
|
+
def process_new_patterns(self, patterns: List[Pattern]) -> Dict[str, Any]:
|
|
31
|
+
"""Process newly detected patterns.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Summary of new, known, and conflicting patterns.
|
|
35
|
+
"""
|
|
36
|
+
new_patterns = []
|
|
37
|
+
known_patterns = []
|
|
38
|
+
conflicts = []
|
|
39
|
+
|
|
40
|
+
for pattern in patterns:
|
|
41
|
+
pattern_id = pattern.compute_hash()
|
|
42
|
+
|
|
43
|
+
# Check if we already know this pattern
|
|
44
|
+
existing = self.memory.get_patterns(category=pattern.category.name)
|
|
45
|
+
existing_ids = {p["id"] for p in existing}
|
|
46
|
+
|
|
47
|
+
if pattern_id in existing_ids:
|
|
48
|
+
known_patterns.append(pattern)
|
|
49
|
+
else:
|
|
50
|
+
new_patterns.append(pattern)
|
|
51
|
+
# Auto-store for review
|
|
52
|
+
self.memory.store_pattern(pattern)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"new": len(new_patterns),
|
|
56
|
+
"known": len(known_patterns),
|
|
57
|
+
"conflicts": len(conflicts),
|
|
58
|
+
"new_patterns": [p.name for p in new_patterns],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def approve(self, pattern_id: str) -> bool:
|
|
62
|
+
"""Approve a pattern as intentional."""
|
|
63
|
+
self.memory.approve_pattern(pattern_id)
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
def check_conformance(self, patterns: List[Pattern]) -> List[Dict[str, Any]]:
|
|
67
|
+
"""Check if current patterns conform to approved patterns.
|
|
68
|
+
|
|
69
|
+
Flags deviations: e.g., if approved pattern is "use @login_required
|
|
70
|
+
on all routes" but a new route lacks it.
|
|
71
|
+
"""
|
|
72
|
+
violations = []
|
|
73
|
+
approved = self.memory.get_patterns(approved_only=True)
|
|
74
|
+
approved_set = {p["id"] for p in approved}
|
|
75
|
+
|
|
76
|
+
for pattern in patterns:
|
|
77
|
+
pid = pattern.compute_hash()
|
|
78
|
+
if pid not in approved_set:
|
|
79
|
+
# Check for similar approved patterns
|
|
80
|
+
similar = self.memory.search_similar(pattern.name, limit=3)
|
|
81
|
+
if similar:
|
|
82
|
+
violations.append({
|
|
83
|
+
"pattern": pattern.name,
|
|
84
|
+
"file": str(pattern.file_path),
|
|
85
|
+
"line": pattern.position.line,
|
|
86
|
+
"message": f"Pattern not in approved set. Similar approved: {similar[0]['name']}",
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return violations
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Shared tool-call telemetry recorder (MONITOR).
|
|
2
|
+
|
|
3
|
+
Both the MCP handler path (``server/handlers.py``) and the CLI command path
|
|
4
|
+
(``cli/commands.py``) route their token-savings accounting through one
|
|
5
|
+
:class:`TelemetryRecorder` so the centralized monitor sees a single, consistent
|
|
6
|
+
metric regardless of how drift was invoked (PM.1 / PM.2).
|
|
7
|
+
|
|
8
|
+
Token-savings model (per-call-site — honest, query-shape-aware):
|
|
9
|
+
|
|
10
|
+
The "saved" number is what an agent would *avoid* spending by asking drift
|
|
11
|
+
instead of locating + reading the code by hand. The manual alternative to a
|
|
12
|
+
drift answer is to **grep-locate and read each distinct call-site / reference
|
|
13
|
+
the answer surfaces** — not each *file*. Per-file accounting (the earlier PM.1
|
|
14
|
+
model) mis-scored both ways: it overstated when one answer spanned many files,
|
|
15
|
+
and *understated* high-fan-in queries where dozens of call-sites live in a few
|
|
16
|
+
files (an agent would still have to read each site). So we count per site:
|
|
17
|
+
|
|
18
|
+
* a **locate cost** of :data:`GREP_TOKENS_PER_SITE` ≈ one grep match line that
|
|
19
|
+
points the agent at the site; plus
|
|
20
|
+
* a **read window** of :data:`READ_WINDOW_PER_SITE` ≈ the call-site line and its
|
|
21
|
+
immediate context the agent would read to confirm the dependency.
|
|
22
|
+
|
|
23
|
+
A "site" is any node id the answer points at (callers, callees, refs, matches,
|
|
24
|
+
the target itself). Sites are **deduplicated across calls** within a recorder's
|
|
25
|
+
lifetime (one MCP session / one CLI process): once an agent has read a site it
|
|
26
|
+
stays in context, so a later call referencing it saves nothing more.
|
|
27
|
+
|
|
28
|
+
The result is an honest upper bound that tracks the actual fan-out of each
|
|
29
|
+
answer instead of a flat per-file guess. The monitor labels it as such.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Dict, List, Optional
|
|
37
|
+
|
|
38
|
+
from adl_drift.lang.registry import backend_for_file
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TelemetryRecorder:
|
|
42
|
+
"""Accumulates per-call token accounting and writes ``tool_invocations`` rows."""
|
|
43
|
+
|
|
44
|
+
# Per distinct call-site / reference an answer surfaces, the manual
|
|
45
|
+
# alternative costs a grep locate + a focused read of that site.
|
|
46
|
+
GREP_TOKENS_PER_SITE = 20 # one grep match line that locates the site
|
|
47
|
+
READ_WINDOW_PER_SITE = 80 # ~20 lines: the site + immediate context
|
|
48
|
+
_MAX_DEPTH = 8
|
|
49
|
+
|
|
50
|
+
def __init__(self, memory: Any, project_root: Path):
|
|
51
|
+
self.memory = memory
|
|
52
|
+
self.project_root = Path(project_root)
|
|
53
|
+
# Cross-call dedup, reset only when a fresh recorder is constructed:
|
|
54
|
+
# call-sites already "read" earlier in this session (the baseline unit),
|
|
55
|
+
# and files referenced (informational `files_referenced` stat).
|
|
56
|
+
self._counted_sites: set = set()
|
|
57
|
+
self._counted_files: set = set()
|
|
58
|
+
|
|
59
|
+
def record(
|
|
60
|
+
self,
|
|
61
|
+
tool: str,
|
|
62
|
+
params: Dict[str, Any],
|
|
63
|
+
result: Any,
|
|
64
|
+
*,
|
|
65
|
+
is_error: bool = False,
|
|
66
|
+
duration_ms: float = 0.0,
|
|
67
|
+
source: str = "mcp",
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Record one tool call. Best-effort — never raises into the caller."""
|
|
70
|
+
try:
|
|
71
|
+
self._record(tool, params, result, is_error, duration_ms, source)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def _record(
|
|
76
|
+
self,
|
|
77
|
+
tool: str,
|
|
78
|
+
params: Dict[str, Any],
|
|
79
|
+
result: Any,
|
|
80
|
+
is_error: bool,
|
|
81
|
+
duration_ms: float,
|
|
82
|
+
source: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
params = params or {}
|
|
85
|
+
response_json = json.dumps(result, default=str)
|
|
86
|
+
response_tokens = max(1, len(response_json) // 4)
|
|
87
|
+
|
|
88
|
+
files: set = set()
|
|
89
|
+
sites: set = set()
|
|
90
|
+
confidences: List[float] = []
|
|
91
|
+
self._collect_refs(result, files, confidences, sites, depth=0)
|
|
92
|
+
|
|
93
|
+
# Baseline = grep-locate + focused read of each distinct call-site the
|
|
94
|
+
# answer surfaces. Sites the agent already saw earlier this session stay
|
|
95
|
+
# in context, so only newly-seen ones cost anything (cross-call dedup).
|
|
96
|
+
new_sites = [s for s in sites if s not in self._counted_sites]
|
|
97
|
+
baseline_tokens = (
|
|
98
|
+
self.GREP_TOKENS_PER_SITE + self.READ_WINDOW_PER_SITE
|
|
99
|
+
) * len(new_sites)
|
|
100
|
+
saved = max(baseline_tokens - response_tokens, 0)
|
|
101
|
+
self._counted_sites.update(new_sites)
|
|
102
|
+
|
|
103
|
+
# `files_referenced` stays an informational count of distinct source files
|
|
104
|
+
# an answer spans (canonicalized so relative/absolute forms collapse).
|
|
105
|
+
canon = {self._canonical(f) for f in files}
|
|
106
|
+
new_files = [f for f in canon if f not in self._counted_files]
|
|
107
|
+
self._counted_files.update(new_files)
|
|
108
|
+
|
|
109
|
+
hi = sum(1 for c in confidences if c >= 0.85)
|
|
110
|
+
md = sum(1 for c in confidences if 0.6 <= c < 0.85)
|
|
111
|
+
lo = sum(1 for c in confidences if c < 0.6)
|
|
112
|
+
cmin = min(confidences) if confidences else None
|
|
113
|
+
cmax = max(confidences) if confidences else None
|
|
114
|
+
cavg = (sum(confidences) / len(confidences)) if confidences else None
|
|
115
|
+
|
|
116
|
+
target = (params.get("symbol") or params.get("query")
|
|
117
|
+
or params.get("node_id") or params.get("path")
|
|
118
|
+
or params.get("topic") or params.get("category")
|
|
119
|
+
or params.get("from"))
|
|
120
|
+
args = {k: v for k, v in params.items() if k != "request_id"}
|
|
121
|
+
args["source"] = source
|
|
122
|
+
|
|
123
|
+
self.memory.store_invocation({
|
|
124
|
+
"project_path": str(self.project_root),
|
|
125
|
+
"tool": tool,
|
|
126
|
+
"target": str(target) if target is not None else None,
|
|
127
|
+
"args": args,
|
|
128
|
+
"response_tokens": response_tokens,
|
|
129
|
+
"baseline_tokens": baseline_tokens,
|
|
130
|
+
"saved_tokens": saved,
|
|
131
|
+
"files_referenced": len(new_files),
|
|
132
|
+
"result_count": self._result_count(result),
|
|
133
|
+
"confidence_min": cmin, "confidence_avg": cavg, "confidence_max": cmax,
|
|
134
|
+
"high_conf": hi, "med_conf": md, "low_conf": lo,
|
|
135
|
+
"duration_ms": duration_ms,
|
|
136
|
+
"is_error": is_error,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
# Node ids are `module:qualname` — they contain ':' and never whitespace or
|
|
140
|
+
# parentheses (which appear in signatures / snippets / docstrings).
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _looks_like_site(s: str) -> bool:
|
|
143
|
+
return ":" in s and not any(c in s for c in " ()\n\t")
|
|
144
|
+
|
|
145
|
+
def _collect_refs(self, obj: Any, files: set, confidences: List[float],
|
|
146
|
+
sites: set, depth: int) -> None:
|
|
147
|
+
"""Recurse a response payload collecting backend file refs, confidences,
|
|
148
|
+
and call-site node ids (the baseline unit)."""
|
|
149
|
+
if depth > self._MAX_DEPTH:
|
|
150
|
+
return
|
|
151
|
+
if isinstance(obj, dict):
|
|
152
|
+
for k, v in obj.items():
|
|
153
|
+
if k == "confidence" and isinstance(v, (int, float)):
|
|
154
|
+
confidences.append(float(v))
|
|
155
|
+
continue
|
|
156
|
+
if k == "id" and isinstance(v, str):
|
|
157
|
+
sites.add(v) # explicit node id — always a site
|
|
158
|
+
continue
|
|
159
|
+
# node-id dict keys (e.g. transitive_callers' {id: depth} map)
|
|
160
|
+
if isinstance(k, str) and self._looks_like_site(k):
|
|
161
|
+
sites.add(k)
|
|
162
|
+
self._collect_refs(v, files, confidences, sites, depth + 1)
|
|
163
|
+
elif isinstance(obj, list):
|
|
164
|
+
for item in obj:
|
|
165
|
+
self._collect_refs(item, files, confidences, sites, depth + 1)
|
|
166
|
+
elif isinstance(obj, str):
|
|
167
|
+
if backend_for_file(Path(obj)) is not None:
|
|
168
|
+
files.add(obj)
|
|
169
|
+
elif self._looks_like_site(obj):
|
|
170
|
+
sites.add(obj)
|
|
171
|
+
|
|
172
|
+
def _canonical(self, ref: str) -> str:
|
|
173
|
+
"""Resolve a source ref (relative or absolute) to one stable key per file."""
|
|
174
|
+
for candidate in (Path(ref), self.project_root / ref):
|
|
175
|
+
try:
|
|
176
|
+
if candidate.is_file():
|
|
177
|
+
return str(candidate.resolve())
|
|
178
|
+
except (OSError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
return ref
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _result_count(result: Any) -> int:
|
|
184
|
+
if isinstance(result, dict):
|
|
185
|
+
if isinstance(result.get("count"), int):
|
|
186
|
+
return result["count"]
|
|
187
|
+
for v in result.values():
|
|
188
|
+
if isinstance(v, list):
|
|
189
|
+
return len(v)
|
|
190
|
+
elif isinstance(result, list):
|
|
191
|
+
return len(result)
|
|
192
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pattern detectors for Python code analysis."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Async/await pattern detectors.
|
|
2
|
+
|
|
3
|
+
Finds async functions, await expressions, asyncio usage, event loops.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import List, Dict, Any
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tree_sitter import Node, Tree
|
|
11
|
+
|
|
12
|
+
from adl_drift.detectors.base import BaseDetector
|
|
13
|
+
from adl_drift.utils.types import Pattern, PatternCategory
|
|
14
|
+
from adl_drift.parser.core import PythonParser
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AsyncPatternDetector(BaseDetector):
|
|
18
|
+
"""Detects async/await patterns."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, parser: PythonParser):
|
|
21
|
+
super().__init__(parser)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def category(self) -> PatternCategory:
|
|
25
|
+
return PatternCategory.ASYNC
|
|
26
|
+
|
|
27
|
+
def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
|
|
28
|
+
patterns = []
|
|
29
|
+
|
|
30
|
+
for node in self._walk(tree.root_node):
|
|
31
|
+
if node.type == "function_definition":
|
|
32
|
+
if self._is_async(node):
|
|
33
|
+
patterns.append(self._make_pattern(
|
|
34
|
+
name="async_function",
|
|
35
|
+
description="Async function definition",
|
|
36
|
+
node=node,
|
|
37
|
+
file_path=file_path,
|
|
38
|
+
confidence=0.99,
|
|
39
|
+
metadata={"type": "async_def"}
|
|
40
|
+
))
|
|
41
|
+
elif node.type == "await" and node.child_count > 0:
|
|
42
|
+
# Skip the bare `await` keyword token; only emit for the
|
|
43
|
+
# expression-level node (which has children).
|
|
44
|
+
patterns.append(self._make_pattern(
|
|
45
|
+
name="await_expression",
|
|
46
|
+
description="Await expression",
|
|
47
|
+
node=node,
|
|
48
|
+
file_path=file_path,
|
|
49
|
+
confidence=0.99,
|
|
50
|
+
metadata={"type": "await"}
|
|
51
|
+
))
|
|
52
|
+
elif node.type == "for_statement":
|
|
53
|
+
if self._is_async_for(node):
|
|
54
|
+
patterns.append(self._make_pattern(
|
|
55
|
+
name="async_for",
|
|
56
|
+
description="Async for loop",
|
|
57
|
+
node=node,
|
|
58
|
+
file_path=file_path,
|
|
59
|
+
confidence=0.95,
|
|
60
|
+
metadata={"type": "async_for"}
|
|
61
|
+
))
|
|
62
|
+
elif node.type == "with_statement":
|
|
63
|
+
if self._is_async_with(node):
|
|
64
|
+
patterns.append(self._make_pattern(
|
|
65
|
+
name="async_with",
|
|
66
|
+
description="Async context manager",
|
|
67
|
+
node=node,
|
|
68
|
+
file_path=file_path,
|
|
69
|
+
confidence=0.95,
|
|
70
|
+
metadata={"type": "async_with"}
|
|
71
|
+
))
|
|
72
|
+
elif node.type == "call":
|
|
73
|
+
patterns.extend(self._analyze_asyncio_calls(node, source_bytes, file_path))
|
|
74
|
+
|
|
75
|
+
return patterns
|
|
76
|
+
|
|
77
|
+
def _is_async(self, node: Node) -> bool:
|
|
78
|
+
return any(child.type == "async" for child in node.children)
|
|
79
|
+
|
|
80
|
+
def _is_async_for(self, node: Node) -> bool:
|
|
81
|
+
return any(child.type == "async" for child in node.children)
|
|
82
|
+
|
|
83
|
+
def _is_async_with(self, node: Node) -> bool:
|
|
84
|
+
return any(child.type == "async" for child in node.children)
|
|
85
|
+
|
|
86
|
+
def _analyze_asyncio_calls(self, node: Node, source: bytes, path: Path) -> List[Pattern]:
|
|
87
|
+
patterns = []
|
|
88
|
+
text = self.parser.node_text(node, source)
|
|
89
|
+
|
|
90
|
+
asyncio_funcs = {
|
|
91
|
+
"create_task": "Creating async task",
|
|
92
|
+
"gather": "Gathering async results",
|
|
93
|
+
"run": "Running event loop",
|
|
94
|
+
"sleep": "Async sleep",
|
|
95
|
+
"wait_for": "Waiting with timeout",
|
|
96
|
+
"ensure_future": "Ensuring future",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for func, desc in asyncio_funcs.items():
|
|
100
|
+
if f"asyncio.{func}" in text or f"{func}(" in text:
|
|
101
|
+
patterns.append(self._make_pattern(
|
|
102
|
+
name=f"asyncio_{func}",
|
|
103
|
+
description=desc,
|
|
104
|
+
node=node,
|
|
105
|
+
file_path=path,
|
|
106
|
+
confidence=0.85,
|
|
107
|
+
metadata={"type": "asyncio", "function": func}
|
|
108
|
+
))
|
|
109
|
+
|
|
110
|
+
return patterns
|
|
111
|
+
|
|
112
|
+
def _walk(self, node):
|
|
113
|
+
yield node
|
|
114
|
+
for child in node.children:
|
|
115
|
+
yield from self._walk(child)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Base detector class - all detectors inherit from this.
|
|
2
|
+
|
|
3
|
+
Equivalent to the detector trait system in Rust.
|
|
4
|
+
Each detector is a plugin that scans the CST for specific patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from typing import List, Optional, Dict, Any
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from tree_sitter import Node, Tree
|
|
15
|
+
|
|
16
|
+
from adl_drift.lang.base import LanguageParser
|
|
17
|
+
from adl_drift.utils.types import Pattern, PatternCategory, Symbol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BaseDetector(ABC):
|
|
21
|
+
"""Abstract base for all pattern detectors.
|
|
22
|
+
|
|
23
|
+
In Rust this would be a trait:
|
|
24
|
+
```rust
|
|
25
|
+
trait Detector {
|
|
26
|
+
fn category(&self) -> PatternCategory;
|
|
27
|
+
fn detect(&self, tree: &Tree, source: &[u8]) -> Vec<Pattern>;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, parser: LanguageParser):
|
|
33
|
+
self.parser = parser
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def category(self) -> PatternCategory:
|
|
38
|
+
"""The pattern category this detector handles."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
|
|
43
|
+
"""Run detection on a parsed file.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
tree: The tree-sitter CST
|
|
47
|
+
source_bytes: Raw source for text extraction
|
|
48
|
+
file_path: Path to the file being analyzed
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
List of detected patterns
|
|
52
|
+
"""
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def _make_pattern(
|
|
56
|
+
self,
|
|
57
|
+
name: str,
|
|
58
|
+
description: str,
|
|
59
|
+
node: Node,
|
|
60
|
+
file_path: Path,
|
|
61
|
+
confidence: float,
|
|
62
|
+
symbol: Optional[Symbol] = None,
|
|
63
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
64
|
+
) -> Pattern:
|
|
65
|
+
"""Helper to create a Pattern from a CST node."""
|
|
66
|
+
return Pattern(
|
|
67
|
+
category=self.category,
|
|
68
|
+
name=name,
|
|
69
|
+
description=description,
|
|
70
|
+
position=self.parser.node_position(node),
|
|
71
|
+
file_path=file_path,
|
|
72
|
+
confidence=confidence,
|
|
73
|
+
symbol=symbol,
|
|
74
|
+
metadata=metadata or {},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _node_matches_type(self, node: Node, *types: str) -> bool:
|
|
78
|
+
"""Check if node type matches any of the given types."""
|
|
79
|
+
return node.type in types
|
|
80
|
+
|
|
81
|
+
def _get_child_by_field(self, node: Node, field: str) -> Optional[Node]:
|
|
82
|
+
"""Safely get a child node by field name."""
|
|
83
|
+
try:
|
|
84
|
+
return node.child_by_field_name(field)
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DetectorRegistry:
|
|
90
|
+
"""Registry of all available detectors.
|
|
91
|
+
|
|
92
|
+
Equivalent to a static registry or plugin system in Rust.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, detectors: Iterable[BaseDetector] = ()):
|
|
96
|
+
self._detectors: Dict[PatternCategory, List[BaseDetector]] = {}
|
|
97
|
+
for detector in detectors:
|
|
98
|
+
self.register(detector)
|
|
99
|
+
|
|
100
|
+
def register(self, detector: BaseDetector) -> None:
|
|
101
|
+
"""Register a detector instance."""
|
|
102
|
+
cat = detector.category
|
|
103
|
+
if cat not in self._detectors:
|
|
104
|
+
self._detectors[cat] = []
|
|
105
|
+
self._detectors[cat].append(detector)
|
|
106
|
+
|
|
107
|
+
def get_for_category(self, category: PatternCategory) -> List[BaseDetector]:
|
|
108
|
+
"""Get all detectors for a specific category."""
|
|
109
|
+
return self._detectors.get(category, [])
|
|
110
|
+
|
|
111
|
+
def get_all(self) -> List[BaseDetector]:
|
|
112
|
+
"""Get all registered detectors."""
|
|
113
|
+
return [d for detectors in self._detectors.values() for d in detectors]
|
|
114
|
+
|
|
115
|
+
def run_all(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
|
|
116
|
+
"""Run all detectors and collect patterns."""
|
|
117
|
+
all_patterns = []
|
|
118
|
+
for detector in self.get_all():
|
|
119
|
+
patterns = detector.detect(tree, source_bytes, file_path)
|
|
120
|
+
all_patterns.extend(patterns)
|
|
121
|
+
return all_patterns
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Config / environment variable detector."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from tree_sitter import Node, Tree
|
|
10
|
+
|
|
11
|
+
from adl_drift.detectors.base import BaseDetector
|
|
12
|
+
from adl_drift.utils.types import Pattern, PatternCategory
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UPPER_SNAKE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigDetector(BaseDetector):
|
|
19
|
+
"""Captures env-var reads, settings access, and UPPER_SNAKE constants."""
|
|
20
|
+
|
|
21
|
+
ENV_FUNCS = {"os.environ.get", "os.getenv"}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def category(self) -> PatternCategory:
|
|
25
|
+
return PatternCategory.CONFIG
|
|
26
|
+
|
|
27
|
+
def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
|
|
28
|
+
patterns: List[Pattern] = []
|
|
29
|
+
is_dotenv_loader = False
|
|
30
|
+
|
|
31
|
+
for node in self._walk(tree.root_node):
|
|
32
|
+
if node.type == "call":
|
|
33
|
+
p = self._check_env_call(node, source_bytes, file_path)
|
|
34
|
+
if p is not None:
|
|
35
|
+
patterns.append(p)
|
|
36
|
+
elif node.type == "subscript":
|
|
37
|
+
p = self._check_environ_subscript(node, source_bytes, file_path)
|
|
38
|
+
if p is not None:
|
|
39
|
+
patterns.append(p)
|
|
40
|
+
elif node.type == "assignment":
|
|
41
|
+
p = self._check_module_constant(node, source_bytes, file_path)
|
|
42
|
+
if p is not None:
|
|
43
|
+
patterns.append(p)
|
|
44
|
+
elif node.type in ("import_statement", "import_from_statement"):
|
|
45
|
+
text = self.parser.node_text(node, source_bytes)
|
|
46
|
+
if "dotenv" in text:
|
|
47
|
+
is_dotenv_loader = True
|
|
48
|
+
|
|
49
|
+
if is_dotenv_loader:
|
|
50
|
+
patterns.append(self._make_pattern(
|
|
51
|
+
name="dotenv_loader",
|
|
52
|
+
description="Module loads .env via python-dotenv",
|
|
53
|
+
node=tree.root_node, file_path=file_path, confidence=0.95,
|
|
54
|
+
metadata={"role": "config_loader"},
|
|
55
|
+
))
|
|
56
|
+
return patterns
|
|
57
|
+
|
|
58
|
+
def _check_env_call(self, node: Node, source: bytes, file_path: Path) -> Optional[Pattern]:
|
|
59
|
+
func = node.child_by_field_name("function")
|
|
60
|
+
if func is None:
|
|
61
|
+
return None
|
|
62
|
+
func_text = self.parser.node_text(func, source).strip()
|
|
63
|
+
if func_text not in self.ENV_FUNCS:
|
|
64
|
+
return None
|
|
65
|
+
key = self._first_string_arg(node, source)
|
|
66
|
+
return self._make_pattern(
|
|
67
|
+
name="env_read",
|
|
68
|
+
description=f"env-var read via {func_text}({key or '?'})",
|
|
69
|
+
node=node, file_path=file_path, confidence=0.9,
|
|
70
|
+
metadata={"function": func_text, "key": key},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def _check_environ_subscript(self, node: Node, source: bytes, file_path: Path) -> Optional[Pattern]:
|
|
74
|
+
value = node.child_by_field_name("value")
|
|
75
|
+
if value is None:
|
|
76
|
+
return None
|
|
77
|
+
if self.parser.node_text(value, source).strip() != "os.environ":
|
|
78
|
+
return None
|
|
79
|
+
subscript = node.child_by_field_name("subscript")
|
|
80
|
+
key = None
|
|
81
|
+
if subscript is not None and subscript.type == "string":
|
|
82
|
+
key = self.parser.node_text(subscript, source).strip("'\"")
|
|
83
|
+
return self._make_pattern(
|
|
84
|
+
name="env_read",
|
|
85
|
+
description=f"env-var read via os.environ[{key or '?'}]",
|
|
86
|
+
node=node, file_path=file_path, confidence=0.9,
|
|
87
|
+
metadata={"function": "os.environ[]", "key": key},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _check_module_constant(self, node: Node, source: bytes, file_path: Path) -> Optional[Pattern]:
|
|
91
|
+
# only module-level
|
|
92
|
+
p = node.parent
|
|
93
|
+
while p is not None:
|
|
94
|
+
if p.type in ("function_definition", "class_definition"):
|
|
95
|
+
return None
|
|
96
|
+
p = p.parent
|
|
97
|
+
left = node.child_by_field_name("left")
|
|
98
|
+
if left is None or left.type != "identifier":
|
|
99
|
+
return None
|
|
100
|
+
name = self.parser.node_text(left, source)
|
|
101
|
+
if not UPPER_SNAKE.match(name) or len(name) < 2:
|
|
102
|
+
return None
|
|
103
|
+
return self._make_pattern(
|
|
104
|
+
name="module_constant",
|
|
105
|
+
description=f"module-level constant: {name}",
|
|
106
|
+
node=node, file_path=file_path, confidence=0.7,
|
|
107
|
+
metadata={"name": name},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _first_string_arg(self, call_node: Node, source: bytes) -> Optional[str]:
|
|
111
|
+
args = call_node.child_by_field_name("arguments")
|
|
112
|
+
if args is None:
|
|
113
|
+
return None
|
|
114
|
+
for c in args.children:
|
|
115
|
+
if c.type == "string":
|
|
116
|
+
return self.parser.node_text(c, source).strip("'\"")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def _walk(self, node: Node):
|
|
120
|
+
yield node
|
|
121
|
+
for child in node.children:
|
|
122
|
+
yield from self._walk(child)
|