Skip to content

Commit 2dd7330

Browse files
committed
reanalyze: make optional args analysis liveness-aware
Optional argument analysis now only considers calls from live code when determining which optional arguments are unused. This prevents false positives when a function is only called from dead code. Changes: - Add pos_from to optional_arg_call to track caller binding position - Add is_live predicate to compute_optional_args_state - Two-pass solve: first compute deadness, then optional args with liveness - Add Decl.isLive helper to simplify repeated liveness checks - Update ARCHITECTURE.md to document the two-pass approach Signed-Off-By: Cristiano Calcagno <cristiano.calcagno@gmail.com>
1 parent 79ff413 commit 2dd7330

File tree

9 files changed

+200
-110
lines changed

9 files changed

+200
-110
lines changed

analysis/reanalyze/ARCHITECTURE.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,17 @@ This design enables:
7070
╠══════════════════════════════════════════════════╪══════════════════════╣
7171
║ ▼ ║
7272
║ ┌─────────────────────────────────────────────────────────────────┐ ║
73-
║ │ DeadCommon.solveDead │ ║
74-
║ │ ~annotations ~decls ~refs ~file_deps │ ║
75-
║ │ ~optional_args_state ~config ~checkOptionalArg │ ║
73+
║ │ Pass 1: DeadCommon.solveDead (core deadness) │ ║
74+
║ │ ~annotations ~decls ~refs ~file_deps ~config │ ║
75+
║ │ → AnalysisResult.t (dead/live status resolved) │ ║
7676
║ │ │ ║
77+
║ │ Pass 2: Optional args analysis (liveness-aware) │ ║
78+
║ │ CrossFileItems.compute_optional_args_state ~is_live │ ║
79+
║ │ DeadOptionalArgs.check (only for live decls) │ ║
7780
║ │ → AnalysisResult.t { issues: Issue.t list } │ ║
7881
║ └─────────────────────────────────────────────────────────────────┘ ║
7982
║ │ ║
80-
║ Pure function: immutable in → immutable out │ issues ║
83+
║ Pure functions: immutable in → immutable out │ issues ║
8184
╚══════════════════════════════════════════════════╪══════════════════════╝
8285
8386
╔══════════════════════════════════════════════════╪══════════════════════╗
@@ -149,20 +152,30 @@ let file_deps = FileDeps.merge_all (file_data_list |> List.map (fun fd -> fd.fil
149152

150153
### Phase 3: SOLVE (Deadness Computation)
151154

152-
**Entry point**: `DeadCommon.solveDead`
155+
**Entry point**: `DeadCommon.solveDead` + optional args second pass in `Reanalyze.runAnalysis`
153156

154157
**Input**: All merged data + config
155158

156159
**Output**: `AnalysisResult.t` containing `Issue.t list`
157160

158-
**Algorithm**:
161+
**Algorithm** (two-pass for liveness-aware optional args):
162+
163+
**Pass 1: Core deadness resolution**
159164
1. Build file dependency order (roots to leaves)
160165
2. Sort declarations by dependency order
161166
3. For each declaration, resolve references recursively
162167
4. Determine dead/live status based on reference count
163168
5. Collect issues for dead declarations
164169

165-
**Key property**: Pure function - immutable in, immutable out. No side effects.
170+
**Pass 2: Liveness-aware optional args analysis**
171+
1. Use `Decl.isLive` to build an `is_live` predicate from Pass 1 results
172+
2. Compute optional args state via `CrossFileItems.compute_optional_args_state`, filtering out calls from dead code
173+
3. Collect optional args issues only for live declarations
174+
4. Merge optional args issues into the final result
175+
176+
This two-pass approach ensures that optional argument warnings (e.g., "argument X is never used") only consider calls from live code, preventing false positives when a function is only called from dead code.
177+
178+
**Key property**: Pure functions - immutable in, immutable out. No side effects.
166179

167180
### Phase 4: REPORT (Output)
168181

analysis/reanalyze/src/CrossFileItems.ml

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
type exception_ref = {exception_path: DcePath.t; loc_from: Location.t}
99

1010
type optional_arg_call = {
11+
pos_from: Lexing.position;
1112
pos_to: Lexing.position;
1213
arg_names: string list;
1314
arg_names_maybe: string list;
@@ -37,9 +38,10 @@ let create_builder () : builder =
3738
let add_exception_ref (b : builder) ~exception_path ~loc_from =
3839
b.exception_refs <- {exception_path; loc_from} :: b.exception_refs
3940

40-
let add_optional_arg_call (b : builder) ~pos_to ~arg_names ~arg_names_maybe =
41+
let add_optional_arg_call (b : builder) ~pos_from ~pos_to ~arg_names
42+
~arg_names_maybe =
4143
b.optional_arg_calls <-
42-
{pos_to; arg_names; arg_names_maybe} :: b.optional_arg_calls
44+
{pos_from; pos_to; arg_names; arg_names_maybe} :: b.optional_arg_calls
4345

4446
let add_function_reference (b : builder) ~pos_from ~pos_to =
4547
b.function_refs <- {pos_from; pos_to} :: b.function_refs
@@ -71,7 +73,7 @@ let process_exception_refs (t : t) ~refs ~file_deps ~find_exception ~config =
7173
(** Compute optional args state from calls and function references.
7274
Returns a map from position to final OptionalArgs.t state.
7375
Pure function - does not mutate declarations. *)
74-
let compute_optional_args_state (t : t) ~decls : OptionalArgsState.t =
76+
let compute_optional_args_state (t : t) ~decls ~is_live : OptionalArgsState.t =
7577
let state = OptionalArgsState.create () in
7678
(* Initialize state from declarations *)
7779
let get_state pos =
@@ -85,22 +87,24 @@ let compute_optional_args_state (t : t) ~decls : OptionalArgsState.t =
8587
let set_state pos s = OptionalArgsState.set state pos s in
8688
(* Process optional arg calls *)
8789
t.optional_arg_calls
88-
|> List.iter (fun {pos_to; arg_names; arg_names_maybe} ->
89-
let current = get_state pos_to in
90-
let updated =
91-
OptionalArgs.apply_call ~argNames:arg_names
92-
~argNamesMaybe:arg_names_maybe current
93-
in
94-
set_state pos_to updated);
90+
|> List.iter (fun {pos_from; pos_to; arg_names; arg_names_maybe} ->
91+
if is_live pos_from then
92+
let current = get_state pos_to in
93+
let updated =
94+
OptionalArgs.apply_call ~argNames:arg_names
95+
~argNamesMaybe:arg_names_maybe current
96+
in
97+
set_state pos_to updated);
9598
(* Process function references *)
9699
t.function_refs
97100
|> List.iter (fun {pos_from; pos_to} ->
98-
let state_from = get_state pos_from in
99-
let state_to = get_state pos_to in
100-
if not (OptionalArgs.isEmpty state_to) then (
101-
let updated_from, updated_to =
102-
OptionalArgs.combine_pair state_from state_to
103-
in
104-
set_state pos_from updated_from;
105-
set_state pos_to updated_to));
101+
if is_live pos_from then
102+
let state_from = get_state pos_from in
103+
let state_to = get_state pos_to in
104+
if not (OptionalArgs.isEmpty state_to) then (
105+
let updated_from, updated_to =
106+
OptionalArgs.combine_pair state_from state_to
107+
in
108+
set_state pos_from updated_from;
109+
set_state pos_to updated_to));
106110
state

analysis/reanalyze/src/CrossFileItems.mli

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ val add_exception_ref :
2323

2424
val add_optional_arg_call :
2525
builder ->
26+
pos_from:Lexing.position ->
2627
pos_to:Lexing.position ->
2728
arg_names:string list ->
2829
arg_names_maybe:string list ->
2930
unit
30-
(** Add a cross-file optional argument call. *)
31+
(** Add a cross-file optional argument call, recording caller and callee. *)
3132

3233
val add_function_reference :
3334
builder -> pos_from:Lexing.position -> pos_to:Lexing.position -> unit
@@ -52,6 +53,10 @@ val process_exception_refs :
5253
(** {2 Optional Args State} *)
5354

5455
val compute_optional_args_state :
55-
t -> decls:Declarations.t -> OptionalArgsState.t
56-
(** Compute final optional args state from calls and function references.
56+
t ->
57+
decls:Declarations.t ->
58+
is_live:(Lexing.position -> bool) ->
59+
OptionalArgsState.t
60+
(** Compute final optional args state from calls and function references,
61+
taking into account caller liveness via the [is_live] predicate.
5762
Pure function - does not mutate declarations. *)

analysis/reanalyze/src/DeadOptionalArgs.ml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,22 @@ let rec fromTypeExpr (texpr : Types.type_expr) =
4040
| _ -> []
4141

4242
let addReferences ~config ~cross_file ~(locFrom : Location.t)
43-
~(locTo : Location.t) ~path (argNames, argNamesMaybe) =
43+
~(locTo : Location.t) ~(binding : Location.t) ~path (argNames, argNamesMaybe)
44+
=
4445
if active () then (
4546
let posTo = locTo.loc_start in
46-
let posFrom = locFrom.loc_start in
47-
CrossFileItems.add_optional_arg_call cross_file ~pos_to:posTo
48-
~arg_names:argNames ~arg_names_maybe:argNamesMaybe;
47+
let posFrom = binding.loc_start in
48+
CrossFileItems.add_optional_arg_call cross_file ~pos_from:posFrom
49+
~pos_to:posTo ~arg_names:argNames ~arg_names_maybe:argNamesMaybe;
4950
if config.DceConfig.cli.debug then
51+
let callPos = locFrom.loc_start in
5052
Log_.item
5153
"DeadOptionalArgs.addReferences %s called with optional argNames:%s \
5254
argNamesMaybe:%s %s@."
5355
(path |> DcePath.fromPathT |> DcePath.toString)
5456
(argNames |> String.concat ", ")
5557
(argNamesMaybe |> String.concat ", ")
56-
(posFrom |> Pos.toString))
58+
(callPos |> Pos.toString))
5759

5860
(** Check for optional args issues. Returns issues instead of logging.
5961
Uses optional_args_state map for final computed state. *)

analysis/reanalyze/src/DeadValue.ml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ let collectValueBinding ~config ~decls ~file ~(current_binding : Location.t)
7878
loc
7979

8080
let processOptionalArgs ~config ~cross_file ~expType ~(locFrom : Location.t)
81-
~locTo ~path args =
81+
~(binding : Location.t) ~locTo ~path args =
8282
if expType |> DeadOptionalArgs.hasOptionalArgs then (
8383
let supplied = ref [] in
8484
let suppliedMaybe = ref [] in
@@ -107,7 +107,8 @@ let processOptionalArgs ~config ~cross_file ~expType ~(locFrom : Location.t)
107107
if argIsSupplied = None then suppliedMaybe := s :: !suppliedMaybe
108108
| _ -> ());
109109
(!supplied, !suppliedMaybe)
110-
|> DeadOptionalArgs.addReferences ~config ~cross_file ~locFrom ~locTo ~path)
110+
|> DeadOptionalArgs.addReferences ~config ~cross_file ~locFrom ~locTo
111+
~binding ~path)
111112

