#!/usr/bin/env python3 """Validate migration assignments, optionally against the pinned donor Git blob.""" import argparse from collections import Counter, defaultdict import hashlib import json from pathlib import Path import subprocess import sys def validate(ledger, inventory): errors = [] expected = [entry['heading'] for entry in inventory['entries']] entries = ledger['entries'] counts = Counter(entry['source_heading'] for entry in entries) if len(set(expected)) != len(expected): errors.append('Duplicate source inventory headings') for heading in expected: if counts[heading] != 1: errors.append(f'{heading}: expected one mapping, found {counts[heading]}') for heading in counts.keys() - set(expected): errors.append(f'Unknown source heading: {heading}') owners = defaultdict(set) unowned = sum(counts[heading] == 0 for heading in expected) target_count = 0 for entry in entries: heading = entry['source_heading'] disposition = entry['disposition'] targets = entry['targets'] if disposition not in {'own', 'import', 'split', 'retire'}: errors.append(f'{heading}: invalid disposition') if disposition in {'own', 'import'} and len(targets) != 1: errors.append(f'{heading}: own/import requires exactly one target') if disposition == 'split' and len(targets) < 2: errors.append(f'{heading}: split requires at least two targets') if disposition == 'retire' and (targets or not entry.get('migration_note')): errors.append(f'{heading}: retirement requires rationale and no targets') if not targets and disposition != 'retire': unowned += 1 if not entry.get('authority') or not entry.get('implementation_tasks'): errors.append(f'{heading}: missing authority or implementation task') if heading.startswith('Non-Canonical Convenience Term: ') and any( target.get('kind') != 'convenience_term' for target in targets ): errors.append(f'{heading}: convenience term promoted to canonical concept') names = [target['concept'] for target in targets] if len(set(names)) != len(names): errors.append(f'{heading}: duplicate split targets') target_count += len(targets) all_targets = [target for entry in entries for target in entry['targets']] all_targets += ledger.get('additional_required_concepts', []) for target in all_targets: name = target['concept'] owner = target.get('owner', {}) canon, model = owner.get('canon'), owner.get('model') if not canon or not model: unowned += 1 errors.append(f'{name}: missing owner') continue registration = ledger['model_registry'].get(model) if not registration or registration['canon'] != canon: errors.append(f'{name}: owner not in model registry') owners[name].add((canon, model)) multiple = sorted(name for name, assigned in owners.items() if len(assigned) > 1) for name in multiple: errors.append(f'{name}: multiple owners {sorted(owners[name])}') return { 'source_entries': len(expected), 'mapped_entries': len(entries), 'source_targets': target_count, 'additional_required_concepts': len(ledger.get('additional_required_concepts', [])), 'dispositions': dict(sorted(Counter(e['disposition'] for e in entries).items())), 'unowned_concepts': unowned, 'multiply_owned_concepts': len(multiple), 'errors': errors, } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--ledger', type=Path, default=Path(__file__).resolve().parents[1] / 'ledger/concept-ownership.json') parser.add_argument('--source-repo', type=Path, help='Donor checkout, under either old or new name') args = parser.parse_args() try: ledger = json.loads(args.ledger.read_text()) inventory = json.loads((args.ledger.parent / ledger['source_inventory']).read_text()) result = validate(ledger, inventory) result['source_blob_verified'] = False if args.source_repo: raw = subprocess.check_output([ 'git', '-C', str(args.source_repo), 'show', f"{inventory['commit']}:{inventory['path']}", ]) headings = [ {'heading': line[3:], 'line': number} for number, line in enumerate(raw.decode().splitlines(), 1) if line.startswith('## ') ] if hashlib.sha256(raw).hexdigest() != inventory['sha256']: result['errors'].append('Source blob SHA-256 mismatch') elif headings != inventory['entries']: result['errors'].append('Inventory differs from pinned source headings') else: result['source_blob_verified'] = True result['valid'] = not result['errors'] except (OSError, ValueError, KeyError, TypeError, subprocess.CalledProcessError) as exc: result = {'valid': False, 'errors': [str(exc)]} print(json.dumps(result, indent=2)) return 0 if result['valid'] else 1 if __name__ == '__main__': sys.exit(main())