@scriptc/compiler 0.0.17 → 0.0.19

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 (62) hide show
  1. package/ambient/scriptc-node-fallback.d.ts +4 -4
  2. package/dist/backend/cc.d.ts +5 -0
  3. package/dist/backend/cc.js +2 -0
  4. package/dist/backend/cc.js.map +1 -1
  5. package/dist/backend/emission/emit-async.js +35 -2
  6. package/dist/backend/emission/emit-async.js.map +1 -1
  7. package/dist/backend/emission/emit-exprs.js +40 -4
  8. package/dist/backend/emission/emit-exprs.js.map +1 -1
  9. package/dist/backend/emission/emitter.js +69 -11
  10. package/dist/backend/emission/emitter.js.map +1 -1
  11. package/dist/backend/emission/may-throw.d.ts +3 -3
  12. package/dist/backend/emission/may-throw.js +15 -4
  13. package/dist/backend/emission/may-throw.js.map +1 -1
  14. package/dist/backend/llvm/emitter.js +199 -21
  15. package/dist/backend/llvm/emitter.js.map +1 -1
  16. package/dist/frontend/lib-contract.d.ts +1 -0
  17. package/dist/frontend/lib-contract.js +2 -2
  18. package/dist/frontend/lib-contract.js.map +1 -1
  19. package/dist/frontend/lowering/lower-builtins.js +4 -7
  20. package/dist/frontend/lowering/lower-builtins.js.map +1 -1
  21. package/dist/frontend/lowering/lower-calls.js +17 -9
  22. package/dist/frontend/lowering/lower-calls.js.map +1 -1
  23. package/dist/frontend/lowering/lower-containers.d.ts +22 -17
  24. package/dist/frontend/lowering/lower-containers.js +628 -51
  25. package/dist/frontend/lowering/lower-containers.js.map +1 -1
  26. package/dist/frontend/lowering/lower-exprs.js +19 -1
  27. package/dist/frontend/lowering/lower-exprs.js.map +1 -1
  28. package/dist/frontend/lowering/lower-island.js +55 -7
  29. package/dist/frontend/lowering/lower-island.js.map +1 -1
  30. package/dist/frontend/lowering/lower-modules.js +144 -10
  31. package/dist/frontend/lowering/lower-modules.js.map +1 -1
  32. package/dist/frontend/lowering/lower-stmts.d.ts +8 -0
  33. package/dist/frontend/lowering/lower-stmts.js +408 -21
  34. package/dist/frontend/lowering/lower-stmts.js.map +1 -1
  35. package/dist/frontend/lowering/lowerer.d.ts +38 -0
  36. package/dist/frontend/lowering/lowerer.js +149 -2
  37. package/dist/frontend/lowering/lowerer.js.map +1 -1
  38. package/dist/frontend/lowering/surfaces.js +4 -0
  39. package/dist/frontend/lowering/surfaces.js.map +1 -1
  40. package/dist/index.js +97 -21
  41. package/dist/index.js.map +1 -1
  42. package/dist/ir/nodes.d.ts +46 -12
  43. package/dist/ir/nodes.js +41 -0
  44. package/dist/ir/nodes.js.map +1 -1
  45. package/dist/ir/serialize.d.ts +1 -1
  46. package/dist/ir/serialize.js +1 -1
  47. package/dist/ir/validate.js +98 -39
  48. package/dist/ir/validate.js.map +1 -1
  49. package/dist/library/int-infer.d.ts +6 -1
  50. package/dist/library/int-infer.js +306 -53
  51. package/dist/library/int-infer.js.map +1 -1
  52. package/dist/library/int-infer.test.js +271 -1
  53. package/dist/library/int-infer.test.js.map +1 -1
  54. package/dist/library/profile.d.ts +6 -5
  55. package/dist/library/profile.js.map +1 -1
  56. package/dist/library/sidecar-validate.js +4 -1
  57. package/dist/library/sidecar-validate.js.map +1 -1
  58. package/dist/library/sidecar.d.ts +32 -4
  59. package/dist/library/sidecar.js +264 -47
  60. package/dist/library/sidecar.js.map +1 -1
  61. package/package.json +2 -2
  62. package/surface-manifest.json +25 -1
@@ -1,4 +1,4 @@
1
- import type { IrModule, IrNumBinOp, SrcLoc } from "../ir/nodes.js";
1
+ import type { IrModule, IrNumBinOp, IrType, SrcLoc } from "../ir/nodes.js";
2
2
  export declare const SAFE_MAX: number;
3
3
  export declare const SAFE_MIN: number;
4
4
  /** The set of f64 values a binding may hold at a program point: a closed
@@ -105,6 +105,11 @@ export interface IntSlotConfig {
105
105
  path: string;
106
106
  }>>;
107
107
  }
108
+ /** The IR representations whose PRESENT values can carry one number slot.
109
+ * A bare f64 is plain; a union of exactly one f64 arm and one or more
110
+ * null/undefined arms is optional. The abstract interpreter tracks the
111
+ * possible f64 arm values and treats unit arms as the empty numeric set. */
112
+ export declare function numberCarrierKind(t: IrType, mod: IrModule): "plain" | "optional" | null;
108
113
  export declare function hasIntSlots(cfg: IntSlotConfig): boolean;
