deadpush 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,147 @@
1
+ """
2
+ Production-grade Rust language plugin for deadpush.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from tree_sitter import Language, Node, Tree
10
+ import tree_sitter_rust as tsrust
11
+
12
+ from ..graph import Symbol, make_symbol_id
13
+ from .base import Import, CallSite, LanguagePlugin
14
+
15
+ RUST_LANGUAGE = Language(tsrust.language())
16
+
17
+
18
+ class RustPlugin:
19
+ extensions = [".rs"]
20
+ language = RUST_LANGUAGE
21
+
22
+ def get_parser(self):
23
+ from tree_sitter import Parser
24
+ return Parser(self.language)
25
+
26
+ def parse(self, source: bytes, path: str) -> Tree:
27
+ return self.get_parser().parse(source)
28
+
29
+ def extract_symbols(self, tree: Tree, path: str) -> list[Symbol]:
30
+ symbols: list[Symbol] = []
31
+ root = tree.root_node
32
+
33
+ def walk(node: Node):
34
+ if node.type == "function_item":
35
+ name_node = node.child_by_field_name("name")
36
+ if name_node:
37
+ name = name_node.text.decode("utf-8")
38
+ symbols.append(Symbol(
39
+ id=make_symbol_id(path, name),
40
+ name=name,
41
+ kind="function",
42
+ path=path,
43
+ line=node.start_point[0] + 1,
44
+ is_entry_point=(name == "main"),
45
+ ))
46
+ elif node.type == "struct_item":
47
+ name_node = node.child_by_field_name("name")
48
+ if name_node:
49
+ symbols.append(Symbol(
50
+ id=make_symbol_id(path, name_node.text.decode("utf-8")),
51
+ name=name_node.text.decode("utf-8"),
52
+ kind="class",
53
+ path=path,
54
+ line=node.start_point[0] + 1,
55
+ ))
56
+ elif node.type == "impl_item":
57
+ for child in node.children:
58
+ if child.type == "function_item":
59
+ walk(child)
60
+
61
+ for child in node.children:
62
+ walk(child)
63
+
64
+ walk(root)
65
+
66
+ symbols.append(Symbol(
67
+ id=make_symbol_id(path, Path(path).name),
68
+ name=Path(path).name,
69
+ kind="file",
70
+ path=path,
71
+ line=1
72
+ ))
73
+ return symbols
74
+
75
+ def extract_call_sites(self, tree: Tree, path: str) -> list[CallSite]:
76
+ """Exhaustive Rust call site extraction.
77
+
78
+ Handles function calls, method calls (via field_expression or UFCS),
79
+ associated functions, macros (basic), turbofish ignored.
80
+ """
81
+ calls: list[CallSite] = []
82
+ root = tree.root_node
83
+
84
+ def get_text(n: Node | None) -> str:
85
+ if not n:
86
+ return ""
87
+ return n.text.decode("utf-8", errors="ignore").strip()
88
+
89
+ def walk(node: Node, current_func_id: str | None = None):
90
+ if node.type == "function_item":
91
+ name_node = node.child_by_field_name("name")
92
+ if name_node:
93
+ current_func_id = make_symbol_id(path, get_text(name_node))
94
+
95
+ if node.type == "call_expression":
96
+ func_node = node.child_by_field_name("function")
97
+ if func_node and current_func_id:
98
+ raw = get_text(func_node)
99
+ callee_name = raw
100
+ receiver = None
101
+ is_method = False
102
+
103
+ if func_node.type == "field_expression":
104
+ is_method = True
105
+ value = func_node.child_by_field_name("value")
106
+ field = func_node.child_by_field_name("field")
107
+ callee_name = get_text(field) if field else raw
108
+ receiver = get_text(value) if value else None
109
+ elif func_node.type in ("identifier", "scoped_identifier"):
110
+ callee_name = raw.split("::")[-1]
111
+
112
+ callee_name = callee_name.split("(")[0].strip().split("::<")[0]
113
+
114
+ call = CallSite(
115
+ caller_id=current_func_id,
116
+ callee=callee_name,
117
+ line=node.start_point[0] + 1,
118
+ is_method=is_method,
119
+ receiver=receiver,
120
+ raw_callee_text=raw
121
+ )
122
+ calls.append(call)
123
+
124
+ for child in node.children:
125
+ walk(child, current_func_id)
126
+
127
+ walk(root)
128
+ return calls
129
+
130
+ def extract_imports(self, tree: Tree, path: str) -> list[Import]:
131
+ return [Import(module="crate", names=["*"], level=0)]
132
+
133
+ def detect_entry_points(self, tree: Tree, path: str, config_dynamic_patterns: list[str]) -> list[str]:
134
+ source = tree.root_node.text.decode("utf-8", errors="ignore")
135
+ return ["main"] if "fn main(" in source[:800] else []
136
+
137
+ def classify_dynamic_risk(self, tree: Tree, path: str) -> float:
138
+ text = tree.root_node.text.decode("utf-8", errors="ignore").lower()
139
+ risk = 0.0
140
+ if "unsafe" in text:
141
+ risk += 0.45
142
+ if "dyn " in text:
143
+ risk += 0.25
144
+ return min(risk, 1.0)
145
+
146
+ def supports_suppression_comment(self, line_text: str) -> bool:
147
+ return "// deadpush: ignore" in line_text
@@ -0,0 +1,192 @@
1
+ """
2
+ Production-grade TypeScript (and TSX) language plugin for deadpush.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+
9
+ from tree_sitter import Language, Node, Tree
10
+ import tree_sitter_typescript as tsts
11
+
12
+ from ..graph import Symbol, make_symbol_id
13
+ from .base import Import, CallSite, LanguagePlugin
14
+
15
+ TS_LANGUAGE = Language(tsts.language_typescript())
16
+ TSX_LANGUAGE = Language(tsts.language_tsx())
17
+
18
+
19
+ def _pick_language(path: str) -> Language:
20
+ return TSX_LANGUAGE if Path(path).suffix.lower() == ".tsx" else TS_LANGUAGE
21
+
22
+
23
+ class TypeScriptPlugin:
24
+ extensions = [".ts", ".tsx", ".mts", ".cts"]
25
+ # language attr is set per instance in practice; we pick default TS
26
+ language = TS_LANGUAGE
27
+
28
+ def get_parser(self, path: str | None = None):
29
+ from tree_sitter import Parser
30
+ lang = _pick_language(path or "") if path else self.language
31
+ return Parser(lang)
32
+
33
+ def parse(self, source: bytes, path: str) -> Tree:
34
+ return self.get_parser(path).parse(source)
35
+
36
+ def extract_symbols(self, tree: Tree, path: str) -> list[Symbol]:
37
+ symbols: list[Symbol] = []
38
+ root = tree.root_node
39
+
40
+ def walk(node: Node):
41
+ if node.type in ("function_declaration", "method_definition", "arrow_function"):
42
+ name_node = node.child_by_field_name("name") or node.child_by_field_name("property_identifier")
43
+ if not name_node:
44
+ # arrow assigned to var, rough scan siblings/parent
45
+ for sib in (node.parent.children if node.parent else []):
46
+ if sib.type == "identifier":
47
+ name_node = sib
48
+ break
49
+ if name_node:
50
+ name = name_node.text.decode("utf-8")
51
+ kind = "function"
52
+ if node.type == "method_definition":
53
+ kind = "method"
54
+ symbols.append(Symbol(
55
+ id=make_symbol_id(path, name),
56
+ name=name,
57
+ kind=kind,
58
+ path=path,
59
+ line=node.start_point[0] + 1,
60
+ is_entry_point=(name in ("main", "default", "index")),
61
+ ))
62
+ elif node.type in ("class_declaration", "interface_declaration", "type_alias_declaration"):
63
+ name_node = node.child_by_field_name("name")
64
+ if name_node:
65
+ symbols.append(Symbol(
66
+ id=make_symbol_id(path, name_node.text.decode("utf-8")),
67
+ name=name_node.text.decode("utf-8"),
68
+ kind="class",
69
+ path=path,
70
+ line=node.start_point[0] + 1,
71
+ ))
72
+
73
+ for child in node.children:
74
+ walk(child)
75
+
76
+ walk(root)
77
+
78
+ symbols.append(Symbol(
79
+ id=make_symbol_id(path, Path(path).name),
80
+ name=Path(path).name,
81
+ kind="file",
82
+ path=path,
83
+ line=1
84
+ ))
85
+ return symbols
86
+
87
+ def extract_call_sites(self, tree: Tree, path: str) -> list[CallSite]:
88
+ """Exhaustive call site extraction for TypeScript/TSX.
89
+
90
+ Similar structure to JS but handles TS-specific nodes (e.g. type arguments are ignored).
91
+ Captures direct calls, member calls, new, with receiver info.
92
+ """
93
+ calls: list[CallSite] = []
94
+ root = tree.root_node
95
+
96
+ def get_text(n: Node | None) -> str:
97
+ if not n:
98
+ return ""
99
+ return n.text.decode("utf-8", errors="ignore").strip()
100
+
101
+ def walk(node: Node, current_func_id: str | None = None, current_func_name: str | None = None):
102
+ if node.type in ("function_declaration", "method_definition", "arrow_function", "function_expression"):
103
+ name_node = node.child_by_field_name("name") or node.child_by_field_name("property_identifier")
104
+ func_name = get_text(name_node) if name_node else current_func_name
105
+ if not func_name:
106
+ # assigned to variable
107
+ parent = node.parent
108
+ if parent and parent.type in ("variable_declarator", "assignment_expression"):
109
+ for c in parent.children:
110
+ if c.type == "identifier":
111
+ func_name = get_text(c)
112
+ break
113
+ current_func_id = make_symbol_id(path, func_name) if func_name else current_func_id
114
+ current_func_name = func_name
115
+
116
+ if node.type in ("call_expression", "new_expression"):
117
+ func_node = node.child_by_field_name("function")
118
+ if func_node and current_func_id:
119
+ raw = get_text(func_node)
120
+ is_new = node.type == "new_expression"
121
+ callee_name = raw
122
+ receiver = None
123
+ is_method = False
124
+
125
+ if func_node.type == "member_expression":
126
+ is_method = True
127
+ prop = func_node.child_by_field_name("property")
128
+ obj = func_node.child_by_field_name("object")
129
+ callee_name = get_text(prop) if prop else raw
130
+ receiver = get_text(obj) if obj else None
131
+ elif func_node.type == "identifier":
132
+ callee_name = raw
133
+ else:
134
+ callee_name = raw.split(".")[-1].split("(")[0].strip() or raw
135
+
136
+ callee_name = callee_name.split("(")[0].strip().rstrip(".")
137
+
138
+ call = CallSite(
139
+ caller_id=current_func_id,
140
+ callee=callee_name,
141
+ line=node.start_point[0] + 1,
142
+ column=node.start_point[1] + 1,
143
+ is_method=is_method or is_new,
144
+ receiver=receiver,
145
+ raw_callee_text=raw
146
+ )
147
+ calls.append(call)
148
+
149
+ for child in node.children:
150
+ walk(child, current_func_id, current_func_name)
151
+
152
+ walk(root)
153
+ return calls
154
+
155
+ def extract_imports(self, tree: Tree, path: str) -> list[Import]:
156
+ imports: list[Import] = []
157
+ root = tree.root_node
158
+ for node in root.children:
159
+ if node.type == "import_statement":
160
+ # import foo from "bar" or import {x} from "bar"
161
+ source_node = None
162
+ for c in node.children:
163
+ if c.type == "string":
164
+ source_node = c
165
+ if source_node:
166
+ mod = source_node.text.decode("utf-8").strip('"\'')
167
+ imports.append(Import(module=mod, names=["*"], level=0))
168
+ elif node.type == "import_clause":
169
+ pass # covered above
170
+ return imports or [Import(module=".", names=["*"], level=0)]
171
+
172
+ def detect_entry_points(self, tree: Tree, path: str, config_dynamic_patterns: list[str]) -> list[str]:
173
+ src = tree.root_node.text.decode("utf-8", errors="ignore")[:800]
174
+ if "export default" in src or "function main" in src:
175
+ return ["default", "main"]
176
+ if "app.listen" in src or "server.listen" in src:
177
+ return ["app"]
178
+ return ["main"] if "main(" in src else []
179
+
180
+ def classify_dynamic_risk(self, tree: Tree, path: str) -> float:
181
+ text = tree.root_node.text.decode("utf-8", errors="ignore").lower()
182
+ risk = 0.0
183
+ if "eval(" in text or "new Function(" in text:
184
+ risk += 0.40
185
+ if "require(" in text and "dynamic" in text:
186
+ risk += 0.15
187
+ if "process.env" in text: # config surface
188
+ risk += 0.10
189
+ return min(risk, 1.0)
190
+
191
+ def supports_suppression_comment(self, line_text: str) -> bool:
192
+ return "// deadpush: ignore" in line_text or "/* deadpush: ignore */" in line_text
deadpush/layers.py ADDED
@@ -0,0 +1,197 @@
1
+ """
2
+ Architecture Layer Enforcer — validates import dependencies between layers.
3
+
4
+ AI agents frequently bypass architectural boundaries, creating direct coupling
5
+ between layers that should remain separate (e.g., views importing models directly).
6
+ This module enforces user-defined layer rules during scans.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from fnmatch import fnmatch
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class LayerRule:
20
+ """A single architectural layer definition."""
21
+ name: str
22
+ paths: list[str]
23
+ allowed_imports: list[str]
24
+ disallowed_imports: list[str] = field(default_factory=list)
25
+
26
+
27
+ @dataclass
28
+ class LayerViolation:
29
+ """An import that violates architecture layer rules."""
30
+ file: str
31
+ line: int
32
+ layer: str
33
+ imported_module: str
34
+ rule_type: str # "disallowed" | "outside_layer"
35
+ description: str
36
+ confidence: float = 0.95
37
+
38
+
39
+ # Default layer rules for common architectures
40
+ DEFAULT_LAYERS: list[LayerRule] = [
41
+ LayerRule(
42
+ name="views/presentation",
43
+ paths=["**/views/**", "**/templates/**", "**/pages/**", "**/components/**"],
44
+ allowed_imports=["controllers", "services", "utils", "helpers"],
45
+ disallowed_imports=["models", "db", "database", "repositories"],
46
+ ),
47
+ LayerRule(
48
+ name="controllers/handlers",
49
+ paths=["**/controllers/**", "**/handlers/**", "**/routes/**"],
50
+ allowed_imports=["services", "models", "utils"],
51
+ disallowed_imports=[],
52
+ ),
53
+ LayerRule(
54
+ name="services",
55
+ paths=["**/services/**", "**/use_cases/**", "**/domain/**"],
56
+ allowed_imports=["models", "repositories", "utils", "infrastructure"],
57
+ disallowed_imports=["views", "templates", "ui"],
58
+ ),
59
+ LayerRule(
60
+ name="models/entities",
61
+ paths=["**/models/**", "**/entities/**"],
62
+ allowed_imports=["utils"],
63
+ disallowed_imports=["views", "controllers", "services", "ui"],
64
+ ),
65
+ ]
66
+
67
+
68
+ class LayerEnforcer:
69
+ """Enforces architectural layer import rules."""
70
+
71
+ def __init__(self, layers: list[LayerRule] | None = None):
72
+ self.layers = layers or DEFAULT_LAYERS
73
+
74
+ def _get_layer_for_file(self, rel_path: str) -> LayerRule | None:
75
+ """Find which layer a file belongs to based on its path."""
76
+ rp = rel_path.replace("\\", "/")
77
+ for layer in self.layers:
78
+ for pat in layer.paths:
79
+ if fnmatch(rp, pat) or fnmatch(rp, "**/" + pat):
80
+ return layer
81
+ return None
82
+
83
+ def _is_import_allowed(self, import_module: str, layer: LayerRule) -> bool:
84
+ """Check if an import is allowed from the given layer."""
85
+ imp = import_module.lower().replace("_", "").replace("-", "").replace(".", "/")
86
+
87
+ # Check explicit disallow list
88
+ for disallowed in layer.disallowed_imports:
89
+ d = disallowed.lower().replace("_", "").replace("-", "")
90
+ if d in imp:
91
+ return False
92
+
93
+ # Check allow list — if empty, everything is allowed (catch-all layer)
94
+ if not layer.allowed_imports:
95
+ return True
96
+
97
+ for allowed in layer.allowed_imports:
98
+ a = allowed.lower().replace("_", "").replace("-", "")
99
+ if a in imp:
100
+ return True
101
+
102
+ # Relative imports from same layer are allowed
103
+ return False
104
+
105
+ def analyze_imports(
106
+ self,
107
+ rel_path: str,
108
+ imports: list[tuple[str, int]], # (module_name, line_number)
109
+ ) -> list[LayerViolation]:
110
+ """Check a file's imports against layer rules."""
111
+ violations: list[LayerViolation] = []
112
+ layer = self._get_layer_for_file(rel_path)
113
+ if layer is None:
114
+ return violations
115
+
116
+ for module, line in imports:
117
+ # Skip relative imports
118
+ if module.startswith(".") or module.startswith(".."):
119
+ continue
120
+
121
+ if not self._is_import_allowed(module, layer):
122
+ violations.append(LayerViolation(
123
+ file=rel_path,
124
+ line=line,
125
+ layer=layer.name,
126
+ imported_module=module,
127
+ rule_type="disallowed",
128
+ description=f"'{rel_path}' ({layer.name}) imports '{module}' which is outside its allowed dependencies",
129
+ ))
130
+
131
+ return violations
132
+
133
+ def analyze_batch(self, files: list[Any]) -> list[LayerViolation]:
134
+ """Analyze all source files for layer violations.
135
+
136
+ Uses regex import extraction on each file, then checks against layer rules.
137
+ """
138
+ violations: list[LayerViolation] = []
139
+
140
+ for f in files:
141
+ if not getattr(f, "is_text", True):
142
+ continue
143
+ rel_path = str(getattr(f, "rel_path", f.path))
144
+ try:
145
+ source = f.path.read_text(encoding="utf-8", errors="ignore")
146
+ imports_list = self.extract_imports_regex(source, f.path.suffix)
147
+ if imports_list:
148
+ violations.extend(self.analyze_imports(rel_path, imports_list))
149
+ except Exception:
150
+ pass
151
+
152
+ return violations
153
+
154
+ def extract_imports_regex(self, source: str, suffix: str) -> list[tuple[str, int]]:
155
+ """Extract import statements from source text using regex."""
156
+ imports: list[tuple[str, int]] = []
157
+ lines = source.splitlines()
158
+
159
+ if suffix == ".py":
160
+ for i, line in enumerate(lines, 1):
161
+ m = re.match(r'^\s*import\s+(\S+)', line)
162
+ if m:
163
+ imports.append((m.group(1).split(".")[0], i))
164
+ m = re.match(r'^\s*from\s+(\S+)\s+import', line)
165
+ if m:
166
+ imports.append((m.group(1).split(".")[0], i))
167
+ elif suffix in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"):
168
+ for i, line in enumerate(lines, 1):
169
+ m = re.match(r'^\s*import\s+(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)\s+from\s+[\'"]([^\'"]+)[\'"]', line)
170
+ if m:
171
+ mod = m.group(1).split("/")[0]
172
+ if mod.startswith("."):
173
+ continue
174
+ imports.append((mod, i))
175
+ m = re.match(r'^\s*(?:const|let|var)\s+\w+\s*=\s*require\s*\([\'"]([^\'"]+)[\'"]', line)
176
+ if m:
177
+ mod = m.group(1).split("/")[0]
178
+ if mod.startswith("."):
179
+ continue
180
+ imports.append((mod, i))
181
+ elif suffix == ".go":
182
+ for i, line in enumerate(lines, 1):
183
+ m = re.match(r'^\s*import\s+[\'"]', line)
184
+ if m:
185
+ continue # handled by next lines
186
+ m = re.match(r'^\s*[\'"](\S+)[\'"]', line)
187
+ if m:
188
+ mod = m.group(1).split("/")[0]
189
+ imports.append((mod, i))
190
+ elif suffix == ".rs":
191
+ for i, line in enumerate(lines, 1):
192
+ m = re.match(r'^\s*use\s+(\S+)', line)
193
+ if m:
194
+ mod = m.group(1).split("::")[0]
195
+ imports.append((mod, i))
196
+
197
+ return imports