feat: Add grammar fetch and comparison tooling
- Add fetch_bind_grammar.py for MCP-based grammar file retrieval - Add compare_bind_versions.py for version differences analysis - Add process_mcp_result.py for handling base64-encoded MCP output - Create upstream directory structure with fetching instructions - Document grammar file locations and structure
This commit is contained in:
390
scripts/compare_bind_versions.py
Normal file
390
scripts/compare_bind_versions.py
Normal file
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare BIND9 grammar files between versions to identify breaking changes.
|
||||
|
||||
This script compares grammar files from two BIND9 versions and generates
|
||||
a comprehensive report of:
|
||||
- Removed options (breaking changes)
|
||||
- Added options (new features)
|
||||
- Modified option syntax
|
||||
- Deprecated options
|
||||
|
||||
Usage:
|
||||
python scripts/compare_bind_versions.py \\
|
||||
--version1-dir bind9-grammar/upstream/v9.18.44 \\
|
||||
--version2-dir bind9-grammar/upstream/v9.20.18 \\
|
||||
--output docs/BIND_VERSION_DIFFERENCES.md
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple
|
||||
import re
|
||||
|
||||
|
||||
class GrammarComparator:
|
||||
"""Compare BIND9 grammar files between versions."""
|
||||
|
||||
def __init__(self, version1_dir: Path, version2_dir: Path):
|
||||
"""
|
||||
Initialize comparator with two version directories.
|
||||
|
||||
Args:
|
||||
version1_dir: Path to first version's grammar files
|
||||
version2_dir: Path to second version's grammar files
|
||||
"""
|
||||
self.version1_dir = version1_dir
|
||||
self.version2_dir = version2_dir
|
||||
self.version1_name = version1_dir.name
|
||||
self.version2_name = version2_dir.name
|
||||
|
||||
def load_grammar_file(self, file_path: Path) -> Dict:
|
||||
"""Load and parse a grammar file."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse grammar file into structured data
|
||||
options = self._parse_grammar(content)
|
||||
return options
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
def _parse_grammar(self, content: str) -> Dict[str, str]:
|
||||
"""
|
||||
Parse grammar content into a dictionary of options.
|
||||
|
||||
This is a simplified parser that extracts top-level keywords
|
||||
and their definitions.
|
||||
"""
|
||||
options = {}
|
||||
lines = content.split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#') or line.startswith('//'):
|
||||
continue
|
||||
|
||||
# Extract keyword and its definition
|
||||
# Pattern: keyword <definition>; or keyword <definition> { ... };
|
||||
match = re.match(r'^([a-z0-9-]+)\s+(.+?)(?:;|$)', line)
|
||||
if match:
|
||||
keyword = match.group(1)
|
||||
definition = match.group(2).strip()
|
||||
|
||||
# Extract flags from comments
|
||||
flags = []
|
||||
if '// may occur multiple times' in line:
|
||||
flags.append('may occur multiple times')
|
||||
if '// deprecated' in line:
|
||||
flags.append('deprecated')
|
||||
if '// obsolete' in line:
|
||||
flags.append('obsolete')
|
||||
if '// not configured' in line:
|
||||
flags.append('not configured')
|
||||
if '// test only' in line:
|
||||
flags.append('test only')
|
||||
if '// experimental' in line:
|
||||
flags.append('experimental')
|
||||
|
||||
options[keyword] = {
|
||||
'definition': definition,
|
||||
'flags': flags,
|
||||
'raw_line': line
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
def compare_files(self, filename: str) -> Dict:
|
||||
"""
|
||||
Compare a specific grammar file between two versions.
|
||||
|
||||
Returns:
|
||||
Dict with added, removed, modified, and deprecated options
|
||||
"""
|
||||
file1 = self.version1_dir / 'grammar' / filename
|
||||
file2 = self.version2_dir / 'grammar' / filename
|
||||
|
||||
options1 = self.load_grammar_file(file1)
|
||||
options2 = self.load_grammar_file(file2)
|
||||
|
||||
keys1 = set(options1.keys())
|
||||
keys2 = set(options2.keys())
|
||||
|
||||
# Identify changes
|
||||
added = keys2 - keys1
|
||||
removed = keys1 - keys2
|
||||
common = keys1 & keys2
|
||||
|
||||
modified = []
|
||||
deprecated_new = []
|
||||
|
||||
for key in common:
|
||||
opt1 = options1[key]
|
||||
opt2 = options2[key]
|
||||
|
||||
# Check if definition changed
|
||||
if opt1['definition'] != opt2['definition']:
|
||||
modified.append({
|
||||
'option': key,
|
||||
'old_definition': opt1['definition'],
|
||||
'new_definition': opt2['definition']
|
||||
})
|
||||
|
||||
# Check if newly deprecated
|
||||
if 'deprecated' not in opt1['flags'] and 'deprecated' in opt2['flags']:
|
||||
deprecated_new.append(key)
|
||||
|
||||
return {
|
||||
'file': filename,
|
||||
'added': sorted(added),
|
||||
'removed': sorted(removed),
|
||||
'modified': modified,
|
||||
'deprecated_new': deprecated_new,
|
||||
'options1_count': len(options1),
|
||||
'options2_count': len(options2)
|
||||
}
|
||||
|
||||
def compare_all(self) -> Dict:
|
||||
"""Compare all grammar files between versions."""
|
||||
# List of grammar files to compare
|
||||
grammar_files = [
|
||||
'options',
|
||||
'forward.zoneopt',
|
||||
'hint.zoneopt',
|
||||
'in-view.zoneopt',
|
||||
'mirror.zoneopt',
|
||||
'primary.zoneopt',
|
||||
'redirect.zoneopt',
|
||||
'secondary.zoneopt',
|
||||
'static-stub.zoneopt',
|
||||
'stub.zoneopt',
|
||||
'delegation-only.zoneopt',
|
||||
'rndc.grammar',
|
||||
]
|
||||
|
||||
results = {}
|
||||
for filename in grammar_files:
|
||||
result = self.compare_files(filename)
|
||||
results[filename] = result
|
||||
|
||||
return results
|
||||
|
||||
def generate_markdown_report(self, results: Dict) -> str:
|
||||
"""Generate a Markdown report from comparison results."""
|
||||
lines = []
|
||||
lines.append(f"# BIND9 Version Differences: {self.version1_name} vs {self.version2_name}")
|
||||
lines.append("")
|
||||
lines.append(f"This document compares BIND9 configuration grammar between {self.version1_name} and {self.version2_name}.")
|
||||
lines.append("")
|
||||
lines.append("Generated automatically by `scripts/compare_bind_versions.py`.")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
lines.append("## Summary")
|
||||
lines.append("")
|
||||
|
||||
total_added = sum(len(r['added']) for r in results.values())
|
||||
total_removed = sum(len(r['removed']) for r in results.values())
|
||||
total_modified = sum(len(r['modified']) for r in results.values())
|
||||
total_deprecated = sum(len(r['deprecated_new']) for r in results.values())
|
||||
|
||||
lines.append(f"- **New Options**: {total_added}")
|
||||
lines.append(f"- **Removed Options**: {total_removed} ⚠️")
|
||||
lines.append(f"- **Modified Options**: {total_modified}")
|
||||
lines.append(f"- **Newly Deprecated**: {total_deprecated}")
|
||||
lines.append("")
|
||||
|
||||
# Breaking Changes
|
||||
if total_removed > 0:
|
||||
lines.append("## ⚠️ Breaking Changes")
|
||||
lines.append("")
|
||||
lines.append(f"The following options were removed in {self.version2_name} and will cause configuration errors:")
|
||||
lines.append("")
|
||||
|
||||
for filename, result in results.items():
|
||||
if result['removed']:
|
||||
lines.append(f"### {filename}")
|
||||
lines.append("")
|
||||
for option in result['removed']:
|
||||
lines.append(f"- `{option}`")
|
||||
lines.append("")
|
||||
|
||||
# New Features
|
||||
if total_added > 0:
|
||||
lines.append("## ✨ New Features")
|
||||
lines.append("")
|
||||
lines.append(f"The following options were added in {self.version2_name}:")
|
||||
lines.append("")
|
||||
|
||||
for filename, result in results.items():
|
||||
if result['added']:
|
||||
lines.append(f"### {filename}")
|
||||
lines.append("")
|
||||
for option in result['added']:
|
||||
lines.append(f"- `{option}`")
|
||||
lines.append("")
|
||||
|
||||
# Modified Options
|
||||
if total_modified > 0:
|
||||
lines.append("## 🔧 Modified Options")
|
||||
lines.append("")
|
||||
lines.append(f"The following options have syntax changes in {self.version2_name}:")
|
||||
lines.append("")
|
||||
|
||||
for filename, result in results.items():
|
||||
if result['modified']:
|
||||
lines.append(f"### {filename}")
|
||||
lines.append("")
|
||||
for mod in result['modified']:
|
||||
lines.append(f"#### `{mod['option']}`")
|
||||
lines.append("")
|
||||
lines.append(f"**{self.version1_name}**:")
|
||||
lines.append(f"```")
|
||||
lines.append(f"{mod['old_definition']}")
|
||||
lines.append(f"```")
|
||||
lines.append("")
|
||||
lines.append(f"**{self.version2_name}**:")
|
||||
lines.append(f"```")
|
||||
lines.append(f"{mod['new_definition']}")
|
||||
lines.append(f"```")
|
||||
lines.append("")
|
||||
|
||||
# Deprecated Options
|
||||
if total_deprecated > 0:
|
||||
lines.append("## 📋 Newly Deprecated Options")
|
||||
lines.append("")
|
||||
lines.append(f"The following options were marked as deprecated in {self.version2_name}:")
|
||||
lines.append("")
|
||||
|
||||
for filename, result in results.items():
|
||||
if result['deprecated_new']:
|
||||
lines.append(f"### {filename}")
|
||||
lines.append("")
|
||||
for option in result['deprecated_new']:
|
||||
lines.append(f"- `{option}`")
|
||||
lines.append("")
|
||||
|
||||
# File-by-File Comparison
|
||||
lines.append("## Detailed File-by-File Comparison")
|
||||
lines.append("")
|
||||
|
||||
for filename, result in results.items():
|
||||
lines.append(f"### {filename}")
|
||||
lines.append("")
|
||||
lines.append(f"- {self.version1_name}: {result['options1_count']} options")
|
||||
lines.append(f"- {self.version2_name}: {result['options2_count']} options")
|
||||
lines.append(f"- Added: {len(result['added'])}")
|
||||
lines.append(f"- Removed: {len(result['removed'])}")
|
||||
lines.append(f"- Modified: {len(result['modified'])}")
|
||||
lines.append("")
|
||||
|
||||
# Migration Guide
|
||||
if total_removed > 0 or total_deprecated > 0:
|
||||
lines.append("## Migration Guide")
|
||||
lines.append("")
|
||||
lines.append(f"### Migrating from {self.version1_name} to {self.version2_name}")
|
||||
lines.append("")
|
||||
|
||||
if total_removed > 0:
|
||||
lines.append("1. **Remove unsupported options** from your configuration")
|
||||
lines.append(" - Review the Breaking Changes section above")
|
||||
lines.append(" - Check if there are replacement options")
|
||||
lines.append("")
|
||||
|
||||
if total_deprecated > 0:
|
||||
lines.append("2. **Plan for deprecated options**")
|
||||
lines.append(" - These options still work but may be removed in future versions")
|
||||
lines.append(" - Start planning migration to recommended alternatives")
|
||||
lines.append("")
|
||||
|
||||
lines.append("3. **Test your configuration**")
|
||||
lines.append(" - Use `named-checkconf` to validate syntax")
|
||||
lines.append(" - Test in a development environment before production")
|
||||
lines.append("")
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare BIND9 grammar files between versions"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version1-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Directory containing first version's grammar files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version2-dir",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Directory containing second version's grammar files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path("docs/BIND_VERSION_DIFFERENCES.md"),
|
||||
help="Output file for Markdown report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
type=Path,
|
||||
help="Also output raw comparison as JSON"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate directories
|
||||
if not args.version1_dir.exists():
|
||||
print(f"Error: {args.version1_dir} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not args.version2_dir.exists():
|
||||
print(f"Error: {args.version2_dir} does not exist", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Perform comparison
|
||||
print(f"Comparing BIND9 versions:")
|
||||
print(f" Version 1: {args.version1_dir.name}")
|
||||
print(f" Version 2: {args.version2_dir.name}")
|
||||
|
||||
comparator = GrammarComparator(args.version1_dir, args.version2_dir)
|
||||
results = comparator.compare_all()
|
||||
|
||||
# Generate and save Markdown report
|
||||
report = comparator.generate_markdown_report(results)
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(report)
|
||||
|
||||
print(f"✓ Markdown report saved to: {args.output}")
|
||||
|
||||
# Save JSON if requested
|
||||
if args.json:
|
||||
args.json.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(args.json, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"✓ JSON comparison saved to: {args.json}")
|
||||
|
||||
# Print summary
|
||||
total_added = sum(len(r['added']) for r in results.values())
|
||||
total_removed = sum(len(r['removed']) for r in results.values())
|
||||
total_modified = sum(len(r['modified']) for r in results.values())
|
||||
|
||||
print(f"\nComparison Summary:")
|
||||
print(f" Added options: {total_added}")
|
||||
print(f" Removed options: {total_removed}")
|
||||
print(f" Modified options: {total_modified}")
|
||||
|
||||
if total_removed > 0:
|
||||
print(f"\n⚠️ Warning: {total_removed} breaking changes detected!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user