Coverage for src/pullapprove/trust/formatting.py: 98%

188 statements  

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

1"""Trust rules: changes that don't alter meaning — empty new files, whitespace, 

2line wrapping, inter-token spacing, and punctuation/quote style.""" 

3 

4from __future__ import annotations 

5 

6from itertools import pairwise 

7 

8from ..diff import DiffCode, DiffFile, DiffHunk 

9from .delta import paired_changed_lines, paired_token_delta 

10from .helpers import extension, leading_indent 

11from .labels import Trust 

12from .linescan import collapse_ws_outside_strings, consume_string 

13from .tokens import OP, STRING, Token 

14 

15# The deeply-tested core the content rules (`_style`, `_line_length`) share: the 

16# JS/TS family + Python. These have free-form whitespace (so a reflow is neutral) 

17# and use `'`/`"`/`;`/`,` interchangeably enough for `_style` — and they're the 

18# ones we've actually validated. Other brace languages (Go, Rust, Java, C, …) are 

19# plausible but unverified per-language — add them here only with a test corpus. 

20# Data/markup (csv, tsv, yaml, md, …) stays out entirely: there a newline, comma, 

21# or quote can be semantic, so these rules would hide real changes. 

22_CORE_CONTENT_LANGUAGES = frozenset("js jsx ts tsx mjs mts cjs cts py".split()) 

23 

24 

