Coverage for src/pullapprove/trust/annotations.py: 96%
137 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-28 14:54 -0500
« prev ^ index » next coverage.py v7.14.1, created at 2026-07-28 14:54 -0500
1"""Trust rule: paired lines whose only change is type annotations.
3Telling a type-annotation ``:`` from a suite / dict / ternary / lambda /
4object-value / case-label colon is a lexing question, so we tokenize the line
5(see `tokens.py`) and reason about token *roles*, not bytes.
7The rule uses a **delta** model: rather than erasing the "trivial" part and
8hoping the rest matches, it diffs the two sides' token sequences and trusts only
9when *every* changed token is flagged as part of an annotation — default-deny, so
10an unrecognized change is never hidden. Token roles come from the span detectors
11(`_py_spans`/`_ts_spans`), which flag a token only when it is *definitely* inside
12an annotation. The paired-line scaffolding it shares with `_style` (equal counts,
13indentation guard, tokenize-or-decline) lives in `delta.py`.
14"""
16from __future__ import annotations
18import difflib
19import keyword
20from itertools import pairwise
22from ..diff import DiffFile, DiffHunk
23from .delta import paired_changed_lines, paired_token_delta
24from .helpers import extension
25from .labels import Trust
26from .tokens import CLOSE_BRACKETS, OP, OPEN_BRACKETS, WORD, Token
28# Narrower than the style/reflow core: only the languages the tokenizer models
29# with an annotation grammar. Must stay a subset of `tokens._COMMENTS` keys — a
30# lang added here without a tokenizer entry would silently mis-lex, not decline.
31_ANNOTATION_LANGS = frozenset(("py", "ts", "tsx"))
33Span = tuple[int, int] # a [start, end) char range covering an annotation
36def _type_span_to(tokens: list[Token], colon: int, stops: tuple[str, ...]) -> Span:
37 """Span from just before `tokens[colon]` (the `:`/`->`) to the end of the type
38 that follows — the type ends at a top-level token in `stops`, at the closer of
39 the bracket it sits inside, or at the line's end. Shared by every annotation
40 context (Python params/returns/vars, TS params/vars)."""
41 start = tokens[colon - 1].end
42 depth = 0
43 type_end = tokens[colon].end
44 j, n = colon + 1, len(tokens)
45 while j < n:
46 tk = tokens[j]
47 if depth == 0 and tk.kind == OP and tk.text in stops:
48 break
49 if tk.kind == OP and tk.text in OPEN_BRACKETS:
50 depth += 1
51 elif tk.kind == OP and tk.text in CLOSE_BRACKETS:
52 if depth == 0:
53 break
54 depth -= 1
55 type_end = tk.end
56 j += 1
57 return (start, type_end)
60# --- Python ---
63def _py_var_spans(tokens: list[Token]) -> list[Span]:
64 """The `: TYPE` span of a `NAME: TYPE = value` annotated ASSIGNMENT.
66 Requires a top-level `=`: a bare `NAME: X` (no assignment) is ambiguous — a
67 statement-level type declaration vs a dict/keyed entry whose `{` opened on an
68 earlier line — and stripping a dict value would hide a real data change, so we
69 leave a bare colon alone. The span runs from just after NAME to the end of the
70 last type token before the `=`."""
71 depth = 0
72 for i in range(2, len(tokens)):
73 t = tokens[i]
74 if t.kind == OP and t.text in OPEN_BRACKETS:
75 depth += 1
76 elif t.kind == OP and t.text in CLOSE_BRACKETS:
77 depth -= 1
78 elif depth == 0 and t.kind == OP and t.text == "=":
79 return [(tokens[0].end, tokens[i - 1].end)]
80 return [] # no top-level '=' -> bare/ambiguous annotation, leave it
83def _py_def_spans(tokens: list[Token]) -> list[Span]:
84 """The param (`a: T`) and return (`-> T`) annotation spans of a def line.
86 A parameter type runs to the next top-level `,` or the closing `)`; a return
87 type runs to the suite `:` — both computed by the shared `_type_span_to`.
88 Bails (strips nothing) if the signature contains a `lambda`, whose body colon
89 would otherwise read as a param annotation. Colons only count as parameter
90 annotations INSIDE the parameter parens: once they close, a depth-1 colon is
91 in some other bracket on the line (a dict or slice in a one-line def body,
92 e.g. `def f(): return {"timeout": 30}`) and must stay unflagged so a value
93 change there is never trusted."""
94 if any(t.kind == WORD and t.text == "lambda" for t in tokens):
95 return []
96 spans: list[Span] = []
97 depth = 0
98 params_closed = False
99 for i, t in enumerate(tokens):
100 if t.kind != OP:
101 continue
102 if t.text == ":" and depth == 1 and not params_closed:
103 # a parameter annotation — stop at the next param `,`, the closing `)`,
104 # or a default-value `=` (else the default would be swallowed too)
105 spans.append(_type_span_to(tokens, i, (",", "=")))
106 elif t.text == "->" and depth == 0:
107 spans.append(_type_span_to(tokens, i, (":",))) # the return annotation
108 elif t.text in OPEN_BRACKETS:
109 depth += 1
110 elif t.text in CLOSE_BRACKETS:
111 depth -= 1
112 if depth == 0:
113 params_closed = True # the def's parameter list has ended
114 return spans
117def _py_spans(tokens: list[Token]) -> list[Span]:
118 if not tokens:
119 return []
120 first = tokens[0]
121 if first.kind == WORD and first.text in ("def", "async"):
122 return _py_def_spans(tokens)
123 # An annotated assignment `NAME: TYPE`: a leading identifier (not a keyword,
124 # so not `if`/`for`/`lambda`/…) immediately followed by a colon.
125 if (
126 first.kind == WORD
127 and not keyword.iskeyword(first.text)
128 and len(tokens) >= 2
129 and tokens[1].kind == OP
130 and tokens[1].text == ":"
131 ):
132 return _py_var_spans(tokens)
133 return []
136# --- TypeScript ---
139def _ts_var_span(tokens: list[Token]) -> list[Span]:
140 """The `: TYPE` span of a `const/let/var NAME: TYPE` declaration.
142 The span stops at a top-level `,` as well as `=`: in a multi-declarator
143 statement (`let x: number, other: string`) the comma ends the first
144 declarator, and running past it would flag the NEXT declarator's name as
145 annotation — letting a rename hide as a type change."""
146 if not (
147 tokens and tokens[0].kind == WORD and tokens[0].text in ("const", "let", "var")
148 ):
149 return []
150 depth = 0
151 for i, t in enumerate(tokens):
152 if t.kind == OP and t.text in OPEN_BRACKETS:
153 depth += 1
154 elif t.kind == OP and t.text in CLOSE_BRACKETS:
155 depth -= 1
156 elif depth == 0 and t.kind == OP and t.text == "=":
157 return [] # a value assignment reached before any annotation
158 elif depth == 0 and t.kind == OP and t.text == ":":
159 return [_type_span_to(tokens, i, ("=", ","))]
160 return []
163def _ts_param_spans(tokens: list[Token]) -> list[Span]:
164 """The `a: T` param annotation spans of a `function` signature.
166 Bails on any `?` in the line — an optional marker (`a?: T`) or a ternary in a
167 default value — rather than risk mistaking a ternary colon for an annotation."""
168 if any(t.kind == OP and t.text == "?" for t in tokens):
169 return []
170 spans: list[Span] = []
171 stack: list[str] = []
172 i, n = 0, len(tokens)
173 while i < n:
174 t = tokens[i]
175 if t.kind == OP and t.text in OPEN_BRACKETS:
176 stack.append(t.text)
177 elif t.kind == OP and t.text in CLOSE_BRACKETS:
178 if stack:
179 stack.pop()
180 elif t.kind == OP and t.text == ":" and stack and stack[-1] == "(":
181 spans.append(_type_span_to(tokens, i, (",", "=")))
182 i += 1
183 return spans
186def _ts_spans(tokens: list[Token]) -> list[Span]:
187 if var_span := _ts_var_span(tokens):
188 return var_span
189 if any(t.kind == WORD and t.text == "function" for t in tokens):
190 return _ts_param_spans(tokens)
191 return []
194# --- Delta classifier + the rule ---
197def _span_has_call(tokens: list[Token], start: int, end: int) -> bool:
198 """Does this annotation span contain a call — `NAME(`, `)(`, or `](`?
200 An annotation is trusted on the premise that it is runtime-inert (erasable
201 type info). That premise breaks when the type region contains a call: an
202 `Annotated[User, Depends(require_admin)]` dependency, a Pydantic
203 `Field(gt=0, le=1000)`, a `Query(max_length=50)` validator, or a
204 module-level annotation that is itself evaluated. Their arguments ARE
205 runtime behavior, so a change inside them (require_admin -> require_login,
206 le=1000 -> le=100000) is a real change, not a type edit — the span must not
207 be trusted."""
208 in_span = [t for t in tokens if start <= t.start and t.end <= end]
209 for prev, cur in pairwise(in_span):
210 if (
211 cur.kind == OP
212 and cur.text == "("
213 and (prev.kind == WORD or (prev.kind == OP and prev.text in ")]"))
214 ):
215 return True
216 return False
219def _annotation_token_flags(tokens: list[Token], ext: str) -> list[bool]:
220 """Per token: does it lie within a type-annotation span (the `:`/`->` plus the
221 type that follows)? A token is flagged only when it is *definitely* part of an
222 annotation, so an unflagged token that changed always defeats the rule."""
223 spans = _py_spans(tokens) if ext == "py" else _ts_spans(tokens)
224 # Drop any span containing a call — its arguments are runtime-active, so a
225 # change there is a real behavior change, not an erasable type edit.
226 spans = [s for s in spans if not _span_has_call(tokens, *s)]
227 return [
228 any(start <= tok.start and tok.end <= end for start, end in spans)
229 for tok in tokens
230 ]
233def _annotation_delta(
234 old_tokens: list[Token], new_tokens: list[Token], ext: str
235) -> bool | None:
236 """Compare one line pair: True if it differs ONLY in type-annotation tokens,
237 False if token-identical, None if a non-annotation token changed. Every
238 changed token, on either side, must fall inside a flagged annotation span."""
239 old_ann = _annotation_token_flags(old_tokens, ext)
240 new_ann = _annotation_token_flags(new_tokens, ext)
241 matcher = difflib.SequenceMatcher(
242 a=[t.text for t in old_tokens], b=[t.text for t in new_tokens], autojunk=False
243 )
244 changed = False
245 for op, i1, i2, j1, j2 in matcher.get_opcodes():
246 if op == "equal":
247 continue
248 changed = True
249 if not all(old_ann[i1:i2]) or not all(new_ann[j1:j2]):
250 return None # a non-annotation token changed — a real edit
251 return changed
254def _type_annotations(file: DiffFile, hunk: DiffHunk) -> Trust | None:
255 """Paired lines whose entire token-level delta is type annotations."""
256 ext = extension(file)
257 if ext not in _ANNOTATION_LANGS:
258 return None
259 pairs = paired_changed_lines(hunk)
260 if pairs is None:
261 return None
262 changed = paired_token_delta(
263 pairs, ext, lambda old, new, o, n: _annotation_delta(o, n, ext)
264 )
265 return Trust.TYPE_ANNOTATIONS_MODIFIED if changed else None