codereview-loop 0.13.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.
codereview/registry.py ADDED
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env python3
2
+ """codereview provider registry — the catalogue of supported LLM
3
+ providers and the per-role model-assignment surface.
4
+
5
+ One source of truth for every preset: wire kind, base URL, default
6
+ model, the environment variable the user's OWN key is read from
7
+ (BYOK — keys are never stored, logged, or shipped with the tool),
8
+ the vendor FAMILY tag (the independence checks in core — cross-
9
+ family confirmation and the Tech Lead's family-distinctness rule —
10
+ read family from here, never from a seat's display name), default
11
+ extra payload, and per-model request quirks.
12
+
13
+ Wire kinds:
14
+ openai-compatible — POST {base}/chat/completions, Bearer auth.
15
+ Most vendors now expose this shape (OpenAI, DeepSeek,
16
+ MiniMax, Moonshot/Kimi, NVIDIA NIM, xAI, Mistral,
17
+ OpenRouter), which is why new majors are one registry row.
18
+ gemini — the native generateContent API (Google).
19
+ anthropic — the native Messages API (x-api-key + version header,
20
+ content-block response).
21
+
22
+ Spec grammar (consumed by providers.build_provider):
23
+ preset — registry default model
24
+ preset:model — registry endpoint, caller's model
25
+ name|type|base|model|KEY_ENV — fully custom seat (type is
26
+ openai-compatible, gemini, or anthropic)
27
+ openai-compatible:<base>|<model>|<KEY_ENV> — generic escape
28
+ hatch for any other compatible endpoint
29
+ agent:<name>:<command> — a local agent CLI seat (providers.py)
30
+ local:<name>:<base-url>|<model> — a named LOCAL model server
31
+ (Epic L5): any OpenAI-compatible
32
+ endpoint the user runs on their own
33
+ machine; keyless (no API key, no
34
+ key env), family tag 'local'. The
35
+ named local presets below (ollama,
36
+ lmstudio) are this form with the
37
+ conventional endpoint pre-filled.
38
+
39
+ Roles (owner directive 2026-11-04: the user picks the model for
40
+ each role): correctness, operations (the two review lenses),
41
+ verifier (the verification-round panel), tech-lead (the dispute
42
+ adjudicator seat — its family must stay distinct from the finding's
43
+ raiser and refuter; the registry family tags feed that check), and
44
+ solver (intake mode). Assignment syntax everywhere is
45
+ `--role <role>=<provider-spec>`, repeatable; nothing assigned =
46
+ today's behaviour exactly.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ # role -> the lens it pins, where the role IS a review lens.
52
+ ROLES = ("correctness", "operations", "verifier", "tech-lead",
53
+ "tech-lead-shadow", "solver")
54
+ LENS_ROLES = {"correctness": "correctness",
55
+ "operations": "operations"}
56
+
57
+ # Each preset:
58
+ # kind wire kind (see module docstring)
59
+ # base_url API base (None for the gemini native shape, whose
60
+ # URL embeds the model)
61
+ # model default model suggestion — a starting point, the
62
+ # user overrides with preset:model or a custom spec
63
+ # key_env env var holding the USER's key for this provider
64
+ # (None for the keyless local presets, Epic L5:
65
+ # a local server needs no credential and none is
66
+ # ever read, sent, or prompted for)
67
+ # family vendor family tag for independence checks
68
+ # extra extra payload fields merged into every request
69
+ # no_temperature model-name prefixes whose API rejects a
70
+ # caller-set temperature (the gpt-5 lesson: HTTP
71
+ # 400 on temperature != default) — for those models
72
+ # the field is OMITTED, never faked
73
+ # completion_tokens model-name prefixes whose API rejects
74
+ # `max_tokens` and requires `max_completion_tokens`
75
+ # instead (the second gpt-5 lesson, from the L3
76
+ # smoke: the openai preset's default gpt-5-mini
77
+ # answered HTTP 400 to every CLI request because
78
+ # the payload carried max_tokens) — for those
79
+ # models the token cap is sent under the new name
80
+ # recipe (local presets only) the short operator recipe
81
+ # the setup wizard prints: start the server, then
82
+ # pick the preset, then probe with `doctor`
83
+ PRESETS = {
84
+ "openai": {
85
+ "kind": "openai-compatible",
86
+ "base_url": "https://api.openai.com/v1",
87
+ "model": "gpt-5-mini",
88
+ "key_env": "OPENAI_API_KEY",
89
+ "family": "openai",
90
+ "extra": None,
91
+ "no_temperature": ("gpt-5", "o1", "o3", "o4"),
92
+ "completion_tokens": ("gpt-5", "o1", "o3", "o4"),
93
+ },
94
+ "gemini": {
95
+ "kind": "gemini",
96
+ "base_url": None,
97
+ "model": "gemini-2.5-flash",
98
+ "key_env": "GEMINI_API_KEY",
99
+ "family": "google",
100
+ "extra": None,
101
+ "no_temperature": (),
102
+ "completion_tokens": (),
103
+ },
104
+ "anthropic": {
105
+ "kind": "anthropic",
106
+ "base_url": "https://api.anthropic.com",
107
+ "model": "claude-sonnet-4-5",
108
+ "key_env": "ANTHROPIC_API_KEY",
109
+ "family": "anthropic",
110
+ "extra": None,
111
+ "no_temperature": (),
112
+ "completion_tokens": (),
113
+ },
114
+ "deepseek": {
115
+ "kind": "openai-compatible",
116
+ "base_url": "https://api.deepseek.com",
117
+ "model": "deepseek-chat",
118
+ "key_env": "DEEPSEEK_API_KEY",
119
+ "family": "deepseek",
120
+ "extra": None,
121
+ "no_temperature": (),
122
+ "completion_tokens": (),
123
+ },
124
+ "minimax": {
125
+ "kind": "openai-compatible",
126
+ "base_url": "https://api.minimax.io/v1",
127
+ "model": "MiniMax-M2",
128
+ "key_env": "MINIMAX_API_KEY",
129
+ "family": "minimax",
130
+ "extra": None,
131
+ "no_temperature": (),
132
+ "completion_tokens": (),
133
+ },
134
+ "moonshot": {
135
+ "kind": "openai-compatible",
136
+ "base_url": "https://api.moonshot.ai/v1",
137
+ "model": "kimi-k2-0711-preview",
138
+ "key_env": "MOONSHOT_API_KEY",
139
+ "family": "moonshot",
140
+ "extra": None,
141
+ "no_temperature": (),
142
+ "completion_tokens": (),
143
+ },
144
+ "nvidia": {
145
+ "kind": "openai-compatible",
146
+ "base_url": "https://integrate.api.nvidia.com/v1",
147
+ "model": "nvidia/nemotron-3.5-lightning-30b-a3b",
148
+ "key_env": "NVIDIA_API_KEY",
149
+ "family": "nvidia",
150
+ "extra": {"chat_template_kwargs": {"enable_thinking": False}},
151
+ "no_temperature": (),
152
+ "completion_tokens": (),
153
+ },
154
+ "xai": {
155
+ "kind": "openai-compatible",
156
+ "base_url": "https://api.x.ai/v1",
157
+ "model": "grok-4-mini",
158
+ "key_env": "XAI_API_KEY",
159
+ "family": "xai",
160
+ "extra": None,
161
+ "no_temperature": (),
162
+ "completion_tokens": (),
163
+ },
164
+ "mistral": {
165
+ "kind": "openai-compatible",
166
+ "base_url": "https://api.mistral.ai/v1",
167
+ "model": "mistral-large-latest",
168
+ "key_env": "MISTRAL_API_KEY",
169
+ "family": "mistral",
170
+ "extra": None,
171
+ "no_temperature": (),
172
+ "completion_tokens": (),
173
+ },
174
+ "openrouter": {
175
+ "kind": "openai-compatible",
176
+ "base_url": "https://openrouter.ai/api/v1",
177
+ "model": "anthropic/claude-sonnet-4.5",
178
+ "key_env": "OPENROUTER_API_KEY",
179
+ # The router is its own family tag: the MODEL behind a
180
+ # routed seat varies, so independence checks treat an
181
+ # OpenRouter seat as family 'openrouter' and users who need
182
+ # a stricter reading tag the seat with --seat-meta.
183
+ "family": "openrouter",
184
+ "extra": None,
185
+ "no_temperature": (),
186
+ "completion_tokens": (),
187
+ },
188
+ # Local model servers (Epic L5): first-class presets for
189
+ # endpoints the user runs on their own machine. Keyless by
190
+ # construction (key_env None — providers.py sends no
191
+ # Authorization header and reads no credential), family
192
+ # 'local' for the independence checks (a local seat never
193
+ # counts as a second VENDOR family), and the endpoints are
194
+ # the servers' conventional loopback defaults — a preset
195
+ # names an endpoint, never a panel: which model serves is
196
+ # still the user's pick (preset:model), exactly like the
197
+ # hosted presets.
198
+ "ollama": {
199
+ "kind": "openai-compatible",
200
+ "base_url": "http://localhost:11434/v1",
201
+ "model": "llama3.1",
202
+ "key_env": None,
203
+ "family": "local",
204
+ "extra": None,
205
+ "no_temperature": (),
206
+ "completion_tokens": (),
207
+ "recipe": "start Ollama (`ollama serve`, or open the "
208
+ "Ollama app) and pull a model (`ollama pull "
209
+ "<model>`), then pick this preset and run "
210
+ "`codereview doctor` to probe the seat",
211
+ },
212
+ "lmstudio": {
213
+ "kind": "openai-compatible",
214
+ "base_url": "http://localhost:1234/v1",
215
+ "model": "local-model",
216
+ "key_env": None,
217
+ "family": "local",
218
+ "extra": None,
219
+ "no_temperature": (),
220
+ "completion_tokens": (),
221
+ "recipe": "open LM Studio, load a model, and start its "
222
+ "local server (Developer tab; default port "
223
+ "1234), then pick this preset and run "
224
+ "`codereview doctor` to probe the seat",
225
+ },
226
+ }
227
+
228
+ WIRE_KINDS = ("openai-compatible", "gemini", "anthropic")
229
+
230
+ # The family tag every local seat carries (Epic L5): local
231
+ # endpoints are the user's own machines, not a vendor, so the
232
+ # independence checks see one honest tag — two local seats are
233
+ # never two independent families, and a local seat never stands
234
+ # in for a hosted vendor's family.
235
+ LOCAL_FAMILY = "local"
236
+
237
+ # Prefix of the named-local-endpoint spec form:
238
+ # local:<name>:<base-url>|<model> (providers.describe_spec is
239
+ # the parser; this is the shared spelling).
240
+ LOCAL_SPEC_PREFIX = "local:"
241
+
242
+
243
+ def is_local_preset(name):
244
+ """True for a registry preset that is a local model server
245
+ (Epic L5): keyless, loopback endpoint, family 'local'."""
246
+ preset = PRESETS.get(name or "")
247
+ return bool(preset) and preset.get("family") == LOCAL_FAMILY
248
+
249
+
250
+ def local_presets():
251
+ """{name: preset} for the local presets, in registry
252
+ order — the setup wizard lists these as their own section,
253
+ apart from the hosted (keyed) providers."""
254
+ return {name: p for name, p in PRESETS.items()
255
+ if p.get("family") == LOCAL_FAMILY}
256
+
257
+
258
+ def omit_temperature(preset_name, model):
259
+ """True when the preset's quirk table says this model rejects a
260
+ caller-set temperature and the field must be omitted."""
261
+ preset = PRESETS.get(preset_name or "", {})
262
+ prefixes = preset.get("no_temperature") or ()
263
+ return any(str(model).startswith(p) for p in prefixes)
264
+
265
+
266
+ def completion_tokens(preset_name, model):
267
+ """True when the preset's quirk table says this model rejects
268
+ `max_tokens` and the token cap must be sent as
269
+ `max_completion_tokens` (OpenAI's gpt-5 / o-series lines)."""
270
+ preset = PRESETS.get(preset_name or "", {})
271
+ prefixes = preset.get("completion_tokens") or ()
272
+ return any(str(model).startswith(p) for p in prefixes)
273
+
274
+
275
+ def parse_role_assignments(values):
276
+ """--role values -> {role: provider-spec}.
277
+
278
+ Validates role names against ROLES and rejects a role assigned
279
+ twice (a silent last-wins would hide a configuration mistake).
280
+ Raises ValueError with an operator-readable message."""
281
+ roles = {}
282
+ for value in values or []:
283
+ role, eq, spec = str(value).partition("=")
284
+ role, spec = role.strip().lower(), spec.strip()
285
+ if not eq or role not in ROLES or not spec:
286
+ raise ValueError(
287
+ f"bad --role {value!r}: want <role>=<provider-spec> "
288
+ f"with role one of {', '.join(ROLES)}")
289
+ if role in roles:
290
+ raise ValueError(
291
+ f"role {role!r} assigned twice ({roles[role]!r} and "
292
+ f"{spec!r}); assign each role once")
293
+ roles[role] = spec
294
+ return roles
codereview/risk.py ADDED
@@ -0,0 +1,368 @@
1
+ #!/usr/bin/env python3
2
+ """Deterministic change-risk scorer — Epic E1 of the adopted
3
+ roadmap.
4
+
5
+ `risk.scorer` reads a change (the same diff sections the panel
6
+ reviews) and produces a risk score with reasons: which
7
+ sensitive patterns the change touches (auth, payments, crypto,
8
+ migrations, secrets, deserialization), whether dependency
9
+ manifests changed, and how large the change is. The result
10
+ lands in the run record's `risk` section when the project
11
+ policy enables it (the `[risk]` table of `.codereview.toml`,
12
+ validated by policy.py in the B1 style).
13
+
14
+ Determinism is the contract: the score is a pure function of
15
+ the diff sections and the resolved thresholds. No model calls,
16
+ no randomness, no clock, no environment reads — the same diff
17
+ under the same policy scores identically on every run, on
18
+ every machine. Every point of the score is explained by a
19
+ human-readable reason string, and the per-signal breakdown is
20
+ recorded alongside, so a consumer never has to re-derive why.
21
+
22
+ Scope of this ticket (E1): the scorer and the record section
23
+ only. The score is descriptive — it never changes a verdict, a
24
+ finding, or any gate input. Tiering (E2) and escalation (E4)
25
+ consume the section next; they read `score` / `band` /
26
+ `signals` from here rather than re-scoring.
27
+
28
+ What is scored:
29
+
30
+ * Sections: the kept diff sections of the run (the ones the
31
+ panel reviews — archived record artifacts are already
32
+ excluded by the caller, exactly as for the panel).
33
+ * Patterns: per class, a file matches when its PATH matches
34
+ the class's path patterns or any of its ADDED lines matches
35
+ the class's content patterns (removed lines are not
36
+ scanned: the score describes what the change introduces
37
+ and touches, and path matches already cover deletions of
38
+ sensitive files). A class contributes its points once, no
39
+ matter how many files match; the pattern subtotal is
40
+ capped (PATTERN_CAP) so breadth alone cannot max the
41
+ score.
42
+ * Dependencies: any changed file whose basename is a known
43
+ dependency manifest (or a requirements*.txt, or a .txt
44
+ under a requirements/ directory) contributes
45
+ DEPENDENCY_POINTS once.
46
+ * Size: total added + removed lines across all sections,
47
+ in fixed tiers up to SIZE_MAX_POINTS.
48
+
49
+ Bands: score >= high threshold is "high", >= medium is
50
+ "medium", otherwise "low". Thresholds default to
51
+ DEFAULT_MEDIUM_THRESHOLD / DEFAULT_HIGH_THRESHOLD and may be
52
+ overridden by the policy's [risk] table.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+
59
+ SCHEMA = "codereview.risk/1"
60
+
61
+ # Band thresholds (defaults; the policy's [risk] table may
62
+ # override either — policy.py mirrors these two constants
63
+ # under the import-free discipline and a test pins the pair).
64
+ DEFAULT_MEDIUM_THRESHOLD = 35
65
+ DEFAULT_HIGH_THRESHOLD = 70
66
+
67
+ # Points per touched pattern class (awarded once per class)
68
+ # and the cap on the pattern subtotal.
69
+ CLASS_POINTS = {
70
+ "auth": 25,
71
+ "payments": 25,
72
+ "crypto": 20,
73
+ "migrations": 20,
74
+ "secrets": 25,
75
+ "deserialization": 20,
76
+ }
77
+ PATTERN_CAP = 60
78
+
79
+ # Dependency-manifest changes.
80
+ DEPENDENCY_POINTS = 15
81
+
82
+ # Size tiers: (minimum changed lines, points), descending.
83
+ SIZE_TIERS = ((1000, 25), (400, 15), (150, 10), (50, 5))
84
+ SIZE_MAX_POINTS = 25
85
+
86
+ # How many matching files a reason names before summarising
87
+ # the rest ("+N more").
88
+ _REASON_FILE_LIMIT = 5
89
+
90
+ # ----------------------------------------------------------------
91
+ # Pattern classes, in the fixed order reasons are reported.
92
+ # Path patterns match the repo-relative path (lowercased);
93
+ # content patterns match added lines (case-insensitive).
94
+
95
+ _PATH_PATTERNS = {
96
+ "auth": re.compile(
97
+ r"(^|/)(auth|authn|authz|login|logout|signin|signup|"
98
+ r"sessions?|oauth|oidc|rbac|acl|permissions?)([./_-]|$)"),
99
+ "payments": re.compile(
100
+ r"(^|/)(payments?|billing|checkout|invoices?|charges?|"
101
+ r"refunds?|subscriptions?|stripe|paypal|pricing)"
102
+ r"([./_-]|$)"),
103
+ "crypto": re.compile(
104
+ r"(^|/)(crypto|crypt|encryption|decrypt|ciphers?|"
105
+ r"hashing)([./_-]|$)"),
106
+ # P2-05: migrations / secrets / deserialization are
107
+ # component-anchored like the auth/payments/crypto rules
108
+ # above — a keyword buried inside a longer component
109
+ # ("im**migration**", "no**secrets**.py") must not score.
110
+ "migrations": re.compile(
111
+ r"(^|/)migrations?([./_-]|$)|(^|/)alembic(/|$)|"
112
+ r"(^|/)flyway(/|$)|(^|/)liquibase(/|$)"),
113
+ "secrets": re.compile(
114
+ r"(^|/)(secrets?|credentials?)(\.|/|_|-|$)|"
115
+ r"(^|/)\.env(\.|$)"),
116
+ "deserialization": re.compile(r"(^|/)deserial"),
117
+ }
118
+
119
+ _CONTENT_PATTERNS = {
120
+ "auth": re.compile(
121
+ r"\b(authenticate|authentication|authorization|"
122
+ r"login_required|permission_required|check_password|"
123
+ r"make_password|jwt|oauth|login_user|current_user|"
124
+ r"set_cookie)\b", re.IGNORECASE),
125
+ "payments": re.compile(
126
+ r"\b(stripe|paypal|braintree|adyen|cvv|cvc|"
127
+ r"card_number|payment_intent|paymentintent|"
128
+ r"payment_method)\b", re.IGNORECASE),
129
+ "crypto": re.compile(
130
+ r"\b(hashlib|bcrypt|argon2|scrypt|pbkdf2|fernet|"
131
+ r"createcipher|createdecipher|messagedigest|hmac|"
132
+ r"encrypt|decrypt)\b", re.IGNORECASE),
133
+ # \s+ rather than a literal space: the phrases are SQL
134
+ # whatever the whitespace, and the pattern text itself
135
+ # then carries no literal destructive-SQL string for a
136
+ # scanner to mistake for a statement.
137
+ "migrations": re.compile(
138
+ r"\b(alter\s+table|create\s+table|drop\s+table|"
139
+ r"add\s+column|drop\s+column|create\s+index|"
140
+ r"drop\s+index)\b",
141
+ re.IGNORECASE),
142
+ "secrets": re.compile(
143
+ r"(api[_-]?key|secret|password|passwd|private[_-]?key|"
144
+ r"aws_secret_access_key)\s*[:=]|"
145
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}",
146
+ re.IGNORECASE),
147
+ "deserialization": re.compile(
148
+ r"\b(pickle\.loads?|marshal\.loads?|dill\.loads?|"
149
+ r"yaml\.load\(|unserialize\s?\(|objectinputstream|"
150
+ r"readobject\s?\(|binaryformatter)\b", re.IGNORECASE),
151
+ }
152
+
153
+ # Class order for reasons and the signals ledger: the ticket's
154
+ # order, fixed so output never depends on dict iteration.
155
+ CLASS_ORDER = tuple(CLASS_POINTS)
156
+
157
+ # Dependency manifests by basename (plus the requirements
158
+ # rule in _is_dependency_manifest).
159
+ DEPENDENCY_MANIFESTS = frozenset({
160
+ "package.json", "package-lock.json", "yarn.lock",
161
+ "pnpm-lock.yaml", "npm-shrinkwrap.json",
162
+ "requirements.txt", "pipfile", "pipfile.lock",
163
+ "pyproject.toml", "setup.py", "setup.cfg", "poetry.lock",
164
+ "uv.lock", "gemfile", "gemfile.lock", "go.mod", "go.sum",
165
+ "cargo.toml", "cargo.lock", "composer.json",
166
+ "composer.lock", "pom.xml", "build.gradle",
167
+ "build.gradle.kts", "packages.lock.json",
168
+ })
169
+
170
+ _REQUIREMENTS_RE = re.compile(r"requirements[^/]*\.txt$")
171
+
172
+
173
+ def _is_dependency_manifest(path):
174
+ lowered = (path or "").lower()
175
+ base = lowered.rsplit("/", 1)[-1]
176
+ if base in DEPENDENCY_MANIFESTS \
177
+ or _REQUIREMENTS_RE.match(base):
178
+ return True
179
+ # The requirements/<env>.txt layout: a .txt inside a
180
+ # requirements directory is a manifest whatever its name.
181
+ parts = lowered.split("/")
182
+ return "requirements" in parts[:-1] \
183
+ and base.endswith(".txt")
184
+
185
+
186
+ def _section_lines(section_text):
187
+ """(added_texts, added_count, removed_count) for one diff
188
+ section, using the same line convention as core.added_lines:
189
+ a line starting with '+' is an addition unless it starts
190
+ with '+++' (the +++ header), likewise '-' / '---'."""
191
+ added_texts = []
192
+ removed = 0
193
+ for raw in (section_text or "").splitlines():
194
+ if raw.startswith("+") and not raw.startswith("+++"):
195
+ added_texts.append(raw[1:])
196
+ elif raw.startswith("-") and not raw.startswith("---"):
197
+ removed += 1
198
+ return added_texts, len(added_texts), removed
199
+
200
+
201
+ def _name_files(files):
202
+ """A deterministic file list for a reason string: sorted,
203
+ capped at _REASON_FILE_LIMIT names plus a '+N more' tail."""
204
+ names = sorted(files)
205
+ if len(names) <= _REASON_FILE_LIMIT:
206
+ return ", ".join(names)
207
+ return ", ".join(names[:_REASON_FILE_LIMIT]) \
208
+ + f", +{len(names) - _REASON_FILE_LIMIT} more"
209
+
210
+
211
+ def risk_config(policy):
212
+ """The resolved scorer config carried by a loaded policy
213
+ dict, or None when risk scoring is not enabled (no policy,
214
+ no [risk] table, or enabled = false) — the caller then
215
+ leaves the record exactly as it was before Epic E1.
216
+
217
+ Defensive by construction, mirroring hooks_from_policy:
218
+ a hand-built policy dict with missing or mistyped values
219
+ resolves to the defaults rather than raising mid-run; the
220
+ validating layer is policy.load_policy."""
221
+ if not isinstance(policy, dict):
222
+ return None
223
+ risk = policy.get("risk") or {}
224
+ if not isinstance(risk, dict) or not risk.get("enabled"):
225
+ return None
226
+
227
+ def _threshold(key, default):
228
+ value = risk.get(key)
229
+ if isinstance(value, bool) \
230
+ or not isinstance(value, int) \
231
+ or not 0 <= value <= 100:
232
+ return default
233
+ return value
234
+
235
+ return {
236
+ "medium_threshold": _threshold(
237
+ "medium_threshold", DEFAULT_MEDIUM_THRESHOLD),
238
+ "high_threshold": _threshold(
239
+ "high_threshold", DEFAULT_HIGH_THRESHOLD),
240
+ }
241
+
242
+
243
+ def band_for(score, config=None):
244
+ """The band for a score under a resolved config (or the
245
+ default thresholds)."""
246
+ config = config or {}
247
+ medium = config.get("medium_threshold",
248
+ DEFAULT_MEDIUM_THRESHOLD)
249
+ high = config.get("high_threshold", DEFAULT_HIGH_THRESHOLD)
250
+ if score >= high:
251
+ return "high"
252
+ if score >= medium:
253
+ return "medium"
254
+ return "low"
255
+
256
+
257
+ def score_sections(sections, config=None):
258
+ """Score a change from its diff sections.
259
+
260
+ `sections` is a list of (path, section_text) pairs — the
261
+ shape core.split_patch_sections returns and run_review
262
+ already holds. `config` is a resolved config from
263
+ risk_config (or None for the default thresholds).
264
+
265
+ Returns the run record's `risk` section:
266
+ {"schema", "score", "band", "reasons", "points",
267
+ "signals", "thresholds"} — see the module docstring for
268
+ the scoring rules. Pure: no I/O, no clock, no randomness.
269
+ """
270
+ config = config or {}
271
+ medium = config.get("medium_threshold",
272
+ DEFAULT_MEDIUM_THRESHOLD)
273
+ high = config.get("high_threshold", DEFAULT_HIGH_THRESHOLD)
274
+
275
+ class_files = {name: [] for name in CLASS_ORDER}
276
+ dependency_files = []
277
+ files_changed = 0
278
+ lines_added = 0
279
+ lines_removed = 0
280
+ for path, text in sections or []:
281
+ files_changed += 1
282
+ added_texts, n_added, n_removed = _section_lines(text)
283
+ lines_added += n_added
284
+ lines_removed += n_removed
285
+ lowered = (path or "").lower()
286
+ for name in CLASS_ORDER:
287
+ if _PATH_PATTERNS[name].search(lowered):
288
+ class_files[name].append(path)
289
+ continue
290
+ content_re = _CONTENT_PATTERNS[name]
291
+ if any(content_re.search(line)
292
+ for line in added_texts):
293
+ class_files[name].append(path)
294
+ if _is_dependency_manifest(path):
295
+ dependency_files.append(path)
296
+
297
+ reasons = []
298
+ pattern_signals = []
299
+ pattern_points = 0
300
+ for name in CLASS_ORDER:
301
+ files = sorted(set(class_files[name]))
302
+ if not files:
303
+ continue
304
+ points = CLASS_POINTS[name]
305
+ pattern_points += points
306
+ pattern_signals.append({
307
+ "class": name, "points": points, "files": files})
308
+ reasons.append(
309
+ f"{name} patterns touched in {len(files)} file(s): "
310
+ f"{_name_files(files)} (+{points})")
311
+ if pattern_points > PATTERN_CAP:
312
+ reasons.append(
313
+ f"pattern subtotal capped at {PATTERN_CAP} "
314
+ f"(uncapped {pattern_points})")
315
+ pattern_points = PATTERN_CAP
316
+
317
+ dependency_points = 0
318
+ dependency_files = sorted(set(dependency_files))
319
+ if dependency_files:
320
+ dependency_points = DEPENDENCY_POINTS
321
+ reasons.append(
322
+ f"dependency manifests changed: "
323
+ f"{_name_files(dependency_files)} "
324
+ f"(+{dependency_points})")
325
+
326
+ changed_lines = lines_added + lines_removed
327
+ size_points = 0
328
+ for minimum, points in SIZE_TIERS:
329
+ if changed_lines >= minimum:
330
+ size_points = points
331
+ break
332
+ if size_points:
333
+ reasons.append(
334
+ f"change size: {changed_lines} changed line(s) "
335
+ f"(+{size_points})")
336
+
337
+ score = min(100, pattern_points + dependency_points
338
+ + size_points)
339
+ if not reasons:
340
+ reasons.append("no risk signals detected")
341
+ return {
342
+ "schema": SCHEMA,
343
+ "score": score,
344
+ "band": band_for(score, {"medium_threshold": medium,
345
+ "high_threshold": high}),
346
+ "reasons": reasons,
347
+ "points": {"patterns": pattern_points,
348
+ "dependencies": dependency_points,
349
+ "size": size_points},
350
+ "signals": {
351
+ "files_changed": files_changed,
352
+ "lines_added": lines_added,
353
+ "lines_removed": lines_removed,
354
+ "patterns": pattern_signals,
355
+ "dependency_files": dependency_files,
356
+ },
357
+ "thresholds": {"medium": medium, "high": high},
358
+ }
359
+
360
+
361
+ def score_patch(patch, config=None):
362
+ """Score a raw unified-diff string — score_sections over
363
+ core.split_patch_sections(patch). The import lives inside
364
+ this function so the module stays importable on its own
365
+ (core imports this module only at function level too, so
366
+ the module graph stays one-directional)."""
367
+ from .core import split_patch_sections
368
+ return score_sections(split_patch_sections(patch), config)