Coverage for src/pullapprove/trust/delta.py: 100%

47 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-02 10:35 -0500

1"""The shared engine for token-delta trust rules (`_style`, `_spacing`, 

2`_type_annotations`). 

3 

4The rules answer the same question — "do these paired lines differ *only* in a 

5way I recognize as trivial?" — and must apply the same default-deny gates to 

6answer it safely: equal add/remove counts, no indentation change (semantic in 

7Python), and a clean tokenization of both sides. If any gate is centralized 

8inconsistently, a rule can start hiding real changes. So the gates live here, 

9once, and each rule supplies only its distinct comparison of one line pair. 

10""" 

11 

12from __future__ import annotations 

13 

14from collections.abc import Callable 

15 

16from ..diff import DiffCode, DiffHunk 

17from .helpers import leading_indent 

18from .tokens import Token, tokenize 

19 

20# Compare one paired (old, new) line: True if it differs ONLY in this rule's 

21# trivial way, False if the rule sees no difference, None to decline (a 

22# non-trivial change — default-deny). Receives the raw pair alongside its tokens 

23# because some rules also need the source text (`_spacing`'s gap/tail checks). 

24ComparePair = Callable[[DiffCode, DiffCode, list[Token], list[Token]], bool | None] 

25 

26 

27def paired_changed_lines(hunk: DiffHunk) -> list[tuple[DiffCode, DiffCode]] | None: 

28 """The hunk's changed lines as POSITIONALLY aligned (old, new) pairs, or None 

29 when they don't align. 

30 

31 Pairing is per replacement block: each contiguous run of changes must be 

32 deletions immediately followed by an equal number of additions, and the 

33 pairs are taken within that block. Counting alone isn't enough — a line 

34 moved across an intervening context line (a lone `-` here, its `+` after 

35 the context) changes execution order, so blocks that don't pair up decline 

36 rather than letting a positional shuffle read as a trivial delta. 

37 

38 Cached on the hunk: four rules (`_style`, `_spacing`, 

39 `_inline_comment_change`, `_type_annotations`) each pair the same lines as 

40 the rule chain walks — the same reasoning as DiffHunk's cached 

41 `changed_lines`/`added_lines`. The cache returns the SAME list to every 

42 caller — treat it as read-only. 

43 """ 

44 try: 

45 return hunk._paired_changed_lines # ty: ignore[unresolved-attribute] 

46 except AttributeError: 

47 pairs = _pair_changed_lines(hunk) 

48 hunk._paired_changed_lines = pairs # ty: ignore[unresolved-attribute] 

49 return pairs 

50 

51 

52def _pair_changed_lines(hunk: DiffHunk) -> list[tuple[DiffCode, DiffCode]] | None: 

53 pairs: list[tuple[DiffCode, DiffCode]] = [] 

54 lines = hunk.lines 

55 i = 0 

56 while i < len(lines): 

57 if lines[i].is_context(): 

58 i += 1 

59 continue 

60 deletions: list[DiffCode] = [] 

61 while i < len(lines) and lines[i].is_deletion(): 

62 deletions.append(lines[i]) 

63 i += 1 

64 additions: list[DiffCode] = [] 

65 while i < len(lines) and lines[i].is_addition(): 

66 additions.append(lines[i]) 

67 i += 1 

68 if not deletions or len(deletions) != len(additions): 

69 return None # an unpaired block — an insertion, removal, or move 

70 pairs.extend(zip(deletions, additions)) 

71 return pairs or None 

72 

73 

74def paired_token_delta( 

75 pairs: list[tuple[DiffCode, DiffCode]], ext: str, compare: ComparePair 

76) -> bool: 

77 """True if the paired lines earn this rule's label: every pair differs only 

78 in trivial tokens, with at least one that does. 

79 

80 Applies the gates every token-delta rule shares — no indentation change 

81 (semantic in Python), a clean tokenization of both sides — and returns False 

82 the moment any gate fails or `compare` rejects a pair, so an unrecognized 

83 change is never trusted. `pairs` comes from `paired_changed_lines`, which 

84 enforces the positional-alignment gate. 

85 """ 

86 any_changed = False 

87 for old, new in pairs: 

88 if leading_indent(old.content) != leading_indent(new.content): 

89 return False # indentation is semantic (Python) — a real change 

90 old_tokens = tokenize(old.content.strip(), ext) 

91 new_tokens = tokenize(new.content.strip(), ext) 

92 if old_tokens is None or new_tokens is None: 

93 return False # a multi-line fragment — can't judge in isolation 

94 verdict = compare(old, new, old_tokens, new_tokens) 

95 if verdict is None: 

96 return False # a non-trivial token changed — a real edit 

97 any_changed = any_changed or verdict 

98 return any_changed