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
« 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."""
3from __future__ import annotations
5from collections.abc import Callable
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
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)
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)
84def _has_directive(text: str) -> bool:
85 lowered = text.lower()
86 return any(marker in lowered for marker in _DIRECTIVE_MARKERS)
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 )
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 "\"'"
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.
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
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.
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
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.
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).
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
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
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