Coverage for src/keel/classify.py: 100%
68 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +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#: url in ``packaging/homebrew/``, 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, ""
87#: Strictest tier — the fail-closed answer when the changed-file list could not be
88#: read at all. An unreadable diff must never classify as the *default* tier: that
89#: is the answer for "an empty changeset", and it silently drops a reviewer and the
90#: gating jury on a change nobody has seen.
91UNKNOWN_TIER = 3
94def _matches_any(path: str, globs: tuple[str, ...]) -> bool:
95 for g in globs:
96 if fnmatch.fnmatch(path, g):
97 return True
98 return False
101def is_docs_only(changed: list[str], docs_globs: tuple[str, ...]) -> bool:
102 """Whether *every* changed path is a docs-surface path (and there is at least one).
104 Asked directly rather than inferred from ``tier_for_files(...) == 1``, because the
105 two questions have deliberately different answers: ``allowlist_globs`` may keep a
106 change classified TIER-1 without making it docs-*only*. The CI empty-check-set
107 carve-out needs this stricter question — a generated site file riding along with a
108 docs edit is precisely the case where a workflow *should* have run.
110 An empty list is not docs-only: an unreadable or empty changeset must fail closed.
111 """
112 if not changed:
113 return False
114 for p in changed:
115 if not _matches_any(p, docs_globs):
116 return False
117 return True
120#: ``diff --git a/<old> b/<new>`` — the header that starts each file in a unified
121#: diff. The *new* name is the key, matching what the changed-file list reports.
122_DIFF_HEADER = re.compile(r"(?m)^diff --git a/(?:\S+) b/(\S+)$")
125def split_unified_diff(diff: str | None) -> dict[str, str]:
126 """Split a whole-repo unified diff into per-file patches, keyed by new path.
128 ``None`` or an unparseable diff yields ``{}`` — no evidence, so every path keeps
129 the tier it would have had. Never a partial mapping: a diff that produced no
130 headers is not silently read as "no files changed".
131 """
132 if not diff:
133 return {}
134 marks = list(_DIFF_HEADER.finditer(diff))
135 if not marks:
136 return {}
137 out: dict[str, str] = {}
138 for index, mark in enumerate(marks):
139 end = marks[index + 1].start() if index + 1 < len(marks) else len(diff)
140 out[mark.group(1)] = diff[mark.start() : end]
141 return out
144def _tier3_downgradable(path: str, patches: dict[str, str] | None) -> bool:
145 """Whether ``path``'s TIER-3 match may be lowered on the strength of its diff.
147 Three things must all hold, and any one missing keeps TIER-3:
149 * the path is one we know how to read a diff for (workflow YAML);
150 * a patch for it was actually supplied — no patch means no evidence, and no
151 evidence means the path decides, exactly as before this existed;
152 * that patch changes nothing privileged.
153 """
154 if patches is None or not _matches_any(path, DIFF_CLASSIFIED_GLOBS):
155 return False
156 patch = patches.get(path)
157 if patch is None:
158 return False
159 privileged, _reason = privileged_change(patch)
160 return not privileged
163def tier_for_files(
164 changed: list[str],
165 *,
166 tier3_globs: tuple[str, ...] = (),
167 docs_globs: tuple[str, ...] = (),
168 allowlist_globs: tuple[str, ...] = (),
169 patches: dict[str, str] | None = None,
170) -> int:
171 """Classify a change into TIER 1/2/3 from its changed files.
173 * any file matching ``tier3_globs`` (migrations, CI, core code…) ⇒ **TIER-3**;
174 * otherwise, if *every* changed file matches ``docs_globs`` or ``allowlist_globs``
175 (docs-only) ⇒ **TIER-1**;
176 * otherwise ⇒ **TIER-2** (the default). An empty changeset is TIER-2 (unknown).
178 ``allowlist_globs`` (``knobs.docs_only_allowlist``) are paths permitted to ride along
179 in a docs change without forcing code-risk classification — generated site output,
180 metadata. They widen *this* judgement only: they are not a docs surface, so they do
181 not relax scope-creep tolerance and they do not buy the empty-CI-check carve-out
182 (see :func:`is_docs_only`).
183 """
184 if not changed:
185 return DEFAULT_TIER
187 if tier3_globs:
188 for p in changed:
189 if not _matches_any(p, tier3_globs):
190 continue
191 if not _tier3_downgradable(p, patches):
192 return 3
194 if docs_globs:
195 for p in changed:
196 if not (_matches_any(p, docs_globs) or _matches_any(p, allowlist_globs)):
197 return DEFAULT_TIER
198 return 1
200 return DEFAULT_TIER