Files
RedBear-OS/local/scripts/tests/test_lint_recipe.py
T
vasilito ffbe098ef8 config: add tlc to redbear-mini and redbear-full; create recipe symlink
TLC (Twilight Commander) was missing from both ISO configs. Added
tlc = {} to [packages] in redbear-mini.toml and redbear-full.toml.
Created missing symlink: recipes/tui/tlc -> ../../local/recipes/tui/tlc.
2026-06-19 11:47:25 +03:00

446 lines
16 KiB
Python

"""Unit tests for local/scripts/lint-recipe.py.
Covers the 7 registered rules with synthetic recipe.toml fixtures
written to a tmpdir, plus the main() entry point with a fake
LOCAL_RECIPES / MAINLINE_RECIPES set.
Run: python3 -m unittest local/scripts/tests/test_lint_recipe.py -v
"""
import json
import os
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
from unittest import mock
SCRIPT_DIR = Path(__file__).resolve().parent
LINT_SCRIPT = SCRIPT_DIR.parent / "lint-recipe.py"
class LintRecipeFixture(unittest.TestCase):
"""Base class that creates a tmp project tree and runs the
linter against synthetic recipes inside it."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
for d in ["local/recipes/kde/kf6-foo",
"local/recipes/core/relibc",
"local/recipes/kde/kf6-clean",
"local/recipes/kde/kf6-with-patches",
"recipes/core/kernel"]:
(self.root / d / "recipe.toml").parent.mkdir(parents=True, exist_ok=True)
(self.root / d / "recipe.toml").write_text("")
(self.root / "local/patches/kf6-with-patches").mkdir(parents=True)
(self.root / "local/patches/kf6-with-patches/01-init.patch").write_text("")
def tearDown(self):
self.tmp.cleanup()
def write(self, recipe_path: str, content: str) -> Path:
path = self.root / recipe_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content))
return path
def run_lint(self, recipe_path: Path, extra_args=()):
import importlib.util
spec = importlib.util.spec_from_file_location("lint_recipe", LINT_SCRIPT)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
with mock.patch.object(mod, "PROJECT_ROOT", self.root), \
mock.patch.object(mod, "LOCAL_RECIPES", self.root / "local" / "recipes"), \
mock.patch.object(mod, "MAINLINE_RECIPES", self.root / "recipes"), \
mock.patch.object(mod, "LOCAL_PATCHES", self.root / "local" / "patches"):
return mod.lint_recipe(recipe_path, strict=False)
def findings_by_rule(self, findings):
return {rule_id: (sev, msg) for sev, rule_id, msg in findings}
class TestRule1NoPatchFile(LintRecipeFixture):
def test_missing_patch_file_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
git = "https://example.com/foo.git"
rev = "deadbeef"
patches = ["nope.patch"]
[build]
script = "echo build"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R1-NO-PATCH-FILE", rules)
sev, msg = rules["R1-NO-PATCH-FILE"]
self.assertEqual(sev, "error")
self.assertIn("nope.patch", msg)
def test_existing_patch_file_passes(self):
(self.root / "local/recipes/kde/kf6-foo/legit.patch").write_text("")
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
git = "https://example.com/foo.git"
rev = "deadbeef"
patches = ["legit.patch"]
[build]
script = "echo build"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R1-NO-PATCH-FILE", rules)
class TestRule1PathSource(LintRecipeFixture):
def test_in_tree_component_with_path_passes(self):
path = self.write("local/recipes/core/relibc/recipe.toml", """
[source]
path = "source"
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R1-PATH-SOURCE", rules)
def test_in_tree_component_with_tar_url_fires(self):
path = self.write("local/recipes/core/relibc/recipe.toml", """
[source]
tar = "https://example.com/relibc.tar.xz"
blake3 = "deadbeef"
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R1-PATH-SOURCE", rules)
sev, msg = rules["R1-PATH-SOURCE"]
self.assertEqual(sev, "warning")
def test_non_in_tree_component_with_tar_passes(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/kf6-foo.tar.xz"
blake3 = "deadbeef"
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R1-PATH-SOURCE", rules)
class TestRule2InlineSed(LintRecipeFixture):
def test_sed_without_patches_fires_error(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/kf6-foo.tar.xz"
[build]
script = '''
sed -i 's/foo/bar/' "${COOKBOOK_SOURCE}/file.c"
sed -i 's/baz/qux/' "${COOKBOOK_SOURCE}/file.c"
make
'''
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R2-INLINE-SED", rules)
sev, msg = rules["R2-INLINE-SED"]
self.assertEqual(sev, "error")
self.assertIn("2 `sed -i`", msg)
def test_sed_with_cookbook_apply_patches_fires_warning(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/kf6-foo.tar.xz"
[build]
script = '''
cookbook_apply_patches $REDBEAR_PATCHES_DIR
sed -i 's/foo/bar/' "${COOKBOOK_SOURCE}/file.c"
make
'''
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R2-INLINE-SED", rules)
sev, msg = rules["R2-INLINE-SED"]
self.assertEqual(sev, "warning")
self.assertIn("WITH-PATCHES", msg)
def test_build_time_seds_are_exempt(self):
# Seds that target ${COOKBOOK_STAGE}, ${COOKBOOK_BUILD},
# ${COOKBOOK_SYSROOT}, or non-source paths are exempt
# from R2 (those are build-time adjustments, not
# upstream source edits).
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/kf6-foo.tar.xz"
[build]
script = '''
sed -i 's/foo/bar/' "${COOKBOOK_BUILD}/Makefile"
sed -i 's/baz/qux/' "${COOKBOOK_STAGE}/usr/lib/foo"
sed -i 's/aaa/bbb/' "${COOKBOOK_SYSROOT}/lib/cmake/foo"
make
'''
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
# No R2 finding — all seds are build-time.
self.assertNotIn("R2-INLINE-SED", rules)
def test_no_sed_passes(self):
path = self.write("local/recipes/kde/kf6-clean/recipe.toml", """
[source]
tar = "https://example.com/clean.tar.xz"
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R2-INLINE-SED", rules)
class TestRule2PatchesDirConsistent(LintRecipeFixture):
def test_patches_dir_with_numbered_files_and_no_apply_fires(self):
path = self.write("local/recipes/kde/kf6-with-patches/recipe.toml", """
[source]
tar = "https://example.com/x.tar.xz"
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R2-PATCHES-DIR-UNUSED", rules)
sev, msg = rules["R2-PATCHES-DIR-UNUSED"]
self.assertEqual(sev, "error")
self.assertIn("PATCHES-DIR-UNUSED", msg)
def test_apply_patches_without_dir_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/x.tar.xz"
[build]
script = "cookbook_apply_patches /tmp/nope"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R2-PATCHES-DIR-UNUSED", rules)
sev, msg = rules["R2-PATCHES-DIR-UNUSED"]
self.assertEqual(sev, "error")
self.assertIn("APPLY-PATCHES-NO-DIR", msg)
def test_patches_dir_used_correctly_passes(self):
path = self.write("local/recipes/kde/kf6-with-patches/recipe.toml", """
[source]
tar = "https://example.com/x.tar.xz"
[build]
script = '''
REDBEAR_PATCHES_DIR=local/patches/kf6-with-patches
cookbook_apply_patches "$REDBEAR_PATCHES_DIR"
make
'''
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R2-PATCHES-DIR-UNUSED", rules)
class TestNoLegacyMake(LintRecipeFixture):
def test_make_all_config_name_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
tar = "https://example.com/x.tar.xz"
[build]
script = "make all CONFIG_NAME=redbear-full"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("NO-LEGACY-MAKE", rules)
sev, _ = rules["NO-LEGACY-MAKE"]
self.assertEqual(sev, "warning")
def test_make_live_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make live CONFIG_NAME=redbear-mini"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("NO-LEGACY-MAKE", rules)
def test_make_something_else_passes(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make install"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("NO-LEGACY-MAKE", rules)
class TestNoApplyPatchesSh(LintRecipeFixture):
def test_apply_patches_sh_reference_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "./apply-patches.sh"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("R1-LEGACY-APPLY-PATCHES", rules)
sev, _ = rules["R1-LEGACY-APPLY-PATCHES"]
self.assertEqual(sev, "error")
def test_no_reference_passes(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make"
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("R1-LEGACY-APPLY-PATCHES", rules)
class TestDepsResolve(LintRecipeFixture):
def test_redbear_dep_missing_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make"
dependencies = ["redbear-nonexistent-daemon"]
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("DEP-NOT-FOUND", rules)
sev, msg = rules["DEP-NOT-FOUND"]
self.assertEqual(sev, "error")
self.assertIn("redbear-nonexistent-daemon", msg)
def test_kf6_dep_missing_fires(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make"
dependencies = ["kf6-bogus-package"]
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertIn("DEP-NOT-FOUND", rules)
def test_known_dep_resolves(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make"
dependencies = ["kf6-clean"]
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("DEP-NOT-FOUND", rules)
def test_mainline_dep_resolves(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[build]
script = "make"
dependencies = ["kernel"]
""")
findings = self.run_lint(path)
rules = self.findings_by_rule(findings)
self.assertNotIn("DEP-NOT-FOUND", rules)
class TestCleanRecipe(LintRecipeFixture):
"""A well-formed clean recipe should produce zero findings."""
def test_clean_recipe_no_findings(self):
path = self.write("local/recipes/kde/kf6-clean/recipe.toml", """
[source]
tar = "https://example.com/clean.tar.xz"
blake3 = "abc123"
[build]
script = "make install"
""")
findings = self.run_lint(path)
# No rules should fire
self.assertEqual(findings, [], f"Expected no findings, got: {findings}")
class TestRecipeIndexCaching(unittest.TestCase):
"""Verify that build_recipe_index precomputes a usable lookup set."""
def setUp(self):
import importlib.util
spec = importlib.util.spec_from_file_location("lint_recipe", LINT_SCRIPT)
assert spec is not None and spec.loader is not None
self.mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.mod)
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
for f in ["local/recipes/kde/kf6-x/recipe.toml",
"recipes/core/kernel/recipe.toml",
"local/recipes/source/should-skip/recipe.toml",
"local/recipes/wip/should-skip/recipe.toml",
"local/recipes/kde/kf6-x/source/sub/recipe.toml"]:
(self.root / f).parent.mkdir(parents=True, exist_ok=True)
(self.root / f).write_text("")
def tearDown(self):
self.tmp.cleanup()
def test_index_includes_pkg_and_cat_pkg(self):
with mock.patch.object(self.mod, "LOCAL_RECIPES", self.root / "local" / "recipes"), \
mock.patch.object(self.mod, "MAINLINE_RECIPES", self.root / "recipes"):
idx = self.mod.build_recipe_index()
self.assertIn("kf6-x", idx)
self.assertIn("kde/kf6-x", idx)
self.assertIn("kernel", idx)
self.assertIn("core/kernel", idx)
self.assertNotIn("should-skip", idx)
class TestExitCodes(LintRecipeFixture):
"""End-to-end: clean recipe produces no findings, errors do."""
def test_clean_recipe_no_findings(self):
self.write("local/recipes/kde/kf6-clean/recipe.toml", """
[source]
tar = "https://example.com/clean.tar.xz"
[build]
script = "make"
""")
path = self.root / "local" / "recipes" / "kde" / "kf6-clean" / "recipe.toml"
findings = self.run_lint(path)
self.assertEqual(findings, [])
def test_error_recipe_exit_1(self):
path = self.write("local/recipes/kde/kf6-foo/recipe.toml", """
[source]
git = "https://example.com/foo.git"
rev = "deadbeef"
patches = ["missing.patch"]
[build]
script = "sed -i 's/a/b/' file && make"
""")
findings = self.run_lint(path)
self.assertTrue(any(s == "error" for s, _, _ in findings))
if __name__ == "__main__":
unittest.main()