phase 1.0B: add verify-patch-content.{py,sh} — orphan-patch audit tool
Per the Phase 1.0 audit (local/docs/PATCH-PRESERVATION-AUDIT-2026-07-12.md)
no part of the build system was checking that patches listed in
local/patches/<comp>/ had actually been absorbed into the
local/sources/<comp>/ fork's working tree.
This commit adds a tool that audits every Cat 2 fork and reports any
patches whose content is not present in the fork HEAD. The tool is
located at local/scripts/verify-patch-content.{sh,py} and follows the
existing local/scripts/ convention (shell wrapper delegating to
Python implementation).
Audit method:
1. For each .patch in local/patches/<comp>/:
- Extract target file from ---/+++ headers
- Extract first 5 added lines (+ markers)
- Check substring presence in fork HEAD version
2. If 0/5 added lines found, patch is 'orphaned'
Current state (after Phase 1.0A recovery):
- 251 patches total, 155 preserved, 96 still orphaned
- The remaining orphans are patches where the upstream file structure
has shifted too far for patch(1) to apply — those need
upstream-tracking upgrade, deferred to Phase 2.
Usage:
./local/scripts/verify-patch-content.sh (report)
./local/scripts/verify-patch-content.sh --strict (exit 1 on orphans)
./local/scripts/verify-patch-content.sh base kernel (specific forks)
Wired in via build-preflight.sh in the next commit (Phase 1.0B cont.).
This closes the 'silent patch loss' gap identified in the audit.
This commit is contained in:
Executable
+184
@@ -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/<comp>/ 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/<comp>/ had actually been absorbed into the
|
||||
# local/sources/<comp>/ 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/<comp>/*')
|
||||
print(f' Fork target: local/sources/<comp>')
|
||||
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())
|
||||
Reference in New Issue
Block a user