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/cli.py CHANGED
@@ -2,335 +2,819 @@ import argparse
2
2
  import json
3
3
  import sys
4
4
  import logging
5
- import ast
5
+ from skylos.constants import parse_exclude_folders, DEFAULT_EXCLUDE_FOLDERS
6
+ from skylos.server import start_server
7
+ from skylos.analyzer import analyze as run_analyze
8
+ from skylos.codemods import (
9
+ remove_unused_import_cst,
10
+ remove_unused_function_cst,
11
+ comment_out_unused_import_cst,
12
+ comment_out_unused_function_cst,
13
+ )
14
+ from skylos.config import load_config
15
+ from skylos.gatekeeper import run_gate_interaction
16
+ import pathlib
6
17
  import skylos
18
+ from collections import defaultdict
19
+
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+ from rich.panel import Panel
23
+ from rich.progress import Progress, SpinnerColumn, TextColumn
24
+ from rich.theme import Theme
25
+ from rich.logging import RichHandler
26
+ from rich.rule import Rule
27
+ from rich.tree import Tree
7
28
 
8
29
  try:
9
30
  import inquirer
31
+
10
32
  INTERACTIVE_AVAILABLE = True
11
33
  except ImportError:
12
34
  INTERACTIVE_AVAILABLE = False
13
35
 
36
+
14
37
  class Colors:
15
- RED = '\033[91m'
16
- GREEN = '\033[92m'
17
- YELLOW = '\033[93m'
18
- BLUE = '\033[94m'
19
- MAGENTA = '\033[95m'
20
- CYAN = '\033[96m'
21
- WHITE = '\033[97m'
22
- BOLD = '\033[1m'
23
- UNDERLINE = '\033[4m'
24
- RESET = '\033[0m'
25
- GRAY = '\033[90m'
38
+ # kept for compatibility with some strings. rich styles are primary use
39
+ RED = "\033[91m"
40
+ GREEN = "\033[92m"
41
+ YELLOW = "\033[93m"
42
+ BLUE = "\033[94m"
43
+ MAGENTA = "\033[95m"
44
+ CYAN = "\033[96m"
45
+ BOLD = "\033[1m"
46
+ RESET = "\033[0m"
47
+ GRAY = "\033[90m"
48
+
26
49
 
27
50
  class CleanFormatter(logging.Formatter):
28
- """Custom formatter that removes timestamps and log levels for clean output"""
29
51
  def format(self, record):
30
52
  return record.getMessage()
31
53
 
54
+
32
55
  def setup_logger(output_file=None):
33
- logger = logging.getLogger('skylos')
56
+ theme = Theme(
57
+ {
58
+ "good": "bold green",
59
+ "warn": "bold yellow",
60
+ "bad": "bold red",
61
+ "muted": "dim",
62
+ "brand": "bold cyan",
63
+ }
64
+ )
65
+ console = Console(theme=theme)
66
+
67
+ logger = logging.getLogger("skylos")
34
68
  logger.setLevel(logging.INFO)
35
-
36
69
  logger.handlers.clear()
37
-
38
- formatter = CleanFormatter()
39
-
40
- console_handler = logging.StreamHandler(sys.stdout)
41
- console_handler.setFormatter(formatter)
42
- logger.addHandler(console_handler)
43
-
70
+
71
+ rich_handler = RichHandler(
72
+ console=console, show_time=False, show_path=False, markup=True
73
+ )
74
+ rich_handler.setFormatter(CleanFormatter())
75
+ logger.addHandler(rich_handler)
76
+
44
77
  if output_file:
45
- file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
78
+ file_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
46
79
  file_handler = logging.FileHandler(output_file)
47
80
  file_handler.setFormatter(file_formatter)
48
81
  logger.addHandler(file_handler)
49
-
82
+
50
83
  logger.propagate = False
51
-
84
+ logger.console = console
52
85
  return logger
53
86
 
54
- def remove_unused_import(file_path: str, import_name: str, line_number: int) -> bool:
87
+
88
+ def remove_unused_import(file_path, import_name, line_number):
89
+ path = pathlib.Path(file_path)
90
+
55
91
  try:
56
- with open(file_path, 'r') as f:
57
- lines = f.readlines()
58
-
59
- line_idx = line_number - 1
60
- original_line = lines[line_idx].strip()
61
-
62
- if original_line.startswith(f'import {import_name}'):
63
- lines[line_idx] = ''
64
- elif original_line.startswith('import ') and f' {import_name}' in original_line:
65
- parts = original_line.split(' ', 1)[1].split(',')
66
- new_parts = [p.strip() for p in parts if p.strip() != import_name]
67
- if new_parts:
68
- lines[line_idx] = f'import {", ".join(new_parts)}\n'
69
- else:
70
- lines[line_idx] = ''
71
- elif original_line.startswith('from ') and import_name in original_line:
72
- if f'import {import_name}' in original_line and ',' not in original_line:
73
- lines[line_idx] = ''
74
- else:
75
- parts = original_line.split('import ', 1)[1].split(',')
76
- new_parts = [p.strip() for p in parts if p.strip() != import_name]
77
- if new_parts:
78
- prefix = original_line.split(' import ')[0]
79
- lines[line_idx] = f'{prefix} import {", ".join(new_parts)}\n'
80
- else:
81
- lines[line_idx] = ''
82
-
83
- with open(file_path, 'w') as f:
84
- f.writelines(lines)
85
-
92
+ src = path.read_text(encoding="utf-8")
93
+ new_code, changed = remove_unused_import_cst(src, import_name, line_number)
94
+ if not changed:
95
+ return False
96
+ path.write_text(new_code, encoding="utf-8")
86
97
  return True
