Coverage for src/pullapprove/trust/annotations.py: 95%

130 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-01 18:26 -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 

20 

21from ..diff import DiffFile, DiffHunk 

22from .delta import paired_changed_lines, paired_token_delta 

23from .helpers import extension 

24from .labels import Trust 

25from .tokens import CLOSE_BRACKETS, OP, OPEN_BRACKETS, WORD, Token 

26 

27# Narrower than the style/reflow core: only the languages the tokenizer models 

28# with an annotation grammar. Must stay a subset of `tokens._COMMENTS` keys — a 

29# lang added here without a tokenizer entry would silently mis-lex, not decline. 

30_ANNOTATION_LANGS = frozenset(("py", "ts", "tsx")) 

31 

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

33# The tokenizer's bracket vocabulary — imported, not redeclared, so span-walking 

34# and tokenization can never disagree about nesting. 

35_OPEN, _CLOSE = OPEN_BRACKETS, CLOSE_BRACKETS 

36 

37 

38def _type_span_to(tokens: list[Token], colon: int, stops: tuple[str, ...]) -> Span: 

39 """Span from just before `tokens[colon]` (the `:`/`->`) to the end of the type 

40 that follows — the type ends at a top-level token in `stops`, at the closer of 

41 the bracket it sits inside, or at the line's end. Shared by every annotation 

42 context (Python params/returns/vars, TS params/vars).""" 

43 start = tokens[colon - 1].end 

44 depth = 0 

45 type_end = tokens[colon].end 

46 j, n = colon + 1, len(tokens) 

47 while j < n: 

48 tk = tokens[j] 

49 if depth == 0 and tk.kind == OP and tk.text in stops: 

50 break 

51 if tk.kind == OP and tk.text in _OPEN: 

52 depth += 1 

53 elif tk.kind == OP and tk.text in _CLOSE: 

54 if depth == 0: 

55 break 

56 depth -= 1 

57 type_end = tk.end 

58 j += 1 

59 return (start, type_end) 

60 

61 

62# --- Python --- 

63 

64 

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

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

67 

68 Requires a top-level `=`: a bare `NAME: X` (no assignment) is ambiguous — a 

69 statement-level type declaration vs a dict/keyed entry whose `{` opened on an 

70 earlier line — and stripping a dict value would hide a real data change, so we 

71 leave a bare colon alone. The span runs from just after NAME to the end of the 

72 last type token before the `=`.""" 

73 depth = 0 

74 for i in range(2, len(tokens)): 

75 t = tokens[i] 

76 if t.kind == OP and t.text in _OPEN: 

77 depth += 1 

78 elif t.kind == OP and t.text in _CLOSE: 

79 depth -= 1 

80 elif depth == 0 and t.kind == OP and t.text == "=": 

81 return [(tokens[0].end, tokens[i - 1].end)] 

82 return [] # no top-level '=' -> bare/ambiguous annotation, leave it 

83 

84 

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

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

87 

88 A parameter type runs to the next top-level `,` or the closing `)`; a return 

89 type runs to the suite `:` — both computed by the shared `_type_span_to`. 

90 Bails (strips nothing) if the signature contains a `lambda`, whose body colon 

91 would otherwise read as a param annotation. Colons only count as parameter 

92 annotations INSIDE the parameter parens: once they close, a depth-1 colon is 

93 in some other bracket on the line (a dict or slice in a one-line def body, 

94 e.g. `def f(): return {"timeout": 30}`) and must stay unflagged so a value 

95 change there is never trusted.""" 

96 if any(t.kind == WORD and t.text == "lambda" for t in tokens): 

97 return [] 

98 spans: list[Span] = [] 

99 depth = 0 

100 params_closed = False 

101 for i, t in enumerate(tokens): 

102 if t.kind != OP: 

103 continue 

104 if t.text == ":" and depth == 1 and not params_closed: 

105 # a parameter annotation — stop at the next param `,`, the closing `)`, 

106 # or a default-value `=` (else the default would be swallowed too) 

107 spans.append(_type_span_to(tokens, i, (",", "="))) 

108 elif t.text == "->" and depth == 0: 

109 spans.append(_type_span_to(tokens, i, (":",))) # the return annotation 

110 elif t.text in _OPEN: 

111 depth += 1 

112 elif t.text in _CLOSE: 

113 depth -= 1 

114 if depth == 0: 

115 params_closed = True # the def's parameter list has ended 

116 return spans 

117 

118 

119def _py_spans(tokens: list[Token]) -> list[Span]: 

120 if not tokens: 

121 return [] 

122 first = tokens[0] 

123 if first.kind == WORD and first.text in ("def", "async"): 

124 return _py_def_spans(tokens) 

