formatter_equivalence.py (7325B)
1 #!/usr/bin/env python3 2 """Check formatting against a frozen native parser, ignoring only source spans. 3 4 The oracle is independent of the formatter. Pattern structure, local scope, 5 operator direction, literals and definition order remain in the comparison. 6 No source files are changed; output candidates and evidence live in --directory. 7 """ 8 import argparse 9 import hashlib 10 import json 11 import subprocess 12 from pathlib import Path 13 from bundle_dag import merge_bundles 14 from fixture_sources import verify 15 from source_order import Runner, field, number 16 from tooling_batch_models import chars 17 18 ROOT = Path(__file__).resolve().parent.parent 19 20 21 def items(tree): 22 while tree != b'\0': 23 yield field(tree, 0) 24 tree = field(tree, 1) 25 26 27 def token(tree): 28 return [field(tree, 0).hex(), field(tree, 1, 0).hex()] 29 30 31 def optional(tree, transform): 32 if tree == b'\0': 33 return None 34 assert tree[0] == 1 35 return transform(tree[1:]) 36 37 38 def ast(tree): 39 kind, payload = number(field(tree, 0)), field(tree, 1, 0) 40 if kind in (1, 2, 6, 7): 41 value = payload.hex() 42 elif kind in (3, 14): 43 value = [ast(field(payload, 0)), ast(field(payload, 1))] 44 elif kind == 4: 45 value = [field(payload, 0).hex(), ast(field(payload, 1))] 46 elif kind == 5: 47 value = ast(payload) 48 elif kind == 8: 49 value = [ast(x) for x in items(payload)] 50 elif kind in (9, 15): 51 value = [token(field(payload, 0)), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))] 52 elif kind == 10: 53 value = [field(payload, 0).hex(), ast(field(payload, 1, 0)), ast(field(payload, 1, 1))] 54 elif kind == 11: 55 value = [ast(field(payload, 0)), [[optional(field(x, 0), token), ast(field(x, 1))] 56 for x in items(field(payload, 1))]] 57 elif kind == 12: 58 value = [[[ast(p) for p in items(field(x, 0))], ast(field(x, 1))] for x in items(payload)] 59 elif kind == 13: 60 tag, data = number(field(payload, 0)), field(payload, 1) 61 if tag in (0, 1, 4, 5): 62 part = data.hex() 63 elif tag in (2, 7): 64 part = ast(data) 65 elif tag == 3: 66 part = [ast(field(data, 0)), ast(field(data, 1))] 67 elif tag == 6: 68 part = [field(data, 0).hex(), ast(field(data, 1))] 69 else: 70 raise AssertionError(('unknown pattern', tag)) 71 value = [tag, part] 72 else: 73 raise AssertionError(('unknown AST kind', kind)) 74 return [kind, value] 75 76 77 def program(reply): 78 if field(reply, 0) == b'\0': 79 return None 80 assert field(reply, 0) == b'\1\0' 81 value = field(reply, 1, 0) 82 return [[[token(field(d, 0)), ast(field(d, 1))] for d in items(field(value, 0))], 83 optional(field(value, 1), ast)] 84 85 86 def main(): 87 p = argparse.ArgumentParser(description=__doc__) 88 p.add_argument('tricu', type=Path) 89 p.add_argument('--formatter-directory', required=True, type=Path) 90 p.add_argument('--directory', required=True, type=Path) 91 p.add_argument('--baseline', type=Path, help='Compare named repository files to saved pre-formatting sources') 92 p.add_argument('files', nargs='*', type=Path) 93 args = p.parse_args() 94 root = args.directory.resolve() 95 root.mkdir(parents=True, exist_ok=False) 96 selected = args.formatter_directory.resolve() 97 metadata = json.loads((selected / 'report.json').read_text()) 98 assert metadata['complete'], 'formatter preparation must pass first' 99 assert hashlib.sha256((selected / 'formatter.arboricx').read_bytes()).hexdigest() == metadata['formatter_sha256'] 100 rows = {r[2]: r for r in verify(ROOT / 'test/fixtures')} 101 fixture = ROOT / 'test/fixtures' / (rows['source_program'][0] + '.arboricx') 102 parser_bundle = root / 'parser.arboricx' 103 merge_bundles(parser_bundle, [(fixture, [('source_program.parseGateProgram', 'parse')])], prune_unreachable=True) 104 runner = Runner(args.tricu, root) 105 pin = runner.imported(parser_bundle, 'parser-import') 106 107 def parse(label, source): 108 masked = '\n'.join('--' + ' ' * (len(line) - 2) if line.startswith('!') else line 109 for line in source.split('\n')) 110 path = root / 'source.tree' 111 path.write_bytes(chars(masked)) 112 return program(runner.invoke(label, pin, 'parse', ['--arg-tree-file', path])) 113 114 def format_source(source): 115 path = root / 'input.tri' 116 path.write_bytes(source.encode('utf-8')) 117 result = subprocess.run([str(args.tricu.resolve()), 'format', str(path), '--formatter', 118 metadata['formatter_manifest'], '--tool-store', str(selected / 'store'), 119 '+RTS', '-N1', '-M8192m', '-RTS'], capture_output=True, timeout=90) 120 assert result.returncode == 0, result.stderr.decode() 121 assert path.read_bytes() == source.encode('utf-8') 122 return result.stdout.decode('utf-8') 123 124 records = [] 125 cases = [(label, before, after, False) for label, before, after in 126 json.loads((ROOT / 'test/formatter_cases.json').read_text())] 127 cases += [(str(path), path.read_bytes().decode('utf-8'), None, True) for path in args.files] 128 for i, (label, before, expected, required) in enumerate(cases): 129 after = format_source(before) 130 if expected is not None: 131 assert after == expected, label 132 assert format_source(after) == after, (label, 'idempotence') 133 original, formatted = parse(f'{i}-before', before), parse(f'{i}-after', after) 134 baseline_digest = None 135 if required: 136 assert original is not None, (label, 'source did not parse') 137 if args.baseline: 138 baseline = args.baseline / label 139 assert not Path(label).is_absolute() and baseline.is_file(), label 140 baseline_bytes = baseline.read_bytes() 141 baseline_digest = hashlib.sha256(baseline_bytes).hexdigest() 142 assert parse(f'{i}-baseline', baseline_bytes.decode('utf-8')) == original, (label, 'baseline AST changed') 143 assert original == formatted, (label, 'AST changed beyond locations') 144 (root / f'{i}-formatted.tri').write_bytes(after.encode('utf-8')) 145 records.append(dict(path=label, parsed=original is not None, changed=before != after, 146 baseline_sha256=baseline_digest, 147 normalized_ast_sha256=hashlib.sha256(json.dumps(original).encode()).hexdigest() if original is not None else None, 148 before_sha256=hashlib.sha256(before.encode()).hexdigest(), 149 after_sha256=hashlib.sha256(after.encode()).hexdigest(), output=f'{i}-formatted.tri')) 150 # A wrong body and a different binding scope must not pass this normalizer. 151 assert parse('control-body-a', 'f x = x\n') != parse('control-body-b', 'f x = t\n') 152 assert parse('control-scope-a', 'f x = let y = x in y\n') != parse('control-scope-b', 'f x = let y = x in x\n') 153 (root / 'report.json').write_text(json.dumps(dict(complete=True, records=records, 154 parser_sha256=hashlib.sha256(parser_bundle.read_bytes()).hexdigest(), 155 formatter_sha256=metadata['formatter_sha256']), indent=2) + '\n') 156 print(f'PASS {len(records)} formatting/idempotence checks; {sum(r["parsed"] for r in records)} span-normalized parse comparisons; body/scope negative controls') 157 158 159 if __name__ == '__main__': 160 main()