ethspecify 0.2.5__py3-none-any.whl → 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ethspecify/core.py CHANGED
@@ -11,6 +11,91 @@ import tokenize
11
11
  import yaml
12
12
 
13
13
 
14
+ def validate_exception_items(exceptions, version):
15
+ """
16
+ Validate that exception items actually exist in the spec.
17
+ Raises an exception if any item doesn't exist.
18
+ """
19
+ if not exceptions:
20
+ return
21
+
22
+ # Get the pyspec data
23
+ try:
24
+ pyspec = get_pyspec(version)
25
+ except Exception as e:
26
+ print(f"Warning: Could not validate exceptions - failed to load pyspec: {e}")
27
+ return
28
+
29
+ # Map exception keys to pyspec keys
30
+ exception_to_pyspec_map = {
31
+ 'functions': 'functions',
32
+ 'fn': 'functions',
33
+ 'constants': 'constant_vars',
34
+ 'constant_variables': 'constant_vars',
35
+ 'constant_var': 'constant_vars',
36
+ 'configs': 'config_vars',
37
+ 'config_variables': 'config_vars',
38
+ 'config_var': 'config_vars',
39
+ 'presets': 'preset_vars',
40
+ 'preset_variables': 'preset_vars',
41
+ 'preset_var': 'preset_vars',
42
+ 'ssz_objects': 'ssz_objects',
43
+ 'containers': 'ssz_objects',
44
+ 'container': 'ssz_objects',
45
+ 'dataclasses': 'dataclasses',
46
+ 'dataclass': 'dataclasses',
47
+ 'custom_types': 'custom_types',
48
+ 'custom_type': 'custom_types'
49
+ }
50
+
51
+ errors = []
52
+
53
+ for exception_key, exception_items in exceptions.items():
54
+ # Get the corresponding pyspec key
55
+ pyspec_key = exception_to_pyspec_map.get(exception_key)
56
+ if not pyspec_key:
57
+ errors.append(f"Unknown exception type: '{exception_key}'")
58
+ continue
59
+
60
+ # Ensure exception_items is a list
61
+ if not isinstance(exception_items, list):
62
+ exception_items = [exception_items]
63
+
64
+ for item in exception_items:
65
+ # Parse item#fork format
66
+ if '#' in item:
67
+ item_name, fork = item.split('#', 1)
68
+ else:
69
+ # If no fork specified, we'll check if it exists in any fork
70
+ item_name = item
71
+ fork = None
72
+
73
+ # Check if the item exists
74
+ item_found = False
75
+ if fork:
76
+ # Check specific fork
77
+ if ('mainnet' in pyspec and
78
+ fork in pyspec['mainnet'] and
79
+ pyspec_key in pyspec['mainnet'][fork] and
80
+ item_name in pyspec['mainnet'][fork][pyspec_key]):
81
+ item_found = True
82
+ else:
83
+ # Check if item exists in any fork
84
+ if 'mainnet' in pyspec:
85
+ for check_fork in pyspec['mainnet']:
86
+ if (pyspec_key in pyspec['mainnet'][check_fork] and
87
+ item_name in pyspec['mainnet'][check_fork][pyspec_key]):
88
+ item_found = True
89
+ break
90
+
91
+ if not item_found:
92
+ fork_suffix = f"#{fork}" if fork else ""
93
+ errors.append(f"invalid key: {exception_key}.{item_name}{fork_suffix}")
94
+
95
+ if errors:
96
+ error_msg = "Invalid exception items in configuration:\n" + "\n".join(f" - {e}" for e in errors)
97
+ raise Exception(error_msg)
98
+
14
99
  def load_config(directory=None):
15
100
  """
16
101
  Load configuration from .ethspecify.yml file in the specified directory.
@@ -25,7 +110,22 @@ def load_config(directory=None):
25
110
  try:
26
111
  with open(config_path, 'r') as f:
27
112
  config = yaml.safe_load(f)
28
- return config if config else {}
113
+ if not config:
114
+ return {}
115
+
116
+ # Get version from config, default to 'nightly'
117
+ version = config.get('version', 'nightly')
118
+
119
+ # Validate exceptions in root config
120
+ if 'exceptions' in config:
121
+ validate_exception_items(config['exceptions'], version)
122
+
123
+ # Also validate exceptions in specrefs section if present
124
+ if 'specrefs' in config and isinstance(config['specrefs'], dict):
125
+ if 'exceptions' in config['specrefs']:
126
+ validate_exception_items(config['specrefs']['exceptions'], version)
127
+
128
+ return config
29
129
  except (yaml.YAMLError, IOError) as e:
30
130
  print(f"Warning: Error reading .ethspecify.yml file: {e}")
31
131
  return {}
@@ -232,10 +332,16 @@ def get_spec(attributes, preset, fork, version="nightly"):
232
332
  + " = "
233
333
  + pyspec[preset][fork]["custom_types"][attributes["custom_type"]]
234
334
  )
235
- elif "ssz_object" in attributes:
335
+ elif "ssz_object" in attributes or "container" in attributes:
236
336
  if spec is not None:
237
337
  raise Exception(f"Tag can only specify one spec item")
238
- spec = pyspec[preset][fork]["ssz_objects"][attributes["ssz_object"]]
338
+ if "ssz_object" in attributes and "container" in attributes:
339
+ raise Exception(f"cannot contain 'ssz_object' and 'container'")
340
+ if "ssz_object" in attributes:
341
+ object_name = attributes["ssz_object"]
342
+ else:
343
+ object_name = attributes["container"]
344
+ spec = pyspec[preset][fork]["ssz_objects"][object_name]
239
345
  elif "dataclass" in attributes:
240
346
  if spec is not None:
241
347
  raise Exception(f"Tag can only specify one spec item")
@@ -471,6 +577,140 @@ def extract_attributes(tag):
471
577
  return dict(attr_pattern.findall(tag))
472
578
 
473
579
 
580
+ def sort_specref_yaml(yaml_file):
581
+ """
582
+ Sort specref entries in a YAML file by their name field.
583
+ Preserves formatting and adds single blank lines between entries.
584
+ """
585
+ if not os.path.exists(yaml_file):
586
+ return False
587
+
588
+ try:
589
+ with open(yaml_file, 'r') as f:
590
+ content_str = f.read()
591
+
592
+ # Try to fix common YAML issues with unquoted search strings containing colons
593
+ # This is the same fix used in check_source_files
594
+ content_str = re.sub(r'(\s+search:\s+)([^"\n]+:)(\s*$)', r'\1"\2"\3', content_str, flags=re.MULTILINE)
595
+
596
+ try:
597
+ content = yaml.safe_load(content_str)
598
+ except yaml.YAMLError:
599
+ # Fall back to FullLoader if safe_load fails
600
+ content = yaml.load(content_str, Loader=yaml.FullLoader)
601
+
602
+ if not content:
603
+ return False
604
+
605
+ # Handle both array of objects and single object formats
606
+ if isinstance(content, list):
607
+ # Sort the list by 'name' field if it exists
608
+ # Special handling for fork ordering within the same item
609
+ def sort_key(x):
610
+ name = x.get('name', '') if isinstance(x, dict) else str(x)
611
+
612
+ # Known fork names for ordering
613
+ forks = ['phase0', 'altair', 'bellatrix', 'capella', 'deneb', 'electra']
614
+
615
+ # Check if name contains a # separator (like "slash_validator#phase0")
616
+ if '#' in name:
617
+ base_name, fork = name.rsplit('#', 1)
618
+ # Define fork order based on known forks list
619
+ fork_lower = fork.lower()
620
+ if fork_lower in forks:
621
+ fork_order = forks.index(fork_lower)
622
+ else:
623
+ # Unknown forks go after known ones, sorted alphabetically
624
+ fork_order = len(forks)
625
+ return (base_name, fork_order, fork)
626
+ else:
627
+ # Check if name ends with a fork name (like "BeaconStatePhase0")
628
+ name_lower = name.lower()
629
+ for i, fork in enumerate(forks):
630
+ if name_lower.endswith(fork):
631
+ # Extract base name
632
+ base_name = name[:-len(fork)]
633
+ return (base_name, i, name)
634
+
635
+ # No fork pattern found, just sort by name
636
+ return (name, 0, '')
637
+
638
+ sorted_content = sorted(content, key=sort_key)
639
+
640
+ # Custom YAML writing to preserve formatting
641
+ output_lines = []
642
+ for i, item in enumerate(sorted_content):
643
+ if i > 0:
644
+ # Add a single blank line between entries
645
+ output_lines.append("")
646
+
647
+ # Start each entry with a dash
648
+ first_line = True
649
+ for key, value in item.items():
650
+ if first_line:
651
+ prefix = "- "
652
+ first_line = False
653
+ else:
654
+ prefix = " "
655
+
656
+ if key == 'spec':
657
+ # Preserve spec content as-is, using literal style
658
+ output_lines.append(f"{prefix}{key}: |")
659
+ # Indent the spec content - preserve it exactly as-is
660
+ spec_lines = value.rstrip().split('\n') if isinstance(value, str) else str(value).rstrip().split('\n')
661
+ for spec_line in spec_lines:
662
+ output_lines.append(f" {spec_line}")
663
+ elif key == 'sources':
664
+ if isinstance(value, list) and len(value) == 0:
665
+ # Keep empty lists on the same line for clarity
666
+ output_lines.append(f"{prefix}{key}: []")
667
+ else:
668
+ output_lines.append(f"{prefix}{key}:")
669
+ if isinstance(value, list):
670
+ for source in value:
671
+ if isinstance(source, dict):
672
+ output_lines.append(f" - file: {source.get('file', '')}")
673
+ if 'search' in source:
674
+ search_val = source['search']
675
+ # Only quote if:
676
+ # 1. Contains a colon followed by space or end of string (YAML mapping issue)
677
+ # 2. Not already quoted
678
+ # 3. Doesn't contain internal quotes (like regex patterns)
679
+ if ((':' in search_val or search_val.endswith(':')) and
680
+ not (search_val.startswith('"') and search_val.endswith('"')) and
681
+ '"' not in search_val):
682
+ # Only quote simple strings with colons, not complex patterns
683
+ if not any(char in search_val for char in ['\\', '*', '|', '[', ']', '(', ')']):
684
+ search_val = f'"{search_val}"'
685
+ output_lines.append(f" search: {search_val}")
686
+ if 'regex' in source:
687
+ # Keep boolean values lowercase for consistency
688
+ regex_val = str(source['regex']).lower()
689
+ output_lines.append(f" regex: {regex_val}")
690
+ else:
691
+ output_lines.append(f" - {source}")
692
+ else:
693
+ # Handle other fields - don't escape or modify the value
694
+ output_lines.append(f"{prefix}{key}: {value}")
695
+
696
+ # Strip trailing whitespace from all lines
697
+ output_lines = [line.rstrip() for line in output_lines]
698
+
699
+ # Write back the sorted content
700
+ with open(yaml_file, 'w') as f:
701
+ f.write('\n'.join(output_lines))
702
+ f.write('\n') # End file with newline
703
+
704
+ return True
705
+ elif isinstance(content, dict):
706
+ # If it's a single dict, we can't sort it
707
+ return False
708
+ except (yaml.YAMLError, IOError) as e:
709
+ print(f"Error sorting {yaml_file}: {e}")
710
+ return False
711
+
712
+ return False
713
+
474
714
  def replace_spec_tags(file_path, config=None):
475
715
  with open(file_path, 'r') as file:
476
716
  content = file.read()
@@ -485,6 +725,9 @@ def replace_spec_tags(file_path, config=None):
485
725
  re.DOTALL
486
726
  )
487
727
 
728
+ # Collect processed spec items for potential YAML updates
729
+ processed_items = []
730
+
488
731
  def rebuild_opening_tag(attributes, hash_value):
489
732
  # Rebuild a fresh opening tag from attributes, overriding any existing hash.
490
733
  new_opening = "<spec"
@@ -523,6 +766,17 @@ def replace_spec_tags(file_path, config=None):
523
766
  spec = get_spec(attributes, preset, fork, version)
524
767
  hash_value = hashlib.sha256(spec.encode('utf-8')).hexdigest()[:8]
525
768
 
769
+ # Collect this item for potential YAML updates
770
+ processed_items.append({
771
+ 'attributes': attributes,
772
+ 'preset': preset,
773
+ 'fork': fork,
774
+ 'style': style,
775
+ 'version': version,
776
+ 'spec': spec,
777
+ 'hash': hash_value
778
+ })
779
+
526
780
  if style == "hash":
527
781
  # Rebuild a fresh self-closing tag.
528
782
  updated_tag = rebuild_self_closing_tag(attributes, hash_value)
@@ -534,7 +788,7 @@ def replace_spec_tags(file_path, config=None):
534
788
  prefix = content[:match.start()].splitlines()[-1]
535
789
  prefixed_spec = "\n".join(
536
790
  f"{prefix}{line}" if line.rstrip() else prefix.rstrip()
537
- for line in spec_content.rstrip().split("\n")
791
+ for line in spec_content.split("\n")
538
792
  )
539
793
  updated_tag = f"{new_opening}\n{prefixed_spec}\n{prefix}</spec>"
540
794
  return updated_tag
@@ -547,6 +801,151 @@ def replace_spec_tags(file_path, config=None):
547
801
  with open(file_path, 'w') as file:
548
802
  file.write(updated_content)
549
803
 
804
+ # Return processed items for potential YAML updates
805
+ return processed_items
806
+
807
+
808
+ def get_yaml_filename_for_spec_attr(spec_attr):
809
+ """Map spec attribute to YAML filename."""
810
+ attr_to_file = {
811
+ 'fn': 'functions.yml',
812
+ 'function': 'functions.yml',
813
+ 'constant_var': 'constants.yml',
814
+ 'config_var': 'configs.yml',
815
+ 'preset_var': 'presets.yml',
816
+ 'container': 'containers.yml',
817
+ 'ssz_object': 'containers.yml',
818
+ 'dataclass': 'dataclasses.yml',
819
+ 'custom_type': 'types.yml',
820
+ }
821
+ return attr_to_file.get(spec_attr)
822
+
823
+
824
+ def get_spec_attr_and_name(attributes):
825
+ """Extract the spec attribute and item name from tag attributes."""
826
+ spec_attrs = ['fn', 'function', 'constant_var', 'config_var', 'preset_var',
827
+ 'container', 'ssz_object', 'dataclass', 'custom_type']
828
+ for attr in spec_attrs:
829
+ if attr in attributes:
830
+ return attr, attributes[attr]
831
+ return None, None
832
+
833
+
834
+ def load_yaml_entries(yaml_file):
835
+ """Load existing entries from a YAML file."""
836
+ if not os.path.exists(yaml_file):
837
+ return []
838
+
839
+ try:
840
+ with open(yaml_file, 'r') as f:
841
+ content_str = f.read()
842
+
843
+ # Try to fix common YAML issues with unquoted search strings containing colons
844
+ content_str = re.sub(r'(\s+search:\s+)([^"\n]+:)(\s*$)', r'\1"\2"\3', content_str, flags=re.MULTILINE)
845
+
846
+ try:
847
+ content = yaml.safe_load(content_str)
848
+ except yaml.YAMLError:
849
+ content = yaml.load(content_str, Loader=yaml.FullLoader)
850
+
851
+ if isinstance(content, list):
852
+ return content
853
+ return []
854
+ except (yaml.YAMLError, IOError):
855
+ return []
856
+
857
+
858
+ def extract_spec_tag_key(spec_content):
859
+ """Extract a unique key from spec tag to identify duplicates."""
860
+ if not spec_content:
861
+ return None
862
+
863
+ # Extract the opening spec tag
864
+ match = re.search(r'<spec\b([^>]*)>', spec_content)
865
+ if not match:
866
+ return None
867
+
868
+ # Extract attributes from the tag
869
+ attributes = extract_attributes(match.group(0))
870
+
871
+ # Build a key from the spec attribute and fork
872
+ # e.g., "constant_var:DOMAIN_PTC_ATTESTER:fork:gloas"
873
+ key_parts = []
874
+ for attr in ['fn', 'function', 'constant_var', 'config_var', 'preset_var',
875
+ 'container', 'ssz_object', 'dataclass', 'custom_type']:
876
+ if attr in attributes:
877
+ key_parts.append(f"{attr}:{attributes[attr]}")
878
+ break
879
+
880
+ if 'fork' in attributes:
881
+ key_parts.append(f"fork:{attributes['fork']}")
882
+
883
+ return ':'.join(key_parts) if key_parts else None
884
+
885
+
886
+ def add_missing_entries_to_yaml(yaml_file, new_entries):
887
+ """Add new entries to a YAML file and sort it."""
888
+ if not new_entries:
889
+ return
890
+
891
+ # Load existing entries
892
+ existing_entries = load_yaml_entries(yaml_file)
893
+
894
+ # Build a set of existing spec tag keys
895
+ existing_spec_keys = set()
896
+ for entry in existing_entries:
897
+ if isinstance(entry, dict) and 'spec' in entry:
898
+ spec_key = extract_spec_tag_key(entry['spec'])
899
+ if spec_key:
900
+ existing_spec_keys.add(spec_key)
901
+
902
+ # Filter out entries that already exist (based on spec tag, not name)
903
+ entries_to_add = []
904
+ for entry in new_entries:
905
+ spec_key = extract_spec_tag_key(entry.get('spec', ''))
906
+ if spec_key and spec_key not in existing_spec_keys:
907
+ entries_to_add.append(entry)
908
+ existing_spec_keys.add(spec_key) # Avoid duplicates within new entries
909
+
910
+ if not entries_to_add:
911
+ return
912
+
913
+ # Combine and write
914
+ all_entries = existing_entries + entries_to_add
915
+
916
+ # Ensure directory exists
917
+ os.makedirs(os.path.dirname(yaml_file) if os.path.dirname(yaml_file) else '.', exist_ok=True)
918
+
919
+ # Write combined entries using the same format as generate_specref_files
920
+ with open(yaml_file, 'w') as f:
921
+ for i, entry in enumerate(all_entries):
922
+ if i > 0:
923
+ f.write('\n')
924
+ f.write(f'- name: {entry["name"]}\n')
925
+ if 'sources' in entry:
926
+ if isinstance(entry['sources'], list) and len(entry['sources']) == 0:
927
+ f.write(' sources: []\n')
928
+ else:
929
+ f.write(' sources:\n')
930
+ for source in entry['sources']:
931
+ if isinstance(source, dict):
932
+ f.write(f' - file: {source.get("file", "")}\n')
933
+ if 'search' in source:
934
+ f.write(f' search: {source["search"]}\n')
935
+ if 'regex' in source:
936
+ f.write(f' regex: {source["regex"]}\n')
937
+ else:
938
+ f.write(f' - {source}\n')
939
+ if 'spec' in entry:
940
+ f.write(' spec: |\n')
941
+ for line in entry['spec'].split('\n'):
942
+ f.write(f' {line}\n')
943
+
944
+ # Sort the file
945
+ sort_specref_yaml(yaml_file)
946
+
947
+ print(f"Added {len(entries_to_add)} new entries to {yaml_file}")
948
+
550
949
 
551
950
  def check_source_files(yaml_file, project_root, exceptions=None):
552
951
  """
