diff --git a/src/skillspector/nodes/meta_analyzer.py b/src/skillspector/nodes/meta_analyzer.py index 8f2b5410..87e478bd 100644 --- a/src/skillspector/nodes/meta_analyzer.py +++ b/src/skillspector/nodes/meta_analyzer.py @@ -282,6 +282,8 @@ def apply_filter( """ _enrichment = tuple[str, str, float] confirmed_granular: dict[tuple[str, str, int, int | None], _enrichment] = {} + # Fallback index keyed without end_line (see lookup below). Issue #67. + confirmed_by_start: dict[tuple[str, str, int], _enrichment] = {} confirmed_coarse: dict[tuple[str, str], _enrichment] = {} for batch, llm_items in batch_results: @@ -308,6 +310,7 @@ def apply_filter( int(end_line) if end_line is not None else None, ) ] = enrichment + confirmed_by_start[(file_path, pattern_id, int(start_line))] = enrichment else: confirmed_coarse[(file_path, pattern_id)] = enrichment @@ -316,10 +319,13 @@ def apply_filter( exact_key = (f.file, f.rule_id, f.start_line, f.end_line) start_only_key = (f.file, f.rule_id, f.start_line, None) coarse_key = (f.file, f.rule_id) + start_key = (f.file, f.rule_id, f.start_line) if f.start_line is not None else None if exact_key in confirmed_granular: expl, rem, conf = confirmed_granular[exact_key] elif start_only_key in confirmed_granular: expl, rem, conf = confirmed_granular[start_only_key] + elif f.end_line is None and start_key is not None and start_key in confirmed_by_start: + expl, rem, conf = confirmed_by_start[start_key] elif coarse_key in confirmed_coarse: expl, rem, conf = confirmed_coarse[coarse_key] else: diff --git a/tests/nodes/test_meta_analyzer.py b/tests/nodes/test_meta_analyzer.py new file mode 100644 index 00000000..0087f463 --- /dev/null +++ b/tests/nodes/test_meta_analyzer.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for LLMMetaAnalyzer.apply_filter (no LLM / no network).""" + +from skillspector.llm_analyzer_base import Batch +from skillspector.models import Finding +from skillspector.nodes.meta_analyzer import LLMMetaAnalyzer + + +def _analyzer() -> LLMMetaAnalyzer: + # Skip __init__ so no LLM client / API key is needed; apply_filter is pure. + return LLMMetaAnalyzer.__new__(LLMMetaAnalyzer) + + +def _finding(rule_id: str, start_line: int, end_line: int | None = None) -> Finding: + return Finding( + rule_id=rule_id, + message=f"static finding {rule_id}", + severity="CRITICAL", + confidence=0.9, + file="requirements.txt", + start_line=start_line, + end_line=end_line, + ) + + +def _llm_item(rule_id: str, start_line: int, **kw: object) -> dict[str, object]: + item: dict[str, object] = { + "pattern_id": rule_id, + "is_vulnerability": True, + "confidence": 1.0, + "start_line": start_line, + "_file": "requirements.txt", + } + item.update(kw) + return item + + +def test_confirmed_finding_kept_when_model_returns_end_line() -> None: + """Regression: a static finding with end_line=None must still match a + confirmation whose end_line is populated (e.g. end_line == start_line, as + some models return). Previously these confirmed findings were silently + dropped. See issue #67.""" + findings = [_finding("SC4", 4), _finding("SC4", 5)] + items = [_llm_item("SC4", 4, end_line=4), _llm_item("SC4", 5, end_line=5)] + batch = Batch(file_path="requirements.txt", content="", findings=findings) + + kept = _analyzer().apply_filter(findings, [(batch, items)]) + + assert {f.start_line for f in kept} == {4, 5} + assert len(kept) == 2 + + +def test_rejected_finding_still_dropped() -> None: + """The end_line-agnostic fallback must not resurrect findings the LLM + rejected (is_vulnerability=False).""" + findings = [_finding("SC4", 4)] + items = [_llm_item("SC4", 4, end_line=4, is_vulnerability=False)] + batch = Batch(file_path="requirements.txt", content="", findings=findings) + + kept = _analyzer().apply_filter(findings, [(batch, items)]) + + assert kept == [] + + +def test_low_confidence_finding_dropped() -> None: + """Confirmations below the confidence threshold are not kept.""" + findings = [_finding("SC4", 4)] + items = [_llm_item("SC4", 4, end_line=4, confidence=0.3)] + batch = Batch(file_path="requirements.txt", content="", findings=findings) + + kept = _analyzer().apply_filter(findings, [(batch, items)]) + + assert kept == [] + + +def test_exact_end_line_match_still_works() -> None: + """Existing behaviour: when both sides carry the same concrete end_line, + the finding is kept (no regression from the new fallback).""" + findings = [_finding("AST1", 21, end_line=21)] + items = [_llm_item("AST1", 21, end_line=21)] + batch = Batch(file_path="requirements.txt", content="", findings=findings) + + kept = _analyzer().apply_filter(findings, [(batch, items)]) + + assert len(kept) == 1 + assert kept[0].rule_id == "AST1"