skylos 1.0.10__py3-none-any.whl → 2.5.2__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.
- skylos/__init__.py +9 -3
- skylos/analyzer.py +674 -168
- skylos/cfg_visitor.py +60 -0
- skylos/cli.py +719 -235
- skylos/codemods.py +277 -0
- skylos/config.py +50 -0
- skylos/constants.py +78 -0
- skylos/gatekeeper.py +147 -0
- skylos/linter.py +18 -0
- skylos/rules/base.py +20 -0
- skylos/rules/danger/calls.py +119 -0
- skylos/rules/danger/danger.py +157 -0
- skylos/rules/danger/danger_cmd/cmd_flow.py +75 -0
- skylos/rules/danger/danger_fs/__init__.py +0 -0
- skylos/rules/danger/danger_fs/path_flow.py +79 -0
- skylos/rules/danger/danger_net/__init__.py +0 -0
- skylos/rules/danger/danger_net/ssrf_flow.py +80 -0
- skylos/rules/danger/danger_sql/__init__.py +0 -0
- skylos/rules/danger/danger_sql/sql_flow.py +245 -0
- skylos/rules/danger/danger_sql/sql_raw_flow.py +96 -0
- skylos/rules/danger/danger_web/__init__.py +0 -0
- skylos/rules/danger/danger_web/xss_flow.py +170 -0
- skylos/rules/danger/taint.py +110 -0
- skylos/rules/quality/__init__.py +0 -0
- skylos/rules/quality/complexity.py +95 -0
- skylos/rules/quality/logic.py +96 -0
- skylos/rules/quality/nesting.py +101 -0
- skylos/rules/quality/structure.py +99 -0
- skylos/rules/secrets.py +325 -0
- skylos/server.py +554 -0
- skylos/visitor.py +502 -90
- skylos/visitors/__init__.py +0 -0
- skylos/visitors/framework_aware.py +437 -0
- skylos/visitors/test_aware.py +74 -0
- skylos-2.5.2.dist-info/METADATA +21 -0
- skylos-2.5.2.dist-info/RECORD +42 -0
- {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/WHEEL +1 -1
- {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/top_level.txt +0 -1
- skylos-1.0.10.dist-info/METADATA +0 -8
- skylos-1.0.10.dist-info/RECORD +0 -21
- test/compare_tools.py +0 -604
- test/diagnostics.py +0 -364
- test/sample_repo/app.py +0 -13
- test/sample_repo/sample_repo/commands.py +0 -81
- test/sample_repo/sample_repo/models.py +0 -122
- test/sample_repo/sample_repo/routes.py +0 -89
- test/sample_repo/sample_repo/utils.py +0 -36
- test/test_skylos.py +0 -456
- test/test_visitor.py +0 -220
- {test → skylos/rules}/__init__.py +0 -0
- {test/sample_repo → skylos/rules/danger}/__init__.py +0 -0
- {test/sample_repo/sample_repo → skylos/rules/danger/danger_cmd}/__init__.py +0 -0
- {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/entry_points.txt +0 -0
skylos/codemods.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import libcst as cst
|
|
3
|
+
from libcst.metadata import PositionProvider
|
|
4
|
+
from libcst.helpers import get_full_name_for_node
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _CommentOutBlock(cst.CSTTransformer):
|
|
8
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
9
|
+
|
|
10
|
+
def __init__(self, module_code, marker="SKYLOS DEADCODE"):
|
|
11
|
+
self.module_code = module_code.splitlines(True)
|
|
12
|
+
self.marker = marker
|
|
13
|
+
|
|
14
|
+
def _comment_block(self, start_line, end_line):
|
|
15
|
+
lines = self.module_code[start_line - 1 : end_line]
|
|
16
|
+
out = []
|
|
17
|
+
out.append(
|
|
18
|
+
cst.EmptyLine(
|
|
19
|
+
comment=cst.Comment(
|
|
20
|
+
f"# {self.marker} START (lines {start_line}-{end_line})"
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
for raw in lines:
|
|
25
|
+
out.append(cst.EmptyLine(comment=cst.Comment("# " + raw.rstrip("\n"))))
|
|
26
|
+
out.append(cst.EmptyLine(comment=cst.Comment(f"# {self.marker} END")))
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _CommentOutFunctionAtLine(_CommentOutBlock):
|
|
31
|
+
def __init__(self, func_name, target_line, module_code, marker):
|
|
32
|
+
super().__init__(module_code, marker)
|
|
33
|
+
self.func_name = func_name
|
|
34
|
+
self.target_line = target_line
|
|
35
|
+
self.changed = False
|
|
36
|
+
|
|
37
|
+
def _is_target(self, node: cst.CSTNode):
|
|
38
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
39
|
+
return pos and pos.start.line == self.target_line
|
|
40
|
+
|
|
41
|
+
def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
|
|
42
|
+
target = self.func_name.split(".")[-1]
|
|
43
|
+
if self._is_target(orig) and (orig.name.value == target):
|
|
44
|
+
self.changed = True
|
|
45
|
+
pos = self.get_metadata(PositionProvider, orig)
|
|
46
|
+
return cst.FlattenSentinel(
|
|
47
|
+
self._comment_block(pos.start.line, pos.end.line)
|
|
48
|
+
)
|
|
49
|
+
return updated
|
|
50
|
+
|
|
51
|
+
def leave_AsyncFunctionDef(
|
|
52
|
+
self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef
|
|
53
|
+
):
|
|
54
|
+
target = self.func_name.split(".")[-1]
|
|
55
|
+
if self._is_target(orig) and (orig.name.value == target):
|
|
56
|
+
self.changed = True
|
|
57
|
+
pos = self.get_metadata(PositionProvider, orig)
|
|
58
|
+
return cst.FlattenSentinel(
|
|
59
|
+
self._comment_block(pos.start.line, pos.end.line)
|
|
60
|
+
)
|
|
61
|
+
return updated
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _CommentOutImportAtLine(_CommentOutBlock):
|
|
65
|
+
def __init__(self, target_name, target_line, module_code, marker):
|
|
66
|
+
super().__init__(module_code, marker)
|
|
67
|
+
self.target_name = target_name
|
|
68
|
+
self.target_line = target_line
|
|
69
|
+
self.changed = False
|
|
70
|
+
|
|
71
|
+
def _is_target_line(self, node: cst.CSTNode):
|
|
72
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
73
|
+
return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
|
|
74
|
+
|
|
75
|
+
def _render_single_alias_text(self, head, alias: cst.ImportAlias, is_from):
|
|
76
|
+
if is_from:
|
|
77
|
+
alias_txt = alias.name.code
|
|
78
|
+
if alias.asname:
|
|
79
|
+
alias_txt += f" as {alias.asname.name.value}"
|
|
80
|
+
return f"from {head} import {alias_txt}"
|
|
81
|
+
else:
|
|
82
|
+
alias_txt = alias.name.code
|
|
83
|
+
if alias.asname:
|
|
84
|
+
alias_txt += f" as {alias.asname.name.value}"
|
|
85
|
+
return f"import {alias_txt}"
|
|
86
|
+
|
|
87
|
+
def _split_aliases(self, aliases, head, is_from):
|
|
88
|
+
kept = []
|
|
89
|
+
removed_for_comment = []
|
|
90
|
+
for alias in list(aliases):
|
|
91
|
+
bound = _bound_name_for_import_alias(alias)
|
|
92
|
+
name_code = get_full_name_for_node(alias.name)
|
|
93
|
+
tail = name_code.split(".")[-1]
|
|
94
|
+
if self.target_name in (bound, tail):
|
|
95
|
+
self.changed = True
|
|
96
|
+
removed_for_comment.append(
|
|
97
|
+
self._render_single_alias_text(head, alias, is_from)
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
kept.append(alias)
|
|
101
|
+
return kept, removed_for_comment
|
|
102
|
+
|
|
103
|
+
def leave_Import(self, orig: cst.Import, updated: cst.Import):
|
|
104
|
+
if not self._is_target_line(orig):
|
|
105
|
+
return updated
|
|
106
|
+
|
|
107
|
+
head = ""
|
|
108
|
+
kept, removed = self._split_aliases(updated.names, head, is_from=False)
|
|
109
|
+
|
|
110
|
+
if not removed:
|
|
111
|
+
return updated
|
|
112
|
+
|
|
113
|
+
pos = self.get_metadata(PositionProvider, orig)
|
|
114
|
+
if not kept:
|
|
115
|
+
return cst.FlattenSentinel(
|
|
116
|
+
self._comment_block(pos.start.line, pos.end.line)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
commented = []
|
|
120
|
+
for txt in removed:
|
|
121
|
+
comment = cst.Comment(f"# {self.marker}: {txt}")
|
|
122
|
+
commented.append(cst.EmptyLine(comment=comment))
|
|
123
|
+
|
|
124
|
+
kept_import = updated.with_changes(names=tuple(kept))
|
|
125
|
+
all_nodes = [kept_import] + commented
|
|
126
|
+
return cst.FlattenSentinel(all_nodes)
|
|
127
|
+
|
|
128
|
+
def leave_ImportFrom(self, orig: cst.ImportFrom, updated: cst.ImportFrom):
|
|
129
|
+
if not self._is_target_line(orig) or isinstance(updated.names, cst.ImportStar):
|
|
130
|
+
return updated
|
|
131
|
+
|
|
132
|
+
if updated.relative:
|
|
133
|
+
dots = "." * len(updated.relative)
|
|
134
|
+
else:
|
|
135
|
+
dots = ""
|
|
136
|
+
|
|
137
|
+
if updated.module is not None:
|
|
138
|
+
modname = updated.module.code
|
|
139
|
+
else:
|
|
140
|
+
modname = ""
|
|
141
|
+
|
|
142
|
+
mod = f"{dots}{modname}"
|
|
143
|
+
|
|
144
|
+
kept, removed = self._split_aliases(list(updated.names), mod, is_from=True)
|
|
145
|
+
|
|
146
|
+
if not removed:
|
|
147
|
+
return updated
|
|
148
|
+
pos = self.get_metadata(PositionProvider, orig)
|
|
149
|
+
|
|
150
|
+
if not kept:
|
|
151
|
+
comment_block = self._comment_block(pos.start.line, pos.end.line)
|
|
152
|
+
return cst.FlattenSentinel(comment_block)
|
|
153
|
+
|
|
154
|
+
commented = []
|
|
155
|
+
for txt in removed:
|
|
156
|
+
comment = cst.Comment(f"# {self.marker}: {txt}")
|
|
157
|
+
commented.append(cst.EmptyLine(comment=comment))
|
|
158
|
+
|
|
159
|
+
updated_import = updated.with_changes(names=tuple(kept))
|
|
160
|
+
all_nodes = [updated_import] + commented
|
|
161
|
+
|
|
162
|
+
return cst.FlattenSentinel(all_nodes)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def comment_out_unused_function_cst(
|
|
166
|
+
code, func_name, line_number, marker="SKYLOS DEADCODE"
|
|
167
|
+
):
|
|
168
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
169
|
+
tx = _CommentOutFunctionAtLine(func_name, line_number, code, marker)
|
|
170
|
+
new_mod = wrapper.visit(tx)
|
|
171
|
+
return new_mod.code, tx.changed
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def comment_out_unused_import_cst(
|
|
175
|
+
code, import_name, line_number, marker="SKYLOS DEADCODE"
|
|
176
|
+
):
|
|
177
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
178
|
+
tx = _CommentOutImportAtLine(import_name, line_number, code, marker)
|
|
179
|
+
new_mod = wrapper.visit(tx)
|
|
180
|
+
return new_mod.code, tx.changed
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bound_name_for_import_alias(alias: cst.ImportAlias):
|
|
184
|
+
if alias.asname:
|
|
185
|
+
return alias.asname.name.value
|
|
186
|
+
node = alias.name
|
|
187
|
+
while isinstance(node, cst.Attribute):
|
|
188
|
+
node = node.value
|
|
189
|
+
return node.value
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _RemoveImportAtLine(cst.CSTTransformer):
|
|
193
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
194
|
+
|
|
195
|
+
def __init__(self, target_name, target_line):
|
|
196
|
+
self.target_name = target_name
|
|
197
|
+
self.target_line = target_line
|
|
198
|
+
self.changed = False
|
|
199
|
+
|
|
200
|
+
def _is_target_line(self, node: cst.CSTNode):
|
|
201
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
202
|
+
return bool(pos and (pos.start.line <= self.target_line <= pos.end.line))
|
|
203
|
+
|
|
204
|
+
def _filter_aliases(self, aliases):
|
|
205
|
+
kept = []
|
|
206
|
+
for alias in aliases:
|
|
207
|
+
bound = _bound_name_for_import_alias(alias)
|
|
208
|
+
name_code = get_full_name_for_node(alias.name) or ""
|
|
209
|
+
tail = name_code.split(".")[-1]
|
|
210
|
+
if self.target_name in (bound, tail):
|
|
211
|
+
self.changed = True
|
|
212
|
+
continue
|
|
213
|
+
kept.append(alias)
|
|
214
|
+
return kept
|
|
215
|
+
|
|
216
|
+
def leave_Import(self, orig: cst.Import, updated: cst.Import):
|
|
217
|
+
if not self._is_target_line(orig):
|
|
218
|
+
return updated
|
|
219
|
+
kept = self._filter_aliases(updated.names)
|
|
220
|
+
if not kept:
|
|
221
|
+
return cst.RemoveFromParent()
|
|
222
|
+
return updated.with_changes(names=tuple(kept))
|
|
223
|
+
|
|
224
|
+
def leave_ImportFrom(self, orig: cst.ImportFrom, updated: cst.ImportFrom):
|
|
225
|
+
if not self._is_target_line(orig):
|
|
226
|
+
return updated
|
|
227
|
+
if isinstance(updated.names, cst.ImportStar):
|
|
228
|
+
return updated
|
|
229
|
+
kept = self._filter_aliases(list(updated.names))
|
|
230
|
+
if not kept:
|
|
231
|
+
return cst.RemoveFromParent()
|
|
232
|
+
|
|
233
|
+
return updated.with_changes(names=tuple(kept))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class _RemoveFunctionAtLine(cst.CSTTransformer):
|
|
237
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
238
|
+
|
|
239
|
+
def __init__(self, func_name, target_line):
|
|
240
|
+
self.func_name = func_name
|
|
241
|
+
self.target_line = target_line
|
|
242
|
+
self.changed = False
|
|
243
|
+
|
|
244
|
+
def _is_target(self, node: cst.CSTNode):
|
|
245
|
+
pos = self.get_metadata(PositionProvider, node, None)
|
|
246
|
+
return pos and pos.start.line == self.target_line
|
|
247
|
+
|
|
248
|
+
def leave_FunctionDef(self, orig: cst.FunctionDef, updated: cst.FunctionDef):
|
|
249
|
+
target = self.func_name.split(".")[-1]
|
|
250
|
+
if self._is_target(orig) and (orig.name.value == target):
|
|
251
|
+
self.changed = True
|
|
252
|
+
return cst.RemoveFromParent()
|
|
253
|
+
return updated
|
|
254
|
+
|
|
255
|
+
def leave_AsyncFunctionDef(
|
|
256
|
+
self, orig: cst.AsyncFunctionDef, updated: cst.AsyncFunctionDef
|
|
257
|
+
):
|
|
258
|
+
target = self.func_name.split(".")[-1]
|
|
259
|
+
if self._is_target(orig) and (orig.name.value == target):
|
|
260
|
+
self.changed = True
|
|
261
|
+
return cst.RemoveFromParent()
|
|
262
|
+
|
|
263
|
+
return updated
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def remove_unused_import_cst(code, import_name, line_number):
|
|
267
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
268
|
+
tx = _RemoveImportAtLine(import_name, line_number)
|
|
269
|
+
new_mod = wrapper.visit(tx)
|
|
270
|
+
return new_mod.code, tx.changed
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def remove_unused_function_cst(code, func_name, line_number):
|
|
274
|
+
wrapper = cst.MetadataWrapper(cst.parse_module(code))
|
|
275
|
+
tx = _RemoveFunctionAtLine(func_name, line_number)
|
|
276
|
+
new_mod = wrapper.visit(tx)
|
|
277
|
+
return new_mod.code, tx.changed
|
skylos/config.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
DEFAULTS = {
|
|
4
|
+
"complexity": 10,
|
|
5
|
+
"nesting": 3,
|
|
6
|
+
"max_args": 5,
|
|
7
|
+
"max_lines": 50,
|
|
8
|
+
"ignore": [],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_config(start_path):
|
|
13
|
+
current = Path(start_path).resolve()
|
|
14
|
+
if current.is_file():
|
|
15
|
+
current = current.parent
|
|
16
|
+
|
|
17
|
+
root_config = None
|
|
18
|
+
|
|
19
|
+
while True:
|
|
20
|
+
toml_path = current / "pyproject.toml"
|
|
21
|
+
if toml_path.exists():
|
|
22
|
+
root_config = toml_path
|
|
23
|
+
break
|
|
24
|
+
if current.parent == current:
|
|
25
|
+
break
|
|
26
|
+
current = current.parent
|
|
27
|
+
|
|
28
|
+
if not root_config:
|
|
29
|
+
return DEFAULTS.copy()
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import tomllib
|
|
33
|
+
except ImportError:
|
|
34
|
+
try:
|
|
35
|
+
import tomli as tomllib
|
|
36
|
+
except ImportError:
|
|
37
|
+
return DEFAULTS.copy()
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
with open(root_config, "rb") as f:
|
|
41
|
+
data = tomllib.load(f)
|
|
42
|
+
|
|
43
|
+
user_cfg = data.get("tool", {}).get("skylos", {})
|
|
44
|
+
|
|
45
|
+
final_cfg = DEFAULTS.copy()
|
|
46
|
+
final_cfg.update(user_cfg)
|
|
47
|
+
return final_cfg
|
|
48
|
+
|
|
49
|
+
except Exception:
|
|
50
|
+
return DEFAULTS.copy()
|
skylos/constants.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
PENALTIES = {
|
|
4
|
+
"private_name": 80,
|
|
5
|
+
"dunder_or_magic": 100,
|
|
6
|
+
"underscored_var": 100,
|
|
7
|
+
"in_init_file": 15,
|
|
8
|
+
"dynamic_module": 40,
|
|
9
|
+
"test_related": 100,
|
|
10
|
+
"framework_magic": 40,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
TEST_FILE_RE = re.compile(r"(?:^|[/\\])tests?[/\\]|_test\.py$", re.I)
|
|
14
|
+
TEST_IMPORT_RE = re.compile(r"^(pytest|unittest|nose|mock|responses)(\.|$)")
|
|
15
|
+
TEST_DECOR_RE = re.compile(
|
|
16
|
+
r"""^(
|
|
17
|
+
pytest\.(fixture|mark) |
|
|
18
|
+
patch(\.|$) |
|
|
19
|
+
responses\.activate |
|
|
20
|
+
freeze_time
|
|
21
|
+
)$""",
|
|
22
|
+
re.X,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
AUTO_CALLED = {"__init__", "__enter__", "__exit__"}
|
|
26
|
+
TEST_METHOD_PATTERN = re.compile(r"^test_\w+$")
|
|
27
|
+
|
|
28
|
+
UNITTEST_LIFECYCLE_METHODS = {
|
|
29
|
+
"setUp",
|
|
30
|
+
"tearDown",
|
|
31
|
+
"setUpClass",
|
|
32
|
+
"tearDownClass",
|
|
33
|
+
"setUpModule",
|
|
34
|
+
"tearDownModule",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
FRAMEWORK_FILE_RE = re.compile(r"(?:views|handlers|endpoints|routes|api)\.py$", re.I)
|
|
38
|
+
|
|
39
|
+
DEFAULT_EXCLUDE_FOLDERS = {
|
|
40
|
+
"__pycache__",
|
|
41
|
+
".git",
|
|
42
|
+
".pytest_cache",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
".tox",
|
|
45
|
+
"htmlcov",
|
|
46
|
+
".coverage",
|
|
47
|
+
"build",
|
|
48
|
+
"dist",
|
|
49
|
+
"*.egg-info",
|
|
50
|
+
"venv",
|
|
51
|
+
".venv",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_test_path(p):
|
|
56
|
+
return bool(TEST_FILE_RE.search(str(p)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def is_framework_path(p):
|
|
60
|
+
return bool(FRAMEWORK_FILE_RE.search(str(p)))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_exclude_folders(
|
|
64
|
+
user_exclude_folders=None, use_defaults=True, include_folders=None
|
|
65
|
+
):
|
|
66
|
+
exclude_folders = set()
|
|
67
|
+
|
|
68
|
+
if use_defaults:
|
|
69
|
+
exclude_folders.update(DEFAULT_EXCLUDE_FOLDERS)
|
|
70
|
+
|
|
71
|
+
if user_exclude_folders:
|
|
72
|
+
exclude_folders.update(user_exclude_folders)
|
|
73
|
+
|
|
74
|
+
if include_folders:
|
|
75
|
+
for folder in include_folders:
|
|
76
|
+
exclude_folders.discard(folder)
|
|
77
|
+
|
|
78
|
+
return exclude_folders
|
skylos/gatekeeper.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import subprocess
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.prompt import Confirm, Prompt
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
import inquirer
|
|
8
|
+
INTERACTIVE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
INTERACTIVE = False
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def run_cmd(cmd_list, error_msg="Git command failed"):
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(cmd_list, check=True, capture_output=True, text=True)
|
|
17
|
+
return result.stdout.strip()
|
|
18
|
+
except subprocess.CalledProcessError as e:
|
|
19
|
+
console.print(f"[bold red]Error:[/bold red] {error_msg}\n[dim]{e.stderr}[/dim]")
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
def get_git_status():
|
|
23
|
+
out = run_cmd(["git", "status", "--porcelain"], "Could not get git status. Is this a repo?")
|
|
24
|
+
if not out:
|
|
25
|
+
return []
|
|
26
|
+
|
|
27
|
+
files = []
|
|
28
|
+
for line in out.splitlines():
|
|
29
|
+
if len(line) > 3: files.append(line[3:])
|
|
30
|
+
return files
|
|
31
|
+
|
|
32
|
+
def run_push():
|
|
33
|
+
console.print("[dim]Pushing to remote...[/dim]")
|
|
34
|
+
try:
|
|
35
|
+
subprocess.run(["git", "push"], check=True)
|
|
36
|
+
console.print("[bold green] Deployment Complete. Code is live.[/bold green]")
|
|
37
|
+
except subprocess.CalledProcessError:
|
|
38
|
+
console.print("[bold red] Push failed. Check your git remote settings.[/bold red]")
|
|
39
|
+
|
|
40
|
+
def start_deployment_wizard():
|
|
41
|
+
if not INTERACTIVE:
|
|
42
|
+
console.print("[yellow]Install 'inquirer' (pip install inquirer) to use interactive deployment.[/yellow]")
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
console.print("\n[bold cyan] Skylos Deployment Wizard[/bold cyan]")
|
|
46
|
+
|
|
47
|
+
files = get_git_status()
|
|
48
|
+
if not files:
|
|
49
|
+
console.print("[green]Working tree is clean.[/green]")
|
|
50
|
+
if Confirm.ask("Push existing commits?"):
|
|
51
|
+
run_push()
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
q_scope = [
|
|
55
|
+
inquirer.List('scope',
|
|
56
|
+
message="What do you want to stage?",
|
|
57
|
+
choices=['All changed files', 'Select files manually', 'Skip commit (Push only)']
|
|
58
|
+
),
|
|
59
|
+
]
|
|
60
|
+
ans_scope = inquirer.prompt(q_scope)
|
|
61
|
+
if not ans_scope:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if ans_scope['scope'] == 'Select files manually':
|
|
65
|
+
q_files = [inquirer.Checkbox('files', message="Select files", choices=files)]
|
|
66
|
+
ans_files = inquirer.prompt(q_files)
|
|
67
|
+
if not ans_files or not ans_files['files']:
|
|
68
|
+
console.print("[red]No files selected.[/red]")
|
|
69
|
+
return
|
|
70
|
+
run_cmd(["git", "add"] + ans_files['files'])
|
|
71
|
+
console.print(f"[green]Staged {len(ans_files['files'])} files.[/green]")
|
|
72
|
+
|
|
73
|
+
elif ans_scope['scope'] == 'All changed files':
|
|
74
|
+
run_cmd(["git", "add", "."])
|
|
75
|
+
console.print("[green]Staged all files.[/green]")
|
|
76
|
+
|
|
77
|
+
if ans_scope['scope'] != 'Skip commit (Push only)':
|
|
78
|
+
msg = Prompt.ask("[bold green]Enter commit message[/bold green]")
|
|
79
|
+
if not msg:
|
|
80
|
+
console.print("[red]Commit message required.[/red]")
|
|
81
|
+
return
|
|
82
|
+
if run_cmd(["git", "commit", "-m", msg]):
|
|
83
|
+
console.print("[green]✓ Committed.[/green]")
|
|
84
|
+
|
|
85
|
+
if Confirm.ask("Ready to git push?"):
|
|
86
|
+
run_push()
|
|
87
|
+
|
|
88
|
+
def check_gate(results, config):
|
|
89
|
+
gate_cfg = config.get("gate", {})
|
|
90
|
+
|
|
91
|
+
danger = results.get("danger", [])
|
|
92
|
+
secrets = results.get("secrets", [])
|
|
93
|
+
quality = results.get("quality", [])
|
|
94
|
+
|
|
95
|
+
reasons = []
|
|
96
|
+
|
|
97
|
+
criticals = []
|
|
98
|
+
for f in danger:
|
|
99
|
+
if f.get("severity") == "CRITICAL":
|
|
100
|
+
criticals.append(f)
|
|
101
|
+
|
|
102
|
+
if gate_cfg.get("fail_on_critical") and (criticals or secrets):
|
|
103
|
+
if criticals: reasons.append(f"Found {len(criticals)} CRITICAL security issues")
|
|
104
|
+
if secrets: reasons.append(f"Found {len(secrets)} Secrets")
|
|
105
|
+
|
|
106
|
+
total_sec = len(danger)
|
|
107
|
+
limit_sec = gate_cfg.get("max_security", 0)
|
|
108
|
+
if total_sec > limit_sec:
|
|
109
|
+
reasons.append(f"Security issues ({total_sec}) exceed limit ({limit_sec})")
|
|
110
|
+
|
|
111
|
+
total_qual = len(quality)
|
|
112
|
+
limit_qual = gate_cfg.get("max_quality", 10)
|
|
113
|
+
if total_qual > limit_qual:
|
|
114
|
+
reasons.append(f"Quality issues ({total_qual}) exceed limit ({limit_qual})")
|
|
115
|
+
|
|
116
|
+
return (len(reasons) == 0), reasons
|
|
117
|
+
|
|
118
|
+
def run_gate_interaction(results, config, command_to_run):
|
|
119
|
+
passed, reasons = check_gate(results, config)
|
|
120
|
+
|
|
121
|
+
if passed:
|
|
122
|
+
console.print("\n[bold green] Skylos Gate Passed.[/bold green]")
|
|
123
|
+
if command_to_run:
|
|
124
|
+
console.print(f"[dim]Running: {' '.join(command_to_run)}[/dim]")
|
|
125
|
+
subprocess.run(command_to_run)
|
|
126
|
+
else:
|
|
127
|
+
start_deployment_wizard()
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
console.print("\n[bold red] Skylos Gate Failed![/bold red]")
|
|
131
|
+
for reason in reasons:
|
|
132
|
+
console.print(f" - {reason}")
|
|
133
|
+
|
|
134
|
+
if config.get("gate", {}).get("strict"):
|
|
135
|
+
console.print("[bold red]Strict mode enabled. Cannot bypass.[/bold red]")
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
if sys.stdout.isatty():
|
|
139
|
+
if Confirm.ask("\n[bold yellow]Do you want to bypass checks and proceed anyway?[/bold yellow]"):
|
|
140
|
+
console.print("[yellow]⚠ Bypassing Gate...[/yellow]")
|
|
141
|
+
if command_to_run:
|
|
142
|
+
subprocess.run(command_to_run)
|
|
143
|
+
else:
|
|
144
|
+
start_deployment_wizard()
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
return 1
|
skylos/linter.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LinterVisitor(ast.NodeVisitor):
|
|
5
|
+
def __init__(self, rules, filename):
|
|
6
|
+
self.rules = rules
|
|
7
|
+
self.filename = filename
|
|
8
|
+
self.findings = []
|
|
9
|
+
self.context = {"filename": filename}
|
|
10
|
+
|
|
11
|
+
def visit(self, node):
|
|
12
|
+
for rule in self.rules:
|
|
13
|
+
results = rule.visit_node(node, self.context)
|
|
14
|
+
if results:
|
|
15
|
+
self.findings.extend(results)
|
|
16
|
+
|
|
17
|
+
for child in ast.iter_child_nodes(node):
|
|
18
|
+
self.visit(child)
|
skylos/rules/base.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SkylosRule(ABC):
|
|
6
|
+
"""Base class for all Skylos rules"""
|
|
7
|
+
|
|
8
|
+
@property
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def rule_id(self):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def name(self):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def visit_node(self, node, context):
|
|
20
|
+
pass
|