supersonar 0.1.0__tar.gz

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.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: supersonar
3
+ Version: 0.1.0
4
+ Summary: A SonarQube-like static analysis CLI for Python projects.
5
+ Author: Supersonar Contributors
6
+ License: MIT
7
+ Keywords: static-analysis,lint,security,ci,sonarqube
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # supersonar
16
+
17
+ `supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
18
+ It is designed for local use and CI pipelines via `pip install`.
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ pip install .
24
+ supersonar scan . --format json
25
+ ```
26
+
27
+ ## CI usage
28
+
29
+ ```bash
30
+ pip install supersonar
31
+ supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
32
+ ```
33
+
34
+ ## Config (`supersonar.toml`)
35
+
36
+ ```toml
37
+ [scan]
38
+ exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
39
+
40
+ [quality_gate]
41
+ fail_on = "high"
42
+ max_issues = 0
43
+
44
+ [report]
45
+ format = "json"
46
+ ```
47
+
48
+ ## Rule coverage (MVP)
49
+
50
+ - `SS001` - dangerous `eval` / `exec`
51
+ - `SS002` - broad `except Exception` or bare `except`
52
+ - `SS003` - hardcoded secret-like tokens
53
+ - `SS004` - `TODO` / `FIXME` markers
@@ -0,0 +1,39 @@
1
+ # supersonar
2
+
3
+ `supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
4
+ It is designed for local use and CI pipelines via `pip install`.
5
+
6
+ ## Quick start
7
+
8
+ ```bash
9
+ pip install .
10
+ supersonar scan . --format json
11
+ ```
12
+
13
+ ## CI usage
14
+
15
+ ```bash
16
+ pip install supersonar
17
+ supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
18
+ ```
19
+
20
+ ## Config (`supersonar.toml`)
21
+
22
+ ```toml
23
+ [scan]
24
+ exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
25
+
26
+ [quality_gate]
27
+ fail_on = "high"
28
+ max_issues = 0
29
+
30
+ [report]
31
+ format = "json"
32
+ ```
33
+
34
+ ## Rule coverage (MVP)
35
+
36
+ - `SS001` - dangerous `eval` / `exec`
37
+ - `SS002` - broad `except Exception` or bare `except`
38
+ - `SS003` - hardcoded secret-like tokens
39
+ - `SS004` - `TODO` / `FIXME` markers
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "supersonar"
7
+ version = "0.1.0"
8
+ description = "A SonarQube-like static analysis CLI for Python projects."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Supersonar Contributors" }]
12
+ license = { text = "MIT" }
13
+ keywords = ["static-analysis", "lint", "security", "ci", "sonarqube"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Environment :: Console",
19
+ ]
20
+
21
+ [project.scripts]
22
+ supersonar = "supersonar.cli:main"
23
+
24
+ [tool.setuptools.packages.find]
25
+ include = ["supersonar*"]
26
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "0.1.0"
3
+
@@ -0,0 +1,6 @@
1
+ from supersonar.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from supersonar.config import Config, load_config
7
+ from supersonar.models import Severity
8
+ from supersonar.quality_gate import evaluate_gate
9
+ from supersonar.reporters import to_json_report, to_sarif_report, write_report
10
+ from supersonar.scanner import scan_path
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(prog="supersonar", description="Static analysis scanner for Python.")
15
+ subparsers = parser.add_subparsers(dest="command", required=True)
16
+
17
+ scan = subparsers.add_parser("scan", help="Run static analysis scan.")
18
+ scan.add_argument("path", nargs="?", default=".", help="Path to scan.")
19
+ scan.add_argument("--config", help="Path to supersonar TOML config.")
20
+ scan.add_argument("--exclude", action="append", default=[], help="Extra exclude directory names.")
21
+ scan.add_argument("--format", choices=["json", "sarif"], help="Report output format.")
22
+ scan.add_argument("--out", help="Write report to file. Defaults to stdout.")
23
+ scan.add_argument("--fail-on", choices=["low", "medium", "high", "critical"], help="Fail on severity level.")
24
+ scan.add_argument("--max-issues", type=int, help="Fail if issue count exceeds this number.")
25
+
26
+ return parser
27
+
28
+
29
+ def main() -> None:
30
+ parser = build_parser()
31
+ args = parser.parse_args()
32
+
33
+ if args.command == "scan":
34
+ exit_code = run_scan(args)
35
+ raise SystemExit(exit_code)
36
+
37
+
38
+ def run_scan(args: argparse.Namespace) -> int:
39
+ config = load_config(args.config)
40
+ merged = merge_cli_with_config(args, config)
41
+
42
+ result = scan_path(args.path, excludes=merged.scan.exclude)
43
+ report_payload = render_report(result, merged.report.output_format)
44
+ write_report(report_payload, merged.report.out)
45
+
46
+ passed, reasons = evaluate_gate(
47
+ result,
48
+ fail_on=merged.quality_gate.fail_on,
49
+ max_issues=merged.quality_gate.max_issues,
50
+ )
51
+ if not passed:
52
+ for reason in reasons:
53
+ print(f"[gate] {reason}", file=sys.stderr)
54
+ return 2
55
+ return 0
56
+
57
+
58
+ def merge_cli_with_config(args: argparse.Namespace, config: Config) -> Config:
59
+ merged = config
60
+ if args.exclude:
61
+ merged.scan.exclude = list(dict.fromkeys([*merged.scan.exclude, *args.exclude]))
62
+ if args.format:
63
+ merged.report.output_format = args.format
64
+ if args.out:
65
+ merged.report.out = args.out
66
+ if args.fail_on:
67
+ merged.quality_gate.fail_on = args.fail_on
68
+ if args.max_issues is not None:
69
+ merged.quality_gate.max_issues = args.max_issues
70
+ return merged
71
+
72
+
73
+ def render_report(result, output_format: str):
74
+ if output_format == "sarif":
75
+ return to_sarif_report(result)
76
+ if output_format == "json":
77
+ return to_json_report(result)
78
+ raise ValueError(f"Unsupported report format: {output_format}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
83
+
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+ from supersonar.models import Severity
8
+
9
+
10
+ DEFAULT_EXCLUDES = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class ScanConfig:
15
+ exclude: list[str] = field(default_factory=lambda: DEFAULT_EXCLUDES.copy())
16
+
17
+
18
+ @dataclass(slots=True)
19
+ class QualityGateConfig:
20
+ fail_on: Severity | None = None
21
+ max_issues: int | None = None
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class ReportConfig:
26
+ output_format: str = "json"
27
+ out: str | None = None
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class Config:
32
+ scan: ScanConfig = field(default_factory=ScanConfig)
33
+ quality_gate: QualityGateConfig = field(default_factory=QualityGateConfig)
34
+ report: ReportConfig = field(default_factory=ReportConfig)
35
+
36
+
37
+ def load_config(path: str | None) -> Config:
38
+ if path is None:
39
+ default = Path("supersonar.toml")
40
+ if not default.exists():
41
+ return Config()
42
+ path = str(default)
43
+
44
+ cfg_path = Path(path)
45
+ if not cfg_path.exists():
46
+ raise FileNotFoundError(f"Config file not found: {cfg_path}")
47
+
48
+ with cfg_path.open("rb") as fh:
49
+ payload = tomllib.load(fh)
50
+
51
+ scan = payload.get("scan", {})
52
+ quality_gate = payload.get("quality_gate", {})
53
+ report = payload.get("report", {})
54
+
55
+ config = Config()
56
+ config.scan.exclude = list(scan.get("exclude", config.scan.exclude))
57
+ config.quality_gate.fail_on = quality_gate.get("fail_on")
58
+ config.quality_gate.max_issues = quality_gate.get("max_issues")
59
+ config.report.output_format = report.get("format", config.report.output_format)
60
+ config.report.out = report.get("out")
61
+ return config
62
+
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ Severity = Literal["low", "medium", "high", "critical"]
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class Issue:
11
+ rule_id: str
12
+ title: str
13
+ severity: Severity
14
+ message: str
15
+ file_path: str
16
+ line: int
17
+ column: int
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class ScanResult:
22
+ issues: list[Issue]
23
+ files_scanned: int
24
+
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from supersonar.models import ScanResult, Severity
4
+
5
+
6
+ SEVERITY_ORDER: dict[Severity, int] = {
7
+ "low": 1,
8
+ "medium": 2,
9
+ "high": 3,
10
+ "critical": 4,
11
+ }
12
+
13
+
14
+ def evaluate_gate(
15
+ result: ScanResult,
16
+ fail_on: Severity | None = None,
17
+ max_issues: int | None = None,
18
+ ) -> tuple[bool, list[str]]:
19
+ failed_reasons: list[str] = []
20
+
21
+ if fail_on is not None:
22
+ threshold = SEVERITY_ORDER[fail_on]
23
+ if any(SEVERITY_ORDER[issue.severity] >= threshold for issue in result.issues):
24
+ failed_reasons.append(f"Detected issue severity >= '{fail_on}'")
25
+
26
+ if max_issues is not None and len(result.issues) > max_issues:
27
+ failed_reasons.append(f"Issue count {len(result.issues)} exceeds max_issues={max_issues}")
28
+
29
+ return (len(failed_reasons) == 0, failed_reasons)
30
+
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from supersonar.models import Issue, ScanResult
9
+
10
+
11
+ def _issue_to_dict(issue: Issue) -> dict[str, Any]:
12
+ return {
13
+ "rule_id": issue.rule_id,
14
+ "title": issue.title,
15
+ "severity": issue.severity,
16
+ "message": issue.message,
17
+ "file_path": issue.file_path,
18
+ "line": issue.line,
19
+ "column": issue.column,
20
+ }
21
+
22
+
23
+ def to_json_report(result: ScanResult) -> dict[str, Any]:
24
+ counts = Counter(issue.severity for issue in result.issues)
25
+ return {
26
+ "files_scanned": result.files_scanned,
27
+ "issues_total": len(result.issues),
28
+ "severity_counts": dict(counts),
29
+ "issues": [_issue_to_dict(issue) for issue in result.issues],
30
+ }
31
+
32
+
33
+ def to_sarif_report(result: ScanResult) -> dict[str, Any]:
34
+ sarif_results: list[dict[str, Any]] = []
35
+ for issue in result.issues:
36
+ sarif_results.append(
37
+ {
38
+ "ruleId": issue.rule_id,
39
+ "level": _severity_to_level(issue.severity),
40
+ "message": {"text": f"{issue.title}: {issue.message}"},
41
+ "locations": [
42
+ {
43
+ "physicalLocation": {
44
+ "artifactLocation": {"uri": issue.file_path},
45
+ "region": {"startLine": issue.line, "startColumn": issue.column},
46
+ }
47
+ }
48
+ ],
49
+ }
50
+ )
51
+
52
+ return {
53
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
54
+ "version": "2.1.0",
55
+ "runs": [
56
+ {
57
+ "tool": {"driver": {"name": "supersonar", "informationUri": "https://example.com"}},
58
+ "results": sarif_results,
59
+ }
60
+ ],
61
+ }
62
+
63
+
64
+ def write_report(payload: dict[str, Any], out: str | None) -> None:
65
+ rendered = json.dumps(payload, indent=2)
66
+ if out is None:
67
+ print(rendered)
68
+ return
69
+ output_path = Path(out)
70
+ output_path.parent.mkdir(parents=True, exist_ok=True)
71
+ output_path.write_text(rendered, encoding="utf-8")
72
+
73
+
74
+ def _severity_to_level(severity: str) -> str:
75
+ mapping = {
76
+ "low": "note",
77
+ "medium": "warning",
78
+ "high": "error",
79
+ "critical": "error",
80
+ }
81
+ return mapping.get(severity, "warning")
82
+
@@ -0,0 +1,4 @@
1
+ from supersonar.rules.python import PythonRuleEngine
2
+
3
+ __all__ = ["PythonRuleEngine"]
4
+
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ import re
6
+
7
+ from supersonar.models import Issue
8
+
9
+
10
+ SECRET_PATTERN = re.compile(
11
+ r"(api[_-]?key|secret|token|password)\s*=\s*['\"][^'\"]{8,}['\"]",
12
+ re.IGNORECASE,
13
+ )
14
+
15
+
16
+ class PythonRuleEngine:
17
+ def run(self, file_path: Path) -> list[Issue]:
18
+ source = file_path.read_text(encoding="utf-8", errors="replace")
19
+ issues: list[Issue] = []
20
+ issues.extend(self._find_todo_fixme(source, file_path))
21
+ issues.extend(self._find_secrets(source, file_path))
22
+ issues.extend(self._analyze_ast(source, file_path))
23
+ return issues
24
+
25
+ def _find_todo_fixme(self, source: str, file_path: Path) -> list[Issue]:
26
+ issues: list[Issue] = []
27
+ for idx, line in enumerate(source.splitlines(), start=1):
28
+ if "TODO" in line or "FIXME" in line:
29
+ issues.append(
30
+ Issue(
31
+ rule_id="SS004",
32
+ title="Work item marker in source",
33
+ severity="low",
34
+ message="Found TODO/FIXME marker. Track and resolve before release.",
35
+ file_path=str(file_path),
36
+ line=idx,
37
+ column=max(line.find("TODO"), line.find("FIXME"), 0) + 1,
38
+ )
39
+ )
40
+ return issues
41
+
42
+ def _find_secrets(self, source: str, file_path: Path) -> list[Issue]:
43
+ issues: list[Issue] = []
44
+ for idx, line in enumerate(source.splitlines(), start=1):
45
+ if SECRET_PATTERN.search(line):
46
+ issues.append(
47
+ Issue(
48
+ rule_id="SS003",
49
+ title="Potential hardcoded secret",
50
+ severity="high",
51
+ message="Potential credential/token assignment found in source.",
52
+ file_path=str(file_path),
53
+ line=idx,
54
+ column=1,
55
+ )
56
+ )
57
+ return issues
58
+
59
+ def _analyze_ast(self, source: str, file_path: Path) -> list[Issue]:
60
+ issues: list[Issue] = []
61
+ try:
62
+ tree = ast.parse(source)
63
+ except SyntaxError as exc:
64
+ issues.append(
65
+ Issue(
66
+ rule_id="SS000",
67
+ title="Syntax error",
68
+ severity="medium",
69
+ message=f"Could not parse file: {exc.msg}",
70
+ file_path=str(file_path),
71
+ line=exc.lineno or 1,
72
+ column=exc.offset or 1,
73
+ )
74
+ )
75
+ return issues
76
+
77
+ for node in ast.walk(tree):
78
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
79
+ if node.func.id in {"eval", "exec"}:
80
+ issues.append(
81
+ Issue(
82
+ rule_id="SS001",
83
+ title="Dangerous dynamic execution",
84
+ severity="critical",
85
+ message=f"Avoid {node.func.id}() because it executes dynamic code.",
86
+ file_path=str(file_path),
87
+ line=getattr(node, "lineno", 1),
88
+ column=getattr(node, "col_offset", 0) + 1,
89
+ )
90
+ )
91
+ if isinstance(node, ast.ExceptHandler):
92
+ is_bare = node.type is None
93
+ is_exception = isinstance(node.type, ast.Name) and node.type.id == "Exception"
94
+ if is_bare or is_exception:
95
+ issues.append(
96
+ Issue(
97
+ rule_id="SS002",
98
+ title="Broad exception handling",
99
+ severity="medium",
100
+ message="Avoid bare except or except Exception; catch specific errors.",
101
+ file_path=str(file_path),
102
+ line=getattr(node, "lineno", 1),
103
+ column=getattr(node, "col_offset", 0) + 1,
104
+ )
105
+ )
106
+ return issues
107
+
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from supersonar.models import Issue, ScanResult
6
+ from supersonar.rules import PythonRuleEngine
7
+
8
+
9
+ def _should_exclude(path: Path, excludes: list[str]) -> bool:
10
+ parts = set(path.parts)
11
+ return any(ex in parts for ex in excludes)
12
+
13
+
14
+ def scan_path(root: str, excludes: list[str]) -> ScanResult:
15
+ root_path = Path(root).resolve()
16
+ rule_engine = PythonRuleEngine()
17
+ issues: list[Issue] = []
18
+ files_scanned = 0
19
+
20
+ for file_path in root_path.rglob("*.py"):
21
+ if _should_exclude(file_path, excludes):
22
+ continue
23
+ files_scanned += 1
24
+ issues.extend(rule_engine.run(file_path))
25
+
26
+ return ScanResult(issues=issues, files_scanned=files_scanned)
27
+
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: supersonar
3
+ Version: 0.1.0
4
+ Summary: A SonarQube-like static analysis CLI for Python projects.
5
+ Author: Supersonar Contributors
6
+ License: MIT
7
+ Keywords: static-analysis,lint,security,ci,sonarqube
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3 :: Only
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # supersonar
16
+
17
+ `supersonar` is a lightweight, SonarQube-inspired static analysis CLI for Python projects.
18
+ It is designed for local use and CI pipelines via `pip install`.
19
+
20
+ ## Quick start
21
+
22
+ ```bash
23
+ pip install .
24
+ supersonar scan . --format json
25
+ ```
26
+
27
+ ## CI usage
28
+
29
+ ```bash
30
+ pip install supersonar
31
+ supersonar scan . --format sarif --out reports/supersonar.sarif --fail-on high
32
+ ```
33
+
34
+ ## Config (`supersonar.toml`)
35
+
36
+ ```toml
37
+ [scan]
38
+ exclude = [".git", ".venv", "venv", "build", "dist", "__pycache__"]
39
+
40
+ [quality_gate]
41
+ fail_on = "high"
42
+ max_issues = 0
43
+
44
+ [report]
45
+ format = "json"
46
+ ```
47
+
48
+ ## Rule coverage (MVP)
49
+
50
+ - `SS001` - dangerous `eval` / `exec`
51
+ - `SS002` - broad `except Exception` or bare `except`
52
+ - `SS003` - hardcoded secret-like tokens
53
+ - `SS004` - `TODO` / `FIXME` markers
@@ -0,0 +1,19 @@
1
+ README.md
2
+ pyproject.toml
3
+ supersonar/__init__.py
4
+ supersonar/__main__.py
5
+ supersonar/cli.py
6
+ supersonar/config.py
7
+ supersonar/models.py
8
+ supersonar/quality_gate.py
9
+ supersonar/reporters.py
10
+ supersonar/scanner.py
11
+ supersonar.egg-info/PKG-INFO
12
+ supersonar.egg-info/SOURCES.txt
13
+ supersonar.egg-info/dependency_links.txt
14
+ supersonar.egg-info/entry_points.txt
15
+ supersonar.egg-info/top_level.txt
16
+ supersonar/rules/__init__.py
17
+ supersonar/rules/python.py
18
+ tests/test_quality_gate.py
19
+ tests/test_scanner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ supersonar = supersonar.cli:main
@@ -0,0 +1 @@
1
+ supersonar
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import unittest
4
+
5
+ from supersonar.models import Issue, ScanResult
6
+ from supersonar.quality_gate import evaluate_gate
7
+
8
+
9
+ class QualityGateTests(unittest.TestCase):
10
+ def test_fails_on_severity_threshold(self) -> None:
11
+ result = ScanResult(
12
+ issues=[
13
+ Issue(
14
+ rule_id="SS001",
15
+ title="t",
16
+ severity="critical",
17
+ message="m",
18
+ file_path="x.py",
19
+ line=1,
20
+ column=1,
21
+ )
22
+ ],
23
+ files_scanned=1,
24
+ )
25
+ passed, reasons = evaluate_gate(result, fail_on="high")
26
+ self.assertFalse(passed)
27
+ self.assertTrue(reasons)
28
+
29
+ def test_passes_under_max_issues(self) -> None:
30
+ result = ScanResult(issues=[], files_scanned=1)
31
+ passed, reasons = evaluate_gate(result, max_issues=0)
32
+ self.assertTrue(passed)
33
+ self.assertEqual(reasons, [])
34
+
35
+
36
+ if __name__ == "__main__":
37
+ unittest.main()
38
+
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import tempfile
5
+ import unittest
6
+
7
+ from supersonar.scanner import scan_path
8
+
9
+
10
+ class ScannerTests(unittest.TestCase):
11
+ def test_detects_eval_issue(self) -> None:
12
+ with tempfile.TemporaryDirectory() as tmp:
13
+ root = Path(tmp)
14
+ sample = root / "sample.py"
15
+ sample.write_text("value = eval('2+2')\n", encoding="utf-8")
16
+
17
+ result = scan_path(str(root), excludes=[])
18
+ rule_ids = {issue.rule_id for issue in result.issues}
19
+
20
+ self.assertIn("SS001", rule_ids)
21
+ self.assertEqual(result.files_scanned, 1)
22
+
23
+ def test_excludes_directory(self) -> None:
24
+ with tempfile.TemporaryDirectory() as tmp:
25
+ root = Path(tmp)
26
+ src = root / "src"
27
+ src.mkdir()
28
+ ignored = root / "venv"
29
+ ignored.mkdir()
30
+ (src / "ok.py").write_text("print('ok')\n", encoding="utf-8")
31
+ (ignored / "bad.py").write_text("eval('x')\n", encoding="utf-8")
32
+
33
+ result = scan_path(str(root), excludes=["venv"])
34
+ rule_ids = {issue.rule_id for issue in result.issues}
35
+
36
+ self.assertNotIn("SS001", rule_ids)
37
+ self.assertEqual(result.files_scanned, 1)
38
+
39
+
40
+ if __name__ == "__main__":
41
+ unittest.main()
42
+