98
+
87
99
  except Exception as e:
88
100
  logging.error(f"Failed to remove import {import_name} from {file_path}: {e}")
89
101
  return False
90
102
 
91
- def remove_unused_function(file_path: str, function_name: str, line_number: int) -> bool:
103
+
104
+ def remove_unused_function(file_path, function_name, line_number):
105
+ path = pathlib.Path(file_path)
106
+
92
107
  try:
93
- with open(file_path, 'r') as f:
94
- content = f.read()
95
-
96
- tree = ast.parse(content)
97
-
98
- lines = content.splitlines()
99
- for node in ast.walk(tree):
100
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
101
- if (node.name in function_name and
102
- node.lineno == line_number):
103
-
104
- start_line = node.lineno - 1
105
-
106
- if node.decorator_list:
107
- start_line = node.decorator_list[0].lineno - 1
108
-
109
- end_line = len(lines)
110
- base_indent = len(lines[start_line]) - len(lines[start_line].lstrip())
111
-
112
- for i in range(node.end_lineno, len(lines)):
113
- if lines[i].strip() == '':
114
- continue
115
- current_indent = len(lines[i]) - len(lines[i].lstrip())
116
- if current_indent <= base_indent and lines[i].strip():
117
- end_line = i
118
- break
119
-
120
- while end_line < len(lines) and lines[end_line].strip() == '':
121
- end_line += 1
122
-
123
- new_lines = lines[:start_line] + lines[end_line:]
124
-
125
- with open(file_path, 'w') as f:
126
- f.write('\n'.join(new_lines) + '\n')
127
-
128
- return True
129
-
108
+ src = path.read_text(encoding="utf-8")
109
+ new_code, changed = remove_unused_function_cst(src, function_name, line_number)
110
+ if not changed:
111
+ return False
112
+ path.write_text(new_code, encoding="utf-8")
113
+ return True
114
+
115
+ except Exception as e:
116
+ logging.error(
117
+ f"Failed to remove function {function_name} from {file_path}: {e}"
118
+ )
119
+ return False
120
+
121
+
122
+ def comment_out_unused_import(
123
+ file_path, import_name, line_number, marker="SKYLOS DEADCODE"
124
+ ):
125
+ path = pathlib.Path(file_path)
126
+
127
+ try:
128
+ src = path.read_text(encoding="utf-8")
129
+ new_code, changed = comment_out_unused_import_cst(
130
+ src, import_name, line_number, marker=marker
131
+ )
132
+ if not changed:
133
+ return False
134
+ path.write_text(new_code, encoding="utf-8")
135
+ return True
136
+
137
+ except Exception as e:
138
+ logging.error(
139
+ f"Failed to comment out import {import_name} from {file_path}: {e}"
140
+ )
130
141
  return False
142
+
143
+
144
+ def comment_out_unused_function(
145
+ file_path, function_name, line_number, marker="SKYLOS DEADCODE"
146
+ ):
147
+ path = pathlib.Path(file_path)
148
+
149
+ try:
150
+ src = path.read_text(encoding="utf-8")
151
+ new_code, changed = comment_out_unused_function_cst(
152
+ src, function_name, line_number, marker=marker
153
+ )
154
+ if not changed:
155
+ return False
156
+ path.write_text(new_code, encoding="utf-8")
157
+ return True
158
+
131
159
  except Exception as e:
132
- logging.error(f"Failed to remove function {function_name} from {file_path}: {e}")
160
+ logging.error(
161
+ f"Failed to comment out function {function_name} from {file_path}: {e}"
162
+ )
133
163
  return False
134
164
 
135
- def interactive_selection(logger, unused_functions, unused_imports):
165
+
166
+ def _shorten_path(file_path, root_path=None):
167
+ if not file_path:
168
+ return "?"
169
+
170
+ try:
171
+ p = pathlib.Path(file_path)
172
+ except TypeError:
173
+ return str(file_path)
174
+
175
+ if root_path is not None:
176
+ try:
177
+ root = pathlib.Path(root_path)
178
+ if root.is_file():
179
+ root = root.parent
180
+ p = p.resolve()
181
+ root = root.resolve()
182
+ rel = p.relative_to(root)
183
+ return str(rel)
184
+ except Exception:
185
+ return p.name
186
+
187
+ return str(p)
188
+
189
+
190
+ def interactive_selection(
191
+ console: Console, unused_functions, unused_imports, root_path=None
192
+ ):
136
193
  if not INTERACTIVE_AVAILABLE:
137
- logger.error("Interactive mode requires 'inquirer' package. Install with: pip install inquirer")
194
+ console.print(
195
+ "[bad]Interactive mode requires 'inquirer'. Install with: pip install inquirer[/bad]"
196
+ )
138
197
  return [], []