109
114
  /** The class seed for a declared parameter: what the slot's own contract
110
115
  * proves about every value that ever arrives through it (internal calls
@@ -284,6 +284,30 @@ export function checkBoundary(v, path, cls, loc) {
284
284
  }
285
285
  return { path, cls, loc, outcome: "prove", provenLo: v.lo, provenHi: v.hi };
286
286
  }
287
+ /** The IR representations whose PRESENT values can carry one number slot.
288
+ * A bare f64 is plain; a union of exactly one f64 arm and one or more
289
+ * null/undefined arms is optional. The abstract interpreter tracks the
290
+ * possible f64 arm values and treats unit arms as the empty numeric set. */
291
+ export function numberCarrierKind(t, mod) {
292
+ if (t.kind === "f64")
293
+ return "plain";
294
+ if (t.kind !== "union")
295
+ return null;
296
+ const def = mod.unions?.find((u) => u.id === t.unionId);
297
+ if (def === undefined)
298
+ return null;
299
+ let numbers = 0;
300
+ let units = 0;
301
+ for (const arm of def.arms) {
302
+ if (arm.kind === "f64")
303
+ numbers++;
304
+ else if (arm.kind === "nullT" || arm.kind === "undefinedT")
305
+ units++;
306
+ else
307
+ return null;
308
+ }
309
+ return numbers === 1 && units > 0 ? "optional" : null;
310
+ }
287
311
  export function hasIntSlots(cfg) {
288
312
  if (cfg.records.size > 0)
289
313
  return true;
@@ -307,8 +331,33 @@ export function classSeed(cls) {
307
331
  default: return { ...TOP };
308
332
  }
309
333
  }
334
+ /** Static-access facts share Env's join/clone machinery but have their own
335
+ * missing-key value: the declared slot seed. A killed field fact therefore
336
+ * falls back to the same whole-in-class-range assumption a field read had
337
+ * before access-path refinement, preserving SC4023 rather than degrading
338
+ * to a spurious SC4022. */
339
+ const PATH_I64_PREFIX = "%path.i64:";
340
+ const PATH_U64_PREFIX = "%path.u64:";
341
+ function pathClassOfKey(id) {
342
+ if (id.startsWith(PATH_I64_PREFIX))
343
+ return "i64";
344
+ if (id.startsWith(PATH_U64_PREFIX))
345
+ return "u64";
346
+ return null;
347
+ }
348
+ function clearPathFacts(env) {
349
+ for (const k of [...env.keys()]) {
350
+ if (pathClassOfKey(k) !== null)
351
+ env.delete(k);
352
+ }
353
+ }
310
354
  const isGlobalId = (id) => id.startsWith("%g.");
311
- const defaultVal = (id) => (isGlobalId(id) ? TOP : BOTTOM);
355
+ const defaultVal = (id) => {
356
+ const pathClass = pathClassOfKey(id);
357
+ if (pathClass !== null)
358
+ return classSeed(pathClass);
359
+ return isGlobalId(id) ? TOP : BOTTOM;
360
+ };
312
361
  function envGet(env, id) {
313
362
  return env.get(id) ?? defaultVal(id);
314
363
  }
@@ -322,6 +371,10 @@ function joinEnv(a, b) {
322
371
  for (const k of keys) {
323
372
  out.set(k, join(a.get(k) ?? defaultVal(k), b.get(k) ?? defaultVal(k)));
324
373
  }
374
+ // A real flow join ends the cheap straight-line access-path proof even
375
+ // when both incoming facts happen to agree. A sole reachable edge
376
+ // (early return/throw on the other edge) retains its dominated fact.
377
+ clearPathFacts(out);
325
378
  return out;
326
379
  }
