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
@@ -15,7 +15,9 @@ STATEMENT_STARTERS = {
15
15
  STORAGE, AUDIT, RESTRICT, SANDBOX, TRAIL, NATIVE, GC, INLINE, BUFFER,
16
16
  SIMD, DEFER, PATTERN, ENUM, STREAM, WATCH, LOG, CAPABILITY, GRANT,
17
17
  REVOKE, VALIDATE, SANITIZE, IMMUTABLE, INTERFACE, TYPE_ALIAS, MODULE,
18
- PACKAGE, USING, MIDDLEWARE, AUTH, THROTTLE, CACHE, REQUIRE
18
+ PACKAGE, USING, MIDDLEWARE, AUTH, THROTTLE, CACHE, REQUIRE,
19
+ EMIT, PROTOCOL, SEAL,
20
+ CHANNEL, SEND, RECEIVE, ATOMIC, ASYNC,
19
21
  }
20
22
 
21
23
  _MEANINGFUL_TOKEN_TYPES = {
@@ -162,6 +164,10 @@ class ContextStackParser:
162
164
  ANIMATION: self._parse_animation_statement,
163
165
  CLOCK: self._parse_clock_statement,
164
166
  GC: self._parse_gc_statement_block,
167
+ # EMIT handler
168
+ EMIT: self._parse_emit_statement_block,
169
+ # PROTOCOL handler
170
+ PROTOCOL: self._parse_protocol_statement_block,
165
171
  }
166
172
 
167
173
  def push_context(self, context_type, context_name=None):
@@ -1262,7 +1268,8 @@ class ContextStackParser:
1262
1268
  EXPORT, USE, DEBUG, ENTITY, CONTRACT, AUDIT, RESTRICT, SANDBOX, TRAIL, NATIVE, GC, INLINE,
1263
1269
  BUFFER, SIMD, DEFER, PATTERN, ENUM, STREAM, WATCH, CAPABILITY, GRANT,
1264
1270
  REVOKE, VALIDATE, SANITIZE, IMMUTABLE, INTERFACE, TYPE_ALIAS, MODULE,
1265
- PACKAGE, USING
1271
+ PACKAGE, USING,
1272
+ CHANNEL, SEND, RECEIVE, ATOMIC, ASYNC,
1266
1273
  }
1267
1274
  j = assign_idx + 1
1268
1275
  nesting_depth = 0
@@ -1558,9 +1565,18 @@ class ContextStackParser:
1558
1565
  except Exception:
1559
1566
  pass
1560
1567
 
1561
- # 1. Extract Name
1568
+ # 1. Extract Name and optional implements clause
1562
1569
  contract_name = tokens[1].literal if tokens[1].type == IDENT else "UnknownContract"
1563
- parser_debug(f" 📝 Contract Name: {contract_name}")
1570
+ implements = None
1571
+
1572
+ # Check for 'implements' keyword: contract Name implements ProtocolName { ... }
1573
+ for idx in range(2, min(len(tokens), 5)):
1574
+ if idx < len(tokens) and tokens[idx].type == IMPLEMENTS:
1575
+ if idx + 1 < len(tokens) and tokens[idx + 1].type == IDENT:
1576
+ implements = Identifier(tokens[idx + 1].literal)
1577
+ break
1578
+
1579
+ parser_debug(f" 📝 Contract Name: {contract_name}" + (f" implements {implements}" if implements else ""))
1564
1580
 
1565
1581
  # 2. Identify Block Boundaries
1566
1582
  brace_start = -1
@@ -1935,7 +1951,8 @@ class ContextStackParser:
1935
1951
  contract_stmt = ContractStatement(
1936
1952
  name=Identifier(contract_name),
1937
1953
  body=body_block,
1938
- modifiers=None
1954
+ modifiers=None,
1955
+ implements=implements
1939
1956
  )
1940
1957
 
1941
1958
  # Add backward compatibility attributes
@@ -3156,6 +3173,67 @@ class ContextStackParser:
3156
3173
  i = j
3157
3174
  continue
3158
3175
 