139
-
198
+
140
199
  selected_functions = []
141
200
  selected_imports = []
142
-
201
+
143
202
  if unused_functions:
144
- logger.info(f"\n{Colors.CYAN}{Colors.BOLD}Select unused functions to remove:{Colors.RESET}")
145
-
203
+ console.print(
204
+ "\n[brand][bold]Select unused functions to remove (space to select):[/bold][/brand]"
205
+ )
206
+
146
207
  function_choices = []
147
208
  for item in unused_functions:
148
- choice_text = f"{item['name']} ({item['file']}:{item['line']})"
209
+ short = _shorten_path(item.get("file"), root_path)
210
+ choice_text = f"{item['name']} ({short}:{item['line']})"
149
211
  function_choices.append((choice_text, item))
150
-
212
+
151
213
  questions = [
152
- inquirer.Checkbox('functions',
153
- message="Select functions to remove",
154
- choices=function_choices,
155
- )
214
+ inquirer.Checkbox(
215
+ "functions",
216
+ message="Select functions to remove",
217
+ choices=function_choices,
218
+ )
156
219
  ]
157
-
158
220
  answers = inquirer.prompt(questions)
159
221
  if answers:
160
- selected_functions = answers['functions']
161
-
222
+ selected_functions = answers["functions"]
223
+
162
224
  if unused_imports:
163
- logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}Select unused imports to remove:{Colors.RESET}")
164
-
225
+ console.print(
226
+ "\n[brand][bold]Select unused imports to act on (space to select):[/bold][/brand]"
227
+ )
228
+
165
229
  import_choices = []
166
230
  for item in unused_imports:
167
- choice_text = f"{item['name']} ({item['file']}:{item['line']})"
231
+ short = _shorten_path(item.get("file"), root_path)
232
+ choice_text = f"{item['name']} ({short}:{item['line']})"
168
233
  import_choices.append((choice_text, item))
169
-
234
+
170
235
  questions = [
171
- inquirer.Checkbox('imports',
172
- message="Select imports to remove",
173
- choices=import_choices,
174
- )
236
+ inquirer.Checkbox(
237
+ "imports", message="Select imports to remove", choices=import_choices
238
+ )
175
239
  ]
176
-
177
240
  answers = inquirer.prompt(questions)
178
241
  if answers:
179
- selected_imports = answers['imports']
180
-
242
+ selected_imports = answers["imports"]
243
+
181
244
  return selected_functions, selected_imports
182
245
 
