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.
- deadpush/__init__.py +1 -0
- deadpush/churn.py +189 -0
- deadpush/cli.py +1584 -0
- deadpush/comments.py +265 -0
- deadpush/complexity.py +254 -0
- deadpush/config.py +284 -0
- deadpush/crawler.py +133 -0
- deadpush/deadness.py +477 -0
- deadpush/debris.py +729 -0
- deadpush/deps.py +323 -0
- deadpush/deps_guard.py +382 -0
- deadpush/entrypoints.py +193 -0
- deadpush/graph.py +401 -0
- deadpush/guard.py +1386 -0
- deadpush/hooks.py +369 -0
- deadpush/importgraph.py +122 -0
- deadpush/imports.py +239 -0
- deadpush/intercept.py +995 -0
- deadpush/languages/__init__.py +143 -0
- deadpush/languages/base.py +70 -0
- deadpush/languages/cpp.py +150 -0
- deadpush/languages/go_.py +177 -0
- deadpush/languages/java.py +185 -0
- deadpush/languages/javascript.py +202 -0
- deadpush/languages/python_.py +278 -0
- deadpush/languages/rust.py +147 -0
- deadpush/languages/typescript.py +192 -0
- deadpush/layers.py +197 -0
- deadpush/mcp_server.py +1061 -0
- deadpush/reachability.py +183 -0
- deadpush/registration.py +280 -0
- deadpush/report.py +113 -0
- deadpush/rules.py +190 -0
- deadpush/sarif.py +123 -0
- deadpush/scorer.py +151 -0
- deadpush/security.py +187 -0
- deadpush/session.py +224 -0
- deadpush/tests.py +333 -0
- deadpush/ui.py +156 -0
- deadpush/verifier.py +168 -0
- deadpush/watch.py +103 -0
- deadpush-0.2.0.dist-info/METADATA +230 -0
- deadpush-0.2.0.dist-info/RECORD +46 -0
- deadpush-0.2.0.dist-info/WHEEL +4 -0
- deadpush-0.2.0.dist-info/entry_points.txt +2 -0
- deadpush-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|