Files
RedBear-OS/local/scripts/tests/test_classify_cook_failure.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

298 lines
13 KiB
Python

#!/usr/bin/env python3
"""Smoke tests for classify-cook-failure.py.
Run with:
python3 -m unittest local/scripts/tests/test_classify_cook_failure.py
or
cd local/scripts && python3 -m unittest discover -s tests
"""
import json
import re
import sys
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(SCRIPTS_DIR))
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"ccf", SCRIPTS_DIR / "classify-cook-failure.py"
)
assert _spec is not None and _spec.loader is not None
ccf = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(ccf)
class TestRuleFires(unittest.TestCase):
"""Each of the 17 rules must fire on a synthetic log that exercises it."""
def test_rule_4_kfilesystemtype_fires(self):
log = (
"[ 12%] Building CXX object kfilesystemtype.cpp.o\n"
"kfilesystemtype.cpp:42:1: error: determineFileSystemTypeImpl "
"was not declared in this scope"
)
self.assertTrue(
_matches(ccf.RULES, log, "kfilesystemtype static function collision")
)
def test_rule_7_ninja_fires(self):
log = "ninja: error: No such file. CMake Error: ninja-build missing"
self.assertTrue(_matches(ccf.RULES, log, "ninja not found in sysroot"))
def test_rule_9_libmount_fires(self):
log = "CMake Error: Could NOT find LibMount (missing: LibMount_DIR)"
self.assertTrue(_matches(ccf.RULES, log, "LibMount missing (kf6-kio)"))
def test_rule_10_qfloat16_fires(self):
log = "undefined reference to `__extendhfdf2'"
self.assertTrue(_matches(ccf.RULES, log, "qfloat16 linker error (libsoftfloat missing)"))
def test_rule_11_kconfig_stale_fires(self):
log = (
'CMake Error at CMakeLists.txt:42 (find_package):\n'
' Found unsuitable version "5.103.0" of KF6CoreAddons, '
'but KF6Config requires exactly "6.26.0"'
)
self.assertTrue(_matches(ccf.RULES, log, "kconfig stale sysroot (KF6CoreAddons version mismatch)"))
class TestRuleDoesNotFire(unittest.TestCase):
"""Generic C++ errors must NOT trigger narrowly-scoped rules."""
def test_rule_4_does_not_fire_on_generic_cpp_error(self):
log = "bar.cpp:1:1: error: two or more data types in declaration specifiers"
# No kfilesystemtype in log -> context_required gate blocks the rule.
self.assertFalse(_matches(ccf.RULES, log, "kfilesystemtype static function collision"))
def test_rule_11_does_not_fire_on_openssl_error(self):
log = (
"CMake Error: Could NOT find OpenSSL (missing: OpenSSL_DIR)\n"
"Found unsuitable version \"1.1\", but required is 3.0\n"
"(looked in KF6Config.cmake)"
)
# KF6CoreAddons NOT mentioned -> context_required gate blocks it.
self.assertFalse(_matches(ccf.RULES, log, "kconfig stale sysroot"))
def test_rule_11_does_not_fire_on_clean_log(self):
log = "Built target relibc\nBuilt target base\n[100%] Built target all"
self.assertFalse(_matches(ccf.RULES, log, "kconfig stale sysroot"))
class TestExitCodeSemantics(unittest.TestCase):
"""--json exit code must be 1 if a rule matches, 0 if not (CI-safe)."""
def setUp(self):
self.tmp_log = Path("/tmp/ccf-test-exit-match.txt")
self.tmp_log.write_text(
"kfilesystemtype.cpp:42: error: determineFileSystemTypeImpl "
"was not declared in this scope"
)
self.tmp_clean = Path("/tmp/ccf-test-exit-clean.txt")
self.tmp_clean.write_text("Built target all\n[100%] Built target")
def tearDown(self):
for p in (self.tmp_log, self.tmp_clean):
if p.exists():
p.unlink()
def test_matched_log_exits_1(self):
import subprocess
rc = subprocess.run(
["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"),
str(self.tmp_log), "--json"],
capture_output=True, text=True,
).returncode
# Exit 1 == "I identified a known failure" (CI signal that a
# fix is available). Exit 0 == "no known pattern matched"
# (novel failure, needs human triage).
self.assertEqual(rc, 1, f"expected 1 (matched), got {rc}")
def test_clean_log_exits_0(self):
import subprocess
rc = subprocess.run(
["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"),
str(self.tmp_clean), "--json"],
capture_output=True, text=True,
).returncode
self.assertEqual(rc, 0, f"expected 0 (clean), got {rc}")
class TestExplainRule(unittest.TestCase):
def test_explain_rule_kfilesystemtype(self):
import subprocess
out = subprocess.run(
["python3", str(SCRIPTS_DIR / "classify-cook-failure.py"),
"--explain-rule", "kfilesystem"],
capture_output=True, text=True,
)
self.assertIn("RULE: kfilesystemtype", out.stdout)
self.assertIn("Context required:", out.stdout)
def _matches(rules, log, target_name):
"""Return True if any rule whose name STARTS WITH `target_name` matches `log`.
Substring match (rather than exact match) lets the test file
use short, human-readable rule names like "kconfig stale sysroot"
that match the full rule name "kconfig stale sysroot (KF6CoreAddons
version mismatch)". If multiple rules share the prefix, the first
one that matches the log wins.
"""
for r in rules:
if not r["name"].lower().startswith(target_name.lower()):
continue
patterns = r["patterns"]
if not all(re.search(p, log) for p in patterns):
continue
context = r.get("context_required")
if context and not all(tok in log for tok in context):
continue
return True
return False
class TestUntestedRules(unittest.TestCase):
"""Cover the 12 rules that have NO test in TestRuleFires.
These rules are exercised in real cooks but lack synthetic-log
coverage. The tests below are intentionally minimal — a one-line
log that exercises the rule's pattern + any context_required gate.
"""
def test_rule_0_glesv2_fires(self):
log = "CMake Error: Could NOT find GLESv2 (missing: GLESv2_DIR)"
self.assertTrue(_matches(ccf.RULES, log, "GLESv2 / Qt6Gui visibility"))
def test_rule_1_kiconloader_fires(self):
# Real GCC linker output: "undefined reference to `KIconLoader::instance'"
# (the `.` in the regex matches the backtick before KIconLoader)
log = "undefined reference to vKIconLoader::instance"
self.assertTrue(_matches(ccf.RULES, log, "KIconLoader undefined reference"))
def test_rule_3_cxx20_ranges_fires(self):
log = "error: 'std::ranges' has not been declared"
self.assertTrue(_matches(ccf.RULES, log, "C++20 std::ranges not declared"))
def test_rule_4_qt6guifrivate_fires(self):
log = "CMake Error: Could NOT find Qt6GuiPrivate"
self.assertTrue(_matches(ccf.RULES, log, "Qt6::GuiPrivate not found"))
def test_rule_5_plasmawaylandprotocols_fires(self):
log = "By not providing PlasmaWaylandProtocols the recipe failed to find"
self.assertTrue(_matches(ccf.RULES, log, "PlasmaWaylandProtocols path-doubling bug"))
def test_rule_10_libc_so_6_fires(self):
log = "/usr/bin/ld: warning: libc.so.6 not found, treating as static"
self.assertTrue(_matches(ccf.RULES, log, "libc.so.6 not found"))
def test_rule_11_gettext_fires(self):
log = "gettext-tools: ./configure failed: HAVE_STDBOOL not defined"
self.assertTrue(_matches(ccf.RULES, log, "gettext gnulib rebuild loop"))
def test_rule_12_python3_fires(self):
log = "CMake Error: Python3 Development not found (missing: Python3_LIBRARIES)"
self.assertTrue(_matches(ccf.RULES, log, "Python3 development headers missing"))
def test_rule_13_cookbook_apply_patches_fires(self):
log = "cookbook_apply_patches: FAILED to apply 02-redox-dispatch.patch"
self.assertTrue(_matches(ccf.RULES, log, "cookbook_apply_patches"))
def test_rule_14_package_not_found_fires(self):
log = "Cookbook error: Package 'kf6-kimageformats' not found in any active filesystem"
self.assertTrue(_matches(ccf.RULES, log, "Package <X> not found"))
def test_rule_15_qvariant_fires(self):
# Real qApp->property() in a private header produces a stack
# trace like this. The rule's pattern uses [\s\S]{0,N} to span
# the lines. The context_required gate is QString + QCoreApplication
# — both must appear in the log for the rule to fire.
log = (
"[ 50%] Building CXX object foo.cpp.o\n"
"In file included from /usr/include/QtCore/QString:1\n"
"In file included from /usr/include/QtCore/QCoreApplication:1\n"
"foo.cpp:42:1: error: 'QVariant' was not declared in this scope\n"
" auto v = qApp->property(\"kde.foo\").toString();\n"
" ^~~~~~~"
)
self.assertTrue(_matches(ccf.RULES, log, "QVariant not declared"))
def test_rule_16_fetch_denied_fires(self):
log = "Cookbook: relibc is not exist and unable to continue in offline mode"
self.assertTrue(_matches(ccf.RULES, log, "fetch denied"))
class TestRuleFalsePositives(unittest.TestCase):
"""Negative cases: synthetic logs that should NOT trigger rules.
These exist to catch future regex over-broadening regressions.
Each test constructs a log that LOOKS similar to a real rule
trigger but is missing a required context or pattern piece.
"""
def test_rule_0_glesv2_does_not_fire_without_keyword(self):
# "OpenGL" alone should not trigger the GLESv2 rule
log = "warning: OpenGL ES 2.0 is preferred but unavailable"
self.assertFalse(_matches(ccf.RULES, log, "GLESv2 / Qt6Gui visibility"))
def test_rule_1_kiconloader_does_not_fire_for_ki18n(self):
# Similar prefix, different symbol
log = "undefined reference to `KLocalizedString::localizedString'"
self.assertFalse(_matches(ccf.RULES, log, "KIconLoader undefined reference"))
def test_rule_3_cxx20_does_not_fire_for_std_string(self):
log = "error: 'std::string' has not been declared"
self.assertFalse(_matches(ccf.RULES, log, "C++20 std::ranges not declared"))
def test_rule_4_qt6guifrivate_does_not_fire_for_qt6core(self):
log = "CMake Error: Could NOT find Qt6Core (missing: Qt6Core_DIR)"
self.assertFalse(_matches(ccf.RULES, log, "Qt6::GuiPrivate not found"))
def test_rule_5_plasmawaylandprotocols_does_not_fire_unrelated(self):
# The string "PlasmaWaylandProtocols" must appear to trigger
# the rule. A log about wayland-protocols without the
# Plasma prefix should not match.
log = "wayland-protocols not found in sysroot"
self.assertFalse(_matches(ccf.RULES, log, "PlasmaWaylandProtocols path-doubling bug"))
def test_rule_10_libc_does_not_fire_for_libpthread(self):
log = "/usr/bin/ld: libpthread.so.0: cannot open shared object file: not found"
self.assertFalse(_matches(ccf.RULES, log, "libc.so.6 not found"))
def test_rule_11_gettext_does_not_fire_unrelated(self):
log = "gettext is missing, install gettext first"
self.assertFalse(_matches(ccf.RULES, log, "gettext gnulib rebuild loop"))
def test_rule_12_python3_does_not_fire_unrelated(self):
# The exact phrases are required
log = "Python interpreter not found in PATH"
self.assertFalse(_matches(ccf.RULES, log, "Python3 development headers missing"))
def test_rule_13_cookbook_apply_patches_does_not_fire_on_cookbook_msgs(self):
# The cookbook logs MANY cookbook_apply_patches lines on
# every successful cook. Only FAILED lines should fire.
log = "cookbook_apply_patches: applied 02-redox-dispatch.patch successfully"
self.assertFalse(_matches(ccf.RULES, log, "cookbook_apply_patches"))
def test_rule_14_package_not_found_does_not_fire_unrelated(self):
# Need "Package <X> not found" — note the word boundary
log = "warning: package was not found in any cache"
self.assertFalse(_matches(ccf.RULES, log, "Package <X> not found"))
def test_rule_15_qvariant_does_not_fire_without_qapp(self):
# Without qApp[\s\S]{0,N}property within range, the rule
# must not fire. Real QVariant errors are usually just the
# "not declared" line, not the full multi-line stack trace.
log = "QVariant not declared"
self.assertFalse(_matches(ccf.RULES, log, "QVariant not declared"))
def test_rule_16_fetch_denied_does_not_fire_unrelated(self):
# Must match either of the two specific phrases
log = "Cookbook: unable to fetch in offline mode"
self.assertFalse(_matches(ccf.RULES, log, "fetch denied"))
if __name__ == "__main__":
unittest.main()