183
- def print_badge(dead_code_count: int, logger):
184
- """Print appropriate badge based on dead code count"""
185
- logger.info(f"\n{Colors.GRAY}{'─' * 50}{Colors.RESET}")
246
+
247
+ def print_badge(
248
+ dead_code_count,
249
+ logger,
250
+ *,
251
+ danger_enabled=False,
252
+ danger_count=0,
253
+ quality_enabled=False,
254
+ quality_count=0,
255
+ ):
256
+ console: Console = logger.console
257
+ console.print(Rule(style="muted"))
258
+
259
+ has_dead_code = dead_code_count > 0
260
+ has_danger = danger_enabled and danger_count > 0
261
+ has_quality = quality_enabled and quality_count > 0
262
+
263
+ if not has_dead_code and not has_danger and not has_quality:
264
+ console.print(
265
+ Panel.fit(
266
+ "[good]Your code is 100% dead-code free![/good]\nAdd this badge to your README:",
267
+ border_style="good",
268
+ )
269
+ )
270
+ console.print("```markdown")
271
+ console.print(
272
+ "![Dead Code Free](https://img.shields.io/badge/Dead_Code-Free-brightgreen?logo=moleculer&logoColor=white)"
273
+ )
274
+ console.print("```")
275
+ return
276
+
277
+ headline = f"Found {dead_code_count} dead-code items"
278
+ if danger_enabled:
279
+ headline += f" and {danger_count} security issues"
280
+ if quality_enabled:
281
+ headline += f" and {quality_count} quality issues"
282
+ headline += ". Add this badge to your README:"
283
+
284
+ console.print(Panel.fit(headline, border_style="warn"))
285
+ console.print("```markdown")
286
+ console.print(
287
+ f"![Dead Code: {dead_code_count}](https://img.shields.io/badge/Dead_Code-{dead_code_count}_detected-orange?logo=codacy&logoColor=red)"
288
+ )
289
+ console.print("```")
290
+
291
+
292
+ def render_results(console: Console, result, tree=False, root_path=None):
293
+ summ = result.get("analysis_summary", {})
294
+ console.print(
295
+ Panel.fit(
296
+ f"[brand]Python Static Analysis Results[/brand]\n[muted]Analyzed {summ.get('total_files', '?')} file(s)[/muted]",
297
+ border_style="brand",
298
+ )
299
+ )
300
+
301
+ def _pill(label, n, ok_style="good", bad_style="bad"):
302
+ if n == 0:
303
+ style = ok_style
304
+ else:
305
+ style = bad_style
306
+ return f"[{style}]{label}: {n}[/{style}]"
307
+
308
+ console.print(
309
+ " ".join(
310
+ [
311
+ _pill("Unreachable", len(result.get("unused_functions", []))),
312
+ _pill("Unused imports", len(result.get("unused_imports", []))),
313
+ _pill("Unused params", len(result.get("unused_parameters", []))),
314
+ _pill("Unused vars", len(result.get("unused_variables", []))),
315
+ _pill("Unused classes", len(result.get("unused_classes", []))),
316
+ _pill(
317
+ "Quality", len(result.get("quality", []) or []), bad_style="warn"
318
+ ),
319
+ ]
320
+ )
321
+ )
322
+ console.print()
323
+
324
+ def _render_unused(title, items, name_key="name"):
325
+ if not items:
326
+ return
327
+
328
+ console.rule(f"[bold]{title}")
329
+
330
+ table = Table(expand=True)
331
+ table.add_column("#", style="muted", width=3)
332
+ table.add_column("Name", style="bold")
333
+ table.add_column("Location", style="muted", overflow="fold")
334
+
335
+ for i, item in enumerate(items, 1):
336
+ nm = item.get(name_key) or item.get("simple_name") or "<?>"
337
+ short = _shorten_path(item.get("file"), root_path)
338
+ loc = f"{short}:{item.get('line', item.get('lineno', '?'))}"
339
+ table.add_row(str(i), nm, loc)
340
+
341
+ console.print(table)
342
+ console.print()
343
+
344
+ def _render_quality(items):
345
+ if not items:
346
+ return
347
+
348
+ console.rule("[bold red]Quality Issues")
349
+ table = Table(expand=True)
350
+ table.add_column("#", style="muted", width=3)
351
+ table.add_column("Type", style="yellow", width=12)
352
+ table.add_column("Function", style="bold")
353
+ table.add_column("Detail")
354
+ table.add_column("Location", style="muted", width=36)
355
+
356
+ for i, quality in enumerate(items, 1):
357
+ kind = (quality.get("kind") or quality.get("metric") or "quality").title()
358
+ func = quality.get("name") or quality.get("simple_name") or "<?>"
359
+ loc = f"{quality.get('basename', '?')}:{quality.get('line', '?')}"
360
+ value = quality.get("value") or quality.get("complexity")
361
+ thr = quality.get("threshold")
362
+ length = quality.get("length")
363
+
364
+ if quality.get("kind") == "nesting":
365
+ detail = f"Deep nesting: depth {value}"
366
+ else:
367
+ detail = f"Cyclomatic complexity: {value}"
368
+ if thr is not None:
369
+ detail += f" (target ≤ {thr})"
370
+ if length is not None:
371
+ detail += f", {length} lines"
372
+ table.add_row(str(i), kind, func, detail, loc)
373
+
374
+ console.print(table)
375
+ console.print(
376
+ "[muted]Tip: split helpers, add early returns, flatten branches.[/muted]\n"
377
+ )
378
+
379
+ def _render_secrets(items):
380
+ if not items:
381
+ return
382
+
383
+ console.rule("[bold red]Secrets")
384
+ table = Table(expand=True)
385
+ table.add_column("#", style="muted", width=3)
386
+ table.add_column("Provider", style="yellow", width=14)
387
+ table.add_column("Message")
388
+ table.add_column("Preview", style="muted", width=18)
389
+ table.add_column("Location", style="muted", overflow="fold")
390
+
391
+ for i, s in enumerate(items[:100], 1):
392
+ prov = s.get("provider") or "generic"
393
+ msg = s.get("message") or "Secret detected"
394
+ prev = s.get("preview") or "****"
395
+ short = _shorten_path(s.get("file"), root_path)
396
+ loc = f"{short}:{s.get('line', '?')}"
397
+ table.add_row(str(i), prov, msg, prev, loc)
398
+
399
+ console.print(table)
400
+ console.print()
401
+
402
+ def render_tree(console: Console, result, root_path=None):
403
+ by_file = defaultdict(list)
404
+
405
+ def _add_unused(items, kind):
406
+ for u in items or []:
407
+ file = u.get("file")
408
+ if not file:
409
+ continue
410
+ line = u.get("line") or u.get("lineno") or 1
411
+ name = u.get("name") or u.get("simple_name") or "<?>"
412
+ msg = f"Unused {kind}: {name}"
413
+ by_file[file].append((line, "info", msg))
414
+
415
+ def _add_findings(items, kind, default_sev="medium"):
416
+ for f in items or []:
417
+ file = f.get("file")
418
+ if not file:
419
+ continue
420
+ line = f.get("line") or 1
421
+ sev = (f.get("severity") or default_sev).lower()
422
+ rule = f.get("rule_id")
423
+ msg = f.get("message") or kind
424
+ if rule:
425
+ msg = f"[{rule}] {msg}"
426
+ by_file[file].append((line, sev, msg))
427
+
428
+ _add_unused(result.get("unused_functions"), "function")
429
+ _add_unused(result.get("unused_imports"), "import")
430
+ _add_unused(result.get("unused_classes"), "class")
431
+ _add_unused(result.get("unused_variables"), "variable")
432
+ _add_unused(result.get("unused_parameters"), "parameter")
433
+
434
+ _add_findings(result.get("danger"), "security", default_sev="high")
435
+ _add_findings(result.get("secrets"), "secret", default_sev="high")
436
+ _add_findings(result.get("quality"), "quality", default_sev="medium")
437
+
438
+ if not by_file:
439
+ console.print("[good]No findings to display.[/good]")
440
+ return
441
+
442
+ root_label = str(root_path) if root_path is not None else "Skylos results"
443
+ tree = Tree(f"[brand]{root_label}[/brand]")
444
+
445
+ for file in sorted(by_file.keys()):
446
+ short = _shorten_path(file, root_path)
447
+ file_node = tree.add(f"[bold]{short}[/bold]")
448
+
449
+ for line, sev, msg in sorted(by_file[file], key=lambda t: t[0]):
450
+ if sev == "high" or sev == "critical":
451
+ style = "bad"
452
+ elif sev == "medium":
453
+ style = "warn"
454
+ else:
455
+ style = "muted"
456
+ file_node.add(f"[{style}]L{line}[/{style}] {msg}")
457
+
458
+ console.print(tree)
459
+
460
+ def _render_danger(items):
461
+ if not items:
462
+ return
463
+
464
+ console.rule("[bold red]Security Issues")
465
+ table = Table(expand=True)
466
+ table.add_column("#", style="muted", width=3)
467
+ table.add_column("Rule", style="yellow", width=18)
468
+ table.add_column("Severity", width=10)
469
+ table.add_column("Message", overflow="fold")
470
+ table.add_column("Location", style="muted", width=36, overflow="fold")
471
+
472
+ for i, d in enumerate(items[:100], 1):
473
+ rule = d.get("rule_id") or "UNKNOWN"
474
+ sev = (d.get("severity") or "UNKNOWN").title()
475
+ msg = d.get("message") or "Issue detected"
476
+ short = _shorten_path(d.get("file"), root_path)
477
+ loc = f"{short}:{d.get('line', '?')}"
478
+ table.add_row(str(i), rule, sev, msg, loc)
479
+
480
+ console.print(table)
481
+ console.print()
482
+
483
+ if tree:
484
+ render_tree(console, result, root_path=root_path)
485
+ else:
486
+ _render_unused(
487
+ "Unreachable Functions", result.get("unused_functions", []), name_key="name"
488
+ )
489
+ _render_unused(
490
+ "Unused Imports", result.get("unused_imports", []), name_key="name"
491
+ )
492
+ _render_unused(
493
+ "Unused Parameters", result.get("unused_parameters", []), name_key="name"
494
+ )
495
+ _render_unused(
496
+ "Unused Variables", result.get("unused_variables", []), name_key="name"
497
+ )
498
+ _render_unused(
499
+ "Unused Classes", result.get("unused_classes", []), name_key="name"
500
+ )
501
+ _render_secrets(result.get("secrets", []) or [])
502
+ _render_danger(result.get("danger", []) or [])
503
+ _render_quality(result.get("quality", []) or [])
504
+
505
+ def run_init():
506
+
507
+ console = Console()
508
+ path = pathlib.Path("pyproject.toml")
186
509
 
