Coverage for extra_codeowners/codeowners.py: 90%
154 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-13 09:46 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-13 09:46 +0000
1"""Strict parsing and matching for the supported GitHub CODEOWNERS subset.
3GitHub silently skips malformed CODEOWNERS lines. Extra CODEOWNERS instead
4reports every malformed line and fails closed, because silently changing the
5approval boundary would be unsafe.
6"""
8from __future__ import annotations
10import re
11from dataclasses import dataclass, field
13from extra_codeowners.models import normalize_owner, normalize_repository_path
15MAX_CODEOWNERS_BYTES = 3 * 1024 * 1024
18@dataclass(frozen=True, slots=True)
19class CodeownersIssue:
20 """One parse error with a stable code suitable for Check Run output."""
22 code: str
23 message: str
24 line_number: int | None = None
26 def render(self) -> str:
27 """Return a concise human-readable representation."""
29 prefix = f"line {self.line_number}: " if self.line_number is not None else ""
30 return f"{prefix}{self.message}"
33class CodeownersParseError(ValueError):
34 """Raised when a CODEOWNERS document cannot be evaluated safely."""
36 def __init__(self, issues: tuple[CodeownersIssue, ...]) -> None:
37 self.issues = issues
38 super().__init__("; ".join(issue.render() for issue in issues))
41def validate_pattern(pattern: str) -> str:
42 """Validate a CODEOWNERS-style pattern supported by GitHub and this engine."""
44 value = pattern.strip()
45 if not value: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true
46 raise ValueError("pattern must not be empty")
47 if value.startswith("!"):
48 raise ValueError("negated patterns ('!') are not supported by GitHub CODEOWNERS")
49 if "[" in value or "]" in value:
50 raise ValueError("character ranges ('[...]') are not supported by GitHub CODEOWNERS")
51 if "\\" in value: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 raise ValueError("backslash escapes are not supported; use a POSIX path pattern")
53 if value.startswith("#"): 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 raise ValueError("a CODEOWNERS pattern cannot begin with '#'")
56 without_anchor = value.removeprefix("/")
57 if not without_anchor: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true
58 raise ValueError("root-only '/' is not a file pattern")
59 segments = without_anchor.rstrip("/").split("/")
60 if any(segment in {"", ".", ".."} for segment in segments): 60 ↛ 61line 60 didn't jump to line 61 because the condition on line 60 was never true
61 raise ValueError("pattern must not contain empty, '.' or '..' path segments")
62 return value
65def _pattern_regex(pattern: str) -> re.Pattern[str]:
66 value = validate_pattern(pattern)
67 anchored = value.startswith("/") or "/" in value.rstrip("/")
68 value = value.removeprefix("/")
69 final_segment = value.rstrip("/").rsplit("/", maxsplit=1)[-1]
70 literal_directory_candidate = "*" not in final_segment and "?" not in final_segment
71 if value.endswith("/"):
72 # A trailing slash names a directory recursively. This follows GitHub's
73 # `/build/logs/` and `apps/` CODEOWNERS examples; `docs/*`, by contrast,
74 # remains limited to direct children.
75 value += "**"
77 translated: list[str] = []
78 index = 0
79 while index < len(value):
80 character = value[index]
81 if character == "*":
82 if index + 1 < len(value) and value[index + 1] == "*":
83 index += 2
84 while index < len(value) and value[index] == "*": 84 ↛ 85line 84 didn't jump to line 85 because the condition on line 84 was never true
85 index += 1
86 if index < len(value) and value[index] == "/":
87 translated.append("(?:.*/)?")
88 index += 1
89 else:
90 translated.append(".*")
91 continue
92 translated.append("[^/]*")
93 elif character == "?": 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 translated.append("[^/]")
95 else:
96 translated.append(re.escape(character))
97 index += 1
99 prefix = "^" if anchored else r"^(?:.*/)?"
100 # GitHub applies a pattern ending in a literal directory name to every file
101 # below that directory (`/apps/github` and `**/logs` in its examples). A
102 # wildcard terminal such as `docs/*` does not gain that recursive suffix.
103 suffix = r"(?:/.*)?$" if literal_directory_candidate else "$"
104 return re.compile(prefix + "".join(translated) + suffix)
107def compile_pattern(pattern: str) -> re.Pattern[str]:
108 """Validate and precompile one CODEOWNERS-style pattern."""
109 return _pattern_regex(pattern)
112def matches_pattern(pattern: str, path: str) -> bool:
113 """Return whether a repository-relative path matches a CODEOWNERS pattern."""
115 normalized_path = normalize_repository_path(path)
116 return _pattern_regex(pattern).fullmatch(normalized_path) is not None
119@dataclass(frozen=True, slots=True)
120class CodeownersRule:
121 """One valid CODEOWNERS rule."""
123 pattern: str
124 owners: tuple[str, ...]
125 line_number: int
126 _regex: re.Pattern[str] = field(init=False, repr=False, compare=False)
128 def __post_init__(self) -> None:
129 object.__setattr__(self, "_regex", _pattern_regex(self.pattern))
131 def matches(self, path: str) -> bool:
132 """Return whether this rule applies to ``path``."""
134 normalized_path = normalize_repository_path(path)
135 return self._regex.fullmatch(normalized_path) is not None
137 def matches_normalized(self, path: str) -> bool:
138 """Match a path already validated by the containing document."""
139 return self._regex.fullmatch(path) is not None
142@dataclass(frozen=True, slots=True)
143class CodeownersDocument:
144 """A validated CODEOWNERS file preserving GitHub's last-match-wins order."""
146 rules: tuple[CodeownersRule, ...]
148 def rule_for(self, path: str) -> CodeownersRule | None:
149 """Return the last matching rule, as GitHub does."""
151 normalized_path = normalize_repository_path(path)
152 matched: CodeownersRule | None = None
153 for rule in self.rules:
154 if rule.matches_normalized(normalized_path):
155 matched = rule
156 return matched
158 def owners_for(self, path: str) -> tuple[str, ...]:
159 """Return owners for the last matching rule, or an empty tuple."""
161 rule = self.rule_for(path)
162 return () if rule is None else rule.owners
165def _split_line(line: str) -> list[str]:
166 """Split a CODEOWNERS line while honoring escaped whitespace in a path."""
168 tokens: list[str] = []
169 current: list[str] = []
170 index = 0
171 while index < len(line):
172 character = line[index]
173 if character == "\\" and index + 1 < len(line) and line[index + 1].isspace():
174 current.append(line[index + 1])
175 index += 2
176 continue
177 if character.isspace():
178 if current: 178 ↛ 183line 178 didn't jump to line 183 because the condition on line 178 was always true
179 tokens.append("".join(current))
180 current = []
181 else:
182 current.append(character)
183 index += 1
184 if current: 184 ↛ 186line 184 didn't jump to line 186 because the condition on line 184 was always true
185 tokens.append("".join(current))
186 return tokens
189def parse_codeowners(content: str) -> CodeownersDocument:
190 """Parse a CODEOWNERS document or raise all detected syntax errors."""
192 size = len(content.encode("utf-8"))
193 if size > MAX_CODEOWNERS_BYTES:
194 issue = CodeownersIssue(
195 code="codeowners_too_large",
196 message=(
197 f"CODEOWNERS is {size} bytes; GitHub ignores files larger than "
198 f"{MAX_CODEOWNERS_BYTES} bytes"
199 ),
200 )
201 raise CodeownersParseError((issue,))
203 rules: list[CodeownersRule] = []
204 issues: list[CodeownersIssue] = []
205 for line_number, raw_line in enumerate(content.splitlines(), start=1):
206 stripped = raw_line.strip()
207 if not stripped or stripped.startswith("#"):
208 continue
210 tokens = _split_line(stripped)
211 comment_index = next(
212 (index for index, token in enumerate(tokens) if token.startswith("#")),
213 len(tokens),
214 )
215 tokens = tokens[:comment_index]
216 if not tokens: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true
217 continue
218 pattern, *raw_owners = tokens
219 try:
220 pattern = validate_pattern(pattern)
221 except ValueError as error:
222 issues.append(
223 CodeownersIssue(
224 code="invalid_pattern",
225 message=str(error),
226 line_number=line_number,
227 )
228 )
229 continue
231 owners: list[str] = []
232 owner_error = False
233 for raw_owner in raw_owners:
234 try:
235 owners.append(normalize_owner(raw_owner))
236 except ValueError as error:
237 code = (
238 "email_owner_unsupported"
239 if "email CODEOWNER" in str(error)
240 else "invalid_owner"
241 )
242 issues.append(
243 CodeownersIssue(code=code, message=str(error), line_number=line_number)
244 )
245 owner_error = True
246 if owner_error:
247 continue
249 # An empty owner list is valid and clears ownership established by an
250 # earlier matching rule. Repeated owners do not change semantics, so
251 # preserve order while removing duplicates.
252 unique_owners = tuple(dict.fromkeys(owners))
253 rules.append(CodeownersRule(pattern=pattern, owners=unique_owners, line_number=line_number))
255 if issues:
256 raise CodeownersParseError(tuple(issues))
257 return CodeownersDocument(tuple(rules))