125 # An annotated assignment `NAME: TYPE`: a leading identifier (not a keyword, 

126 # so not `if`/`for`/`lambda`/…) immediately followed by a colon. 

127 if ( 

128 first.kind == WORD 

129 and not keyword.iskeyword(first.text) 

130 and len(tokens) >= 2 

131 and tokens[1].kind == OP 

132 and tokens[1].text == ":" 

133 ): 

134 return _py_var_spans(tokens) 

135 return [] 

136 

137 

138# --- TypeScript --- 

139 

140 

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

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

143 

144 The span stops at a top-level `,` as well as `=`: in a multi-declarator 

145 statement (`let x: number, other: string`) the comma ends the first 

146 declarator, and running past it would flag the NEXT declarator's name as 

147 annotation — letting a rename hide as a type change.""" 

148 if not ( 

149 tokens and tokens[0].kind == WORD and tokens[0].text in ("const", "let", "var") 

150 ): 

151 return [] 

152 depth = 0 

153 for i, t in enumerate(tokens): 

154 if t.kind == OP and t.text in _OPEN: 

155 depth += 1 

156 elif t.kind == OP and t.text in _CLOSE: 

157 depth -= 1 

158 elif depth == 0 and t.kind == OP and t.text == "=": 

159 return [] # a value assignment reached before any annotation 

160 elif depth == 0 and t.kind == OP and t.text == ":": 

161 return [_type_span_to(tokens, i, ("=", ","))] 

162 return [] 

163 

164 

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

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

167 

168 Bails on any `?` in the line — an optional marker (`a?: T`) or a ternary in a 

169 default value — rather than risk mistaking a ternary colon for an annotation.""" 

170 if any(t.kind == OP and t.text == "?" for t in tokens): 

171 return [] 

172 spans: list[Span] = [] 

173 stack: list[str] = [] 

174 i, n = 0, len(tokens) 

175 while i < n: 

176 t = tokens[i] 

177 if t.kind == OP and t.text in _OPEN: 

178 stack.append(t.text) 

179 elif t.kind == OP and t.text in _CLOSE: 

180 if stack: 

181 stack.pop() 

182 elif t.kind == OP and t.text == ":" and stack and stack[-1] == "(": 

183 spans.append(_type_span_to(tokens, i, (",", "="))) 

184 i += 1 

185 return spans 

186 

187 

188def _ts_spans(tokens: list[Token]) -> list[Span]: 

189 if var_span := _ts_var_span(tokens): 

190 return var_span 

191 if any(t.kind == WORD and t.text == "function" for t in tokens): 

192 return _ts_param_spans(tokens) 

193 return [] 

194 

195 

196# --- Delta classifier + the rule --- 

197 

198 

199def _annotation_token_flags(tokens: list[Token], ext: str) -> list[bool]: 

200 """Per token: does it lie within a type-annotation span (the `:`/`->` plus the 

201 type that follows)? A token is flagged only when it is *definitely* part of an 

202 annotation, so an unflagged token that changed always defeats the rule.""" 

203 spans = _py_spans(tokens) if ext == "py" else _ts_spans(tokens) 

204 return [ 

205 any(start <= tok.start and tok.end <= end for start, end in spans) 

206 for tok in tokens 

207 ] 

208 

209 

210def _annotation_delta( 

211 old_tokens: list[Token], new_tokens: list[Token], ext: str 

212) -> bool | None: 

213 """Compare one line pair: True if it differs ONLY in type-annotation tokens, 

214 False if token-identical, None if a non-annotation token changed. Every 

215 changed token, on either side, must fall inside a flagged annotation span.""" 

216 old_ann = _annotation_token_flags(old_tokens, ext) 

217 new_ann = _annotation_token_flags(new_tokens, ext) 

218 matcher = difflib.SequenceMatcher( 

219 a=[t.text for t in old_tokens], b=[t.text for t in new_tokens], autojunk=False 

220 ) 

221 changed = False 

222 for op, i1, i2, j1, j2 in matcher.get_opcodes(): 

223 if op == "equal": 

224 continue 

225 changed = True 

226 if not all(old_ann[i1:i2]) or not all(new_ann[j1:j2]): 

227 return None # a non-annotation token changed — a real edit 

228 return changed 

229 

230 

231def _type_annotations(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

232 """Paired lines whose entire token-level delta is type annotations.""" 

233 ext = extension(file) 

234 if ext not in _ANNOTATION_LANGS: 

235 return None 

236 pairs = paired_changed_lines(hunk) 

237 if pairs is None: 

238 return None 

239 changed = paired_token_delta( 

240 pairs, ext, lambda old, new, o, n: _annotation_delta(o, n, ext) 

241 ) 

242 return Trust.TYPE_ANNOTATIONS_MODIFIED if changed else None