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

1"""Trust rule: paired lines whose only change is type annotations. 

2 

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. 

6 

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""" 

15 

16from __future__ import annotations 

17 

18import difflib 

19import keyword 

20from itertools import pairwise 

21 

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 

27 

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")) 

32 

33Span = tuple[int, int] # a [start, end) char range covering an annotation 

34 

35 

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) 

58 

59 

60# --- Python --- 

61 

62 

63def _py_var_spans(tokens: list[Token]) -> list[Span]: 

64 """The `: TYPE` span of a `NAME: TYPE = value` annotated ASSIGNMENT. 

65 

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 

81 

82 

83def _py_def_spans(tokens: list[Token]) -> list[Span]: 

84 """The param (`a: T`) and return (`-> T`) annotation spans of a def line. 

85 

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 

115 

116 

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 [] 

134 

135 

136# --- TypeScript --- 

137 

138 

139def _ts_var_span(tokens: list[Token]) -> list[Span]: 

140 """The `: TYPE` span of a `const/let/var NAME: TYPE` declaration. 

141 

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 [] 

161 

162 

163def _ts_param_spans(tokens: list[Token]) -> list[Span]: 

164 """The `a: T` param annotation spans of a `function` signature. 

165 

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 

184 

185 

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 [] 

192 

193 

194# --- Delta classifier + the rule --- 

195 

196 

197def _span_has_call(tokens: list[Token], start: int, end: int) -> bool: 

198 """Does this annotation span contain a call — `NAME(`, `)(`, or `](`? 

199 

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 

217 

218 

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 ] 

231 

232 

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 

252 

253 

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