zexus 1.8.0 → 1.8.2

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.
Files changed (51) hide show
  1. package/README.md +34 -6
  2. package/bin/zexus +12 -2
  3. package/bin/zpics +12 -2
  4. package/bin/zpm +12 -2
  5. package/bin/zx +12 -2
  6. package/bin/zx-deploy +12 -2
  7. package/bin/zx-dev +12 -2
  8. package/bin/zx-run +12 -2
  9. package/package.json +2 -1
  10. package/rust_core/Cargo.lock +603 -0
  11. package/rust_core/Cargo.toml +26 -0
  12. package/rust_core/README.md +15 -0
  13. package/rust_core/pyproject.toml +25 -0
  14. package/rust_core/src/binary_bytecode.rs +543 -0
  15. package/rust_core/src/contract_vm.rs +643 -0
  16. package/rust_core/src/executor.rs +847 -0
  17. package/rust_core/src/hasher.rs +90 -0
  18. package/rust_core/src/lib.rs +71 -0
  19. package/rust_core/src/merkle.rs +128 -0
  20. package/rust_core/src/rust_vm.rs +2313 -0
  21. package/rust_core/src/signature.rs +79 -0
  22. package/rust_core/src/state_adapter.rs +281 -0
  23. package/rust_core/src/validator.rs +116 -0
  24. package/scripts/postinstall.js +204 -21
  25. package/src/zexus/__init__.py +1 -1
  26. package/src/zexus/cli/main.py +1 -1
  27. package/src/zexus/cli/zpm.py +1 -1
  28. package/src/zexus/evaluator/bytecode_compiler.py +150 -52
  29. package/src/zexus/evaluator/core.py +151 -809
  30. package/src/zexus/evaluator/expressions.py +27 -22
  31. package/src/zexus/evaluator/functions.py +171 -126
  32. package/src/zexus/evaluator/statements.py +55 -112
  33. package/src/zexus/module_cache.py +20 -9
  34. package/src/zexus/object.py +330 -38
  35. package/src/zexus/parser/parser.py +103 -23
  36. package/src/zexus/parser/strategy_context.py +318 -6
  37. package/src/zexus/parser/strategy_structural.py +2 -2
  38. package/src/zexus/persistence.py +46 -17
  39. package/src/zexus/security.py +140 -234
  40. package/src/zexus/type_checker.py +44 -5
  41. package/src/zexus/vm/binary_bytecode.py +7 -3
  42. package/src/zexus/vm/bytecode.py +6 -0
  43. package/src/zexus/vm/cache.py +24 -46
  44. package/src/zexus/vm/compiler.py +549 -68
  45. package/src/zexus/vm/memory_pool.py +21 -9
  46. package/src/zexus/vm/vm.py +609 -95
  47. package/src/zexus/zpm/package_manager.py +1 -1
  48. package/src/zexus.egg-info/PKG-INFO +56 -12
  49. package/src/zexus.egg-info/SOURCES.txt +14 -0
  50. package/src/zexus.egg-info/entry_points.txt +5 -1
  51. package/src/zexus.egg-info/requires.txt +26 -0
@@ -3,9 +3,69 @@ import time
3
3
  import random
4
4
  import json
5
5
  import os
6
+ import re
6
7
  import sys
8
+ import threading
9
+ import atexit
7
10
  from threading import Lock
8
11
 
