e4bb452d86
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.
185 lines
6.6 KiB
Python
Executable File
185 lines
6.6 KiB
Python
Executable File
#!/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())
|