From 5dc89b5484efada35d308bd3345ef0bcba2db955 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 25 Sep 2026 13:23:00 -0700 Subject: [PATCH] Stop the Prose Gate Reading a Bash Parameter Expansion as a Comment `extracted_comments` in the prose gate read the `#` inside a bash parameter expansion such as `${#items[@]}` as a comment opener, so a `run:` line counting an array was judged as prose, and an `echo` string holding one failed `comment-wrap`. A `#` right after `$` or `${` is now skipped as a line comment marker, read off the raw line so an escaped `$` still counts. A real leading or trailing comment on the same line is still extracted. The wider parser carry work for string and heredoc content stays with #1664. A regression test covers both spellings from the issue, `$#`, the gate's own verdict on the `echo` line, and the still-extracted leading and trailing comments. It fails with the fix reverted. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/actions/prose-gate/prose_lint.py | 17 +++++++++++++- scripts/tests/test_prose_lint.py | 29 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/.github/actions/prose-gate/prose_lint.py b/.github/actions/prose-gate/prose_lint.py index 893c9451a..3babaa30c 100755 --- a/.github/actions/prose-gate/prose_lint.py +++ b/.github/actions/prose-gate/prose_lint.py @@ -1770,6 +1770,21 @@ def python_comments(raw: str) -> list[Comment] | None: return out +PARAMETER_HASH = ("$", "${") + + +def line_marker(line: str, masked: str, marker: str, pos: int) -> int: + """Where the first line comment marker at or after `pos` sits, or -1 when there is none. + + A `#` right after `$` or `${` names a parameter, as in `$#` or `${#items[@]}`, not a comment. + The prefix is read off the raw line, since an escaped `$` is blanked in the masked one. + """ + where = masked.find(marker, pos) + while marker == "#" and where >= 0 and line[:where].endswith(PARAMETER_HASH): + where = masked.find(marker, where + 1) + return where + + def extracted_comments(path: Path, lines: list[str], spec: Syntax | None = None) -> list[Comment]: """Every comment in the file as (line, text, starts-the-line), for any syntax the fleet uses. @@ -1837,7 +1852,7 @@ def extracted_comments(path: Path, lines: list[str], spec: Syntax | None = None) found: str | tuple[str, str] | None = None at = len(line) for marker in spec["line"]: - where = masked.find(marker, pos) + where = line_marker(line, masked, marker, pos) if 0 <= where < at: at, found = where, marker for opener, closer in spec["block"]: diff --git a/scripts/tests/test_prose_lint.py b/scripts/tests/test_prose_lint.py index c789b8a96..6d467246b 100755 --- a/scripts/tests/test_prose_lint.py +++ b/scripts/tests/test_prose_lint.py @@ -776,6 +776,35 @@ def test_only_a_c_style_continuation_loses_its_leading_asterisk(self) -> None: [(c.line, c.body, c.leading) for c in found], ) + def test_a_parameter_expansion_hash_opens_no_comment(self) -> None: + """A `#` after `$` or `${` is bash naming a count, so neither spelling reads as a comment. + + A real comment on the same kind of line is still found, leading or trailing. + """ + for text in ( + " count=${#items[@]}", + ' echo "::error::${#items[@]} open issues. Close them by hand."', + " echo $#", + ): + with self.subTest(line=text): + self.assertEqual([], prose_lint.extracted_comments(Path("x.yml"), [text])) + self.assertEqual( + [], + self.flag( + "x.yml", + 'run: |\n echo "::error::${#items[@]} open issues. Close them by hand."\n', + ), + ) + for text, leading in ( + (" count=${#items[@]} # Count the open items.", False), + (" # Count the open items.", True), + ): + with self.subTest(line=text): + found = prose_lint.extracted_comments(Path("x.yml"), [text]) + self.assertEqual( + [("Count the open items.", leading)], [(c.body, c.leading) for c in found] + ) + def test_a_format_with_no_comment_syntax_is_skipped(self) -> None: for name in ("a.lock", "a.csv", "a.txt"): with self.subTest(file=name):