diff --git a/desloppify/languages/_framework/generic_parts/tool_runner.py b/desloppify/languages/_framework/generic_parts/tool_runner.py index 095f6db15..ca45ee394 100644 --- a/desloppify/languages/_framework/generic_parts/tool_runner.py +++ b/desloppify/languages/_framework/generic_parts/tool_runner.py @@ -12,6 +12,12 @@ from pathlib import Path from typing import Literal +from desloppify.base.discovery.file_paths import ( + matches_exclusion, + rel, + resolve_scan_file, +) +from desloppify.base.discovery.source import get_exclusions from desloppify.languages._framework.generic_parts.parsers import ToolParserError SubprocessRun = Callable[..., subprocess.CompletedProcess[str]] @@ -180,9 +186,24 @@ def run_tool_result( meta=meta, returncode=result.returncode, ) + # External tools discover their own files and may ignore our exclusions. + # Filter only after validating parsed output: a nonzero lint result whose + # diagnostics are all excluded is still a successful, empty detector run. + exclusions = get_exclusions() + if exclusions: + parsed_entries = [ + entry + for entry in parsed_entries + if not any( + matches_exclusion( + rel(resolve_scan_file(entry["file"], scan_root=path)), exclusion + ) + for exclusion in exclusions + ) + ] return ToolRunResult( entries=parsed_entries, - status="ok", + status="ok" if parsed_entries else "empty", meta=meta, returncode=result.returncode, ) diff --git a/desloppify/tests/lang/common/test_generic_tool_exclusions.py b/desloppify/tests/lang/common/test_generic_tool_exclusions.py new file mode 100644 index 000000000..b4f843091 --- /dev/null +++ b/desloppify/tests/lang/common/test_generic_tool_exclusions.py @@ -0,0 +1,133 @@ +"""Generic external tools must honor the scan's configured exclusions.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest + +from desloppify.base.runtime_state import RuntimeContext, runtime_scope +from desloppify.languages._framework.generic_parts.parsers import ( + parse_json, + parse_next_lint, +) +from desloppify.languages._framework.generic_parts.tool_factories import ( + make_detect_fn, + make_tool_phase, +) +from desloppify.languages._framework.generic_parts.tool_runner import run_tool_result + + +@pytest.mark.parametrize("consumer", ["phase", "detect"]) +@pytest.mark.parametrize("absolute", [False, True]) +@pytest.mark.parametrize("exclusion", ["ios/.build", "ios/.build/**"]) +def test_nested_scan_filters_tool_findings_using_project_exclusions( + tmp_path: Path, consumer: str, absolute: bool, exclusion: str +): + scan_path = tmp_path / "ios" + entries = [ + {"file": ".build/debug/runner.swift", "line": 1, "reason": "generated"}, + {"file": "Atlas/App.swift", "line": 2, "reason": "source"}, + {"file": ".build-tools/Keep.swift", "line": 3, "reason": "similar name"}, + ] + for entry in entries: + file = scan_path / entry["file"] + file.parent.mkdir(parents=True, exist_ok=True) + file.touch() + if absolute: + entries = [ + {**entry, "file": str(scan_path / entry["file"])} for entry in entries + ] + output = subprocess.CompletedProcess( + args="swiftlint", returncode=2, stdout=json.dumps(entries), stderr="" + ) + runtime = RuntimeContext(project_root=tmp_path, exclusions=(exclusion,)) + + with runtime_scope(runtime), patch("subprocess.run", return_value=output): + if consumer == "phase": + phase = make_tool_phase( + "swiftlint", + "swiftlint lint --reporter json", + "json", + "swiftlint_violation", + 2, + ) + findings, potentials = phase.run(scan_path, None) + assert [finding["summary"] for finding in findings] == [ + "source", + "similar name", + ] + assert potentials == {"swiftlint_violation": 2} + else: + detect = make_detect_fn("swiftlint lint --reporter json", parse_json) + findings = detect(scan_path) + assert [finding["message"] for finding in findings] == [ + "source", + "similar name", + ] + + +def test_only_excluded_diagnostics_do_not_report_tool_failure(tmp_path: Path): + generated = tmp_path / "ios/.build/runner.swift" + generated.parent.mkdir(parents=True) + generated.touch() + output = subprocess.CompletedProcess( + args="swiftlint", + returncode=2, + stdout=json.dumps( + [{"file": ".build/runner.swift", "line": 1, "reason": "generated"}] + ), + stderr="", + ) + runtime = RuntimeContext(project_root=tmp_path, exclusions=("ios/.build",)) + + with runtime_scope(runtime): + result = run_tool_result( + "swiftlint lint --reporter json", + tmp_path / "ios", + parse_json, + run_subprocess=lambda *_args, **_kwargs: output, + ) + + assert result.entries == [] + assert result.status == "empty" + assert result.error_kind is None + assert result.returncode == 2 + + +def test_already_project_relative_parser_paths_are_not_rebased_twice(tmp_path: Path): + generated = tmp_path / "apps/web/generated/component.tsx" + generated.parent.mkdir(parents=True) + generated.touch() + output = subprocess.CompletedProcess( + args="next", + returncode=1, + stdout=json.dumps( + [ + { + "filePath": str(generated), + "messages": [{"line": 1, "message": "generated"}], + }, + {"filePath": str(tmp_path / "apps/web/clean.tsx"), "messages": []}, + ] + ), + stderr="", + ) + runtime = RuntimeContext(project_root=tmp_path, exclusions=("apps/web/generated",)) + + with runtime_scope(runtime): + result = run_tool_result( + "next lint --format json", + tmp_path / "apps/web", + parse_next_lint, + run_subprocess=lambda *_args, **_kwargs: output, + ) + + assert result.entries == [] + assert result.status == "empty" + assert result.meta == {"potential": 2}, ( + "Aggregate metadata cannot be recomputed from retained diagnostics." + )