12
+
13
+ # ===============================================
14
+ # SECURITY: Path & Identifier Sanitization (v1.8.1)
15
+ # ===============================================
16
+
17
+ def _safe_resolve_path(path: str, sandbox: str = None) -> str:
18
+ """Resolve *path* and ensure it stays within *sandbox* (default: cwd).
19
+
20
+ Raises ``PermissionError`` if the resolved path escapes the sandbox.
21
+ Rejects paths that contain null bytes.
22
+ """
23
+ if "\x00" in path:
24
+ raise PermissionError("Path contains null bytes")
25
+ if sandbox is None:
26
+ sandbox = os.getcwd()
27
+ sandbox = os.path.realpath(sandbox)
28
+ resolved = os.path.realpath(os.path.join(sandbox, path))
29
+ # Ensure the resolved path is still within the sandbox
30
+ if not (resolved == sandbox or resolved.startswith(sandbox + os.sep)):
31
+ raise PermissionError(
32
+ f"Path traversal denied: '{path}' resolves outside the project directory"
33
+ )
34
+ return resolved
35
+
36
+
37
+ # Regex: only alphanumeric, underscore, hyphen, dot allowed in identifiers
38
+ _SAFE_ID_RE = re.compile(r'^[A-Za-z0-9_.\-]+$')
39
+
40
+
41
+ def _sanitize_identifier(name: str, label: str = "identifier") -> str:
42
+ """Ensure *name* is a safe filesystem identifier (no path separators, no traversal).
43
+
44
+ Raises ``ValueError`` on invalid input.
45
+ """
46
+ if not name or not isinstance(name, str):
47
+ raise ValueError(f"Invalid {label}: must be a non-empty string")
48
+ if not _SAFE_ID_RE.match(name):
49
+ raise ValueError(
50
+ f"Invalid {label} '{name}': only alphanumeric, underscore, hyphen, "
51
+ f"and dot characters are allowed"
52
+ )
53
+ # Extra guard: reject hidden-file tricks and double-dot
54
+ if name.startswith('.') or '..' in name:
55
+ raise ValueError(f"Invalid {label} '{name}': leading dot or '..' not allowed")
56
+ return name
57
+
58
+
59
+ # Env vars that must never be modified by Zexus programs
60
+ BLOCKED_ENV_VARS = frozenset({
61
+ 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'DYLD_INSERT_LIBRARIES',
62
+ 'DYLD_LIBRARY_PATH', 'PYTHONPATH', 'PYTHONSTARTUP', 'PYTHONHOME',
63
+ 'NODE_PATH', 'NODE_OPTIONS', 'PERL5LIB', 'RUBYLIB',
64
+ 'CLASSPATH', 'HOME', 'USER', 'SHELL', 'LOGNAME',
65
+ 'SSH_AUTH_SOCK', 'GPG_AGENT_INFO',
66
+ })
67
+
68
+
9
69
  class Object:
10
70
  def inspect(self):
11
71
  raise NotImplementedError("Subclasses must implement this method")
@@ -489,10 +549,43 @@ class Coroutine(Object):
489
549
 
490
550
  # === ENTITY OBJECTS ===
491
551
 
552
+ # Thread-local evaluator cache for entity/contract method execution.
553
+ # Kept lazy to avoid circular imports at module import time.
554
+ _entity_evaluator_cache = threading.local()
555
+
556
+
557
+ def _get_cached_evaluator():
558
+ evaluator = getattr(_entity_evaluator_cache, "evaluator", None)
559
+ if evaluator is None:
560
+ from .evaluator.core import Evaluator
561
+ evaluator = Evaluator()
562
+ _entity_evaluator_cache.evaluator = evaluator
563
+ return evaluator
564
+
565
+
566
+ def _clear_cached_evaluator():
567
+ if hasattr(_entity_evaluator_cache, "evaluator"):
568
+ del _entity_evaluator_cache.evaluator
569
+
570
+
571
+ # Best-effort cleanup for the current thread at interpreter shutdown.
572
+ atexit.register(_clear_cached_evaluator)
573
+
492
574
  class EntityDefinition(Object):
493
- def __init__(self, name, properties, parent=None):
575
+ """Entity definition.
576
+
577
+ MEDIUM (M5): This is the canonical EntityDefinition implementation.
578
+ It supports:
579
+ - properties as either a dict (new format) or list (legacy format)
580
+ - optional methods (Action objects)
581
+ - optional parent entity (inheritance)
582
+ - optional dependency injection for marked properties
583
+ """
584
+
585
+ def __init__(self, name, properties, methods=None, parent=None):
494
586
  self.name = name
