Created
March 14, 2026 05:36
-
-
Save jasperf/45e14a3388eea86c01aad01558379da4 to your computer and use it in GitHub Desktop.
uzzy-match and replace a WordPress block comment in a pattern file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| patch-block.py — Fuzzy-match and replace a WordPress block comment in a pattern file. | |
| Solves the problem where AI tools generate search text with minor structural | |
| differences (e.g., wrong brace count) causing exact search/replace to fail | |
| despite 99%+ similarity. Uses the same fuzzy matching that already correctly | |
| identifies the right block, and actually applies the replacement. | |
| How it works: | |
| 1. Finds all <!-- wp:blockname {...} --> comments in the file | |
| 2. Scores each against the --search text using string similarity | |
| 3. If best match >= threshold (default 95%), replaces it with --replace text | |
| 4. Validates replacement JSON before writing | |
| Usage: | |
| # Dry-run to preview | |
| python3 scripts/elayne/patch-block.py FILE --search 'OLD_BLOCK' --replace 'NEW_BLOCK' --dry-run | |
| # Apply change | |
| python3 scripts/elayne/patch-block.py FILE --search 'OLD_BLOCK' --replace 'NEW_BLOCK' | |
| # Lower threshold if needed (not recommended below 0.90) | |
| python3 scripts/elayne/patch-block.py FILE --search 'OLD_BLOCK' --replace 'NEW_BLOCK' --threshold 0.90 | |
| Notes: | |
| - --search can have wrong brace counts / minor JSON differences; fuzzy matching handles it | |
| - --replace must contain valid JSON (script validates before writing) | |
| - Always run --dry-run first to confirm the right block is matched | |
| """ | |
| import re | |
| import json | |
| import sys | |
| import difflib | |
| import argparse | |
| from pathlib import Path | |
| def find_json_end(text: str, start: int) -> int: | |
| """Find the closing brace index for a JSON object starting at `start`.""" | |
| depth = 0 | |
| in_string = False | |
| escape_next = False | |
| for i in range(start, len(text)): | |
| ch = text[i] | |
| if escape_next: | |
| escape_next = False | |
| continue | |
| if ch == '\\' and in_string: | |
| escape_next = True | |
| continue | |
| if ch == '"': | |
| in_string = not in_string | |
| continue | |
| if in_string: | |
| continue | |
| if ch == '{': | |
| depth += 1 | |
| elif ch == '}': | |
| depth -= 1 | |
| if depth == 0: | |
| return i | |
| return -1 | |
| def find_block_comments(content: str) -> list: | |
| """ | |
| Find all WordPress block opening comments with JSON attributes. | |
| Returns list of (start, end, raw_string) tuples (end is exclusive). | |
| """ | |
| pattern = re.compile(r'<!-- wp:[\w/:-]+\s+\{') | |
| results = [] | |
| for match in pattern.finditer(content): | |
| json_start = match.end() - 1 # position of opening '{' | |
| json_end = find_json_end(content, json_start) | |
| if json_end == -1: | |
| continue | |
| after = content[json_end + 1:] | |
| close = re.match(r'\s*/?-->', after) | |
| if not close: | |
| continue | |
| end_pos = json_end + 1 + close.end() | |
| raw = content[match.start():end_pos] | |
| results.append((match.start(), end_pos, raw)) | |
| return results | |
| def extract_block_json(block_comment: str): | |
| """ | |
| Parse the JSON from a block comment string. | |
| Returns parsed dict or None if JSON is invalid. | |
| """ | |
| json_match = re.search(r'\{', block_comment) | |
| if not json_match: | |
| return None | |
| json_start = json_match.start() | |
| json_end = find_json_end(block_comment, json_start) | |
| if json_end == -1: | |
| return None | |
| try: | |
| return json.loads(block_comment[json_start:json_end + 1]) | |
| except json.JSONDecodeError: | |
| return None | |
| def validate_replacement(replace_text: str) -> tuple: | |
| """ | |
| Validate that the replacement is a well-formed block comment with valid JSON. | |
| Returns (ok: bool, error_message: str). | |
| """ | |
| if not re.match(r'<!-- wp:[\w/:-]+', replace_text): | |
| return False, "Does not start with <!-- wp:blockname" | |
| if not replace_text.rstrip().endswith('-->'): | |
| return False, "Does not end with -->" | |
| parsed = extract_block_json(replace_text) | |
| if parsed is None: | |
| return False, "JSON inside block comment is invalid" | |
| return True, "" | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description='Fuzzy-match and replace a WordPress block comment.', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=__doc__, | |
| ) | |
| parser.add_argument('file', help='PHP pattern file to patch') | |
| parser.add_argument('--search', required=True, | |
| help='Block comment to find (fuzzy — brace count errors OK)') | |
| parser.add_argument('--replace', required=True, | |
| help='Block comment to replace with (must have valid JSON)') | |
| parser.add_argument('--dry-run', action='store_true', | |
| help='Show diff without writing') | |
| parser.add_argument('--threshold', type=float, default=0.95, | |
| help='Minimum similarity to accept match (default: 0.95)') | |
| args = parser.parse_args() | |
| path = Path(args.file) | |
| if not path.exists(): | |
| print(f'ERROR: {args.file} not found', file=sys.stderr) | |
| sys.exit(1) | |
| # Validate replacement before touching the file | |
| ok, err = validate_replacement(args.replace) | |
| if not ok: | |
| print(f'ERROR: Invalid --replace text: {err}', file=sys.stderr) | |
| sys.exit(1) | |
| content = path.read_text(encoding='utf-8') | |
| blocks = find_block_comments(content) | |
| if not blocks: | |
| print('ERROR: No block comments with JSON found in file', file=sys.stderr) | |
| sys.exit(1) | |
| # Score each block comment against the search text | |
| scored = [ | |
| (difflib.SequenceMatcher(None, args.search, raw).ratio(), start, end, raw) | |
| for start, end, raw in blocks | |
| ] | |
| scored.sort(reverse=True) | |
| best_score, best_start, best_end, best_raw = scored[0] | |
| print(f'Best match: {best_score:.1%} similarity') | |
| print(f' {best_raw[:100]}{"..." if len(best_raw) > 100 else ""}') | |
| if best_score < args.threshold: | |
| print(f'\nERROR: Best match {best_score:.1%} is below threshold {args.threshold:.0%}') | |
| print('Try lowering --threshold or check that --search targets the right block.') | |
| sys.exit(1) | |
| if best_raw == args.replace: | |
| print('No change — match is identical to replacement.') | |
| sys.exit(0) | |
| new_content = content[:best_start] + args.replace + content[best_end:] | |
| if args.dry_run: | |
| diff = difflib.unified_diff( | |
| content.splitlines(keepends=True), | |
| new_content.splitlines(keepends=True), | |
| fromfile=f'{args.file} (original)', | |
| tofile=f'{args.file} (patched)', | |
| n=3, | |
| ) | |
| print() | |
| print(''.join(list(diff)[:80])) | |
| print('(dry-run — no file written)') | |
| else: | |
| path.write_text(new_content, encoding='utf-8') | |
| print(f'\nPatched: {args.file} (replaced at {best_score:.1%} similarity)') | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment