#!/usr/bin/env python3 """Audit every KF6/Qt recipe's [build].dependencies against what its source actually requires. For each recipe under local/recipes/kde/ and recipes/, this script: 1. Resolves the upstream source (git or tar) at the pinned rev 2. Extracts/reads the source's CMakeLists.txt + all .cmake files 3. Greps for `find_package(KF6::* COMPONENTS ...)` and `find_package(Qt6* ...)` calls 4. Reads the recipe's [build].dependencies array 5. Reports any KF6::/Qt* component referenced in the source but missing from the recipe's dependencies, AND any recipe dependency that is unused (i.e. not referenced by the source) 6. Optionally: emits a fixed [build].dependencies array as a patch Per AGENTS.md "BUILD DURABILITY" policy, the recipe.toml is the durable artifact. This audit ensures the recipe matches what the source actually needs, preventing "Package 'X' not found" failures at cook time. Usage: ./local/scripts/audit-kf6-deps.py --verbose # audit all 46 recipes ./local/scripts/audit-kf6-deps.py --component kf6-kio ./local/scripts/audit-kf6-deps.py --fix --dry-run # show proposed fix ./local/scripts/audit-kf6-deps.py --fix # write the fix in place """ import argparse import re import shutil import subprocess import sys import tempfile import time import tomllib from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] LOCAL_RECIPES = PROJECT_ROOT / "local/recipes" MAINLINE_RECIPES = PROJECT_ROOT / "recipes" # KF6 components appear in three forms in upstream KDE source. The # dominant form, used by ~99% of KF6 code, is the named form: one # find_package call per line, with the component name spelled out in # full. The other two forms are occasional variants we still need # to catch. KF6_DIRECT_RE = re.compile( r"find_package\s*\(\s*(KF6[A-Za-z]*(?:::[A-Za-z0-9]+)+)" ) KF6_COMPONENTS_BLOCK_RE = re.compile( r"find_package\s*\(\s*KF6\b[^\)]*?COMPONENTS[^\)]*\)" ) KF6_NAMED_RE = re.compile( r"find_package\s*\(\s*(KF6[A-Z][A-Za-z0-9]+)\b" ) # Form 4: find_package(KF6 REQUIRED) — rare in modern KDE code but # exists in some older Plasma components and a handful of KF6 addons. KF6_PLAIN_NAME_RE = re.compile( r"find_package\s*\(\s*KF6\s+([A-Z][A-Za-z0-9]+)\b" ) KF6_COMPONENT_TOKEN_RE = re.compile( r"\b([A-Z][A-Za-z0-9]+)\b" ) # Qt6 components: find_package(Qt6Foo REQUIRED) — usually individual modules QT6_COMPONENT_RE = re.compile( r"find_package\s*\(\s*Qt6([A-Z][A-Za-z0-9]*)" ) # Also catch the more explicit form # find_package(Qt6 5.15.0 COMPONENTS Core Network DBus) QT6_GENERIC_RE = re.compile( r"find_package\s*\(\s*Qt6\b" ) QT6_COMPONENTS_BLOCK_RE = re.compile( r"find_package\s*\(\s*Qt6\b[^\)]*?COMPONENTS[^\n]+" ) def run(cmd, **kwargs): proc = subprocess.run(cmd, capture_output=True, text=True, check=False, **kwargs) return proc.returncode, proc.stdout, proc.stderr def _strip_cmake_noise(text: str) -> str: """Strip CMake comments and string literals from source before regex. CMake comments are introduced by `#` and run to end of line; string literals are double-quoted with backslash escapes. Without this pass, code like `set(MY_NOTE "needs find_package(KF6::Foo) later")` or `# find_package(KF6::FakeUsedOnlyInComment)` would be falsely classified as a real dependency reference. """ text = re.sub(r"(?m)#.*$", "", text) text = re.sub(r'"(?:\\.|[^"\\])*"', '""', text) return text def fetch_source(recipe_toml: Path): """Fetch the upstream source at the pinned rev into a tempdir. Returns (Path, error_msg).""" with open(recipe_toml, "rb") as f: data = tomllib.load(f) source = data.get("source") or {} url = source.get("git") rev = source.get("rev") tar = source.get("tar") tmp = Path(tempfile.mkdtemp(prefix="audit-kf6-")) if tar: # tar-based: download to tmp/tarball, extract into tmp/src tarball = tmp / "src.tar.xz" rc, _, err = run(["curl", "-sSL", "-o", str(tarball), tar]) if rc != 0: return None, f"download failed: {err}" extract_dir = tmp / "src" extract_dir.mkdir() rc, _, err = run(["tar", "-xJf", str(tarball), "-C", str(extract_dir)]) if rc != 0: return None, f"extract failed: {err}" # The tarball may have a top-level dir; find it candidates = list(extract_dir.iterdir()) if len(candidates) == 1 and candidates[0].is_dir(): return candidates[0], None return extract_dir, None elif url and rev: # git-based: clone at the pinned rev rc, _, err = run(["git", "clone", "--quiet", "--no-checkout", url, str(tmp / "src")]) if rc != 0: return None, f"clone failed: {err}" rc, _, err = run(["git", "-C", str(tmp / "src"), "checkout", "--quiet", rev]) if rc != 0: return None, f"checkout failed: {err}" return tmp / "src", None else: return None, "no git or tar source" def scan_source(source_dir: Path): """Walk the source tree and extract every KF6:: and Qt6 component used. Returns (set_of_kf6_components, set_of_qt6_components). """ kf6 = set() qt6 = set() for cmake_file in list(source_dir.rglob("CMakeLists.txt")) + \ list(source_dir.rglob("*.cmake")): try: text = cmake_file.read_text(errors="replace") except OSError: continue text = _strip_cmake_noise(text) # Form 1: find_package(KF6::Foo REQUIRED) — rare, mostly Plasma for m in KF6_DIRECT_RE.finditer(text): kf6.add(m.group(1)) # Form 2: find_package(KF6 COMPONENTS Foo Bar Baz) — all in one for m in KF6_COMPONENTS_BLOCK_RE.finditer(text): line = m.group(0) for tok in KF6_COMPONENT_TOKEN_RE.findall(line): if tok in ("REQUIRED", "QUIET", "COMPONENTS", "CONFIG", "VERSION", "EXACT", "QUIETLY", "MODULE", "KF6"): continue kf6.add(f"KF6::{tok}") # Form 3 (the dominant KDE form): find_package(KF6Xxx REQUIRED). # The full name is captured without the "KF6" prefix, then # normalized to the KF6::Foo form so normalize_dep_name handles # it like every other KF6 component. We filter out self-references # like "KF6KF6" (which the regex would otherwise mis-capture). for m in KF6_NAMED_RE.finditer(text): rest = m.group(1)[len("KF6"):] if rest.startswith("KF6") or not rest: continue kf6.add(f"KF6::{rest}") # Form 4: find_package(KF6 REQUIRED) — rare, but emitted # by some older Plasma components. The capture is the bare name. for m in KF6_PLAIN_NAME_RE.finditer(text): kf6.add(f"KF6::{m.group(1)}") # Qt6 individual modules: find_package(Qt6Foo REQUIRED) for m in QT6_COMPONENT_RE.finditer(text): qt6.add(f"Qt6{m.group(1)}") # Qt6 block form: find_package(Qt6 5.15.0 COMPONENTS Core Network DBus) for m in QT6_COMPONENTS_BLOCK_RE.finditer(text): line = m.group(0) for tok in KF6_COMPONENT_TOKEN_RE.findall(line): if tok in ("REQUIRED", "QUIET", "COMPONENTS", "CONFIG", "VERSION", "EXACT", "QUIETLY", "MODULE", "Qt6"): continue qt6.add(f"Qt6{tok}") # Plain find_package(Qt6 ...) without components — minimal if QT6_GENERIC_RE.search(text): qt6.add("Qt6Core") return kf6, qt6 def read_recipe_deps(recipe_toml: Path): """Return (set_of_dep_names, raw_deps_text).""" with open(recipe_toml, "rb") as f: data = tomllib.load(f) build = data.get("build") or {} raw = build.get("dependencies") or [] return {d.strip() for d in raw}, raw KF6_RECIPE_OVERRIDES = { "Archive": "karchive", "Attica": "attica", "Auth": "kauth", "Bookmarks": "kbookmarks", "Codecs": "kcodecs", "ColorScheme": "kcolorscheme", "Completion": "kcompletion", "Config": "kconfig", "ConfigWidgets": "kconfigwidgets", "CoreAddons": "kcoreaddons", "Crash": "kcrash", "DBusAddons": "kdbusaddons", "Declarative": "kdeclarative", "DocTools": "kdoctools", "GuiAddons": "kguiaddons", "GlobalAccel": "kglobalaccel", "I18n": "ki18n", "IconThemes": "kiconthemes", "IdleTime": "kidletime", "ImageFormats": "kimageformats", "ItemModels": "kitemmodels", "ItemViews": "kitemviews", "JobWidgets": "kjobwidgets", "KCMUtils": "kcmutils", "KDED": "kded6", "KIO": "kio", "KNewStuff": "knewstuff", "KNotifyConfig": "notifyconfig", "Notifications": "knotifications", "KPackage": "kpackage", "Parts": "parts", "Plasma": "plasma", "Prison": "prison", "Pty": "pty", "Service": "kservice", "Solid": "solid", "Sonnet": "sonnet", "Svg": "ksvg", "SyntaxHighlighting": "syntaxhighlighting", "TextEditor": "ktexteditor", "TextWidgets": "ktextwidgets", "Wallet": "kwallet", "Wayland": "kwayland", "WidgetsAddons": "kwidgetsaddons", "WindowSystem": "kwindowsystem", "XmlGui": "kxmlgui", "ExtraCMakeModules": "extra-cmake-modules", } def normalize_dep_name(component: str) -> str: """Map a CMake KF6::Foo / Qt6Bar reference to a Red Bear OS recipe name. Examples: KF6::KIO -> kf6-kio KF6::KCMUtils -> kf6-kcmutils KF6::IconThemes -> kf6-kiconthemes Qt6Core -> qtbase Qt6Gui -> qtbase Qt6GuiPrivate -> qtbase Qt6Qml -> qtdeclarative """ if component.startswith("KF6::"): rest = component[len("KF6::"):] if rest in KF6_RECIPE_OVERRIDES: return f"kf6-{KF6_RECIPE_OVERRIDES[rest]}" s = re.sub(r"(?