36554161cb
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.
186 lines
7.2 KiB
Python
Executable File
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())
|