@@ -614,6 +1013,7 @@ def check_source_files(yaml_file, project_root, exceptions=None):
614
1013
  'config_var': 'configs',
615
1014
  'preset_var': 'presets',
616
1015
  'ssz_object': 'ssz_objects',
1016
+ 'container': 'ssz_objects',
617
1017
  'dataclass': 'dataclasses',
618
1018
  'custom_type': 'custom_types'
619
1019
  }
@@ -625,19 +1025,23 @@ def check_source_files(yaml_file, project_root, exceptions=None):
625
1025
  if not spec_ref and 'name' in item:
626
1026
  spec_ref = item['name']
627
1027
 
1028
+ # Extract item name and fork for exception checking
1029
+ item_name_for_exception = None
1030
+ fork_for_exception = None
1031
+ if spec_ref and '#' in spec_ref and '.' in spec_ref:
1032
+ # Format: "functions.item_name#fork"
1033
+ _, item_with_fork = spec_ref.split('.', 1)
1034
+ if '#' in item_with_fork:
1035
+ item_name_for_exception, fork_for_exception = item_with_fork.split('#', 1)
1036
+
628
1037
  # Check if sources list is empty
629
1038
  if not item['sources']:
630
1039
  if spec_ref:
631
- # Extract item name and fork from spec_ref for exception checking
632
- if '#' in spec_ref and '.' in spec_ref:
633
- # Format: "functions.item_name#fork"
634
- _, item_with_fork = spec_ref.split('.', 1)
635
- if '#' in item_with_fork:
636
- item_name, fork = item_with_fork.split('#', 1)
637
- # Check if this item is in exceptions
638
- if is_excepted(item_name, fork, exceptions):
639
- total_count += 1
640
- continue
1040
+ # Check if this item is in exceptions
1041
+ if item_name_for_exception and fork_for_exception:
1042
+ if is_excepted(item_name_for_exception, fork_for_exception, exceptions):
1043
+ total_count += 1
1044
+ continue
641
1045
 
