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
« 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`).
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 _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.
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())
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.
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