Skip to content

Commit 19cdf1c

Browse files
cristianocclaude
andcommitted
Fix soundness and analysis gaps found in review
Four review findings on #8597, all confirmed by probe or inspection: Variance ignored field mutability. The old phantom "x#=" setter member was an arrow whose contravariant occurrence incidentally made settable fields invariant; removing the phantom left compute_variance treating every field payload with the ambient variance, so an explicitly covariant parameter could annotate a settable field and leak write capability through an abstract type. The Tfield arm now sends a Mutable field's payload through Variance.full, like a mutable record label; Immutable fields keep the ambient variance, preserving read-only covariance. Pinned by object_settable_field_covariant_param (Bad_variance). Writes instantiated polymorphic field schemes. Assigning to {@set "id": 'a. 'a => 'a} typed the value at one instance, so a monomorphic function satisfied the field while reads kept instantiating the unchanged scheme. A field's type is a scheme: reading eliminates it, writing must establish it. The write path now uses the checker's scheme-introduction discipline - fixed instantiation, typing at that instance, check_univars - extracted as type_object_field_value next to its record twin type_label_exp, returning the value at an ordinary instance as both siblings do. type_label_exp's PR#4862 retry is a label-specific completeness recovery and is deliberately not replicated; the helper's comment records that. Pinned by object_write_poly_field_less_general (Less_general) and a positive settable-poly case in object_poly_field. Along the way, instance_poly's positional boolean becomes ~fixed with a contract comment in ctype.mli: the flag controls fixed copying of polymorphic-variant rows; scheme introduction is identified by the whole operation, not by this flag. reanalyze missed Texp_object_literal. Side-effect analysis fell through to the permissive default, so a dead binding whose object literal called effectful code was classified as removable; it now checks every field expression (ObjectLiteralSideEffects deadcode case). Termination analysis crashed on the wildcard; it now compiles the literal as an ordered sequence of its fields - ordered, not unordered, because fields evaluate in source order and crediting a later field's progress past a non-returning earlier field would be unsound (the testObjectLiteralRecursionFirst case is now reported as a possible infinite loop while testObjectLiteralProgressFirst passes). Texp_object_get/Texp_object_set traverse their receiver and value instead of asserting (testObjectAccess). Signed-Off-By: Cristiano Calcagno <ccrisccris@gmail.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCtQiaDijUqA2fujQXvKUw
1 parent d2cd022 commit 19cdf1c

18 files changed

Lines changed: 206 additions & 28 deletions

analysis/reanalyze/src/arnold.ml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,12 +1003,16 @@ module Compile = struct
10031003
| Texp_for_await_of (_id, _pat, e1, e2) ->
10041004
let open Command in
10051005
expression ~ctx e1 +++ expression ~ctx e2
1006-
| Texp_object_get _ ->
1007-
not_implemented "Texp_object_get";
1008-
assert false
1009-
| Texp_object_set _ ->
1010-
not_implemented "Texp_object_set";
1011-
assert false
1006+
| Texp_object_literal fields ->
1007+
(* Fields are emitted and evaluated in source order *)
1008+
fields
1009+
|> List.map (fun (_name, e) -> e |> expression ~ctx)
1010+
|> Command.sequence
1011+
| Texp_object_get (e, _) -> e |> expression ~ctx
1012+
| Texp_object_set (e1, _, e2) ->
1013+
(* Receiver first, then the assigned value *)
1014+
let open Command in
1015+
expression ~ctx e1 +++ expression ~ctx e2
10121016
| Texp_letmodule _ ->
10131017
not_implemented "Texp_letmodule";
10141018
assert false

analysis/reanalyze/src/side_effects.ml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ let rec expr_no_side_effects (expr : Typedtree.expression) =
6565
e1 |> expr_no_side_effects && e2 |> expr_no_side_effects
6666
&& e3 |> expr_no_side_effects
6767
| Texp_for_of _ | Texp_for_await_of _ -> false
68+
| Texp_object_literal fields ->
69+
fields |> List.for_all (fun (_name, e) -> e |> expr_no_side_effects)
6870
| Texp_object_get _ -> false
6971
| Texp_object_set _ -> false
7072
| Texp_letexception (_ec, e) -> e |> expr_no_side_effects

compiler/ml/ctype.ml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ let rec copy_sep fixed free bound visited ty =
10141014
| _ -> copy_type_desc copy_rec ty.desc);
10151015
t
10161016

