#!/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 # 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 # machine-readable ./local/scripts/lint-recipe.py --strict # 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 upstream-source sed -i chains and no cookbook_apply_patches call; `warning` when both are present (partially migrated). Build-time seds that target `${COOKBOOK_STAGE}/`, `${COOKBOOK_SYSROOT}/`, or `${COOKBOOK_BUILD}/` (not `${COOKBOOK_SOURCE}/`) are exempt because they're build-time adjustments to the staged artifacts, not upstream source edits — they don't survive `make distclean` but they're not the Rule 2 concern (which is upstream-source durability). """ 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 # Find every `sed -i` line and check whether its target is # an upstream source path. Build-time seds that target the # staged tree, sysroot, or build dir are exempt. upstream_sed_count = 0 for line in script.splitlines(): if not re.search(r"\bsed\s+-i\b", line): continue # The sed's target is whatever paths appear after the # `sed -i` flags. Look for ${COOKBOOK_SOURCE}/... # explicitly. Build-time targets like # ${COOKBOOK_STAGE}/..., ${COOKBOOK_BUILD}/..., # ${COOKBOOK_SYSROOT}/..., and other non-source paths # are exempt. if "${COOKBOOK_SOURCE}/" in line or "${COOKBOOK_SOURCE}\"" in line: upstream_sed_count += 1 # Also catch `find "${COOKBOOK_SOURCE}/...` patterns elif "COOKBOOK_SOURCE}" in line and ("find" in line or "-exec" in line or "-name" in line): upstream_sed_count += 1 if upstream_sed_count == 0: return findings if "cookbook_apply_patches" in script: findings.append(( "warning", f"R2-INLINE-SED-WITH-PATCHES: {name} has {upstream_sed_count} " f"`sed -i` call(s) targeting \`${{COOKBOOK_SOURCE}}\` in " f"[build].script AND a cookbook_apply_patches call. The " f"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 {upstream_sed_count} " f"`sed -i` call(s) targeting \`${{COOKBOOK_SOURCE}}\` in " f"[build].script. Per Rule 2, all upstream source edits " f"should live in `local/patches/{name}/` and be applied " f"via `cookbook_apply_patches` in the build script. " f"Inline `sed -i` chains do not survive `make clean` or " f"upstream syncs. (Build-time seds that target " f"\`${{COOKBOOK_STAGE}}\`, \`${{COOKBOOK_BUILD}}\`, or " f"\`${{COOKBOOK_SYSROOT}}\` are exempt — those are " f"build-time adjustments to staged artifacts, not " f"upstream source edits.)" )) return findings def check_rule_2_patches_dir_consistent(path: Path, recipe: dict) -> list[str]: """If local/patches// 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 `. Per-recipe " f"cooks should use `./target/release/repo cook ` " f"or the `make repair.` 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 = `/` 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 `/` 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=") 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())