495
- self.properties = properties # List of property definitions
587
+ self.properties = properties # dict or list
588
+ self.methods = methods or {}
496
589
  self.parent = parent # Optional parent entity for inheritance
497
590
 
498
591
  def type(self):
@@ -506,33 +599,169 @@ class EntityDefinition(Object):
506
599
  else:
507
600
  props_str = ", ".join([f"{prop['name']}: {prop['type']}" for prop in self.properties])
508
601
  return f"entity {self.name} {{ {props_str} }}"
602
+
603
+ def get_all_properties(self):
604
+ """Get all properties including inherited ones (parent first)."""
605
+ props = {}
606
+ if self.parent:
607
+ try:
608
+ props.update(self.parent.get_all_properties())
609
+ except Exception:
610
+ pass
611
+ if isinstance(self.properties, dict):
612
+ props.update(self.properties)
613
+ else:
614
+ # Legacy list format: [{name,type,default_value?}, ...]
615
+ for prop in self.properties:
616
+ try:
617
+ props[prop['name']] = prop
618
+ except Exception:
619
+ continue
620
+ return props
509
621
 
510
622
  def create_instance(self, initial_values=None):
511
- """Create an instance of this entity with optional initial values"""
512
- return EntityInstance(self, initial_values or {})
623
+ """Create an instance of this entity with optional initial values."""
624
+ injected_values = dict(initial_values or {})
625
+
626
+ # Dependency injection support: fill missing injected deps with container values.
627
+ injected_deps = getattr(self, 'injected_deps', None)
628
+ if injected_deps:
629
+ try:
630
+ from .dependency_injection import get_di_registry
631
+ registry = get_di_registry()
632
+ container = registry.get_container("__main__")
633
+ except Exception:
634
+ container = None
635
+
636
+ for dep_name in injected_deps:
637
+ if dep_name in injected_values:
638
+ continue
639
+ if container is None:
640
+ injected_values[dep_name] = NULL
641
+ continue
642
+ try:
643
+ injected_values[dep_name] = container.get(dep_name)
644
+ except BaseException:
645
+ injected_values[dep_name] = NULL
646
+
647
+ return EntityInstance(self, injected_values)
513
648
 
514
649
  class EntityInstance(Object):
515
650
  def __init__(self, entity_def, values):
516
651
  self.entity_def = entity_def
517
- self.values = values
652
+ self.data = values or {}
653
+ # Backward compatibility alias
654
+ self.values = self.data
655
+ self._validate_properties()
518
656
 
519
657
  def type(self):
520
658
  return "ENTITY_INSTANCE"
521
659
 
522
660
  def inspect(self):
523
- values_str = ", ".join([f"{k}: {v.inspect()}" for k, v in self.values.items()])
661
+ values_str = ", ".join([
662
+ f"{k}: {v.inspect() if hasattr(v, 'inspect') else v}" for k, v in self.data.items()
663
+ ])
524
664
  return f"{self.entity_def.name} {{ {values_str} }}"
665
+
666
+ def __str__(self):
667
+ return self.inspect()
668
+
669
+ def __repr__(self):
670
+ return self.__str__()
671
+
672
+ def _validate_properties(self):
673
+ """Populate missing injected/default values for declared properties."""
674
+ try:
675
+ all_props = self.entity_def.get_all_properties()
676
+ except Exception:
677
+ all_props = {}
678
+
679
+ for prop_name, prop_config in all_props.items():
680
+ if prop_name in self.data:
681
+ continue
682
+
683
+ # New-format dict config
684
+ if isinstance(prop_config, dict):
685
+ if prop_config.get("injected", False):
686
+ try:
687
+ from .dependency_injection import get_di_registry
688
+ registry = get_di_registry()
689
+ container = registry.get_container("__main__")
690
+ if container is not None:
691
+ self.data[prop_name] = container.get(prop_name)
692
+ else:
693
+ self.data[prop_name] = NULL
694
+ except Exception:
695
+ self.data[prop_name] = NULL
696
+ continue
697
+
698
+ if "default_value" in prop_config:
699
+ self.data[prop_name] = prop_config.get("default_value")
700
+ continue
701
+
702
+ # Legacy list config may include default_value
703
+ if isinstance(prop_config, dict) and "default_value" in prop_config:
704
+ self.data[prop_name] = prop_config.get("default_value")
525
705
 
