Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:39 +08:00

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