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,128 @@
1
+ """Data access / ORM pattern detectors.
2
+
3
+ Finds SQLAlchemy, Django ORM, Peewee, Tortoise queries.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Dict, Any, Optional
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 DataAccessDetector(BaseDetector):
18
+ """Detects database access patterns."""
19
+
20
+ ORM_PATTERNS = {
21
+ "sqlalchemy": [
22
+ "session.query", "Query(", ".filter(", ".all()", ".first()",
23
+ ".join(", ".add(", ".commit()", ".rollback()",
24
+ ],
25
+ "django": [
26
+ ".objects.filter", ".objects.get", ".objects.all", ".objects.create",
27
+ ".save(", ".delete(", ".update(", ".aggregate(",
28
+ ],
29
+ "peewee": [
30
+ ".select()", ".where(", ".join(", ".get()", ".create(",
31
+ ],
32
+ "tortoise": [
33
+ ".filter(", ".get(", ".all()", ".create(", ".update(",
34
+ ],
35
+ }
36
+
37
+ def __init__(self, parser: PythonParser):
38
+ super().__init__(parser)
39
+
40
+ @property
41
+ def category(self) -> PatternCategory:
42
+ return PatternCategory.DATA_ACCESS
43
+
44
+ def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
45
+ patterns = []
46
+
47
+ for node in self._walk(tree.root_node):
48
+ if node.type == "call":
49
+ pattern = self._analyze_call(node, source_bytes, file_path)
50
+ if pattern:
51
+ patterns.append(pattern)
52
+ elif node.type == "attribute":
53
+ pattern = self._analyze_attribute_chain(node, source_bytes, file_path)
54
+ if pattern:
55
+ patterns.append(pattern)
56
+
57
+ return patterns
58
+
59
+ def _analyze_call(self, node: Node, source: bytes, path: Path) -> Optional[Pattern]:
60
+ text = self.parser.node_text(node, source)
61
+
62
+ for orm, markers in self.ORM_PATTERNS.items():
63
+ for marker in markers:
64
+ if marker in text:
65
+ return self._make_pattern(
66
+ name=f"{orm}_query",
67
+ description=f"{orm.capitalize()} database query: {text[:60]}",
68
+ node=node,
69
+ file_path=path,
70
+ confidence=0.8,
71
+ metadata={
72
+ "orm": orm,
73
+ "operation": marker.strip(".()"),
74
+ "query_text": text[:200],
75
+ }
76
+ )
77
+
78
+ func_node = node.child_by_field_name("function")
79
+ if func_node:
80
+ func_text = self.parser.node_text(func_node, source)
81
+ if func_text in {"execute", "executemany", "run"}:
82
+ args = node.child_by_field_name("arguments")
83
+ if args:
84
+ for arg in args.children:
85
+ if arg.type == "string":
86
+ arg_text = self.parser.node_text(arg, source).lower()
87
+ if any(kw in arg_text for kw in ["select", "insert", "update", "delete"]):
88
+ return self._make_pattern(
89
+ name="raw_sql",
90
+ description=f"Raw SQL execution: {func_text}",
91
+ node=node,
92
+ file_path=path,
93
+ confidence=0.9,
94
+ metadata={
95
+ "type": "raw_sql",
96
+ "sql_preview": arg_text[:100],
97
+ }
98
+ )
99
+
100
+ return None
101
+
102
+ def _analyze_attribute_chain(self, node: Node, source: bytes, path: Path) -> Optional[Pattern]:
103
+ text = self.parser.node_text(node, source)
104
+
105
+ if ".objects." in text:
106
+ parts = text.split(".objects.")
107
+ if len(parts) >= 2:
108
+ model = parts[0].split(".")[-1]
109
+ operation = parts[1].split("(")[0]
110
+ return self._make_pattern(
111
+ name="django_orm_chain",
112
+ description=f"Django ORM: {model}.objects.{operation}",
113
+ node=node,
114
+ file_path=path,
115
+ confidence=0.85,
116
+ metadata={
117
+ "orm": "django",
118
+ "model": model,
119
+ "operation": operation,
120
+ }
121
+ )
122
+
123
+ return None
124
+
125
+ def _walk(self, node):
126
+ yield node
127
+ for child in node.children:
128
+ yield from self._walk(child)
@@ -0,0 +1,122 @@
1
+ """Decorator pattern detectors.
2
+
3
+ Finds custom decorators, caching decorators, auth decorators, etc.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Dict, Any, Set
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 DecoratorDetector(BaseDetector):
18
+ """Detects decorator usage patterns."""
19
+
20
+ BUILT_IN = {"staticmethod", "classmethod", "property", "abstractmethod", "dataclass"}
21
+ CACHE_DECORATORS = {"lru_cache", "cache", "cached_property", "memoize"}
22
+ AUTH_DECORATORS = {"login_required", "permission_required", "requires_auth", "authenticated"}
23
+
24
+ def __init__(self, parser: PythonParser):
25
+ super().__init__(parser)
26
+
27
+ @property
28
+ def category(self) -> PatternCategory:
29
+ return PatternCategory.DECORATORS
30
+
31
+ def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
32
+ patterns = []
33
+
34
+ for node in self._walk(tree.root_node):
35
+ if node.type == "decorator":
36
+ pattern = self._analyze_decorator(node, source_bytes, file_path)
37
+ if pattern:
38
+ patterns.append(pattern)
39
+ elif node.type == "function_definition":
40
+ if self._is_decorator_factory(node, source_bytes):
41
+ patterns.append(self._make_pattern(
42
+ name="custom_decorator",
43
+ description="Custom decorator factory function",
44
+ node=node,
45
+ file_path=file_path,
46
+ confidence=0.8,
47
+ metadata={"type": "decorator_factory"}
48
+ ))
49
+
50
+ return patterns
51
+
52
+ def _analyze_decorator(self, node: Node, source: bytes, path: Path) -> Optional[Pattern]:
53
+ text = self.parser.node_text(node, source).strip()
54
+ name = self._extract_name(node, source)
55
+
56
+ if name in self.BUILT_IN:
57
+ return self._make_pattern(
58
+ name=f"builtin_decorator:{name}",
59
+ description=f"Built-in decorator: @{name}",
60
+ node=node,
61
+ file_path=path,
62
+ confidence=0.99,
63
+ metadata={"category": "builtin", "decorator": name}
64
+ )
65
+ elif name in self.CACHE_DECORATORS:
66
+ return self._make_pattern(
67
+ name=f"cache_decorator:{name}",
68
+ description=f"Caching decorator: @{name}",
69
+ node=node,
70
+ file_path=path,
71
+ confidence=0.95,
72
+ metadata={"category": "caching", "decorator": name}
73
+ )
74
+ elif name in self.AUTH_DECORATORS:
75
+ return self._make_pattern(
76
+ name=f"auth_decorator:{name}",
77
+ description=f"Authentication decorator: @{name}",
78
+ node=node,
79
+ file_path=path,
80
+ confidence=0.9,
81
+ metadata={"category": "auth", "decorator": name}
82
+ )
83
+ else:
84
+ return self._make_pattern(
85
+ name=f"custom_decorator:{name}",
86
+ description=f"Custom decorator: @{name}",
87
+ node=node,
88
+ file_path=path,
89
+ confidence=0.7,
90
+ metadata={"category": "custom", "decorator": name}
91
+ )
92
+
93
+ def _extract_name(self, node: Node, source: bytes) -> str:
94
+ for child in node.children:
95
+ if child.type == "identifier":
96
+ return self.parser.node_text(child, source)
97
+ elif child.type == "attribute":
98
+ attr = child.child_by_field_name("attribute")
99
+ if attr:
100
+ return self.parser.node_text(attr, source)
101
+ elif child.type == "call":
102
+ func = child.child_by_field_name("function")
103
+ if func:
104
+ if func.type == "identifier":
105
+ return self.parser.node_text(func, source)
106
+ elif func.type == "attribute":
107
+ attr = func.child_by_field_name("attribute")
108
+ if attr:
109
+ return self.parser.node_text(attr, source)
110
+ return "unknown"
111
+
112
+ def _is_decorator_factory(self, node: Node, source: bytes) -> bool:
113
+ body = node.child_by_field_name("body")
114
+ if not body:
115
+ return False
116
+ body_text = self.parser.node_text(body, source)
117
+ return "def " in body_text and "return" in body_text
118
+
119
+ def _walk(self, node):
120
+ yield node
121
+ for child in node.children:
122
+ yield from self._walk(child)
@@ -0,0 +1,260 @@
1
+ """Dynamic dispatch / reflection pattern detector (P2.12).
2
+
3
+ Flags 8 categories of dynamic code that defeat static analysis:
4
+ getattr/setattr/delattr with non-literal name args,
5
+ __getattr__/__getattribute__ definitions (open attribute universe),
6
+ exec/eval (cross-tagged with SECURITY),
7
+ importlib.import_module / __import__ calls,
8
+ metaclass= class kwargs,
9
+ __init_subclass__ definitions (subclass registries),
10
+ subscript-call dispatch (d[k]()),
11
+ decorator bodies that return a new locally-defined class (decorator rebind).
12
+
13
+ Each Pattern.metadata carries:
14
+ resolvable: "candidates" (literals available), "partial", or "none"
15
+ reason: short human-readable explanation for the agent
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import List, Optional
22
+
23
+ from tree_sitter import Node, Tree
24
+
25
+ from adl_drift.detectors.base import BaseDetector
26
+ from adl_drift.utils.types import Pattern, PatternCategory, SourcePosition
27
+
28
+
29
+ class DynamicPatternDetector(BaseDetector):
30
+ """Detects patterns where static resolution is unreliable or impossible."""
31
+
32
+ @property
33
+ def category(self) -> PatternCategory:
34
+ return PatternCategory.DYNAMIC
35
+
36
+ def detect(self, tree: Tree, source_bytes: bytes, file_path: Path) -> List[Pattern]:
37
+ patterns: List[Pattern] = []
38
+ for node in self._walk(tree.root_node):
39
+ t = node.type
40
+ if t == "call":
41
+ patterns.extend(self._check_call(node, source_bytes, file_path))
42
+ elif t == "subscript":
43
+ patterns.extend(self._check_subscript_call(node, source_bytes, file_path))
44
+ elif t == "class_definition":
45
+ patterns.extend(self._check_class_def(node, source_bytes, file_path))
46
+ elif t == "function_definition":
47
+ patterns.extend(self._check_func_def(node, source_bytes, file_path))
48
+ return patterns
49
+
50
+ # ------------------------------------------------------------------ calls
51
+
52
+ def _check_call(self, node: Node, source: bytes, file_path: Path) -> List[Pattern]:
53
+ out: List[Pattern] = []
54
+ func = node.child_by_field_name("function")
55
+ if func is None:
56
+ return out
57
+ func_text = self.parser.node_text(func, source).strip()
58
+
59
+ # getattr / setattr / delattr with non-literal name argument
60
+ if func_text in ("getattr", "setattr", "delattr"):
61
+ args = node.child_by_field_name("arguments")
62
+ name_arg = self._nth_positional_arg(args, 1) # second positional
63
+ if name_arg is not None and not self._is_literal_string(name_arg):
64
+ resolvable = "candidates" if self._is_prefixed_fstring(name_arg, source) else "none"
65
+ out.append(self._make_pattern(
66
+ name=f"dynamic_{func_text}",
67
+ description=(
68
+ f"{func_text}() with non-literal name — "
69
+ "attribute resolution bypasses static analysis"
70
+ ),
71
+ node=node, file_path=file_path, confidence=0.9,
72
+ metadata={
73
+ "function": func_text,
74
+ "resolvable": resolvable,
75
+ "reason": "non-literal attribute name; candidates may be recoverable from f-string prefix",
76
+ },
77
+ ))
78
+
79
+ # importlib.import_module / __import__
80
+ elif func_text in ("importlib.import_module", "__import__"):
81
+ args = node.child_by_field_name("arguments")
82
+ mod_arg = self._nth_positional_arg(args, 0)
83
+ resolvable = "candidates" if (
84
+ mod_arg is not None and self._is_literal_string(mod_arg)
85
+ ) else ("candidates" if (
86
+ mod_arg is not None and self._is_prefixed_fstring(mod_arg, source)
87
+ ) else "none")
88
+ out.append(self._make_pattern(
89
+ name="dynamic_import",
90
+ description=(
91
+ f"{func_text}() — dynamically imported module "
92
+ "invisible to import graph"
93
+ ),
94
+ node=node, file_path=file_path, confidence=0.85,
95
+ metadata={
96
+ "function": func_text,
97
+ "resolvable": resolvable,
98
+ "reason": "dynamic import; module may be inferrable from literal prefix",
99
+ },
100
+ ))
101
+
102
+ # exec / eval (cross-tag: also caught by SecurityDetector with HIGH severity)
103
+ elif func_text in ("exec", "eval"):
104
+ args = node.child_by_field_name("arguments")
105
+ code_arg = self._nth_positional_arg(args, 0)
106
+ resolvable = "candidates" if (
107
+ code_arg is not None and self._is_literal_string(code_arg)
108
+ ) else "none"
109
+ out.append(self._make_pattern(
110
+ name="dynamic_exec_eval",
111
+ description=(
112
+ f"{func_text}() — emitted symbols invisible to graph; "
113
+ "static analysis confidence reduced"
114
+ ),
115
+ node=node, file_path=file_path, confidence=0.8,
116
+ metadata={
117
+ "function": func_text,
118
+ "resolvable": resolvable,
119
+ "reason": "synthesized symbols not in graph; recoverable when arg is a literal template",
120
+ },
121
+ ))
122
+
123
+ return out
124
+
125
+ # ---------------------------------------------------- subscript-call dispatch
126
+
127
+ def _check_subscript_call(self, node: Node, source: bytes, file_path: Path) -> List[Pattern]:
128
+ """Detect d[k]() — parent of subscript node is a call."""
129
+ parent = node.parent
130
+ if parent is None or parent.type != "call":
131
+ return []
132
+ func_field = parent.child_by_field_name("function")
133
+ if func_field is None or func_field != node:
134
+ return []
135
+ return [self._make_pattern(
136
+ name="subscript_dispatch",
137
+ description=(
138
+ "subscript-call dispatch d[k]() — target determined at runtime; "
139
+ "CALLS edges missing for all candidates"
140
+ ),
141
+ node=parent, file_path=file_path, confidence=0.8,
142
+ metadata={
143
+ "resolvable": "candidates",
144
+ "reason": "literal dict values may be recoverable as candidate targets",
145
+ },
146
+ )]
147
+
148
+ # ------------------------------------------------------------ class-level
149
+
150
+ def _check_class_def(self, node: Node, source: bytes, file_path: Path) -> List[Pattern]:
151
+ out: List[Pattern] = []
152
+
153
+ # metaclass= keyword argument
154
+ args_node = node.child_by_field_name("superclasses")
155
+ if args_node is not None:
156
+ for child in args_node.children:
157
+ if child.type == "keyword_argument":
158
+ kname = child.child_by_field_name("name")
159
+ if kname is not None and self.parser.node_text(kname, source).strip() == "metaclass":
160
+ out.append(self._make_pattern(
161
+ name="metaclass_class",
162
+ description=(
163
+ "class uses metaclass= — injected attributes/methods "
164
+ "invisible to static graph"
165
+ ),
166
+ node=node, file_path=file_path, confidence=0.9,
167
+ metadata={
168
+ "resolvable": "partial",
169
+ "reason": "metaclass __new__ may inject literal-keyed namespace entries",
170
+ },
171
+ ))
172
+ break
173
+
174
+ return out
175
+
176
+ # ----------------------------------------------------------- function-level
177
+
178
+ def _check_func_def(self, node: Node, source: bytes, file_path: Path) -> List[Pattern]:
179
+ out: List[Pattern] = []
180
+ name_node = node.child_by_field_name("name")
181
+ if name_node is None:
182
+ return out
183
+ name = self.parser.node_text(name_node, source).strip()
184
+
185
+ # __getattr__ / __getattribute__ definitions → open attribute universe
186
+ if name in ("__getattr__", "__getattribute__"):
187
+ out.append(self._make_pattern(
188
+ name="open_attr_universe",
189
+ description=(
190
+ f"{name}() defined — attribute reads on instances are "
191
+ "unresolvable; impact routed through this method"
192
+ ),
193
+ node=node, file_path=file_path, confidence=0.9,
194
+ metadata={
195
+ "function": name,
196
+ "resolvable": "none",
197
+ "reason": "open attribute universe; unresolved reads should emit DYNAMIC_ATTR edges",
198
+ },
199
+ ))
200
+
201
+ # __init_subclass__ → subclass registry pattern
202
+ elif name == "__init_subclass__":
203
+ out.append(self._make_pattern(
204
+ name="subclass_registry",
205
+ description=(
206
+ "__init_subclass__ defined — subclasses auto-registered; "
207
+ "registry lookup may be recoverable via INHERITS walk"
208
+ ),
209
+ node=node, file_path=file_path, confidence=0.85,
210
+ metadata={
211
+ "function": name,
212
+ "resolvable": "candidates",
213
+ "reason": "INHERITS edges to subclasses exist; kwarg literals identify registry keys",
214
+ },
215
+ ))
216
+
217
+ return out
218
+
219
+ # ----------------------------------------------------------------- helpers
220
+
221
+ @staticmethod
222
+ def _nth_positional_arg(args_node: Optional[Node], n: int) -> Optional[Node]:
223
+ """Return the n-th positional argument node, skipping *args/**kwargs."""
224
+ if args_node is None:
225
+ return None
226
+ pos = 0
227
+ for child in args_node.children:
228
+ if child.type in (",", "(", ")", "*", "**"):
229
+ continue
230
+ if child.type in ("keyword_argument", "dictionary_splat_argument",
231
+ "list_splat_argument"):
232
+ continue
233
+ if pos == n:
234
+ return child
235
+ pos += 1
236
+ return None
237
+
238
+ @staticmethod
239
+ def _is_literal_string(node: Node) -> bool:
240
+ return node.type in ("string", "concatenated_string")
241
+
242
+ @staticmethod
243
+ def _is_prefixed_fstring(node: Node, source: bytes) -> bool:
244
+ """True when the node is an f-string with at least one literal prefix."""
245
+ if node.type not in ("string", "concatenated_string"):
246
+ return False
247
+ try:
248
+ text = source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
249
+ except Exception:
250
+ return False
251
+ return text.startswith(('f"', "f'", 'f"""', "f'''"))
252
+
253
+ def _walk(self, node: Node):
254
+ yield node
255
+ for child in node.children:
256
+ yield from self._walk(child)
257
+
258
+ @staticmethod
259
+ def _line_position(line: int) -> SourcePosition:
260
+ return SourcePosition(line=line, column=0, end_line=line, end_column=0)
@@ -0,0 +1,134 @@
1
+ """Error handling pattern detectors.
2
+
3
+ Finds try/except blocks, custom exceptions, error propagation patterns.
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 ErrorPatternDetector(BaseDetector):
18
+ """Detects error handling patterns."""
19
+
20
+ def __init__(self, parser: PythonParser):
21
+ super().__init__(parser)
22
+
23
+ @property
24
+ def category(self) -> PatternCategory:
25
+ return PatternCategory.ERRORS
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 == "try_statement":
32
+ patterns.extend(self._analyze_try_block(node, source_bytes, file_path))
33
+ elif node.type == "raise_statement":
34
+ patterns.extend(self._analyze_raise(node, source_bytes, file_path))
35
+ elif node.type == "class_definition":
36
+ patterns.extend(self._analyze_exception_class(node, source_bytes, file_path))
37
+
38
+ return patterns
39
+
40
+ def _analyze_try_block(self, node: Node, source: bytes, path: Path) -> List[Pattern]:
41
+ patterns = []
42
+
43
+ for child in node.children:
44
+ if child.type != "except_clause":
45
+ continue
46
+
47
+ except_text = self.parser.node_text(child, source)
48
+
49
+ if "except:" in except_text and "except Exception" not in except_text:
50
+ patterns.append(self._make_pattern(
51
+ name="bare_except",
52
+ description="Bare except clause catches KeyboardInterrupt, SystemExit",
53
+ node=child,
54
+ file_path=path,
55
+ confidence=0.85,
56
+ metadata={"severity": "warning", "fix": "Use 'except Exception:'"}
57
+ ))
58
+ elif "except Exception:" in except_text:
59
+ patterns.append(self._make_pattern(
60
+ name="broad_exception",
61
+ description="Catching all exceptions may hide bugs",
62
+ node=child,
63
+ file_path=path,
64
+ confidence=0.7,
65
+ metadata={"severity": "info"}
66
+ ))
67
+
68
+ except_body = child.child_by_field_name("body")
69
+ if except_body:
70
+ body_text = self.parser.node_text(except_body, source)
71
+ if "pass" in body_text and len(body_text.strip()) < 10:
72
+ patterns.append(self._make_pattern(
73
+ name="empty_except",
74
+ description="Except block does nothing (pass)",
75
+ node=child,
76
+ file_path=path,
77
+ confidence=0.9,
78
+ metadata={"severity": "error"}
79
+ ))
80
+
81
+ return patterns
82
+
83
+ def _analyze_raise(self, node: Node, source: bytes, path: Path) -> List[Pattern]:
84
+ patterns = []
85
+ text = self.parser.node_text(node, source)
86
+
87
+ if "from" not in text:
88
+ parent = node.parent
89
+ while parent:
90
+ if parent.type == "except_clause":
91
+ patterns.append(self._make_pattern(
92
+ name="raise_without_from",
93
+ description="Re-raising without 'from' loses original traceback context",
94
+ node=node,
95
+ file_path=path,
96
+ confidence=0.6,
97
+ metadata={"severity": "info", "fix": "Use 'raise NewError from old_e'"}
98
+ ))
99
+ break
100
+ parent = parent.parent
101
+
102
+ return patterns
103
+
104
+ def _analyze_exception_class(self, node: Node, source: bytes, path: Path) -> List[Pattern]:
105
+ patterns = []
106
+
107
+ bases = node.child_by_field_name("superclasses")
108
+ if not bases:
109
+ return patterns
110
+
111
+ bases_text = self.parser.node_text(bases, source)
112
+ if "Exception" in bases_text or "BaseException" in bases_text:
113
+ name_node = node.child_by_field_name("name")
114
+ name = self.parser.node_text(name_node, source) if name_node else "<unknown>"
115
+
116
+ patterns.append(self._make_pattern(
117
+ name="custom_exception",
118
+ description=f"Custom exception class: {name}",
119
+ node=node,
120
+ file_path=path,
121
+ confidence=0.95,
122
+ metadata={
123
+ "exception_name": name,
124
+ "bases": bases_text,
125
+ "severity": "info",
126
+ }
127
+ ))
128
+
129
+ return patterns
130
+
131
+ def _walk(self, node):
132
+ yield node
133
+ for child in node.children:
134
+ yield from self._walk(child)