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

58 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-07-28 14:54 -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 _non_token_text(stripped: str, tokens: list[Token]) -> str: 

75 """The line's non-token text — the comments the tokenizer silently drops — 

76 with all whitespace removed. 

77 

78 The tokenizer skips line and block comments, so two lines that differ ONLY 

79 in a comment tokenize identically. Without this, a tool directive hidden in 

80 a comment (`# noqa`, `# type: ignore[…]`, `/* eslint-disable no-eval */`, 

81 `/* webpackChunkName */`) could ride along invisibly behind a trivial token 

82 delta (a quote swap, an annotation edit) and be hidden from review. A 

83 comment change belongs to `_comments` (which declines tool directives), so 

84 the token-delta rules must decline when this text changes. Whitespace is 

85 stripped so `_spacing`'s legitimate whitespace moves don't register.""" 

86 parts: list[str] = [] 

87 pos = 0 

88 for tok in tokens: 

89 parts.append(stripped[pos : tok.start]) 

90 pos = tok.end 

91 parts.append(stripped[pos:]) 

92 return "".join("".join(parts).split()) 

93 

94 

95def paired_token_delta( 

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

97) -> bool: 

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

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

100 

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

102 (semantic in Python), a clean tokenization of both sides, and no change to 

103 the comments the tokenizer drops — and returns False the moment any gate 

104 fails or `compare` rejects a pair, so an unrecognized change is never 

105 trusted. `pairs` comes from `paired_changed_lines`, which enforces the 

106 positional-alignment gate. 

107 """ 

108 any_changed = False 

109 for old, new in pairs: 

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

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

112 old_stripped, new_stripped = old.content.strip(), new.content.strip() 

113 old_tokens = tokenize(old_stripped, ext) 

114 new_tokens = tokenize(new_stripped, ext) 

115 if old_tokens is None or new_tokens is None: 

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

117 if _non_token_text(old_stripped, old_tokens) != _non_token_text( 

118 new_stripped, new_tokens 

119 ): 

120 return False # a dropped comment/directive changed — _comments' job 

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

122 if verdict is None: 

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

124 any_changed = any_changed or verdict 

125 return any_changed