stem-bio-ai / stem_ai /detector_surface.py
Flamehaven Initiative
docs: sync hf space to v1.8.4 surfaces
8ea9f7d
Raw
History Blame Contribute Delete
29 kB
from __future__ import annotations
import ast
import re
from collections.abc import Iterable
from pathlib import Path
from .evidence import EvidenceFinding
from .detector_utils import (
add_finding,
existing_named_files,
is_fixture_like_path,
is_unpinned_dependency,
iter_code_files,
iter_deprecated_files,
iter_text_files,
manifest_dependency_entries,
read_text,
relative_path,
source_line,
)
from .patterns import (
BIAS_LIMITATION_TERMS,
BIAS_MEASUREMENT_TERMS,
BIO_TERMS,
CHANGELOG_BUG_TERMS,
COI_FUNDING_TERMS,
DATA_SOURCE_TERMS,
DEMOGRAPHIC_BIAS_TERMS,
DISCLAIMER_TERMS,
HYPE_AUTONOMOUS_REPLACEMENT,
HYPE_BREAKTHROUGH,
HYPE_CLINICAL_CERTAINTY,
HYPE_PERFECT_ACCURACY,
HYPE_REGULATORY_APPROVAL,
HYPE_UNIVERSAL_GENERALIZATION,
LEGAL_COMPLIANCE_CLAIM_TERMS,
LEGAL_COMPLIANCE_GROUNDING_TERMS,
LEGAL_COMPLIANCE_NEGATION_TERMS,
LIMITATIONS_SECTION,
MOCK_AUTH_FAIL_OPEN_TERMS,
PATIENT_METADATA,
PLACEHOLDER_SECRET_VALUES,
REGULATORY_FRAMEWORK_TERMS,
REPRODUCIBILITY_TERMS,
SECRET_TERMS,
WEAK_REGULATORY_SELF_ASSERTION_TERMS,
LOCAL_SELF_HOST_CLAIM_TERMS,
)
_EXTERNAL_PROVIDER_ENV_MAP: dict[str, str] = {
"VALYU_API_KEY": "Valyu",
"DAYTONA_API_KEY": "Daytona",
"OPENAI_API_KEY": "OpenAI",
"ANTHROPIC_API_KEY": "Anthropic",
"GEMINI_API_KEY": "Gemini",
"OPENAI_COMPATIBLE_API_KEY": "OpenAI-compatible",
"SUPABASE_SERVICE_ROLE_KEY": "Supabase",
"SUPABASE_ANON_KEY": "Supabase",
"NEXT_PUBLIC_SUPABASE_ANON_KEY": "Supabase",
}
_REQUIRED_HINT = re.compile(r"\brequired\b", re.I)
_OPTIONAL_HINT = re.compile(r"\boptional\b", re.I)
_API_KEY_ASSIGNMENT = re.compile(r"^\s*([A-Z0-9_]+_API_KEY)\s*=")
_PROVIDER_LINE_HINT = re.compile(r"\b(api key|powered by|used for|built with|platform)\b", re.I)
def collect_surface_findings(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
) -> None:
readme_paths = existing_named_files(target, ["README.md", "README.rst", "readme.md"])
docs_paths = list(iter_text_files(target / "docs", max_files=30))
package_paths = existing_named_files(target, ["pyproject.toml", "setup.py", "setup.cfg", "package.json"])
config_paths = existing_named_files(target, [".env.example", ".env.local.example", ".env.sample", "env.example"])
funding_paths = existing_named_files(target, ["FUNDING.md", "CITATION.cff", "AUTHORS.md"])
workflow_paths = list(iter_text_files(target / ".github" / "workflows", max_files=20))
test_paths = list(iter_text_files(target / "tests", max_files=40))
provenance_manifest_paths = existing_named_files(
target,
[
"environment.yml",
"requirements.txt",
"pyproject.toml",
"setup.cfg",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"npm-shrinkwrap.json",
"package.json",
],
)
dependency_pinning_paths = existing_named_files(
target,
[
"environment.yml",
"requirements.txt",
"pyproject.toml",
"setup.cfg",
"package.json",
"package-lock.json",
"pnpm-lock.yaml",
"yarn.lock",
"npm-shrinkwrap.json",
],
)
changelog_paths = existing_named_files(target, ["CHANGELOG.md", "CHANGELOG", "NEWS.md"])
code_paths = list(iter_code_files(target, max_files=200))
deprecated_paths = list(iter_deprecated_files(target, max_files=120))
regex_detector(target, findings, counters, "S1_readme_bio_terms", "bio_terms_v1", BIO_TERMS, readme_paths, "README exposes bio/medical vocabulary.")
regex_detector(target, findings, counters, "S1_clinical_boundary", "clinical_boundary_v1", DISCLAIMER_TERMS, [*readme_paths, *docs_paths], "Explicit non-clinical or non-diagnostic boundary language detected.")
regex_detector(target, findings, counters, "S1_H1_clinical_certainty_hype", "hype_clinical_certainty_v1", HYPE_CLINICAL_CERTAINTY, readme_paths, "Clinical certainty or deployment-readiness claim detected.")
regex_detector(target, findings, counters, "S1_H2_regulatory_approval_hype", "hype_regulatory_approval_v1", HYPE_REGULATORY_APPROVAL, readme_paths, "Regulatory approval or clearance claim detected.")
regex_detector(target, findings, counters, "S1_H3_autonomous_replacement_hype", "hype_autonomous_replacement_v1", HYPE_AUTONOMOUS_REPLACEMENT, readme_paths, "Autonomous clinician-replacement claim detected.")
regex_detector(target, findings, counters, "S1_H4_breakthrough_marketing_hype", "hype_breakthrough_marketing_v1", HYPE_BREAKTHROUGH, readme_paths, "Marketing hype language detected.")
regex_detector(target, findings, counters, "S1_H5_universal_generalization_hype", "hype_universal_generalization_v1", HYPE_UNIVERSAL_GENERALIZATION, readme_paths, "Universal generalization claim detected.")
regex_detector(target, findings, counters, "S1_H6_perfect_accuracy_hype", "hype_perfect_accuracy_v1", HYPE_PERFECT_ACCURACY, readme_paths, "Perfect or guaranteed accuracy claim detected.")
regex_detector(target, findings, counters, "S1_R1_limitations_section", "limitations_section_v1", LIMITATIONS_SECTION, readme_paths, "Explicit limitations or validation-boundary section detected.")
regex_detector(target, findings, counters, "S1_R2_regulatory_framework", "regulatory_framework_v1", REGULATORY_FRAMEWORK_TERMS, [*readme_paths, *docs_paths], "Regulatory, IRB, SaMD, or clinical-reporting framework language detected.")
regex_detector(target, findings, counters, "S1_R2_weak_regulatory_self_assertion", "weak_regulatory_self_assertion_v1", WEAK_REGULATORY_SELF_ASSERTION_TERMS, [*readme_paths, *docs_paths], "Self-asserted privacy/compliance language detected without stronger regulatory-framework evidence.")
unsupported_legal_or_compliance_claim_detector(target, findings, counters, [*readme_paths, *docs_paths, *package_paths])
regex_detector(target, findings, counters, "S1_R4_demographic_bias_boundary", "demographic_bias_boundary_v1", DEMOGRAPHIC_BIAS_TERMS, [*readme_paths, *docs_paths], "Demographic, subgroup, fairness, bias, or validation-cohort language detected.")
regex_detector(target, findings, counters, "S1_R5_reproducibility_provisions", "reproducibility_provisions_v1", REPRODUCIBILITY_TERMS, [*readme_paths, *docs_paths], "Reproducibility, replication, seed, lockfile, or checksum language detected.")
file_presence_detector(target, findings, counters, "S3_T1_workflow_files", "workflow_presence_v1", workflow_paths, "Workflow file exists.")
regex_line_detector(target, findings, counters, "S3_T2_domain_tests", "domain_tests_bio_terms_v1", BIO_TERMS, test_paths, "Domain-specific test text detected.", normalize_underscores=True)
file_presence_detector(target, findings, counters, "S3_T3_changelog_release_hygiene", "changelog_presence_v1", changelog_paths, "Changelog or release-history file exists.")
regex_detector(target, findings, counters, "S3_T3_changelog_bugfix_evidence", "changelog_bugfix_terms_v1", CHANGELOG_BUG_TERMS, changelog_paths, "Bug-fix, patch, or security entry detected in changelog.")
file_presence_detector(target, findings, counters, "S3_B1_dependency_manifest", "dependency_manifest_presence_v1", provenance_manifest_paths, "Dependency or environment manifest exists.")
regex_detector(target, findings, counters, "S3_B1_data_source_language", "data_source_terms_v1", DATA_SOURCE_TERMS, [*readme_paths, *docs_paths], "Data source, dataset citation, IRB, or provenance language detected.")
regex_detector(target, findings, counters, "S3_B2_bias_limitations", "bias_limitations_v2", BIAS_LIMITATION_TERMS, [*readme_paths, *docs_paths], "Bias, limitation, or validation-boundary language detected.")
regex_detector(target, findings, counters, "S3_B2_measurement_evidence", "bias_measurement_terms_v1", BIAS_MEASUREMENT_TERMS, [*readme_paths, *docs_paths, *test_paths], "Quantitative bias/limitation measurement or related test evidence detected.")
regex_detector(target, findings, counters, "S3_B3_coi_funding", "coi_funding_v1", COI_FUNDING_TERMS, [*readme_paths, *docs_paths, *funding_paths], "COI, funding, sponsor, or acknowledgement language detected.")
regex_detector(target, findings, counters, "S2_package_bio_terms", "package_bio_terms_v1", BIO_TERMS, package_paths, "Package metadata exposes bio/medical vocabulary.")
external_service_dependency_detector(target, findings, counters, [*readme_paths, *docs_paths, *package_paths], config_paths)
mock_auth_or_fail_open_boundary_detector(
target,
findings,
counters,
[*readme_paths, *docs_paths, *package_paths],
config_paths,
code_paths,
)
credential_detector(target, findings, counters, code_paths)
dependency_pinning_detector(target, findings, counters, dependency_pinning_paths)
regex_detector(target, findings, counters, "C3_dead_or_deprecated_patient_adjacent_paths", "deprecated_patient_metadata_v1", PATIENT_METADATA, deprecated_paths, "Patient-adjacent metadata pattern detected in deprecated/legacy/archive path.")
fail_open_detector(target, findings, counters, code_paths)
def regex_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
detector: str,
pattern_id: str,
pattern: re.Pattern[str],
paths: Iterable[Path],
explanation: str,
) -> None:
detected = False
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
text = read_text(path)
lines = text.splitlines()
matches = list(pattern.finditer(text))
if not matches:
continue
detected = True
first = matches[0]
line = text.count("\n", 0, first.start()) + 1
matched_terms: list[str] = []
matched_lines: list[int] = []
for match in matches:
term = match.group(0)
if term not in matched_terms:
matched_terms.append(term)
match_line = text.count("\n", 0, match.start()) + 1
if match_line not in matched_lines:
matched_lines.append(match_line)
add_finding(
target,
findings,
counters,
detector,
pattern_id,
"detected",
"info",
path,
line,
source_line(lines, line),
"regex",
explanation,
{
"match": first.group(0),
"match_count": len(matches),
"matched_terms": matched_terms[:5],
"matched_lines": matched_lines[:8],
"aggregate_scope": "same_file_regex_presence",
},
)
if not detected:
add_finding(target, findings, counters, detector, pattern_id, "not_detected", "info", Path("."), 0, "", "regex", f"No evidence detected for {detector}.")
def regex_line_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
detector: str,
pattern_id: str,
pattern: re.Pattern[str],
paths: Iterable[Path],
explanation: str,
normalize_underscores: bool = False,
) -> None:
detected = False
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
lines = read_text(path).splitlines()
for line_number, line in enumerate(lines, start=1):
searchable = line.replace("_", " ") if normalize_underscores else line
if not pattern.search(searchable):
continue
detected = True
add_finding(target, findings, counters, detector, pattern_id, "detected", "info", path, line_number, line, "regex", explanation)
if not detected:
add_finding(target, findings, counters, detector, pattern_id, "not_detected", "info", Path("."), 0, "", "regex", f"No evidence detected for {detector}.")
def dependency_pinning_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
paths: Iterable[Path],
) -> None:
unpinned_found = False
manifest_found = False
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
manifest_found = True
if _check_unpinned_deps_in_file(target, path, findings, counters):
unpinned_found = True
if not unpinned_found:
status = "not_detected" if manifest_found else "absent"
explanation = "No loose dependency evidence detected." if manifest_found else "No dependency manifest detected."
add_finding(target, findings, counters, "C2_dependency_pinning", "loose_dependency_v1",
status, "info", Path("."), 0, "", "dependency", explanation)
def _check_unpinned_deps_in_file(
target: Path,
path: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
) -> bool:
found = False
text = read_text(path)
for line_number, entry, snippet in manifest_dependency_entries(path, text):
if not is_unpinned_dependency(entry):
continue
found = True
add_finding(target, findings, counters, "C2_dependency_pinning",
"loose_dependency_v1", "detected", "warn", path, line_number,
snippet, "dependency", "Unpinned or loosely pinned dependency detected.",
{"dependency": entry})
return found
def credential_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
paths: Iterable[Path],
) -> None:
detected = False
placeholders = False
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
text = read_text(path)
lines = text.splitlines()
for match in SECRET_TERMS.finditer(text):
line = text.count("\n", 0, match.start()) + 1
placeholder = PLACEHOLDER_SECRET_VALUES.search(match.group(0)) is not None
fixture_like = is_fixture_like_path(path)
status = "not_applicable" if placeholder or fixture_like else "detected"
severity = "info" if status == "not_applicable" else "warn"
explanation = (
"Credential-like placeholder or test/example fixture ignored for C1 penalty."
if status == "not_applicable" else "Hardcoded credential-like pattern detected."
)
detected = detected or status == "detected"
placeholders = placeholders or status == "not_applicable"
add_finding(
target,
findings,
counters,
"C1_hardcoded_credentials",
"credential_pattern_v2",
status,
severity,
path,
line,
source_line(lines, line),
"regex",
explanation,
{"match": match.group(0), "placeholder": placeholder, "fixture_context": fixture_like},
)
if not detected and not placeholders:
add_finding(
target,
findings,
counters,
"C1_hardcoded_credentials",
"credential_pattern_v2",
"not_detected",
"info",
Path("."),
0,
"",
"regex",
"No hardcoded credential evidence detected for C1_hardcoded_credentials.",
)
def fail_open_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
paths: Iterable[Path],
) -> None:
detected = False
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
if path.suffix.lower() != ".py":
continue
text = read_text(path)
if not text:
continue
try:
tree = ast.parse(text)
except SyntaxError:
continue
lines = text.splitlines()
for node in ast.walk(tree):
if not isinstance(node, ast.ExceptHandler) or not _is_fail_open_handler(node):
continue
detected = True
line = getattr(node, "lineno", 0)
add_finding(
target,
findings,
counters,
"C4_exception_handling_clinical_adjacent_paths",
"fail_open_exception_v2",
"detected",
"warn",
path,
line,
source_line(lines, line),
"ast",
"Fail-open exception handler detected in executable Python code.",
)
if not detected:
add_finding(
target,
findings,
counters,
"C4_exception_handling_clinical_adjacent_paths",
"fail_open_exception_v2",
"not_detected",
"info",
Path("."),
0,
"",
"ast",
"No fail-open exception handler detected in executable Python code.",
)
def external_service_dependency_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
surface_paths: Iterable[Path],
config_paths: Iterable[Path],
) -> None:
required_env_rows = _required_external_service_rows(target, config_paths)
provider_lines = _provider_dependency_lines(target, surface_paths)
local_claim_lines = _local_self_host_claim_lines(target, surface_paths)
providers = sorted({row["provider"] for row in required_env_rows})
if required_env_rows and (provider_lines or local_claim_lines):
for row in required_env_rows:
add_finding(
target,
findings,
counters,
"R2R_D5_single_external_service_dependency",
"single_external_service_dependency_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"config",
"Required external service API key detected for a named workflow dependency.",
{"provider": row["provider"], "env_var": row["env_var"], "required_signal": True},
)
for row in provider_lines[:3]:
add_finding(
target,
findings,
counters,
"R2R_D5_single_external_service_dependency",
"single_external_service_dependency_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"regex",
"Named external service provider is presented as part of the core repository workflow.",
{"provider": row["provider"], "line_role": "provider_dependency_claim"},
)
for row in local_claim_lines[:3]:
add_finding(
target,
findings,
counters,
"R2R_D5_single_external_service_dependency",
"single_external_service_dependency_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"regex",
"Local or self-host claims coexist with required external service dependencies.",
{
"providers": providers,
"line_role": "local_or_self_host_claim",
"provider_count": len(providers),
},
)
return
add_finding(
target,
findings,
counters,
"R2R_D5_single_external_service_dependency",
"single_external_service_dependency_v1",
"not_detected",
"info",
Path("."),
0,
"",
"aggregate",
"No named required external service dependency pattern was detected.",
)
def unsupported_legal_or_compliance_claim_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
paths: Iterable[Path],
) -> None:
grounding_present = False
claim_rows: list[dict[str, object]] = []
for path in sorted(set(paths), key=lambda p: relative_path(target, p).as_posix()):
lines = read_text(path).splitlines()
for line_number, line in enumerate(lines, start=1):
if LEGAL_COMPLIANCE_GROUNDING_TERMS.search(line):
grounding_present = True
claim_match = LEGAL_COMPLIANCE_CLAIM_TERMS.search(line)
if not claim_match:
continue
if LEGAL_COMPLIANCE_NEGATION_TERMS.search(line):
continue
claim_rows.append(
{
"path": path,
"line": line_number,
"snippet": line,
"claim": claim_match.group(0),
}
)
if claim_rows and not grounding_present:
for row in claim_rows[:5]:
add_finding(
target,
findings,
counters,
"S1_R2_unsupported_legal_or_compliance_claim",
"unsupported_legal_or_compliance_claim_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"regex",
"Legal, privacy, or compliance claim detected without supporting governance or security-grounding evidence in reviewed sources.",
{"claim": row["claim"], "grounding_present": False},
)
return
add_finding(
target,
findings,
counters,
"S1_R2_unsupported_legal_or_compliance_claim",
"unsupported_legal_or_compliance_claim_v1",
"not_detected",
"info",
Path("."),
0,
"",
"aggregate",
"No unsupported legal or compliance claim pattern was detected.",
)
def mock_auth_or_fail_open_boundary_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
surface_paths: Iterable[Path],
config_paths: Iterable[Path],
code_paths: Iterable[Path],
) -> None:
review_paths = [*surface_paths, *config_paths, *code_paths]
local_claim_rows = _local_self_host_claim_lines(target, [*surface_paths, *config_paths])
mock_rows: list[dict[str, object]] = []
for path in sorted(set(review_paths), key=lambda p: relative_path(target, p).as_posix()):
lines = read_text(path).splitlines()
for line_number, line in enumerate(lines, start=1):
match = MOCK_AUTH_FAIL_OPEN_TERMS.search(line)
if not match:
continue
mock_rows.append(
{
"path": path,
"line": line_number,
"snippet": line,
"match": match.group(0),
}
)
if mock_rows:
for row in mock_rows[:4]:
add_finding(
target,
findings,
counters,
"C6_mock_auth_or_fail_open_boundary",
"mock_auth_fail_open_boundary_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"regex",
"Mock-auth, auto-login, or no-auth local/self-host boundary surfaced in reviewed sources.",
{"match": row["match"], "local_claim_context": bool(local_claim_rows)},
)
for row in local_claim_rows:
add_finding(
target,
findings,
counters,
"C6_mock_auth_or_fail_open_boundary",
"mock_auth_fail_open_boundary_v1",
"detected",
"warn",
row["path"],
row["line"],
row["snippet"],
"regex",
"Local or self-host claims coexist with mock-auth or auto-login boundary signals.",
{"line_role": "local_or_self_host_claim"},
)
return
add_finding(
target,
findings,
counters,
"C6_mock_auth_or_fail_open_boundary",
"mock_auth_fail_open_boundary_v1",
"not_detected",
"info",
Path("."),
0,
"",
"aggregate",
"No mock-auth or fail-open local-boundary pattern was detected.",
)
def _required_external_service_rows(target: Path, config_paths: Iterable[Path]) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for path in sorted(set(config_paths), key=lambda p: relative_path(target, p).as_posix()):
section_required = False
for line_number, line in enumerate(read_text(path).splitlines(), start=1):
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
if _OPTIONAL_HINT.search(stripped):
section_required = False
elif _REQUIRED_HINT.search(stripped):
section_required = True
continue
match = _API_KEY_ASSIGNMENT.match(stripped)
if not match:
continue
env_var = match.group(1)
provider = _EXTERNAL_PROVIDER_ENV_MAP.get(env_var)
if not provider or not section_required:
continue
rows.append(
{
"path": path,
"line": line_number,
"snippet": line,
"env_var": env_var,
"provider": provider,
}
)
return rows
def _provider_dependency_lines(target: Path, surface_paths: Iterable[Path]) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for path in sorted(set(surface_paths), key=lambda p: relative_path(target, p).as_posix()):
for line_number, line in enumerate(read_text(path).splitlines(), start=1):
if not _PROVIDER_LINE_HINT.search(line) and "API" not in line and "api" not in line:
continue
provider = _provider_name_from_text(line)
if not provider:
continue
rows.append({"path": path, "line": line_number, "snippet": line, "provider": provider})
return rows
def _local_self_host_claim_lines(target: Path, surface_paths: Iterable[Path]) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for path in sorted(set(surface_paths), key=lambda p: relative_path(target, p).as_posix()):
for line_number, line in enumerate(read_text(path).splitlines(), start=1):
if not LOCAL_SELF_HOST_CLAIM_TERMS.search(line):
continue
rows.append({"path": path, "line": line_number, "snippet": line})
return rows
def _provider_name_from_text(text: str) -> str | None:
lowered = text.lower()
for env_var, provider in _EXTERNAL_PROVIDER_ENV_MAP.items():
stem = env_var.replace("_API_KEY", "").replace("NEXT_PUBLIC_", "").replace("_SERVICE_ROLE_KEY", "").replace("_ANON_KEY", "")
provider_token = provider.lower()
if provider_token in lowered or stem.lower().replace("_", " ") in lowered or stem.lower() in lowered:
return provider
return None
def _is_fail_open_handler(node: ast.ExceptHandler) -> bool:
if not node.body:
return False
for child in node.body:
if isinstance(child, ast.Pass):
return True
if isinstance(child, ast.Return) and isinstance(child.value, ast.Constant) and child.value.value is True:
return True
return False
def file_presence_detector(
target: Path,
findings: list[EvidenceFinding],
counters: dict[tuple[str, str], int],
detector: str,
pattern_id: str,
paths: Iterable[Path],
explanation: str,
) -> None:
sorted_paths = sorted(set(paths), key=lambda p: relative_path(target, p).as_posix())
if not sorted_paths:
add_finding(target, findings, counters, detector, pattern_id, "not_detected", "info", Path("."), 0, "", "file_presence", f"No evidence detected for {detector}.")
return
for path in sorted_paths:
add_finding(target, findings, counters, detector, pattern_id, "detected", "info", path, 0, relative_path(target, path).as_posix(), "file_presence", explanation)