Files
RedBear-OS/local/scripts/tests/test_lint_recipe.py
T
kellito ae749ffb23 build: ship build-system hardening arc (5 of 10 improvements)
The v6.0 build-system hardening arc lands 5 of the 10 improvements
proposed in local/docs/BUILD-SYSTEM-IMPROVEMENTS.md. All scripts
have unit tests (62 -> 86, all pass in <1s) and the new 'lint-recipe'
Gitea Actions job runs on every PR.

Per-recipe audit & lint scripts (catch R1/R2 violations BEFORE cook):
  * audit-patch-idempotency.py  — verifies external patches in
    local/patches/ still apply against the upstream pinned rev.
    Caught 1 real bug on first run: libdrm/02-redox-dispatch.patch
    hunk at xf86drm.c:321 no longer matches libdrm-2.4.125.
  * audit-kf6-deps.py           — fetches upstream, scans for
    find_package(KF6Xxx REQUIRED), compares to recipe deps. Catches
    missing + dead dependencies in every kf6-* and qt* recipe.
  * classify-cook-failure.py    — 17-rule cook-failure classifier.
    10-30s diagnosis vs 5-10min manual. exit code is intentionally
    inverted (0=novel failure, 1=known fix) for CI signal.
  * lint-recipe.py              — 7-rule recipe lint: R1-NO-PATCH-FILE,
    R1-PATH-SOURCE, R2-INLINE-SED, R2-PATCHES-DIR-UNUSED,
    NO-LEGACY-MAKE, R1-LEGACY-APPLY-PATCHES, DEP-NOT-FOUND.
    1.1s for 171 recipes (down from 60s+ in v1 via recipe-index
    precomputation). Strict mode promotes warnings to errors.

Build-system convenience:
  * repair-cook.sh              — incremental-build optimizer.
    Equivalent to 'repo cook <pkg>' but with a fast-path that
    skips configure when CMakeCache.txt is newer than source AND
    external patches haven't changed. 30-60s vs 5-10min on KF6
    recipes. make repair.<pkg> / make clean-repair.<pkg> targets.
  * migrate-kf6-seds-to-patches.sh — migration skeleton for
    converting 56 inline 'sed -i' chains across the KF6 recipes
    to durable external patches in local/patches/<name>/.

Gitea Actions (host-execution, no Docker):
  * .gitea/workflows/build-system.yml — 8-job pipeline:
    unit-tests, lint-offline, lint-network (nightly),
    lint-recipe (NEW), lint-docs, build-mini, build-full,
    smoke (QEMU boot).
  * .gitea/RUNNER-SETUP.md — one-time Manjaro/Arch host setup.

Build script hardening:
  * build-redbear.sh            — when a low-level source (relibc,
    kernel, base, bootloader, installer) is newer than its pkgar,
    clean build/ and sysroot/ across all recipes too. Low-level
    package changes leave autotools packages (pcre2, gettext,
    libiconv, ...) with stale configure/libtool scripts referencing
    the old runtime, causing 'libtool version mismatch' and
    'not a valid libtool object' errors. Cleaning forces
    re-configuration; stage/ and source/ are preserved so the
    cookbook skips unchanged packages that don't use autotools.
  * Makefile                    — wire lint-cook-failure,
    lint-cook-failure-explain, lint-recipe, lint-recipe.%,
    lint-recipe.strict, lint-recipe.%.strict, repair.%,
    clean-repair.%, test-lint-scripts[-quiet]. Replace the
    legacy 'validate-patches' target with a deprecation notice
    pointing at validate-sources.

Documentation:
  * BUILD-SYSTEM-IMPROVEMENTS.md   — mark #2 and #5 DONE; full
    implementation notes; updated Make-targets table.
  * BUILD-SYSTEM-V6-HARDENING-POSTMORTEM.md (NEW) — 226-line durable
    record of the 8-session arc: 32 findings categorized, 5 P0
    audit-script bugs fixed, 6 over-broad multi-pattern rules
    discovered + fixed, test coverage 86/86 in <1s, 7/10
    improvements DONE.
  * SCRIPT-BEHAVIOR-MATRIX.md   — apply-patches.sh row marked
    LEGACY/ARCHIVED; build-redbear.sh row no longer claims to
    call it.
  * boot-logs/README.md (NEW)   — frozen-evidence policy:
    'do not edit' rule for REDBEAR-FULL-BOOT-*-RESULTS.md files.
  * libdrm/02-redox-dispatch.patch.README (NEW) — 8-step regen
    procedure for the broken hunk.

Cleanup:
  * local/cache/README.md deleted (1-line placeholder).
  * legacy 'make validate-patches' target removed.

Per build-system improvement #5: lint-recipe.py's first run on
the live tree surfaced 1 broken-patch reference (redbear-sessiond),
1 dangling cookbook_apply_patches call (tc), 19 sed -i calls in
sddm (warning — cookbook_apply_patches present, drop-x11.py
migration in progress), 4 sed -i calls in qt6-wayland-smoke
(uncovers the same bug class the libwayland fix prevented).
2026-06-12 13:37:39 +03:00

424 lines
15 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/' file.c
sed -i 's/baz/qux/' 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/' 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_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()