642
1046
  errors.append(f"EMPTY SOURCES: {spec_ref}")
643
1047
  else:
@@ -647,6 +1051,13 @@ def check_source_files(yaml_file, project_root, exceptions=None):
647
1051
  total_count += 1
648
1052
  continue
649
1053
 
1054
+ # Check if item has non-empty sources but is in exceptions
1055
+ if item_name_for_exception and fork_for_exception:
1056
+ if is_excepted(item_name_for_exception, fork_for_exception, exceptions):
1057
+ errors.append(f"EXCEPTION CONFLICT: {spec_ref} has a specref")
1058
+ total_count += 1
1059
+ continue
1060
+
650
1061
  for source in item['sources']:
651
1062
  # All sources now use the standardized dict format with file and optional search
652
1063
  if not isinstance(source, dict) or 'file' not in source:
@@ -758,7 +1169,7 @@ def extract_spec_tags_from_yaml(yaml_file, tag_type=None):
758
1169
 
759
1170
  # Known tag type attributes
760
1171
  tag_attributes = ['fn', 'function', 'constant_var', 'config_var', 'preset_var',
761
- 'ssz_object', 'dataclass', 'custom_type']
1172
+ 'ssz_object', 'container', 'dataclass', 'custom_type']
762
1173
 
763
1174
  try:
764
1175
  with open(yaml_file, 'r') as f:
@@ -807,6 +1218,9 @@ def extract_spec_tags_from_yaml(yaml_file, tag_type=None):
807
1218
  # Normalize function to fn
808
1219
  if found_tag_type == 'function':
809
1220
  found_tag_type = 'fn'
1221
+ # Normalize container to ssz_object
1222
+ if found_tag_type == 'container':
1223
+ found_tag_type = 'ssz_object'
810
1224
  break
811
1225
 
812
1226
  if found_tag_type and 'fork' in attrs:
@@ -822,6 +1236,209 @@ def extract_spec_tags_from_yaml(yaml_file, tag_type=None):
822
1236
  return tag_types_found, pairs
823
1237
 
824
1238
 
