slopaudit 1.0.0__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.
slopaudit/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """slopaudit: verify AI-generated code.
2
+
3
+ ``import slopaudit`` is cheap. The API names below load on first use.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ __version__ = "1.0.0"
11
+
12
+ if TYPE_CHECKING:
13
+ from slopaudit.api import Config, Finding, ScanResult, Severity, scan, scan_source
14
+
15
+ _API = {"Config", "Finding", "ScanResult", "Severity", "scan", "scan_source"}
16
+ __all__ = ["Config", "Finding", "ScanResult", "Severity", "__version__", "scan", "scan_source"]
17
+
18
+
19
+ def __getattr__(name: str) -> Any:
20
+ if name in _API:
21
+ from slopaudit import api
22
+
23
+ return getattr(api, name)
24
+ raise AttributeError(f"module 'slopaudit' has no attribute {name!r}")
slopaudit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from slopaudit.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
slopaudit/api.py ADDED
@@ -0,0 +1,103 @@
1
+ """The supported Python API.
2
+
3
+ Everything importable from ``slopaudit`` directly (``slopaudit.scan``, ``Finding``,
4
+ ``Severity``, ``Config``) comes from here. The CLI is a thin layer over the same functions.
5
+
6
+ import slopaudit
7
+
8
+ result = slopaudit.scan(["src"], network=False)
9
+ for finding in result.findings:
10
+ print(finding.rule_id, finding.path, finding.line, finding.message)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable
16
+ from pathlib import Path
17
+
18
+ from slopaudit.config import MODULES, Config
19
+ from slopaudit.context import build_project
20
+ from slopaudit.engine import ScanResult, discover, find_root, run_scan, scan_text
21
+ from slopaudit.models import Finding, Rule, Severity
22
+ from slopaudit.rules import all_rules
23
+
24
+ __all__ = ["Config", "Finding", "ScanResult", "Severity", "scan", "scan_source", "select_rules"]
25
+
26
+
27
+ def select_rules(
28
+ config: Config, modules: Iterable[str] = (), rule_ids: Iterable[str] = ()
29
+ ) -> list[Rule]:
30
+ """Rules to run: those in ``modules`` (or enabled in ``config``), narrowed to ``rule_ids``.
31
+
32
+ Raises ``ValueError`` for an unknown module or rule ID.
33
+ """
34
+ modules, wanted = list(modules), {r.upper() for r in rule_ids}
35
+ for name in modules:
36
+ if name not in MODULES:
37
+ raise ValueError(f"unknown module {name!r}; expected one of: {', '.join(MODULES)}")
38
+ unknown = wanted - {r.meta.id for r in all_rules()}
39
+ if unknown:
40
+ raise ValueError(f"unknown rule(s): {', '.join(sorted(unknown))}")
41
+ chosen = []
42
+ for rule in all_rules():
43
+ meta = rule.meta
44
+ if wanted and meta.id not in wanted:
45
+ continue
46
+ if modules:
47
+ if meta.module not in modules:
48
+ continue
49
+ elif not config.modules.get(meta.module, True):
50
+ continue
51
+ if config.rule_enabled(meta.id):
52
+ chosen.append(rule)
53
+ return chosen
54
+
55
+
56
+ def scan(
57
+ paths: Iterable[str | Path] = (".",),
58
+ *,
59
+ config: Config | None = None,
60
+ config_path: str | Path | None = None,
61
+ modules: Iterable[str] = (),
62
+ rules: Iterable[str] = (),
63
+ network: bool = True,
64
+ ) -> ScanResult:
65
+ """Scan files or folders and return the findings.
66
+
67
+ ``config`` wins over ``config_path``; with neither, ``slopaudit.toml`` or
68
+ ``[tool.slopaudit]`` is read from the project root. ``network=False`` skips PyPI lookups.
69
+ Raises ``slopaudit.config.ConfigError`` for a bad configuration and ``FileNotFoundError``
70
+ for a path that does not exist.
71
+ """
72
+ targets = [Path(p) for p in paths]
73
+ for target in targets:
74
+ if not target.exists():
75
+ raise FileNotFoundError(target)
76
+ root = find_root(targets)
77
+ if config is None:
78
+ config = Config.load(root, Path(config_path) if config_path else None)
79
+ selected = select_rules(config, modules, rules)
80
+ files = discover(targets, root, config, {s for r in selected for s in r.file_types})
81
+ project = build_project(root, config, network=network)
82
+ result = run_scan(files, selected, project)
83
+ if project.registry is not None:
84
+ project.registry.save()
85
+ return result
86
+
87
+
88
+ def scan_source(
89
+ source: str,
90
+ filename: str = "snippet.py",
91
+ *,
92
+ config: Config | None = None,
93
+ modules: Iterable[str] = (),
94
+ rules: Iterable[str] = (),
95
+ ) -> list[Finding]:
96
+ """Scan a string of source code, for example an assistant's answer before it is saved.
97
+
98
+ Offline and read-only: PyPI lookups are skipped. Project checks use the current directory.
99
+ """
100
+ config = config or Config()
101
+ selected = select_rules(config, modules, rules)
102
+ project = build_project(Path.cwd(), config, network=False)
103
+ return scan_text(filename, source, selected, project)
slopaudit/bench.py ADDED
@@ -0,0 +1,136 @@
1
+ """Benchmark runner: precision and recall per rule on a labelled corpus.
2
+
3
+ Every corpus file starts with a label line, ``# expect: RULE-ID[, RULE-ID]`` or
4
+ ``# expect: none``. A rule counts as a true positive for a file when it is both expected
5
+ and reported, a false positive when reported but not expected, and a false negative when
6
+ expected but not reported. Scoring is per file, so line-level accuracy is not measured.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ from slopaudit.config import Config
16
+ from slopaudit.context import build_project
17
+ from slopaudit.engine import discover, run_scan
18
+ from slopaudit.rules import all_rules
19
+
20
+ LABEL = re.compile(r"^#\s*expect:\s*(.+)$", re.MULTILINE)
21
+
22
+
23
+ @dataclass
24
+ class RuleScore:
25
+ tp: int = 0
26
+ fp: int = 0
27
+ fn: int = 0
28
+
29
+ @property
30
+ def precision(self) -> float | None:
31
+ total = self.tp + self.fp
32
+ return self.tp / total if total else None
33
+
34
+ @property
35
+ def recall(self) -> float | None:
36
+ total = self.tp + self.fn
37
+ return self.tp / total if total else None
38
+
39
+ @property
40
+ def f1(self) -> float | None:
41
+ p, r = self.precision, self.recall
42
+ if p is None or r is None or p + r == 0:
43
+ return None
44
+ return 2 * p * r / (p + r)
45
+
46
+
47
+ @dataclass
48
+ class BenchReport:
49
+ files: int = 0
50
+ scores: dict[str, RuleScore] = field(default_factory=dict)
51
+ mismatches: list[str] = field(default_factory=list)
52
+ unlabelled: list[str] = field(default_factory=list)
53
+
54
+ def overall(self) -> RuleScore:
55
+ total = RuleScore()
56
+ for score in self.scores.values():
57
+ total.tp += score.tp
58
+ total.fp += score.fp
59
+ total.fn += score.fn
60
+ return total
61
+
62
+ def failing(self, min_precision: float, min_recall: float) -> list[str]:
63
+ bad = []
64
+ for rule_id, score in sorted(self.scores.items()):
65
+ if score.precision is not None and score.precision < min_precision:
66
+ bad.append(f"{rule_id} precision {score.precision:.2f} < {min_precision:.2f}")
67
+ if score.recall is not None and score.recall < min_recall:
68
+ bad.append(f"{rule_id} recall {score.recall:.2f} < {min_recall:.2f}")
69
+ return bad
70
+
71
+ def to_dict(self) -> dict:
72
+ def num(value: float | None) -> float | None:
73
+ return None if value is None else round(value, 3)
74
+
75
+ return {
76
+ "files": self.files,
77
+ "rules": {
78
+ rule_id: {
79
+ "tp": s.tp, "fp": s.fp, "fn": s.fn,
80
+ "precision": num(s.precision), "recall": num(s.recall), "f1": num(s.f1),
81
+ }
82
+ for rule_id, s in sorted(self.scores.items())
83
+ },
84
+ "overall": {
85
+ "precision": num(self.overall().precision),
86
+ "recall": num(self.overall().recall),
87
+ "f1": num(self.overall().f1),
88
+ },
89
+ "mismatches": self.mismatches,
90
+ "unlabelled": self.unlabelled,
91
+ }
92
+
93
+
94
+ def parse_expected(source: str) -> set[str] | None:
95
+ """Return the labelled rule IDs, an empty set for ``none``, or None if unlabelled."""
96
+ match = LABEL.search(source)
97
+ if not match:
98
+ return None
99
+ ids = {part.strip().upper() for part in match.group(1).split(",") if part.strip()}
100
+ return set() if ids <= {"NONE"} else ids
101
+
102
+
103
+ def run_benchmark(corpus: Path) -> BenchReport:
104
+ corpus = corpus.resolve()
105
+ config = Config()
106
+ project = build_project(corpus, config, network=False)
107
+ rules = all_rules()
108
+ suffixes = {s for r in rules for s in r.file_types}
109
+ files = discover([corpus], corpus, config, suffixes)
110
+ result = run_scan(files, rules, project)
111
+
112
+ found: dict[str, set[str]] = {}
113
+ for finding in result.findings:
114
+ found.setdefault(finding.path, set()).add(finding.rule_id)
115
+
116
+ report = BenchReport()
117
+ for path in files:
118
+ rel = path.relative_to(corpus).as_posix()
119
+ expected = parse_expected(path.read_text(errors="replace"))
120
+ if expected is None:
121
+ report.unlabelled.append(rel)
122
+ continue
123
+ report.files += 1
124
+ reported = found.get(rel, set())
125
+ for rule_id in expected | reported:
126
+ score = report.scores.setdefault(rule_id, RuleScore())
127
+ if rule_id in expected and rule_id in reported:
128
+ score.tp += 1
129
+ elif rule_id in reported:
130
+ score.fp += 1
131
+ report.mismatches.append(f"false positive {rel} {rule_id}")
132
+ else:
133
+ score.fn += 1
134
+ report.mismatches.append(f"false negative {rel} {rule_id}")
135
+ report.mismatches.sort()
136
+ return report