3176
+ elif token.type == EMIT:
3177
+ # Parse EMIT statement: emit EventName(args)
3178
+ j = i + 1
3179
+ emit_tokens = [token]
3180
+ paren_nest = 0
3181
+
3182
+ # Collect until end of emit statement (close paren or semicolon/newline boundary)
3183
+ while j < len(tokens):
3184
+ tj = tokens[j]
3185
+ if tj.type == LPAREN:
3186
+ paren_nest += 1
3187
+ elif tj.type == RPAREN:
3188
+ paren_nest -= 1
3189
+ if paren_nest == 0:
3190
+ emit_tokens.append(tj)
3191
+ j += 1
3192
+ break
3193
+ elif tj.type == SEMICOLON and paren_nest == 0:
3194
+ j += 1
3195
+ break
3196
+ emit_tokens.append(tj)
3197
+ j += 1
3198
+
3199
+ parser_debug(f" 📝 Found emit statement: {[t.literal for t in emit_tokens]}")
3200
+
3201
+ block_info = {'tokens': emit_tokens}
3202
+ stmt = self._parse_emit_statement_block(block_info, tokens)
3203
+ if stmt:
3204
+ statements.append(stmt)
3205
+
3206
+ i = j
3207
+ continue
3208
+
3209
+ elif token.type == PROTOCOL:
3210
+ # Parse PROTOCOL statement: protocol Name { action method1() ... }
3211
+ j = i + 1
3212
+ proto_tokens = [token]
3213
+ brace_nest = 0
3214
+
3215
+ while j < len(tokens):
3216
+ tj = tokens[j]
3217
+ proto_tokens.append(tj)
3218
+ if tj.type == LBRACE:
3219
+ brace_nest += 1
3220
+ elif tj.type == RBRACE:
3221
+ brace_nest -= 1
3222
+ if brace_nest == 0:
3223
+ j += 1
3224
+ break
3225
+ j += 1
3226
+
3227
+ parser_debug(f" 📝 Found protocol statement: {[t.literal for t in proto_tokens[:10]]}")
3228
+
3229
+ block_info = {'tokens': proto_tokens}
3230
+ stmt = self._parse_protocol_statement_block(block_info, tokens)
3231
+ if stmt:
3232
+ statements.append(stmt)
3233
+
3234
+ i = j
3235
+ continue
3236
+
3159
3237
  elif token.type == WATCH:
3160
3238
  j = i + 1
3161
3239
  stmt_tokens = [token]
@@ -3907,6 +3985,35 @@ class ContextStackParser:
3907
3985
  i = j
3908
3986
  continue
3909
3987
 
3988
+ elif token.type == CHANNEL:
3989
+ # Parse CHANNEL declaration: channel<type>[capacity] name
3990
+ j = i + 1
3991
+ channel_tokens = [token]
3992
+ # Collect until we hit a statement starter or semicolon at nesting 0
3993
+ nesting = 0
3994
+ while j < len(tokens):
3995
+ tj = tokens[j]
3996
+ if tj.type in (LBRACKET, LT):
3997
+ nesting += 1
3998
+ elif tj.type in (RBRACKET, GT):
3999
+ nesting -= 1
4000
+ elif nesting == 0 and tj.type == SEMICOLON:
4001
+ j += 1 # skip semicolon
4002
+ break
4003
+ elif nesting == 0 and tj.type in statement_starters and len(channel_tokens) > 1:
4004
+ break
4005
+ channel_tokens.append(tj)
4006
+ j += 1
4007
+
4008
+ # Use the context handler to parse the channel tokens
4009
+ block_info = {'tokens': channel_tokens}
4010
+ stmt = self._parse_channel_statement(block_info, tokens)
4011
+ if stmt:
4012
+ statements.append(stmt)
4013
+
4014
+ i = j
4015
+ continue
4016
+
3910
4017
  elif token.type == ATOMIC:
3911
4018
  # Parse ATOMIC statement: atomic { statements }
3912
4019
  j = i + 1
@@ -3939,6 +4046,48 @@ class ContextStackParser:
3939
4046
  continue
3940
4047
 
3941
4048
  elif token.type == ASYNC:
