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 +24 -0
- slopaudit/__main__.py +4 -0
- slopaudit/api.py +103 -0
- slopaudit/bench.py +136 -0
- slopaudit/cli.py +465 -0
- slopaudit/config.py +233 -0
- slopaudit/context.py +170 -0
- slopaudit/docgen.py +89 -0
- slopaudit/drift.py +137 -0
- slopaudit/engine.py +189 -0
- slopaudit/gentests.py +118 -0
- slopaudit/llm.py +193 -0
- slopaudit/manifest.py +119 -0
- slopaudit/models.py +127 -0
- slopaudit/py.typed +0 -0
- slopaudit/registry.py +137 -0
- slopaudit/regress.py +190 -0
- slopaudit/repair.py +182 -0
- slopaudit/reporters.py +153 -0
- slopaudit/rules/__init__.py +35 -0
- slopaudit/rules/drift.py +156 -0
- slopaudit/rules/hallucination.py +314 -0
- slopaudit/rules/incomplete.py +344 -0
- slopaudit/rules/llm_agents.py +220 -0
- slopaudit/rules/llm_flow.py +159 -0
- slopaudit/rules/secrets.py +134 -0
- slopaudit/rules/virtual.py +65 -0
- slopaudit/sandbox.py +170 -0
- slopaudit/spec.py +352 -0
- slopaudit/taint.py +530 -0
- slopaudit-1.0.0.dist-info/METADATA +273 -0
- slopaudit-1.0.0.dist-info/RECORD +35 -0
- slopaudit-1.0.0.dist-info/WHEEL +4 -0
- slopaudit-1.0.0.dist-info/entry_points.txt +2 -0
- slopaudit-1.0.0.dist-info/licenses/LICENSE +21 -0
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
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
|