ae749ffb23
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).
458 lines
18 KiB
Python
458 lines
18 KiB
Python
#!/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())
|