Coverage for src/keel/classify.py: 100%
68 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Risk classification — which tier a change is, from the files it touches.
3Pure and deterministic: the tier is a function of the changed paths and the
4project's globs, with no I/O. The tier drives the reviewer count (see
5:func:`keel.ship.reviewer_count`).
6"""
8from __future__ import annotations
10import fnmatch
11import re
13#: Default tier when nothing else matches.
14DEFAULT_TIER = 2
16#: Paths where a *diff* may lower the tier a path alone would set (#794).
17#:
18#: Only workflow YAML. For the other tier-3 paths the content **is** the risk — a
19#: checksum in ``Formula/keel.rb``, a pin in ``.github/requirements`` — so there is
20#: no such thing as a cosmetic change there and every edit stays TIER-3.
21DIFF_CLASSIFIED_GLOBS = (
22 ".github/workflows/*.yml",
23 ".github/workflows/*.yaml",
24)
26#: What makes a workflow diff privileged, regardless of how small it is.
27#:
28#: ``.github/workflows/**`` used to be TIER-3 wholesale, which tiered up a comment,
29#: two added CI jobs and a change that *tightened* four action pins — all waived —
30#: while leaving the formula ``brew install`` runs at TIER-2 (#786). Splitting the
31#: glob by write permissions fixed three of those four; the fourth stays because a
32#: path cannot say what was done to the file. These patterns can.
33_PRIVILEGED_LINE = re.compile(
34 r"""
35 \buses\s*: # a third-party action: a pin swap is the attack
36 | \bsecrets\s*\. # reading a stored credential
37 | ^\s*permissions\s*: # granting or widening a token scope
38 | ^\s*[\w-]+\s*:\s*write\b # a scope inside a permissions block
39 | ^\s*on\s*: # the trigger surface (pull_request_target…)
40 | \b(?:curl|wget|nc|ssh|scp)\b # a run: step reaching the network
41 | \bpip\s+install\b
42 | \bnpm\s+(?:i|install|ci)\b
43 | \bgh\s+(?:api|auth)\b
44 """,
45 re.VERBOSE | re.MULTILINE,
46)
48#: A diff line that changes content: an addition or a removal, not context and not
49#: the ``+++``/``---`` file headers.
50_CHANGED_LINE = re.compile(r"(?m)^[+-](?![+-])(.*)$")
52#: A whole-line comment — YAML/shell ``#`` or an HTML comment. Skipped before the
53#: privilege match, because a comment cannot execute: #775's only workflow change
54#: was a generated banner and a prose line reading ``pip install "git+https://…"``,
55#: and matching inside it is what kept a comment-only edit at TIER-3.
56#:
57#: This is not a bypass. A line that starts with ``#`` is inert in both YAML and
58#: the shell, so hiding a ``uses:`` or a ``secrets.`` reference behind one buys an
59#: attacker a lower tier on a change that also does nothing.
60_COMMENT_LINE = re.compile(r"^\s*(?:#|<!--)")
63def privileged_change(patch: str) -> tuple[bool, str]:
64 """Whether a workflow diff changes what the workflow *can do*.
66 Returns ``(privileged, reason)``. ``reason`` names the first line that decided
67 it, so a TIER-3 call is explainable rather than an assertion.
69 **Fails closed.** An empty or unreadable patch is privileged: a classifier that
70 silently downgrades what it cannot parse is worse than the glob it replaces,
71 because the glob at least never guessed. Only a diff that was read *and*
72 contained nothing privileged earns the lower tier.
73 """
74 if not patch or not patch.strip():
75 return True, "empty or unreadable patch"
76 changed = _CHANGED_LINE.findall(patch)
77 if not changed:
78 return True, "no add/remove lines found — patch not understood"
79 for line in changed:
80 if _COMMENT_LINE.match(line):
81 continue
82 if _PRIVILEGED_LINE.search(line):
83 return True, line.strip()[:120]
84 return False, ""
86#: Strictest tier — the fail-closed answer when the changed-file list could not be
87#: read at all. An unreadable diff must never classify as the *default* tier: that
88#: is the answer for "an empty changeset", and it silently drops a reviewer and the
89#: gating jury on a change nobody has seen.
90UNKNOWN_TIER = 3
93def _matches_any(path: str, globs: tuple[str, ...]) -> bool:
94 for g in globs:
95 if fnmatch.fnmatch(path, g):
96 return True
97 return False
100def is_docs_only(changed: list[str], docs_globs: tuple[str, ...]) -> bool:
101 """Whether *every* changed path is a docs-surface path (and there is at least one).
103 Asked directly rather than inferred from ``tier_for_files(...) == 1``, because the
104 two questions have deliberately different answers: ``allowlist_globs`` may keep a
105 change classified TIER-1 without making it docs-*only*. The CI empty-check-set
106 carve-out needs this stricter question — a generated site file riding along with a
107 docs edit is precisely the case where a workflow *should* have run.
109 An empty list is not docs-only: an unreadable or empty changeset must fail closed.
110 """
111 if not changed:
112 return False
113 for p in changed:
114 if not _matches_any(p, docs_globs):
115 return False
116 return True
119#: ``diff --git a/<old> b/<new>`` — the header that starts each file in a unified
120#: diff. The *new* name is the key, matching what the changed-file list reports.
121_DIFF_HEADER = re.compile(r"(?m)^diff --git a/(?:\S+) b/(\S+)$")
124def split_unified_diff(diff: str | None) -> dict[str, str]:
125 """Split a whole-repo unified diff into per-file patches, keyed by new path.
127 ``None`` or an unparseable diff yields ``{}`` — no evidence, so every path keeps
128 the tier it would have had. Never a partial mapping: a diff that produced no
129 headers is not silently read as "no files changed".
130 """
131 if not diff:
132 return {}
133 marks = list(_DIFF_HEADER.finditer(diff))
134 if not marks:
135 return {}
136 out: dict[str, str] = {}
137 for index, mark in enumerate(marks):
138 end = marks[index + 1].start() if index + 1 < len(marks) else len(diff)
139 out[mark.group(1)] = diff[mark.start():end]
140 return out
143def _tier3_downgradable(path: str, patches: dict[str, str] | None) -> bool:
144 """Whether ``path``'s TIER-3 match may be lowered on the strength of its diff.
146 Three things must all hold, and any one missing keeps TIER-3:
148 * the path is one we know how to read a diff for (workflow YAML);
149 * a patch for it was actually supplied — no patch means no evidence, and no
150 evidence means the path decides, exactly as before this existed;
151 * that patch changes nothing privileged.
152 """
153 if patches is None or not _matches_any(path, DIFF_CLASSIFIED_GLOBS):
154 return False
155 patch = patches.get(path)
156 if patch is None:
157 return False
158 privileged, _reason = privileged_change(patch)
159 return not privileged
162def tier_for_files(
163 changed: list[str],
164 *,
165 tier3_globs: tuple[str, ...] = (),
166 docs_globs: tuple[str, ...] = (),
167 allowlist_globs: tuple[str, ...] = (),
168 patches: dict[str, str] | None = None,
169) -> int:
170 """Classify a change into TIER 1/2/3 from its changed files.
172 * any file matching ``tier3_globs`` (migrations, CI, core code…) ⇒ **TIER-3**;
173 * otherwise, if *every* changed file matches ``docs_globs`` or ``allowlist_globs``
174 (docs-only) ⇒ **TIER-1**;
175 * otherwise ⇒ **TIER-2** (the default). An empty changeset is TIER-2 (unknown).
177 ``allowlist_globs`` (``knobs.docs_only_allowlist``) are paths permitted to ride along
178 in a docs change without forcing code-risk classification — generated site output,
179 metadata. They widen *this* judgement only: they are not a docs surface, so they do
180 not relax scope-creep tolerance and they do not buy the empty-CI-check carve-out
181 (see :func:`is_docs_only`).
182 """
183 if not changed:
184 return DEFAULT_TIER
186 if tier3_globs:
187 for p in changed:
188 if not _matches_any(p, tier3_globs):
189 continue
190 if not _tier3_downgradable(p, patches):
191 return 3
193 if docs_globs:
194 for p in changed:
195 if not (_matches_any(p, docs_globs) or _matches_any(p, allowlist_globs)):
196 return DEFAULT_TIER
197 return 1
199 return DEFAULT_TIER