"""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()