Coverage for src/pullapprove/trust/comments.py: 99%

102 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-08-19 16:21 -0500

1"""Trust rule: hunks that only touch comments, plus the comment-syntax tables.""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Callable 

6 

7from ..diff import DiffCode, DiffFile, DiffHunk 

8from .delta import paired_changed_lines 

9from .helpers import ( 

10 BLOCK_COMMENT_DELIMITERS, 

11 LINE_COMMENT_PREFIXES, 

12 change_suffix, 

13 extension, 

14 leading_indent, 

15) 

16from .labels import Trust 

17from .linescan import consume_string 

18 

19# Languages whose strings can be backtick-delimited (JS/TS template literals, Go 

20# raw strings) — without this a `//` inside one reads as a comment. 

21_BACKTICK_LANGS = frozenset( 

22 ["js", "jsx", "ts", "tsx", "mjs", "mts", "cjs", "cts", "go"] 

23) 

24 

25# Comments that DIRECT TOOLS rather than inform readers: linter suppressions, 

26# type-checker escapes, formatter/coverage toggles, compiler and bundler pragmas. 

27# Changing one changes how tools treat the surrounding code — a real edit, not 

28# comment churn — so a changed comment containing any of these is never trusted. 

29# Matched case-insensitively as substrings, deliberately loose: a prose comment 

30# that merely *mentions* a marker declines too, which only costs coverage (the 

31# hunk is shown), never hides a change. Start with the most common tools; extend 

32# as they come up. 

33_DIRECTIVE_MARKERS = ( 

34 # Python 

35 "noqa", # flake8/ruff suppression 

36 "pylint:", # pylint suppression/config 

37 "nopep8", # pep8/autopep8 suppression 

38 "skipcq", # DeepSource suppression 

39 "type: ignore", # mypy/pyright escape 

40 "pragma:", # coverage.py (pragma: no cover) 

41 "doctest:", # doctest directive (e.g. doctest: +SKIP) 

42 "fmt: off", # black/ruff formatter toggles 

43 "fmt: on", 

44 "fmt: skip", 

45 "yapf:", # yapf formatter toggle 

46 "ruff:", 

47 "mypy:", 

48 "pyright:", 

49 "pyre-ignore", # Meta's Pyre type checker 

50 "pyre-fixme", 

51 "pytype:", # Google's pytype type checker 

52 "isort:", 

53 "nosec", # bandit 

54 "coding:", # PEP 263 encoding declaration 

55 "coding=", 

56 # JS/TS 

57 "eslint-", # eslint-disable / -enable / -disable-next-line / -disable-line 

58 "@ts-", # @ts-ignore / @ts-expect-error / @ts-nocheck / @ts-check 

59 "prettier-ignore", 

60 "biome-ignore", 

61 "istanbul ignore", 

62 "c8 ignore", 

63 "v8 ignore", 

64 "sourcemappingurl", # //# sourceMappingURL=… 

65 "webpackchunkname", # webpack magic comments 

66 "webpackmode", 

67 # Go 

68 "go:build", 

69 "go:generate", 

70 "go:embed", 

71 "+build", 

72 "nolint", # golangci-lint; also covers clang-tidy's NOLINT 

73 # Ruby 

74 "rubocop:", 

75 "frozen_string_literal", 

76 "typed:", # sorbet 

77 # Shell 

78 "shellcheck", 

79 # C/C++ 

80 "clang-format", 

81) 

82 

83 

84def _has_directive(text: str) -> bool: 

85 lowered = text.lower() 

86 return any(marker in lowered for marker in _DIRECTIVE_MARKERS) 

87 

88 

89def _is_shebang(line: DiffCode) -> bool: 

90 """A `#!` interpreter line at file line 1 — execution semantics, not a 

91 comment (deeper in a file, `#!` is just comment text).""" 

92 return line.content.startswith("#!") and 1 in ( 

93 line.old_line_number, 

94 line.new_line_number, 

95 ) 

96 

97 

98def _string_quotes(ext: str) -> str: 

99 """The quote characters that open a string literal in this language.""" 

100 return "\"'`" if ext in _BACKTICK_LANGS else "\"'" 

101 

102 

103def strip_inline_comment( 

104 line: str, prefixes: tuple[str, ...], quotes: str = "\"'" 

105) -> str: 

106 """Drop a trailing line-comment, ignoring prefixes inside string literals. 

107 

108 A prefix only starts a comment at line-start or after whitespace — glued to a 

109 preceding token it is part of a value, not a comment (YAML/shell treat it that 

110 way, and a URL fragment `url: https://x/#frag` must not read as a `#` comment). 

111 Requiring the space is conservative elsewhere too: it can only *miss* a 

112 space-less comment like `x=1#c`, never hide a real change.""" 

113 text = line.strip() 

114 i, n = 0, len(text) 

115 while i < n: 

116 if text[i] in quotes: 

117 i = consume_string(text, i) 

118 elif text.startswith(prefixes, i) and (i == 0 or text[i - 1].isspace()): 

119 return text[:i].rstrip() 

120 else: 

121 i += 1 

122 return text 

123 

124 

125def _block_comment_step( 

126 text: str, delimiters: tuple[str, str], in_block: bool 

127) -> tuple[bool, bool]: 

128 """Whether *all* of `text` is block comment, and the new in-block state. 

129 

130 Walks the line so that code trailing a closed comment (`/* note */ run()`) 

131 is recognized as code, not swallowed by the comment. 

