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/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
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)
|