4049
+ # Check if this is "async action" — treat as action with async modifier
4050
+ if i + 1 < len(tokens) and tokens[i + 1].type == ACTION:
4051
+ # Collect entire async action declaration including brace body
4052
+ j = i + 1 # skip to ACTION token
4053
+ stmt_tokens = [tokens[j]] # Start with ACTION token
4054
+ j += 1
4055
+ brace_nest = 0
4056
+ paren_nest = 0
4057
+ while j < len(tokens):
4058
+ tj = tokens[j]
4059
+ stmt_tokens.append(tj)
4060
+ if tj.type == LPAREN:
4061
+ paren_nest += 1
4062
+ elif tj.type == RPAREN:
4063
+ if paren_nest > 0:
4064
+ paren_nest -= 1
4065
+ elif tj.type == LBRACE:
4066
+ brace_nest += 1
4067
+ elif tj.type == RBRACE:
4068
+ brace_nest -= 1
4069
+ if brace_nest == 0:
4070
+ j += 1
4071
+ break
4072
+ j += 1
4073
+
4074
+ # Parse as action statement and set is_async flag
4075
+ block_info = {'tokens': stmt_tokens}
4076
+ stmt = self._parse_action_statement(block_info, tokens)
4077
+ if stmt:
4078
+ stmt.is_async = True
4079
+ try:
4080
+ existing_modifiers = list(getattr(stmt, 'modifiers', []) or [])
4081
+ if 'async' not in existing_modifiers:
4082
+ existing_modifiers.append('async')
4083
+ stmt.modifiers = existing_modifiers
4084
+ except Exception:
4085
+ stmt.modifiers = ['async']
4086
+ statements.append(stmt)
4087
+
4088
+ i = j
4089
+ continue
4090
+
3942
4091
  # Parse ASYNC expression: async <expression>
3943
4092
  j = i + 1
3944
4093
 
@@ -6081,6 +6230,22 @@ class ContextStackParser:
6081
6230
  # If this is "async function", let _parse_function_statement_context handle it
6082
6231
  if len(tokens) > 1 and tokens[1].type == FUNCTION:
6083
6232
  return self._parse_function_statement_context(block_info, all_tokens)
6233
+
6234
+ # If this is "async action", let _parse_action_statement handle it with async flag
6235
+ if len(tokens) > 1 and tokens[1].type == ACTION:
6236
+ action_tokens = tokens[1:] # Strip ASYNC
6237
+ action_block = {'tokens': action_tokens}
6238
+ stmt = self._parse_action_statement(action_block, all_tokens)
6239
+ if stmt:
6240
+ stmt.is_async = True
6241
+ try:
6242
+ existing_modifiers = list(getattr(stmt, 'modifiers', []) or [])
6243
+ if 'async' not in existing_modifiers:
6244
+ existing_modifiers.append('async')
6245
+ stmt.modifiers = existing_modifiers
6246
+ except Exception:
6247
+ stmt.modifiers = ['async']
6248
+ return stmt
6084
6249
 
6085
6250
  # The tokens are [ASYNC, ...expression tokens...]
6086
6251
  # We can just call parse_async_expression from the main parser!
@@ -7667,10 +7832,157 @@ class ContextStackParser:
7667
7832
 
7668
7833
  parser_debug(" ✅ Watch statement (explicit)")
7669
7834
  return WatchStatement(reaction=reaction, watched_expr=watched_expr)
7670
-
7835
+
7836
+ # Form 3: watch expr { ... } (expression followed by block, no arrow)
7837
+ brace_idx = -1
7838
+ for i, t in enumerate(tokens):
7839
+ if t.type == LBRACE:
7840
+ brace_idx = i
7841
+ break
7842
+
7843
+ if brace_idx > 1:
7844
+ expr_tokens = tokens[1:brace_idx]
7845
+ reaction_tokens = tokens[brace_idx:]
7846
+ watched_expr = self._parse_expression(expr_tokens)
7847
+ if reaction_tokens and reaction_tokens[0].type == LBRACE:
7848
+ inner = reaction_tokens[1:-1] if reaction_tokens[-1].type == RBRACE else reaction_tokens[1:]
7849
+ stmts = self._parse_block_statements(inner)
7850
+ reaction = BlockStatement()
7851
+ reaction.statements = stmts
7852
+ else:
7853
+ reaction = self._parse_expression(reaction_tokens)
7854
+ parser_debug(" ✅ Watch statement (variable)")
7855
+ return WatchStatement(reaction=reaction, watched_expr=watched_expr)
7856
+
7671
7857
  parser_debug(" ❌ Invalid watch syntax")
7672
7858
  return None
7673
7859
 