1239
+ def generate_specrefs_from_files(files_with_spec_tags, project_dir):
1240
+ """
1241
+ Generate specrefs data from files containing spec tags.
1242
+ Returns a dict with spec tag info and their source locations.
1243
+ """
1244
+ specrefs = {}
1245
+
1246
+ for file_path in files_with_spec_tags:
1247
+ try:
1248
+ with open(file_path, 'r', encoding='utf-8') as f:
1249
+ content = f.read()
1250
+
1251
+ # Find all spec tags in the file
1252
+ spec_tag_pattern = r'<spec\s+([^>]+?)(?:\s*/>|>)'
1253
+ matches = re.finditer(spec_tag_pattern, content)
1254
+
1255
+ for match in matches:
1256
+ tag_attrs_str = match.group(1)
1257
+ attrs = extract_attributes(f"<spec {tag_attrs_str}>")
1258
+
1259
+ # Determine the spec type and name
1260
+ spec_type = None
1261
+ spec_name = None
1262
+ fork = attrs.get('fork', None)
1263
+
1264
+ # Check each possible spec attribute
1265
+ for attr_name in ['fn', 'function', 'constant_var', 'config_var',
1266
+ 'preset_var', 'ssz_object', 'container', 'dataclass', 'custom_type']:
1267
+ if attr_name in attrs:
1268
+ spec_type = attr_name
1269
+ spec_name = attrs[attr_name]
1270
+ break
1271
+
1272
+ if spec_type and spec_name:
1273
+ # Normalize container to ssz_object for consistency
1274
+ if spec_type == 'container':
1275
+ spec_type = 'ssz_object'
1276
+ # Create a unique key for this spec reference
1277
+ key = f"{spec_type}.{spec_name}"
1278
+ if fork:
1279
+ key += f"#{fork}"
1280
+
1281
+ if key not in specrefs:
1282
+ specrefs[key] = {
1283
+ 'name': spec_name,
1284
+ 'type': spec_type,
1285
+ 'fork': fork,
1286
+ 'sources': []
1287
+ }
1288
+
1289
+ # Add this source location
1290
+ rel_path = os.path.relpath(file_path, project_dir)
1291
+
1292
+ # Get line number of the match
1293
+ lines_before = content[:match.start()].count('\n')
1294
+ line_num = lines_before + 1
1295
+
1296
+ specrefs[key]['sources'].append({
1297
+ 'file': rel_path,
1298
+ 'line': line_num
1299
+ })
1300
+
1301
+ except (IOError, UnicodeDecodeError):
1302
+ continue
1303
+
1304
+ return specrefs
1305
+
1306
+
1307
+ def process_generated_specrefs(specrefs, exceptions, version):
1308
+ """
1309
+ Process the generated specrefs and check coverage.
1310
+ Returns (success, results)
1311
+ """
1312
+ results = {}
1313
+ overall_success = True
1314
+
1315
+ # Group specrefs by type for coverage checking
1316
+ specrefs_by_type = {}
1317
+ for _, data in specrefs.items():
1318
+ spec_type = data['type']
1319
+ if spec_type not in specrefs_by_type:
1320
+ specrefs_by_type[spec_type] = []
1321
+ specrefs_by_type[spec_type].append(data)
1322
+
1323
+ # Map spec types to history keys
1324
+ type_to_history_key = {
1325
+ 'fn': 'functions',
1326
+ 'function': 'functions',
1327
+ 'constant_var': 'constant_vars',
1328
+ 'config_var': 'config_vars',
1329
+ 'preset_var': 'preset_vars',
1330
+ 'ssz_object': 'ssz_objects',
1331
+ 'container': 'ssz_objects',
1332
+ 'dataclass': 'dataclasses',
1333
+ 'custom_type': 'custom_types'
1334
+ }
1335
+
1336
+ # Map to exception keys
1337
+ type_to_exception_key = {
1338
+ 'fn': 'functions',
1339
+ 'function': 'functions',
1340
+ 'constant_var': 'constants',
1341
+ 'config_var': 'configs',
1342
+ 'preset_var': 'presets',
1343
+ 'ssz_object': 'ssz_objects',
1344
+ 'container': 'ssz_objects',
1345
+ 'dataclass': 'dataclasses',
1346
+ 'custom_type': 'custom_types'
1347
+ }
1348
+
1349
+ # Get spec history for coverage checking
1350
+ history = get_spec_item_history("mainnet", version)
1351
+
1352
+ # Check coverage for each type
1353
+ total_found = 0
1354
+ total_expected = 0
1355
+ all_missing = []
1356
+
1357
+ for spec_type, items in specrefs_by_type.items():
1358
+ history_key = type_to_history_key.get(spec_type, spec_type)
1359
+ exception_key = type_to_exception_key.get(spec_type, spec_type)
1360
+
1361
+ # Get exceptions for this type - handle both singular and plural keys
1362
+ type_exceptions = []
1363
+ if exception_key in exceptions:
1364
+ type_exceptions = exceptions[exception_key]
1365
+ # Also check plural forms
1366
+ elif exception_key + 's' in exceptions:
1367
+ type_exceptions = exceptions[exception_key + 's']
1368
+ # Check if singular form exists when we have plural
1369
+ elif exception_key.endswith('s') and exception_key[:-1] in exceptions:
1370
+ type_exceptions = exceptions[exception_key[:-1]]
1371
+
1372
+ # Special handling for ssz_objects/containers
1373
+ if spec_type in ['ssz_object', 'container'] and not type_exceptions:
1374
+ # Check for 'containers' as an alternative key
1375
+ if 'containers' in exceptions:
1376
+ type_exceptions = exceptions['containers']
1377
+ elif 'container' in exceptions:
1378
+ type_exceptions = exceptions['container']
1379
+
1380
+ # Build set of what we found
1381
+ found_items = set()
1382
+ for item in items:
1383
+ if item['fork']:
1384
+ found_items.add(f"{item['name']}#{item['fork']}")
1385
+ else:
1386
+ # If no fork specified, we need to check all forks
1387
+ if history_key in history and item['name'] in history[history_key]:
1388
+ for fork in history[history_key][item['name']]:
1389
+ found_items.add(f"{item['name']}#{fork}")
1390
+
1391
+ # Check what's expected
1392
+ if history_key in history:
1393
+ for item_name, forks in history[history_key].items():
1394
+ for fork in forks:
1395
+ expected_key = f"{item_name}#{fork}"
1396
+ total_expected += 1
1397
+
1398
+ # Check if excepted
1399
+ if is_excepted(item_name, fork, type_exceptions):
1400
+ total_found += 1
1401
+ continue
1402
+
1403
+ if expected_key in found_items:
1404
+ total_found += 1
1405
+ else:
1406
+ # Use the proper type prefix for the missing item
1407
+ type_prefix_map = {
1408
+ 'functions': 'functions',
1409
+ 'constant_vars': 'constants',
1410
+ 'config_vars': 'configs',
1411
+ 'preset_vars': 'presets',
1412
+ 'ssz_objects': 'ssz_objects',
1413
+ 'dataclasses': 'dataclasses',
1414
+ 'custom_types': 'custom_types'
1415
+ }
1416
+ prefix = type_prefix_map.get(history_key, history_key)
1417
+ all_missing.append(f"{prefix}.{expected_key}")
1418
+
1419
+ # Count total spec references found
1420
+ total_refs = len(specrefs)
1421
+
1422
+ # Store results
1423
+ results['Project Coverage'] = {
1424
+ 'source_files': {
1425
+ 'valid': total_refs,
1426
+ 'total': total_refs,
1427
+ 'errors': []
1428
+ },
1429
+ 'coverage': {
1430
+ 'found': total_found,
1431
+ 'expected': total_expected,
1432
+ 'missing': all_missing
1433
+ }
1434
+ }
1435
+
1436
+ if all_missing:
1437
+ overall_success = False
1438
+
1439
+ return overall_success, results
1440
+
1441
+
825
1442
  def check_coverage(yaml_file, tag_type, exceptions, preset="mainnet", version="nightly"):
