From 6bdf96078ea74646cff9831a951a9e8d1b1354c9 Mon Sep 17 00:00:00 2001 From: Lalit Shrotriya Date: Wed, 24 Jun 2026 06:58:47 +0000 Subject: [PATCH] test(meta_analyzer): add regression tests for static findings with end_line=None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static analyzers emit findings with end_line=None while the LLM always fills in an explicit end_line. Before the confirmed_by_start fix (issue #67), these findings were silently dropped because the exact (file, rule, start, end) key never matched. Add two tests that pin the correct behaviour: - test_static_finding_with_none_end_line_confirmed_by_start: core issue #67 scenario — static finding with end_line=None is confirmed when the LLM reports the same start_line with an explicit end_line. - test_static_findings_at_different_lines_only_confirmed_kept: two findings at different start_lines; LLM denies one — only the confirmed finding survives apply_filter. Signed-off-by: Lalit Shrotriya --- tests/nodes/test_llm_analyzer_base.py | 64 +++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/nodes/test_llm_analyzer_base.py b/tests/nodes/test_llm_analyzer_base.py index 72efbaf..233cc44 100644 --- a/tests/nodes/test_llm_analyzer_base.py +++ b/tests/nodes/test_llm_analyzer_base.py @@ -1321,6 +1321,70 @@ def test_end_line_used_when_provided(self) -> None: assert result[0].end_line == 10 assert result[0].explanation == "Long block is dangerous" + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + def test_static_finding_with_none_end_line_confirmed_by_start(self) -> None: + """Issue #67: static finding with end_line=None must not be dropped when + the LLM confirms the same start_line with an explicit end_line. + + Static analyzers typically emit end_line=None; the LLM always fills it + in. The confirmed_by_start fallback ensures the finding is kept. + """ + analyzer = LLMMetaAnalyzer(model=self.MODEL) + # Construct directly — _make_finding converts None to line via `or`. + finding = Finding( + rule_id="E2", + message="env harvest", + file="agent.py", + start_line=42, + end_line=None, + ) + batch = Batch(file_path="agent.py", content="code", findings=[finding]) + llm_items = [ + { + "pattern_id": "E2", + "start_line": 42, + "end_line": 42, + "is_vulnerability": True, + "confidence": 0.88, + "explanation": "Harvests all env vars", + "remediation": "Use specific env lookups", + "_file": "agent.py", + } + ] + result = analyzer.apply_filter([finding], [(batch, llm_items)]) + assert len(result) == 1, "Static finding with end_line=None must not be dropped" + assert result[0].explanation == "Harvests all env vars" + + @patch(MOCK_PATCH_TARGET, _mock_get_chat_model) + def test_static_findings_at_different_lines_only_confirmed_kept(self) -> None: + """Two static findings (end_line=None) at different start_lines; LLM + confirms only one. The unconfirmed finding must not survive the filter.""" + analyzer = LLMMetaAnalyzer(model=self.MODEL) + f1 = Finding(rule_id="P1", message="override", file="skill.md", start_line=10, end_line=None) + f2 = Finding(rule_id="P1", message="override", file="skill.md", start_line=30, end_line=None) + batch = Batch(file_path="skill.md", content="code", findings=[f1, f2]) + llm_items = [ + { + "pattern_id": "P1", + "start_line": 10, + "end_line": 10, + "is_vulnerability": True, + "confidence": 0.9, + "explanation": "Instruction override at line 10", + "_file": "skill.md", + }, + { + "pattern_id": "P1", + "start_line": 30, + "is_vulnerability": False, + "confidence": 0.2, + "_file": "skill.md", + }, + ] + result = analyzer.apply_filter([f1, f2], [(batch, llm_items)]) + assert len(result) == 1 + assert result[0].start_line == 10 + # --------------------------------------------------------------------------- # LLMMetaAnalyzer.apply_filter — severity-gated suppression floor