7860
+ def _parse_emit_statement_block(self, block_info, all_tokens):
7861
+ """Parse emit statement.
7862
+
7863
+ Form: emit EventName(arg1, arg2, ...)
7864
+ Example: emit Transfer("0xALICE", "0xBOB", 100)
7865
+ """
7866
+ parser_debug("🔧 [Context] Parsing emit statement")
7867
+ tokens = block_info.get('tokens', [])
7868
+
7869
+ if not tokens or tokens[0].type != EMIT:
7870
+ parser_debug(" ❌ Expected EMIT keyword")
7871
+ return None
7872
+
7873
+ # Token 1 should be the event name (IDENT)
7874
+ if len(tokens) < 2 or tokens[1].type != IDENT:
7875
+ parser_debug(" ❌ Expected event name after emit")
7876
+ return None
7877
+
7878
+ event_name = Identifier(tokens[1].literal)
7879
+
7880
+ # Parse arguments if present: emit EventName(arg1, arg2)
7881
+ arguments = []
7882
+ if len(tokens) > 2 and tokens[2].type == LPAREN:
7883
+ # Find matching RPAREN
7884
+ paren_depth = 0
7885
+ arg_start = 3
7886
+ for idx in range(2, len(tokens)):
7887
+ if tokens[idx].type == LPAREN:
7888
+ paren_depth += 1
7889
+ elif tokens[idx].type == RPAREN:
7890
+ paren_depth -= 1
7891
+ if paren_depth == 0:
7892
+ # Parse arguments between parens
7893
+ arg_tokens = tokens[3:idx]
7894
+ if arg_tokens:
7895
+ # Split by commas at depth 0
7896
+ current_arg = []
7897
+ inner_depth = 0
7898
+ for at in arg_tokens:
7899
+ if at.type == LPAREN:
7900
+ inner_depth += 1
7901
+ elif at.type == RPAREN:
7902
+ inner_depth -= 1
7903
+ if at.type == COMMA and inner_depth == 0:
7904
+ if current_arg:
7905
+ expr = self._parse_expression(current_arg)
7906
+ if expr:
7907
+ arguments.append(expr)
7908
+ current_arg = []
7909
+ else:
7910
+ current_arg.append(at)
7911
+ if current_arg:
7912
+ expr = self._parse_expression(current_arg)
7913
+ if expr:
7914
+ arguments.append(expr)
7915
+ break
7916
+
7917
+ parser_debug(f" ✅ Emit statement: {event_name} with {len(arguments)} args")
7918
+ return EmitStatement(event_name, arguments)
7919
+
7920
+ def _parse_protocol_statement_block(self, block_info, all_tokens):
7921
+ """Parse protocol statement.
7922
+
7923
+ Form: protocol Name { action method1() action method2(arg) -> type }
7924
+ Example: protocol Greetable { action greet() -> string }
7925
+ """
7926
+ parser_debug("🔧 [Context] Parsing protocol statement")
7927
+ tokens = block_info.get('tokens', [])
7928
+
7929
+ if not tokens or tokens[0].type != PROTOCOL:
7930
+ parser_debug(" ❌ Expected PROTOCOL keyword")
7931
+ return None
7932
+
7933
+ # Token 1 should be protocol name
7934
+ if len(tokens) < 2 or tokens[1].type != IDENT:
7935
+ parser_debug(" ❌ Expected protocol name")
7936
+ return None
7937
+
7938
+ protocol_name = Identifier(tokens[1].literal)
7939
+
7940
+ # Parse method signatures from brace block
7941
+ methods = []
7942
+ brace_start = -1
7943
+ for idx, t in enumerate(tokens):
7944
+ if t.type == LBRACE:
7945
+ brace_start = idx
7946
+ break
7947
+
7948
+ if brace_start != -1:
7949
+ # Find matching RBRACE
7950
+ brace_end = len(tokens) - 1
7951
+ for idx in range(len(tokens) - 1, brace_start, -1):
7952
+ if tokens[idx].type == RBRACE:
7953
+ brace_end = idx
7954
+ break
7955
+
7956
+ # Parse inner tokens for method signatures
7957
+ inner = tokens[brace_start + 1:brace_end]
7958
+ i = 0
7959
+ while i < len(inner):
7960
+ t = inner[i]
7961
+ # Look for action/function keyword or bare identifiers as method names
7962
+ if t.type in (ACTION, FUNCTION):
7963
+ i += 1
7964
+ if i < len(inner) and inner[i].type == IDENT:
7965
+ methods.append(inner[i].literal)
7966
+ # Skip past params and return type
7967
+ while i < len(inner) and inner[i].type != SEMICOLON and inner[i].type != ACTION and inner[i].type != FUNCTION:
7968
+ i += 1
7969
+ if i < len(inner) and inner[i].type == SEMICOLON:
7970
+ i += 1
7971
+ continue
7972
+ elif t.type == IDENT:
7973
+ methods.append(t.literal)
7974
+ # Skip to next method
7975
+ i += 1
7976
+ while i < len(inner) and inner[i].type != SEMICOLON and inner[i].type != ACTION and inner[i].type != FUNCTION:
7977
+ i += 1
7978
+ if i < len(inner) and inner[i].type == SEMICOLON:
7979
+ i += 1
7980
+ continue
7981
+ i += 1
7982
+
7983
+ parser_debug(f" ✅ Protocol statement: {protocol_name} with methods: {methods}")
7984
+ return ProtocolStatement(name=protocol_name, methods=methods)
7985
+
7674
7986
  def _parse_protect_statement(self, block_info, all_tokens):
