diff --git a/src/skillspector/nodes/analyzers/static_runner.py b/src/skillspector/nodes/analyzers/static_runner.py index 0161f9db..0c2554cf 100644 --- a/src/skillspector/nodes/analyzers/static_runner.py +++ b/src/skillspector/nodes/analyzers/static_runner.py @@ -69,6 +69,10 @@ "eval/dataset.yml", } +_LICENSE_FILE_TYPES = frozenset({"markdown", "text", "other"}) +_LICENSE_BASENAME = re.compile(r"^(?:license|licenses|copying|notice|notices)(?:[._-].*)?$") +_LICENSE_OTHER_SUFFIXES = frozenset({".lesser"}) + def _infer_file_type(path: str) -> str: """Infer file type from path (extension).""" @@ -77,6 +81,18 @@ def _infer_file_type(path: str) -> str: return FILE_TYPES.get(suffix, "other") +def _is_license_basename(path: str, file_type: str) -> bool: + """Return whether a text-like path has a conventional legal-file basename.""" + if file_type not in _LICENSE_FILE_TYPES: + return False + basename = path.replace("\\", "/").rsplit("/", 1)[-1] + if file_type == "other" and "." in basename: + suffix = "." + basename.rsplit(".", 1)[-1].casefold() + if suffix not in _LICENSE_OTHER_SUFFIXES: + return False + return _LICENSE_BASENAME.fullmatch(basename.casefold()) is not None + + _BINARY_EXTENSIONS = frozenset( { ".pdf", @@ -289,6 +305,9 @@ def _scan_path(path: str, content: str, pattern_modules: list) -> list[Finding]: for module in pattern_modules: raw = module.analyze(content=content, file_path=path, file_type=file_type) for af in raw: + if af.rule_id == "EA3" and _is_license_basename(path, file_type): + logger.debug("Filtered EA3 license boilerplate finding: %s", path) + continue if _is_env_file_reference_in_docs(af, file_type, path, content): logger.debug( "Filtered PE3 .env doc reference: %s in %s:%d", diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index ff025395..b7021836 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -17,14 +17,20 @@ from __future__ import annotations +from unittest.mock import MagicMock + import pytest +from skillspector.models import AnalyzerFinding, Location, Severity from skillspector.nodes.analyzers import ( static_patterns_agent_snooping as agent_snooping_module, ) from skillspector.nodes.analyzers import ( static_patterns_data_exfiltration as data_exfiltration_module, ) +from skillspector.nodes.analyzers import ( + static_patterns_excessive_agency as excessive_agency_module, +) from skillspector.nodes.analyzers import ( static_patterns_memory_poisoning as memory_poisoning_module, ) @@ -1002,3 +1008,135 @@ def test_trigger_analysis_uses_distinct_work_after_static_skip(self): events = result["inspection_ledger"] assert [event["outcome"] for event in events] == ["skipped", "completed"] assert len({event["work_id"] for event in events}) == 2 + + +class TestLicenseFiles: + @pytest.mark.parametrize( + "path", + [ + "LICENSE", + "licenses", + "licenses/LICENSE", + "COPYING", + "NOTICE", + "NOTICES", + "LICENSE.txt", + "license-MIT", + "COPYING.LESSER", + "NOTICE.md", + ], + ) + def test_license_families_suppress_only_ea3(self, path: str) -> None: + state = { + "components": [path], + "file_cache": {path: "Responsibilities are not limited to the items described above."}, + } + + findings = static_runner.run_static_patterns(state, [excessive_agency_module]) + + assert not any(f.rule_id == "EA3" for f in findings) + + def test_non_ea3_finding_is_preserved_on_license(self) -> None: + non_ea3 = AnalyzerFinding( + rule_id="TM1", + message="Tool misuse", + severity=Severity.MEDIUM, + location=Location(file="LICENSE", start_line=1), + confidence=0.8, + tags=["tool_misuse"], + context="Responsibilities are not limited to the items described above.", + matched_text="not limited to", + ) + ea3 = AnalyzerFinding( + rule_id="EA3", + message="Scope creep", + severity=Severity.LOW, + location=Location(file="LICENSE", start_line=1), + confidence=0.7, + context=non_ea3.context, + matched_text=non_ea3.matched_text, + ) + module = MagicMock() + module.analyze.return_value = [ea3, non_ea3] + + findings = static_runner.run_static_patterns( + { + "components": ["LICENSE"], + "file_cache": {"LICENSE": non_ea3.context}, + }, + [module], + ) + + assert len(findings) == 1 + finding = findings[0] + assert finding.rule_id == non_ea3.rule_id + assert finding.message == non_ea3.message + assert finding.severity == non_ea3.severity.value + assert finding.confidence == non_ea3.confidence + assert finding.file == non_ea3.location.file + assert finding.start_line == non_ea3.location.start_line + assert finding.tags == non_ea3.tags + assert finding.context == non_ea3.context + assert finding.matched_text == non_ea3.matched_text + module.analyze.assert_called_once_with( + content=non_ea3.context, + file_path="LICENSE", + file_type="other", + ) + + @pytest.mark.parametrize( + "path", + [ + "SKILL.md", + "README.md", + "README.txt", + "docs/guide.md", + "LICENSES/guide.md", + "license_terms.py", + ], + ) + def test_non_license_paths_preserve_ea3(self, path: str) -> None: + state = { + "components": [path], + "file_cache": {path: "Responsibilities are not limited to the items described above."}, + } + + findings = static_runner.run_static_patterns(state, [excessive_agency_module]) + + assert any(f.rule_id == "EA3" and f.file == path for f in findings) + + @pytest.mark.parametrize( + "path,expected", + [ + ("LICENSE", True), + ("docs\\license-mit", True), + ("NOTICE.md", True), + ("licensing.md", False), + ("LICENSES/guide.md", False), + ("THIRD_PARTY_NOTICES.md", False), + ("licence-check.sh", False), + ("license_terms.py", False), + ("license.php", False), + ("notice.c", False), + ], + ) + def test_helper_boundaries(self, path: str, expected: bool) -> None: + file_type = static_runner._infer_file_type(path) + + assert static_runner._is_license_basename(path, file_type) is expected + + def test_license_is_completed_in_ledger_without_emitted_ids(self) -> None: + path = "LICENSE" + state = { + "components": [path], + "file_cache": {path: "Responsibilities are not limited to the items described above."}, + } + + result = static_runner.run_static_patterns_with_ledger(state, [excessive_agency_module]) + + assert state["components"] == [path] + assert path in state["file_cache"] + assert result["findings"] == [] + assert result["inspection_ledger"][0]["outcome"] == "completed" + assert result["inspection_ledger"][0]["path"] == path + assert result["inspection_ledger"][0]["emitted_finding_ids"] == [] diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 9173e499..1bdfc094 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -168,6 +168,15 @@ def test_ea2_uvm_code_example_not_flagged(self) -> None: def test_ea3_detected(self, content: str) -> None: assert any(f.rule_id == "EA3" for f in ea_mod.analyze(content, "SKILL.md", "markdown")) + def test_ea3_direct_analyzer_accepts_license_path(self) -> None: + findings = ea_mod.analyze( + "Responsibilities are not limited to the items described above.", + "LICENSE", + "other", + ) + + assert any(f.rule_id == "EA3" for f in findings) + @pytest.mark.parametrize( "content,filename,filetype", [