1017-
let instance_poly ?(keep_names = false) fixed univars sch =
1017+
let instance_poly ?(keep_names = false) ~fixed univars sch =
10181018
with_copy_session (fun () ->
10191019
let univars = List.map repr univars in
10201020
let copy_var ty =
@@ -1035,7 +1035,7 @@ let instance_label fixed lbl =
10351035
let ty_res = copy lbl.lbl_res in
10361036
let vars, ty_arg =
10371037
match repr lbl.lbl_arg with
1038-
| {desc = Tpoly (ty, tl)} -> instance_poly fixed tl ty
1038+
| {desc = Tpoly (ty, tl)} -> instance_poly ~fixed tl ty
10391039
| _ -> ([], copy lbl.lbl_arg)
10401040
in
10411041
(vars, ty_arg, ty_res))
@@ -3785,7 +3785,7 @@ let rec subtype_rec env trace t1 t2 cstrs =
37853785
| Tvariant v, _ when !variant_is_subtype env (row_repr v) t2 -> cstrs
37863786
| Tpoly (u1, []), Tpoly (u2, []) -> subtype_rec env trace u1 u2 cstrs
37873787
| Tpoly (u1, tl1), Tpoly (u2, []) ->
3788-
let _, u1' = instance_poly false tl1 u1 in
3788+
let _, u1' = instance_poly ~fixed:false tl1 u1 in
37893789
subtype_rec env trace u1' u2 cstrs
37903790
| Tpoly (u1, tl1), Tpoly (u2, tl2) -> (
37913791
try

compiler/ml/ctype.mli

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,18 @@ val instance_parameterized_type :
166166
val instance_declaration : type_declaration -> type_declaration
167167
val instance_poly :
168168
?keep_names:bool ->
169-
bool ->
169+
fixed:bool ->
170170
type_expr list ->
171171
type_expr ->
172172
type_expr list * type_expr
173+
(* Instantiate a scheme [Tpoly(sch, univars)]: replace the universal
174+
variables with fresh ones and return them with the instance. [~fixed]
175+
controls the copy of polymorphic-variant rows: a fixed copy keeps their
176+
rows closed to further extension. Scheme *use* sites instantiate with
177+
[~fixed:false]; scheme *introduction* sites (checking a value against
178+
the scheme) instantiate with [~fixed:true] and then verify the value
179+
generalizes over the returned variables ([Typecore.check_univars]) -
180+
the introduction discipline is that whole operation, not this flag. *)
173181
(* Take an instance of a type scheme containing free univars *)
174182

175183
val instance_label :

compiler/ml/typecore.ml

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,7 +1280,7 @@ and type_pat_aux ~constrs ~labels ~no_existentials ~mode ~explode ~env sp
12801280
match ty.desc with
12811281
| Tpoly (body, tyl) ->
12821282
begin_def ();
1283-
let _, ty' = instance_poly ~keep_names:true false tyl body in
1283+
let _, ty' = instance_poly ~keep_names:true ~fixed:false tyl body in
12841284
end_def ();
12851285
generalize ty';
12861286
let id = enter_variable lloc name ty' in
@@ -2339,7 +2339,7 @@ type targs = (Asttypes.arg_label * Typedtree.expression option) list
23392339
let object_field_use_type env typ =
23402340
match Ctype.repr typ with
23412341
| {desc = Tpoly (ty, [])} -> instance env ty
2342-
| {desc = Tpoly (ty, tl)} -> snd (instance_poly false tl ty)
2342+
| {desc = Tpoly (ty, tl)} -> snd (instance_poly ~fixed:false tl ty)
23432343
| {desc = Tvar _} as ty ->
23442344
let ty' = newvar () in
23452345
unify env (instance_def ty) (newty (Tpoly (ty', [])));
@@ -3403,8 +3403,7 @@ and type_expect_ ?deprecated_context ~context ?(recarg = Rejected) env sexp
34033403
| Error Owrite_not_mutable ->
34043404
raise (Error (loc, env, Object_field_not_mutable (obj.exp_type, name)))
34053405
| Ok typ ->
3406-
let typ = object_field_use_type env typ in
3407-
let value = type_expect ~context:None env svalue typ in
3406+
let value = type_object_field_value env svalue typ in
34083407
rue
34093408
{
34103409
exp_desc = Texp_object_set (obj, name_loc, value);
@@ -3981,6 +3980,31 @@ and type_label_access env srecord lid =
39813980
in
39823981
(record, label, opath)
39833982
3983+
(* Typing the right-hand side of an object-field assignment: the
3984+
introduction dual of [object_field_use_type]. A field's type is a scheme:
3985+
reading instantiates it, while writing must establish it, so a
3986+
polymorphic field only accepts a value at least as polymorphic — checked
3987+
by typing the value at a fixed instance and verifying it generalizes
3988+
([instance_poly true] + [check_univars]), the same discipline as
3989+
[type_label_exp] for record labels and [type_let] for polymorphic
3990+
annotations. With no quantified variables, establishing and instantiating
3991+
the scheme coincide. [type_label_exp] additionally retries an expansive
3992+
value without type propagation (PR#4862); that is a label-specific
3993+
completeness recovery, not part of the scheme-introduction contract, and
3994+
is deliberately not replicated here. *)
3995+
and type_object_field_value env svalue typ =
3996+
match (Ctype.repr typ).desc with
3997+
| Tpoly (ty, (_ :: _ as tl)) ->
3998+
begin_def ();
3999+
let vars, ty' = instance_poly ~fixed:true tl ty in
4000+
let value = type_expect ~context:None env svalue ty' in
4001+
end_def ();
4002+
check_univars env true "field value" value typ vars;
4003+
{value with exp_type = instance env value.exp_type}
4004+
| _ ->
4005+
let typ = object_field_use_type env typ in
4006+
type_expect ~context:None env svalue typ
4007+
39844008
(* Typing format strings for printing or reading.
39854009
These formats are used by functions in modules Printf, Format, and Scanf.
39864010
(Handling of * modifiers contributed by Thorsten Ohl.) *)
@@ -4675,7 +4699,7 @@ and type_let ~context ?(check = fun s -> Warnings.Unused_var s)
46754699
| Tpoly (ty, tl) ->
46764700
{
46774701
pat with
4678-
pat_type = snd (instance_poly ~keep_names:true false tl ty);
4702+
pat_type = snd (instance_poly ~keep_names:true ~fixed:false tl ty);
46794703
}
46804704
| _ -> pat
46814705
in
@@ -4783,7 +4807,7 @@ and type_let ~context ?(check = fun s -> Warnings.Unused_var s)
47834807
match pat.pat_type.desc with
47844808
| Tpoly (ty, tl) ->
47854809
begin_def ();
4786-
let vars, ty' = instance_poly ~keep_names:true true tl ty in
4810+
let vars, ty' = instance_poly ~keep_names:true ~fixed:true tl ty in
47874811
let exp = type_expression ty' in
47884812
end_def ();
47894813
check_univars env true "definition" exp pat.pat_type vars;

compiler/ml/typedecl.ml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -770,7 +770,7 @@ let rec check_constraints_rec env loc visited ty =
770770
raise (Error (loc, Constraint_failed (ty, ty')));
771771
List.iter (check_constraints_rec env loc visited) args
772772
| Tpoly (ty, tl) ->
773-
let _, ty = Ctype.instance_poly false tl ty in
773+
let _, ty = Ctype.instance_poly ~fixed:false tl ty in
774774
check_constraints_rec env loc visited ty
775775
| _ -> Btype.iter_type_expr (check_constraints_rec env loc visited) ty)
776776

@@ -1003,7 +1003,7 @@ let check_recursion env loc path decl to_check =
10031003
with Not_found -> ());
10041004
List.iter (check_regular cpath args prev_exp) args'
10051005
| Tpoly (ty, tl) ->
1006-
let _, ty = Ctype.instance_poly ~keep_names:true false tl ty in
1006+
let _, ty = Ctype.instance_poly ~keep_names:true ~fixed:false tl ty in
10071007
check_regular cpath args prev_exp ty
10081008
| _ -> Btype.iter_type_expr (check_regular cpath args prev_exp) ty)
10091009
in
@@ -1078,8 +1078,12 @@ let compute_variance env visited vari ty =
10781078
tl decl.type_variance
10791079
with Not_found -> List.iter (compute_variance_rec may_inv) tl)
10801080
| Tobject ty -> compute_same ty
1081-
| Tfield {typ = ty1; rest = ty2} ->
1082-
compute_same ty1;
1081+
| Tfield {mutability; typ = ty1; rest = ty2} ->
1082+
(* A settable field can be both read and written, so its payload is
1083+
an invariant occurrence, like a mutable record label. *)
1084+
(match Btype.mutability_repr mutability with
1085+
| Mutable -> compute_variance_rec Variance.full ty1
1086+
| Immutable -> compute_same ty1);
10831087
compute_same ty2
10841088
| Tsubst ty -> compute_same ty
10851089
| Tvariant row ->

tests/ERROR_VARIANTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ Source: [typecore.ml:27](../compiler/ml/typecore.ml).
223223
| `Abstract_wrong_label` || `abstract_wrong_label.res` | Multi-arg function literal where an inner argument label doesn't match the expected arrow's label (e.g. `let f: (~a, ~b) => int = (~a, ~c) => …`). |
224224
| `Scoping_let_module` || `scoping_let_module.res` | |
225225
| `Not_a_variant_type` || `variant_spread_pattern_not_a_variant.res` | Pattern-level variant spread of a non-variant type. |
226-
| `Less_general` || `less_general_universal.res` | |
226+
| `Less_general` || `less_general_universal.res`, `object_write_poly_field_less_general.res` | The latter pins that assigning to a polymorphic object field checks the value against the field's scheme. |
227227
| `Modules_not_allowed` || `super_errors_multi/Modules_not_allowed_toplevel` | Toplevel `let module(M) = …` pattern with `allow_modules=false`. |
228228
| `Cannot_infer_signature` || `cannot_infer_signature.res` | |
229229
| `Not_a_packed_module` || `not_a_packed_module.res` | |
@@ -278,7 +278,7 @@ Type-declaration errors. Source: [typedecl.ml:27](../compiler/ml/typedecl.ml).
278278
| `Rebind_wrong_type` || `extension_rebind_mismatch.res` | Rebinding constructor into a different extensible type fails while unifying the source constructor result with the extension target. |
279279
| `Rebind_mismatch` | ? || The later declaration-shape check after `Rebind_wrong_type`; no source fixture was confirmed in this pass. |
280280
| `Rebind_private` || `extension_rebind_private.res` | Rebinding a private extension constructor as public. |
281-
| `Bad_variance` || `bad_variance.res`, `bad_variance_contra.res` | |
281+
| `Bad_variance` || `bad_variance.res`, `bad_variance_contra.res`, `object_settable_field_covariant_param.res` | The latter pins that a settable object field is an invariant occurrence, like a mutable record label. |
282282
| `Unavailable_type_constructor` | ☐ (needs build harness) || typedecl.ml:778. Requires a type path findable at parse time but missing during constraint enforcement; only cross-unit scenarios where a `.cmi` was found but later removed. |
283283
| `Bad_fixed_type` || `fixed_type_no_row_variable.res` | Fully-bounded closed private polymorphic variant (`type t = private [< #A | #B > #A #B]`) satisfies `is_fixed_type` but has a static (non-`Tvar`) row. |
284284
| `Unbound_type_var_ext` || `unbound_type_var_extension.res` | |

tests/analysis_tests/tests-reanalyze/deadcode/expected/deadcode.txt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,9 @@
11611161
addValueReference Newton.res:31:13 --> Newton.res:29:4
11621162
addValueReference Newton.res:31:23 --> Newton.res:29:4
11631163
addValueReference Newton.res:31:21 --> Newton.res:25:4
1164+
Scanning ObjectLiteralSideEffects.cmt Source:ObjectLiteralSideEffects.res
1165+
addValueDeclaration +deadWithEffect ObjectLiteralSideEffects.res:3:4 path:+ObjectLiteralSideEffects
1166+
addValueDeclaration +deadNoEffect ObjectLiteralSideEffects.res:4:4 path:+ObjectLiteralSideEffects
11641167
Scanning OcamlWarningSuppressToplevel.cmt Source:OcamlWarningSuppressToplevel.res
11651168
addValueDeclaration +suppressed1 OcamlWarningSuppressToplevel.res:3:4 path:+OcamlWarningSuppressToplevel
11661169
addValueDeclaration +suppressed2 OcamlWarningSuppressToplevel.res:4:4 path:+OcamlWarningSuppressToplevel
@@ -2103,7 +2106,7 @@
21032106

21042107
Forward Liveness Analysis
21052108

2106-
decls: 744
2109+
decls: 746
21072110
roots(external targets): 161
21082111
decl-deps: decls_with_out=451 edges_to_decls=323
21092112

@@ -3635,6 +3638,8 @@ Forward Liveness Analysis
36353638
-> +Newton.+newton
36363639
-> +Newton.+f
36373640
-> +Newton.+fPrimed
3641+
Dead Value +ObjectLiteralSideEffects.+deadWithEffect
3642+
Dead Value +ObjectLiteralSideEffects.+deadNoEffect
36383643
Live (annotated) Value +OcamlWarningSuppressToplevel.+suppressed1
36393644
Live (annotated) Value +OcamlWarningSuppressToplevel.+suppressed2
36403645
Live (annotated) Value +OcamlWarningSuppressToplevel.M.+suppressed3
@@ -5251,6 +5256,18 @@ Forward Liveness Analysis
52515256
Newsyntax.res:12:24-29
52525257
record2.yy is a record label never used to read a value
52535258

5259+
Warning Dead Module
5260+
ObjectLiteralSideEffects.res:0:1
5261+
ObjectLiteralSideEffects is a dead module as all its items are dead.
5262+
5263+
Warning Dead Value With Side Effects
5264+
ObjectLiteralSideEffects.res:3:1-49
5265+
deadWithEffect is never used and could have side effects
5266+
5267+
Warning Dead Value
5268+
ObjectLiteralSideEffects.res:4:1-27
5269+
deadNoEffect is never used
5270+
52545271
Warning Dead Type
52555272
Opaque.res:2:26-41
52565273
opaqueFromRecords.A is a variant case which is never constructed
@@ -5643,4 +5660,4 @@ Forward Liveness Analysis
56435660
OptArg.res:14:1-42
56445661
optional argument b of function twoArgs is never used
56455662

5646-
Analysis reported 327 issues (Incorrect Dead Annotation:1, Warning Dead Exception:2, Warning Dead Module:22, Warning Dead Type:94, Warning Dead Value:178, Warning Dead Value With Side Effects:5, Warning Redundant Optional Argument:7, Warning Unused Argument:18)
5663+
Analysis reported 330 issues (Incorrect Dead Annotation:1, Warning Dead Exception:2, Warning Dead Module:23, Warning Dead Type:94, Warning Dead Value:179, Warning Dead Value With Side Effects:6, Warning Redundant Optional Argument:7, Warning Unused Argument:18)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// An object literal's field expressions determine its side effects: the
2+
// first binding must be classified with side effects, the second without.
3+
let deadWithEffect = {"x": Console.log("effect")}
4+
let deadNoEffect = {"x": 1}

tests/analysis_tests/tests-reanalyze/termination/expected/termination.txt

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,31 @@
150150

151151
Termination Analysis for testTry
152152

153+
Function Table
154+
1 testObjectLiteral: +progress; testObjectLiteral
155+
156+
Termination Analysis for testObjectLiteral
157+
158+
Function Table
159+
1 testObjectLiteralProgressFirst: +progress; testObjectLiteralProgressFirst; _
160+
161+
Termination Analysis for testObjectLiteralProgressFirst
162+
163+
Function Table
164+
1 testObjectLiteralRecursionFirst: testObjectLiteralRecursionFirst; +progress; _
165+
166+
Termination Analysis for testObjectLiteralRecursionFirst
167+
168+
Function Table
169+
1 testObjectAccess: +progress; testObjectAccess
170+
171+
Termination Analysis for testObjectAccess
172+
153173
Termination Analysis Stats
154174
Files:1
155-
Recursive Blocks:21
156-
Functions:49
157-
Infinite Loops:10
175+
Recursive Blocks:25
176+
Functions:53
177+
Infinite Loops:11
158178
Hygiene Errors:2
159179
Cache Hits:7/30
160180

@@ -230,5 +250,11 @@
230250
Possible infinite loop when calling countRendersCompiled
231251
CallStack:
232252
1 countRendersCompiled (TestCyberTruck.res 283)
253+
254+
Error Termination
255+
TestCyberTruck.res:468:20-52
256+
Possible infinite loop when calling testObjectLiteralRecursionFirst
257+
CallStack:
258+
1 testObjectLiteralRecursionFirst (TestCyberTruck.res 467)
233259

234-
Analysis reported 12 issues (Error Hygiene:2, Error Termination:10)
260+
Analysis reported 13 issues (Error Hygiene:2, Error Termination:11)

0 commit comments

Comments
 (0)