187
- if dead_code_count == 0:
188
- logger.info(f"✨ Your code is 100% dead code free! Add this badge to your README:")
189
- logger.info("```markdown")
190
- logger.info("![Dead Code Free](https://img.shields.io/badge/Dead_Code-Free-brightgreen?logo=moleculer&logoColor=white)")
191
- logger.info("```")
510
+ template = """
511
+ [tool.skylos]
512
+ # Analysis Settings
513
+ complexity = 10
514
+ nesting = 3
515
+ max_args = 5
516
+ max_lines = 50
517
+ ignore = [] # e.g. ["SKY-L002", "SKY-C304"]
518
+
519
+ [tool.skylos.gate]
520
+ # Gatekeeper Settings (skylos --gate)
521
+ fail_on_critical = true
522
+ max_security = 0
523
+ max_quality = 10
524
+ strict = false
525
+ """
526
+
527
+ if path.exists():
528
+ content = path.read_text(encoding="utf-8")
529
+ if "[tool.skylos]" in content:
530
+ console.print("[warn]pyproject.toml already contains [tool.skylos] configuration.[/warn]")
531
+ return
532
+
533
+ console.print("[brand]Appending Skylos configuration to existing pyproject.toml...[/brand]")
534
+ with open(path, "a", encoding="utf-8") as f:
535
+ f.write("\n" + template)
192
536
  else:
193
- logger.info(f"Found {dead_code_count} dead code items. Add this badge to your README:")
194
- logger.info("```markdown")
195
- logger.info(f"![Dead Code: {dead_code_count}](https://img.shields.io/badge/Dead_Code-{dead_code_count}_detected-orange?logo=codacy&logoColor=red)")
196
- logger.info("```")
537
+ console.print("[brand]Creating new pyproject.toml...[/brand]")
538
+ path.write_text(template.strip(), encoding="utf-8")
539
+
540
+ console.print("[good] ** Configuration initialized! You can now edit pyproject.toml[/good]")
541
+
542
+ def main():
543
+
544
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
545
+ run_init()
546
+ sys.exit(0)
547
+
548
+ if len(sys.argv) > 1 and sys.argv[1] == "run":
549
+ try:
550
+ start_server()
551
+ return
552
+ except ImportError:
553
+ print(f"{Colors.RED}Error: Flask is required {Colors.RESET}")
554
+ print(
555
+ f"{Colors.YELLOW}Install with: pip install flask flask-cors{Colors.RESET}"
556
+ )
557
+ sys.exit(1)
197
558
 
