lucidscan 0.5.12__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.
- lucidscan/__init__.py +12 -0
- lucidscan/bootstrap/__init__.py +26 -0
- lucidscan/bootstrap/paths.py +160 -0
- lucidscan/bootstrap/platform.py +111 -0
- lucidscan/bootstrap/validation.py +76 -0
- lucidscan/bootstrap/versions.py +119 -0
- lucidscan/cli/__init__.py +50 -0
- lucidscan/cli/__main__.py +8 -0
- lucidscan/cli/arguments.py +405 -0
- lucidscan/cli/commands/__init__.py +64 -0
- lucidscan/cli/commands/autoconfigure.py +294 -0
- lucidscan/cli/commands/help.py +69 -0
- lucidscan/cli/commands/init.py +656 -0
- lucidscan/cli/commands/list_scanners.py +59 -0
- lucidscan/cli/commands/scan.py +307 -0
- lucidscan/cli/commands/serve.py +142 -0
- lucidscan/cli/commands/status.py +84 -0
- lucidscan/cli/commands/validate.py +105 -0
- lucidscan/cli/config_bridge.py +152 -0
- lucidscan/cli/exit_codes.py +17 -0
- lucidscan/cli/runner.py +284 -0
- lucidscan/config/__init__.py +29 -0
- lucidscan/config/ignore.py +178 -0
- lucidscan/config/loader.py +431 -0
- lucidscan/config/models.py +316 -0
- lucidscan/config/validation.py +645 -0
- lucidscan/core/__init__.py +3 -0
- lucidscan/core/domain_runner.py +463 -0
- lucidscan/core/git.py +174 -0
- lucidscan/core/logging.py +34 -0
- lucidscan/core/models.py +207 -0
- lucidscan/core/streaming.py +340 -0
- lucidscan/core/subprocess_runner.py +164 -0
- lucidscan/detection/__init__.py +21 -0
- lucidscan/detection/detector.py +154 -0
- lucidscan/detection/frameworks.py +270 -0
- lucidscan/detection/languages.py +328 -0
- lucidscan/detection/tools.py +229 -0
- lucidscan/generation/__init__.py +15 -0
- lucidscan/generation/config_generator.py +275 -0
- lucidscan/generation/package_installer.py +330 -0
- lucidscan/mcp/__init__.py +20 -0
- lucidscan/mcp/formatter.py +510 -0
- lucidscan/mcp/server.py +297 -0
- lucidscan/mcp/tools.py +1049 -0
- lucidscan/mcp/watcher.py +237 -0
- lucidscan/pipeline/__init__.py +17 -0
- lucidscan/pipeline/executor.py +187 -0
- lucidscan/pipeline/parallel.py +181 -0
- lucidscan/plugins/__init__.py +40 -0
- lucidscan/plugins/coverage/__init__.py +28 -0
- lucidscan/plugins/coverage/base.py +160 -0
- lucidscan/plugins/coverage/coverage_py.py +454 -0
- lucidscan/plugins/coverage/istanbul.py +411 -0
- lucidscan/plugins/discovery.py +107 -0
- lucidscan/plugins/enrichers/__init__.py +61 -0
- lucidscan/plugins/enrichers/base.py +63 -0
- lucidscan/plugins/linters/__init__.py +26 -0
- lucidscan/plugins/linters/base.py +125 -0
- lucidscan/plugins/linters/biome.py +448 -0
- lucidscan/plugins/linters/checkstyle.py +393 -0
- lucidscan/plugins/linters/eslint.py +368 -0
- lucidscan/plugins/linters/ruff.py +498 -0
- lucidscan/plugins/reporters/__init__.py +45 -0
- lucidscan/plugins/reporters/base.py +30 -0
- lucidscan/plugins/reporters/json_reporter.py +79 -0
- lucidscan/plugins/reporters/sarif_reporter.py +303 -0
- lucidscan/plugins/reporters/summary_reporter.py +61 -0
- lucidscan/plugins/reporters/table_reporter.py +81 -0
- lucidscan/plugins/scanners/__init__.py +57 -0
- lucidscan/plugins/scanners/base.py +60 -0
- lucidscan/plugins/scanners/checkov.py +484 -0
- lucidscan/plugins/scanners/opengrep.py +464 -0
- lucidscan/plugins/scanners/trivy.py +492 -0
- lucidscan/plugins/test_runners/__init__.py +27 -0
- lucidscan/plugins/test_runners/base.py +111 -0
- lucidscan/plugins/test_runners/jest.py +381 -0
- lucidscan/plugins/test_runners/karma.py +481 -0
- lucidscan/plugins/test_runners/playwright.py +434 -0
- lucidscan/plugins/test_runners/pytest.py +598 -0
- lucidscan/plugins/type_checkers/__init__.py +27 -0
- lucidscan/plugins/type_checkers/base.py +106 -0
- lucidscan/plugins/type_checkers/mypy.py +355 -0
- lucidscan/plugins/type_checkers/pyright.py +313 -0
- lucidscan/plugins/type_checkers/typescript.py +280 -0
- lucidscan-0.5.12.dist-info/METADATA +242 -0
- lucidscan-0.5.12.dist-info/RECORD +91 -0
- lucidscan-0.5.12.dist-info/WHEEL +5 -0
- lucidscan-0.5.12.dist-info/entry_points.txt +34 -0
- lucidscan-0.5.12.dist-info/licenses/LICENSE +201 -0
- lucidscan-0.5.12.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""ESLint linter plugin.
|
|
2
|
+
|
|
3
|
+
ESLint is a pluggable linting utility for JavaScript and TypeScript.
|
|
4
|
+
https://eslint.org/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from lucidscan.core.logging import get_logger
|
|
17
|
+
from lucidscan.core.models import (
|
|
18
|
+
ScanContext,
|
|
19
|
+
Severity,
|
|
20
|
+
ToolDomain,
|
|
21
|
+
UnifiedIssue,
|
|
22
|
+
)
|
|
23
|
+
from lucidscan.core.subprocess_runner import run_with_streaming
|
|
24
|
+
from lucidscan.plugins.linters.base import FixResult, LinterPlugin
|
|
25
|
+
|
|
26
|
+
LOGGER = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
# ESLint severity mapping
|
|
29
|
+
# ESLint uses: 1=warning, 2=error
|
|
30
|
+
SEVERITY_MAP = {
|
|
31
|
+
2: Severity.HIGH, # error
|
|
32
|
+
1: Severity.MEDIUM, # warning
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ESLintLinter(LinterPlugin):
|
|
37
|
+
"""ESLint linter plugin for JavaScript/TypeScript code analysis."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
40
|
+
"""Initialize ESLintLinter.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
project_root: Optional project root for finding ESLint installation.
|
|
44
|
+
"""
|
|
45
|
+
self._project_root = project_root
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def name(self) -> str:
|
|
49
|
+
"""Plugin identifier."""
|
|
50
|
+
return "eslint"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def languages(self) -> List[str]:
|
|
54
|
+
"""Supported languages."""
|
|
55
|
+
return ["javascript", "typescript"]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def supports_fix(self) -> bool:
|
|
59
|
+
"""ESLint supports auto-fix."""
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def get_version(self) -> str:
|
|
63
|
+
"""Get ESLint version.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Version string or 'unknown' if unable to determine.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
binary = self.ensure_binary()
|
|
70
|
+
result = subprocess.run(
|
|
71
|
+
[str(binary), "--version"],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
errors="replace",
|
|
76
|
+
timeout=30,
|
|
77
|
+
)
|
|
78
|
+
# Output is like "v8.56.0"
|
|
79
|
+
if result.returncode == 0:
|
|
80
|
+
return result.stdout.strip().lstrip("v")
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
return "unknown"
|
|
84
|
+
|
|
85
|
+
def ensure_binary(self) -> Path:
|
|
86
|
+
"""Ensure ESLint is available.
|
|
87
|
+
|
|
88
|
+
Checks for ESLint in:
|
|
89
|
+
1. Project's node_modules/.bin/eslint
|
|
90
|
+
2. System PATH (globally installed)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Path to ESLint binary.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
FileNotFoundError: If ESLint is not installed.
|
|
97
|
+
"""
|
|
98
|
+
# Check project node_modules first
|
|
99
|
+
if self._project_root:
|
|
100
|
+
node_eslint = self._project_root / "node_modules" / ".bin" / "eslint"
|
|
101
|
+
if node_eslint.exists():
|
|
102
|
+
return node_eslint
|
|
103
|
+
|
|
104
|
+
# Check system PATH
|
|
105
|
+
eslint_path = shutil.which("eslint")
|
|
106
|
+
if eslint_path:
|
|
107
|
+
return Path(eslint_path)
|
|
108
|
+
|
|
109
|
+
raise FileNotFoundError(
|
|
110
|
+
"ESLint is not installed. Install it with:\n"
|
|
111
|
+
" npm install eslint --save-dev\n"
|
|
112
|
+
" OR\n"
|
|
113
|
+
" npm install -g eslint"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def lint(self, context: ScanContext) -> List[UnifiedIssue]:
|
|
117
|
+
"""Run ESLint linting.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
context: Scan context with paths and configuration.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
List of linting issues.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
binary = self.ensure_binary()
|
|
127
|
+
except FileNotFoundError as e:
|
|
128
|
+
LOGGER.warning(str(e))
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
# Build command
|
|
132
|
+
cmd = [
|
|
133
|
+
str(binary),
|
|
134
|
+
"--format", "json",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
# Add paths to check - default to src if exists, otherwise current dir
|
|
138
|
+
if context.paths:
|
|
139
|
+
paths = [str(p) for p in context.paths]
|
|
140
|
+
else:
|
|
141
|
+
src_dir = context.project_root / "src"
|
|
142
|
+
if src_dir.exists():
|
|
143
|
+
paths = [str(src_dir)]
|
|
144
|
+
else:
|
|
145
|
+
paths = ["."]
|
|
146
|
+
|
|
147
|
+
cmd.extend(paths)
|
|
148
|
+
|
|
149
|
+
# Add ignore patterns
|
|
150
|
+
exclude_patterns = context.get_exclude_patterns()
|
|
151
|
+
for pattern in exclude_patterns:
|
|
152
|
+
cmd.extend(["--ignore-pattern", pattern])
|
|
153
|
+
|
|
154
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
result = run_with_streaming(
|
|
158
|
+
cmd=cmd,
|
|
159
|
+
cwd=context.project_root,
|
|
160
|
+
tool_name="eslint",
|
|
161
|
+
stream_handler=context.stream_handler,
|
|
162
|
+
timeout=120,
|
|
163
|
+
)
|
|
164
|
+
except subprocess.TimeoutExpired:
|
|
165
|
+
LOGGER.warning("ESLint lint timed out after 120 seconds")
|
|
166
|
+
return []
|
|
167
|
+
except Exception as e:
|
|
168
|
+
LOGGER.error(f"Failed to run ESLint: {e}")
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
# Parse output
|
|
172
|
+
issues = self._parse_output(result.stdout, context.project_root)
|
|
173
|
+
|
|
174
|
+
LOGGER.info(f"ESLint found {len(issues)} issues")
|
|
175
|
+
return issues
|
|
176
|
+
|
|
177
|
+
def fix(self, context: ScanContext) -> FixResult:
|
|
178
|
+
"""Apply ESLint auto-fixes.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
context: Scan context with paths and configuration.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
FixResult with statistics.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
binary = self.ensure_binary()
|
|
188
|
+
except FileNotFoundError as e:
|
|
189
|
+
LOGGER.warning(str(e))
|
|
190
|
+
return FixResult()
|
|
191
|
+
|
|
192
|
+
# Run without fix to count issues first
|
|
193
|
+
pre_issues = self.lint(context)
|
|
194
|
+
|
|
195
|
+
# Build fix command
|
|
196
|
+
cmd = [
|
|
197
|
+
str(binary),
|
|
198
|
+
"--fix",
|
|
199
|
+
"--format", "json",
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
if context.paths:
|
|
203
|
+
paths = [str(p) for p in context.paths]
|
|
204
|
+
else:
|
|
205
|
+
src_dir = context.project_root / "src"
|
|
206
|
+
if src_dir.exists():
|
|
207
|
+
paths = [str(src_dir)]
|
|
208
|
+
else:
|
|
209
|
+
paths = ["."]
|
|
210
|
+
|
|
211
|
+
cmd.extend(paths)
|
|
212
|
+
|
|
213
|
+
exclude_patterns = context.get_exclude_patterns()
|
|
214
|
+
for pattern in exclude_patterns:
|
|
215
|
+
cmd.extend(["--ignore-pattern", pattern])
|
|
216
|
+
|
|
217
|
+
LOGGER.debug(f"Running: {' '.join(cmd)}")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
result = run_with_streaming(
|
|
221
|
+
cmd=cmd,
|
|
222
|
+
cwd=context.project_root,
|
|
223
|
+
tool_name="eslint-fix",
|
|
224
|
+
stream_handler=context.stream_handler,
|
|
225
|
+
timeout=120,
|
|
226
|
+
)
|
|
227
|
+
except subprocess.TimeoutExpired:
|
|
228
|
+
LOGGER.warning("ESLint fix timed out after 120 seconds")
|
|
229
|
+
return FixResult()
|
|
230
|
+
except Exception as e:
|
|
231
|
+
LOGGER.error(f"Failed to run ESLint fix: {e}")
|
|
232
|
+
return FixResult()
|
|
233
|
+
|
|
234
|
+
# Parse remaining issues
|
|
235
|
+
post_issues = self._parse_output(result.stdout, context.project_root)
|
|
236
|
+
|
|
237
|
+
# Calculate stats
|
|
238
|
+
files_modified = len(set(
|
|
239
|
+
str(issue.file_path)
|
|
240
|
+
for issue in pre_issues
|
|
241
|
+
if issue not in post_issues
|
|
242
|
+
))
|
|
243
|
+
|
|
244
|
+
return FixResult(
|
|
245
|
+
files_modified=files_modified,
|
|
246
|
+
issues_fixed=len(pre_issues) - len(post_issues),
|
|
247
|
+
issues_remaining=len(post_issues),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _parse_output(self, output: str, project_root: Path) -> List[UnifiedIssue]:
|
|
251
|
+
"""Parse ESLint JSON output.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
output: JSON output from ESLint.
|
|
255
|
+
project_root: Project root directory.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
List of UnifiedIssue objects.
|
|
259
|
+
"""
|
|
260
|
+
if not output.strip():
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
results = json.loads(output)
|
|
265
|
+
except json.JSONDecodeError:
|
|
266
|
+
LOGGER.warning("Failed to parse ESLint output as JSON")
|
|
267
|
+
return []
|
|
268
|
+
|
|
269
|
+
issues = []
|
|
270
|
+
for file_result in results:
|
|
271
|
+
file_path = file_result.get("filePath", "")
|
|
272
|
+
messages = file_result.get("messages", [])
|
|
273
|
+
|
|
274
|
+
for message in messages:
|
|
275
|
+
issue = self._message_to_issue(message, file_path, project_root)
|
|
276
|
+
if issue:
|
|
277
|
+
issues.append(issue)
|
|
278
|
+
|
|
279
|
+
return issues
|
|
280
|
+
|
|
281
|
+
def _message_to_issue(
|
|
282
|
+
self,
|
|
283
|
+
message: Dict[str, Any],
|
|
284
|
+
file_path: str,
|
|
285
|
+
project_root: Path,
|
|
286
|
+
) -> Optional[UnifiedIssue]:
|
|
287
|
+
"""Convert ESLint message to UnifiedIssue.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
message: ESLint message dict.
|
|
291
|
+
file_path: File path from ESLint.
|
|
292
|
+
project_root: Project root directory.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
UnifiedIssue or None.
|
|
296
|
+
"""
|
|
297
|
+
try:
|
|
298
|
+
severity_int = message.get("severity", 2)
|
|
299
|
+
rule_id = message.get("ruleId", "")
|
|
300
|
+
msg = message.get("message", "")
|
|
301
|
+
line = message.get("line")
|
|
302
|
+
column = message.get("column")
|
|
303
|
+
end_line = message.get("endLine")
|
|
304
|
+
|
|
305
|
+
# Get severity
|
|
306
|
+
severity = SEVERITY_MAP.get(severity_int, Severity.MEDIUM)
|
|
307
|
+
|
|
308
|
+
# Build file path
|
|
309
|
+
path = Path(file_path)
|
|
310
|
+
if not path.is_absolute():
|
|
311
|
+
path = project_root / path
|
|
312
|
+
|
|
313
|
+
# Generate deterministic ID
|
|
314
|
+
issue_id = self._generate_issue_id(rule_id, file_path, line, column, msg)
|
|
315
|
+
|
|
316
|
+
# Build title
|
|
317
|
+
title = f"[{rule_id}] {msg}" if rule_id else msg
|
|
318
|
+
|
|
319
|
+
# Check if fixable
|
|
320
|
+
fixable = message.get("fix") is not None
|
|
321
|
+
|
|
322
|
+
# Extract end column
|
|
323
|
+
end_column = message.get("endColumn")
|
|
324
|
+
|
|
325
|
+
return UnifiedIssue(
|
|
326
|
+
id=issue_id,
|
|
327
|
+
domain=ToolDomain.LINTING,
|
|
328
|
+
source_tool="eslint",
|
|
329
|
+
severity=severity,
|
|
330
|
+
rule_id=rule_id or "unknown",
|
|
331
|
+
title=title,
|
|
332
|
+
description=msg,
|
|
333
|
+
documentation_url=f"https://eslint.org/docs/rules/{rule_id}" if rule_id else None,
|
|
334
|
+
file_path=path,
|
|
335
|
+
line_start=line,
|
|
336
|
+
line_end=end_line or line,
|
|
337
|
+
column_start=column,
|
|
338
|
+
column_end=end_column,
|
|
339
|
+
fixable=fixable,
|
|
340
|
+
metadata={},
|
|
341
|
+
)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
LOGGER.warning(f"Failed to parse ESLint message: {e}")
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def _generate_issue_id(
|
|
347
|
+
self,
|
|
348
|
+
rule: str,
|
|
349
|
+
file: str,
|
|
350
|
+
line: Optional[int],
|
|
351
|
+
column: Optional[int],
|
|
352
|
+
message: str,
|
|
353
|
+
) -> str:
|
|
354
|
+
"""Generate deterministic issue ID.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
rule: Rule ID.
|
|
358
|
+
file: File path.
|
|
359
|
+
line: Line number.
|
|
360
|
+
column: Column number.
|
|
361
|
+
message: Error message.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Unique issue ID.
|
|
365
|
+
"""
|
|
366
|
+
content = f"{rule}:{file}:{line or 0}:{column or 0}:{message}"
|
|
367
|
+
hash_val = hashlib.sha256(content.encode()).hexdigest()[:12]
|
|
368
|
+
return f"eslint-{rule}-{hash_val}" if rule else f"eslint-{hash_val}"
|