526
706
  def get(self, property_name):
527
- return self.values.get(property_name, NULL)
707
+ return self.data.get(property_name, NULL)
528
708
 
529
709
  def set(self, property_name, value):
530
- # Check if property exists in entity definition
531
- prop_def = next((prop for prop in self.entity_def.properties if prop['name'] == property_name), None)
532
- if prop_def:
533
- self.values[property_name] = value
534
- return TRUE
535
- return FALSE
710
+ # Check if property exists in entity definition (including inherited props)
711
+ try:
712
+ props = self.entity_def.get_all_properties()
713
+ if property_name not in props:
714
+ return FALSE
715
+ except Exception:
716
+ # Legacy list-only entity definitions
717
+ try:
718
+ prop_def = next(
719
+ (prop for prop in self.entity_def.properties if prop.get('name') == property_name),
720
+ None,
721
+ )
722
+ if not prop_def:
723
+ return FALSE
724
+ except Exception:
725
+ return FALSE
726
+
727
+ existing = self.data.get(property_name)
728
+ if existing is not None and existing.__class__.__name__ == 'SealedObject':
729
+ return EvaluationError(f"Cannot modify sealed property: {property_name}")
730
+
731
+ self.data[property_name] = value
732
+ return TRUE
733
+
734
+ def call_method(self, method_name, args):
735
+ """Call an entity method (Action) defined on the entity definition."""
736
+ if not hasattr(self.entity_def, 'methods') or method_name not in self.entity_def.methods:
737
+ return EvaluationError(f"Method '{method_name}' not supported for ENTITY_INSTANCE")
738
+
739
+ method = self.entity_def.methods[method_name]
740
+
741
+ # Create a new environment for method execution
742
+ from .environment import Environment as ExecEnvironment
743
+ method_env = ExecEnvironment(outer=method.env if hasattr(method, 'env') else None)
744
+ method_env.set('this', self)
745
+
746
+ # Bind method parameters
747
+ if hasattr(method, 'parameters'):
748
+ for i, param in enumerate(method.parameters):
749
+ if i >= len(args):
750
+ break
751
+ if hasattr(param, 'name'):
752
+ param_name = param.name.value if hasattr(param.name, 'value') else str(param.name)
753
+ elif hasattr(param, 'value'):
754
+ param_name = param.value
755
+ else:
756
+ param_name = str(param)
757
+ method_env.set(param_name, args[i])
758
+
759
+ evaluator = _get_cached_evaluator()
760
+
761
+ result = evaluator.eval_node(method.body, method_env, stack_trace=[])
762
+ if isinstance(result, ReturnValue):
763
+ return result.value
764
+ return result
536
765
 
537
766
  # === UTILITY CLASSES ===
538
767
 
@@ -600,9 +829,12 @@ class File(Object):
600
829
  try:
601
830
  if isinstance(path, String):
602
831
  path = path.value
603
- with open(path, 'r', encoding='utf-8') as f:
832
+ resolved = _safe_resolve_path(path)
833
+ with open(resolved, 'r', encoding='utf-8') as f:
604
834
  # Files are external data sources - return untrusted strings
605
835
  return String(f.read(), is_trusted=False)
836
+ except PermissionError as e:
837
+ return EvaluationError(f"File read denied: {str(e)}")
606
838
  except Exception as e:
607
839
  return EvaluationError(f"File read error: {str(e)}")
608
840
 
@@ -613,9 +845,12 @@ class File(Object):
613
845
  path = path.value
614
846
  if isinstance(content, String):
615
847
  content = content.value
616
- with open(path, 'w', encoding='utf-8') as f:
848
+ resolved = _safe_resolve_path(path)
849
+ with open(resolved, 'w', encoding='utf-8') as f:
617
850
  f.write(content)
618
851
  return Boolean(True)
852
+ except PermissionError as e:
853
+ return EvaluationError(f"File write denied: {str(e)}")
619
854
  except Exception as e:
620
855
  return EvaluationError(f"File write error: {str(e)}")
621
856
 
@@ -623,7 +858,11 @@ class File(Object):
623
858
  def exists(path):
624
859
  if isinstance(path, String):
625
860
  path = path.value
626
- return Boolean(os.path.exists(path))
861
+ try:
862
+ resolved = _safe_resolve_path(path)
863
+ return Boolean(os.path.exists(resolved))
864
+ except PermissionError:
865
+ return Boolean(False)
627
866
 
628
867
  # === MEDIUM TIER ===
629
868
  @staticmethod
@@ -729,18 +968,65 @@ class File(Object):
729
968
  _lock = Lock()
730
969
 
731
970
  @staticmethod
732
- def lock_file(path):
733
- """Lock file for exclusive access"""
971
+ def lock_file(path, timeout=None):
972
+ """Lock file for exclusive access.
973
+
974
+ SECURITY (M9): Previously this could block forever and hang the interpreter.
975
+ A default timeout is applied (configurable via env var).
976
+
977
+ Args:
978
+ path: File path (string or String)
979
+ timeout: Optional seconds (Integer/Float/str/number). If omitted, uses
980
+ $ZEXUS_FILE_LOCK_TIMEOUT_SECONDS (default: 30).
981
+ """
734
982
  try:
735
983
  if isinstance(path, String):
736
984
  path = path.value
737
985
 
738
- with File._lock:
739
- if path not in File._file_locks:
740
- File._file_locks[path] = Lock()
986
+ # Normalize key to reduce accidental duplicate lock objects
987
+ try:
988
+ lock_key = os.path.realpath(str(path))
989
+ except Exception:
990
+ lock_key = str(path)
991
+
992
+ timeout_seconds = None
993
+ if timeout is None:
994
+ env_timeout = os.environ.get("ZEXUS_FILE_LOCK_TIMEOUT_SECONDS", "30")
995
+ try:
996
+ timeout_seconds = float(env_timeout)
997
+ except Exception:
998
+ timeout_seconds = 30.0
999
+ else:
1000
+ if isinstance(timeout, Integer):
1001
+ timeout_seconds = float(timeout.value)
1002
+ elif isinstance(timeout, Float):
1003
+ timeout_seconds = float(timeout.value)
1004
+ elif isinstance(timeout, String):
1005
+ timeout_seconds = float(timeout.value)
1006
+ else:
1007
+ timeout_seconds = float(timeout)
1008
+
1009
+ if timeout_seconds is not None and timeout_seconds < 0:
1010
+ timeout_seconds = 0.0
741
1011
 
742
- File._file_locks[path].acquire()
1012
+ with File._lock:
1013
+ lock_obj = File._file_locks.get(lock_key)
1014
+ if lock_obj is None:
1015
+ lock_obj = Lock()
1016
+ File._file_locks[lock_key] = lock_obj
1017
+
1018
+ # Acquire without holding the global map lock
1019
+ acquired = False
1020
+ if timeout_seconds is None:
1021
+ # Shouldn't happen, but keep backward-compatible behavior
1022
+ lock_obj.acquire()
1023
+ acquired = True
1024
+ else:
1025
+ acquired = lock_obj.acquire(timeout=timeout_seconds)
1026
+
1027
+ if acquired:
743
1028
  return Boolean(True)
