diff --git a/local/scripts/verify-patch-content.py b/local/scripts/verify-patch-content.py new file mode 100755 index 0000000000..0f299f0642 --- /dev/null +++ b/local/scripts/verify-patch-content.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# verify-patch-content.sh (Python implementation) — Enforce patch-presence +# invariant from local/AGENTS.md § "Fork Supremacy Policy": +# "Each patch in local/patches// MUST be reflected in fork HEAD." +# +# Usage: +# local/scripts/verify-patch-content.sh # Audit all forks +# local/scripts/verify-patch-content.sh --strict # Exit 1 if orphans +# local/scripts/verify-patch-content.sh base kernel # Audit specific forks +# +# Exit codes: +# 0 — All patches preserved in fork HEAD +# 1 — At least one orphaned patch detected (only in --strict mode) +# +# This is the missing check that the Phase 1.0 audit revealed: +# no part of the build system checked that patches listed in +# local/patches// had actually been absorbed into the +# local/sources// fork's working tree before now. + +import argparse +import os +import re +import subprocess +import sys + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +COMPONENTS = { + 'base': 'local/sources/base', + 'bootloader': 'local/sources/bootloader', + 'installer': 'local/sources/installer', + 'kernel': 'local/sources/kernel', + 'libredox': 'local/sources/libredox', + 'redoxfs': 'local/sources/redoxfs', + 'relibc': 'local/sources/relibc', + 'syscall': 'local/sources/syscall', + 'userutils': 'local/sources/userutils', +} + + +def extract_target_file(text): + """Get the file path that the patch modifies.""" + m_plus = re.search(r'^\+\+\+ b/(\S+)', text, re.MULTILINE) + if m_plus and m_plus.group(1) != '/dev/null': + return m_plus.group(1) + m_minus = re.search(r'^--- a/(\S+)', text, re.MULTILINE) + if m_minus and m_minus.group(1) != '/dev/null': + return m_minus.group(1) + return None + + +def extract_added_lines(text): + """Get the substantive added lines from a unified diff (ignoring headers).""" + lines = [] + for line in text.split('\n'): + if line.startswith('+++') or line.startswith('---') or line.startswith('@@'): + continue + if line.startswith('+') and not line.startswith('+++'): + content = line[1:].strip() + # Skip trivial additions (timestamps, empty) + if content and not re.match(r'^\d{4}-\d{2}-\d{2}', content): + lines.append(content[:60]) + return lines + + +def audit_component(component, fork_root): + """Audit a single component's patch preservation status.""" + patches_dir = os.path.join(REPO_ROOT, 'local/patches', component) + if not os.path.isdir(patches_dir): + return None # component has no patches dir — skip + if not os.path.isdir(fork_root): + return None + total = 0 + preserved = 0 + orphaned = [] + for fn in sorted(os.listdir(patches_dir)): + if not fn.endswith('.patch'): + continue + total += 1 + ppath = os.path.join(patches_dir, fn) + with open(ppath, 'r', errors='ignore') as f: + text = f.read() + target = extract_target_file(text) + if not target: + orphaned.append((fn, '?', 'no-target-file')) + continue + target_path = os.path.join(fork_root, target) + if not os.path.exists(target_path): + orphaned.append((fn, target, 'target-missing-in-fork')) + continue + added = extract_added_lines(text) + if not added: + orphaned.append((fn, target, 'patch-has-no-added-lines')) + continue + try: + with open(target_path, 'r', errors='ignore') as f: + target_text = f.read() + except Exception: + orphaned.append((fn, target, 'cannot-read-target')) + continue + matched = sum(1 for line in added[:5] if line in target_text) + if matched >= 1: + preserved += 1 + else: + orphaned.append((fn, target, 'no-added-line-matches')) + return total, preserved, orphaned + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('components', nargs='*', default=list(COMPONENTS.keys())) + ap.add_argument('--strict', action='store_true', + help='Exit non-zero if any orphaned patch detected') + ap.add_argument('--report', choices=['summary', 'detail'], default='summary') + ap.add_argument('--quiet', '-q', action='store_true') + args = ap.parse_args() + + total_patches = 0 + total_preserved = 0 + total_orphaned = 0 + failed_components = [] + detail_lines = [] + + print('=' * 70) + print(' Red Bear OS — Patch Preservation Audit') + print('=' * 70) + print(f' Source of truth: local/patches//*') + print(f' Fork target: local/sources/') + print(f' Strict mode: {args.strict}') + print('=' * 70) + + for comp in args.components: + if comp not in COMPONENTS: + print(f' SKIP: unknown component {comp}', file=sys.stderr) + continue + fork_rel = COMPONENTS[comp] + fork_path = os.path.join(REPO_ROOT, fork_rel) + if not os.path.isdir(fork_path): + if not args.quiet: + print(f' {comp:12s}: SKIP (fork not at {fork_rel})') + continue + result = audit_component(comp, fork_path) + if result is None: + if not args.quiet: + print(f' {comp:12s}: SKIP (no patches dir)') + continue + total, preserved, orphaned = result + total_patches += total + total_preserved += preserved + total_orphaned += len(orphaned) + pct = 100 * preserved / total if total > 0 else 0 + status = 'OK ' if not orphaned else 'WARN' + line = f' [{status}] {comp:10s}: preserved={preserved}/{total} ({pct:.0f}%) orphaned={len(orphaned)}' + print(line) + if orphaned and args.report == 'detail': + detail_lines.append(f'\n {comp} orphaned patches:') + for fn, target, reason in orphaned[:50]: + detail_lines.append(f' - {fn}: {target} ({reason})') + if len(orphaned) > 50: + detail_lines.append(f' ... +{len(orphaned)-50} more') + if orphaned: + failed_components.append(comp) + + print('=' * 70) + print(f' TOTAL: {total_patches} patches, {total_preserved} preserved, {total_orphaned} orphaned') + print('=' * 70) + + if detail_lines: + print('\n'.join(detail_lines)) + + if total_orphaned == 0: + print(' ALL FORKS: patches preserved in fork HEAD') + return 0 + + print(' ACTION REQUIRED: orphaned patches must be applied to fork HEAD') + print(' See: local/docs/PATCH-PRESERVATION-AUDIT-2026-07-12.md') + print(' Helper: re-run local/scripts/apply-patches.sh and re-absorb') + if args.strict: + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/local/scripts/verify-patch-content.sh b/local/scripts/verify-patch-content.sh new file mode 100755 index 0000000000..974cc707a9 --- /dev/null +++ b/local/scripts/verify-patch-content.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Shell wrapper for verify-patch-content.py — invokes the Python audit. +# Per local/AGENTS.md § "BRANCH AND SUBMODULE POLICY": scripts in +# local/scripts/ MUST NOT introduce new policy; they implement existing +# policy from AGENTS.md and local/AGENTS.md. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec python3 "${SCRIPT_DIR}/verify-patch-content.py" "$@"