diff --git a/CHANGELOG.md b/CHANGELOG.md index b924b4113..f1cda7a10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 2.12.0 (Unreleased) ### Features/Bug Fixes +* fix(baseline): fingerprint every occurrence of a deduplicated finding so the next scan suppresses all of them (#633) * fix(security): retain incomplete coverage for runtime-selected commands and remeasure active Git clones strictly (#514) * fix(scan): preserve required-input failures and multiline prompt uncertainty (#563) * fix(analyzer): preserve Perl print literal ownership and explain referenced-artifact limitations (#615) diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index 3a4d1b520..c66271431 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -3181,7 +3181,9 @@ def baseline( state = _scan_state(input_path, FormatChoice.json, no_llm) state["baseline_path"] = os.path.abspath(output.expanduser()) result = graph.invoke(state) - findings = effective_findings(result) + # Fingerprint every occurrence the next scan checks. The reported + # findings are deduplicated and keep only one occurrence's evidence. + findings = result["active_findings"] data = build_baseline_dict( findings, reason=reason, diff --git a/src/skillspector/nodes/report.py b/src/skillspector/nodes/report.py index d2e5ae9bd..6a153e96c 100644 --- a/src/skillspector/nodes/report.py +++ b/src/skillspector/nodes/report.py @@ -1838,6 +1838,7 @@ def report(state: SkillspectorState) -> dict[str, object]: "report_body": report_body, "filtered_findings": reported_findings, "suppressed_findings": suppressed, + "active_findings": active_findings, "execution_successful": execution_successful, "analysis_completeness": dict(analysis_completeness), "transitive_targets_scanned": transitive_targets_scanned, diff --git a/src/skillspector/state.py b/src/skillspector/state.py index ba39b5621..c472fa7f4 100644 --- a/src/skillspector/state.py +++ b/src/skillspector/state.py @@ -307,6 +307,10 @@ class SkillspectorState(TypedDict, total=False): baseline_path: str | None show_suppressed: bool suppressed_findings: list[object] + # Kept findings as baseline suppression saw them: one per occurrence, before + # deduplication compacts them. `skillspector baseline` fingerprints these so + # every occurrence the next scan checks has its own entry. + active_findings: list[Finding] # Model IDs per LLM-using node: e.g. {"default": "...", "meta_analyzer": "..."} model_config: dict[str, str] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 96dd9203a..8d019bbe7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1013,6 +1013,46 @@ def test_cli_baseline_generate_then_scan_round_trip(tmp_path: Path) -> None: assert data["risk_assessment"]["score"] == 0 +def test_cli_baseline_round_trip_suppresses_every_occurrence_of_a_repeated_match( + tmp_path: Path, +) -> None: + """A match compacted across files is fingerprinted once per occurrence (#633).""" + skill = tmp_path / "demo" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text( + "---\nname: demo\ndescription: A demo skill for the baseline reproduction.\n---\n\n" + "# Demo\n\n" + "The upstream service deletes unused files, and the link dies with no warning.\n", + encoding="utf-8", + ) + (skill / "references" / "notes.md").write_text( + "# Notes\n\nThe mirror drops stale entries with no warning.\n", + encoding="utf-8", + ) + baseline_file = tmp_path / "baseline.yaml" + + plain = runner.invoke(app, ["scan", str(skill), "--no-llm", "--format", "json"]) + assert plain.exit_code == 0, plain.output + reported = [ + (issue["id"], issue["location"]["file"]) for issue in json.loads(plain.stdout)["issues"] + ] + assert sorted(reported) == [("AR2", "SKILL.md"), ("AR2", "references/notes.md")] + + gen = runner.invoke(app, ["baseline", str(skill), "--no-llm", "--output", str(baseline_file)]) + assert gen.exit_code == 0, gen.output + + scan = runner.invoke( + app, + ["scan", str(skill), "--no-llm", "--format", "json", "--baseline", str(baseline_file)], + ) + assert scan.exit_code == 0, scan.output + data = json.loads(scan.stdout) + assert data["issues"] == [] + assert sorted((item["id"], item["location"]["file"]) for item in data["suppressed"]) == sorted( + reported + ) + + def test_cli_baseline_regeneration_excludes_in_tree_output(tmp_path: Path) -> None: """Regeneration cannot fingerprint findings created by the old output file.""" skill = tmp_path / "skill" @@ -6038,6 +6078,7 @@ def test_cli_baseline_command_excludes_filtered_out_findings(tmp_path: Path) -> "findings": [Finding(rule_id="SQP-1", message="one", file="SKILL.md")], "filtered_findings": [], "suppressed_findings": [], + "active_findings": [], "file_cache": {"SKILL.md": source}, "risk_score": 0, } @@ -6063,6 +6104,7 @@ def test_cli_baseline_uses_local_cache_for_provider_excluded_findings(tmp_path: "findings": [finding], "filtered_findings": [finding], "suppressed_findings": [], + "active_findings": [finding], "file_cache": {"SKILL.md": "# Baseline helper\n"}, "local_file_cache": { "SKILL.md": "# Baseline helper\n",