1029
+ return EvaluationError(f"File lock timeout after {timeout_seconds:.3f}s")
744
1030
  except Exception as e:
745
1031
  return EvaluationError(f"File lock error: {str(e)}")
746
1032
 
@@ -751,10 +1037,22 @@ class File(Object):
751
1037
  if isinstance(path, String):
752
1038
  path = path.value
753
1039
 
1040
+ try:
1041
+ lock_key = os.path.realpath(str(path))
1042
+ except Exception:
1043
+ lock_key = str(path)
1044
+
754
1045
  with File._lock:
755
- if path in File._file_locks:
756
- File._file_locks[path].release()
757
- return Boolean(True)
1046
+ lock_obj = File._file_locks.get(lock_key)
1047
+ if lock_obj is not None:
1048
+ try:
1049
+ if hasattr(lock_obj, "locked") and not lock_obj.locked():
1050
+ return Boolean(False)
1051
+ lock_obj.release()
1052
+ return Boolean(True)
1053
+ except RuntimeError:
1054
+ # Release on an unlocked lock
1055
+ return Boolean(False)
758
1056
  return Boolean(False)
759
1057
  except Exception as e:
760
1058
  return EvaluationError(f"File unlock error: {str(e)}")
@@ -996,13 +1294,10 @@ class Environment:
996
1294
  """Initialize persistence system if scope is provided"""
997
1295
  if self.persistence_scope:
998
1296
  try:
999
- # Lazy import to avoid circular dependencies
1000
- import sys
1001
- if 'zexus.persistence' in sys.modules:
1002
- from .persistence import PersistentStorage, MemoryTracker
1003
- self._persistent_storage = PersistentStorage(self.persistence_scope)
1004
- self._memory_tracker = MemoryTracker()
1005
- self._memory_tracker.start_tracking()
1297
+ from .persistence import PersistentStorage, MemoryTracker
1298
+ self._persistent_storage = PersistentStorage(self.persistence_scope)
1299
+ self._memory_tracker = MemoryTracker()
1300
+ self._memory_tracker.start_tracking()
1006
1301
  except (ImportError, Exception):
1007
1302
  # Persistence module not available or error - continue without it
1008
1303
  pass
@@ -1042,12 +1337,9 @@ class Environment:
1042
1337
  """Enable memory leak detection"""
1043
1338
  if not self._memory_tracker:
1044
1339
  try:
1045
- # Lazy import
1046
- import sys
1047
- if 'zexus.persistence' in sys.modules:
1048
- from .persistence import MemoryTracker
1049
- self._memory_tracker = MemoryTracker()
1050
- self._memory_tracker.start_tracking()
1340
+ from .persistence import MemoryTracker
1341
+ self._memory_tracker = MemoryTracker()
1342
+ self._memory_tracker.start_tracking()
1051
1343
  except (ImportError, Exception):
1052
1344
  pass
1053
1345
 
@@ -140,6 +140,7 @@ class UltimateParser:
140
140
  REQUIRE: self.parse_require_statement,
141
141
  REVERT: self.parse_revert_statement,
142
142
  LIMIT: self.parse_limit_statement,
143
+ PROTOCOL: self.parse_protocol_statement,
143
144
  }
144
145
 
145
146
  # Traditional parser setup (fallback)
@@ -361,6 +362,15 @@ class UltimateParser:
361
362
  if match_brace_depth == 0:
362
363
  in_match_brace = False
363
364
  elif t.type == LAMBDA and getattr(t, 'literal', None) == '=>':
365
+ # Exclude watch => patterns — those are reactive watchers, not lambdas
366
+ if idx > 0 and all_tokens[idx - 1].type == IDENT:
367
+ # Check if this is a watch statement: watch <expr> =>
368
+ # Walk back past identifiers and dots (e.g. watch order.status =>)
369
+ watch_idx = idx - 2
370
+ while watch_idx >= 0 and all_tokens[watch_idx].type in (IDENT, DOT):
371
+ watch_idx -= 1
372
+ if watch_idx >= 0 and getattr(all_tokens[watch_idx], 'type', None) == WATCH:
373
+ continue # Skip watch arrows
364
374
  has_non_match_arrow = True
