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 ADDED
@@ -0,0 +1,3 @@
1
+ """slopscore - heuristic slop linter for your own PRs and code."""
2
+
3
+ __version__ = "0.1.0"
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