Files
RedBear-OS/local/scripts/verify-patch-sanity.py
T
vasilito 36554161cb verify-patch-sanity: skip source-staged, the out-of-tree copy of source/
Out-of-tree staging made the checker scan source-staged/, which holds the same
upstream third-party patches as source/ -- already skipped because they are not
ours to fix. qtdeclarative's bundled yoga patch then failed the build at
preflight. Same rationale as the existing /source/ entry.
2026-08-04 18:26:44 +03:00

186 lines
7.2 KiB
Python
Executable File

#!/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
# '/source-staged/' is the out-of-tree build copy of '/source/'. It holds the
# same upstream third-party patches (e.g. qtdeclarative's bundled yoga), which
# are deliberately skipped here because they are not ours to fix -- but the
# copy's name does not match the '/source/' entry, so staging made the checker
# start reporting them:
# MALFORMED .../qtdeclarative/.../source-staged/src/3rdparty/yoga/patches/
# 002-Namespace-Yoga-For-ODR-Violation.patch: hunk old count 34 != actual 31
SKIP_DIRS = ('/source/', '/source-staged/', '/.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())