365
375
  break
366
376
  if has_non_match_arrow:
@@ -2817,24 +2827,30 @@ class UltimateParser:
2817
2827
  self.errors.append(f"Line {token.line}:{token.column} - Expected expression after 'watch'")
2818
2828
  return None
2819
2829
 
2820
- # Expect '=>' (LAMBDA token)
2821
- if not self.expect_peek(LAMBDA):
2822
- self.errors.append(f"Line {self.cur_token.line}:{self.cur_token.column} - Expected '=>' in watch statement")
2823
- return None
2830
+ # Check for '=>' (LAMBDA token) — optional; allow watch expr { ... } form
2831
+ if self.peek_token_is(LAMBDA):
2832
+ self.next_token() # consume '=>'
2824
2833
 
2825
- # Parse reaction (block or expression)
2826
- if self.peek_token_is(LBRACE):
2834
+ # Parse reaction (block or expression)
2835
+ if self.peek_token_is(LBRACE):
2836
+ self.next_token()
2837
+ reaction = self.parse_block("watch")
2838
+ else:
2839
+ self.next_token()
2840
+ reaction_block = BlockStatement()
2841
+ stmt = self.parse_statement()
2842
+ if stmt is None:
2843
+ self.errors.append(f"Line {self.cur_token.line}:{self.cur_token.column} - Expected reaction after '=>'")
2844
+ return None
2845
+ reaction_block.statements.append(stmt)
2846
+ reaction = reaction_block
2847
+ elif self.peek_token_is(LBRACE):
2848
+ # Form: watch expr { ... }
2827
2849
  self.next_token()
2828
2850
  reaction = self.parse_block("watch")
2829
2851
  else:
2830
- self.next_token()
2831
- reaction_block = BlockStatement()
2832
- stmt = self.parse_statement()
2833
- if stmt is None:
2834
- self.errors.append(f"Line {self.cur_token.line}:{self.cur_token.column} - Expected reaction after '=>'")
2835
- return None
2836
- reaction_block.statements.append(stmt)
2837
- reaction = reaction_block
2852
+ self.errors.append(f"Line {self.cur_token.line}:{self.cur_token.column} - Expected '=>' or '{{' in watch statement")
2853
+ return None
2838
2854
 
2839
2855
  if reaction is None:
2840
2856
  self.errors.append(f"Line {self.cur_token.line}:{self.cur_token.column} - Expected reaction after '=>'")
@@ -4451,6 +4467,54 @@ class UltimateParser:
4451
4467
 
4452
4468
  return InterfaceStatement(name=interface_name, methods=methods, properties=properties)
4453
4469
 
