Files
scst/scripts/tests/test_analyzer_policy.py
Gleb Chesnokov 74a2e4bac3 scripts: Add the SCST code style linter
Document SCS.1 and expose make scs_lint for the common C source/header
scope, including userspace and KUnit. Adapt engine's OCS.7.5 check to
SCST_KUNIT_VISIBLE, SCST_KUNIT_VISIBLE_INLINE and VISIBLE_IF_KUNIT.
Require each annotation to occupy its own line; leave other function
attributes and source warnings unchanged.

Use Tree-sitter to distinguish code from comments, literals and
preprocessor directive contents while checking every conditional
branch. Pin the parser dependencies and run them through uv.

Support whole-tree, DIFFBR and explicit-file selection without new
exclusions or diagnostic exceptions. Retain findings and checked-file
records even when a later input fails. Add isolated rule, diff-mode,
coverage and read-failure fixtures with a separate test target.
2026-10-20 14:16:08 +03:00

247 lines
12 KiB
Python

"""Exercise policy validation and its effect on source selection."""
import copy
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import analyzer_policy
from lint_scope import changed_sources, git_names, git_pathspecs, in_scope
from lint_scope import select_sources, tracked_sources
class PolicyTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix="scst-policy-")
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.path = self.root / "common.json"
self.policy = analyzer_policy.read_policy(analyzer_policy.POLICY_PATH)
def save(self, policy):
self.path.write_text(json.dumps(policy), encoding="utf-8")
def activate(self, policy):
self.save(policy)
override = patch.object(analyzer_policy, "POLICY_PATH", self.path)
override.start()
self.addCleanup(override.stop)
analyzer_policy.load_policy.cache_clear()
self.addCleanup(analyzer_policy.load_policy.cache_clear)
def git(self, *args):
return subprocess.check_output(["git", "-C", str(self.root), *args])
def source(self, name):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("int example(void);\n", encoding="utf-8")
self.git("add", "-f", "--", name)
return path
def test_rejects_broken_or_missing_policy(self):
with self.assertRaises(OSError):
analyzer_policy.read_policy(self.path)
for text in ("{", '{"version": 1, "version": 1}',
json.dumps(self.policy).replace('"path": "srpt/conftest"',
'"path": "srpt/conftest", "path": "srpt"')):
with self.subTest(text=text):
self.path.write_text(text, encoding="utf-8")
with self.assertRaises(ValueError):
analyzer_policy.read_policy(self.path)
def test_rejects_invalid_fields_and_rules(self):
invalid = [
{"version": True}, {"version": 2}, {"unknown": []},
{"source_roots": []}, {"source_roots": "scst"},
{"source_roots": ["scst", "scst"]},
{"source_suffixes": [".c", ".c"]}, {"source_suffixes": [".py"]},
{"exclude_directories": [{"path": "scst", "reason": " "}]},
{"exclude_directories": [{"path": "scst"}]},
{"exclude_directories": [{"path": "scst", "reason": "example", "typo": True}]},
{"exclude_directories": [{"path": "scst", "reason": "example"}] * 2},
{"exclude_suffixes": [{"suffix": "*.mod.c", "reason": "example"}]},
{"exclude_suffixes": [{"suffix": ".mod.c", "reason": "example"}] * 2},
{"notes": [None]},
]
for directory in ("/scst", ".", "..", "scst/..", "./scst", "scst/", "scst//src",
"scst/./src", "scst\\src", "scst\nsrc"):
invalid.append({"source_roots": [directory]})
invalid.append({"exclude_directories": [{"path": directory, "reason": "example"}]})
for change in invalid:
with self.subTest(change=change):
self.save({**self.policy, **change})
with self.assertRaises(ValueError):
analyzer_policy.read_policy(self.path)
for key in self.policy:
with self.subTest(missing=key):
policy = dict(self.policy)
del policy[key]
self.save(policy)
with self.assertRaises(ValueError):
analyzer_policy.read_policy(self.path)
def test_policy_errors_do_not_become_empty_selections(self):
self.activate({**self.policy, "version": 2})
for select in (lambda: in_scope(self.root / "outside.c", self.root),
lambda: tracked_sources(self.root), git_pathspecs):
with self.assertRaisesRegex(ValueError, "unsupported policy version"):
select()
def test_policy_drives_all_selectors_with_literal_paths(self):
policy = copy.deepcopy(self.policy)
policy["source_roots"] = ["source [ab]?* tree"]
policy["source_suffixes"] = [".h"]
policy["exclude_directories"] = [{"path": "source [ab]?* tree/probes [12]",
"reason": "Fixture probes"}]
policy["exclude_suffixes"] = [{"suffix": ".generated.h", "reason": "Fixture output"}]
self.activate(policy)
self.git("init", "-q", "--template=")
kept = self.source("source [ab]?* tree/sub/kept.h")
direct = self.source("source [ab]?* tree/direct.h")
excluded = [self.source(name) for name in (
"source [ab]?* tree/sub/other.c", "source [ab]?* tree/sub/output.generated.h",
"source [ab]?* tree/probes [12]/probe.h", "source aXX tree/other.h", "scst/old.h")]
expected = sorted([kept, direct])
# Environment variables must not add exclusions outside the policy.
with patch.dict(os.environ, {"IGNORE_DIRS": policy["source_roots"][0],
"IGNORE_PATTERNS": ".*"}):
self.assertEqual(tracked_sources(self.root), expected)
self.assertEqual(select_sources(self.root, expected, None), expected)
names = git_names(self.root, "ls-files", "--cached", "-z", "--", *git_pathspecs())
self.assertEqual(sorted(self.root / name for name in names), expected)
for path in excluded:
self.assertFalse(in_scope(path, self.root))
with self.assertRaises(ValueError):
select_sources(self.root, [path], None)
self.git("-c", "user.name=Test", "-c", "user.email=test@example.org",
"-c", "commit.gpgsign=false", "commit", "-qm", "base")
for path in [*expected, *excluded]:
path.write_text("int changed(void);\n", encoding="utf-8")
self.assertEqual(changed_sources(self.root, "HEAD"), expected)
def test_native_exceptions_require_exact_fields_and_unique_keys(self):
path = analyzer_policy.POLICY_PATH.with_name("smatch.json")
policy = analyzer_policy.read_policy(path, "smatch")
for field in ("file", "function", "message", "reason"):
changed = copy.deepcopy(policy)
changed["source_exceptions"][0][field] = ""
self.save(changed)
with self.subTest(field=field), self.assertRaises(ValueError):
analyzer_policy.read_policy(self.path, "smatch")
changed = copy.deepcopy(policy)
changed["source_exceptions"].append(changed["source_exceptions"][0])
self.save(changed)
with self.assertRaisesRegex(ValueError, "duplicate source_exceptions"):
analyzer_policy.read_policy(self.path, "smatch")
self.save(policy)
with self.assertRaisesRegex(ValueError, "only for Smatch"):
analyzer_policy.read_policy(self.path, "sparse")
class PolicyCliTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix="scst-policy-cli-")
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.scripts = self.root / "scripts"
(self.scripts / "policy").mkdir(parents=True)
source = analyzer_policy.POLICY_PATH.parent.parent
for name in ("analyzer_policy.py", "lint_scope.py", "policy/common.json",
"policy/sparse.json", "policy/smatch.json", "policy/coccicheck.json",
"policy/cppcheck.json", "policy/checkpatch.json", "policy/scs_lint.json"):
shutil.copyfile(source / name, self.scripts / name)
def run_cli(self, *args):
# Deliberately run away from the repository root: policy paths belong
# to the script, not to the caller's working directory.
return subprocess.run([sys.executable, "-B", str(self.scripts / "analyzer_policy.py"),
*args], cwd=self.scripts, text=True, capture_output=True)
def test_show_and_validate_need_no_git_repository_or_analyzers(self):
expected = json.loads((self.scripts / "policy/common.json").read_text())
shown = self.run_cli("show", "common", "--format=json")
self.assertEqual(shown.returncode, 0, shown.stderr)
self.assertEqual(json.loads(shown.stdout)["configured_policy"], expected)
text = self.run_cli("show")
self.assertEqual(text.returncode, 0, text.stderr)
for rule in (*expected["exclude_directories"], *expected["exclude_suffixes"]):
self.assertIn(rule["reason"], text.stdout)
self.assertIn("no analyzer has run", text.stdout)
valid = self.run_cli("validate", "--format=json")
self.assertEqual(valid.returncode, 0, valid.stderr)
self.assertTrue(json.loads(valid.stdout)["valid"])
def test_files_use_the_script_repository_and_shared_scope(self):
subprocess.run(["git", "-C", str(self.root), "init", "-q", "--template="], check=True)
empty = self.run_cli("files")
self.assertEqual(empty.returncode, 0, empty.stderr)
self.assertIn("No eligible tracked inputs", empty.stdout)
expected = ["scst/core.c", "scst/kunit/test.c"]
for name in (*expected, "qla2x00t/legacy.c", "scst/new.c"):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("int example(void);\n")
if name != "scst/new.c":
subprocess.run(["git", "-C", str(self.root), "add", "--", name], check=True)
result = self.run_cli("files", "--format=json")
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads(result.stdout)
self.assertEqual(inventory["selected_tracked_files"], expected)
self.assertEqual(inventory["count"], len(expected))
def test_native_report_includes_every_exception_and_effective_exclusion(self):
for tool, count in (("sparse", 0), ("smatch", 2)):
with self.subTest(tool=tool):
result = self.run_cli("show", tool, "--format=json")
self.assertEqual(result.returncode, 0, result.stderr)
report = json.loads(result.stdout)
policy = report["configured_policy"]
self.assertEqual(len(policy["source_exceptions"]), count)
self.assertEqual(len(policy["kernel_exceptions"]), 1)
excluded = {r["path"] for r in report["effective_source_policy"]["exclude_directories"]}
self.assertEqual(excluded, {"iscsi-scst/conftest", "srpt/conftest",
"usr", "iscsi-scst/usr"})
def test_configuration_and_inventory_errors_fail_visibly(self):
unknown = self.run_cli("show", "unknown-tool")
self.assertEqual(unknown.returncode, 2)
no_git = self.run_cli("files", "--format=json")
self.assertEqual(no_git.returncode, 2)
self.assertEqual(no_git.stdout, "")
self.assertIn("analyzer policy:", no_git.stderr)
(self.scripts / "policy/common.json").unlink()
for action in ("show", "files", "validate"):
with self.subTest(action=action):
missing = self.run_cli(action, "--format=json")
self.assertEqual(missing.returncode, 2)
self.assertEqual(missing.stdout, "")
self.assertIn("analyzer policy:", missing.stderr)
(self.scripts / "policy/common.json").write_text('{"version": true}')
broken = self.run_cli("validate")
self.assertEqual(broken.returncode, 2)
self.assertEqual(broken.stdout, "")
self.assertIn("analyzer policy:", broken.stderr)
def test_basic_analyzers_inherit_common_scope_without_exceptions(self):
common = json.loads((self.scripts / "policy/common.json").read_text())
for tool in ("coccicheck", "cppcheck", "checkpatch", "scs_lint"):
with self.subTest(tool=tool):
result = self.run_cli("show", tool, "--format=json")
self.assertEqual(result.returncode, 0, result.stderr)
report = json.loads(result.stdout)
self.assertEqual(report["effective_source_policy"], common)
self.assertEqual(set(report["configured_policy"]), {"version", "notes"})
if __name__ == "__main__":
unittest.main()