327
380
  function envEquals(a, b) {
@@ -338,6 +391,7 @@ function widenEnv(prev, next) {
338
391
  for (const k of keys) {
339
392
  out.set(k, widen(prev.get(k) ?? defaultVal(k), next.get(k) ?? defaultVal(k)));
340
393
  }
394
+ clearPathFacts(out);
341
395
  return out;
342
396
  }
343
397
  function globalEffectsOf(mod) {
@@ -553,7 +607,7 @@ class FnAnalyzer {
553
607
  const env = new Map();
554
608
  const slots = this.cfg.fns.get(fn.name);
555
609
  fn.params.forEach((p, i) => {
556
- if (p.type.kind !== "f64")
610
+ if (!this.bindingCarriesNumber(p.localId))
557
611
  return;
558
612
  const declared = slots?.params[i] ?? null;
559
613
  const seed = slots?.paramSeeds[i] ?? null;
@@ -589,13 +643,15 @@ class FnAnalyzer {
589
643
  if (s.init === null)
590
644
  return env;
591
645
  const v = this.evalExpr(s.init, env);
592
- if (this.bindingIsF64(s.localId))
646
+ this.clearPathsRootedAt(env, s.localId);
647
+ if (this.bindingCarriesNumber(s.localId))
593
648
  env.set(s.localId, v);
594
649
  return env;
595
650
  }
596
651
  case "assign": {
597
652
  const v = this.evalExpr(s.value, env);
598
- if (this.bindingIsF64(s.localId))
653
+ this.clearPathsRootedAt(env, s.localId);
654
+ if (this.bindingCarriesNumber(s.localId))
599
655
  env.set(s.localId, v);
600
656
  return env;
601
657
  }
@@ -604,8 +660,9 @@ class FnAnalyzer {
604
660
  return env;
605
661
  case "if": {
606
662
  this.evalExpr(s.cond, env);
607
- const thenEnv = this.refine(cloneEnv(env), s.cond, true);
608
- const elseEnv = this.refine(cloneEnv(env), s.cond, false);
663
+ const allowPaths = this.stablePathGuard(s.cond);
664
+ const thenEnv = this.refine(cloneEnv(env), s.cond, true, allowPaths);
665
+ const elseEnv = this.refine(cloneEnv(env), s.cond, false, allowPaths);
609
666
  const a = thenEnv === null ? null : this.execStmts(s.then, thenEnv);
610
667
  const b = s.else_ === null ? elseEnv : elseEnv === null ? null : this.execStmts(s.else_, elseEnv);
611
668
  return joinEnv(a, b);
@@ -629,7 +686,7 @@ class FnAnalyzer {
629
686
  return this.execLoop(env, { cond: s.cond, body: s.body, labels: s.labels ?? [], doWhile: true });
630
687
  case "forOf": {
631
688
  this.evalExpr(s.iterable, env);
632
- const elemF64 = this.bindingIsF64(s.localId);
689
+ const elemF64 = this.bindingCarriesNumber(s.localId);
633
690
  return this.execLoop(env, {
634
691
  body: s.body,
635
692
  labels: s.labels ?? [],
@@ -668,10 +725,12 @@ class FnAnalyzer {
668
725
  this.evalExpr(s.arr, env);
669
726
  this.evalExpr(s.index, env);
670
727
  this.evalExpr(s.value, env);
728
+ clearPathFacts(env);
671
729
  return env;
672
730
  case "fieldSet":
673
731
  this.evalExpr(s.obj, env);
674
732
  this.evalExpr(s.value, env);
733
+ clearPathFacts(env);
675
734
  return env;
676
735
  case "recordSet": {
677
736
  this.evalExpr(s.obj, env);
@@ -679,16 +738,33 @@ class FnAnalyzer {
679
738
  const slot = this.cfg.records.get(s.shapeId)?.get(s.field);
680
739
  if (slot !== undefined)
681
740
  this.emit(v, slot.path, slot.cls, s.loc);
741
+ // The RHS and its boundary obligation observe the pre-write value;
742
+ // only the completed heap write invalidates paths (JS evaluation
743
+ // order, and what lets `m.count = m.count + 1` prove).
744
+ clearPathFacts(env);
682
745
  return env;
683
746
  }
684
- case "recordKeySet":
747
+ case "recordKeySet": {
685
748
  this.evalExpr(s.obj, env);
686
749
  this.evalExpr(s.key, env);
687
- this.evalExpr(s.value, env);
750
+ const v = this.evalExpr(s.value, env);
751
+ // A runtime key can dispatch to any declared field of the shape.
752
+ // overflowOnly proves a literal key names no declared field; every
753
+ // other keyed write must therefore discharge every integer slot it
754
+ // could select. Multiple classified fields intentionally emit
755
+ // independent obligations (their classes and paths may differ).
756
+ if (s.overflowOnly !== true) {
757
+ for (const slot of this.cfg.records.get(s.shapeId)?.values() ?? []) {
758
+ this.emit(v, slot.path, slot.cls, s.loc);
759
+ }
760
+ }
761
+ clearPathFacts(env);
688
762
  return env;
763
+ }
689
764
  case "recordKeyDelete":
690
765
  this.evalExpr(s.obj, env);
691
766
  this.evalExpr(s.key, env);
767
+ clearPathFacts(env);
692
768
  return env;
693
769
  case "return": {
694
770
  if (s.value !== null) {
@@ -820,7 +896,7 @@ class FnAnalyzer {
820
896
  };
821
897
  tryBody.forEach(visitStmt);
822
898
  for (const id of assigned) {
823
- if (this.bindingIsF64(id))
899
+ if (this.bindingCarriesNumber(id))
824
900
  out.set(id, { ...TOP });
825
901
  }
826
902
  // Globals: any callee may have written before the throw.
@@ -828,10 +904,14 @@ class FnAnalyzer {
828
904
  if (isGlobalId(k))
829
905
  out.delete(k); // absent global = TOP
830
906
  }
907
+ clearPathFacts(out);
831
908
  return out;
832
909
  }
833
910
  /* ── loops: header widening + body-edge refinement ──────────────────── */
834
911
  execLoop(env, opts) {
912
+ // Loop headers/backedges are outside the deliberately straight-line
913
+ // access-path scope. Guards inside the body can establish fresh facts.
914
+ clearPathFacts(env);
835
915
  const savedCollect = this.collect;
836
916
  this.collect = false;
837
917
  let head = cloneEnv(env);
@@ -870,10 +950,10 @@ class FnAnalyzer {
870
950
  // (`for (;;)`) exits only through breaks.
871
951
  let exit = null;
872
952
  if (opts.doWhile ?? false) {
873
- exit = opts.cond !== undefined && final.postBody !== null ? this.refine(final.postBody, opts.cond, false) : null;
953
+ exit = opts.cond !== undefined && final.postBody !== null ? this.refine(final.postBody, opts.cond, false, false) : null;
874
954
  }
875
955
  else if (opts.cond !== undefined) {
876
- exit = this.refine(cloneEnv(head), opts.cond, false);
956
+ exit = this.refine(cloneEnv(head), opts.cond, false, false);
877
957
  }
878
958
  else if (opts.alwaysExits ?? false) {
879
959
  exit = cloneEnv(head); // for-of ends when the array runs out
@@ -894,7 +974,7 @@ class FnAnalyzer {
894
974
  let bodyIn = cloneEnv(head);
895
975
  if (opts.cond !== undefined && !(opts.doWhile ?? false)) {
896
976
  this.evalExpr(opts.cond, bodyIn);
897
- bodyIn = this.refine(bodyIn, opts.cond, true);
977
+ bodyIn = this.refine(bodyIn, opts.cond, true, false);
898
978
  }
899
979
  if (bodyIn !== null && opts.seedEachIteration !== undefined) {
900
980
  bodyIn.set(opts.seedEachIteration, { ...TOP });
@@ -915,7 +995,7 @@ class FnAnalyzer {
915
995
  if ((opts.doWhile ?? false) && opts.cond !== undefined && out !== null) {
916
996
  this.evalExpr(opts.cond, out);
917
997
  postBody = cloneEnv(out);
918
- out = this.refine(out, opts.cond, true);
998
+ out = this.refine(out, opts.cond, true, false);
919
999
  }
920
1000
  return { back: out, postBody };
921
1001
  }
@@ -925,7 +1005,7 @@ class FnAnalyzer {
925
1005
  * false when either side is NaN, so the edge where one HELD proves both
926
1006
  * operands NaN-free; on the failed edge NaN survives while the negated
927
1007
  * comparison still refines the numeric members. */
928
- refine(env, cond, branch) {
1008
+ refine(env, cond, branch, allowPaths = false) {
929
1009
  if (env === null)
930
1010
  return null;
931
1011
  switch (cond.kind) {
@@ -933,19 +1013,39 @@ class FnAnalyzer {
933
1013
  return cond.value === branch ? env : null;
934
1014
  case "unary":
935
1015
  if (cond.op === "!")
936
- return this.refine(env, cond.operand, !branch);
1016
+ return this.refine(env, cond.operand, !branch, allowPaths);
937
1017
  return env;
938
1018
  case "logical": {
939
1019
  const isAnd = cond.op === "&&";
940
1020
  if (isAnd === branch) {
941
1021
  // (a && b) true — both held; (a || b) false — both failed.
942
- return this.refine(this.refine(env, cond.left, branch), cond.right, branch);
1022
+ return this.refine(this.refine(env, cond.left, branch, allowPaths), cond.right, branch, allowPaths);
943
1023
  }
944
1024
  // (a && b) false — a failed, or a held and b failed; dually for ||.
945
- const viaLeft = this.refine(cloneEnv(env), cond.left, branch);
946
- const viaRight = this.refine(this.refine(cloneEnv(env), cond.left, !branch), cond.right, branch);
1025
+ const viaLeft = this.refine(cloneEnv(env), cond.left, branch, allowPaths);
1026
+ const viaRight = this.refine(this.refine(cloneEnv(env), cond.left, !branch, allowPaths), cond.right, branch, allowPaths);
947
1027
  return joinEnv(viaLeft, viaRight);
948
1028
  }
1029
+ case "unionIsTag": {
1030
+ const unionType = { kind: "union", unionId: cond.unionId };
1031
+ if (numberCarrierKind(unionType, this.mod) !== "optional")
1032
+ return env;
1033
+ const def = this.mod.unions?.find((u) => u.id === cond.unionId);
1034
+ const arm = def?.arms[cond.tag];
1035
+ const key = this.refinementKey(cond.value, allowPaths);
1036
+ if (arm === undefined || key === null)
1037
+ return env;
1038
+ const tagMatches = cond.negated ? !branch : branch;
1039
+ // The abstract value describes only PRESENT f64 inhabitants. A
1040
+ // branch selecting a unit arm has no numeric inhabitants; likewise
1041
+ // a branch excluding the optional carrier's sole f64 arm.
1042
+ if ((tagMatches && arm.kind !== "f64") || (!tagMatches && arm.kind === "f64")) {
1043
+ const out = cloneEnv(env);
1044
+ out.set(key, { ...BOTTOM });
1045
+ return out;
1046
+ }
1047
+ return env;
1048
+ }
949
1049
  case "bin": {
950
1050
  if (!CMP_OPS.has(cond.op))
951
1051
  return env;
@@ -961,17 +1061,22 @@ class FnAnalyzer {
961
1061
  const a = this.evalPure(cond.left, env);
962
1062
  const b = this.evalPure(cond.right, env);
963
1063
  const out = cloneEnv(env);
964
- if (cond.left.kind === "varRef")
965
- out.set(cond.left.localId, refineLhs(op, a, b, clearNaN));
966
- if (cond.right.kind === "varRef")
967
- out.set(cond.right.localId, refineLhs(FLIP[op], b, a, clearNaN));
1064
+ const leftKey = this.refinementKey(cond.left, allowPaths);
1065
+ const rightKey = this.refinementKey(cond.right, allowPaths);
1066
+ if (leftKey !== null)
1067
+ out.set(leftKey, refineLhs(op, a, b, clearNaN));
1068
+ if (rightKey !== null)
1069
+ out.set(rightKey, refineLhs(FLIP[op], b, a, clearNaN));
968
1070
  return out;
969
1071
  }
970
1072
  case "toBool": {
971
1073
  const inner = cond.operand;
972
- if (inner.type.kind !== "f64" || inner.kind !== "varRef" || !this.isPure(inner))
1074
+ if (inner.type.kind !== "f64" || !this.isPure(inner))
1075
+ return env;
1076
+ const key = this.refinementKey(inner, allowPaths);
1077
+ if (key === null)
973
1078
  return env;
974
- const v = envGet(env, inner.localId);
1079
+ const v = this.evalPure(inner, env);
975
1080
  const out = cloneEnv(env);
976
1081
  if (branch) {
977
1082
  // Truthy: not NaN, not zero — endpoint exclusion when whole.
@@ -980,11 +1085,11 @@ class FnAnalyzer {
980
1085
  r = absVal(1, r.hi, r.whole, false, r.spelling);
981
1086
  else if (r.whole && r.hi === 0 && r.lo <= -1)
982
1087
  r = absVal(r.lo, -1, r.whole, false, r.spelling);
983
- out.set(inner.localId, r);
1088
+ out.set(key, r);
984
1089
  }
985
1090
  else {
986
1091
  // Falsy: 0, -0, or NaN.
987
- out.set(inner.localId, meetInterval(v, 0, 0, false));
1092
+ out.set(key, meetInterval(v, 0, 0, false));
988
1093
  }
989
1094
  return out;
990
1095
  }
@@ -1019,11 +1124,21 @@ class FnAnalyzer {
1019
1124
  case "boolLit":
1020
1125
  case "varRef":
1021
1126
  case "unitLit":
1127
+ case "selfRef":
1022
1128
  return true;
1129
+ case "recordGet":
1130
+ case "fieldGet":
1131
+ return this.staticAccessPath(e) !== null;
1132
+ case "unionNarrow":
1133
+ return this.isPure(e.value);
1023
1134
  case "bin":
1024
1135
  return this.isPure(e.left) && this.isPure(e.right);
1025
1136
  case "unary":
1026
1137
  return this.isPure(e.operand);
1138
+ case "logical":
1139
+ return this.isPure(e.left) && this.isPure(e.right);
1140
+ case "toBool":
1141
+ return this.isPure(e.operand);
1027
1142
  default:
1028
1143
  return false;
1029
1144
  }
@@ -1034,7 +1149,18 @@ class FnAnalyzer {
1034
1149
  case "numLit":
1035
1150
  return constVal(e.value, e.spelling);
1036
1151
  case "varRef":
1037
- return e.type.kind === "f64" ? envGet(env, e.localId) : { ...TOP };
1152
+ return this.bindingCarriesNumber(e.localId) ? envGet(env, e.localId) : { ...TOP };
1153
+ case "recordGet": {
1154
+ const slot = this.cfg.records.get(e.shapeId)?.get(e.field);
1155
+ if (slot === undefined || numberCarrierKind(e.type, this.mod) === null)
1156
+ return { ...TOP };
1157
+ const key = this.pathKey(e, slot.cls);
1158
+ return key === null ? classSeed(slot.cls) : envGet(env, key);
1159
+ }
1160
+ case "unionNarrow":
1161
+ return e.type.kind === "f64" && numberCarrierKind(e.value.type, this.mod) === "optional"
1162
+ ? this.evalPure(e.value, env)
1163
+ : { ...TOP };
1038
1164
  case "unary":
1039
1165
  if (e.op === "-")
1040
1166
  return transferNeg(this.evalPure(e.operand, env));
@@ -1049,6 +1175,91 @@ class FnAnalyzer {
1049
1175
  return { ...TOP };
1050
1176
  }
1051
1177
  }
1178
+ /** A canonical static data path. Source spellings that lower to the same
1179
+ * IR access (`m.total`, `m["total"]`) intentionally share a key; distinct
1180
+ * receiver bindings never do. Accessors/dynamic keys/computed receivers
1181
+ * have different IR nodes and are excluded. */
1182
+ staticAccessPath(e) {
1183
+ switch (e.kind) {
1184
+ case "varRef":
1185
+ return { rootId: e.localId, steps: [["var", e.localId]] };
1186
+ case "selfRef":
1187
+ return { rootId: null, steps: [["self"]] };
1188
+ case "recordGet": {
1189
+ const base = this.staticAccessPath(e.obj);
1190
+ if (base === null)
1191
+ return null;
1192
+ return { rootId: base.rootId, steps: [...base.steps, ["record", e.shapeId, e.field]] };
1193
+ }
1194
+ case "unionNarrow":
1195
+ return this.staticAccessPath(e.value);
1196
+ case "fieldGet": {
1197
+ const base = this.staticAccessPath(e.obj);
1198
+ if (base === null)
1199
+ return null;
1200
+ return { rootId: base.rootId, steps: [...base.steps, ["field", e.className, e.field]] };
1201
+ }
1202
+ default:
1203
+ return null;
1204
+ }
1205
+ }
1206
+ pathRoots = new Map();
1207
+ pathKey(e, cls) {
1208
+ const path = this.staticAccessPath(e);
1209
+ if (path === null)
1210
+ return null;
1211
+ const prefix = cls === "i64" ? PATH_I64_PREFIX : PATH_U64_PREFIX;
1212
+ const key = `${prefix}${JSON.stringify(path.steps)}`;
1213
+ this.pathRoots.set(key, path.rootId);
1214
+ return key;
1215
+ }
1216
+ refinementKey(e, allowPaths) {
1217
+ if (e.kind === "varRef")
1218
+ return e.localId;
1219
+ if (e.kind === "unionNarrow" && e.type.kind === "f64") {
1220
+ return this.refinementKey(e.value, allowPaths);
1221
+ }
1222
+ if (!allowPaths || e.kind !== "recordGet" || numberCarrierKind(e.type, this.mod) === null)
1223
+ return null;
1224
+ const slot = this.cfg.records.get(e.shapeId)?.get(e.field);
1225
+ return slot === undefined ? null : this.pathKey(e, slot.cls);
1226
+ }
1227
+ /** Path facts are admitted only when the entire guard is synchronous,
1228
+ * call-free, assignment-free static data access. This prevents a later
1229
+ * subexpression from mutating a field and `refine` subsequently
1230
+ * reconstructing a stale fact from the guard syntax. */
1231
+ stablePathGuard(e) {
1232
+ switch (e.kind) {
1233
+ case "numLit":
1234
+ case "strLit":
1235
+ case "boolLit":
1236
+ case "unitLit":
1237
+ case "varRef":
1238
+ case "selfRef":
1239
+ return true;
1240
+ case "recordGet":
1241
+ case "fieldGet":
1242
+ return this.staticAccessPath(e) !== null;
1243
+ case "unionIsTag":
1244
+ return this.stablePathGuard(e.value);
1245
+ case "unionNarrow":
1246
+ return this.stablePathGuard(e.value);
1247
+ case "bin":
1248
+ case "logical":
1249
+ return this.stablePathGuard(e.left) && this.stablePathGuard(e.right);
1250
+ case "unary":
1251
+ case "toBool":
1252
+ return this.stablePathGuard(e.operand);
1253
+ default:
1254
+ return false;
1255
+ }
1256
+ }
1257
+ clearPathsRootedAt(env, localId) {
1258
+ for (const k of [...env.keys()]) {
1259
+ if (pathClassOfKey(k) !== null && this.pathRoots.get(k) === localId)
1260
+ env.delete(k);
1261
+ }
1262
+ }
1052
1263
  /* ── expression evaluation (side effects applied to env) ────────────── */
1053
1264
  evalMath(e, env, mutate) {
1054
1265
  const arg = (i) => (mutate ? this.evalExpr(e.args[i], env) : this.evalPure(e.args[i], env));
@@ -1064,18 +1275,20 @@ class FnAnalyzer {
1064
1275
  default: return null;
1065
1276
  }
1066
1277
  }
1067
- bindingIsF64(id) {
1068
- return this.f64Bindings.has(id);
1278
+ bindingCarriesNumber(id) {
1279
+ return this.numberBindings.has(id);
1069
1280
  }
1070
- f64Bindings = new Set();
1281
+ numberBindings = new Set();
1071
1282
  seedBindings(fn, mod) {
1072
- this.f64Bindings = new Set();
1073
- for (const l of fn.locals)
1074
- if (l.type.kind === "f64" && l.boxed !== true)
1075
- this.f64Bindings.add(l.id);
1076
- for (const g of mod.globals ?? [])
1077
- if (g.type.kind === "f64")
1078
- this.f64Bindings.add(g.id);
1283
+ this.numberBindings = new Set();
1284
+ for (const l of fn.locals) {
1285
+ if (numberCarrierKind(l.type, mod) !== null && l.boxed !== true)
1286
+ this.numberBindings.add(l.id);
1287
+ }
1288
+ for (const g of mod.globals ?? []) {
1289
+ if (numberCarrierKind(g.type, mod) !== null)
1290
+ this.numberBindings.add(g.id);
1291
+ }
1079
1292
  }
1080
1293
  /** Evaluate an expression over the environment, applying the side
1081
1294
  * effects of nested writes and calls (a call havocs the globals its
@@ -1096,7 +1309,7 @@ class FnAnalyzer {
1096
1309
  case "chainRecv":
1097
1310
  return { ...TOP };
1098
1311
  case "varRef":
1099
- return e.type.kind === "f64" && this.bindingIsF64(e.localId) ? envGet(env, e.localId) : { ...TOP };
1312
+ return this.bindingCarriesNumber(e.localId) ? envGet(env, e.localId) : { ...TOP };
1100
1313
  case "bin": {
1101
1314
  const a = this.evalExpr(e.left, env);
1102
1315
  const b = this.evalExpr(e.right, env);
@@ -1115,22 +1328,24 @@ class FnAnalyzer {
1115
1328
  return { ...TOP };
1116
1329
  }
1117
1330
  case "incDec": {
1118
- const old = this.bindingIsF64(e.localId) ? envGet(env, e.localId) : { ...TOP };
1331
+ const old = this.bindingCarriesNumber(e.localId) ? envGet(env, e.localId) : { ...TOP };
1119
1332
  const next = transferAdd(old, constVal(e.op === "+" ? 1 : -1));
1120
- if (this.bindingIsF64(e.localId))
1333
+ if (this.bindingCarriesNumber(e.localId))
1121
1334
  env.set(e.localId, next);
1122
1335
  return e.prefix ? next : old;
1123
1336
  }
1124
1337
  case "assignExpr": {
1125
1338
  const v = this.evalExpr(e.value, env);
1126
- if (this.bindingIsF64(e.localId))
1339
+ this.clearPathsRootedAt(env, e.localId);
1340
+ if (this.bindingCarriesNumber(e.localId))
1127
1341
  env.set(e.localId, v);
1128
1342
  return v;
1129
1343
  }
1130
1344
  case "ternary": {
1131
1345
  this.evalExpr(e.cond, env);
1132
- const thenEnv = this.refine(cloneEnv(env), e.cond, true);
1133
- const elseEnv = this.refine(cloneEnv(env), e.cond, false);
1346
+ const allowPaths = this.stablePathGuard(e.cond);
1347
+ const thenEnv = this.refine(cloneEnv(env), e.cond, true, allowPaths);
1348
+ const elseEnv = this.refine(cloneEnv(env), e.cond, false, allowPaths);
1134
1349
  const a = thenEnv === null ? BOTTOM : this.evalExpr(e.then, thenEnv);
1135
1350
  const b = elseEnv === null ? BOTTOM : this.evalExpr(e.else_, elseEnv);
1136
1351
  mergeInto(env, joinEnv(thenEnv, elseEnv));
@@ -1139,18 +1354,18 @@ class FnAnalyzer {
1139
1354
  case "logical":
1140
1355
  case "nullish":
1141
1356
  case "orDefault": {
1142
- this.evalExpr(e.left, env);
1357
+ const left = this.evalExpr(e.left, env);
1143
1358
  const rightEnv = cloneEnv(env);
1144
- this.evalExpr(e.right, rightEnv);
1359
+ const right = this.evalExpr(e.right, rightEnv);
1145
1360
  mergeInto(env, joinEnv(env, rightEnv));
1146
- return { ...TOP };
1361
+ return numberCarrierKind(e.type, this.mod) !== null ? join(left, right) : { ...TOP };
1147
1362
  }
1148
1363
  case "optChain": {
1149
1364
  this.evalExpr(e.receiver, env);
1150
1365
  const bodyEnv = cloneEnv(env);
1151
- this.evalExpr(e.body, bodyEnv);
1366
+ const body = this.evalExpr(e.body, bodyEnv);
1152
1367
  mergeInto(env, joinEnv(env, bodyEnv));
1153
- return { ...TOP };
1368
+ return numberCarrierKind(e.type, this.mod) !== null ? body : { ...TOP };
1154
1369
  }
1155
1370
  case "seqExpr": {
1156
1371
  // Expression-position statements: no jumps can escape them.
@@ -1185,14 +1400,31 @@ class FnAnalyzer {
1185
1400
  return classSeed(slots.ret);
1186
1401
  return { ...TOP };
1187
1402
  }
1403
+ case "unionWrap": {
1404
+ const value = this.evalExpr(e.value, env);
1405
+ if (numberCarrierKind(e.type, this.mod) !== "optional")
1406
+ return { ...TOP };
1407
+ // Unit arms are absence, not an integer crossing. The one f64 arm
1408
+ // contributes its abstract value unchanged.
1409
+ return e.value.type.kind === "f64" ? value : BOTTOM;
1410
+ }
1411
+ case "unionNarrow": {
1412
+ const value = this.evalExpr(e.value, env);
1413
+ return e.type.kind === "f64" && numberCarrierKind(e.value.type, this.mod) === "optional"
1414
+ ? value
1415
+ : { ...TOP };
1416
+ }
1188
1417
  case "libCall": {
1189
1418
  const math = this.evalMath(e, env, true);
1190
- if (math !== null)
1419
+ if (math !== null) {
1420
+ clearPathFacts(env);
1191
1421
  return math;
1422
+ }
1192
1423
  for (const a of e.args)
1193
1424
  this.evalExpr(a, env);
1194
1425
  if (e.args.some((a) => typeContainsFunc(a.type)))
1195
1426
  this.havocAllGlobals(env);
1427
+ clearPathFacts(env);
1196
1428
  return { ...TOP };
1197
1429
  }
1198
1430
  case "callValue":
@@ -1207,13 +1439,23 @@ class FnAnalyzer {
1207
1439
  this.havocAllGlobals(env);
1208
1440
  return { ...TOP };
1209
1441
  }
1442
+ case "ffiCall":
1443
+ case "yieldExpr":
1444
+ case "awaitExpr":
1445
+ case "awaitUnionExpr": {
1446
+ for (const v of childExprs(e))
1447
+ this.evalExpr(v, env);
1448
+ clearPathFacts(env);
1449
+ return { ...TOP };
1450
+ }
1210
1451
  case "recordLit": {
1211
1452
  const slotMap = this.cfg.records.get(e.type.shapeId);
1212
1453
  for (const f of e.fields) {
1213
1454
  const v = this.evalExpr(f.value, env);
1214
1455
  const slot = slotMap?.get(f.name);
1215
- if (slot !== undefined && f.value.type.kind === "f64")
1456
+ if (slot !== undefined && numberCarrierKind(f.value.type, this.mod) !== null) {
1216
1457
  this.emit(v, slot.path, slot.cls, f.value.loc);
1458
+ }
1217
1459
  }
1218
1460
  return { ...TOP };
1219
1461
  }
@@ -1226,18 +1468,28 @@ class FnAnalyzer {
1226
1468
  // — instead of a spurious may-be-NaN wholeness refusal).
1227
1469
  this.evalExpr(e.obj, env);
1228
1470
  const slot = this.cfg.records.get(e.shapeId)?.get(e.field);
1229
- if (slot !== undefined && e.type.kind === "f64")
1230
- return classSeed(slot.cls);
1471
+ if (slot !== undefined && numberCarrierKind(e.type, this.mod) !== null) {
1472
+ const key = this.pathKey(e, slot.cls);
1473
+ return key === null ? classSeed(slot.cls) : envGet(env, key);
1474
+ }
1231
1475
  return { ...TOP };
1232
1476
  }
1477
+ case "fieldGet":
1478
+ this.evalExpr(e.obj, env);
1479
+ return { ...TOP };
1233
1480
  default: {
1234
1481
  for (const v of childExprs(e))
1235
1482
  this.evalExpr(v, env);
1483
+ // Unmodeled expressions do not participate in the cheap
1484
+ // straight-line proof. Some can invoke runtime/user machinery;
1485
+ // refusing to carry a field fact through them is the safe verdict.
1486
+ clearPathFacts(env);
1236
1487
  return { ...TOP };
1237
1488
  }
1238
1489
  }
1239
1490
  }
1240
1491
  havocCall(callee, env) {
1492
+ clearPathFacts(env);
1241
1493
  if (this.effects.havocAll.has(callee) || !this.effects.perFn.has(callee)) {
1242
1494
  this.havocAllGlobals(env);
1243
1495
  return;
@@ -1246,6 +1498,7 @@ class FnAnalyzer {
1246
1498
  env.delete(g); // absent global = TOP
1247
1499
  }
1248
1500
  havocAllGlobals(env) {
1501
+ clearPathFacts(env);
1249
1502
  for (const k of [...env.keys()]) {
1250
1503
  if (isGlobalId(k))
1251
1504
  env.delete(k);