112113
let rec collectExpr ~config ~refs ~file_deps ~cross_file
113114
~(last_binding : Location.t) super self (e : Typedtree.expression) =
@@ -142,7 +143,7 @@ let rec collectExpr ~config ~refs ~file_deps ~cross_file
142143
args
143144
|> processOptionalArgs ~config ~cross_file ~expType:exp_type
144145
~locFrom:(locFrom : Location.t)
145-
~locTo ~path
146+
~binding:last_binding ~locTo ~path
146147
| Texp_let
147148
( (* generated for functions with optional args *)
148149
Nonrecursive,
@@ -183,7 +184,7 @@ let rec collectExpr ~config ~refs ~file_deps ~cross_file
183184
args
184185
|> processOptionalArgs ~config ~cross_file ~expType:exp_type
185186
~locFrom:(locFrom : Location.t)
186-
~locTo ~path
187+
~binding:last_binding ~locTo ~path
187188
| Texp_field
188189
(_, _, {lbl_loc = {Location.loc_start = posTo; loc_ghost = false}; _}) ->
189190
if !Config.analyzeTypes then

analysis/reanalyze/src/Decl.ml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ let isValue decl =
4343
| Value _ (* | Exception *) -> true
4444
| _ -> false
4545

46+
(** Check if a declaration is live (or unknown). Returns false only if resolved as dead. *)
47+
let isLive decl =
48+
match decl.resolvedDead with
49+
| Some true -> false
50+
| Some false | None -> true
51+
4652
let compareUsingDependencies ~orderedFiles
4753
{
4854
declKind = kind1;

analysis/reanalyze/src/Reanalyze.ml

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -168,18 +168,43 @@ let runAnalysis ~dce_config ~cmtRoot =
168168
CrossFileItems.process_exception_refs cross_file ~refs:refs_builder
169169
~file_deps:file_deps_builder ~find_exception:DeadException.find_exception
170170
~config:dce_config;
171-
(* Compute optional args state (pure - no mutation) *)
172-
let optional_args_state =
173-
CrossFileItems.compute_optional_args_state cross_file ~decls
174-
in
175171
(* Now freeze refs and file_deps for solver *)
176172
let refs = References.freeze_builder refs_builder in
177173
let file_deps = FileDeps.freeze_builder file_deps_builder in
178-
(* Run the solver - returns immutable AnalysisResult.t *)
179-
let analysis_result =
174+
(* Run the solver - returns immutable AnalysisResult.t.
175+
Optional-args checks are done in a separate pass after liveness is known. *)
176+
let empty_optional_args_state = OptionalArgsState.create () in
177+
let analysis_result_core =
180178
DeadCommon.solveDead ~annotations ~decls ~refs ~file_deps
181-
~optional_args_state ~config:dce_config
182-
~checkOptionalArg:DeadOptionalArgs.check
179+
~optional_args_state:empty_optional_args_state ~config:dce_config
180+
~checkOptionalArg:(fun
181+
~optional_args_state:_ ~annotations:_ ~config:_ _ -> [])
182+
in
183+
(* Compute liveness-aware optional args state *)
184+
let is_live pos =
185+
match Declarations.find_opt decls pos with
186+
| Some decl -> Decl.isLive decl
187+
| None -> true
188+
in
189+
let optional_args_state =
190+
CrossFileItems.compute_optional_args_state cross_file ~decls ~is_live
191+
in
192+
(* Collect optional args issues only for live declarations *)
193+
let optional_args_issues =
194+
Declarations.fold
195+
(fun _pos decl acc ->
196+
if Decl.isLive decl then
197+
let issues =
198+
DeadOptionalArgs.check ~optional_args_state ~annotations
199+
~config:dce_config decl
200+
in
201+
List.rev_append issues acc
202+
else acc)
203+
decls []
204+
|> List.rev
205+
in
206+
let analysis_result =
207+
AnalysisResult.add_issues analysis_result_core optional_args_issues
183208
in
184209
(* Report all issues *)
185210
AnalysisResult.get_issues analysis_result

0 commit comments

Comments
 (0)