#!/usr/bin/env python3 """verify-patch-sanity.py — static validator for recipe .patch files. Catches the "malformed patch" class at commit/CI time instead of hours into a cook. This session hit it three times (qtwaylandscanner, sddm, xwayland): a hunk's `@@ -a,b +c,d @@` header declared line counts that did not match the actual hunk body, so GNU patch aborted with "malformed patch at line N" only when the recipe was fetched/cooked. Checks, per hunk, with NO need for the target source tree: * the body's context+deleted line count == the header's old count (b) * the body's context+added line count == the header's new count (d) * the header is well-formed and the file has proper ---/+++ framing Exit 0 = all patches well-formed. Exit 1 = at least one malformed patch. Usage: verify-patch-sanity.py [--quiet] [paths...] (default: scan the repo) """ import re, sys, os from pathlib import Path HUNK = re.compile(r'^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@') def check_patch(path): """Return a list of (line_no, message) problems for one patch file.""" problems = [] try: lines = Path(path).read_text(errors='replace').splitlines() except Exception as e: return [(0, f"unreadable: {e}")] i = 0 saw_hunk = False while i < len(lines): m = HUNK.match(lines[i]) if not m: i += 1 continue saw_hunk = True hdr_line = i + 1 old_decl = int(m.group(2)) if m.group(2) is not None else 1 new_decl = int(m.group(4)) if m.group(4) is not None else 1 # walk the body until the next hunk/file header or EOF old_ct = new_ct = 0 j = i + 1 while j < len(lines): b = lines[j] if b.startswith('@@ ') or b.startswith('--- ') or b.startswith('+++ ') \ or b.startswith('diff ') or b.startswith('Index: '): break c = b[:1] if c == ' ' or b == '': # context (blank counts as context) old_ct += 1; new_ct += 1 elif c == '-': old_ct += 1 elif c == '+': new_ct += 1 elif c == '\\': # "\ No newline at end of file" pass else: # a stray line that isn't part of the hunk grammar — stop the body break # stop once BOTH counts are satisfied (avoids counting trailing junk) if old_ct >= old_decl and new_ct >= new_decl: j += 1 break j += 1 if old_ct != old_decl: problems.append((hdr_line, f"hunk old count {old_decl} != actual {old_ct}")) if new_ct != new_decl: problems.append((hdr_line, f"hunk new count {new_decl} != actual {new_ct}")) i = j # No standard "@@ -a,b +c,d @@" hunks: an empty placeholder patch, or a # non-standard format (bare "@@" git zero-context, binary, pure rename) that # this static checker cannot validate. Not proven malformed — skip rather # than false-flag. The count-mismatch class (the fatal one) is fully covered # by the hunk checks above. return problems # archived/obsolete patch trees that are not part of any active recipe SKIP_DIRS = ('/source/', '/.git/', '/legacy-', '/legacy/', '/obsolete', '/superseded', '/absorbed/', '/recovered-', '/wip/') def find_patches(roots): seen = set() for root in roots: p = Path(root) if p.is_file() and p.suffix == '.patch': seen.add(p.resolve()); continue for f in p.rglob('*.patch'): s = str(f) if any(d in s for d in SKIP_DIRS): continue if not f.exists(): # dangling symlink in an archived tree continue seen.add(f.resolve()) return sorted(seen) def recount_fix(path): """Rewrite each hunk header's counts to match its actual body. Only touches the `@@ -a,b +c,d @@` numbers, never the body — safe for the count-error class. Returns True if the file changed.""" lines = Path(path).read_text(errors='replace').splitlines() out = [] i = 0 changed = False while i < len(lines): m = HUNK.match(lines[i]) if not m: out.append(lines[i]); i += 1; continue old_start = m.group(1); new_start = m.group(3) tail = lines[i][m.end():] old_ct = new_ct = 0 body = [] j = i + 1 while j < len(lines): b = lines[j] if b.startswith('@@ ') or b.startswith('--- ') or b.startswith('+++ ') \ or b.startswith('diff ') or b.startswith('Index: '): break c = b[:1] if c == ' ' or b == '': old_ct += 1; new_ct += 1 elif c == '-': old_ct += 1 elif c == '+': new_ct += 1 elif c == '\\': pass else: break body.append(b); j += 1 new_hdr = f"@@ -{old_start},{old_ct} +{new_start},{new_ct} @@{tail}" if new_hdr != lines[i]: changed = True out.append(new_hdr); out.extend(body) i = j if changed: Path(path).write_text('\n'.join(out) + '\n') return changed def main(): flags = {a for a in sys.argv[1:] if a.startswith('--')} args = [a for a in sys.argv[1:] if not a.startswith('--')] quiet = '--quiet' in flags do_fix = '--fix' in flags repo = Path(__file__).resolve().parents[2] roots = args or [repo / 'local/recipes', repo / 'local/patches'] patches = find_patches(roots) bad = fixed = 0 for pt in patches: probs = check_patch(pt) if not probs: continue rel = os.path.relpath(pt, repo) if do_fix: # only auto-fix pure count mismatches, not structural problems if all('count' in msg for _, msg in probs): if recount_fix(pt) and not check_patch(pt): fixed += 1 print(f"FIXED {rel}") continue print(f"UNFIXABLE {rel}: {'; '.join(m for _, m in probs)}", file=sys.stderr) bad += 1 else: bad += 1 for ln, msg in probs: print(f"MALFORMED {rel}:{ln}: {msg}", file=sys.stderr) if do_fix: print(f">>> recount fixed {fixed} patch(es); {bad} still need manual attention.") return 1 if bad else 0 if bad: print(f"FAIL: {bad}/{len(patches)} patch file(s) malformed.", file=sys.stderr) return 1 if not quiet: print(f">>> Preflight: {len(patches)} recipe patch file(s) are well-formed.") return 0 if __name__ == '__main__': sys.exit(main())