ffbe098ef8
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.
488 lines
19 KiB
Python
488 lines
19 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 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())
|