Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/skillspector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/skillspector/nodes/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/skillspector/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
}
Expand All @@ -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",
Expand Down
Loading