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.
Files changed (53) hide show
  1. skylos/__init__.py +9 -3
  2. skylos/analyzer.py +674 -168
  3. skylos/cfg_visitor.py +60 -0
  4. skylos/cli.py +719 -235
  5. skylos/codemods.py +277 -0
  6. skylos/config.py +50 -0
  7. skylos/constants.py +78 -0
  8. skylos/gatekeeper.py +147 -0
  9. skylos/linter.py +18 -0
  10. skylos/rules/base.py +20 -0
  11. skylos/rules/danger/calls.py +119 -0
  12. skylos/rules/danger/danger.py +157 -0
  13. skylos/rules/danger/danger_cmd/cmd_flow.py +75 -0
  14. skylos/rules/danger/danger_fs/__init__.py +0 -0
  15. skylos/rules/danger/danger_fs/path_flow.py +79 -0
  16. skylos/rules/danger/danger_net/__init__.py +0 -0
  17. skylos/rules/danger/danger_net/ssrf_flow.py +80 -0
  18. skylos/rules/danger/danger_sql/__init__.py +0 -0
  19. skylos/rules/danger/danger_sql/sql_flow.py +245 -0
  20. skylos/rules/danger/danger_sql/sql_raw_flow.py +96 -0
  21. skylos/rules/danger/danger_web/__init__.py +0 -0
  22. skylos/rules/danger/danger_web/xss_flow.py +170 -0
  23. skylos/rules/danger/taint.py +110 -0
  24. skylos/rules/quality/__init__.py +0 -0
  25. skylos/rules/quality/complexity.py +95 -0
  26. skylos/rules/quality/logic.py +96 -0
  27. skylos/rules/quality/nesting.py +101 -0
  28. skylos/rules/quality/structure.py +99 -0
  29. skylos/rules/secrets.py +325 -0
  30. skylos/server.py +554 -0
  31. skylos/visitor.py +502 -90
  32. skylos/visitors/__init__.py +0 -0
  33. skylos/visitors/framework_aware.py +437 -0
  34. skylos/visitors/test_aware.py +74 -0
  35. skylos-2.5.2.dist-info/METADATA +21 -0
  36. skylos-2.5.2.dist-info/RECORD +42 -0
  37. {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/WHEEL +1 -1
  38. {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/top_level.txt +0 -1
  39. skylos-1.0.10.dist-info/METADATA +0 -8
  40. skylos-1.0.10.dist-info/RECORD +0 -21
  41. test/compare_tools.py +0 -604
  42. test/diagnostics.py +0 -364
  43. test/sample_repo/app.py +0 -13
  44. test/sample_repo/sample_repo/commands.py +0 -81
  45. test/sample_repo/sample_repo/models.py +0 -122
  46. test/sample_repo/sample_repo/routes.py +0 -89
  47. test/sample_repo/sample_repo/utils.py +0 -36
  48. test/test_skylos.py +0 -456
  49. test/test_visitor.py +0 -220
  50. {test → skylos/rules}/__init__.py +0 -0
  51. {test/sample_repo → skylos/rules/danger}/__init__.py +0 -0
  52. {test/sample_repo/sample_repo → skylos/rules/danger/danger_cmd}/__init__.py +0 -0
  53. {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