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
« 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.
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
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
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"))
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
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)
62# --- Python ---
65def _py_var_spans(tokens: list[Token]) -> list[Span]:
66 """The `: TYPE` span of a `NAME: TYPE = value` annotated ASSIGNMENT.
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
85def _py_def_spans(tokens: list[Token]) -> list[Span]:
86 """The param (`a: T`) and return (`-> T`) annotation spans of a def line.
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
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 []
138# --- TypeScript ---
141def _ts_var_span(tokens: list[Token]) -> list[Span]:
142 """The `: TYPE` span of a `const/let/var NAME: TYPE` declaration.
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 []
165def _ts_param_spans(tokens: list[Token]) -> list[Span]:
166 """The `a: T` param annotation spans of a `function` signature.
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
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 []
196# --- Delta classifier + the rule ---
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 ]
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
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