198
- def main() -> None:
199
559
  parser = argparse.ArgumentParser(
200
560
  description="Detect unreachable functions and unused imports in a Python project"
201
561
  )
202
- parser.add_argument("path", help="Path to the Python project to analyze")
562
+ parser.add_argument("path", help="Path to the Python project")
563
+ parser.add_argument(
564
+ "--gate",
565
+ action="store_true",
566
+ help="Run as a quality gate (block deployment on failure)"
567
+ )
568
+ parser.add_argument(
569
+ "--table", action="store_true", help="(deprecated) Show findings in table"
570
+ )
571
+ parser.add_argument(
572
+ "--tree", action="store_true", help="Show findings in tree format"
573
+ )
574
+ parser.add_argument(
575
+ "--version",
576
+ action="version",
577
+ version=f"skylos {skylos.__version__}",
578
+ help="Show version and exit",
579
+ )
580
+ parser.add_argument("--json", action="store_true", help="Output raw JSON")
203
581
  parser.add_argument(
204
- "--json",
582
+ "--comment-out",
205
583
  action="store_true",
206
- help="Output raw JSON instead of formatted text",
584
+ help="Comment out selected dead code instead of deleting item",
585
+ )
586
+ parser.add_argument("--output", "-o", type=str, help="Write output to file")
587
+ parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose")
588
+ parser.add_argument(
589
+ "--confidence",
590
+ "-c",
591
+ type=int,
592
+ default=60,
593
+ help="Confidence threshold (0-100). Lower = include more. Default: 60",
207
594
  )
208
595
  parser.add_argument(
209
- "--output",
210
- "-o",
211
- type=str,
212
- help="Write output to file instead of stdout",
596
+ "--interactive", "-i", action="store_true", help="Select items to remove"
213
597
  )
214
598
  parser.add_argument(
215
- "--verbose", "-v",
599
+ "--dry-run", action="store_true", help="Show what would be removed"
600
+ )
601
+
602
+ parser.add_argument(
603
+ "--exclude-folder",
604
+ action="append",
605
+ dest="exclude_folders",
606
+ help=(
607
+ "Exclude a folder from analysis (can be used multiple times). By default, common folders like __pycache__, "
608
+ ".git, venv are excluded. Use --no-default-excludes to disable default exclusions."
609
+ ),
610
+ )
611
+ parser.add_argument(
612
+ "--include-folder",
613
+ action="append",
614
+ dest="include_folders",
615
+ help=(
616
+ "Force include a folder that would otherwise be excluded (overrides both default and custom exclusions). "
617
+ "Example: --include-folder venv"
618
+ ),
619
+ )
620
+ parser.add_argument(
621
+ "--no-default-excludes",
622
+ action="store_true",
623
+ help="Do not exclude default folders (__pycache__, .git, venv, etc.). Only exclude folders with --exclude-folder.",
624
+ )
625
+ parser.add_argument(
626
+ "--list-default-excludes",
216
627
  action="store_true",
217
- help="Enable verbose output"
628
+ help="List the default excluded folders and exit.",
629
+ )
630
+ parser.add_argument(
631
+ "--secrets", action="store_true", help="Scan for API keys. Off by default."
218
632
  )
219
633
  parser.add_argument(
220
- "--interactive", "-i",
634
+ "--danger",
221
635
  action="store_true",
222
- help="Interactively select items to remove (requires inquirer)"
636
+ help="Scan for security issues. Off by default.",
223
637
  )
224
638
  parser.add_argument(
225
- "--dry-run",
639
+ "--quality",
226
640
  action="store_true",
227
- help="Show what would be removed without actually modifying files"
641
+ help="Run code quality checks. Off by default.",
642
+ )
643
+
644
+ parser.add_argument(
645
+ "command",
646
+ nargs="*",
647
+ help="Command to run if gate passes"
228
648
  )
229
649
 
230
650
  args = parser.parse_args()
651
+ project_root = pathlib.Path(args.path).resolve()
652
+ if project_root.is_file():
653
+ project_root = project_root.parent
654
+
231
655
  logger = setup_logger(args.output)
232
-
656
+ console = logger.console
657
+
658
+ if args.list_default_excludes:
659
+ console.print("[brand]Default excluded folders:[/brand]")
660
+ for folder in sorted(DEFAULT_EXCLUDE_FOLDERS):
661
+ console.print(f" {folder}")
662
+ console.print(f"\n[muted]Total: {len(DEFAULT_EXCLUDE_FOLDERS)} folders[/muted]")
663
+ console.print("\nUse --no-default-excludes to disable these exclusions")
664
+ console.print("Use --include-folder <folder> to force include specific folders")
665
+ return
666
+
233
667
  if args.verbose:
234
668
  logger.setLevel(logging.DEBUG)
235
669
  logger.debug(f"Analyzing path: {args.path}")
670
+ if args.exclude_folders:
671
+ logger.debug(f"Excluding folders: {args.exclude_folders}")
672
+
673
+ use_defaults = not args.no_default_excludes
674
+ final_exclude_folders = parse_exclude_folders(
675
+ user_exclude_folders=args.exclude_folders,
676
+ use_defaults=use_defaults,
677
+ include_folders=args.include_folders,
678
+ )
679
+
680
+ if not args.json:
681
+ if final_exclude_folders:
682
+ console.print(
683
+ f"[warn] Excluding:[/warn] {', '.join(sorted(final_exclude_folders))}"
684
+ )
685
+ else:
686
+ console.print("[good] No folders excluded[/good]")
236
687
 
237
688
  try:
238
- result_json = skylos.analyze(args.path)
689
+ with Progress(
690
+ SpinnerColumn(style="brand"),
691
+ TextColumn("[brand]Skylos[/brand] analyzing your code…"),
692
+ transient=True,
693
+ console=console,
694
+ ) as progress:
695
+ progress.add_task("analyze", total=None)
696
+ result_json = run_analyze(
697
+ args.path,
698
+ conf=args.confidence,
699
+ enable_secrets=bool(args.secrets),
700
+ enable_danger=bool(args.danger),
701
+ enable_quality=bool(args.quality),
702
+ exclude_folders=list(final_exclude_folders),
703
+ )
704
+
705
+ if args.json:
706
+ print(result_json)
707
+ return
708
+
239
709
  result = json.loads(result_json)
710
+
240
711
  except Exception as e:
241
712
  logger.error(f"Error during analysis: {e}")
242
713
  sys.exit(1)
243
714
 
244
- if args.json:
245
- logger.info(result_json)
246
- return
715
+ if args.interactive:
716
+ unused_functions = result.get("unused_functions", [])
717
+ unused_imports = result.get("unused_imports", [])
247
718
 
248
- unused_functions = result.get("unused_functions", [])
249
- unused_imports = result.get("unused_imports", [])
250
-
251
- logger.info(f"{Colors.CYAN}{Colors.BOLD}🔍 Python Static Analysis Results{Colors.RESET}")
252
- logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
253
-
254
- logger.info(f"\n{Colors.BOLD}Summary:{Colors.RESET}")
255
- logger.info(f" • Unreachable functions: {Colors.YELLOW}{len(unused_functions)}{Colors.RESET}")
256
- logger.info(f" • Unused imports: {Colors.YELLOW}{len(unused_imports)}{Colors.RESET}")
257
-
258
- if args.interactive and (unused_functions or unused_imports):
259
- logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
260
- selected_functions, selected_imports = interactive_selection(logger, unused_functions, unused_imports)
261
-
262
- if selected_functions or selected_imports:
263
- logger.info(f"\n{Colors.BOLD}Selected items to remove:{Colors.RESET}")
264
-
265
- if selected_functions:
266
- logger.info(f" Functions: {len(selected_functions)}")
267
- for func in selected_functions:
268
- logger.info(f" - {func['name']} ({func['file']}:{func['line']})")
269
-
270
- if selected_imports:
271
- logger.info(f" Imports: {len(selected_imports)}")
272
- for imp in selected_imports:
273
- logger.info(f" - {imp['name']} ({imp['file']}:{imp['line']})")
274
-
275
- if not args.dry_run:
276
- questions = [
277
- inquirer.Confirm('confirm',
278
- message="Are you sure you want to remove these items?",
279
- default=False)
280
- ]
281
- answers = inquirer.prompt(questions)
282
-
283
- if answers and answers['confirm']:
284
- logger.info(f"\n{Colors.YELLOW}Removing selected items...{Colors.RESET}")
285
-
286
- for func in selected_functions:
287
- success = remove_unused_function(func['file'], func['name'], func['line'])
288
- if success:
289
- logger.info(f" {Colors.GREEN}✓{Colors.RESET} Removed function: {func['name']}")
290
- else:
291
- logger.error(f" {Colors.RED}✗{Colors.RESET} Failed to remove: {func['name']}")
292
-
293
- for imp in selected_imports:
294
- success = remove_unused_import(imp['file'], imp['name'], imp['line'])
295
- if success:
296
- logger.info(f" {Colors.GREEN}✓{Colors.RESET} Removed import: {imp['name']}")
297
- else:
298
- logger.error(f" {Colors.RED}✗{Colors.RESET} Failed to remove: {imp['name']}")
299
-
300
- logger.info(f"\n{Colors.GREEN}Cleanup complete!{Colors.RESET}")
719
+ if not (unused_functions or unused_imports):
720
+ console.print("[good]No unused functions/imports to process.[/good]")
721
+ else:
722
+ selected_functions, selected_imports = interactive_selection(
723
+ console, unused_functions, unused_imports, root_path=project_root
724
+ )
725
+
726
+ if selected_functions or selected_imports:
727
+ if not args.dry_run:
728
+ if args.comment_out:
729
+ action_func_fn = comment_out_unused_function
730
+ action_func_imp = comment_out_unused_import
731
+ action_past = "Commented out"
732
+ action_verb = "comment out"
733
+ else:
734
+ action_func_fn = remove_unused_function
735
+ action_func_imp = remove_unused_import
736
+ action_past = "Removed"
737
+ action_verb = "remove"
738
+
739
+ if INTERACTIVE_AVAILABLE:
740
+ confirm_q = [
741
+ inquirer.Confirm(
742
+ "confirm",
743
+ message="Proceed with changes?",
744
+ default=False,
745
+ )
746
+ ]
747
+ answers = inquirer.prompt(confirm_q)
748
+ proceed = answers and answers.get("confirm")
749
+ else:
750
+ proceed = True
751
+
752
+ if proceed:
753
+ console.print(f"[warn]Applying changes…[/warn]")
754
+ for func in selected_functions:
755
+ ok = action_func_fn(
756
+ func["file"], func["name"], func["line"]
757
+ )
758
+ if ok:
759
+ console.print(
760
+ f"[good] {action_past} function:[/good] {func['name']}"
761
+ )
762
+ else:
763
+ console.print(
764
+ f"[bad] x Failed to {action_verb} function:[/bad] {func['name']}"
765
+ )
766
+
767
+ for imp in selected_imports:
768
+ ok = action_func_imp(imp["file"], imp["name"], imp["line"])
769
+ if ok:
770
+ console.print(
771
+ f"[good] ✓ {action_past} import:[/good] {imp['name']}"
772
+ )
773
+ else:
774
+ console.print(
775
+ f"[bad] x Failed to {action_verb} import:[/bad] {imp['name']}"
776
+ )
777
+ console.print(f"[good]Cleanup complete![/good]")
778
+ else:
779
+ console.print(f"[warn]Operation cancelled.[/warn]")
301
780
  else:
302
- logger.info(f"\n{Colors.YELLOW}Operation cancelled.{Colors.RESET}")
781
+ console.print(f"[warn]Dry run — no files modified.[/warn]")
303
782
  else:
304
- logger.info(f"\n{Colors.YELLOW}Dry run - no files were modified.{Colors.RESET}")
305
- else:
306
- logger.info(f"\n{Colors.BLUE}No items selected.{Colors.RESET}")
307
-
308
- else:
309
- if unused_functions:
310
- logger.info(f"\n{Colors.RED}{Colors.BOLD}📦 Unreachable Functions{Colors.RESET}")
311
- logger.info(f"{Colors.RED}{'=' * 23}{Colors.RESET}")
312
- for i, item in enumerate(unused_functions, 1):
313
- logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.RED}{item['name']}{Colors.RESET}")
314
- logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
315
- else:
316
- logger.info(f"\n{Colors.GREEN}✓ All functions are reachable!{Colors.RESET}")
317
-
318
- if unused_imports:
319
- logger.info(f"\n{Colors.MAGENTA}{Colors.BOLD}📥 Unused Imports{Colors.RESET}")
320
- logger.info(f"{Colors.MAGENTA}{'=' * 16}{Colors.RESET}")
321
- for i, item in enumerate(unused_imports, 1):
322
- logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{Colors.MAGENTA}{item['name']}{Colors.RESET}")
323
- logger.info(f" {Colors.GRAY}└─ {item['file']}:{item['line']}{Colors.RESET}")
324
- else:
325
- logger.info(f"\n{Colors.GREEN}✓ All imports are being used!{Colors.RESET}")
783
+ console.print("[muted]No items selected.[/muted]")
784
+
785
+ render_results(console, result, tree=args.tree, root_path=project_root)
786
+
787
+ unused_total = sum(
788
+ len(result.get(k, []))
789
+ for k in (
790
+ "unused_functions",
791
+ "unused_imports",
792
+ "unused_variables",
793
+ "unused_classes",
794
+ "unused_parameters",
795
+ )
796
+ )
797
+ danger_count = len(result.get("danger", []) or [])
798
+ quality_count = len(result.get("quality", []) or [])
799
+ print_badge(
800
+ unused_total,
801
+ logging.getLogger("skylos"),
802
+ danger_enabled=bool(danger_count),
803
+ danger_count=danger_count,
804
+ quality_enabled=bool(quality_count),
805
+ quality_count=quality_count,
806
+ )
807
+
808
+ if args.gate:
809
+ cfg = load_config(project_root)
326
810
 
327
- dead_code_count = len(unused_functions) + len(unused_imports)
328
- print_badge(dead_code_count, logger)
811
+ cmd = args.command
812
+ if cmd and cmd[0] == "--":
813
+ cmd = cmd[1:]
814
+
815
+ exit_code = run_gate_interaction(result, cfg, cmd)
816
+ sys.exit(exit_code)
329
817
 
330
- if unused_functions or unused_imports:
331
- logger.info(f"\n{Colors.BOLD}Next steps:{Colors.RESET}")
332
- logger.info(f" • Use --interactive to select specific items to remove")
333
- logger.info(f" • Use --dry-run to preview changes before applying them")
334
818
 
335
819
  if __name__ == "__main__":
336
- main()
820
+ main()