#!/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, patches_dir=None): """Audit a single component's patch preservation status. patches_dir defaults to local/patches/; selftest passes a temp dir to override. """ if patches_dir is None: 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 suggest_action_for_orphan(patch_name, target_path, comp, fork_dir=None): """For an orphan patch, suggest an action by inspecting the fork log for related work. Implements the SUPERSEDED / INTEGRATED decision tree from AGENTS.md § 'Orphan-Patch Supersession Decision Tree' so the operator doesn't have to do this manually. Returns: 'INTEGRATED', 'FILE-RESTRUCTURED', 'NO-TARGET-FILE', or 'MISSING-UPSTREAM' depending on what the fork log + file system show. fork_dir is normally f'local/sources/{comp}' but selftest passes a custom path to override. """ pname = patch_name.replace('.patch', '') if fork_dir is None: fork_dir = f'local/sources/{comp}' if not os.path.isdir(fork_dir): return 'MISSING-UPSTREAM' # 1. No target file: patch lacks +++ b/ header (non-actionable) # target_path is the string '?' for no-target patches (set by # audit_component's orphan-tuples) or None for unknown cases. if not target_path or target_path == '?': return 'NO-TARGET-FILE' full_target = os.path.join(fork_dir, target_path) if not os.path.exists(full_target): return 'FILE-RESTRUCTURED' # 2. Fork log: are there commits about this topic? keywords = re.findall(r'[A-Za-z]+', pname.lower()) stop = {'a','an','the','in','of','on','to','for','with','and','or','is', 'p0','p1','p2','p3','p4','p5','p6','p7','p8','p9','p10','p11', 'patch','the','for'} keywords = [k for k in keywords if len(k) > 3 and k not in stop][:6] try: result = subprocess.run( ['git', '-C', fork_dir, 'log', '--all', '--oneline', '-i', '--grep', keywords[0], '-n', '50'], capture_output=True, text=True, timeout=15 ) n_commits = len(result.stdout.strip().splitlines()) if result.stdout.strip() else 0 if n_commits > 5: return 'INTEGRATED' except Exception: pass return 'MISSING-UPSTREAM' 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', 'action'], default='summary', help='Summary: per-component totals. Detail: per-orphan listing. ' 'Action: per-orphan SUPERSEDED/INTEGRATED decision suggestions.') ap.add_argument('--quiet', '-q', action='store_true') args = ap.parse_args() total_patches = 0 total_preserved = 0 total_orphaned = 0 failed_components = [] orphans_by_comp = {} # comp -> [orphan_tuples]; for operator-decision 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: failed_components.append(comp) # per-comp orphan list for operator-decision support orphans_by_comp[comp] = orphaned 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)) # Operator-decision support: for each orphan, suggest an action # (INTEGRATED / FILE-RESTRUCTURED / NO-TARGET-FILE / MISSING-UPSTREAM) # Implements AGENTS.md § 'Orphan-Patch Supersession Decision Tree' if args.report == 'action' and total_orphaned > 0: print() print('=' * 70) print(' Operator-decision suggestions (per AGENTS.md decision tree)') print('=' * 70) for comp, comp_orphans in orphans_by_comp.items(): for orphan_info in comp_orphans: fn, target, _reason = orphan_info action = suggest_action_for_orphan(fn, target, comp) print(f' {comp}/{fn} -> {action}') if action == 'INTEGRATED': print(' (work IS in fork under different commit; safe to archive as superseded)') elif action == 'FILE-RESTRUCTURED': print(' (target file no longer in fork; safe to archive as superseded)') elif action == 'NO-TARGET-FILE': print(' (patch lacks +++ b/ header; non-actionable; safe to archive)') else: # MISSING-UPSTREAM print(' (work NOT in fork; needs reapply or fork rebase)') 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 def selftest(): """Selftest mode — runs regression cases against a temp directory with a known layout. The temp dir has a fake fork + fake patches dir so we don't touch the real audit state. Cases: 1. patch with sig in fork target preserved 2. patch without sig in fork target orphan 3. no-target patch (no +++ b/ header) orphan (no-target) 4. patch with content-integrated target orphan (classification check via suggest_action) 5. verify --report action produces per-orphan decision Implementation note: we create the test fork dir under REPO_ROOT/../.tmp-redbear-selftest-/. This is OUTSIDE the RedBear-OS git working tree so that 'git log' from the test fork_dir is not contaminated by the parent repo's commit log. The dir is removed at the end. """ import shutil import tempfile # Use a parent-relative temp dir (NOT inside the parent git tree # so the git log from the test fork_dir doesn't pick up the parent's # 100+ commits). selftest_dir = tempfile.mkdtemp(prefix='redbear-pc-selftest-', dir=os.path.dirname(REPO_ROOT)) fork_dir = os.path.join(selftest_dir, 'fork') patches_dir = os.path.join(selftest_dir, 'patches') try: os.makedirs(os.path.join(fork_dir, 'src'), exist_ok=True) os.makedirs(patches_dir, exist_ok=True) with open(os.path.join(fork_dir, 'src', 'foo.rs'), 'w') as f: f.write('// existing fork content\nfn main() {\n "hello"\n}\n') # Case 1: patch with sig in target → preserved. # The patch modifies an EXISTING line in the fork, which means # the + line context (line being added below) won't match — but # the fork's existing line is in the patch's @@ context, and # the patch's "added" line includes the existing fork line. # To make this a true preserved case, we need a patch whose # added-line string literally appears in the fork file. c1 = os.path.join(patches_dir, 'case1.patch') with open(c1, 'w') as f: # Patch adds a line that already exists in the fork file f.write('''diff --git a/src/foo.rs b/src/foo.rs index 0000000..abcdef0 --- a/src/foo.rs +++ b/src/foo.rs @@ -3,1 +3,2 @@ fn main() { + "hello" } ''') # Case 2: patch without sig in target → orphan c2 = os.path.join(patches_dir, 'case2.patch') with open(c2, 'w') as f: f.write('''diff --git a/src/foo.rs b/src/foo.rs index 0000000..abcdef0 --- a/src/foo.rs +++ b/src/foo.rs @@ -1,1 +1,2 @@ // existing fork content +// case2 marker that's totally new content +fn case2_function() {} ''') # Case 3: no-target patch c3 = os.path.join(patches_dir, 'case3.patch') with open(c3, 'w') as f: f.write('# this is a comment, no +++ b/ header\n') # Run audit (with patches_dir override for selftest) result = audit_component('test', fork_dir, patches_dir) if result is None: print(' FAIL: audit_component returned None (fork dir missing)') return 1 total, preserved, orphaned = result fails = 0 # Verify case 1 if total != 3: print(f' FAIL: expected 3 total, got {total}') fails += 1 if preserved != 1: print(f' FAIL: expected 1 preserved (case1), got {preserved}') fails += 1 if len(orphaned) != 2: print(f' FAIL: expected 2 orphans, got {len(orphaned)}') fails += 1 # Verify the action recommender if orphaned: # Get the first orphan (case2 — sig should be MISSING-UPSTREAM) for fn, target, reason in orphaned: if 'case2' in fn: action = suggest_action_for_orphan(fn, target, 'test', fork_dir) if action != 'MISSING-UPSTREAM': print(f' FAIL: case2 expected MISSING-UPSTREAM, got {action}') fails += 1 elif 'case3' in fn: action = suggest_action_for_orphan(fn, target, 'test', fork_dir) if action != 'NO-TARGET-FILE': print(f' FAIL: case3 expected NO-TARGET-FILE, got {action}') fails += 1 if fails == 0: print(' All selftest cases passed.') else: print(f' {fails} test(s) failed.') return fails finally: # cleanup: remove the entire selftest dir if os.path.exists(selftest_dir): shutil.rmtree(selftest_dir, ignore_errors=True) if __name__ == '__main__': if len(sys.argv) > 1 and sys.argv[1] == '--selftest': sys.exit(selftest()) sys.exit(main())