2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
302 lines
11 KiB
Python
302 lines
11 KiB
Python
# 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.
|
|
|
|
"""Tests for the meta_analyzer node.
|
|
|
|
Covers ``LLMMetaAnalyzer`` filtering and partial-batch-failure handling, plus
|
|
the LLM-call telemetry and fail-closed construction that drive the report's
|
|
degradation signal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from skillspector.llm_analyzer_base import Batch
|
|
from skillspector.models import Finding
|
|
from skillspector.nodes.meta_analyzer import LLMMetaAnalyzer, meta_analyzer
|
|
from skillspector.state import SkillspectorState
|
|
|
|
MOCK_PATCH_TARGET = "skillspector.llm_analyzer_base.get_chat_model"
|
|
|
|
|
|
def _mock_get_chat_model(*_args, **_kwargs):
|
|
mock_llm = MagicMock()
|
|
mock_llm.with_structured_output.return_value = MagicMock()
|
|
return mock_llm
|
|
|
|
|
|
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,
|
|
severity: str = "CRITICAL",
|
|
) -> Finding:
|
|
return Finding(
|
|
rule_id=rule_id,
|
|
message=f"static finding {rule_id}",
|
|
severity=severity,
|
|
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, severity="MEDIUM")]
|
|
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, severity="MEDIUM")]
|
|
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"
|
|
|
|
|
|
def _confirm(pattern_id: str, file: str, start_line: int) -> dict[str, object]:
|
|
"""LLM item confirming a finding, as parse_response would emit it."""
|
|
return {
|
|
"pattern_id": pattern_id,
|
|
"is_vulnerability": True,
|
|
"confidence": 0.9,
|
|
"explanation": "confirmed by llm",
|
|
"remediation": "fix it",
|
|
"_file": file,
|
|
"start_line": start_line,
|
|
"end_line": None,
|
|
}
|
|
|
|
|
|
@patch(MOCK_PATCH_TARGET, _mock_get_chat_model)
|
|
class TestMetaAnalyzerPartialBatchFailure:
|
|
def _state(self, findings: list[Finding]) -> dict[str, object]:
|
|
return {
|
|
"findings": findings,
|
|
"use_llm": True,
|
|
"file_cache": {"a.py": "code a", "b.py": "code b"},
|
|
"manifest": {},
|
|
"model_config": {},
|
|
}
|
|
|
|
def test_unanalysed_findings_survive_a_failed_batch(self) -> None:
|
|
"""Findings whose batch failed are kept (no verdict != rejection)."""
|
|
f_confirmed = Finding(rule_id="R1", message="m", file="a.py", start_line=1)
|
|
f_rejected = Finding(rule_id="R2", message="m", file="a.py", start_line=5)
|
|
f_unseen = Finding(rule_id="R1", message="m", file="b.py", start_line=3)
|
|
|
|
batch_a = Batch(file_path="a.py", content="code a", findings=[f_confirmed, f_rejected])
|
|
batch_b = Batch(file_path="b.py", content="code b", findings=[f_unseen])
|
|
|
|
# batch_b never returned (timeout/429): only batch_a's verdicts exist,
|
|
# and the LLM confirmed R1 but stayed silent on R2 (= rejection).
|
|
partial_results = [(batch_a, [_confirm("R1", "a.py", 1)])]
|
|
|
|
with (
|
|
patch.object(LLMMetaAnalyzer, "get_batches", return_value=[batch_a, batch_b]),
|
|
patch.object(
|
|
LLMMetaAnalyzer,
|
|
"arun_batches",
|
|
new_callable=AsyncMock,
|
|
return_value=partial_results,
|
|
),
|
|
):
|
|
result = meta_analyzer(self._state([f_confirmed, f_rejected, f_unseen]))
|
|
|
|
filtered = result["filtered_findings"]
|
|
kept = {(f.file, f.rule_id) for f in filtered}
|
|
|
|
# the real filter still applies to the batch that came back
|
|
assert ("a.py", "R1") in kept
|
|
assert ("a.py", "R2") not in kept
|
|
# the finding the LLM never saw must NOT be silently dropped
|
|
assert ("b.py", "R1") in kept
|
|
|
|
confirmed = next(f for f in filtered if f.file == "a.py")
|
|
assert confirmed.explanation == "confirmed by llm"
|
|
|
|
def test_all_batches_failed_keeps_everything_via_fallback(self) -> None:
|
|
f1 = Finding(rule_id="R1", message="m", file="a.py", start_line=1)
|
|
f2 = Finding(rule_id="R2", message="m", file="b.py", start_line=2)
|
|
batch_a = Batch(file_path="a.py", content="code a", findings=[f1])
|
|
batch_b = Batch(file_path="b.py", content="code b", findings=[f2])
|
|
|
|
with (
|
|
patch.object(LLMMetaAnalyzer, "get_batches", return_value=[batch_a, batch_b]),
|
|
patch.object(
|
|
LLMMetaAnalyzer,
|
|
"arun_batches",
|
|
new_callable=AsyncMock,
|
|
return_value=[],
|
|
),
|
|
):
|
|
result = meta_analyzer(self._state([f1, f2]))
|
|
|
|
kept = {(f.file, f.rule_id) for f in result["filtered_findings"]}
|
|
assert kept == {("a.py", "R1"), ("b.py", "R2")}
|
|
|
|
def test_no_failures_keeps_strict_confirm_or_drop(self) -> None:
|
|
"""When every batch returns, unconfirmed findings are dropped as before."""
|
|
f_confirmed = Finding(rule_id="R1", message="m", file="a.py", start_line=1)
|
|
f_rejected = Finding(rule_id="R2", message="m", file="b.py", start_line=2)
|
|
batch_a = Batch(file_path="a.py", content="code a", findings=[f_confirmed])
|
|
batch_b = Batch(file_path="b.py", content="code b", findings=[f_rejected])
|
|
|
|
full_results = [
|
|
(batch_a, [_confirm("R1", "a.py", 1)]),
|
|
(batch_b, []),
|
|
]
|
|
|
|
with (
|
|
patch.object(LLMMetaAnalyzer, "get_batches", return_value=[batch_a, batch_b]),
|
|
patch.object(
|
|
LLMMetaAnalyzer,
|
|
"arun_batches",
|
|
new_callable=AsyncMock,
|
|
return_value=full_results,
|
|
),
|
|
):
|
|
result = meta_analyzer(self._state([f_confirmed, f_rejected]))
|
|
|
|
kept = {(f.file, f.rule_id) for f in result["filtered_findings"]}
|
|
assert kept == {("a.py", "R1")}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LLM-call telemetry + fail-closed construction (drives the report's
|
|
# degradation signal).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _degr_finding(rule_id: str = "P1", severity: str = "HIGH") -> Finding:
|
|
return Finding(
|
|
rule_id=rule_id,
|
|
message="test",
|
|
severity=severity,
|
|
confidence=0.8,
|
|
file="SKILL.md",
|
|
start_line=1,
|
|
)
|
|
|
|
|
|
def _degr_state(**overrides: object) -> SkillspectorState:
|
|
state: SkillspectorState = {
|
|
"findings": [_degr_finding()],
|
|
"use_llm": True,
|
|
"file_cache": {"SKILL.md": "# Skill"},
|
|
"manifest": {},
|
|
"model_config": {},
|
|
}
|
|
state.update(overrides) # type: ignore[typeddict-item]
|
|
return state
|
|
|
|
|
|
def test_records_ok_true_on_success() -> None:
|
|
with (
|
|
patch("skillspector.llm_analyzer_base.get_chat_model", return_value=MagicMock()),
|
|
patch(
|
|
"skillspector.nodes.meta_analyzer.LLMMetaAnalyzer.arun_batches",
|
|
new_callable=AsyncMock,
|
|
return_value=[],
|
|
),
|
|
):
|
|
result = meta_analyzer(_degr_state())
|
|
assert result["llm_call_log"] == [{"node": "meta_analyzer", "ok": True, "error": None}]
|
|
|
|
|
|
def test_construction_failure_is_caught_not_raised() -> None:
|
|
"""Regression: the chat model is constructed INSIDE the try, so a construction
|
|
failure degrades (records ok=False, preserves findings) instead of crashing
|
|
the whole graph."""
|
|
with patch(
|
|
"skillspector.llm_analyzer_base.get_chat_model",
|
|
side_effect=RuntimeError("provider construction failed"),
|
|
):
|
|
result = meta_analyzer(_degr_state()) # must not raise
|
|
# Findings are preserved via the fallback path...
|
|
assert len(result["filtered_findings"]) == 1
|
|
# ...and the failure is recorded so the report can flag degradation.
|
|
log = result["llm_call_log"]
|
|
assert log[0]["node"] == "meta_analyzer"
|
|
assert log[0]["ok"] is False
|
|
assert "provider construction failed" in log[0]["error"]
|
|
|
|
|
|
def test_use_llm_false_records_nothing() -> None:
|
|
result = meta_analyzer(_degr_state(use_llm=False))
|
|
assert "llm_call_log" not in result
|
|
|
|
|
|
def test_no_findings_records_nothing() -> None:
|
|
result = meta_analyzer(_degr_state(findings=[]))
|
|
assert "llm_call_log" not in result
|