7675
7987
  """Parse protect statement.
7676
7988
 
@@ -46,7 +46,7 @@ class StructuralAnalyzer:
46
46
  DEFER, PATTERN, ENUM, STREAM, WATCH, MATCH,
47
47
  CAPABILITY, GRANT, REVOKE, VALIDATE, SANITIZE, IMMUTABLE,
48
48
  INTERFACE, TYPE_ALIAS, MODULE, PACKAGE, USING,
49
- CHANNEL, ATOMIC,
49
+ CHANNEL, ATOMIC, EMIT, PROTOCOL,
50
50
  # Blockchain keywords
51
51
  LEDGER, STATE, REQUIRE, REVERT, LIMIT
52
52
  }
@@ -1212,7 +1212,7 @@ class StructuralAnalyzer:
1212
1212
  DEFER, PATTERN, ENUM, STREAM, WATCH,
1213
1213
  CAPABILITY, GRANT, REVOKE, VALIDATE, SANITIZE, IMMUTABLE,
1214
1214
  INTERFACE, TYPE_ALIAS, MODULE, PACKAGE, USING,
1215
- CHANNEL, ATOMIC, ASYNC, # Added ASYNC to recognize async expressions as statement boundaries
1215
+ CHANNEL, ATOMIC, ASYNC, EMIT, PROTOCOL,
1216
1216
  LBRACE # Added LBRACE to recognize standalone blocks as statements
1217
1217
  }
1218
1218
 
@@ -12,12 +12,21 @@ from threading import Lock
12
12
  from typing import Dict, Any, Optional, Set
13
13
  from .object import (
14
14
  Object, Integer, Float, String, Boolean as BooleanObj,
15
- Null, NULL, List, Map, EntityInstance
15
+ Null, NULL, List, Map, EntityInstance,
16
+ _sanitize_identifier
16
17
  )
17
18
 
18
19
  # Storage directory for persistent data
19
20
  PERSISTENCE_DIR = os.path.expanduser("~/.zexus/persistence")
20
- os.makedirs(PERSISTENCE_DIR, exist_ok=True)
21
+
22
+
23
+ def _ensure_persistence_dir(path: str = PERSISTENCE_DIR) -> None:
24
+ """Ensure persistence directory exists.
25
+
26
+ MEDIUM (M3): Directory creation is deferred until persistence is actually used
27
+ to avoid surprising filesystem writes at import time.
28
+ """
29
+ os.makedirs(path, exist_ok=True)
21
30
 
22
31
 
23
32
  # ===============================================
@@ -142,8 +151,10 @@ class PersistentStorage:
142
151
 
143
152
  def __init__(self, scope_id: str, storage_dir: str = PERSISTENCE_DIR,
144
153
  max_items: int = None, max_size_mb: int = None):
145
- self.scope_id = scope_id
146
- self.db_path = os.path.join(storage_dir, f"{scope_id}.sqlite")
154
+ _ensure_persistence_dir(storage_dir)
155
+ # SECURITY (C5): Sanitize scope_id to prevent path traversal
156
+ self.scope_id = _sanitize_identifier(scope_id, "scope_id")
157
+ self.db_path = os.path.join(storage_dir, f"{self.scope_id}.sqlite")
147
158
  self.conn = None
148
159
  self.lock = Lock()
149
160
 
@@ -278,10 +289,9 @@ class PersistentStorage:
278
289
 
279
290
  if row is None:
280
291
  return None
281
-
282
-
283
- # Update usage stats
284
- self._update_usage_stats()
292
+
293
+ # LI2: Reads should not force a full usage-stats refresh.
294
+ # Usage is updated on writes/deletes/clear.
285
295
  return self._deserialize({'type': row[0], 'value': row[1]})
286
296
 
287
297
  def delete(self, name: str):
@@ -290,6 +300,7 @@ class PersistentStorage:
290
300
  cursor = self.conn.cursor()
291
301
  cursor.execute('DELETE FROM variables WHERE name = ?', (name,))
292
302
  self.conn.commit()
303
+ self._update_usage_stats()
293
304
 
294
305
  def is_const(self, name: str) -> bool:
295
306
  """Check if a variable is const"""
@@ -309,12 +320,11 @@ class PersistentStorage:
309
320
  def clear(self):
310
321
  """Clear all persisted variables"""
311
322
  with self.lock:
312
-
313
- # Update usage stats
314
- self._update_usage_stats()
315
323
  cursor = self.conn.cursor()
316
324
  cursor.execute('DELETE FROM variables')
317
325
  self.conn.commit()
326
+ # LI1: update stats after mutation, not before.
327
+ self._update_usage_stats()
318
328
 
319
329
  def _key_to_str(self, key):
320
330
  """Convert a Zexus object key to a Python string for JSON serialization."""
@@ -350,11 +360,24 @@ class PersistentStorage:
350
360
  return {'type': 'null', 'value': json.dumps(None)}
351
361
  elif isinstance(obj, EntityInstance):
352
362
  serialized_values = {k: self._serialize(v) for k, v in obj.values.items()}
363
+
364
+ # LI3: Preserve entity shape so deserialization can rebuild a usable
365
+ # EntityDefinition (properties + inheritance layout) even if methods
366
+ # can't be persisted.
367
+ prop_names = []
368
+ try:
369
+ if hasattr(obj, 'entity_def') and hasattr(obj.entity_def, 'get_all_properties'):
370
+ prop_names = list(obj.entity_def.get_all_properties().keys())
371
+ except Exception:
372
+ prop_names = []
373
+ if not prop_names:
374
+ prop_names = list(serialized_values.keys())
353
375
  return {
354
376
  'type': 'entity_instance',
355
377
  'value': json.dumps({
356
378
  'entity_name': obj.entity_def.name,
357
- 'values': serialized_values
379
+ 'values': serialized_values,
380
+ 'properties': prop_names,
358
381
  })
359
382
  }
360
383
  else:
@@ -383,15 +406,19 @@ class PersistentStorage:
383
406
  pairs = {k: self._deserialize(v) for k, v in value.items()}
384
407
  return Map(pairs)
385
408
  elif obj_type == 'entity_instance':
386
- # Note: This creates a basic EntityInstance without full EntityDefinition
387
- # For production, you'd need to store/restore the entity definition
409
+ # LI3: Rebuild a minimally useful EntityDefinition from persisted
410
+ # property names. Methods/computed properties are not persisted.
388
411
  from .object import EntityDefinition
389
412
  entity_name = value['entity_name']
390
413
  serialized_values = value['values']
391
414
  deserialized_values = {k: self._deserialize(v) for k, v in serialized_values.items()}
392
-
393
- # Create minimal entity definition
394
- entity_def = EntityDefinition(entity_name, [])
415
+
416
+ prop_names = value.get('properties') or list(deserialized_values.keys())
417
+ try:
418
+ props = {name: {"type": "any", "default_value": NULL} for name in prop_names}
419
+ except Exception:
420
+ props = {}
421
+ entity_def = EntityDefinition(entity_name, props)
395
422
  return EntityInstance(entity_def, deserialized_values)
396
423
  else:
397
424
  return String(str(value))
@@ -499,6 +526,8 @@ def list_persistent_scopes() -> list:
499
526
 
500
527
  def delete_persistent_scope(scope_name: str):
501
528
  """Delete a persistent storage scope"""
529
+ # SECURITY (C7): Sanitize scope_name to prevent path traversal / arbitrary deletion
530
+ scope_name = _sanitize_identifier(scope_name, "scope_name")
502
531
  db_path = os.path.join(PERSISTENCE_DIR, f"{scope_name}.sqlite")
503
532
  if os.path.exists(db_path):
504
533
  os.remove(db_path)