#!/usr/bin/env python3 """Verify exported reference coverage, repair texts and provenance offline.""" import argparse import hashlib import json from pathlib import Path import re import sys ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from src.output.originals import load_site from src.output.alignment import read_output def unique_object(pairs): result = {} for key, value in pairs: if key in result: raise ValueError(f'Duplicate JSON key: {key}') result[key] = value return result def read(path): return json.loads(path.read_text(), object_pairs_hook=unique_object) def sha(path): return hashlib.sha256(path.read_bytes()).hexdigest() def verify(release, audit_path, bundle_path, source): manifest, audit, bundle = map(read, (release / 'manifest.json', audit_path, bundle_path)) assert manifest['audit_sha256'] == sha(audit_path), 'Audit hash mismatch' assert audit['numbering_ready'], 'Unresolved numbering audit' assert not any(audit[k] for k in ('missing_passages', 'conflicts', 'held_records')) raw, files = read_output(source) assert files == audit['input_files'], 'Raw source changed' site, codes = load_site() reverse = {code: book for book, code in codes.items()} actual, headings = {}, 0 assert set(manifest['files']) == {code + '.json' for code in codes.values()} assert {p.name for p in release.iterdir()} == set(manifest['files']) | {'manifest.json'} for filename, expected in manifest['files'].items(): path = release / filename assert sha(path) == expected, f'Asset hash mismatch: {filename}' book = read(path) assert book['translation'] == '日常訳第1版' and book['schema'] == 1 assert book['book'] == path.stem for ch, chapter in book['chapters'].items(): headings += bool(chapter['heading']) for verse, text in chapter['verses'].items(): key = (reverse[book['book']], int(ch), int(verse)) assert int(verse) > 0 and key not in actual assert text.strip() and not re.search(r'[\x00-\x1f\x7f<>\ufffd\u0370-\u03ff\u0590-\u05ff]|```', text), key actual[key] = text assert actual.keys() == site.keys(), 'Missing or extra release references' for key, text in actual.items(): ref = ':'.join(map(str, key)) expected = audit['aligned_changes'].get(ref, {}).get('text', raw.get(ref)) assert text == expected, f'Export differs from approved alignment: {ref}' for decision in bundle['decisions']: book, ch, verse = decision['target'].split(':') assert actual[(book, int(ch), int(verse))] == decision['text'], decision['target'] counts = {'books': len({k[0] for k in actual}), 'chapters': len({k[:2] for k in actual}), 'verses': len(actual)} assert all(manifest[k] == value for k, value in counts.items()) return {'status': 'pass', **counts, 'heading_blocks': headings, 'verified_repairs': len(bundle['decisions']), 'audit_sha256': sha(audit_path), 'bundle_sha256': sha(bundle_path), 'manifest_sha256': sha(release / 'manifest.json')} if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--release', type=Path, default=ROOT / 'reports/releases/nichijo-v1') parser.add_argument('--audit', type=Path, default=ROOT / 'reports/nichijo-v1-numbering-alignment.json') parser.add_argument('--bundle', type=Path, default=ROOT / 'reports/numbering-repairs/repair-bundle.json') parser.add_argument('--source', type=Path, default=ROOT / 'data/output') parser.add_argument('--report', type=Path) args = parser.parse_args() try: result = verify(args.release, args.audit, args.bundle, args.source) except (AssertionError, ValueError, KeyError, OSError) as exc: print(f'Release verification failed: {exc}', file=sys.stderr) sys.exit(1) output = json.dumps(result, ensure_ascii=False, indent=2) + '\n' if args.report: args.report.write_text(output) print(output, end='')