826
1443
  """
827
1444
  Check that all spec items from ethspecify have corresponding tags in the YAML file.
@@ -830,6 +1447,7 @@ def check_coverage(yaml_file, tag_type, exceptions, preset="mainnet", version="n
830
1447
  # Map tag types to history keys
831
1448
  history_key_map = {
832
1449
  'ssz_object': 'ssz_objects',
1450
+ 'container': 'ssz_objects',
833
1451
  'config_var': 'config_vars',
834
1452
  'preset_var': 'preset_vars',
835
1453
  'dataclass': 'dataclasses',
@@ -890,16 +1508,53 @@ def run_checks(project_dir, config):
890
1508
  else:
891
1509
  # New format: specrefs: { files: [...], exceptions: {...} }
892
1510
  specrefs_files = specrefs_config.get('files', [])
893
- exceptions = specrefs_config.get('exceptions', {})
894
1511
 
1512
+ # Support exceptions in either specrefs section or root, but not both
1513
+ specrefs_exceptions = specrefs_config.get('exceptions', {})
1514
+ root_exceptions = config.get('exceptions', {})
1515
+
1516
+ if specrefs_exceptions and root_exceptions:
1517
+ print("Warning: Exceptions found in both root and specrefs sections. Using specrefs exceptions.")
1518
+ exceptions = specrefs_exceptions
1519
+ elif specrefs_exceptions:
1520
+ exceptions = specrefs_exceptions
1521
+ else:
1522
+ exceptions = root_exceptions
1523
+
1524
+ # If no files specified, search the whole project for spec tags
895
1525
  if not specrefs_files:
896
- print("Error: No specrefs files specified in .ethspecify.yml")
897
- print("Please add a 'specrefs:' section with 'files:' listing the files to check")
898
- return False, {}
1526
+ print("No specific files configured, searching entire project for spec tags...")
1527
+
1528
+ # Determine search root - configurable in specrefs section
1529
+ if 'search_root' in specrefs_config:
1530
+ # Use configured search_root (relative to project_dir)
1531
+ search_root_rel = specrefs_config['search_root']
1532
+ search_root = os.path.join(project_dir, search_root_rel) if not os.path.isabs(search_root_rel) else search_root_rel
1533
+ search_root = os.path.abspath(search_root)
1534
+ else:
1535
+ # Default behavior: if we're in a specrefs directory, search in the parent directory
1536
+ search_root = os.path.dirname(project_dir) if os.path.basename(project_dir) == 'specrefs' else project_dir
1537
+
1538
+ print(f"Searching for spec tags in: {search_root}")
1539
+
1540
+ # Use grep to find all files containing spec tags
1541
+ files_with_spec_tags = grep(search_root, r'<spec\b[^>]*>', [])
1542
+
1543
+ if not files_with_spec_tags:
1544
+ print(f"No files with spec tags found in the project")
1545
+ return True, {}
1546
+
1547
+ # Generate in-memory specrefs data from the found spec tags
1548
+ all_specrefs = generate_specrefs_from_files(files_with_spec_tags, search_root)
1549
+
1550
+ # Process the generated specrefs
1551
+ return process_generated_specrefs(all_specrefs, exceptions, version)
1552
+
899
1553
 
900
1554
  # Map tag types to exception keys (support both singular and plural)
901
1555
  exception_key_map = {
902
- 'ssz_object': ['ssz_objects', 'ssz_object'],
1556
+ 'ssz_object': ['ssz_objects', 'ssz_object', 'containers', 'container'],
1557
+ 'container': ['ssz_objects', 'ssz_object', 'containers', 'container'],
903
1558
  'config_var': ['configs', 'config_variables', 'config_var'],
904
1559
  'preset_var': ['presets', 'preset_variables', 'preset_var'],
905
1560
  'dataclass': ['dataclasses', 'dataclass'],
@@ -928,7 +1583,16 @@ def run_checks(project_dir, config):
928
1583
  # Process each tag type found in the file
929
1584
  if not tag_types_found:
930
1585
  # No spec tags found, still check source files
931
- valid_count, total_count, source_errors = check_source_files(yaml_path, os.path.dirname(project_dir), [])
1586
+ # Determine source root - use search_root if configured, otherwise use default behavior
1587
+ if 'search_root' in specrefs_config:
1588
+ search_root_rel = specrefs_config['search_root']
1589
+ source_root = os.path.join(project_dir, search_root_rel) if not os.path.isabs(search_root_rel) else search_root_rel
1590
+ source_root = os.path.abspath(source_root)
1591
+ else:
1592
+ # Default behavior: parent directory
1593
+ source_root = os.path.dirname(project_dir)
1594
+
1595
+ valid_count, total_count, source_errors = check_source_files(yaml_path, source_root, [])
932
1596
 
933
1597
  # Store results using filename as section name
934
1598
  section_name = filename.replace('.yml', '').replace('-', ' ').title()
@@ -980,7 +1644,16 @@ def run_checks(project_dir, config):
980
1644
  if key in exceptions:
981
1645
  all_exceptions.extend(exceptions[key])
982
1646
 
983
- valid_count, total_count, source_errors = check_source_files(yaml_path, os.path.dirname(project_dir), all_exceptions)
1647
+ # Determine source root - use search_root if configured, otherwise use default behavior
1648
+ if 'search_root' in specrefs_config:
1649
+ search_root_rel = specrefs_config['search_root']
1650
+ source_root = os.path.join(project_dir, search_root_rel) if not os.path.isabs(search_root_rel) else search_root_rel
1651
+ source_root = os.path.abspath(source_root)
1652
+ else:
1653
+ # Default behavior: parent directory
1654
+ source_root = os.path.dirname(project_dir)
1655
+
1656
+ valid_count, total_count, source_errors = check_source_files(yaml_path, source_root, all_exceptions)
984
1657
 
985
1658
  # Store results using filename as section name
986
1659
  section_name = filename.replace('.yml', '').replace('-', ' ').title()
@@ -1005,3 +1678,375 @@ def run_checks(project_dir, config):
1005
1678
  overall_success = False
1006
1679
 
1007
1680
  return overall_success, results
1681
+
1682
+
1683
+ def update_entry_names_in_yaml_files(project_dir, specrefs_files):
1684
+ """
1685
+ Update all entry names to use the format <spec_item>#<fork>.
1686
+ """
1687
+ for yaml_file in specrefs_files:
1688
+ yaml_path = os.path.join(project_dir, yaml_file)
1689
+
1690
+ if not os.path.exists(yaml_path):
1691
+ continue
1692
+
1693
+ # Load existing entries
1694
+ existing_entries = load_yaml_entries(yaml_path)
1695
+ if not existing_entries:
1696
+ continue
1697
+
1698
+ updated = False
1699
+ for entry in existing_entries:
1700
+ if not isinstance(entry, dict) or 'spec' not in entry:
1701
+ continue
1702
+
1703
+ # Extract spec tag attributes
1704
+ spec_content = entry['spec']
1705
+ match = re.search(r'<spec\b([^>]*)>', spec_content)
1706
+ if not match:
1707
+ continue
1708
+
1709
+ attributes = extract_attributes(match.group(0))
1710
+
1711
+ # Get the spec item name and fork
1712
+ spec_attr, item_name = get_spec_attr_and_name(attributes)
1713
+ fork = attributes.get('fork')
1714
+
1715
+ if item_name and fork:
1716
+ # Build the expected name
1717
+ expected_name = f'{item_name}#{fork}'
1718
+
1719
+ # Update if different
1720
+ if entry.get('name') != expected_name:
1721
+ entry['name'] = expected_name
1722
+ updated = True
1723
+
1724
+ # Write back if updated
1725
+ if updated:
1726
+ with open(yaml_path, 'w') as f:
1727
+ for i, entry in enumerate(existing_entries):
1728
+ if i > 0:
1729
+ f.write('\n')
1730
+ f.write(f'- name: {entry["name"]}\n')
1731
+ if 'sources' in entry:
1732
+ if isinstance(entry['sources'], list) and len(entry['sources']) == 0:
1733
+ f.write(' sources: []\n')
1734
+ else:
1735
+ f.write(' sources:\n')
1736
+ for source in entry['sources']:
1737
+ if isinstance(source, dict):
1738
+ f.write(f' - file: {source.get("file", "")}\n')
1739
+ if 'search' in source:
1740
+ f.write(f' search: {source["search"]}\n')
1741
+ if 'regex' in source:
1742
+ f.write(f' regex: {source["regex"]}\n')
1743
+ else:
1744
+ f.write(f' - {source}\n')
1745
+ if 'spec' in entry:
1746
+ f.write(' spec: |\n')
1747
+ for line in entry['spec'].split('\n'):
1748
+ f.write(f' {line}\n')
1749
+
1750
+ # Sort the file
1751
+ sort_specref_yaml(yaml_path)
1752
+ print(f"Updated entry names in {yaml_file}")
1753
+
1754
+
1755
+ def add_missing_spec_items_to_yaml_files(project_dir, config, specrefs_files):
1756
+ """
1757
+ Add missing spec items to existing YAML files.
1758
+ Ensures all spec items from the specification exist in YAML files with sources: []
1759
+ """
1760
+ version = config.get('version', 'nightly')
1761
+ preset = 'mainnet' # Could make this configurable
1762
+
1763
+ # Get all spec items
1764
+ pyspec = get_pyspec(version)
1765
+ if preset not in pyspec:
1766
+ print(f"Error: Preset '{preset}' not found")
1767
+ return
1768
+
1769
+ # Get all forks in chronological order, excluding EIP forks
1770
+ all_forks = sorted(
1771
+ [fork for fork in pyspec[preset].keys() if not fork.startswith("eip")],
1772
+ key=lambda x: (x != "phase0", x)
1773
+ )
1774
+
1775
+ # Map YAML filenames to category keys and spec attribute names
1776
+ filename_to_category = {
1777
+ 'constants.yml': ('constant_vars', 'constant_var'),
1778
+ 'configs.yml': ('config_vars', 'config_var'),
1779
+ 'presets.yml': ('preset_vars', 'preset_var'),
1780
+ 'functions.yml': ('functions', 'fn'),
1781
+ 'containers.yml': ('ssz_objects', 'container'),
1782
+ 'dataclasses.yml': ('dataclasses', 'dataclass'),
1783
+ 'types.yml': ('custom_types', 'custom_type'),
1784
+ }
1785
+
1786
+ for yaml_file in specrefs_files:
1787
+ yaml_path = os.path.join(project_dir, yaml_file)
1788
+ yaml_basename = os.path.basename(yaml_file)
1789
+
1790
+ if yaml_basename not in filename_to_category:
1791
+ continue
1792
+
1793
+ category, spec_attr = filename_to_category[yaml_basename]
1794
+
1795
+ # Collect all items in this category organized by name and fork
1796
+ items_by_name = {}
1797
+ for fork in all_forks:
1798
+ if fork not in pyspec[preset]:
1799
+ continue
1800
+ fork_data = pyspec[preset][fork]
1801
+
1802
+ if category not in fork_data:
1803
+ continue
1804
+
1805
+ for item_name, item_data in fork_data[category].items():
1806
+ if item_name not in items_by_name:
1807
+ items_by_name[item_name] = []
1808
+ items_by_name[item_name].append((fork, item_data))
1809
+
1810
+ # Build entries for missing items
1811
+ new_entries = []
1812
+ for item_name in sorted(items_by_name.keys()):
1813
+ forks_data = items_by_name[item_name]
1814
+
1815
+ # Find all unique versions of this item (where content differs)
1816
+ versions = [] # List of (fork, item_data, spec_content)
1817
+ prev_content = None
1818
+
1819
+ for fork, item_data in forks_data:
1820
+ # Build the spec content based on category
1821
+ if category == 'functions':
1822
+ spec_content = item_data
1823
+ elif category in ['constant_vars', 'config_vars', 'preset_vars']:
1824
+ # item_data is a list: [type, value, ...]
1825
+ if isinstance(item_data, (list, tuple)) and len(item_data) >= 2:
1826
+ type_info = item_data[0]
1827
+ value = item_data[1]
1828
+ if type_info:
1829
+ spec_content = f"{item_name}: {type_info} = {value}"
1830
+ else:
1831
+ spec_content = f"{item_name} = {value}"
1832
+ else:
1833
+ spec_content = str(item_data)
1834
+ elif category == 'ssz_objects':
1835
+ spec_content = item_data
1836
+ elif category == 'dataclasses':
1837
+ spec_content = item_data.replace("@dataclass\n", "")
1838
+ elif category == 'custom_types':
1839
+ # custom_types are simple type aliases: TypeName = SomeType
1840
+ spec_content = f"{item_name} = {item_data}"
1841
+ else:
1842
+ spec_content = str(item_data)
1843
+
1844
+ # Only add this version if it's different from the previous one
1845
+ if prev_content is None or spec_content != prev_content:
1846
+ versions.append((fork, item_data, spec_content))
1847
+ prev_content = spec_content
1848
+
1849
+ # Create entries based on number of unique versions
1850
+ use_fork_suffix = len(versions) > 1
1851
+
1852
+ for idx, (fork, item_data, spec_content) in enumerate(versions):
1853
+ # Calculate hash of current version
1854
+ hash_value = hashlib.sha256(spec_content.encode('utf-8')).hexdigest()[:8]
1855
+
1856
+ # For multiple versions after the first, use diff style
1857
+ if use_fork_suffix and idx > 0:
1858
+ # Get previous version for diff
1859
+ prev_fork, _, prev_spec_content = versions[idx - 1]
1860
+
1861
+ # Generate diff
1862
+ diff_content = diff(prev_fork, strip_comments(prev_spec_content), fork, strip_comments(spec_content))
1863
+
1864
+ # Build spec tag with style="diff"
1865
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" style="diff" hash="{hash_value}">'
1866
+ content = diff_content
1867
+ else:
1868
+ # Build spec tag without style="diff"
1869
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" hash="{hash_value}">'
1870
+ content = spec_content
1871
+
1872
+ # Create entry
1873
+ entry_name = f'{item_name}#{fork}' if use_fork_suffix else item_name
1874
+ entry = {
1875
+ 'name': entry_name,
1876
+ 'sources': [],
1877
+ 'spec': f'{spec_tag}\n{content}\n</spec>'
1878
+ }
1879
+ new_entries.append(entry)
1880
+
1881
+ # Add missing entries to the YAML file
1882
+ if new_entries:
1883
+ add_missing_entries_to_yaml(yaml_path, new_entries)
1884
+
1885
+
1886
+ def generate_specref_files(output_dir, version="nightly", preset="mainnet"):
1887
+ """
1888
+ Generate specref YAML files without sources for manual mapping.
1889
+ Creates a basic directory structure with empty sources.
1890
+ """
1891
+ # Create output directory if it doesn't exist
1892
+ os.makedirs(output_dir, exist_ok=True)
1893
+
1894
+ # Get all spec items
1895
+ pyspec = get_pyspec(version)
1896
+ if preset not in pyspec:
1897
+ raise ValueError(f"Preset '{preset}' not found")
1898
+
1899
+ # Get all forks in chronological order, excluding EIP forks
1900
+ all_forks = sorted(
1901
+ [fork for fork in pyspec[preset].keys() if not fork.startswith("eip")],
1902
+ key=lambda x: (x != "phase0", x)
1903
+ )
1904
+
1905
+ # Map history keys to file names and spec attribute names
1906
+ category_map = {
1907
+ 'constant_vars': ('constants.yml', 'constant_var'),
1908
+ 'config_vars': ('configs.yml', 'config_var'),
1909
+ 'preset_vars': ('presets.yml', 'preset_var'),
1910
+ 'functions': ('functions.yml', 'fn'),
1911
+ 'ssz_objects': ('containers.yml', 'container'),
1912
+ 'dataclasses': ('dataclasses.yml', 'dataclass'),
1913
+ 'custom_types': ('types.yml', 'custom_type'),
1914
+ }
1915
+
1916
+ # Collect all items organized by category
1917
+ items_by_category = {cat: {} for cat in category_map.keys()}
1918
+
1919
+ for fork in all_forks:
1920
+ if fork not in pyspec[preset]:
1921
+ continue
1922
+ fork_data = pyspec[preset][fork]
1923
+
1924
+ for category in items_by_category.keys():
1925
+ if category not in fork_data:
1926
+ continue
1927
+
1928
+ for item_name, item_data in fork_data[category].items():
1929
+ # Track which forks have this item
1930
+ if item_name not in items_by_category[category]:
1931
+ items_by_category[category][item_name] = []
1932
+ items_by_category[category][item_name].append((fork, item_data))
1933
+
1934
+ # Generate YAML files for each category
1935
+ for category, (filename, spec_attr) in category_map.items():
1936
+ if not items_by_category[category]:
1937
+ continue
1938
+
1939
+ output_path = os.path.join(output_dir, filename)
1940
+ entries = []
1941
+
1942
+ # Sort items alphabetically
1943
+ for item_name in sorted(items_by_category[category].keys()):
1944
+ forks_data = items_by_category[category][item_name]
1945
+
1946
+ # Find all unique versions of this item (where content differs)
1947
+ versions = [] # List of (fork, item_data, spec_content)
1948
+ prev_content = None
1949
+
1950
+ for fork, item_data in forks_data:
1951
+ # Build the spec content based on category
1952
+ if category == 'functions':
1953
+ spec_content = item_data
1954
+ elif category in ['constant_vars', 'config_vars', 'preset_vars']:
1955
+ # item_data is a list: [type, value, ...]
1956
+ if isinstance(item_data, (list, tuple)) and len(item_data) >= 2:
1957
+ type_info = item_data[0]
1958
+ value = item_data[1]
1959
+ if type_info:
1960
+ spec_content = f"{item_name}: {type_info} = {value}"
1961
+ else:
1962
+ spec_content = f"{item_name} = {value}"
1963
+ else:
1964
+ spec_content = str(item_data)
1965
+ elif category == 'ssz_objects':
1966
+ spec_content = item_data
1967
+ elif category == 'dataclasses':
1968
+ spec_content = item_data.replace("@dataclass\n", "")
1969
+ elif category == 'custom_types':
1970
+ # custom_types are simple type aliases: TypeName = SomeType
1971
+ spec_content = f"{item_name} = {item_data}"
1972
+ else:
1973
+ spec_content = str(item_data)
1974
+
1975
+ # Only add this version if it's different from the previous one
1976
+ if prev_content is None or spec_content != prev_content:
1977
+ versions.append((fork, item_data, spec_content))
1978
+ prev_content = spec_content
1979
+
1980
+ # Create entries based on number of unique versions
1981
+ use_fork_suffix = len(versions) > 1
1982
+
1983
+ for idx, (fork, item_data, spec_content) in enumerate(versions):
1984
+ # Calculate hash of current version
1985
+ hash_value = hashlib.sha256(spec_content.encode('utf-8')).hexdigest()[:8]
1986
+
1987
+ # For multiple versions after the first, use diff style
1988
+ if use_fork_suffix and idx > 0:
1989
+ # Get previous version for diff
1990
+ prev_fork, _, prev_spec_content = versions[idx - 1]
1991
+
1992
+ # Generate diff
1993
+ diff_content = diff(prev_fork, strip_comments(prev_spec_content), fork, strip_comments(spec_content))
1994
+
1995
+ # Build spec tag with style="diff"
1996
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" style="diff" hash="{hash_value}">'
1997
+ content = diff_content
1998
+ else:
1999
+ # Build spec tag without style="diff"
2000
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" hash="{hash_value}">'
2001
+ content = spec_content
2002
+
2003
+ # Create entry
2004
+ entry_name = f'{item_name}#{fork}' if use_fork_suffix else item_name
2005
+ entry = {
2006
+ 'name': entry_name,
2007
+ 'sources': [],
2008
+ 'spec': f'{spec_tag}\n{content}\n</spec>'
2009
+ }
2010
+ entries.append(entry)
2011
+
2012
+ # Write YAML file
2013
+ if entries:
2014
+ with open(output_path, 'w') as f:
2015
+ for i, entry in enumerate(entries):
2016
+ if i > 0:
2017
+ f.write('\n')
2018
+ f.write(f'- name: {entry["name"]}\n')
2019
+ f.write(' sources: []\n')
2020
+ f.write(' spec: |\n')
2021
+ for line in entry['spec'].split('\n'):
2022
+ f.write(f' {line}\n')
2023
+
2024
+ # Create .ethspecify.yml config file
2025
+ config_path = os.path.join(output_dir, '.ethspecify.yml')
2026
+ with open(config_path, 'w') as f:
2027
+ f.write(f'version: {version}\n')
2028
+ f.write('style: full\n')
2029
+ f.write('\n')
2030
+ f.write('specrefs:\n')
2031
+ f.write(' files:\n')
2032
+ for category, (filename, _) in category_map.items():
2033
+ if items_by_category[category]:
2034
+ f.write(f' - {filename}\n')
2035
+ f.write('\n')
2036
+ f.write(' exceptions:\n')
2037
+ f.write(' # Add any exceptions here\n')
2038
+
2039
+ # Strip trailing whitespace from all generated files
2040
+ all_files = [config_path]
2041
+ for category, (filename, _) in category_map.items():
2042
+ if items_by_category[category]:
2043
+ all_files.append(os.path.join(output_dir, filename))
2044
+
2045
+ for file_path in all_files:
2046
+ with open(file_path, 'r') as f:
2047
+ lines = f.readlines()
2048
+ with open(file_path, 'w') as f:
2049
+ for line in lines:
2050
+ f.write(line.rstrip() + '\n')
2051
+
2052
+ return list(category_map.values())