diff --git a/scripts/pr_review.py b/scripts/pr_review.py index e8dd3fb3..e0256edd 100755 --- a/scripts/pr_review.py +++ b/scripts/pr_review.py @@ -4534,7 +4534,7 @@ def marker_blocks(body: str, marker: re.Pattern[str], strip_blockquote: bool = F """Return the review body's sections whose own heading matches `marker`. Each is sliced from its heading to the next sibling heading or the end of its own region. - Past a region's first section, only the sibling heading it ends at can open another. + Past a region's first section, only a sibling heading some section ends at can open another. Shared by `suppressed_blocks`, `previously_missed_blocks`, and `outside_diff_blocks`. `strip_blockquote` affects heading detection only, never the returned content. A summary split @@ -4565,9 +4565,10 @@ def marker_blocks(body: str, marker: re.Pattern[str], strip_blockquote: bool = F bare = COUNT.search(line) and not OPENING_COUNT.match(line) if marker.search(line) and (HEADING.match(line) or bare) and not entry_heading(line): start = i - while start < len(scan_lines) and marker.search(scan_lines[start]): + while start < len(scan_lines): end = sibling_heading(scan_lines, start) - blocks.append("\n".join(raw_lines[start:end])) + if marker.search(scan_lines[start]): + blocks.append("\n".join(raw_lines[start:end])) start = end break return blocks diff --git a/tests/test_pr_review.py b/tests/test_pr_review.py index 1382e7e6..1f3a4206 100755 --- a/tests/test_pr_review.py +++ b/tests/test_pr_review.py @@ -1603,6 +1603,48 @@ def test_a_section_ends_where_its_sibling_s_heading_starts(self) -> None: self.assertEqual(1, out.count("The suppressed finding.")) self.assertEqual(1, out.count("The previously missed finding.")) + def test_sections_alternating_in_one_wrapper_are_each_read(self) -> None: + """The chain of bounds runs through a section of the other kind rather than stopping there. + + Stopped at the first sibling of the other kind, the second suppressed section reached no + block. The digest counted one suppressed finding of three, under `shapes=ok`. + """ + shared = ( + f"{OVERVIEW}\n
\nReview details\n\n" + "Suppressed comments (1)\n\nThe first suppressed finding.\n\n" + "Previously missed (1)\n\nThe previously missed finding.\n\n" + "Suppressed comments (2)\n\nThe second suppressed finding.\n\n" + "The third suppressed finding.\n
\n" + ) + suppressed = pr_review.suppressed_blocks(shared) + self.assertEqual(2, len(suppressed)) + self.assertIn("The first suppressed finding.", suppressed[0]) + self.assertIn("The second suppressed finding.", suppressed[1]) + self.assertIn("The third suppressed finding.", suppressed[1]) + self.assertNotIn("The previously missed finding.", "".join(suppressed)) + missed = pr_review.previously_missed_blocks(shared) + self.assertEqual(1, len(missed)) + self.assertNotIn("suppressed finding.", missed[0]) + self.answer(payload([review(body=shared)])) + out, _ = pr_review.digest("o", "r", 7) + self.assertIn("suppressed=3", out) + self.assertIn("shapes=ok", out) + for finding in ( + "first suppressed", + "previously missed", + "second suppressed", + "third suppressed", + ): + self.assertEqual(1, out.count(f"The {finding} finding.")) + swapped = ( + f"{OVERVIEW}\n
\nReview details\n\n" + "Previously missed (1)\n\nThe first.\n\n" + "Suppressed comments (1)\n\nThe second.\n\n" + "Previously missed (1)\n\nThe third.\n
\n" + ) + self.assertEqual(2, len(pr_review.previously_missed_blocks(swapped))) + self.assertEqual([], pr_review.unrecognized_in(swapped)) + def test_a_finding_entry_is_no_section_whatever_its_title_says(self) -> None: """An entry's title is free text, and either section's marker words can appear in it. diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 96627095..c725d697 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -1481,6 +1481,9 @@ def test_both_engines_agree_on_every_pattern_over_a_corpus(self) -> None: Node is what an editor's JSON language service uses, so it is the engine the schema actually meets. A host without node skips this rather than failing, since the shape check above still binds everywhere. + + A probe that times out skips too, since a cold start on a slow runner is no verdict on the patterns. A + pattern that backtracks without end cannot hide behind that skip, since Python matches the corpus first. """ node = shutil.which("node") if node is None: @@ -1539,16 +1542,42 @@ def test_both_engines_agree_on_every_pattern_over_a_corpus(self) -> None: "console.log(JSON.stringify(Object.fromEntries(" "pats.map(p => [p, cases.map(c => new RegExp(p).test(c))]))));" ) - result = subprocess.run( - [node, "-e", probe, json.dumps([patterns, corpus])], - capture_output=True, - text=True, - encoding="utf-8", - timeout=60, - check=True, - ) + try: + result = subprocess.run( + [node, "-e", probe, json.dumps([patterns, corpus])], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + check=True, + ) + except subprocess.TimeoutExpired: + self.skipTest("node timed out, which is no verdict on the patterns") self.assertEqual(json.loads(result.stdout), want) + def test_a_timed_out_corpus_probe_skips_rather_than_failing(self) -> None: + """A real timeout takes a minute, so the probe is made to raise one rather than to be slow. + + Node refusing a pattern exits non-zero, and that still fails, so a widened skip is caught too. + """ + + def probe(side_effect: BaseException) -> unittest.TestResult: + result = unittest.TestResult() + with ( + unittest.mock.patch.object(shutil, "which", return_value="node"), + unittest.mock.patch.object(subprocess, "run", side_effect=side_effect), + ): + type(self)("test_both_engines_agree_on_every_pattern_over_a_corpus").run(result) + return result + + timed_out = probe(subprocess.TimeoutExpired("node", 60)) + self.assertEqual([], timed_out.failures + timed_out.errors) + self.assertEqual(1, len(timed_out.skipped)) + self.assertIn("node timed out", timed_out.skipped[0][1]) + refused = probe(subprocess.CalledProcessError(1, "node")) + self.assertEqual([], refused.skipped) + self.assertEqual(1, len(refused.errors)) + def test_the_description_pattern_never_refuses_what_the_validator_accepts(self) -> None: """The schema's own statement rather than a copy, so the direction is what has to hold.