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
« 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`).
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"""
12from __future__ import annotations
14from collections.abc import Callable
16from ..diff import DiffCode, DiffHunk
17from .helpers import leading_indent
18from .tokens import Token, tokenize
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]
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.
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.
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
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
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.
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