4470
+ def parse_protocol_statement(self):
4471
+ """Parse protocol definition statement
4472
+
4473
+ protocol Greetable {
4474
+ action greet() -> string
4475
+ action farewell()
4476
+ }
4477
+ """
4478
+ token = self.cur_token
4479
+ self.next_token()
4480
+
4481
+ # protocol Name { ... }
4482
+ if not self.cur_token_is(IDENT):
4483
+ self.errors.append(f"Line {token.line}:{token.column} - Expected protocol name")
4484
+ return None
4485
+
4486
+ protocol_name = Identifier(self.cur_token.literal)
4487
+ self.next_token()
4488
+
4489
+ methods = []
4490
+
4491
+ if self.cur_token_is(LBRACE):
4492
+ self.next_token()
4493
+ while not self.cur_token_is(RBRACE) and self.cur_token.type != EOF:
4494
+ if self.cur_token_is(ACTION) or self.cur_token_is(FUNCTION):
4495
+ self.next_token()
4496
+ if self.cur_token_is(IDENT):
4497
+ methods.append(self.cur_token.literal)
4498
+ self.next_token()
4499
+ # Skip past params and return type annotation
4500
+ while not self.cur_token_is(SEMICOLON) and not self.cur_token_is(RBRACE) and self.cur_token.type != EOF:
4501
+ self.next_token()
4502
+ if self.cur_token_is(SEMICOLON):
4503
+ self.next_token()
4504
+ else:
4505
+ self.next_token()
4506
+ elif self.cur_token_is(IDENT):
4507
+ methods.append(self.cur_token.literal)
4508
+ self.next_token()
4509
+ while not self.cur_token_is(SEMICOLON) and not self.cur_token_is(RBRACE) and self.cur_token.type != EOF:
4510
+ self.next_token()
4511
+ if self.cur_token_is(SEMICOLON):
4512
+ self.next_token()
4513
+ else:
4514
+ self.next_token()
4515
+
4516
+ return ProtocolStatement(name=protocol_name, methods=methods)
4517
+
4454
4518
  def parse_type_alias_statement(self):
4455
4519
  """Parse type alias statement"""
4456
4520
  token = self.cur_token
@@ -4551,8 +4615,16 @@ class UltimateParser:
4551
4615
 
4552
4616
  return UsingStatement(resource_name=resource_name, resource_expr=resource_expr, body=body)
4553
4617
 
4618
+ def parse_type_expression(self):
4619
+ """Parse a simple type expression (identifier)."""
4620
+ if self.cur_token_is(IDENT):
4621
+ type_node = Identifier(self.cur_token.literal)
4622
+ self.next_token()
4623
+ return type_node
4624
+ return None
4625
+
4554
4626
  def parse_channel_statement(self):
4555
- """Parse channel declaration: channel<type> name; or channel<type> name = expr;"""
4627
+ """Parse channel declaration: channel<type>[capacity] name"""
4556
4628
  token = self.cur_token
4557
4629
  self.next_token() # consume CHANNEL
4558
4630
 
@@ -4570,25 +4642,33 @@ class UltimateParser:
4570
4642
 
4571
4643
  self.next_token()
4572
4644
 
4645
+ # Optional capacity in brackets: [10]
4646
+ capacity = None
4647
+ if self.cur_token_is(LBRACKET):
4648
+ self.next_token()
4649
+ capacity = self.parse_expression(LOWEST)
4650
+ if not self.cur_token_is(RBRACKET):
4651
+ self.errors.append(f"Line {token.line}:{token.column} - Expected ']' after channel capacity")
4652
+ return None
4653
+ self.next_token()
4654
+
4573
4655
  # Parse channel name
4574
4656
  if not self.cur_token_is(IDENT):
4575
4657
  self.errors.append(f"Line {token.line}:{token.column} - Expected channel name")
4576
4658
  return None
4577
4659
 
4578
- name = self.cur_token.literal
4660
+ name = Identifier(self.cur_token.literal)
4579
4661
  self.next_token()
4580
4662
 
4581
- # Optional capacity specification
4582
- capacity = None
4583
- if self.cur_token_is(ASSIGN):
4663
+ # Optional capacity via assignment: = expr
4664
+ if capacity is None and self.cur_token_is(ASSIGN):
4584
4665
  self.next_token()
4585
4666
  capacity = self.parse_expression(LOWEST)
4586
4667
 
4587
- if not self.cur_token_is(SEMICOLON):
4588
- self.errors.append(f"Line {token.line}:{token.column} - Expected ';' after channel declaration")
4589
- return None
4668
+ # Semicolons are optional in modern Zexus
4669
+ if self.cur_token_is(SEMICOLON):
4670
+ self.next_token()
4590
4671
 
4591
- self.next_token()
4592
4672
  return ChannelStatement(name=name, element_type=element_type, capacity=capacity)
4593
4673
 
4594
4674
  def parse_send_statement(self):