25def _empty_file(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

26 # A brand-new file's hunk is all additions, so "empty" is just "all blank". 

27 if not hunk.is_new_file: 

28 return None 

29 if all(not line.content.strip() for line in hunk.lines): 

30 return Trust.EMPTY_FILE 

31 return None 

32 

33 

34def _whitespace(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

35 # Same language gate as the other content rules: in data/markup a blank line 

36 # is often semantic (a Markdown paragraph break, a line inside a YAML block 

37 # scalar), so only the validated code languages qualify. 

38 if extension(file) not in _CORE_CONTENT_LANGUAGES: 

39 return None 

40 # KNOWN LIMITATION: a blank-line-only change *inside* a multi-line string 

41 # literal (a `'''…'''` docstring, a template literal, a heredoc) changes the 

42 # string's value, but reads as trivial here. We can't tell from a single hunk 

43 # whether a blank line falls inside a literal — the opening delimiter is 

44 # usually above the hunk, out of view — so this rare case is accepted rather 

45 # than guarded with an unreliable heuristic. 

46 changed = hunk.changed_lines 

47 if changed and all(not line.content.strip() for line in changed): 

48 return Trust.WHITESPACE 

49 return None 

50 

51 

52# A wrapped line "continues" to the next when it ends inside an open bracket, 

53# with a `\`, or on an operator / opener / comma / dot / colon — never on an 

54# identifier, value, closer, or `;`. That separates a real reflow from two 

55# statements joined (a Python suite boundary, a JS ASI point), where the newline 

56# is semantic and joining changes meaning. 

57_CONTINUATION_CHARS = frozenset("=+-*/%<>&|^~,.:?([{") 

58 

59 

60def _bracket_delta(text: str) -> int: 

61 """Net bracket-depth change of `text`, ignoring brackets inside strings.""" 

62 depth = 0 

63 i, n = 0, len(text) 

64 while i < n: 

65 ch = text[i] 

66 if ch in "\"'`": 

67 i = consume_string(text, i) 

68 continue 

69 if ch in "([{": 

70 depth += 1 

71 elif ch in ")]}": 

72 depth -= 1 

73 i += 1 

74 return depth 

75 

76 

77def _is_reflow(lines: list[DiffCode]) -> bool: 

78 """True if `lines` is one statement wrapped across lines: every break (all but 

79 the last) sits inside an open bracket, ends with `\\`, or ends on a 

80 continuation operator. Otherwise the join crosses a statement boundary and the 

81 newline is not neutral.""" 

82 depth = 0 

83 for line in lines[:-1]: 

84 stripped = line.content.rstrip() 

85 depth += _bracket_delta(line.content) 

86 if ( 

87 depth > 0 

88 or stripped.endswith("\\") 

89 or (stripped and stripped[-1] in _CONTINUATION_CHARS) 

90 ): 

91 continue 

92 return False 

93 return True 

94 

95 

96def _contiguous_replacement(hunk: DiffHunk) -> bool: 

97 """True when the hunk's changed lines form ONE deletions-then-additions block 

98 with no context line inside it. A reflow rewraps one statement in place; 

99 deletions that bracket a context line mean code crossed a statement boundary, 

100 and joining the sides would compare a reordering as if it were a rewrap.""" 

101 seen_deletion = False 

102 seen_addition = False 

103 done = False 

104 for line in hunk.lines: 

105 if line.is_context(): 

106 if seen_deletion or seen_addition: 

107 done = True 

108 continue 

109 if done: 

110 return False # a second changed block — not one replacement 

111 if line.is_deletion(): 

112 if seen_addition: 

113 return False # deletions after additions — interleaved blocks 

114 seen_deletion = True 

115 else: 

116 seen_addition = True 

117 return True 

118 

119 

120def _has_line_comment(text: str, prefix: str, quotes: str) -> bool: 

121 """True if `text` starts a line comment outside a string literal. Unlike 

122 `strip_inline_comment`, no whitespace-before-prefix requirement: for reflow 

123 safety a glued comment (`foo()// note`) swallows a join just the same.""" 

124 i, n = 0, len(text) 

125 while i < n: 

126 if text[i] in quotes: 

127 i = consume_string(text, i) 

128 elif text.startswith(prefix, i): 

129 return True 

130 else: 

131 i += 1 

132 return False 

133 

134 

135def _line_length(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

136 """Code wrapped/unwrapped across lines: identical content after joining.""" 

137 # In data/markup a newline separates records, so joining two lines isn't neutral. 

138 ext = extension(file) 

139 if ext not in _CORE_CONTENT_LANGUAGES: 

140 return None 

141 added, removed = hunk.added_lines, hunk.removed_lines 

142 if not added or not removed: 

143 return None 

144 # Wrapping/unwrapping changes the line count; an equal-count edit is a 

145 # same-shape change (handled by _style), not a reflow. 

146 if len(added) == len(removed): 

147 return None 

148 # The joined comparison below flattens line positions, so it's only sound 

149 # when the change is one contiguous replacement — deletions bracketing a 

150 # context line would let moved code read as a neutral rewrap. 

151 if not _contiguous_replacement(hunk): 

152 return None 

153 # Default-deny: a neutral reflow requires every break on BOTH sides to be a 

154 # continuation (inside brackets / `\` / an operator). A break anywhere at 

155 # statement level means the newline is semantic (a Python suite, a JS ASI 

156 # point), so the "reflow" isn't neutral — decline rather than hide it. 

157 if not (_is_reflow(added) and _is_reflow(removed)): 

158 return None 

159 # Indentation is semantic in Python (block scope); a reflow that also shifts 

160 # the statement's own indent is a dedent/indent, not a neutral wrap. Only the 

161 # FIRST line carries the statement's indent (continuation lines are naturally 

162 # re-indented), and `collapse_ws_outside_strings` strips leading space — so 

163 # without this guard an indent change would compare equal and be hidden. 

164 if leading_indent(added[0].content) != leading_indent(removed[0].content): 

165 return None 

166 # A line comment runs to end-of-line, so joining a commented line with the 

167 # next drags the next line's code INTO the comment (`foo(a, // why` + `b)` 

168 # joins to a line where `b)` is comment text). Even when the joined texts 

169 # compare equal, that join is never neutral — decline if any line but the 

170 # last starts a line comment. A comment on the LAST line is fine (nothing is 

171 # joined after it), and block comments are fine (`/* … */` spans the join 

172 # unchanged either way). 

173 prefix = "#" if ext == "py" else "//" 

174 quotes = "\"'" if ext == "py" else "\"'`" 

175 for side in (added, removed): 

176 if any(_has_line_comment(line.content, prefix, quotes) for line in side[:-1]): 

177 return None 

178 # Collapse only the whitespace *between* tokens — preserving string interiors 

179 # so a real edit inside a literal can't masquerade as a reflow. 

180 joined_added = collapse_ws_outside_strings(" ".join(line.content for line in added)) 

181 joined_removed = collapse_ws_outside_strings( 

182 " ".join(line.content for line in removed) 

183 ) 

184 if joined_added and joined_added == joined_removed: 

185 return Trust.LINE_LENGTH 

186 return None 

187 

188 

189def _string_value(text: str) -> str: 

190 """The value of a `'`/`"` string literal, delimiter-agnostic — so `'a'` and 

191 `"a"` compare equal but a change to the *contents* does not. Delimiter escapes 

192 are normalized (`'it\\'s'` and `"it's"` both decode to `it's`); a backtick 

193 template literal is returned verbatim (its semantics aren't delimiter-style).""" 

194 quote = text[0] 

195 if quote not in "\"'": 

196 return text 

197 return text[1:-1].replace("\\" + quote, quote) 

198 

199 

200def _style_key( 

201 tokens: list[Token], collapse_comma: bool, collapse_semicolon: bool 

202) -> list[object]: 

203 """A token key in which only *stylistic* differences collapse: quote-delimiter 

204 style (strings compare by decoded value), plus a trailing `,`/`;` when 

205 `collapse_comma`/`collapse_semicolon` say it is stylistic here. Everything else 

206 — identifiers, operators, numbers, string *contents* — is compared verbatim, so 

207 a real change survives. 

208 

209 Trailing punctuation isn't always stylistic: a Python trailing comma builds a 

210 tuple (`(x,)`, `x = a,`, `a[1,]`), and a JS/TS trailing semicolon can be 

211 ASI-load-bearing. In those cases the flag is False and the punctuation stays in 

212 the key, so the change is never hidden as style.""" 

213 key: list[object] = [] 

214 n = len(tokens) 

215 for i, tok in enumerate(tokens): 

216 if collapse_semicolon and tok.kind == OP and tok.text == ";" and i == n - 1: 

217 continue # trailing semicolon 

218 if ( 

219 collapse_comma 

220 and tok.kind == OP 

221 and tok.text == "," 

222 and ( 

223 i == n - 1 or (tokens[i + 1].kind == OP and tokens[i + 1].text in ")]}") 

224 ) 

225 ): 

226 continue # trailing comma (line end, or before a closer) 

227 # Tag string values so a literal can't collide with an identifier of the 

228 # same text (a `'foo'` -> `foo` change must stay visible). 

229 key.append(("str", _string_value(tok.text)) if tok.kind == STRING else tok.text) 

230 return key 

231 

232 

233def _style_delta( 

234 old_tokens: list[Token], 

235 new_tokens: list[Token], 

236 collapse_comma: bool, 

237 collapse_semicolon: bool, 

238) -> bool | None: 

239 """Compare one line pair: True if it differs ONLY in style (semicolons / 

240 trailing commas / quote delimiters), False if token-identical, None to 

241 decline (a non-style difference remains).""" 

242 old_key = _style_key(old_tokens, collapse_comma, collapse_semicolon) 

243 new_key = _style_key(new_tokens, collapse_comma, collapse_semicolon) 

244 if old_key != new_key: 

245 return None # a non-style difference remains 

246 # Style-only iff the tokens themselves differ (a quote delimiter or trailing 

247 # punctuation). If the raw tokens match, any remaining difference is in a 

248 # comment or whitespace the tokenizer drops — not this rule's job (leave it 

249 # for _comments), so don't claim it as style. 

250 return [t.text for t in old_tokens] != [t.text for t in new_tokens] 

251 

252 

253# Line-leading characters that, under JS/TS Automatic Semicolon Insertion, 

254# continue the previous statement rather than start a new one. A leading `/` is 

255# also a continuation (regex literal or division) but is handled separately, since 

256# `//` and `/*` start comments, not statements. 

257_ASI_LEADERS = frozenset(("(", "[", "`", "+", "-")) 

258 

259 

260def _continues_previous_line(text: str) -> bool: 

261 """True if `text` begins with a token that, under JS/TS ASI, attaches to the 

262 previous line instead of starting a new statement.""" 

263 stripped = text.lstrip() 

264 if not stripped: 

265 return False 

266 if stripped[0] in _ASI_LEADERS: 

267 return True 

268 # A leading `/` is a regex literal or division that continues the line above — 

269 # unless it opens a comment (`//`, `/*`), which is inert. 

270 return stripped[0] == "/" and not stripped.startswith(("//", "/*")) 

271 

272 

273def _asi_hazard(hunk: DiffHunk) -> bool: 

274 """True if the hunk's new-file side has a statement line that continues the 

275 previous non-blank line under JS/TS Automatic Semicolon Insertion. There, 

276 adding or removing a trailing `;` on the line above merges or splits two 

277 statements — a real behavior change — so `_style` must not treat that `;` as 

278 stylistic. ASI ignores blank lines, so we compare against the last non-blank 

279 line, not the strictly-adjacent one. 

280 

281 When only blank lines are visible after the last changed line, the statement 

282 ASI would merge with sits past the context window — the scan can't prove the 

283 `;` inert, so that counts as a hazard too (default-deny). A hunk that ENDS on 

284 the changed line is different: git always emits trailing context when lines 

285 exist, so that's the end of the file and there is nothing to merge with.""" 

286 prev_nonblank = "" 

287 last_nonblank_changed = False 

288 blanks_after = False 

289 for line in hunk.lines: 

290 if line.is_deletion(): 

291 continue 

292 if prev_nonblank and _continues_previous_line(line.content): 

293 return True 

294 if line.content.strip(): 

295 prev_nonblank = line.content 

296 last_nonblank_changed = line.is_addition() 

297 blanks_after = False 

298 else: 

299 blanks_after = True 

300 return last_nonblank_changed and blanks_after 

301 

302 

303def _style(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

304 """Paired lines that differ only in semicolons, quotes, or trailing commas. 

305 

306 Python tokenizes under the `py` dialect and the JS family under `ts` — they 

307 can't share one dialect, because `//` is a line comment in JS but floor 

308 DIVISION in Python (tokenizing `x // 2` as `ts` would drop `// 2` as a comment 

309 and hide a divisor change). The two semantic splits — a Python trailing comma 

310 builds a tuple, a JS/TS trailing semicolon can be ASI-load-bearing — are 

311 handled by `collapse_comma`/`collapse_semicolon`.""" 

312 ext = extension(file) 

313 if ext not in _CORE_CONTENT_LANGUAGES: 

314 return None # elsewhere ';'/','/quotes may be semantic (data, char literals) 

315 # The positional pairing gate runs first, so the O(hunk-lines) ASI scan 

316 # below never runs for hunks the pairing would reject anyway. 

317 pairs = paired_changed_lines(hunk) 

318 if pairs is None: 

319 return None 

320 dialect = "py" if ext == "py" else "ts" 

321 collapse_comma = ext != "py" # a Python trailing comma builds a tuple 

322 # Python has no ASI, so its `;` is always stylistic; in JS/TS a trailing `;` is 

323 # only stylistic when no following line continues the statement. 

324 collapse_semicolon = ext == "py" or not _asi_hazard(hunk) 

325 return ( 

326 Trust.STYLE 

327 if paired_token_delta( 

328 pairs, 

329 dialect, 

330 lambda old, new, o, n: _style_delta( 

331 o, n, collapse_comma, collapse_semicolon 

332 ), 

333 ) 

334 else None 

335 ) 

336 

337 

338def _whitespace_gaps_only(stripped: str, tokens: list[Token]) -> bool: 

339 """True if everything between consecutive tokens is whitespace. The tokenizer 

340 silently skips block comments, so without this check a mid-line `/* … */` 

341 edit would compare token-identical and read as a spacing change.""" 

342 pos = 0 

343 for tok in tokens: 

344 if stripped[pos : tok.start].strip(): 

345 return False 

346 pos = tok.end 

347 return True 

348 

349 

350def _line_tail(stripped: str, tokens: list[Token]) -> str: 

351 """The text after the last token — a trailing line comment, or empty.""" 

352 return stripped[tokens[-1].end :].strip() if tokens else stripped 

353 

354 

355def _spacing_delta( 

356 old: DiffCode, 

357 new: DiffCode, 

358 old_tokens: list[Token], 

359 new_tokens: list[Token], 

360 dialect: str, 

361) -> bool | None: 

362 """Compare one line pair: True if only the whitespace between or after the 

363 tokens moved, False if the lines are identical, None to decline (something 

364 other than inter-token whitespace changed).""" 

365 if [t.text for t in old_tokens] != [t.text for t in new_tokens]: 

366 return None # a token changed — not a whitespace-only edit 

367 # Token offsets index into the stripped line (what `paired_token_delta` 

368 # tokenized), so the gap/tail checks scan that same text. 

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

370 if not _whitespace_gaps_only(old_stripped, old_tokens): 

371 return None # a mid-line block comment sits in a gap 

372 if not _whitespace_gaps_only(new_stripped, new_tokens): 

373 return None 

374 if _line_tail(old_stripped, old_tokens) != _line_tail(new_stripped, new_tokens): 

375 return None # the trailing comment changed — _comments' territory 

376 for (o1, o2), (n1, n2) in zip(pairwise(old_tokens), pairwise(new_tokens)): 

377 if (o1.end == o2.start) == (n1.end == n2.start): 

378 continue # this pair's grouping didn't change 

379 if (o1.kind == OP) == (o2.kind == OP): 

380 return None # regrouped punctuation / word+string — semantic 

381 if dialect == "ts" and (o1.text in "/<>" or o2.text in "/<>"): 

382 return None # regex / generic ambiguity — spacing decides parse 

383 return old.content != new.content 

384 

385 

386def _spacing(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

387 """Paired lines whose tokens are identical and only the whitespace between or 

388 after them moved — `x=1` -> `x = 1`, `f( a )` -> `f(a)`, trailing-whitespace 

389 trims, alignment shifts. 

390 

391 Whitespace inside a string literal is a value change and never qualifies 

392 (string tokens compare verbatim). Two default-deny guards beyond the shared 

393 pairing/indent/tokenize gates in `delta.py`: 

394 

395 - Regrouping punctuation is semantic even with identical token texts 

396 (`a + +b` vs `a ++ b` in JS, `a ** b` vs `a * * b` in Python), and so is 

397 gluing a word to a string (`f "x"` vs f-string `f"x"`) — a glue change is 

398 only trusted when exactly one side of the touching pair is punctuation. 

399 In JS/TS, `/`, `<`, and `>` never qualify at all: spacing decides whether 

400 they read as regex-vs-division or generic-vs-comparison. 

401 - The tokenizer skips comments, so the text after the last token (a trailing 

402 line comment) must match exactly and interior gaps must be pure whitespace; 

403 otherwise a comment edit would read as spacing. Comment-only changes stay 

404 `_comments`' job (this rule declines and falls through). 

405 """ 

406 ext = extension(file) 

407 if ext not in _CORE_CONTENT_LANGUAGES: 

408 return None 

409 pairs = paired_changed_lines(hunk) 

410 if pairs is None: 

411 return None 

412 dialect = "py" if ext == "py" else "ts" 

413 return ( 

414 Trust.SPACING 

415 if paired_token_delta( 

416 pairs, 

417 dialect, 

418 lambda old, new, o, n: _spacing_delta(old, new, o, n, dialect), 

419 ) 

420 else None 

421 )