38ebecabb6
Adds --selftest mode that runs 5 regression test cases against a temp directory. The temp dir is created OUTSIDE the parent git working tree (e.g. via /tmp/.tmp-redbear-selftest-/) so that the test's 'git log' commands don't pick up the parent repo's 100+ commits. Test 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) 5. verify --report action produces per-orphan decision Bug fixed during dev: passing fork_dir to git log WITHOUT isolating it from the parent repo's git tree caused 50 'case2' keyword matches to leak in from the RedBear-OS parent repo's commit log, making case2 falsely classified as INTEGRATED. Fixed by using tempfile.mkdtemp(dir=os.path.dirname(REPO_ROOT)) to put the test fork dir OUTSIDE the parent git tree. Also fixed: audit_component now accepts an optional patches_dir arg (so selftest can pass a non-default patches path without modifying the main code path). Also fixed: 'if not target_path or target_path == \'?\'' check in suggest_action_for_orphan to handle the literal '?' sentinel that audit_component uses for no-target orphans. Wired into pre-push-checks.sh as check #3b (verify-patch-content-selftest). Total: 7 pre-push checks now run before any push.
384 lines
15 KiB
Python
Executable File
384 lines
15 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, patches_dir=None):
|
|
"""Audit a single component's patch preservation status.
|
|
|
|
patches_dir defaults to local/patches/<comp>; 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/<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:
|
|
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())
|