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