Files
RedBear-OS/local/scripts/lint-recipe.py
T
vasilito ffbe098ef8 config: add tlc to redbear-mini and redbear-full; create recipe symlink
TLC (Twilight Commander) was missing from both ISO configs. Added
tlc = {} to [packages] in redbear-mini.toml and redbear-full.toml.
Created missing symlink: recipes/tui/tlc -> ../../local/recipes/tui/tlc.
2026-06-19 11:47:25 +03:00

488 lines
19 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 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/<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())