slopscore 0.1.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.
- slopscore/__init__.py +3 -0
- slopscore/cli.py +283 -0
- slopscore/config.py +76 -0
- slopscore/githooks.py +319 -0
- slopscore/imports.py +139 -0
- slopscore/ingest.py +46 -0
- slopscore/signals.py +659 -0
- slopscore/triage.py +273 -0
- slopscore-0.1.0.dist-info/METADATA +299 -0
- slopscore-0.1.0.dist-info/RECORD +14 -0
- slopscore-0.1.0.dist-info/WHEEL +5 -0
- slopscore-0.1.0.dist-info/entry_points.txt +2 -0
- slopscore-0.1.0.dist-info/licenses/LICENSE +21 -0
- slopscore-0.1.0.dist-info/top_level.txt +1 -0
slopscore/__init__.py
ADDED
slopscore/cli.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Command-line entry point for slopscore.
|
|
2
|
+
|
|
3
|
+
Reads a PR/issue document (JSON ``{title, body, commits[]}`` or, with --text,
|
|
4
|
+
raw body text), runs the triage engine, and prints the report. The exit code
|
|
5
|
+
doubles as a CI / git-hook gate:
|
|
6
|
+
|
|
7
|
+
0 below threshold (PASS)
|
|
8
|
+
1 at or above threshold (FLAG)
|
|
9
|
+
2 usage or input error
|
|
10
|
+
|
|
11
|
+
Flags craft, not authorship: it reports fixable residue with evidence, never a claim about who or what wrote the text.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from slopscore.config import default_config, load_config
|
|
22
|
+
from slopscore.ingest import MAX_INPUT_CHARS, files_from_diff, files_from_paths
|
|
23
|
+
from slopscore.signals import SIGNALS, CodeFile, Document
|
|
24
|
+
from slopscore.triage import DEFAULT_THRESHOLD, triage
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _document_from_json(raw: str, files: tuple[CodeFile, ...] = ()) -> Document:
|
|
28
|
+
data = json.loads(raw)
|
|
29
|
+
if not isinstance(data, dict):
|
|
30
|
+
raise ValueError("expected a JSON object with title/body/commits")
|
|
31
|
+
title = data.get("title", "")
|
|
32
|
+
body = data.get("body", "")
|
|
33
|
+
commits = data.get("commits", [])
|
|
34
|
+
# Validate rather than str()-coerce: a dict/number smuggled into a field
|
|
35
|
+
# would silently poison the scored text and PASS a malformed input.
|
|
36
|
+
if not isinstance(title, str) or not isinstance(body, str):
|
|
37
|
+
raise ValueError("'title' and 'body' must be strings")
|
|
38
|
+
if not isinstance(commits, list) or not all(isinstance(c, str) for c in commits):
|
|
39
|
+
raise ValueError("'commits' must be a list of strings")
|
|
40
|
+
return Document(
|
|
41
|
+
title=title, body=body, commits=tuple(commits), files=files, structured=True
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_input(path: str | None) -> str:
|
|
46
|
+
# Capped read: a multi-MB body must not exhaust the scanner. Decode with
|
|
47
|
+
# errors="replace": a stray non-UTF-8 byte (legacy latin-1 file in a diff)
|
|
48
|
+
# must degrade to a replacement character, never raise - an unhandled
|
|
49
|
+
# decode error exits 1, which the hooks read as FLAG (false accusation).
|
|
50
|
+
if path and path != "-":
|
|
51
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
52
|
+
return fh.read(MAX_INPUT_CHARS)
|
|
53
|
+
return sys.stdin.buffer.read(MAX_INPUT_CHARS).decode("utf-8", errors="replace")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _version() -> str:
|
|
57
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
return version("slopscore")
|
|
61
|
+
except PackageNotFoundError: # running from a bare checkout
|
|
62
|
+
return "0.0.0-dev"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _use_color(mode: str) -> bool:
|
|
66
|
+
if mode == "always":
|
|
67
|
+
return True
|
|
68
|
+
if mode == "never":
|
|
69
|
+
return False
|
|
70
|
+
# auto: a real terminal, and the user hasn't asked for no colour
|
|
71
|
+
# (https://no-color.org - any non-empty value disables).
|
|
72
|
+
if os.environ.get("NO_COLOR"):
|
|
73
|
+
return False
|
|
74
|
+
return sys.stdout.isatty()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
78
|
+
parser = argparse.ArgumentParser(
|
|
79
|
+
prog="slopscore",
|
|
80
|
+
description="Score a commit, PR or text for AI residue and low-craft\n"
|
|
81
|
+
"slop (0-100, itemised findings). Flags craft, not authorship - never\n"
|
|
82
|
+
"a claim about who or what wrote it.",
|
|
83
|
+
epilog=(
|
|
84
|
+
"examples:\n"
|
|
85
|
+
" slopscore pr.json score a PR described as JSON\n"
|
|
86
|
+
" echo 'Certainly!' | slopscore --text - score raw text from stdin\n"
|
|
87
|
+
" slopscore --text msg.txt --diff changes.diff\n"
|
|
88
|
+
" score a message plus a diff's added lines\n"
|
|
89
|
+
" slopscore --files src/*.py scan code files for leftovers\n"
|
|
90
|
+
"\n"
|
|
91
|
+
"exit codes: 0 pass, 1 flag (score at/above threshold), 2 usage error.\n"
|
|
92
|
+
"\n"
|
|
93
|
+
"subcommands:\n"
|
|
94
|
+
" slopscore install-hooks install the commit-msg + pre-push hooks here\n"
|
|
95
|
+
" slopscore hook commit-msg|pre-push what those installed hooks invoke\n"
|
|
96
|
+
"\n"
|
|
97
|
+
"The score, bands and signals are explained in the README."
|
|
98
|
+
),
|
|
99
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"path",
|
|
103
|
+
nargs="?",
|
|
104
|
+
help="input file (JSON, or text with --text); '-' or omitted reads stdin",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--text",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="treat the input as raw body text rather than JSON",
|
|
110
|
+
)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--json",
|
|
113
|
+
dest="as_json",
|
|
114
|
+
action="store_true",
|
|
115
|
+
help="emit a machine-readable JSON report",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--strict",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="thorough tier: enable every signal, including the opt-in folklore "
|
|
121
|
+
"(higher recall, higher false-positive - read the score, do not gate on it)",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--threshold",
|
|
125
|
+
type=float,
|
|
126
|
+
default=None,
|
|
127
|
+
help=f"0-100 slop score at/above which the verdict is FLAG "
|
|
128
|
+
f"(default {DEFAULT_THRESHOLD}, or the config's value)",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--config",
|
|
132
|
+
help="path to a TOML config file (per-signal on/off, weight overrides, threshold)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--files",
|
|
136
|
+
nargs="*",
|
|
137
|
+
metavar="PATH",
|
|
138
|
+
help="code files to scan whole (go wide on your working tree)",
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
"--diff",
|
|
142
|
+
metavar="PATH",
|
|
143
|
+
help="read a unified diff and scan its added lines only; '-' for stdin",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"--version",
|
|
147
|
+
action="version",
|
|
148
|
+
version=f"slopscore {_version()}",
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument(
|
|
151
|
+
"--color",
|
|
152
|
+
choices=("auto", "always", "never"),
|
|
153
|
+
default="auto",
|
|
154
|
+
help="colour the report (auto: only when stdout is a terminal; "
|
|
155
|
+
"the NO_COLOR convention is respected)",
|
|
156
|
+
)
|
|
157
|
+
parser.add_argument(
|
|
158
|
+
"--root",
|
|
159
|
+
metavar="DIR",
|
|
160
|
+
help="project root for manifest/import resolution (default: current directory)",
|
|
161
|
+
)
|
|
162
|
+
return parser
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main(argv: list[str] | None = None) -> int:
|
|
166
|
+
# Exit-code contract: 0 PASS, 1 FLAG, 2 anything else. A crash must come
|
|
167
|
+
# out as 2, not 1 - the hooks treat 1 as a verdict and would block on it.
|
|
168
|
+
args = sys.argv[1:] if argv is None else argv
|
|
169
|
+
if args and args[0] == "hook":
|
|
170
|
+
from slopscore.githooks import hook_main
|
|
171
|
+
|
|
172
|
+
return hook_main(args[1:])
|
|
173
|
+
if args and args[0] == "install-hooks":
|
|
174
|
+
from slopscore.githooks import install_hooks
|
|
175
|
+
|
|
176
|
+
return install_hooks()
|
|
177
|
+
try:
|
|
178
|
+
return _main(argv)
|
|
179
|
+
except Exception as exc: # noqa: BLE001
|
|
180
|
+
print(f"slopscore: internal error: {exc}", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _main(argv: list[str] | None = None) -> int:
|
|
185
|
+
args = _build_parser().parse_args(argv)
|
|
186
|
+
|
|
187
|
+
if args.config:
|
|
188
|
+
try:
|
|
189
|
+
with open(args.config, encoding="utf-8") as fh:
|
|
190
|
+
config = load_config(fh.read())
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
print(f"slopscore: cannot read config: {exc}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
except ValueError as exc:
|
|
195
|
+
print(f"slopscore: invalid config: {exc}", file=sys.stderr)
|
|
196
|
+
return 2
|
|
197
|
+
else:
|
|
198
|
+
config = default_config()
|
|
199
|
+
|
|
200
|
+
threshold = args.threshold if args.threshold is not None else config.threshold
|
|
201
|
+
if not 0.0 <= threshold <= 100.0:
|
|
202
|
+
print(
|
|
203
|
+
f"slopscore: threshold must be between 0 and 100 (got {threshold})",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
files: tuple[CodeFile, ...] = ()
|
|
209
|
+
if args.files:
|
|
210
|
+
files += files_from_paths(args.files)
|
|
211
|
+
if args.diff:
|
|
212
|
+
try:
|
|
213
|
+
files += files_from_diff(_read_input(args.diff))
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
print(f"slopscore: cannot read diff: {exc}", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
# Read prose input unless this is a code-only run (--files/--diff given, no
|
|
219
|
+
# positional path): then there is no prose to wait on stdin for.
|
|
220
|
+
code_only = bool(args.files or args.diff) and not args.path
|
|
221
|
+
if not code_only and not args.path and sys.stdin.isatty():
|
|
222
|
+
# A bare `slopscore` on a terminal would silently block on stdin -
|
|
223
|
+
# exactly a new user's first command. Greet instead.
|
|
224
|
+
print(
|
|
225
|
+
"slopscore - lint your own slop before you ship it.\n"
|
|
226
|
+
"\n"
|
|
227
|
+
"Watch every commit and push (recommended - run inside each repo):\n"
|
|
228
|
+
" slopscore install-hooks\n"
|
|
229
|
+
"\n"
|
|
230
|
+
"Or score something now:\n"
|
|
231
|
+
' echo "Certainly! ..." | slopscore --text -\n'
|
|
232
|
+
" slopscore pr.json\n"
|
|
233
|
+
"\n"
|
|
234
|
+
"Full options: slopscore --help"
|
|
235
|
+
)
|
|
236
|
+
return 0
|
|
237
|
+
if not code_only:
|
|
238
|
+
try:
|
|
239
|
+
raw = _read_input(args.path)
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
print(f"slopscore: cannot read input: {exc}", file=sys.stderr)
|
|
242
|
+
return 2
|
|
243
|
+
try:
|
|
244
|
+
doc = (
|
|
245
|
+
Document(body=raw, files=files)
|
|
246
|
+
if args.text
|
|
247
|
+
else _document_from_json(raw, files)
|
|
248
|
+
)
|
|
249
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
250
|
+
hint = ""
|
|
251
|
+
if not args.text and not raw.lstrip().startswith(("{", "[")):
|
|
252
|
+
hint = " - input is not JSON; did you mean --text?"
|
|
253
|
+
print(f"slopscore: invalid input: {exc}{hint}", file=sys.stderr)
|
|
254
|
+
return 2
|
|
255
|
+
else:
|
|
256
|
+
doc = Document(files=files)
|
|
257
|
+
|
|
258
|
+
enabled = frozenset(s.name for s in SIGNALS) if args.strict else config.enabled
|
|
259
|
+
report = triage(
|
|
260
|
+
doc,
|
|
261
|
+
threshold=threshold,
|
|
262
|
+
enabled=enabled,
|
|
263
|
+
weights=config.weights,
|
|
264
|
+
root=args.root or ".",
|
|
265
|
+
)
|
|
266
|
+
if args.as_json:
|
|
267
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
268
|
+
else:
|
|
269
|
+
use_color = _use_color(args.color)
|
|
270
|
+
print(report.to_text(color=use_color))
|
|
271
|
+
dim, reset = ("\x1b[2m", "\x1b[0m") if use_color else ("", "")
|
|
272
|
+
if args.strict:
|
|
273
|
+
print(f"{dim}Running thorough tier - every signal on (higher recall; "
|
|
274
|
+
f"read the score, don't gate on it).{reset}")
|
|
275
|
+
else:
|
|
276
|
+
print(f"{dim}Running default tier. Use --strict to additionally flag "
|
|
277
|
+
f"the opt-in signals (section scaffolding, marketing prose, "
|
|
278
|
+
f"rhetorical questions, hallucinated imports...).{reset}")
|
|
279
|
+
return 1 if report.verdict == "FLAG" else 0
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__": # pragma: no cover
|
|
283
|
+
raise SystemExit(main())
|
slopscore/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Configuration: per-signal on/off, weight overrides, and the flag threshold.
|
|
2
|
+
|
|
3
|
+
Parsed from TOML via the stdlib ``tomllib`` - no third-party dependency. The
|
|
4
|
+
default config keeps the distinctive LLM tells plus the keyboard-character
|
|
5
|
+
folklore (em-dash, emoji - additive-only, D-12) and disables the prose-style
|
|
6
|
+
folklore (high human overlap); everything is overridable. Example::
|
|
7
|
+
|
|
8
|
+
threshold = 40
|
|
9
|
+
|
|
10
|
+
[signals]
|
|
11
|
+
promotional_adjectives = true # opt a prose-folklore signal in
|
|
12
|
+
emoji_density = false # opt a default signal out
|
|
13
|
+
|
|
14
|
+
[weights]
|
|
15
|
+
ai_self_reference = 6.0
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from slopscore.signals import SIGNALS
|
|
25
|
+
from slopscore.triage import DEFAULT_THRESHOLD
|
|
26
|
+
|
|
27
|
+
_NAMES = frozenset(s.name for s in SIGNALS)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _default_enabled() -> frozenset[str]:
|
|
31
|
+
return frozenset(s.name for s in SIGNALS if s.default_enabled)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Config:
|
|
36
|
+
threshold: float = DEFAULT_THRESHOLD
|
|
37
|
+
enabled: frozenset[str] = field(default_factory=_default_enabled)
|
|
38
|
+
weights: dict[str, float] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_config() -> Config:
|
|
42
|
+
"""The product default: distinctive tells + keyboard-character folklore
|
|
43
|
+
on (the latter additive-only); prose-style folklore off (D-12)."""
|
|
44
|
+
return Config()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check_name(name: str) -> None:
|
|
48
|
+
if name not in _NAMES:
|
|
49
|
+
raise ValueError(f"unknown signal in config: {name!r}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_config(text: str) -> Config:
|
|
53
|
+
"""Parse a TOML config, layered over the defaults. Raises ValueError on bad
|
|
54
|
+
TOML or an unknown signal name."""
|
|
55
|
+
try:
|
|
56
|
+
data = tomllib.loads(text)
|
|
57
|
+
except tomllib.TOMLDecodeError as exc:
|
|
58
|
+
raise ValueError(f"invalid TOML: {exc}") from exc
|
|
59
|
+
|
|
60
|
+
enabled = set(_default_enabled())
|
|
61
|
+
for name, on in data.get("signals", {}).items():
|
|
62
|
+
_check_name(name)
|
|
63
|
+
enabled.add(name) if on else enabled.discard(name)
|
|
64
|
+
|
|
65
|
+
weights: dict[str, float] = {}
|
|
66
|
+
for name, weight in data.get("weights", {}).items():
|
|
67
|
+
_check_name(name)
|
|
68
|
+
value = float(weight)
|
|
69
|
+
if not math.isfinite(value) or value < 0:
|
|
70
|
+
raise ValueError(f"weight for {name!r} must be a finite number >= 0")
|
|
71
|
+
weights[name] = value
|
|
72
|
+
|
|
73
|
+
threshold = float(data.get("threshold", DEFAULT_THRESHOLD))
|
|
74
|
+
if not math.isfinite(threshold):
|
|
75
|
+
raise ValueError("threshold must be a finite number")
|
|
76
|
+
return Config(threshold=threshold, enabled=frozenset(enabled), weights=weights)
|
slopscore/githooks.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Git hook implementations and the hook installer.
|
|
2
|
+
|
|
3
|
+
The shell files in ``hooks/`` are thin shims that exec ``slopscore hook
|
|
4
|
+
commit-msg``/``pre-push``; all behaviour lives here so a pip-installed
|
|
5
|
+
package is self-contained (``slopscore install-hooks``). Hooks fail OPEN:
|
|
6
|
+
any internal error warns and allows the operation - a broken linter must
|
|
7
|
+
never block a commit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from slopscore.config import Config, default_config, load_config
|
|
19
|
+
from slopscore.ingest import MAX_INPUT_CHARS, files_from_diff
|
|
20
|
+
from slopscore.signals import SIGNALS, Document
|
|
21
|
+
from slopscore.triage import triage
|
|
22
|
+
|
|
23
|
+
_ZERO = "0" * 40
|
|
24
|
+
_OBJECT_ID = re.compile(r"[0-9a-f]{7,64}") # git sha-1/sha-256 abbreviations
|
|
25
|
+
|
|
26
|
+
COMMIT_MSG_SHIM = """\
|
|
27
|
+
#!/bin/sh
|
|
28
|
+
# slopscore commit-msg shim - the logic lives in `slopscore hook commit-msg`.
|
|
29
|
+
# Install: `slopscore install-hooks` (writes this shim into .git/hooks; from a
|
|
30
|
+
# checkout). Bypass any time with `git commit --no-verify`.
|
|
31
|
+
if command -v slopscore >/dev/null 2>&1; then
|
|
32
|
+
exec slopscore hook commit-msg "$1"
|
|
33
|
+
fi
|
|
34
|
+
# -P (isolated sys.path, Python 3.11+) is load-bearing: without it this would
|
|
35
|
+
# import a slopscore/ package from the repo being scored - code execution on
|
|
36
|
+
# commit in a hostile clone. On 3.10 the probe fails and the hook skips.
|
|
37
|
+
if command -v python3 >/dev/null 2>&1 && python3 -P -c "import slopscore" 2>/dev/null; then
|
|
38
|
+
exec python3 -P -m slopscore.cli hook commit-msg "$1"
|
|
39
|
+
fi
|
|
40
|
+
exit 0 # slopscore not available - skip rather than block a commit
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
PRE_PUSH_SHIM = """\
|
|
44
|
+
#!/bin/sh
|
|
45
|
+
# slopscore pre-push shim - the logic lives in `slopscore hook pre-push`.
|
|
46
|
+
# Install: `slopscore install-hooks` (writes this shim into .git/hooks; from a
|
|
47
|
+
# checkout). Bypass any time with `git push --no-verify`.
|
|
48
|
+
if command -v slopscore >/dev/null 2>&1; then
|
|
49
|
+
exec slopscore hook pre-push "$@"
|
|
50
|
+
fi
|
|
51
|
+
# -P (isolated sys.path, Python 3.11+) is load-bearing: without it this would
|
|
52
|
+
# import a slopscore/ package from the repo being scored - code execution on
|
|
53
|
+
# push in a hostile clone. On 3.10 the probe fails and the hook skips.
|
|
54
|
+
if command -v python3 >/dev/null 2>&1 && python3 -P -c "import slopscore" 2>/dev/null; then
|
|
55
|
+
exec python3 -P -m slopscore.cli hook pre-push "$@"
|
|
56
|
+
fi
|
|
57
|
+
exit 0 # slopscore not available - skip rather than block a push
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
SHIMS = {"commit-msg": COMMIT_MSG_SHIM, "pre-push": PRE_PUSH_SHIM}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _git(*args: str) -> subprocess.CompletedProcess:
|
|
64
|
+
return subprocess.run(["git", *args], capture_output=True, text=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _git_config(key: str, type_flag: str | None = None) -> str:
|
|
68
|
+
cmd = ["config"]
|
|
69
|
+
if type_flag:
|
|
70
|
+
cmd.append(f"--type={type_flag}")
|
|
71
|
+
cmd += ["--get", key]
|
|
72
|
+
result = _git(*cmd)
|
|
73
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _Settings:
|
|
77
|
+
"""Per-repo hook settings from git config plus the SLOPSCORE_BLOCK env."""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self.block = _git_config("slopscore.block", "bool") == "true"
|
|
81
|
+
env = os.environ.get("SLOPSCORE_BLOCK", "")
|
|
82
|
+
if env == "1":
|
|
83
|
+
self.block = True
|
|
84
|
+
elif env == "0":
|
|
85
|
+
self.block = False
|
|
86
|
+
elif env:
|
|
87
|
+
print(
|
|
88
|
+
f"slopscore: SLOPSCORE_BLOCK must be 1 or 0 (got '{env}'); ignoring it.",
|
|
89
|
+
file=sys.stderr,
|
|
90
|
+
)
|
|
91
|
+
self.threshold_raw = _git_config("slopscore.threshold")
|
|
92
|
+
self.strict = _git_config("slopscore.strict", "bool") == "true"
|
|
93
|
+
self.config = default_config()
|
|
94
|
+
self.broken: str | None = None # error text when settings are unusable
|
|
95
|
+
config_path = _git_config("slopscore.config", "path")
|
|
96
|
+
if config_path:
|
|
97
|
+
if os.path.isfile(config_path):
|
|
98
|
+
try:
|
|
99
|
+
with open(config_path, encoding="utf-8") as fh:
|
|
100
|
+
self.config = load_config(fh.read())
|
|
101
|
+
except (OSError, ValueError) as exc:
|
|
102
|
+
self.broken = f"slopscore: invalid config: {exc}"
|
|
103
|
+
else:
|
|
104
|
+
print(
|
|
105
|
+
f"slopscore: git config slopscore.config points at "
|
|
106
|
+
f"'{config_path}' which does not exist; ignoring it.",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
self.threshold = self.config.threshold
|
|
110
|
+
if self.threshold_raw:
|
|
111
|
+
try:
|
|
112
|
+
self.threshold = float(self.threshold_raw)
|
|
113
|
+
except ValueError:
|
|
114
|
+
self.broken = (
|
|
115
|
+
f"slopscore: threshold must be a number 0-100 "
|
|
116
|
+
f"(got '{self.threshold_raw}')"
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
if not 0.0 <= self.threshold <= 100.0:
|
|
120
|
+
self.broken = (
|
|
121
|
+
f"slopscore: threshold must be between 0 and 100 "
|
|
122
|
+
f"(got {self.threshold})"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def threshold_display(self) -> str:
|
|
127
|
+
return self.threshold_raw or "30"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _footer(settings: _Settings) -> None:
|
|
131
|
+
"""Config status footer so the knobs are discoverable from the report."""
|
|
132
|
+
if sys.stdout.isatty():
|
|
133
|
+
b, d, c, r = "\x1b[1m", "\x1b[2m", "\x1b[36m", "\x1b[0m"
|
|
134
|
+
else:
|
|
135
|
+
b = d = c = r = ""
|
|
136
|
+
thr = settings.threshold_display
|
|
137
|
+
print()
|
|
138
|
+
if settings.strict:
|
|
139
|
+
print(f"{b}Running thorough tier{r}{d} - every signal on (higher recall; "
|
|
140
|
+
f"read the score, don't gate on it). Off:{r} {c}git config slopscore.strict false{r}")
|
|
141
|
+
else:
|
|
142
|
+
print(f"{b}Running default tier.{r}{d} Run{r} {c}git config slopscore.strict true{r}"
|
|
143
|
+
f"{d} to additionally flag the opt-in signals (section scaffolding, "
|
|
144
|
+
f"marketing prose, rhetorical questions, hallucinated imports...).{r}")
|
|
145
|
+
if settings.block:
|
|
146
|
+
print(f"{b}Blocking: ON{r}{d} - commits and pushes scoring {thr}+ are refused.{r}")
|
|
147
|
+
print(f"{d}To turn off run:{r} {c}git config slopscore.block false{r}")
|
|
148
|
+
else:
|
|
149
|
+
print(f"{b}Blocking: off{r}{d} - commits and pushes only get this advisory.{r}")
|
|
150
|
+
print(
|
|
151
|
+
f"{d}To turn on run:{r} {c}git config slopscore.block true{r}"
|
|
152
|
+
f"{d} (refuses at score {thr}+){r}"
|
|
153
|
+
)
|
|
154
|
+
print()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _score(msg: str, diff: str, settings: _Settings, label: str | None, badge: str):
|
|
158
|
+
cfg: Config = settings.config
|
|
159
|
+
enabled = frozenset(s.name for s in SIGNALS) if settings.strict else cfg.enabled
|
|
160
|
+
doc = Document(
|
|
161
|
+
body=msg[:MAX_INPUT_CHARS], files=files_from_diff(diff[:MAX_INPUT_CHARS])
|
|
162
|
+
)
|
|
163
|
+
report = triage(
|
|
164
|
+
doc, threshold=settings.threshold, enabled=enabled, weights=cfg.weights
|
|
165
|
+
)
|
|
166
|
+
text = report.to_text(color=sys.stdout.isatty(), label=label, badge=badge)
|
|
167
|
+
return report, text
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def commit_msg(args: list[str]) -> int:
|
|
171
|
+
if not args:
|
|
172
|
+
print("slopscore: hook commit-msg needs the message file path", file=sys.stderr)
|
|
173
|
+
return 0
|
|
174
|
+
settings = _Settings()
|
|
175
|
+
if settings.broken:
|
|
176
|
+
print(settings.broken, file=sys.stderr)
|
|
177
|
+
print("slopscore: scoring failed (see above) - commit allowed.", file=sys.stderr)
|
|
178
|
+
return 0
|
|
179
|
+
try:
|
|
180
|
+
with open(args[0], encoding="utf-8", errors="replace") as fh:
|
|
181
|
+
msg = fh.read(MAX_INPUT_CHARS)
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
print(f"slopscore: cannot read commit message: {exc}", file=sys.stderr)
|
|
184
|
+
return 0
|
|
185
|
+
diff = _git("diff", "--cached", "--diff-filter=ACMR").stdout
|
|
186
|
+
report, text = _score(msg, diff, settings, None, "SLOPSCORE COMMIT CHECK")
|
|
187
|
+
print(text)
|
|
188
|
+
_footer(settings)
|
|
189
|
+
if settings.block and report.verdict == "FLAG":
|
|
190
|
+
print(
|
|
191
|
+
"slopscore: score at/above threshold; commit blocked (slopscore.block). "
|
|
192
|
+
"Use --no-verify to bypass.",
|
|
193
|
+
file=sys.stderr,
|
|
194
|
+
)
|
|
195
|
+
return 1
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _outgoing_pairs() -> list[tuple[str, str]]:
|
|
200
|
+
"""(local_sha, remote_sha) pairs from the pre-push stdin protocol, or from
|
|
201
|
+
PRE_COMMIT_FROM_REF/TO_REF when run under the pre-commit framework
|
|
202
|
+
(which consumes stdin itself)."""
|
|
203
|
+
pairs = []
|
|
204
|
+
if not sys.stdin.isatty():
|
|
205
|
+
for line in sys.stdin.read().splitlines():
|
|
206
|
+
parts = line.split()
|
|
207
|
+
if len(parts) == 4:
|
|
208
|
+
pairs.append((parts[1], parts[3]))
|
|
209
|
+
if not pairs and os.environ.get("PRE_COMMIT_TO_REF"):
|
|
210
|
+
local = _git("rev-parse", os.environ["PRE_COMMIT_TO_REF"]).stdout.strip()
|
|
211
|
+
remote = _git(
|
|
212
|
+
"rev-parse", os.environ.get("PRE_COMMIT_FROM_REF", _ZERO)
|
|
213
|
+
).stdout.strip()
|
|
214
|
+
if local:
|
|
215
|
+
pairs.append((local, remote or _ZERO))
|
|
216
|
+
return pairs
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def pre_push(args: list[str]) -> int:
|
|
220
|
+
settings = _Settings()
|
|
221
|
+
if settings.broken:
|
|
222
|
+
print(settings.broken, file=sys.stderr)
|
|
223
|
+
print("slopscore: scoring failed (see above) - push allowed.", file=sys.stderr)
|
|
224
|
+
return 0
|
|
225
|
+
flagged = 0
|
|
226
|
+
scanned = 0
|
|
227
|
+
for local_sha, remote_sha in _outgoing_pairs():
|
|
228
|
+
if local_sha == _ZERO:
|
|
229
|
+
continue # ref delete - nothing outgoing
|
|
230
|
+
if remote_sha == _ZERO:
|
|
231
|
+
# New remote ref: score only what no remote has seen yet.
|
|
232
|
+
rev_args = ["rev-list", local_sha, "--not", "--remotes"]
|
|
233
|
+
else:
|
|
234
|
+
rev_args = ["rev-list", f"{remote_sha}..{local_sha}"]
|
|
235
|
+
for sha in _git(*rev_args).stdout.split():
|
|
236
|
+
# Defence-in-depth: only ever interpolate a real object id into the
|
|
237
|
+
# git calls below, so a future change can't let a dash-prefixed
|
|
238
|
+
# token reach git as an option (review, security Low).
|
|
239
|
+
if not _OBJECT_ID.fullmatch(sha):
|
|
240
|
+
continue
|
|
241
|
+
scanned += 1
|
|
242
|
+
msg = _git("log", "-1", "--format=%B", sha).stdout
|
|
243
|
+
# First-parent diff so a merge scores what it introduced.
|
|
244
|
+
diff = _git(
|
|
245
|
+
"show", sha, "--diff-merges=first-parent", "--diff-filter=ACMR",
|
|
246
|
+
"--format=",
|
|
247
|
+
).stdout
|
|
248
|
+
label = _git("log", "-1", "--format=[%h] %s", sha).stdout.strip()
|
|
249
|
+
report, text = _score(msg, diff, settings, label, "SLOPSCORE PUSH CHECK")
|
|
250
|
+
if report.verdict == "FLAG":
|
|
251
|
+
flagged += 1
|
|
252
|
+
print(text)
|
|
253
|
+
if flagged:
|
|
254
|
+
print(f"slopscore: {flagged} of {scanned} outgoing commits at/above threshold.")
|
|
255
|
+
_footer(settings)
|
|
256
|
+
if settings.block:
|
|
257
|
+
print(
|
|
258
|
+
"slopscore: push blocked (slopscore.block). Use --no-verify to bypass.",
|
|
259
|
+
file=sys.stderr,
|
|
260
|
+
)
|
|
261
|
+
return 1
|
|
262
|
+
return 0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def install_hooks() -> int:
|
|
266
|
+
hook_dir = _git("rev-parse", "--git-path", "hooks").stdout.strip()
|
|
267
|
+
if not hook_dir:
|
|
268
|
+
print("slopscore: not inside a git repository", file=sys.stderr)
|
|
269
|
+
return 1
|
|
270
|
+
os.makedirs(hook_dir, exist_ok=True)
|
|
271
|
+
installed = []
|
|
272
|
+
for name, shim in SHIMS.items():
|
|
273
|
+
dst = os.path.join(hook_dir, name)
|
|
274
|
+
if os.path.exists(dst):
|
|
275
|
+
with open(dst, encoding="utf-8", errors="replace") as fh:
|
|
276
|
+
if "slopscore" not in fh.read():
|
|
277
|
+
print(
|
|
278
|
+
f"Skipped {name}: a non-slopscore hook already exists at "
|
|
279
|
+
f"{dst} (remove it and re-run to replace).",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
continue
|
|
283
|
+
os.remove(dst) # never write through a pre-existing symlink
|
|
284
|
+
with open(dst, "w", encoding="utf-8") as fh:
|
|
285
|
+
fh.write(shim)
|
|
286
|
+
os.chmod(dst, os.stat(dst).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
287
|
+
installed.append(name)
|
|
288
|
+
if not installed:
|
|
289
|
+
return 1
|
|
290
|
+
print(f"Installed slopscore hooks ({', '.join(installed)}) -> {hook_dir}")
|
|
291
|
+
print(
|
|
292
|
+
"Advisory by default (never blocks). Opt in: git config slopscore.block true; "
|
|
293
|
+
"set the bar with git config slopscore.threshold 50. "
|
|
294
|
+
"SLOPSCORE_BLOCK=1/0 overrides; --no-verify bypasses."
|
|
295
|
+
)
|
|
296
|
+
print(
|
|
297
|
+
"Config: slopscore.toml in the repo root (threshold, signals, weights) - "
|
|
298
|
+
"see the README."
|
|
299
|
+
)
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def hook_main(args: list[str]) -> int:
|
|
304
|
+
"""Dispatch for `slopscore hook <name> ...`. Fails OPEN on any crash."""
|
|
305
|
+
try:
|
|
306
|
+
if args and args[0] == "commit-msg":
|
|
307
|
+
return commit_msg(args[1:])
|
|
308
|
+
if args and args[0] == "pre-push":
|
|
309
|
+
return pre_push(args[1:])
|
|
310
|
+
print(
|
|
311
|
+
"slopscore: unknown hook (expected commit-msg or pre-push)", file=sys.stderr
|
|
312
|
+
)
|
|
313
|
+
return 0
|
|
314
|
+
except Exception as exc: # noqa: BLE001 - a broken linter must not block
|
|
315
|
+
print(
|
|
316
|
+
f"slopscore: internal hook error: {exc} - operation allowed.",
|
|
317
|
+
file=sys.stderr,
|
|
318
|
+
)
|
|
319
|
+
return 0
|