132 """ 

133 open_, close = delimiters 

134 text = text.strip() 

135 while True: 

136 if in_block: 

137 end = text.find(close) 

138 if end == -1: 

139 return True, True # comment runs on to the next line 

140 text = text[end + len(close) :].strip() 

141 in_block = False 

142 elif not text: 

143 return True, False # nothing but comment(s) on this line 

144 elif text.startswith(open_): 

145 text = text[len(open_) :] 

146 in_block = True 

147 else: 

148 return False, False # real code on this line 

149 

150 

151def _all_comment_lines( 

152 side_lines: list[DiffCode], 

153 changed: Callable[[DiffCode], bool], 

154 prefixes: tuple[str, ...] | None, 

155 block: tuple[str, str] | None, 

156) -> bool: 

157 """True if every changed, non-blank line on this SIDE is a line or block 

158 comment. `side_lines` is the hunk's full line sequence for one side of the 

159 file (context + this side's changes, in order), so block-comment state is 

160 tracked across the lines the way the file actually reads. 

161 

162 A block comment opened on a changed line must close before the next context 

163 line: if it's still open there, the unchanged code on that context line is 

164 now INSIDE the comment — code commented out is a semantic change, not 

165 comment churn. The same reasoning closes the bottom of the hunk (`in_block` 

166 at the end would swallow code below). 

167 

168 KNOWN LIMITATION (shared with `_whitespace`): a comment-prefix line that is 

169 actually *inside* a multi-line string literal (a JS template literal, a Python 

170 triple-quoted string) reads as a comment here, so editing it can be hidden as 

171 a comment change though the string's value changed. The opening delimiter is 

172 usually above the hunk, out of view, so we can't detect it from a single hunk; 

173 this rare case is accepted rather than guarded with an unreliable heuristic.""" 

174 in_block = False 

175 for line in side_lines: 

176 if not changed(line): 

177 if in_block: 

178 return False # a changed `/*` swallows this unchanged code 

179 continue 

180 text = line.content.strip() 

181 if not text: 

182 continue 

183 if not in_block and prefixes and text.startswith(prefixes): 

184 continue 

185 if block: 

186 is_comment, in_block = _block_comment_step(text, block, in_block) 

187 if is_comment: 

188 continue 

189 return False 

190 return not in_block 

191 

192 

193def _inline_comment_change( 

194 hunk: DiffHunk, prefixes: tuple[str, ...], quotes: str 

195) -> Trust | None: 

196 """Label hunks where paired lines differ only in their inline comments.""" 

197 # The positional pairing gate is shared with the token-delta rules 

198 # (delta.paired_changed_lines); this rule then compares raw strings, not 

199 # tokens — comments span ~40 languages the tokenizer doesn't model. 

200 pairs = paired_changed_lines(hunk) 

201 if pairs is None: 

202 return None 

203 old_has_comment = new_has_comment = False 

204 for old, new in pairs: 

205 old_code = strip_inline_comment(old.content, prefixes, quotes) 

206 new_code = strip_inline_comment(new.content, prefixes, quotes) 

207 # Compare with leading indentation (semantic in Python), so a re-indent of 

208 # a commented line isn't mistaken for an unchanged-code, comment-only edit. 

209 if ( 

210 not old_code 

211 or leading_indent(old.content) + old_code 

212 != leading_indent(new.content) + new_code 

213 ): 

214 return None # the code (or its indentation) changed, not just a comment 

215 # The comment remainders (whatever strip_inline_comment dropped). A tool 

216 # directive in either one (noqa, eslint-disable, …) means tool behavior 

217 # changed, not prose — never trust it. 

218 old_comment = old.content.strip()[len(old_code) :] 

219 new_comment = new.content.strip()[len(new_code) :] 

220 if _has_directive(old_comment) or _has_directive(new_comment): 

221 return None 

222 old_has_comment = old_has_comment or bool(old_comment) 

223 new_has_comment = new_has_comment or bool(new_comment) 

224 if new_has_comment and old_has_comment: 

225 return Trust.COMMENTS_MODIFIED 

226 if new_has_comment: 

227 return Trust.COMMENTS_ADDED 

228 if old_has_comment: 

229 return Trust.COMMENTS_REMOVED 

230 return None 

231 

232 

233def _comments(file: DiffFile, hunk: DiffHunk) -> Trust | None: 

234 ext = extension(file) 

235 prefixes = LINE_COMMENT_PREFIXES.get(ext) 

236 block = BLOCK_COMMENT_DELIMITERS.get(ext) 

237 if not prefixes and not block: 

238 return None 

239 if not hunk.changed_lines: 

240 return None 

241 # A shebang selects the interpreter the file runs under — changing it is an 

242 # execution change that happens to be spelled in comment syntax. 

243 if any(_is_shebang(line) for line in hunk.changed_lines): 

244 return None 

245 # Each side reads as its own file (context + that side's changes, in order), 

246 # so block-comment state threads through context lines correctly. 

247 new_side = [line for line in hunk.lines if not line.is_deletion()] 

248 old_side = [line for line in hunk.lines if not line.is_addition()] 

249 if _all_comment_lines( 

250 new_side, DiffCode.is_addition, prefixes, block 

251 ) and _all_comment_lines(old_side, DiffCode.is_deletion, prefixes, block): 

252 # Every changed line is a comment, so scan them whole for tool directives. 

253 if any(_has_directive(line.content) for line in hunk.changed_lines): 

254 return None 

255 # change_suffix yields added/removed/modified — each a real Trust member. 

256 return Trust(f"comments:{change_suffix(hunk)}") 

257 # Fallback: paired lines whose only difference is a trailing inline comment. 

258 if prefixes: 

259 return _inline_comment_change(hunk, prefixes, _string_quotes(ext)) 

260 return None