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

458 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""lint-recipe.py — per-recipe v6.0-policy lint.
Per `local/docs/BUILD-SYSTEM-IMPROVEMENTS.md` build-system
improvement #5, this script validates a single recipe's
[source] / [build] / [package] blocks against the v6.0 fork
model (Rule 1 in-tree direct edit, Rule 2 external patches).
Build-time recipe lint catches policy violations BEFORE the slow
cook starts. Each rule has:
- id: short identifier (e.g. R1-NO-PATCH-FILE)
- severity: error | warning
- description: one-line human-readable summary
- check(path): the actual validation function
Exit code:
0 = clean (no errors; warnings allowed)
1 = errors found (one or more `severity: error` rules failed)
2 = bad usage (no recipe path, file not found, etc.)
Usage:
./local/scripts/lint-recipe.py <recipe-path> # lint one
./local/scripts/lint-recipe.py --all # all recipes
./local/scripts/lint-recipe.py --category=kde # one category
./local/scripts/lint-recipe.py --json <recipe-path> # machine-readable
./local/scripts/lint-recipe.py --strict <recipe-path> # warnings are errors
"""
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
LOCAL_RECIPES = PROJECT_ROOT / "local" / "recipes"
MAINLINE_RECIPES = PROJECT_ROOT / "recipes"
LOCAL_PATCHES = PROJECT_ROOT / "local" / "patches"
# ---------------------------------------------------------------------------
# Rule 1 (in-tree Red Bear component) — must be a direct edit, not a
# patch on top of upstream
# ---------------------------------------------------------------------------
def check_rule_1_no_redox_patch_in_source_block(path: Path, recipe: dict) -> list[str]:
"""Rule 1 recipes must not reference a `patches = [...]` block that
points at a non-existent patch file (the v5.x overlay anti-pattern).
Triggers the find-package-KF6P-NO-PATCH-FILE bug seen in libwayland
(commit 7ebffe9c2, fixed in this arc).
"""
errors = []
source = recipe.get("source", {})
if not isinstance(source, dict):
return errors
patches = source.get("patches", [])
if not isinstance(patches, list):
return errors
recipe_dir = path.parent
for patch_name in patches:
# Recipe-local patch (legacy v5.x overlay model)
candidate = recipe_dir / patch_name
if not candidate.exists():
errors.append(
f"R1-NO-PATCH-FILE: source.patches references {patch_name!r} "
f"but {candidate} does not exist. Either restore the "
f"patch file or remove the `patches = [...]` line."
)
return errors
def check_rule_1_path_source(path: Path, recipe: dict) -> list[str]:
"""Rule 1 in-tree components (kernel, relibc, base, installer,
bootloader, redox-drm) must use `[source] path = "source"`, NOT
a tar URL (which would put them under Rule 2).
"""
errors = []
name = path.parent.name
IN_TREE_COMPONENTS = {
"kernel", "relibc", "base", "bootloader", "installer",
"redox-drm", "redoxfs", "userutils", "libpciaccess",
}
if name not in IN_TREE_COMPONENTS:
return errors
source = recipe.get("source", {})
if "path" not in source and "tar" not in source and "git" not in source:
errors.append(
f"R1-NO-SOURCE: {name} is an in-tree Red Bear component but "
f"the recipe has no [source] entry. Add `path = \"source\"`."
)
if "tar" in source or "git" in source:
errors.append(
f"R1-WRONG-SOURCE-KIND: {name} is an in-tree Red Bear "
f"component but the recipe references upstream via "
f"`{('tar' if 'tar' in source else 'git')} =`. Per Rule 1, "
f"in-tree components must use `path = \"source\"` (direct edit)."
)
return errors
# ---------------------------------------------------------------------------
# Rule 2 (big external project) — must use cookbook_apply_patches
# ---------------------------------------------------------------------------
def check_rule_2_inline_sed_in_script(path: Path, recipe: dict) -> list[tuple[str, str]]:
"""Returns [(severity, message), ...]. Severity is `error` when the
recipe has sed -i chains and no cookbook_apply_patches call; `warning`
when both are present (partially migrated).
"""
findings: list[tuple[str, str]] = []
name = path.parent.name
build = recipe.get("build", {})
if not isinstance(build, dict):
return findings
script = build.get("script", "")
if not isinstance(script, str):
return findings
sed_count = len(re.findall(r"\bsed\s+-i\b", script))
if sed_count == 0:
return findings
if "cookbook_apply_patches" in script:
findings.append((
"warning",
f"R2-INLINE-SED-WITH-PATCHES: {name} has {sed_count} `sed -i` "
f"call(s) in [build].script AND a cookbook_apply_patches "
f"call. The sed chains should be migrated to "
f"local/patches/{name}/NN-*.patch files for durability. "
f"See local/scripts/migrate-kf6-seds-to-patches.sh."
))
else:
findings.append((
"error",
f"R2-INLINE-SED-NO-PATCHES: {name} has {sed_count} `sed -i` "
f"call(s) in [build].script. Per Rule 2, all upstream "
f"edits should live in `local/patches/{name}/` and be "
f"applied via `cookbook_apply_patches` in the build "
f"script. Inline `sed -i` chains do not survive "
f"`make clean` or upstream syncs."
))
return findings
def check_rule_2_patches_dir_consistent(path: Path, recipe: dict) -> list[str]:
"""If local/patches/<name>/ exists, the recipe's build script
must call cookbook_apply_patches. Conversely, if the script
calls cookbook_apply_patches, the patches dir must exist.
"""
errors = []
name = path.parent.name
build = recipe.get("build", {})
if not isinstance(build, dict):
return errors
script = build.get("script", "")
if not isinstance(script, str):
return errors
patches_dir = LOCAL_PATCHES / name
has_patches_dir = patches_dir.is_dir()
applies_patches = "cookbook_apply_patches" in script
if has_patches_dir and not applies_patches:
# Check if any patches exist (numbered)
has_numbered = any(patches_dir.glob("[0-9]*.patch"))
if has_numbered:
errors.append(
f"R2-PATCHES-DIR-UNUSED: {name} has a non-empty "
f"`local/patches/{name}/` directory but the build "
f"script does NOT call cookbook_apply_patches. "
f"The patches are silently ignored."
)
if applies_patches and not has_patches_dir:
errors.append(
f"R2-APPLY-PATCHES-NO-DIR: {name} build script calls "
f"cookbook_apply_patches but `local/patches/{name}/` "
f"does not exist. Either create the dir (with at least "
f"one patch) or remove the cookbook_apply_patches call."
)
return errors
# ---------------------------------------------------------------------------
# No legacy build commands in the recipe
# ---------------------------------------------------------------------------
def check_no_legacy_make_all_in_script(path: Path, recipe: dict) -> list[str]:
"""A recipe's [build].script must not contain `make all CONFIG_NAME=`
or `make live CONFIG_NAME=` — those are the build-system's
underlying primitives, not the canonical v6.0 entry point.
"""
errors = []
build = recipe.get("build", {})
if not isinstance(build, dict):
return errors
script = build.get("script", "")
if not isinstance(script, str):
return errors
if re.search(r"\bmake\s+(all|live)\s+CONFIG_NAME=", script):
errors.append(
f"NO-LEGACY-MAKE: {path.parent.name} [build].script uses "
f"`make all/live CONFIG_NAME=`. That is the underlying "
f"primitive; the canonical v6.0 entry is "
f"`local/scripts/build-redbear.sh <profile>`. Per-recipe "
f"cooks should use `./target/release/repo cook <recipe>` "
f"or the `make repair.<pkg>` target."
)
return errors
def check_no_apply_patches_sh(path: Path, recipe: dict) -> list[str]:
"""A recipe's [build].script must not reference apply-patches.sh
(the legacy v5.x overlay mechanism). Per `local/AGENTS.md` Rule 1,
in-tree components are NOT patched via apply-patches.sh.
"""
errors = []
build = recipe.get("build", {})
if not isinstance(build, dict):
return errors
script = build.get("script", "")
if not isinstance(script, str):
return errors
if "apply-patches.sh" in script:
errors.append(
f"R1-LEGACY-APPLY-PATCHES: {path.parent.name} references "
f"`apply-patches.sh` in [build].script. That is the "
f"legacy v5.x overlay mechanism. Per Rule 1 (in-tree "
f"direct edit) and Rule 2 (external patches), this should "
f"be removed."
)
return errors
# ---------------------------------------------------------------------------
# Dependencies are real (every dep must resolve to a recipe)
# ---------------------------------------------------------------------------
def build_recipe_index() -> set[str]:
"""Build a set of every recipe name available in local/recipes/ + recipes/.
Computed once per lint run and passed via `recipe_index` to rule checks.
Recipe name = `<cat>/<pkg>` to disambiguate `core/kernel` (mainline)
from `core/ext4d` (local). For dep lookup, however, we only need the
bare pkg name (deps are bare strings in recipe.toml). We index both
`pkg` and `<cat>/<pkg>` so callers can choose the lookup granularity.
"""
names: set[str] = set()
for root in (LOCAL_RECIPES, MAINLINE_RECIPES):
if not root.is_dir():
continue
for r in root.rglob("recipe.toml"):
parts = r.relative_to(root).parts
if "source" in parts or "target" in parts or "wip" in parts:
continue
if len(parts) >= 2:
cat, pkg = parts[0], parts[-2]
names.add(pkg)
names.add(f"{cat}/{pkg}")
return names
def check_deps_resolve(path: Path, recipe: dict, *, recipe_index: set[str]) -> list[str]:
"""Every dep in [build].dependencies should resolve to a known recipe
name (in local/recipes/ or recipes/).
Severity is `error` for Red Bear-specific names (redbear-*, redox-*,
kf6-*) and `warning` for plain names (which may be Cargo dep strings
or system packages that don't need a recipe).
"""
errors = []
build = recipe.get("build", {})
if not isinstance(build, dict):
return errors
deps = build.get("dependencies", [])
if not isinstance(deps, list):
return errors
name = path.parent.name
for dep in deps:
if not isinstance(dep, str):
continue
if dep in recipe_index:
continue
# Red Bear-prefixed deps that aren't in either tree are bugs.
is_rb_specific = (
dep.startswith("redbear-")
or dep.startswith("redox-")
or dep.startswith("kf6-")
)
if is_rb_specific:
errors.append(
f"DEP-NOT-FOUND: {name} depends on {dep!r} but no recipe by "
f"that name exists in local/recipes/ or recipes/. Verify "
f"the dep name."
)
return errors
# ---------------------------------------------------------------------------
# Rule registry
# ---------------------------------------------------------------------------
RULES = [
("R1-NO-PATCH-FILE", "error", check_rule_1_no_redox_patch_in_source_block),
("R1-PATH-SOURCE", "warning", check_rule_1_path_source),
("R2-INLINE-SED", "mixed", check_rule_2_inline_sed_in_script),
("R2-PATCHES-DIR-UNUSED", "error", check_rule_2_patches_dir_consistent),
("NO-LEGACY-MAKE", "warning", check_no_legacy_make_all_in_script),
("R1-LEGACY-APPLY-PATCHES", "error", check_no_apply_patches_sh),
("DEP-NOT-FOUND", "error", check_deps_resolve),
]
def lint_recipe(
path: Path,
strict: bool = False,
recipe_index: set[str] | None = None,
) -> list[tuple[str, str, str]]:
"""Lint a single recipe. Returns [(severity, rule_id, message), ...].
recipe_index is precomputed by build_recipe_index(); passing it avoids
the O(recipes × deps) rglob blowup on `--all` runs.
"""
if not path.exists():
return [("error", "BAD-USAGE", f"recipe not found: {path}")]
with open(path, "rb") as f:
try:
recipe = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
return [("error", "TOML-PARSE", f"invalid TOML in {path}: {e}")]
if recipe_index is None:
recipe_index = build_recipe_index()
findings: list[tuple[str, str, str]] = []
for rule_id, default_severity, check_fn in RULES:
try:
if check_fn is check_deps_resolve:
result = check_fn(path, recipe, recipe_index=recipe_index)
else:
result = check_fn(path, recipe)
except Exception as e:
findings.append(("error", rule_id, f"check raised exception: {e}"))
continue
for item in result:
if isinstance(item, tuple) and len(item) == 2:
s, m = item
if strict and s == "warning":
s = "error"
findings.append((s, rule_id, m))
else:
sev = "error" if strict else default_severity
findings.append((sev, rule_id, str(item)))
return findings
def discover_recipes(category: str | None = None) -> list[Path]:
"""Yield every recipe.toml in local/recipes/ (Rule 1 + Rule 2)."""
paths = []
for r in sorted(LOCAL_RECIPES.rglob("recipe.toml")):
if "source" in r.parts or "target" in r.parts:
continue
if "wip" in r.parts:
continue
if category and category not in r.parts:
continue
paths.append(r)
return paths
def main() -> int:
p = argparse.ArgumentParser(
description=__doc__.split("\n")[0] if __doc__ else "lint-recipe"
)
p.add_argument("recipe", nargs="?", help="Path to a single recipe.toml "
"or recipe directory")
p.add_argument("--all", action="store_true",
help="Lint every recipe in local/recipes/")
p.add_argument("--category", help="Lint every recipe in this category")
p.add_argument("--json", action="store_true",
help="Emit machine-readable JSON summary")
p.add_argument("--strict", action="store_true",
help="Treat warnings as errors (CI mode)")
args = p.parse_args()
if not args.recipe and not args.all and not args.category:
p.error("specify a recipe path, --all, or --category=<name>")
if args.all or args.category:
targets = discover_recipes(args.category)
else:
target = Path(args.recipe)
if not target.exists() and "/" not in args.recipe:
for root in (LOCAL_RECIPES, MAINLINE_RECIPES):
matches = [
m for m in root.rglob(f"{args.recipe}/recipe.toml")
if "source" not in m.parts
and "target" not in m.parts
and "wip" not in m.parts
]
if matches:
target = matches[0]
break
if target.is_dir():
target = target / "recipe.toml"
targets = [target]
recipe_index = build_recipe_index() if len(targets) > 1 else None
all_findings = []
rc = 0
for path in targets:
findings = lint_recipe(path, strict=args.strict, recipe_index=recipe_index)
all_findings.append((path, findings))
if any(s == "error" for s, _, _ in findings):
rc = 1
if args.json:
print(json.dumps({
"recipes": [
{
"path": str(p.relative_to(PROJECT_ROOT)),
"findings": [
{"severity": s, "rule_id": r, "message": m}
for s, r, m in findings
],
}
for p, findings in all_findings
],
"total": len(all_findings),
"errors": sum(1 for _, findings in all_findings
for s, _, _ in findings if s == "error"),
"warnings": sum(1 for _, findings in all_findings
for s, _, _ in findings if s == "warning"),
}, indent=2))
return rc
for path, findings in all_findings:
if not findings:
print(f"{path.relative_to(PROJECT_ROOT)}")
continue
print(f"\n {path.relative_to(PROJECT_ROOT)}:")
for sev, rule_id, msg in findings:
icon = "" if sev == "error" else ""
print(f" {icon} [{rule_id}] {msg}")
n_err = sum(1 for _, f in all_findings for s, _, _ in f if s == "error")
n_warn = sum(1 for _, f in all_findings for s, _, _ in f if s == "warning")
n_clean = sum(1 for _, f in all_findings if not f)
print(f"\nSummary: {len(all_findings)} recipes, "
f"{n_clean} clean, {n_warn} warnings, {n_err} errors")
return rc
if __name__ == "__main__":
sys.exit(main())